Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,30 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
dependencies {
api(libs.koin.core)
implementation(projects.core.logging.api)
implementation(projects.legacy.core)
// Required for MigrationTo107
implementation(projects.mail.common)
implementation(projects.mail.protocols.imap)
implementation(libs.androidx.core.ktx)
implementation(libs.mime4j.core)
implementation(libs.commons.io)
implementation(libs.moshi)
testImplementation(projects.core.logging.testing)
testImplementation(projects.mail.testing)
testImplementation(projects.feature.telemetry.noop)
testImplementation(libs.robolectric)
testImplementation(libs.commons.io)
testImplementation(projects.core.featureflag)
}
android {
namespace = "com.fsck.k9.storage"
}

View file

@ -0,0 +1,134 @@
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 androidx.annotation.NonNull;
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback;
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations;
import net.thunderbird.core.logging.Logger;
import net.thunderbird.core.preference.storage.InMemoryStorage;
import net.thunderbird.core.preference.storage.Storage;
import net.thunderbird.core.preference.storage.StorageEditor;
import net.thunderbird.core.preference.storage.StorageUpdater;
public class K9StorageEditor implements StorageEditor {
private StorageUpdater storageUpdater;
private K9StoragePersister storagePersister;
private Logger logger;
private Map<String, String> changes = new HashMap<>();
private List<String> removals = new ArrayList<>();
public K9StorageEditor(
StorageUpdater storageUpdater,
K9StoragePersister storagePersister,
Logger logger
) {
this.storageUpdater = storageUpdater;
this.storagePersister = storagePersister;
this.logger = logger;
}
@Override
public boolean commit() {
try {
storageUpdater.updateStorage(this::commitChanges);
return true;
} catch (Exception e) {
logger.error(null, e, () -> "Failed to save preferences");
return false;
}
}
private Storage commitChanges(Storage storage) {
long startTime = SystemClock.elapsedRealtime();
logger.info(null, null, () -> "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();
logger.info(null, null, () -> String.format("Preferences commit took %d ms", endTime - startTime));
return new InMemoryStorage(
newValues,
logger
);
}
@NonNull
@Override
public StorageEditor putBoolean(String key,
boolean value) {
changes.put(key, "" + value);
return this;
}
@NonNull
@Override
public StorageEditor putInt(String key, int value) {
changes.put(key, "" + value);
return this;
}
@NonNull
@Override
public StorageEditor putLong(String key, long value) {
changes.put(key, "" + value);
return this;
}
@NonNull
@Override
public StorageEditor putString(String key, String value) {
if (value == null) {
remove(key);
} else {
changes.put(key, value);
}
return this;
}
@NonNull
@Override
public StorageEditor remove(String key) {
removals.add(key);
return this;
}
}

View file

@ -0,0 +1,179 @@
package com.fsck.k9.preferences;
import java.util.HashMap;
import java.util.Map;
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.migration.DefaultStorageMigrationHelper;
import com.fsck.k9.preferences.migration.StorageMigrations;
import com.fsck.k9.preferences.migration.StorageMigrationHelper;
import net.thunderbird.core.logging.Logger;
import net.thunderbird.core.preference.storage.InMemoryStorage;
import net.thunderbird.core.preference.storage.Storage;
import net.thunderbird.core.preference.storage.StorageEditor;
import net.thunderbird.core.preference.storage.StoragePersister;
import net.thunderbird.core.preference.storage.StorageUpdater;
public class K9StoragePersister implements StoragePersister {
private static final int DB_VERSION = 28;
private static final String DB_NAME = "preferences_storage";
private final Context context;
private final Logger logger;
private final StorageMigrationHelper migrationHelper = new DefaultStorageMigrationHelper();
public K9StoragePersister(
Context context,
Logger logger
) {
this.context = context;
this.logger = logger;
}
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, migrationHelper);
}
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) {
logger.info(null, null, () -> "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, logger);
}
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();
logger.info(null, null, () -> "Loading preferences from DB into Storage");
try (SQLiteDatabase database = openDB()) {
return new InMemoryStorage(readAllValues(database), logger);
} finally {
long endTime = SystemClock.elapsedRealtime();
logger.info(null, null, () -> String.format("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);
logger.debug(null, null, () -> String.format("Loading key '%s', value = '%s'", key, value));
loadedValues.put(key, value);
}
} finally {
Utility.closeQuietly(cursor);
}
return loadedValues;
}
}

View file

@ -0,0 +1,82 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import androidx.core.content.contentValuesOf
import app.k9mail.core.android.common.database.getStringOrThrow
import app.k9mail.core.android.common.database.map
import net.thunderbird.core.logging.legacy.Log
class DefaultStorageMigrationHelper : StorageMigrationHelper {
override fun readAllValues(db: SQLiteDatabase): Map<String, String> {
return db.query(TABLE_NAME, arrayOf(KEY_COLUMN, VALUE_COLUMN), null, null, null, null, null).use {
it.map { cursor ->
val key = cursor.getStringOrThrow(KEY_COLUMN)
val value = cursor.getStringOrThrow(VALUE_COLUMN)
Log.d("Loading key '%s', value = '%s'", key, value)
key to value
}
}.toMap()
}
override fun readValue(db: SQLiteDatabase, key: String): String? {
return db.query(
TABLE_NAME,
arrayOf(VALUE_COLUMN),
"$KEY_COLUMN = ?",
arrayOf(key),
null,
null,
null,
).use { cursor ->
if (cursor.moveToNext()) {
cursor.getStringOrThrow(VALUE_COLUMN).also { value ->
Log.d("Loading key '%s', value = '%s'", key, value)
}
} else {
null
}
}
}
override fun writeValue(db: SQLiteDatabase, key: String, value: String?) {
if (value == null) {
db.delete(TABLE_NAME, "$KEY_COLUMN = ?", arrayOf(key))
return
}
val values = contentValuesOf(
KEY_COLUMN to key,
VALUE_COLUMN to value,
)
val result = db.update(TABLE_NAME, values, "$KEY_COLUMN = ?", arrayOf(key))
if (result == -1) {
Log.e("Error writing key '%s', value = '%s'", key, value)
}
}
override fun insertValue(db: SQLiteDatabase, key: String, value: String?) {
if (value == null) {
return
}
val values = contentValuesOf(
KEY_COLUMN to key,
VALUE_COLUMN to value,
)
val result = db.insert(TABLE_NAME, null, values)
if (result == -1L) {
Log.e("Error writing key '%s', value = '%s'", key, value)
}
}
companion object {
private const val TABLE_NAME = "preferences_storage"
private const val KEY_COLUMN = "primkey"
private const val VALUE_COLUMN = "value"
}
}

View file

@ -0,0 +1,10 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
interface StorageMigrationHelper {
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?)
}

View file

@ -0,0 +1,37 @@
package com.fsck.k9.preferences.migration
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: StorageMigrationHelper,
) {
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)
}
}
}
}

View file

@ -0,0 +1,26 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.preferences.upgrader.GeneralSettingsUpgraderTo31
/**
* 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: StorageMigrationHelper,
) {
fun upgradeMessageViewContentFontSize() {
val newFontSizeValue = migrationsHelper.readValue(db, "fontSizeMessageViewContentPercent")
if (newFontSizeValue != null) return
val oldFontSizeValue = migrationsHelper.readValue(db, "fontSizeMessageViewContent")?.toIntOrNull() ?: 3
val fontSizeValue = GeneralSettingsUpgraderTo31.convertFromOldSize(oldFontSizeValue)
migrationsHelper.writeValue(db, "fontSizeMessageViewContentPercent", fontSizeValue.toString())
migrationsHelper.writeValue(db, "fontSizeMessageViewContent", null)
}
}

View file

@ -0,0 +1,65 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mail.filter.Base64
import com.fsck.k9.preferences.migration.migration12.ImapStoreUriDecoder
import com.fsck.k9.preferences.migration.migration12.Pop3StoreUriDecoder
import com.fsck.k9.preferences.migration.migration12.SmtpTransportUriDecoder
import com.fsck.k9.preferences.migration.migration12.WebDavStoreUriDecoder
import net.thunderbird.feature.account.storage.legacy.serializer.ServerSettingsDtoSerializer
/**
* Convert server settings from the old URI format to the new JSON format
*/
class StorageMigrationTo12(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
private val serverSettingsDtoSerializer = ServerSettingsDtoSerializer()
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 = serverSettingsDtoSerializer.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 = serverSettingsDtoSerializer.serialize(serverSettings)
migrationsHelper.insertValue(db, "$accountUuid.outgoingServerSettings", json)
migrationsHelper.writeValue(db, "$accountUuid.transportUri", null)
}
private fun String.base64Decode() = Base64.decode(this)
}

View file

@ -0,0 +1,18 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
/**
* Rename `hideSpecialAccounts` to `showUnifiedInbox` (and invert value).
*/
class StorageMigrationTo13(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun renameHideSpecialAccounts() {
val hideSpecialAccounts = migrationsHelper.readValue(db, "hideSpecialAccounts")?.toBoolean() ?: false
val showUnifiedInbox = !hideSpecialAccounts
migrationsHelper.insertValue(db, "showUnifiedInbox", showUnifiedInbox.toString())
migrationsHelper.writeValue(db, "hideSpecialAccounts", null)
}
}

View file

@ -0,0 +1,35 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import net.thunderbird.core.common.mail.Protocols
import net.thunderbird.feature.account.storage.legacy.serializer.ServerSettingsDtoSerializer
/**
* Rewrite 'folderPushMode' value of non-IMAP accounts to 'NONE'.
*/
class StorageMigrationTo14(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
private val serverSettingsDtoSerializer = ServerSettingsDtoSerializer()
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 = serverSettingsDtoSerializer.deserialize(json)
if (serverSettings.type != Protocols.IMAP) {
migrationsHelper.writeValue(db, "$accountUuid.folderPushMode", "NONE")
}
}
}

View file

@ -0,0 +1,36 @@
package com.fsck.k9.preferences.migration
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: StorageMigrationHelper,
) {
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())
}
}
}

View file

@ -0,0 +1,18 @@
package com.fsck.k9.preferences.migration
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: StorageMigrationHelper,
) {
fun changeDefaultRegisteredNameColor() {
val registeredNameColorValue = migrationsHelper.readValue(db, "registeredNameColor")?.toInt()
if (registeredNameColorValue == 0xFF00008F.toInt()) {
migrationsHelper.writeValue(db, "registeredNameColor", 0xFF1093F5.toInt().toString())
}
}
}

View file

@ -0,0 +1,54 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite 'led' and 'ledColor' values to 'notificationLight'.
*/
class StorageMigrationTo17(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
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
}

View file

@ -0,0 +1,36 @@
package com.fsck.k9.preferences.migration
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: StorageMigrationHelper,
) {
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)
}
}

View file

@ -0,0 +1,54 @@
package com.fsck.k9.preferences.migration
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: StorageMigrationHelper,
) {
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")
}
}
}

View file

@ -0,0 +1,100 @@
package com.fsck.k9.preferences.migration;
import java.net.URI;
import android.database.sqlite.SQLiteDatabase;
import com.fsck.k9.helper.UrlEncodingHelper;
import com.fsck.k9.mail.filter.Base64;
import net.thunderbird.core.logging.legacy.Log;
public class StorageMigrationTo2 {
public static void urlEncodeUserNameAndPassword(SQLiteDatabase db, StorageMigrationHelper migrationsHelper) {
Log.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) {
Log.e(e, "ooops");
}
}
}
}
}

View file

@ -0,0 +1,56 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mail.Address
/**
* Clean up [Identity][com.fsck.k9.Identity] properties stored in the database
*
* Previously, we didn't validate input in the "Edit identity" screen, and so there was no guarantee that the `email`
* and `replyTo` values contained a valid email address.
*
* Additionally, we now rewrite blank values in `description`, `name`, and `replyTo` to `null`.
*/
class StorageMigrationTo20(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun fixIdentities() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue.isNullOrEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
fixIdentitiesInAccount(accountUuid)
}
}
private fun fixIdentitiesInAccount(accountUuid: String) {
var identityIndex = 0
while (true) {
val email = migrationsHelper.readValue(db, "$accountUuid.email.$identityIndex") ?: break
val description = migrationsHelper.readValue(db, "$accountUuid.description.$identityIndex")
val name = migrationsHelper.readValue(db, "$accountUuid.name.$identityIndex")
val replyTo = migrationsHelper.readValue(db, "$accountUuid.replyTo.$identityIndex")
val newDescription = description?.takeUnless { it.isBlank() }
val newName = name?.takeUnless { it.isBlank() }
val emailAddress = Address.parse(email).firstOrNull()
val newEmail = emailAddress?.address ?: "please.fix@invalid"
val replyToAddress = Address.parse(replyTo).firstOrNull()
val newReplyTo = replyToAddress?.address
migrationsHelper.writeValue(db, "$accountUuid.description.$identityIndex", newDescription)
migrationsHelper.writeValue(db, "$accountUuid.name.$identityIndex", newName)
migrationsHelper.writeValue(db, "$accountUuid.email.$identityIndex", newEmail)
migrationsHelper.writeValue(db, "$accountUuid.replyTo.$identityIndex", newReplyTo)
identityIndex++
}
}
}

View file

@ -0,0 +1,26 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
/**
* Combine `messageViewReturnToList` and `messageViewShowNext` into `messageViewPostDeleteAction`.
*/
class StorageMigrationTo21(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun createPostRemoveNavigationSetting() {
val messageViewReturnToList = migrationsHelper.readValue(db, "messageViewReturnToList").toBoolean()
val messageViewShowNext = migrationsHelper.readValue(db, "messageViewShowNext").toBoolean()
val postRemoveNavigation = when {
messageViewReturnToList -> "ReturnToMessageList"
messageViewShowNext -> "ShowNextMessage"
else -> "ShowPreviousMessage"
}
migrationsHelper.writeValue(db, "messageViewPostDeleteAction", postRemoveNavigation)
migrationsHelper.writeValue(db, "messageViewReturnToList", null)
migrationsHelper.writeValue(db, "messageViewShowNext", null)
}
}

View file

@ -0,0 +1,78 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
/**
* Fix server settings by removing line breaks from username and password.
*/
class StorageMigrationTo22(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun fixServerSettings() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue.isNullOrEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
fixServerSettingsForAccount(accountUuid)
}
}
private fun fixServerSettingsForAccount(accountUuid: String) {
val incomingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.incomingServerSettings") ?: return
val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
val adapter = createJsonAdapter()
adapter.fromJson(incomingServerSettingsJson)?.let { settings ->
createFixedServerSettings(settings)?.let { newSettings ->
val json = adapter.toJson(newSettings)
migrationsHelper.writeValue(db, "$accountUuid.incomingServerSettings", json)
}
}
adapter.fromJson(outgoingServerSettingsJson)?.let { settings ->
createFixedServerSettings(settings)?.let { newSettings ->
val json = adapter.toJson(newSettings)
migrationsHelper.writeValue(db, "$accountUuid.outgoingServerSettings", json)
}
}
}
private fun createFixedServerSettings(serverSettings: Map<String, Any?>): Map<String, Any?>? {
val username = serverSettings["username"] as? String
val password = serverSettings["password"] as? String
val newUsername = username?.stripLineBreaks()
val newPassword = password?.stripLineBreaks()
return if (username != newUsername || password != newPassword) {
serverSettings.toMutableMap().apply {
this["username"] = newUsername
this["password"] = newPassword
// This is so we don't end up with a port value of e.g. "993.0". It would still work, but it looks odd.
this["port"] = (serverSettings["port"] as? Double)?.toInt()
}
} else {
null
}
}
private fun createJsonAdapter(): JsonAdapter<Map<String, Any?>> {
val moshi = Moshi.Builder().build()
return moshi.adapter<Map<String, Any?>>(
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
).serializeNulls()
}
}
private val LINE_BREAK = "[\\r\\n]".toRegex()
private fun String.stripLineBreaks() = replace(LINE_BREAK, replacement = "")

View file

@ -0,0 +1,32 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
/**
* Rename account setting `sendClientId` to `sendClientInfo`.
*/
class StorageMigrationTo23(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun renameSendClientId() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue.isNullOrEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
renameSendClientIdForAccount(accountUuid)
}
}
private fun renameSendClientIdForAccount(accountUuid: String) {
// Write new key with existing value
val value = migrationsHelper.readValue(db, "$accountUuid.sendClientId")
migrationsHelper.insertValue(db, "$accountUuid.sendClientInfo", value)
// Remove old key
migrationsHelper.writeValue(db, "$accountUuid.sendClientId", null)
}
}

View file

@ -0,0 +1,80 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
/**
* Clean up the authentication type in outgoing server settings.
*
* Replaces the authentication value "AUTOMATIC" with "PLAIN" when TLS is used, "CRAM_MD5" otherwise.
* Replaces the authentication value "LOGIN" with "PLAIN".
*/
class StorageMigrationTo24(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun removeLegacyAuthenticationModes() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue.isNullOrEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
removeLegacyAuthenticationModesForAccount(accountUuid)
}
}
private fun removeLegacyAuthenticationModesForAccount(accountUuid: String) {
val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
val adapter = createJsonAdapter()
adapter.fromJson(outgoingServerSettingsJson)?.let { settings ->
createUpdatedServerSettings(settings)?.let { newSettings ->
val json = adapter.toJson(newSettings)
migrationsHelper.writeValue(db, "$accountUuid.outgoingServerSettings", json)
}
}
}
private fun createUpdatedServerSettings(serverSettings: Map<String, Any?>): Map<String, Any?>? {
val isSecure = serverSettings["connectionSecurity"] == "STARTTLS_REQUIRED" ||
serverSettings["connectionSecurity"] == "SSL_TLS_REQUIRED"
return when (serverSettings["authenticationType"]) {
"AUTOMATIC" -> {
serverSettings.toMutableMap().apply {
fixPortType()
this["authenticationType"] = if (isSecure) "PLAIN" else "CRAM_MD5"
}
}
"LOGIN" -> {
serverSettings.toMutableMap().apply {
fixPortType()
this["authenticationType"] = "PLAIN"
}
}
else -> {
null
}
}
}
private fun MutableMap<String, Any?>.fixPortType() {
// This is so we don't end up with a port value of e.g. "993.0". It would still work, but it looks odd.
this["port"] = (this["port"] as? Double)?.toInt()
}
private fun createJsonAdapter(): JsonAdapter<Map<String, Any?>> {
val moshi = Moshi.Builder().build()
return moshi.adapter<Map<String, Any?>>(
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
).serializeNulls()
}
}

View file

@ -0,0 +1,67 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
/**
* Updates outgoing server settings to use an authentication type value of "NONE" when appropriate.
*/
class StorageMigrationTo25(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun convertToAuthTypeNone() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue.isNullOrEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
convertToAuthTypeNoneForAccount(accountUuid)
}
}
private fun convertToAuthTypeNoneForAccount(accountUuid: String) {
val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
val adapter = createJsonAdapter()
adapter.fromJson(outgoingServerSettingsJson)?.let { settings ->
createUpdatedServerSettings(settings)?.let { newSettings ->
val json = adapter.toJson(newSettings)
migrationsHelper.writeValue(db, "$accountUuid.outgoingServerSettings", json)
}
}
}
private fun createUpdatedServerSettings(serverSettings: Map<String, Any?>): Map<String, Any?>? {
val username = serverSettings["username"] as? String?
return if (username.isNullOrEmpty()) {
serverSettings.toMutableMap().apply {
fixPortType()
this["authenticationType"] = "NONE"
this["username"] = ""
this["password"] = null
}
} else {
null
}
}
private fun MutableMap<String, Any?>.fixPortType() {
// This is so we don't end up with a port value of e.g. "993.0". It would still work, but it looks odd.
this["port"] = (this["port"] as? Double)?.toInt()
}
private fun createJsonAdapter(): JsonAdapter<Map<String, Any?>> {
val moshi = Moshi.Builder().build()
return moshi.adapter<Map<String, Any?>>(
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
).serializeNulls()
}
}

View file

@ -0,0 +1,48 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.EmailAddressValidator
/**
* Make sure identities are using a syntactically valid email address.
*
* Previously, we didn't validate input in the "Composition defaults" screen, and so there was no guarantee that the
* `email` values contained a valid email address.
*/
class StorageMigrationTo26(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
private val emailAddressValidator = EmailAddressValidator()
fun fixIdentities() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue.isNullOrEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
fixIdentitiesInAccount(accountUuid)
}
}
private fun fixIdentitiesInAccount(accountUuid: String) {
var identityIndex = 0
while (true) {
val email = migrationsHelper.readValue(db, "$accountUuid.email.$identityIndex") ?: break
val trimmedEmail = email.trim()
val newEmail = if (emailAddressValidator.isValidAddressOnly(trimmedEmail)) {
trimmedEmail
} else {
"please.edit@invalid"
}
migrationsHelper.writeValue(db, "$accountUuid.email.$identityIndex", newEmail)
identityIndex++
}
}
}

View file

@ -0,0 +1,84 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
/**
* Migration to add avatar monograms for accounts that have the MONOGRAM avatar type
* and do not have an existing avatar monogram.
*/
class StorageMigrationTo27(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun addAvatarMonogram() {
val accountUuidsValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsValue.isNullOrEmpty()) {
return
}
val accountUuids = accountUuidsValue.split(",")
for (accountUuid in accountUuids) {
addAvatarMonogramToAccount(accountUuid)
}
}
private fun addAvatarMonogramToAccount(accountUuid: String) {
val avatarType = readAvatarType(accountUuid)
val avatarMonogram = readAvatarMonogram(accountUuid)
if (avatarType == AvatarTypeDto.MONOGRAM.name && avatarMonogram.isEmpty()) {
val monogram = generateAvatarMonogram(accountUuid)
insertAvatarMonogram(accountUuid, monogram)
}
}
private fun generateAvatarMonogram(accountUuid: String): String {
val name = readName(accountUuid)
val email = readEmail(accountUuid)
return getAvatarMonogram(name, email)
}
private fun getAvatarMonogram(name: String?, email: String?): String {
return if (name != null && name.isNotEmpty()) {
composeAvatarMonogram(name)
} else if (email != null && email.isNotEmpty()) {
composeAvatarMonogram(email)
} else {
AVATAR_MONOGRAM_DEFAULT
}
}
private fun composeAvatarMonogram(name: String): String {
return name.replace(" ", "").take(2).uppercase()
}
private fun readAvatarType(accountUuid: String): String {
return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_TYPE_KEY") ?: ""
}
private fun readAvatarMonogram(accountUuid: String): String {
return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY") ?: ""
}
private fun readName(accountUuid: String): String {
return migrationsHelper.readValue(db, "$accountUuid.$NAME_KEY") ?: ""
}
private fun readEmail(accountUuid: String): String {
return migrationsHelper.readValue(db, "$accountUuid.$EMAIL_KEY") ?: ""
}
private fun insertAvatarMonogram(accountUuid: String, monogram: String) {
migrationsHelper.insertValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY", monogram)
}
private companion object {
const val NAME_KEY = "name.0"
const val EMAIL_KEY = "email.0"
const val AVATAR_TYPE_KEY = "avatarType"
const val AVATAR_MONOGRAM_KEY = "avatarMonogram"
private const val AVATAR_MONOGRAM_DEFAULT = "XX"
}
}

View file

@ -0,0 +1,94 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
/**
* Migration to ensure all accounts have an avatar type set.
* This fixes an issue where migration 27 might not have set the avatar type correctly.
*/
@Suppress("TooManyFunctions")
class StorageMigrationTo28(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
fun ensureAvatarSet() {
val accountUuidsValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsValue.isNullOrEmpty()) {
return
}
val accountUuids = accountUuidsValue.split(",")
for (accountUuid in accountUuids) {
ensureAvatarTypeForAccount(accountUuid)
}
}
private fun ensureAvatarTypeForAccount(accountUuid: String) {
var avatarType = readAvatarType(accountUuid)
val avatarMonogram = readAvatarMonogram(accountUuid)
if (avatarType.isEmpty()) {
avatarType = AvatarTypeDto.MONOGRAM.name
insertAvatarType(accountUuid, avatarType)
}
if (avatarType == AvatarTypeDto.MONOGRAM.name && avatarMonogram.isEmpty()) {
val monogram = generateAvatarMonogram(accountUuid)
insertAvatarMonogram(accountUuid, monogram)
}
}
private fun generateAvatarMonogram(accountUuid: String): String {
val name = readName(accountUuid)
val email = readEmail(accountUuid)
return getAvatarMonogram(name, email)
}
private fun getAvatarMonogram(name: String?, email: String?): String {
return if (name != null && name.isNotEmpty()) {
composeAvatarMonogram(name)
} else if (email != null && email.isNotEmpty()) {
composeAvatarMonogram(email)
} else {
AVATAR_MONOGRAM_DEFAULT
}
}
private fun composeAvatarMonogram(name: String): String {
return name.replace(" ", "").take(2).uppercase()
}
private fun readAvatarType(accountUuid: String): String {
return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_TYPE_KEY") ?: ""
}
private fun readAvatarMonogram(accountUuid: String): String {
return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY") ?: ""
}
private fun readName(accountUuid: String): String {
return migrationsHelper.readValue(db, "$accountUuid.$NAME_KEY") ?: ""
}
private fun readEmail(accountUuid: String): String {
return migrationsHelper.readValue(db, "$accountUuid.$EMAIL_KEY") ?: ""
}
private fun insertAvatarType(accountUuid: String, avatarType: String) {
migrationsHelper.insertValue(db, "$accountUuid.$AVATAR_TYPE_KEY", avatarType)
}
private fun insertAvatarMonogram(accountUuid: String, monogram: String) {
migrationsHelper.insertValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY", monogram)
}
private companion object {
const val NAME_KEY = "name.0"
const val EMAIL_KEY = "email.0"
const val AVATAR_TYPE_KEY = "avatarType"
const val AVATAR_MONOGRAM_KEY = "avatarMonogram"
private const val AVATAR_MONOGRAM_DEFAULT = "XX"
}
}

View file

@ -0,0 +1,44 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite folder name values of "-NONE-" to `null`
*/
class StorageMigrationTo3(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
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
}
}

View file

@ -0,0 +1,31 @@
package com.fsck.k9.preferences.migration
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: StorageMigrationHelper,
) {
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")
}
}

View file

@ -0,0 +1,36 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite frequencies lower than LOWEST_FREQUENCY_SUPPORTED
*/
class StorageMigrationTo5(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
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
}
}

View file

@ -0,0 +1,54 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
/**
* Perform legacy conversions that previously lived in `K9`.
*/
class StorageMigrationTo6(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
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
}
}

View file

@ -0,0 +1,54 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite settings to use enum names instead of ordinals.
*/
class StorageMigrationTo7(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationHelper,
) {
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"
}
}

View file

@ -0,0 +1,23 @@
package com.fsck.k9.preferences.migration
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: StorageMigrationHelper,
) {
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"
}
}

View file

@ -0,0 +1,39 @@
package com.fsck.k9.preferences.migration
import android.database.sqlite.SQLiteDatabase
internal object StorageMigrations {
@Suppress("MagicNumber", "CyclomaticComplexMethod")
@JvmStatic
fun upgradeDatabase(db: SQLiteDatabase, migrationsHelper: StorageMigrationHelper) {
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()
if (oldVersion < 20) StorageMigrationTo20(db, migrationsHelper).fixIdentities()
if (oldVersion < 21) StorageMigrationTo21(db, migrationsHelper).createPostRemoveNavigationSetting()
if (oldVersion < 22) StorageMigrationTo22(db, migrationsHelper).fixServerSettings()
if (oldVersion < 23) StorageMigrationTo23(db, migrationsHelper).renameSendClientId()
if (oldVersion < 24) StorageMigrationTo24(db, migrationsHelper).removeLegacyAuthenticationModes()
if (oldVersion < 25) StorageMigrationTo25(db, migrationsHelper).convertToAuthTypeNone()
if (oldVersion < 26) StorageMigrationTo26(db, migrationsHelper).fixIdentities()
if (oldVersion < 27) StorageMigrationTo27(db, migrationsHelper).addAvatarMonogram()
if (oldVersion < 28) StorageMigrationTo28(db, migrationsHelper).ensureAvatarSet()
}
}

View file

@ -0,0 +1,144 @@
package com.fsck.k9.preferences.migration.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);
}
}

View file

@ -0,0 +1,106 @@
package com.fsck.k9.preferences.migration.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);
}
}

View file

@ -0,0 +1,108 @@
package com.fsck.k9.preferences.migration.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");
}
}
}

View file

@ -0,0 +1,120 @@
package com.fsck.k9.preferences.migration.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);
}
}

View file

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

View file

@ -0,0 +1,23 @@
package com.fsck.k9.storage
import app.k9mail.legacy.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(),
storageFilesProviderFactory = get(),
basicPartInfoExtractor = get(),
generalSettingsManager = get(),
)
}
single<NotificationStoreProvider> {
K9NotificationStoreProvider(localStoreProvider = get())
}
}

View file

@ -0,0 +1,273 @@
package com.fsck.k9.storage;
import android.database.sqlite.SQLiteDatabase;
import com.fsck.k9.K9;
import com.fsck.k9.core.BuildConfig;
import com.fsck.k9.mailstore.LockableDatabase.SchemaDefinition;
import com.fsck.k9.mailstore.MigrationsHelper;
import com.fsck.k9.storage.migrations.Migrations;
import net.thunderbird.core.logging.legacy.Log;
class StoreSchemaDefinition implements SchemaDefinition {
static final int DB_VERSION = 90;
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 (BuildConfig.DEBUG) {
throw new Error("Exception while upgrading database", e);
}
Log.e(e, "Exception while upgrading database. Resetting the DB to v0");
db.setVersion(0);
upgradeDatabase(db);
}
}
private void upgradeDatabase(final SQLiteDatabase db) {
Log.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, " +
"sync_enabled INTEGER DEFAULT 0, " +
"push_enabled INTEGER DEFAULT 0, " +
"visible INTEGER DEFAULT 1, " +
"notifications_enabled INTEGER DEFAULT 0, " +
"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)");
}
}

View file

@ -0,0 +1,35 @@
package com.fsck.k9.storage.messages
import com.fsck.k9.helper.FileHelper
import com.fsck.k9.mailstore.StorageFilesProvider
import java.io.File
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.preference.GeneralSettingsManager
internal class AttachmentFileManager(
private val storageFilesProvider: StorageFilesProvider,
private val generalSettingsManager: GeneralSettingsManager,
) {
fun deleteFile(messagePartId: Long) {
val file = getAttachmentFile(messagePartId)
if (file.exists() && !file.delete() && generalSettingsManager.getConfig().debugging.isDebugLoggingEnabled) {
Log.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 = storageFilesProvider.getAttachmentDirectory()
return File(attachmentDirectory, messagePartId.toString())
}
}

View file

@ -0,0 +1,42 @@
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
}
}
fun hasPushEnabledFolder(): Boolean {
return lockableDatabase.execute(false) { database ->
database.rawQuery("SELECT id FROM folders WHERE push_enabled = 1 LIMIT 1", null, null).use { cursor ->
cursor.count > 0
}
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,33 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import app.k9mail.legacy.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>): Set<Long> = buildSet {
lockableDatabase.execute(true) { db ->
for (folder in folders) {
val folderSettings = folder.settings
val values = ContentValues().apply {
put("name", folder.name.replace("\\[(Gmail|Google Mail)]/".toRegex(), ""))
put("visible_limit", folderSettings.visibleLimit)
put("integrate", folderSettings.integrate)
put("top_group", folderSettings.inTopGroup)
put("sync_enabled", folderSettings.isSyncEnabled)
put("push_enabled", folderSettings.isPushEnabled)
put("visible", folderSettings.isVisible)
put("notifications_enabled", folderSettings.isNotificationsEnabled)
put("server_id", folder.serverId)
put("local_only", false)
put("type", folder.type.toDatabaseFolderType())
}
db.insert("folders", null, values)
.takeIf { it != -1L }
?.let(::add)
}
}
}
}

View file

@ -0,0 +1,3 @@
package com.fsck.k9.storage.messages
internal const val DATA_LOCATION_ON_DISK = 2

View file

@ -0,0 +1,38 @@
package com.fsck.k9.storage.messages
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.StorageFilesProvider
import net.thunderbird.core.logging.legacy.Log
internal class DatabaseOperations(
private val lockableDatabase: LockableDatabase,
private val storageFilesProvider: StorageFilesProvider,
) {
fun getSize(): Long {
val attachmentDirectory = storageFilesProvider.getAttachmentDirectory()
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 = storageFilesProvider.getDatabaseFile()
val databaseSize = databaseFile.length()
databaseSize + attachmentsSize
}
}
fun compact() {
Log.i("Before compaction size = %d", getSize())
lockableDatabase.execute(false) { database ->
database.execSQL("VACUUM")
}
Log.i("After compaction size = %d", getSize())
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,36 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import com.fsck.k9.mailstore.LockableDatabase
internal class FolderNameSanitizer(private val lockableDatabase: LockableDatabase) {
fun removeGmailPrefixFromFolders() {
lockableDatabase.execute(false) { db ->
val cursor = db.query(
"folders",
arrayOf("id", "name"),
"name LIKE ? OR name LIKE ?",
arrayOf("%[Gmail]/%", "%[Google Mail]/%"),
null,
null,
null,
)
while (cursor.moveToNext()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow("id"))
val name = cursor.getString(cursor.getColumnIndexOrThrow("name"))
val updatedName = name
.replace("[Gmail]/", "")
.replace("[Google Mail]/", "")
val values = ContentValues().apply {
put("name", updatedName)
}
db.update("folders", values, "id = ?", arrayOf(id.toString()))
}
cursor.close()
}
}
}

View file

@ -0,0 +1,295 @@
package com.fsck.k9.storage.messages
import app.k9mail.legacy.mailstore.CreateFolderInfo
import app.k9mail.legacy.mailstore.FolderMapper
import app.k9mail.legacy.mailstore.MessageMapper
import app.k9mail.legacy.mailstore.MessageStore
import app.k9mail.legacy.mailstore.MoreMessages
import app.k9mail.legacy.mailstore.SaveMessageData
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.Header
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.StorageFilesProvider
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
import java.util.Date
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.preference.GeneralSettingsManager
import net.thunderbird.feature.mail.folder.api.FolderDetails
import net.thunderbird.feature.search.legacy.SearchConditionTreeNode
class K9MessageStore(
database: LockableDatabase,
storageFilesProvider: StorageFilesProvider,
basicPartInfoExtractor: BasicPartInfoExtractor,
generalSettingsManager: GeneralSettingsManager,
) : MessageStore {
private val attachmentFileManager = AttachmentFileManager(storageFilesProvider, generalSettingsManager)
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, storageFilesProvider)
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)
}
@Throws(MessagingException::class)
override fun createFolders(folders: List<CreateFolderInfo>): Set<Long> =
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(
includeHiddenFolders: Boolean,
outboxFolderId: Long?,
mapper: FolderMapper<T>,
): List<T> {
return retrieveFolderOperations.getDisplayFolders(includeHiddenFolders, 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: SearchConditionTreeNode?): Int {
return retrieveFolderOperations.getUnreadMessageCount(conditions)
}
override fun getStarredMessageCount(conditions: SearchConditionTreeNode?): 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 setVisible(folderId: Long, visible: Boolean) {
updateFolderOperations.setVisible(folderId, visible)
}
override fun setSyncEnabled(folderId: Long, enable: Boolean) {
updateFolderOperations.setSyncEnabled(folderId, enable)
}
override fun setPushEnabled(folderId: Long, enable: Boolean) {
updateFolderOperations.setPushEnabled(folderId, enable)
}
override fun setNotificationsEnabled(folderId: Long, enable: Boolean) {
updateFolderOperations.setNotificationsEnabled(folderId, enable)
}
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 setPushDisabled() {
updateFolderOperations.setPushDisabled()
}
override fun hasPushEnabledFolder(): Boolean {
return checkFolderOperations.hasPushEnabledFolder()
}
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()
}
}

View file

@ -0,0 +1,42 @@
package com.fsck.k9.storage.messages
import app.k9mail.legacy.mailstore.ListenableMessageStore
import app.k9mail.legacy.mailstore.MessageStoreFactory
import com.fsck.k9.mailstore.LocalStoreProvider
import com.fsck.k9.mailstore.NotifierMessageStore
import com.fsck.k9.mailstore.StorageFilesProviderFactory
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
import net.thunderbird.core.android.account.LegacyAccount
import net.thunderbird.core.preference.GeneralSettingsManager
class K9MessageStoreFactory(
private val localStoreProvider: LocalStoreProvider,
private val storageFilesProviderFactory: StorageFilesProviderFactory,
private val basicPartInfoExtractor: BasicPartInfoExtractor,
private val generalSettingsManager: GeneralSettingsManager,
) : MessageStoreFactory {
override fun create(account: LegacyAccount): ListenableMessageStore {
val localStore = localStoreProvider.getInstance(account)
if (account.incomingServerSettings.host.isGoogle() ||
account.outgoingServerSettings.host.isGoogle()
) {
val folderNameSanitizer = FolderNameSanitizer(lockableDatabase = localStore.database)
folderNameSanitizer.removeGmailPrefixFromFolders()
}
val storageFilesProvider = storageFilesProviderFactory.createStorageFilesProvider(account.uuid)
val messageStore = K9MessageStore(
localStore.database,
storageFilesProvider,
basicPartInfoExtractor,
generalSettingsManager,
)
val notifierMessageStore = NotifierMessageStore(messageStore, localStore)
return ListenableMessageStore(notifierMessageStore)
}
}
private fun String.isGoogle(): Boolean {
val domains = listOf(".gmail.com", ".googlemail.com")
return domains.any { this.endsWith(it, ignoreCase = true) }
}

View file

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

View file

@ -0,0 +1,133 @@
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 net.thunderbird.core.logging.legacy.Log
internal class MoveMessageOperations(
private val database: LockableDatabase,
private val threadMessageOperations: ThreadMessageOperations,
) {
fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
Log.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()))
}
}

View file

@ -0,0 +1,268 @@
package com.fsck.k9.storage.messages
import android.database.Cursor
import androidx.core.database.getLongOrNull
import app.k9mail.core.android.common.database.map
import app.k9mail.legacy.mailstore.FolderDetailsAccessor
import app.k9mail.legacy.mailstore.FolderMapper
import app.k9mail.legacy.mailstore.MoreMessages
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mailstore.FolderNotFoundException
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.toFolderType
import net.thunderbird.feature.search.legacy.SearchConditionTreeNode
import net.thunderbird.feature.search.legacy.sql.SqlWhereClause
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(includeHiddenFolders: Boolean, outboxFolderId: Long?, mapper: FolderMapper<T>): List<T> {
return lockableDatabase.execute(false) { db ->
val displayModeSelection = getDisplayModeSelection(includeHiddenFolders)
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(includeHiddenFolders: Boolean): String {
return if (includeHiddenFolders) {
""
} else {
"WHERE visible = 1"
}
}
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: SearchConditionTreeNode?): Int {
return getMessageCount(condition = "messages.read = 0", conditions)
}
fun getStarredMessageCount(conditions: SearchConditionTreeNode?): Int {
return getMessageCount(condition = "messages.flagged = 1", conditions)
}
private fun getMessageCount(condition: String, extraConditions: SearchConditionTreeNode?): Int {
val whereClause = extraConditions?.let {
SqlWhereClause.Builder()
.withConditions(extraConditions)
.build()
}
val where = if (whereClause != null) "AND (${whereClause.selection})" else ""
val selectionArgs = whereClause?.selectionArgs?.toTypedArray() ?: emptyArray()
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 isSyncEnabled: Boolean
get() = cursor.getInt(7) == 1
override val isVisible: Boolean
get() = cursor.getInt(8) == 1
override val isNotificationsEnabled: Boolean
get() = cursor.getInt(9) == 1
override val isPushEnabled: Boolean
get() = cursor.getInt(10) == 1
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 val FOLDER_COLUMNS = arrayOf(
"id",
"name",
"type",
"server_id",
"local_only",
"top_group",
"integrate",
"sync_enabled",
"visible",
"notifications_enabled",
"push_enabled",
"visible_limit",
"more_messages",
"last_updated",
)

View file

@ -0,0 +1,246 @@
package com.fsck.k9.storage.messages
import android.database.Cursor
import app.k9mail.legacy.mailstore.MessageDetailsAccessor
import app.k9mail.legacy.mailstore.MessageMapper
import app.k9mail.legacy.message.extractors.PreviewResult
import com.fsck.k9.mail.Address
import com.fsck.k9.mailstore.DatabasePreviewType
import com.fsck.k9.mailstore.LockableDatabase
import net.thunderbird.feature.search.legacy.sql.SqlWhereClause
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 = SqlWhereClause.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",
)

View file

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

View file

@ -0,0 +1,539 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import app.k9mail.legacy.mailstore.SaveMessageData
import app.k9mail.legacy.message.extractors.PreviewResult.PreviewType
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.message.extractors.BasicPartInfoExtractor
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)

View file

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

View file

@ -0,0 +1,118 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import app.k9mail.legacy.mailstore.MoreMessages
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.toDatabaseFolderType
import net.thunderbird.feature.mail.folder.api.FolderDetails
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("sync_enabled", folderDetails.isSyncEnabled)
put("visible", folderDetails.isVisible)
put("notifications_enabled", folderDetails.isNotificationsEnabled)
put("push_enabled", folderDetails.isPushEnabled)
}
db.update("folders", contentValues, "id = ?", arrayOf(folderDetails.folder.id.toString()))
}
}
fun setIncludeInUnifiedInbox(folderId: Long, includeInUnifiedInbox: Boolean) {
setBoolean(folderId, columnName = "integrate", value = includeInUnifiedInbox)
}
fun setVisible(folderId: Long, visible: Boolean) {
setBoolean(folderId = folderId, columnName = "visible", value = visible)
}
fun setSyncEnabled(folderId: Long, enable: Boolean) {
setBoolean(folderId = folderId, columnName = "sync_enabled", value = enable)
}
fun setPushEnabled(folderId: Long, enable: Boolean) {
setBoolean(folderId = folderId, columnName = "push_enabled", value = enable)
}
fun setNotificationsEnabled(folderId: Long, enable: Boolean) {
setBoolean(folderId, columnName = "notifications_enabled", value = enable)
}
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()))
}
}
fun setPushDisabled() {
lockableDatabase.execute(false) { db ->
val contentValues = ContentValues().apply {
put("push_enabled", false)
}
db.update("folders", contentValues, null, null)
}
}
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()))
}
}
private fun setBoolean(folderId: Long, columnName: String, value: Boolean) {
lockableDatabase.execute(false) { db ->
val contentValues = ContentValues().apply {
put(columnName, value)
}
db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
}
}
}

View file

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

View file

@ -0,0 +1,6 @@
package com.fsck.k9.storage.migrations;
class LegacyPendingAppend extends LegacyPendingCommand {
public String folder;
public String uid;
}

View file

@ -0,0 +1,4 @@
package com.fsck.k9.storage.migrations;
abstract class LegacyPendingCommand {
}

View file

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

View file

@ -0,0 +1,5 @@
package com.fsck.k9.storage.migrations;
class LegacyPendingExpunge extends LegacyPendingCommand {
public String folder;
}

View file

@ -0,0 +1,5 @@
package com.fsck.k9.storage.migrations;
class LegacyPendingMarkAllAsRead extends LegacyPendingCommand {
public String folder;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,22 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mailstore.MigrationsHelper
import net.thunderbird.core.common.mail.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
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,98 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
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 'INHERITED', " +
"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;",
)
}
}

View file

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

View file

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

View file

@ -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 net.thunderbird.core.logging.legacy.Log
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 }) {
Log.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")
}
}

View file

@ -0,0 +1,124 @@
package com.fsck.k9.storage.migrations
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import net.thunderbird.core.android.account.DeletePolicy
import net.thunderbird.core.android.account.LegacyAccount
/**
* 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: LegacyAccount) {
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()))
}
}

View file

@ -0,0 +1,35 @@
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.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
}
}
}

View file

@ -0,0 +1,118 @@
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.mailstore.MigrationsHelper
import net.thunderbird.core.android.account.LegacyAccount
import net.thunderbird.core.common.mail.Protocols
import net.thunderbird.core.logging.legacy.Log
/**
* 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
if (account.isPop3()) {
Log.v("Cleaning up Drafts folder")
val draftsFolderId = account.draftsFolderId ?: createFolder("Drafts", "Drafts", DRAFTS_FOLDER_TYPE)
moveMessages(DRAFTS_FOLDER_TYPE, draftsFolderId)
account.draftsFolderId = draftsFolderId
Log.v("Cleaning up Sent folder")
val sentFolderId = account.sentFolderId ?: createFolder("Sent", "Sent", SENT_FOLDER_TYPE)
moveMessages(SENT_FOLDER_TYPE, sentFolderId)
account.sentFolderId = sentFolderId
Log.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 {
Log.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)
Log.v(" Created folder with ID $folderId")
return 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) {
Log.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()))
Log.v(" $rows messages moved.")
}
private fun deleteFolder(folderId: Long) {
Log.v(" Deleting folder [$folderId]")
db.delete("folders", "id = ?", arrayOf(folderId.toString()))
}
private fun LegacyAccount.isPop3() = incomingServerSettings.type == Protocols.POP3
companion object {
private const val DRAFTS_FOLDER_TYPE = "drafts"
private const val SENT_FOLDER_TYPE = "sent"
private const val TRASH_FOLDER_TYPE = "trash"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,60 @@
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?,
)

View file

@ -0,0 +1,133 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import android.os.Build
import androidx.core.content.contentValuesOf
import com.fsck.k9.mailstore.MigrationsHelper
import net.thunderbird.core.android.account.FolderMode
import net.thunderbird.core.android.account.LegacyAccount
internal class MigrationTo85(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
fun addFoldersNotificationsEnabledColumn() {
addColumn()
populateColumn()
removeNotifyClassColumn()
}
private fun addColumn() {
db.execSQL("ALTER TABLE folders ADD notifications_enabled INTEGER DEFAULT 0")
}
@Suppress("LongMethod")
private fun populateColumn() {
val account = migrationsHelper.account
val ignoreFolders = getNotificationIgnoredFolders(account)
val ignoreFoldersWhereClause = if (ignoreFolders.isNotEmpty()) {
"id NOT IN (${ignoreFolders.joinToString(separator = ",") { "?" }})"
} else {
"1"
}
val whereClause = when (account.folderNotifyNewMailMode) {
FolderMode.NONE -> {
return
}
FolderMode.ALL -> {
ignoreFoldersWhereClause
}
FolderMode.FIRST_CLASS -> {
"""
(
notify_class = 'FIRST_CLASS' OR (
notify_class = 'INHERITED' AND (
push_class = 'FIRST_CLASS' OR (
push_class = 'INHERITED' AND (
poll_class = 'FIRST_CLASS' OR (
poll_class = 'INHERITED' AND display_class = 'FIRST_CLASS'
)
)
)
)
)
) AND $ignoreFoldersWhereClause
""".trimIndent()
}
FolderMode.FIRST_AND_SECOND_CLASS -> {
"""
(
notify_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
notify_class = 'INHERITED' AND (
push_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
push_class = 'INHERITED' AND (
poll_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
poll_class = 'INHERITED' AND display_class IN ('FIRST_CLASS', 'SECOND_CLASS')
)
)
)
)
)
) AND $ignoreFoldersWhereClause
""".trimIndent()
}
FolderMode.NOT_SECOND_CLASS -> {
"""
(
notify_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
notify_class = 'INHERITED' AND (
push_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
push_class = 'INHERITED' AND (
poll_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
poll_class = 'INHERITED' AND display_class IN ('NO_CLASS', 'FIRST_CLASS')
)
)
)
)
)
) AND $ignoreFoldersWhereClause
""".trimIndent()
}
}
db.update("folders", contentValuesOf("notifications_enabled" to true), whereClause, ignoreFolders)
}
private fun getNotificationIgnoredFolders(account: LegacyAccount): Array<String> {
val inboxFolderId = account.inboxFolderId
// These special folders were ignored via K9NotificationStrategy unless they were pointing to the inbox.
return listOf(
account.trashFolderId,
account.draftsFolderId,
account.spamFolderId,
account.sentFolderId,
).asSequence()
.filterNotNull()
.filterNot { it == inboxFolderId }
.map { it.toString() }
.toList()
.toTypedArray()
}
private fun removeNotifyClassColumn() {
// Support for dropping columns was added in SQLite 3.35.0 (2021-03-12).
// See https://www.sqlite.org/releaselog/3_35_5.html
//
// So a SQLite version containing support for dropping tables is only available starting with API 34.
// See https://developer.android.com/reference/android/database/sqlite/package-summary
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
dropNotifyClassColumn()
} else {
clearNotifyClassColumn()
}
}
private fun dropNotifyClassColumn() {
db.execSQL("ALTER TABLE folders DROP COLUMN notify_class")
}
private fun clearNotifyClassColumn() {
db.update("folders", contentValuesOf("notify_class" to null), null, null)
}
}

View file

@ -0,0 +1,85 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import android.os.Build
import androidx.core.content.contentValuesOf
import com.fsck.k9.mailstore.MigrationsHelper
import net.thunderbird.core.android.account.FolderMode
internal class MigrationTo86(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
fun addFoldersPushEnabledColumn() {
addColumn()
populateColumn()
removePushClassColumn()
}
private fun addColumn() {
db.execSQL("ALTER TABLE folders ADD push_enabled INTEGER DEFAULT 0")
}
@Suppress("LongMethod")
private fun populateColumn() {
val account = migrationsHelper.account
val whereClause = when (account.folderPushMode) {
FolderMode.NONE -> {
return
}
FolderMode.ALL -> {
""
}
FolderMode.FIRST_CLASS -> {
"""
push_class = 'FIRST_CLASS' OR (
push_class = 'INHERITED' AND (
poll_class = 'FIRST_CLASS' OR (
poll_class = 'INHERITED' AND display_class = 'FIRST_CLASS'
)
)
)
""".trimIndent()
}
FolderMode.FIRST_AND_SECOND_CLASS -> {
"""
push_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
push_class = 'INHERITED' AND (
poll_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
poll_class = 'INHERITED' AND display_class IN ('FIRST_CLASS', 'SECOND_CLASS')
)
)
)
""".trimIndent()
}
FolderMode.NOT_SECOND_CLASS -> {
"""
push_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
push_class = 'INHERITED' AND (
poll_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
poll_class = 'INHERITED' AND display_class IN ('NO_CLASS', 'FIRST_CLASS')
)
)
)
""".trimIndent()
}
}
db.update("folders", contentValuesOf("push_enabled" to true), whereClause, null)
}
private fun removePushClassColumn() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
dropPushClassColumn()
} else {
clearPushClassColumn()
}
}
private fun dropPushClassColumn() {
db.execSQL("ALTER TABLE folders DROP COLUMN push_class")
}
private fun clearPushClassColumn() {
db.update("folders", contentValuesOf("push_class" to null), null, null)
}
}

View file

@ -0,0 +1,73 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import android.os.Build
import androidx.core.content.contentValuesOf
import com.fsck.k9.mailstore.MigrationsHelper
import net.thunderbird.core.android.account.FolderMode
internal class MigrationTo87(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
fun addFoldersSyncEnabledColumn() {
addColumn()
populateColumn()
removePollClassColumn()
}
private fun addColumn() {
db.execSQL("ALTER TABLE folders ADD sync_enabled INTEGER DEFAULT 0")
}
@Suppress("LongMethod")
private fun populateColumn() {
val account = migrationsHelper.account
val whereClause = when (account.folderSyncMode) {
FolderMode.NONE -> {
return
}
FolderMode.ALL -> {
""
}
FolderMode.FIRST_CLASS -> {
"""
poll_class = 'FIRST_CLASS' OR (
poll_class = 'INHERITED' AND display_class = 'FIRST_CLASS'
)
""".trimIndent()
}
FolderMode.FIRST_AND_SECOND_CLASS -> {
"""
poll_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
poll_class = 'INHERITED' AND display_class IN ('FIRST_CLASS', 'SECOND_CLASS')
)
""".trimIndent()
}
FolderMode.NOT_SECOND_CLASS -> {
"""
poll_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
poll_class = 'INHERITED' AND display_class IN ('NO_CLASS', 'FIRST_CLASS')
)
""".trimIndent()
}
}
db.update("folders", contentValuesOf("sync_enabled" to true), whereClause, null)
}
private fun removePollClassColumn() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
dropPollClassColumn()
} else {
clearPollClassColumn()
}
}
private fun dropPollClassColumn() {
db.execSQL("ALTER TABLE folders DROP COLUMN poll_class")
}
private fun clearPollClassColumn() {
db.update("folders", contentValuesOf("poll_class" to null), null, null)
}
}

View file

@ -0,0 +1,61 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import android.os.Build
import androidx.core.content.contentValuesOf
import com.fsck.k9.mailstore.MigrationsHelper
import net.thunderbird.core.android.account.FolderMode
internal class MigrationTo88(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
fun addFoldersVisibleColumn() {
addColumn()
populateColumn()
removeDisplayClassColumn()
}
private fun addColumn() {
db.execSQL("ALTER TABLE folders ADD visible INTEGER DEFAULT 1")
}
private fun populateColumn() {
val account = migrationsHelper.account
// The default is for folders to be visible. So we only update folders that should be hidden.
val whereClause = when (account.folderDisplayMode) {
FolderMode.NONE -> {
""
}
FolderMode.ALL -> {
return
}
FolderMode.FIRST_CLASS -> {
"display_class != 'FIRST_CLASS'"
}
FolderMode.FIRST_AND_SECOND_CLASS -> {
"display_class NOT IN ('FIRST_CLASS', 'SECOND_CLASS')"
}
FolderMode.NOT_SECOND_CLASS -> {
"display_class = 'SECOND_CLASS'"
}
}
db.update("folders", contentValuesOf("visible" to false), whereClause, null)
}
private fun removeDisplayClassColumn() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
dropDisplayClassColumn()
} else {
clearDisplayClassColumn()
}
}
private fun dropDisplayClassColumn() {
db.execSQL("ALTER TABLE folders DROP COLUMN display_class")
}
private fun clearDisplayClassColumn() {
db.update("folders", contentValuesOf("display_class" to null), null, null)
}
}

View file

@ -0,0 +1,139 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import androidx.annotation.VisibleForTesting
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.oauth.AuthStateStorage
import com.fsck.k9.mail.oauth.OAuth2TokenProviderFactory
import com.fsck.k9.mail.ssl.TrustedSocketFactory
import com.fsck.k9.mail.store.imap.ImapClientInfo
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.store.imap.ImapStoreConfig
import com.fsck.k9.mail.store.imap.ImapStoreFactory
import com.fsck.k9.mail.store.imap.ImapStoreSettings
import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace
import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix
import com.fsck.k9.mailstore.MigrationsHelper
import net.thunderbird.core.android.account.Expunge
import net.thunderbird.core.android.account.LegacyAccount
import net.thunderbird.core.common.mail.Protocols
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.logging.legacy.Log
import okio.IOException
import org.intellij.lang.annotations.Language
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
private const val TAG = "MigrationTo90"
internal class MigrationTo90(
private val db: SQLiteDatabase,
private val migrationsHelper: MigrationsHelper,
private val logger: Logger = Log,
private val imapStoreFactory: ImapStoreFactory = ImapStore.Companion,
) : KoinComponent {
private val trustedSocketFactory: TrustedSocketFactory by inject()
private val clientInfoAppName: String by inject(named("ClientInfoAppName"))
private val clientInfoAppVersion: String by inject(named("ClientInfoAppVersion"))
private val oAuth2TokenProviderFactory: OAuth2TokenProviderFactory by inject()
fun removeImapPrefixFromFolderServerId() {
val account = migrationsHelper.account
if (account.incomingServerSettings.type != Protocols.IMAP) {
logger.verbose(TAG) {
"account ${account.uuid} is not an IMAP account, skipping db migration for this account."
}
return
}
logger.verbose(TAG) { "started db migration to version 90 to account ${account.uuid}" }
val imapStore = createImapStore(account)
try {
logger.verbose(TAG) { "fetching IMAP prefix" }
imapStore.fetchImapPrefix()
val imapPrefix = imapStore.combinedPrefix
if (imapPrefix?.isNotBlank() == true) {
logger.verbose(TAG) { "Imap Prefix ($imapPrefix) detected, updating folder's server_id" }
val query = buildQuery(imapPrefix)
db.execSQL(query)
} else {
logger.verbose(TAG) { "No Imap Prefix detected, skipping db migration" }
}
logger.verbose(TAG) { "completed db migration to version 90 for account ${account.uuid}" }
} catch (e: AuthenticationFailedException) {
logger.warn(TAG, e) {
"failed to fetch IMAP prefix due to authentication error. skipping db migration"
}
} catch (e: IOException) {
logger.warn(TAG, e) {
"failed to fetch IMAP prefix due to network error. skipping db migration"
}
}
}
private fun createImapStore(account: LegacyAccount): ImapStore {
val serverSettings = account.toImapServerSettings()
val oAuth2TokenProvider = if (serverSettings.authenticationType == AuthType.XOAUTH2) {
val authStateStorage = object : AuthStateStorage {
override fun getAuthorizationState(): String? = account.oAuthState
override fun updateAuthorizationState(authorizationState: String?) = Unit
}
oAuth2TokenProviderFactory.create(authStateStorage)
} else {
null
}
return imapStoreFactory.create(
serverSettings = serverSettings,
config = createImapStoreConfig(account),
trustedSocketFactory = trustedSocketFactory,
oauthTokenProvider = oAuth2TokenProvider,
)
}
private fun LegacyAccount.toImapServerSettings(): ServerSettings {
val serverSettings = incomingServerSettings
return serverSettings.copy(
extra = ImapStoreSettings.createExtra(
autoDetectNamespace = serverSettings.autoDetectNamespace,
pathPrefix = serverSettings.pathPrefix,
useCompression = useCompression,
sendClientInfo = isSendClientInfoEnabled,
),
)
}
private fun createImapStoreConfig(account: LegacyAccount): ImapStoreConfig {
return object : ImapStoreConfig {
override val logLabel
get() = account.uuid
override fun isSubscribedFoldersOnly() = account.isSubscribedFoldersOnly
override fun isExpungeImmediately() = account.expungePolicy == Expunge.EXPUNGE_IMMEDIATELY
override fun clientInfo() = ImapClientInfo(appName = clientInfoAppName, appVersion = clientInfoAppVersion)
}
}
@Language("RoomSql")
@VisibleForTesting
internal fun buildQuery(imapPrefix: String): String {
return """
|UPDATE folders
| SET server_id = REPLACE(server_id, '$imapPrefix', '')
|WHERE
| server_id IS NOT NULL
| AND server_id LIKE '$imapPrefix%'
| AND type <> 'outbox'
| AND local_only <> 1
""".trimMargin()
}
}

View file

@ -0,0 +1,40 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mailstore.MigrationsHelper
@Suppress("MagicNumber")
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()
if (oldVersion < 85) MigrationTo85(db, migrationsHelper).addFoldersNotificationsEnabledColumn()
if (oldVersion < 86) MigrationTo86(db, migrationsHelper).addFoldersPushEnabledColumn()
if (oldVersion < 87) MigrationTo87(db, migrationsHelper).addFoldersSyncEnabledColumn()
if (oldVersion < 88) MigrationTo88(db, migrationsHelper).addFoldersVisibleColumn()
if (oldVersion < 90) MigrationTo90(db, migrationsHelper).removeImapPrefixFromFolderServerId()
}
}

View file

@ -0,0 +1,72 @@
package com.fsck.k9.storage.notifications
import android.database.sqlite.SQLiteDatabase
import app.k9mail.legacy.message.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),
)
}
}

View file

@ -0,0 +1,13 @@
package com.fsck.k9.storage.notifications
import com.fsck.k9.mailstore.LocalStoreProvider
import com.fsck.k9.notification.NotificationStore
import com.fsck.k9.notification.NotificationStoreProvider
import net.thunderbird.core.android.account.LegacyAccount
class K9NotificationStoreProvider(private val localStoreProvider: LocalStoreProvider) : NotificationStoreProvider {
override fun getNotificationStore(account: LegacyAccount): NotificationStore {
val localStore = localStoreProvider.getInstance(account)
return K9NotificationStore(lockableDatabase = localStore.database)
}
}

View file

@ -0,0 +1,22 @@
package com.fsck.k9.preferences
import android.database.sqlite.SQLiteDatabase
private const val TABLE_NAME = "preferences_storage"
private const val PRIMARY_KEY_COLUMN = "primkey"
private const val VALUE_COLUMN = "value"
fun createPreferencesDatabase(): SQLiteDatabase {
val database = SQLiteDatabase.create(null)
database.execSQL(
"""
CREATE TABLE $TABLE_NAME (
$PRIMARY_KEY_COLUMN TEXT PRIMARY KEY ON CONFLICT REPLACE,
$VALUE_COLUMN TEXT
)
""".trimIndent(),
)
return database
}

View file

@ -0,0 +1,213 @@
package com.fsck.k9.preferences
import assertk.assertThat
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations
import com.fsck.k9.storage.K9RobolectricTest
import net.thunderbird.core.logging.testing.TestLogger
import net.thunderbird.core.preference.storage.InMemoryStorage
import net.thunderbird.core.preference.storage.Storage
import net.thunderbird.core.preference.storage.StorageUpdater
import org.junit.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.mock
import org.mockito.kotlin.stubbing
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoMoreInteractions
class DefaultStorageEditorTest : K9RobolectricTest() {
private val logger = TestLogger()
private val storage: Storage =
InMemoryStorage(mapOf("storage-key" to "storage-value"), logger)
private val storageUpdater = TestStorageUpdater(storage)
private val storagePersister = mock<K9StoragePersister>()
private val storagePersisterOps = mock<StoragePersistOperations>()
private val editor = K9StorageEditor(storageUpdater, storagePersister, logger)
private val workingMap = mutableMapOf<String, String>()
private val newValues: Map<String, String>
get() = storageUpdater.newStorage!!.getAll()
@Test
fun commit_exception() {
stubbing(storagePersister) {
on { doInTransaction(any()) } doThrow RuntimeException()
}
val success = editor.commit()
assertThat(success).isFalse()
}
@Test
fun commit_trivial() {
prepareStoragePersisterMock()
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEqualTo(storage.getAll())
verifyNoMoreInteractions(storagePersisterOps)
}
@Test
fun putBoolean() {
prepareStoragePersisterMock()
editor.putBoolean("x", true)
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "true"))
verify(storagePersisterOps).put("x", "true")
verifyNoMoreInteractions(storagePersisterOps)
}
@Test
fun putInt() {
prepareStoragePersisterMock()
editor.putInt("x", 123)
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "123"))
verify(storagePersisterOps).put("x", "123")
verifyNoMoreInteractions(storagePersisterOps)
}
@Test
fun putLong() {
prepareStoragePersisterMock()
editor.putLong("x", 1234)
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "1234"))
verify(storagePersisterOps).put("x", "1234")
verifyNoMoreInteractions(storagePersisterOps)
}
@Test
fun putString() {
prepareStoragePersisterMock()
editor.putString("x", "y")
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "y"))
verify(storagePersisterOps).put("x", "y")
verifyNoMoreInteractions(storagePersisterOps)
}
@Test
fun putString_duplicateSame() {
prepareStoragePersisterMock()
editor.putString("storage-key", "storage-value")
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value"))
verifyNoMoreInteractions(storagePersisterOps)
}
@Test
fun putString_duplicateOther() {
prepareStoragePersisterMock()
editor.putString("storage-key", "other-value")
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEqualTo(mapOf("storage-key" to "other-value"))
verify(storagePersisterOps).put("storage-key", "other-value")
verifyNoMoreInteractions(storagePersisterOps)
}
@Test
fun putString_removedDuplicate() {
prepareStoragePersisterMock()
editor.remove("storage-key")
editor.putString("storage-key", "storage-value")
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value"))
verify(storagePersisterOps).remove("storage-key")
verify(storagePersisterOps).put("storage-key", "storage-value")
verifyNoMoreInteractions(storagePersisterOps)
}
@Test
fun `remove key that doesn't exist`() {
prepareStoragePersisterMock()
editor.remove("x")
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value"))
verify(storagePersisterOps).remove("x")
verifyNoMoreInteractions(storagePersisterOps)
}
@Test
fun remove() {
prepareStoragePersisterMock()
editor.remove("storage-key")
val success = editor.commit()
assertThat(success).isTrue()
assertThat(newValues).isEmpty()
verify(storagePersisterOps).remove("storage-key")
verifyNoMoreInteractions(storagePersisterOps)
}
private fun prepareStoragePersisterMock() {
stubbing(storagePersisterOps) {
on { put(any(), any()) } doAnswer {
val key = it.getArgument<String>(0)
val value = it.getArgument<String>(1)
workingMap[key] = value
}
on { remove(any()) } doAnswer {
val key = it.getArgument<String>(0)
workingMap.remove(key)
return@doAnswer
}
}
stubbing(storagePersister) {
on { doInTransaction(any()) } doAnswer {
val operationCallback = it.getArgument<StoragePersistOperationCallback>(0)
operationCallback.beforePersistTransaction(workingMap)
operationCallback.persist(storagePersisterOps)
operationCallback.onPersistTransactionSuccess(workingMap)
}
}
}
}
class TestStorageUpdater(private val currentStorage: Storage) : StorageUpdater {
var newStorage: Storage? = null
override fun updateStorage(updater: (currentStorage: Storage) -> Storage) {
newStorage = updater(currentStorage)
}
}

Some files were not shown because too many files have changed in this diff Show more