Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 18:55:42 +01:00
parent a629de6271
commit 3cef7c5092
2161 changed files with 246605 additions and 2 deletions

View file

@ -0,0 +1,114 @@
package com.fsck.k9.preferences;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import android.os.SystemClock;
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback;
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations;
import timber.log.Timber;
public class K9StorageEditor implements StorageEditor {
private StorageUpdater storageUpdater;
private K9StoragePersister storagePersister;
private Map<String, String> changes = new HashMap<>();
private List<String> removals = new ArrayList<>();
public K9StorageEditor(StorageUpdater storageUpdater, K9StoragePersister storagePersister) {
this.storageUpdater = storageUpdater;
this.storagePersister = storagePersister;
}
@Override
public boolean commit() {
try {
storageUpdater.updateStorage(this::commitChanges);
return true;
} catch (Exception e) {
Timber.e(e, "Failed to save preferences");
return false;
}
}
private Storage commitChanges(Storage storage) {
long startTime = SystemClock.elapsedRealtime();
Timber.i("Committing preference changes");
Map<String, String> newValues = new HashMap<>();
Map<String, String> oldValues = storage.getAll();
StoragePersistOperationCallback committer = new StoragePersistOperationCallback() {
@Override
public void beforePersistTransaction(Map<String, String> workingStorage) {
workingStorage.putAll(oldValues);
}
@Override
public void persist(StoragePersistOperations ops) {
for (String removeKey : removals) {
ops.remove(removeKey);
}
for (Entry<String, String> entry : changes.entrySet()) {
String key = entry.getKey();
String newValue = entry.getValue();
String oldValue = oldValues.get(key);
if (removals.contains(key) || !newValue.equals(oldValue)) {
ops.put(key, newValue);
}
}
}
@Override
public void onPersistTransactionSuccess(Map<String, String> workingStorage) {
newValues.putAll(workingStorage);
}
};
storagePersister.doInTransaction(committer);
long endTime = SystemClock.elapsedRealtime();
Timber.i("Preferences commit took %d ms", endTime - startTime);
return new Storage(newValues);
}
@Override
public StorageEditor putBoolean(String key,
boolean value) {
changes.put(key, "" + value);
return this;
}
@Override
public StorageEditor putInt(String key, int value) {
changes.put(key, "" + value);
return this;
}
@Override
public StorageEditor putLong(String key, long value) {
changes.put(key, "" + value);
return this;
}
@Override
public StorageEditor putString(String key, String value) {
if (value == null) {
remove(key);
} else {
changes.put(key, value);
}
return this;
}
@Override
public StorageEditor remove(String key) {
removals.add(key);
return this;
}
}

View file

@ -0,0 +1,251 @@
package com.fsck.k9.preferences;
import java.util.HashMap;
import java.util.Map;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteStatement;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import com.fsck.k9.helper.Utility;
import com.fsck.k9.preferences.migrations.StorageMigrations;
import com.fsck.k9.preferences.migrations.StorageMigrationsHelper;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import timber.log.Timber;
public class K9StoragePersister implements StoragePersister {
private static final int DB_VERSION = 19;
private static final String DB_NAME = "preferences_storage";
private final Context context;
public K9StoragePersister(Context context) {
this.context = context;
}
private SQLiteDatabase openDB() {
SQLiteDatabase db = context.openOrCreateDatabase(DB_NAME, Context.MODE_PRIVATE, null);
db.beginTransaction();
try {
if (db.getVersion() > DB_VERSION) {
throw new AssertionError("Database downgrades are not supported. " +
"Please fix the database '" + DB_NAME + "' manually or clear app data.");
}
if (db.getVersion() < 1) {
createStorageDatabase(db);
} else {
StorageMigrations.upgradeDatabase(db, migrationsHelper);
}
db.setVersion(DB_VERSION);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (db.getVersion() != DB_VERSION) {
throw new RuntimeException("Storage database upgrade failed!");
}
return db;
}
private void createStorageDatabase(SQLiteDatabase db) {
Timber.i("Creating Storage database");
db.execSQL("DROP TABLE IF EXISTS preferences_storage");
db.execSQL("CREATE TABLE preferences_storage " +
"(primkey TEXT PRIMARY KEY ON CONFLICT REPLACE, value TEXT)");
db.setVersion(DB_VERSION);
}
void doInTransaction(StoragePersistOperationCallback operationCallback) {
HashMap<String, String> workingStorage = new HashMap<>();
SQLiteDatabase workingDb = openDB();
try {
operationCallback.beforePersistTransaction(workingStorage);
StoragePersistOperations storagePersistOperations = new StoragePersistOperations(workingStorage, workingDb);
workingDb.beginTransaction();
operationCallback.persist(storagePersistOperations);
storagePersistOperations.close();
workingDb.setTransactionSuccessful();
operationCallback.onPersistTransactionSuccess(workingStorage);
} finally {
workingDb.endTransaction();
workingDb.close();
}
}
@NonNull
@Override
public StorageEditor createStorageEditor(@NonNull StorageUpdater storageUpdater) {
return new K9StorageEditor(storageUpdater, this);
}
static class StoragePersistOperations {
private Map<String, String> workingStorage;
private final SQLiteStatement deleteStatement;
private final SQLiteStatement insertStatement;
private StoragePersistOperations(Map<String, String> workingStorage, SQLiteDatabase database) {
this.workingStorage = workingStorage;
insertStatement = database.compileStatement(
"INSERT INTO preferences_storage (primkey, value) VALUES (?, ?)");
deleteStatement = database.compileStatement(
"DELETE FROM preferences_storage WHERE primkey = ?");
}
void put(String key, String value) {
insertStatement.bindString(1, key);
insertStatement.bindString(2, value);
insertStatement.execute();
insertStatement.clearBindings();
workingStorage.put(key, value);
}
void remove(String key) {
deleteStatement.bindString(1, key);
deleteStatement.executeUpdateDelete();
deleteStatement.clearBindings();
workingStorage.remove(key);
}
private void close() {
insertStatement.close();
deleteStatement.close();
}
}
interface StoragePersistOperationCallback {
void beforePersistTransaction(Map<String, String> workingStorage);
void persist(StoragePersistOperations ops);
void onPersistTransactionSuccess(Map<String, String> workingStorage);
}
@NonNull
@Override
public Storage loadValues() {
long startTime = SystemClock.elapsedRealtime();
Timber.i("Loading preferences from DB into Storage");
try (SQLiteDatabase database = openDB()) {
return new Storage(readAllValues(database));
} finally {
long endTime = SystemClock.elapsedRealtime();
Timber.i("Preferences load took %d ms", endTime - startTime);
}
}
private Map<String, String> readAllValues(SQLiteDatabase database) {
HashMap<String, String> loadedValues = new HashMap<>();
Cursor cursor = null;
try {
cursor = database.rawQuery("SELECT primkey, value FROM preferences_storage", null);
while (cursor.moveToNext()) {
String key = cursor.getString(0);
String value = cursor.getString(1);
Timber.d("Loading key '%s', value = '%s'", key, value);
loadedValues.put(key, value);
}
} finally {
Utility.closeQuietly(cursor);
}
return loadedValues;
}
private String readValue(SQLiteDatabase mDb, String key) {
Cursor cursor = null;
String value = null;
try {
cursor = mDb.query(
"preferences_storage",
new String[] {"value"},
"primkey = ?",
new String[] {key},
null,
null,
null);
if (cursor.moveToNext()) {
value = cursor.getString(0);
Timber.d("Loading key '%s', value = '%s'", key, value);
}
} finally {
Utility.closeQuietly(cursor);
}
return value;
}
private void writeValue(SQLiteDatabase mDb, String key, String value) {
if (value == null) {
mDb.delete("preferences_storage", "primkey = ?", new String[] { key });
return;
}
ContentValues cv = new ContentValues();
cv.put("primkey", key);
cv.put("value", value);
long result = mDb.update("preferences_storage", cv, "primkey = ?", new String[] { key });
if (result == -1) {
Timber.e("Error writing key '%s', value = '%s'", key, value);
}
}
private void insertValue(SQLiteDatabase mDb, String key, String value) {
if (value == null) {
return;
}
ContentValues cv = new ContentValues();
cv.put("primkey", key);
cv.put("value", value);
long result = mDb.insert("preferences_storage", null, cv);
if (result == -1) {
Timber.e("Error writing key '%s', value = '%s'", key, value);
}
}
private StorageMigrationsHelper migrationsHelper = new StorageMigrationsHelper() {
@Override
public void writeValue(@NotNull SQLiteDatabase db, @NotNull String key, String value) {
K9StoragePersister.this.writeValue(db, key, value);
}
@NotNull
@Override
public Map<String, String> readAllValues(@NotNull SQLiteDatabase db) {
return K9StoragePersister.this.readAllValues(db);
}
@Override
public String readValue(@NotNull SQLiteDatabase db, @NotNull String key) {
return K9StoragePersister.this.readValue(db, key);
}
@Override
public void insertValue(@NotNull SQLiteDatabase db, @NotNull String key, @Nullable String value) {
K9StoragePersister.this.insertValue(db, key, value);
}
};
}

View file

@ -0,0 +1,37 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Remove saved folder settings
*
* Folder settings are now only written to 'Storage' when settings are imported. The saved settings will be used when
* folders are first created and then the saved settings are discarded.
* Since this wasn't the procedure in earlier app versions, we remove any saved folder settings in this migration.
*/
class StorageMigrationTo10(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
private val folderKeyPattern = Regex("[^.]+\\..+\\.([^.]+)")
private val folderSettingKeys = setOf(
"displayMode",
"notifyMode",
"syncMode",
"pushMode",
"inTopGroup",
"integrate"
)
fun removeSavedFolderSettings() {
val loadedValues = migrationsHelper.readAllValues(db)
for (key in loadedValues.keys) {
val matches = folderKeyPattern.matchEntire(key) ?: continue
val folderKey = matches.groupValues[1]
if (folderKey in folderSettingKeys) {
migrationsHelper.writeValue(db, key, null)
}
}
}
}

View file

@ -0,0 +1,26 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.preferences.GeneralSettingsDescriptions
/**
* Convert old value for message view content font size to new format.
*
* This change in formats has been made a long time ago. But never in a migration. So it's possible there are still
* installations out there that have the old version in the database. And they would work just fine, because this
* conversion was done when loading font size values.
*/
class StorageMigrationTo11(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun upgradeMessageViewContentFontSize() {
val newFontSizeValue = migrationsHelper.readValue(db, "fontSizeMessageViewContentPercent")
if (newFontSizeValue != null) return
val oldFontSizeValue = migrationsHelper.readValue(db, "fontSizeMessageViewContent")?.toIntOrNull() ?: 3
val fontSizeValue = GeneralSettingsDescriptions.SettingsUpgraderV31.convertFromOldSize(oldFontSizeValue)
migrationsHelper.writeValue(db, "fontSizeMessageViewContentPercent", fontSizeValue.toString())
migrationsHelper.writeValue(db, "fontSizeMessageViewContent", null)
}
}

View file

@ -0,0 +1,65 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.ServerSettingsSerializer
import com.fsck.k9.mail.filter.Base64
import com.fsck.k9.preferences.migrations.migration12.ImapStoreUriDecoder
import com.fsck.k9.preferences.migrations.migration12.Pop3StoreUriDecoder
import com.fsck.k9.preferences.migrations.migration12.SmtpTransportUriDecoder
import com.fsck.k9.preferences.migrations.migration12.WebDavStoreUriDecoder
/**
* Convert server settings from the old URI format to the new JSON format
*/
class StorageMigrationTo12(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
private val serverSettingsSerializer = ServerSettingsSerializer()
fun removeStoreAndTransportUri() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
convertStoreUri(accountUuid)
convertTransportUri(accountUuid)
}
}
private fun convertStoreUri(accountUuid: String) {
val storeUri = migrationsHelper.readValue(db, "$accountUuid.storeUri")?.base64Decode() ?: return
val serverSettings = when {
storeUri.startsWith("imap") -> ImapStoreUriDecoder.decode(storeUri)
storeUri.startsWith("pop3") -> Pop3StoreUriDecoder.decode(storeUri)
storeUri.startsWith("webdav") -> WebDavStoreUriDecoder.decode(storeUri)
else -> error("Unsupported account type")
}
val json = serverSettingsSerializer.serialize(serverSettings)
migrationsHelper.insertValue(db, "$accountUuid.incomingServerSettings", json)
migrationsHelper.writeValue(db, "$accountUuid.storeUri", null)
}
private fun convertTransportUri(accountUuid: String) {
val transportUri = migrationsHelper.readValue(db, "$accountUuid.transportUri")?.base64Decode() ?: return
val serverSettings = when {
transportUri.startsWith("smtp") -> SmtpTransportUriDecoder.decodeSmtpUri(transportUri)
transportUri.startsWith("webdav") -> WebDavStoreUriDecoder.decode(transportUri)
else -> error("Unsupported account type")
}
val json = serverSettingsSerializer.serialize(serverSettings)
migrationsHelper.insertValue(db, "$accountUuid.outgoingServerSettings", json)
migrationsHelper.writeValue(db, "$accountUuid.transportUri", null)
}
private fun String.base64Decode() = Base64.decode(this)
}

View file

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

View file

@ -0,0 +1,35 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.ServerSettingsSerializer
import com.fsck.k9.preferences.Protocols
/**
* Rewrite 'folderPushMode' value of non-IMAP accounts to 'NONE'.
*/
class StorageMigrationTo14(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
private val serverSettingsSerializer = ServerSettingsSerializer()
fun disablePushFoldersForNonImapAccounts() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
disablePushFolders(accountUuid)
}
}
private fun disablePushFolders(accountUuid: String) {
val json = migrationsHelper.readValue(db, "$accountUuid.incomingServerSettings") ?: return
val serverSettings = serverSettingsSerializer.deserialize(json)
if (serverSettings.type != Protocols.IMAP) {
migrationsHelper.writeValue(db, "$accountUuid.folderPushMode", "NONE")
}
}
}

View file

@ -0,0 +1,36 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
private const val DEFAULT_IDLE_REFRESH_MINUTES = 24
private const val MINIMUM_IDLE_REFRESH_MINUTES = 2
/**
* Rewrite 'idleRefreshMinutes' to make sure the minimum value is 2 minutes.
*/
class StorageMigrationTo15(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun rewriteIdleRefreshInterval() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
rewriteIdleRefreshInterval(accountUuid)
}
}
private fun rewriteIdleRefreshInterval(accountUuid: String) {
val idleRefreshMinutes = migrationsHelper.readValue(db, "$accountUuid.idleRefreshMinutes")?.toIntOrNull()
?: DEFAULT_IDLE_REFRESH_MINUTES
val newIdleRefreshMinutes = idleRefreshMinutes.coerceAtLeast(MINIMUM_IDLE_REFRESH_MINUTES)
if (newIdleRefreshMinutes != idleRefreshMinutes) {
migrationsHelper.writeValue(db, "$accountUuid.idleRefreshMinutes", newIdleRefreshMinutes.toString())
}
}
}

View file

@ -0,0 +1,18 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Change default value of `registeredNameColor` to have enough contrast in both the light and dark theme.
*/
class StorageMigrationTo16(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun changeDefaultRegisteredNameColor() {
val registeredNameColorValue = migrationsHelper.readValue(db, "registeredNameColor")?.toInt()
if (registeredNameColorValue == 0xFF00008F.toInt()) {
migrationsHelper.writeValue(db, "registeredNameColor", 0xFF1093F5.toInt().toString())
}
}
}

View file

@ -0,0 +1,54 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite 'led' and 'ledColor' values to 'notificationLight'.
*/
class StorageMigrationTo17(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun rewriteNotificationLightSettings() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
rewriteNotificationLightSettings(accountUuid)
}
}
private fun rewriteNotificationLightSettings(accountUuid: String) {
val isLedEnabled = migrationsHelper.readValue(db, "$accountUuid.led").toBoolean()
val ledColor = migrationsHelper.readValue(db, "$accountUuid.ledColor")?.toInt() ?: 0
val accountColor = migrationsHelper.readValue(db, "$accountUuid.chipColor")?.toInt() ?: 0
val notificationLight = convertToNotificationLightValue(isLedEnabled, ledColor, accountColor)
migrationsHelper.writeValue(db, "$accountUuid.notificationLight", notificationLight)
migrationsHelper.writeValue(db, "$accountUuid.led", null)
migrationsHelper.writeValue(db, "$accountUuid.ledColor", null)
}
private fun convertToNotificationLightValue(isLedEnabled: Boolean, ledColor: Int, accountColor: Int): String {
if (!isLedEnabled) return "Disabled"
return when (ledColor.rgb) {
accountColor.rgb -> "AccountColor"
0xFFFFFF -> "White"
0xFF0000 -> "Red"
0x00FF00 -> "Green"
0x0000FF -> "Blue"
0xFFFF00 -> "Yellow"
0x00FFFF -> "Cyan"
0xFF00FF -> "Magenta"
else -> "SystemDefaultColor"
}
}
private val Int.rgb
get() = this and 0x00FFFFFF
}

View file

@ -0,0 +1,36 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite the per-network type IMAP compression settings to a single setting.
*/
class StorageMigrationTo18(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun rewriteImapCompressionSettings() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
rewriteImapCompressionSetting(accountUuid)
}
}
private fun rewriteImapCompressionSetting(accountUuid: String) {
val useCompressionWifi = migrationsHelper.readValue(db, "$accountUuid.useCompression.WIFI").toBoolean()
val useCompressionMobile = migrationsHelper.readValue(db, "$accountUuid.useCompression.MOBILE").toBoolean()
val useCompressionOther = migrationsHelper.readValue(db, "$accountUuid.useCompression.OTHER").toBoolean()
val useCompression = useCompressionWifi && useCompressionMobile && useCompressionOther
migrationsHelper.writeValue(db, "$accountUuid.useCompression", useCompression.toString())
migrationsHelper.writeValue(db, "$accountUuid.useCompression.WIFI", null)
migrationsHelper.writeValue(db, "$accountUuid.useCompression.MOBILE", null)
migrationsHelper.writeValue(db, "$accountUuid.useCompression.OTHER", null)
}
}

View file

@ -0,0 +1,54 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
/**
* Mark Gmail accounts.
*
* Gmail has stopped allowing clients to use the Google account password to authenticate via IMAP/POP3/SMTP. We want to
* automatically update the server settings to use OAuth 2.0 for users who can no longer access Gmail because of the
* change. However, we don't want to touch accounts that are using an app-specific password and still work fine. Since
* we can't distinguish an app-specific password from an account password, we only switch accounts to using OAuth after
* an authentication failure. We usually want to avoid automatically changing the user's settings like this. So we only
* do it for existing accounts, and only after the first authentication failure.
*/
class StorageMigrationTo19(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun markGmailAccounts() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
markIfGmailAccount(accountUuid)
}
}
private fun markIfGmailAccount(accountUuid: String) {
val incomingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.incomingServerSettings") ?: return
val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
val moshi = Moshi.Builder().build()
val adapter = moshi.adapter<Map<String, Any?>>(
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)
)
val incomingServerSettings = adapter.fromJson(incomingServerSettingsJson) ?: return
val outgoingServerSettings = adapter.fromJson(outgoingServerSettingsJson) ?: return
if (incomingServerSettings["type"] == "imap" &&
incomingServerSettings["host"] in setOf("imap.gmail.com", "imap.googlemail.com") &&
incomingServerSettings["authenticationType"] != "XOAUTH2" ||
outgoingServerSettings["host"] in setOf("smtp.gmail.com", "smtp.googlemail.com") &&
outgoingServerSettings["authenticationType"] != "XOAUTH2"
) {
migrationsHelper.insertValue(db, "$accountUuid.migrateToOAuth", "true")
}
}
}

View file

@ -0,0 +1,100 @@
package com.fsck.k9.preferences.migrations;
import java.net.URI;
import android.database.sqlite.SQLiteDatabase;
import com.fsck.k9.helper.UrlEncodingHelper;
import com.fsck.k9.mail.filter.Base64;
import timber.log.Timber;
public class StorageMigrationTo2 {
public static void urlEncodeUserNameAndPassword(SQLiteDatabase db, StorageMigrationsHelper migrationsHelper) {
Timber.i("Updating preferences to urlencoded username/password");
String accountUuids = migrationsHelper.readValue(db, "accountUuids");
if (accountUuids != null && accountUuids.length() != 0) {
String[] uuids = accountUuids.split(",");
for (String uuid : uuids) {
try {
String storeUriStr = Base64.decode(migrationsHelper.readValue(db, uuid + ".storeUri"));
String transportUriStr = Base64.decode(migrationsHelper.readValue(db, uuid + ".transportUri"));
URI uri = new URI(transportUriStr);
String newUserInfo = null;
if (transportUriStr != null) {
String[] userInfoParts = uri.getUserInfo().split(":");
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
String passwordEnc = "";
String authType = "";
if (userInfoParts.length > 1) {
passwordEnc = ":" + UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
}
if (userInfoParts.length > 2) {
authType = ":" + userInfoParts[2];
}
newUserInfo = usernameEnc + passwordEnc + authType;
}
if (newUserInfo != null) {
URI newUri = new URI(uri.getScheme(), newUserInfo, uri.getHost(), uri.getPort(), uri.getPath(),
uri.getQuery(), uri.getFragment());
String newTransportUriStr = Base64.encode(newUri.toString());
migrationsHelper.writeValue(db, uuid + ".transportUri", newTransportUriStr);
}
uri = new URI(storeUriStr);
newUserInfo = null;
if (storeUriStr.startsWith("imap")) {
String[] userInfoParts = uri.getUserInfo().split(":");
if (userInfoParts.length == 2) {
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
String passwordEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
newUserInfo = usernameEnc + ":" + passwordEnc;
} else {
String authType = userInfoParts[0];
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
String passwordEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[2]);
newUserInfo = authType + ":" + usernameEnc + ":" + passwordEnc;
}
} else if (storeUriStr.startsWith("pop3")) {
String[] userInfoParts = uri.getUserInfo().split(":", 2);
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
String passwordEnc = "";
if (userInfoParts.length > 1) {
passwordEnc = ":" + UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
}
newUserInfo = usernameEnc + passwordEnc;
} else if (storeUriStr.startsWith("webdav")) {
String[] userInfoParts = uri.getUserInfo().split(":", 2);
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
String passwordEnc = "";
if (userInfoParts.length > 1) {
passwordEnc = ":" + UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
}
newUserInfo = usernameEnc + passwordEnc;
}
if (newUserInfo != null) {
URI newUri = new URI(uri.getScheme(), newUserInfo, uri.getHost(), uri.getPort(), uri.getPath(),
uri.getQuery(), uri.getFragment());
String newStoreUriStr = Base64.encode(newUri.toString());
migrationsHelper.writeValue(db, uuid + ".storeUri", newStoreUriStr);
}
} catch (Exception e) {
Timber.e(e, "ooops");
}
}
}
}
}

View file

@ -0,0 +1,44 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite folder name values of "-NONE-" to `null`
*/
class StorageMigrationTo3(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun rewriteFolderNone() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
rewriteAccount(accountUuid)
}
}
private fun rewriteAccount(accountUuid: String) {
rewriteFolderValue("$accountUuid.archiveFolderName")
rewriteFolderValue("$accountUuid.autoExpandFolderName")
rewriteFolderValue("$accountUuid.draftsFolderName")
rewriteFolderValue("$accountUuid.sentFolderName")
rewriteFolderValue("$accountUuid.spamFolderName")
rewriteFolderValue("$accountUuid.trashFolderName")
}
private fun rewriteFolderValue(key: String) {
val folderValue = migrationsHelper.readValue(db, key)
if (folderValue == OLD_FOLDER_VALUE) {
migrationsHelper.writeValue(db, key, NEW_FOLDER_VALUE)
}
}
companion object {
private const val OLD_FOLDER_VALUE = "-NONE-"
private val NEW_FOLDER_VALUE = null
}
}

View file

@ -0,0 +1,31 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Add `*FolderSelection` values of "MANUAL" for existing accounts (default for new accounts is "AUTOMATIC").
*/
class StorageMigrationTo4(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun insertSpecialFolderSelectionValues() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
insertSpecialFolderSelectionValues(accountUuid)
}
}
private fun insertSpecialFolderSelectionValues(accountUuid: String) {
migrationsHelper.insertValue(db, "$accountUuid.archiveFolderSelection", "MANUAL")
migrationsHelper.insertValue(db, "$accountUuid.draftsFolderSelection", "MANUAL")
migrationsHelper.insertValue(db, "$accountUuid.sentFolderSelection", "MANUAL")
migrationsHelper.insertValue(db, "$accountUuid.spamFolderSelection", "MANUAL")
migrationsHelper.insertValue(db, "$accountUuid.trashFolderSelection", "MANUAL")
}
}

View file

@ -0,0 +1,36 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite frequencies lower than LOWEST_FREQUENCY_SUPPORTED
*/
class StorageMigrationTo5(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun fixMailCheckFrequencies() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
fixFrequencyForAccount(accountUuid)
}
}
private fun fixFrequencyForAccount(accountUuid: String) {
val key = "$accountUuid.automaticCheckIntervalMinutes"
val frequencyValue = migrationsHelper.readValue(db, key)?.toIntOrNull()
if (frequencyValue != null && frequencyValue > -1 && frequencyValue < LOWEST_FREQUENCY_SUPPORTED) {
migrationsHelper.writeValue(db, key, LOWEST_FREQUENCY_SUPPORTED.toString())
}
}
companion object {
// see: https://github.com/evernote/android-job/wiki/FAQ#why-cant-an-interval-be-smaller-than-15-minutes-for-periodic-jobs
private const val LOWEST_FREQUENCY_SUPPORTED = 15
}
}

View file

@ -0,0 +1,54 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Perform legacy conversions that previously lived in `K9`.
*/
class StorageMigrationTo6(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun performLegacyMigrations() {
rewriteTheme()
migrateOpenPgpGlobalToAccountSettings()
}
private fun rewriteTheme() {
val theme = migrationsHelper.readValue(db, "theme")?.toInt()
// We used to save the resource ID of the theme. So convert that to the new format if necessary.
val newTheme = if (theme == THEME_ORDINAL_DARK || theme == android.R.style.Theme) {
THEME_ORDINAL_DARK
} else {
THEME_ORDINAL_LIGHT
}
migrationsHelper.writeValue(db, "theme", newTheme.toString())
}
private fun migrateOpenPgpGlobalToAccountSettings() {
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
return
}
val openPgpProvider = migrationsHelper.readValue(db, "openPgpProvider") ?: ""
val openPgpSupportSignOnly = migrationsHelper.readValue(db, "openPgpSupportSignOnly")?.toBoolean() ?: false
val openPgpHideSignOnly = (!openPgpSupportSignOnly).toString()
val accountUuids = accountUuidsListValue.split(",")
for (accountUuid in accountUuids) {
migrationsHelper.writeValue(db, "$accountUuid.openPgpProvider", openPgpProvider)
migrationsHelper.writeValue(db, "$accountUuid.openPgpHideSignOnly", openPgpHideSignOnly)
}
migrationsHelper.writeValue(db, "openPgpProvider", null)
migrationsHelper.writeValue(db, "openPgpSupportSignOnly", null)
}
companion object {
private const val THEME_ORDINAL_LIGHT = 0
private const val THEME_ORDINAL_DARK = 1
}
}

View file

@ -0,0 +1,54 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite settings to use enum names instead of ordinals.
*/
class StorageMigrationTo7(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun rewriteEnumOrdinalsToNames() {
rewriteTheme()
rewriteMessageViewTheme()
rewriteMessageComposeTheme()
}
private fun rewriteTheme() {
val theme = migrationsHelper.readValue(db, "theme")?.toInt()
val newTheme = if (theme == THEME_ORDINAL_DARK) {
THEME_DARK
} else {
THEME_LIGHT
}
migrationsHelper.writeValue(db, "theme", newTheme)
}
private fun rewriteMessageViewTheme() {
rewriteScreenTheme("messageViewTheme")
}
private fun rewriteMessageComposeTheme() {
rewriteScreenTheme("messageComposeTheme")
}
private fun rewriteScreenTheme(key: String) {
val newTheme = when (migrationsHelper.readValue(db, key)?.toInt()) {
THEME_ORDINAL_DARK -> THEME_DARK
THEME_ORDINAL_USE_GLOBAL -> THEME_USE_GLOBAL
else -> THEME_LIGHT
}
migrationsHelper.writeValue(db, key, newTheme)
}
companion object {
private const val THEME_ORDINAL_DARK = 1
private const val THEME_ORDINAL_USE_GLOBAL = 2
private const val THEME_LIGHT = "LIGHT"
private const val THEME_DARK = "DARK"
private const val THEME_USE_GLOBAL = "USE_GLOBAL"
}
}

View file

@ -0,0 +1,23 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
/**
* Rewrite theme setting to use `FOLLOW_SYSTEM` when it's currently set to `LIGHT`.
*/
class StorageMigrationTo8(
private val db: SQLiteDatabase,
private val migrationsHelper: StorageMigrationsHelper
) {
fun rewriteTheme() {
val theme = migrationsHelper.readValue(db, "theme")
if (theme == THEME_LIGHT) {
migrationsHelper.writeValue(db, "theme", THEME_FOLLOW_SYSTEM)
}
}
companion object {
private const val THEME_LIGHT = "LIGHT"
private const val THEME_FOLLOW_SYSTEM = "FOLLOW_SYSTEM"
}
}

View file

@ -0,0 +1,29 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
internal object StorageMigrations {
@JvmStatic
fun upgradeDatabase(db: SQLiteDatabase, migrationsHelper: StorageMigrationsHelper) {
val oldVersion = db.version
if (oldVersion < 2) StorageMigrationTo2.urlEncodeUserNameAndPassword(db, migrationsHelper)
if (oldVersion < 3) StorageMigrationTo3(db, migrationsHelper).rewriteFolderNone()
if (oldVersion < 4) StorageMigrationTo4(db, migrationsHelper).insertSpecialFolderSelectionValues()
if (oldVersion < 5) StorageMigrationTo5(db, migrationsHelper).fixMailCheckFrequencies()
if (oldVersion < 6) StorageMigrationTo6(db, migrationsHelper).performLegacyMigrations()
if (oldVersion < 7) StorageMigrationTo7(db, migrationsHelper).rewriteEnumOrdinalsToNames()
if (oldVersion < 8) StorageMigrationTo8(db, migrationsHelper).rewriteTheme()
// 9: "Temporarily disable Push" is no longer necessary
if (oldVersion < 10) StorageMigrationTo10(db, migrationsHelper).removeSavedFolderSettings()
if (oldVersion < 11) StorageMigrationTo11(db, migrationsHelper).upgradeMessageViewContentFontSize()
if (oldVersion < 12) StorageMigrationTo12(db, migrationsHelper).removeStoreAndTransportUri()
if (oldVersion < 13) StorageMigrationTo13(db, migrationsHelper).renameHideSpecialAccounts()
if (oldVersion < 14) StorageMigrationTo14(db, migrationsHelper).disablePushFoldersForNonImapAccounts()
if (oldVersion < 15) StorageMigrationTo15(db, migrationsHelper).rewriteIdleRefreshInterval()
if (oldVersion < 16) StorageMigrationTo16(db, migrationsHelper).changeDefaultRegisteredNameColor()
if (oldVersion < 17) StorageMigrationTo17(db, migrationsHelper).rewriteNotificationLightSettings()
if (oldVersion < 18) StorageMigrationTo18(db, migrationsHelper).rewriteImapCompressionSettings()
if (oldVersion < 19) StorageMigrationTo19(db, migrationsHelper).markGmailAccounts()
}
}

View file

@ -0,0 +1,10 @@
package com.fsck.k9.preferences.migrations
import android.database.sqlite.SQLiteDatabase
interface StorageMigrationsHelper {
fun readAllValues(db: SQLiteDatabase): Map<String, String>
fun readValue(db: SQLiteDatabase, key: String): String?
fun writeValue(db: SQLiteDatabase, key: String, value: String?)
fun insertValue(db: SQLiteDatabase, key: String, value: String?)
}

View file

@ -0,0 +1,144 @@
package com.fsck.k9.preferences.migrations.migration12;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import com.fsck.k9.mail.AuthType;
import com.fsck.k9.mail.ConnectionSecurity;
import com.fsck.k9.mail.ServerSettings;
import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8;
public class ImapStoreUriDecoder {
private static final int DEFAULT_PORT = 143;
private static final int DEFAULT_TLS_PORT = 993;
/**
* Decodes an ImapStore URI.
*
* <p>Possible forms:</p>
* <pre>
* imap://auth:user:password@server:port ConnectionSecurity.NONE
* imap+tls+://auth:user:password@server:port ConnectionSecurity.STARTTLS_REQUIRED
* imap+ssl+://auth:user:password@server:port ConnectionSecurity.SSL_TLS_REQUIRED
* </pre>
*
* NOTE: this method expects the userinfo part of the URI to be encoded twice, due to a bug in the URI creation
* code.
*
* @param uri the store uri.
*/
public static ServerSettings decode(String uri) {
String host;
int port;
ConnectionSecurity connectionSecurity;
AuthType authenticationType = AuthType.PLAIN;
String username = "";
String password = null;
String clientCertificateAlias = null;
String pathPrefix = null;
boolean autoDetectNamespace = true;
URI imapUri;
try {
imapUri = new URI(uri);
} catch (URISyntaxException use) {
throw new IllegalArgumentException("Invalid ImapStore URI", use);
}
String scheme = imapUri.getScheme();
/*
* Currently available schemes are:
* imap
* imap+tls+
* imap+ssl+
*
* The following are obsolete schemes that may be found in pre-existing
* settings from earlier versions or that may be found when imported. We
* continue to recognize them and re-map them appropriately:
* imap+tls
* imap+ssl
*/
if (scheme.equals("imap")) {
connectionSecurity = ConnectionSecurity.NONE;
port = DEFAULT_PORT;
} else if (scheme.startsWith("imap+tls")) {
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED;
port = DEFAULT_PORT;
} else if (scheme.startsWith("imap+ssl")) {
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
port = DEFAULT_TLS_PORT;
} else {
throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
}
host = imapUri.getHost();
if (imapUri.getPort() != -1) {
port = imapUri.getPort();
}
if (imapUri.getUserInfo() != null) {
String userinfo = imapUri.getUserInfo();
String[] userInfoParts = userinfo.split(":");
if (userinfo.endsWith(":")) {
// Last field (password/certAlias) is empty.
// For imports e.g.: PLAIN:username: or username:
// Or XOAUTH2 where it's a valid config - XOAUTH:username:
if (userInfoParts.length > 1) {
authenticationType = AuthType.valueOf(userInfoParts[0]);
username = decodeUtf8(userInfoParts[1]);
} else {
username = decodeUtf8(userInfoParts[0]);
}
} else if (userInfoParts.length == 2) {
// Old/standard style of encoding - PLAIN auth only:
// username:password
username = decodeUtf8(userInfoParts[0]);
password = decodeUtf8(userInfoParts[1]);
} else if (userInfoParts.length == 3) {
// Standard encoding
// PLAIN:username:password
// EXTERNAL:username:certAlias
authenticationType = AuthType.valueOf(userInfoParts[0]);
username = decodeUtf8(userInfoParts[1]);
if (AuthType.EXTERNAL == authenticationType) {
clientCertificateAlias = decodeUtf8(userInfoParts[2]);
} else {
password = decodeUtf8(userInfoParts[2]);
}
}
}
String path = imapUri.getPath();
if (path != null && path.length() > 1) {
// Strip off the leading "/"
String cleanPath = path.substring(1);
if (cleanPath.length() >= 2 && cleanPath.charAt(1) == '|') {
autoDetectNamespace = cleanPath.charAt(0) == '1';
if (!autoDetectNamespace) {
pathPrefix = cleanPath.substring(2);
}
} else {
if (cleanPath.length() > 0) {
pathPrefix = cleanPath;
autoDetectNamespace = false;
}
}
}
Map<String, String> extra = new HashMap<>();
extra.put("autoDetectNamespace", Boolean.toString(autoDetectNamespace));
extra.put("pathPrefix", pathPrefix);
return new ServerSettings("imap", host, port, connectionSecurity, authenticationType, username,
password, clientCertificateAlias, extra);
}
}

View file

@ -0,0 +1,106 @@
package com.fsck.k9.preferences.migrations.migration12;
import java.net.URI;
import java.net.URISyntaxException;
import com.fsck.k9.mail.AuthType;
import com.fsck.k9.mail.ConnectionSecurity;
import com.fsck.k9.mail.ServerSettings;
import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8;
public class Pop3StoreUriDecoder {
private static final int DEFAULT_PORT = 110;
private static final int DEFAULT_TLS_PORT = 995;
/**
* Decodes a Pop3Store URI.
*
* <p>Possible forms:</p>
* <pre>
* pop3://authType:user:password@server:port
* ConnectionSecurity.NONE
* pop3+tls+://authType:user:password@server:port
* ConnectionSecurity.STARTTLS_REQUIRED
* pop3+ssl+://authType:user:password@server:port
* ConnectionSecurity.SSL_TLS_REQUIRED
* </pre>
*
* e.g.
* <pre>pop3://PLAIN:admin:pass123@example.org:12345</pre>
*/
public static ServerSettings decode(String uri) {
String host;
int port;
ConnectionSecurity connectionSecurity;
String username = "";
String password = null;
String clientCertificateAlias = null;
URI pop3Uri;
try {
pop3Uri = new URI(uri);
} catch (URISyntaxException use) {
throw new IllegalArgumentException("Invalid Pop3Store URI", use);
}
String scheme = pop3Uri.getScheme();
/*
* Currently available schemes are:
* pop3
* pop3+tls+
* pop3+ssl+
*
* The following are obsolete schemes that may be found in pre-existing
* settings from earlier versions or that may be found when imported. We
* continue to recognize them and re-map them appropriately:
* pop3+tls
* pop3+ssl
*/
if (scheme.equals("pop3")) {
connectionSecurity = ConnectionSecurity.NONE;
port = DEFAULT_PORT;
} else if (scheme.startsWith("pop3+tls")) {
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED;
port = DEFAULT_PORT;
} else if (scheme.startsWith("pop3+ssl")) {
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
port = DEFAULT_TLS_PORT;
} else {
throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
}
host = pop3Uri.getHost();
if (pop3Uri.getPort() != -1) {
port = pop3Uri.getPort();
}
AuthType authType = AuthType.PLAIN;
if (pop3Uri.getUserInfo() != null) {
int userIndex = 0, passwordIndex = 1;
String userinfo = pop3Uri.getUserInfo();
String[] userInfoParts = userinfo.split(":");
if (userInfoParts.length > 2 || userinfo.endsWith(":") ) {
// If 'userinfo' ends with ":" the password is empty. This can only happen
// after an account was imported (so authType and username are present).
userIndex++;
passwordIndex++;
authType = AuthType.valueOf(userInfoParts[0]);
}
username = decodeUtf8(userInfoParts[userIndex]);
if (userInfoParts.length > passwordIndex) {
if (authType == AuthType.EXTERNAL) {
clientCertificateAlias = decodeUtf8(userInfoParts[passwordIndex]);
} else {
password = decodeUtf8(userInfoParts[passwordIndex]);
}
}
}
return new ServerSettings("pop3", host, port, connectionSecurity, authType, username,
password, clientCertificateAlias);
}
}

View file

@ -0,0 +1,108 @@
package com.fsck.k9.preferences.migrations.migration12;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import com.fsck.k9.mail.AuthType;
import com.fsck.k9.mail.ConnectionSecurity;
import com.fsck.k9.mail.ServerSettings;
public class SmtpTransportUriDecoder {
private static final int DEFAULT_PORT = 587;
private static final int DEFAULT_TLS_PORT = 465;
/**
* Decodes a SmtpTransport URI.
*
* NOTE: In contrast to ImapStore and Pop3Store, the authType is appended at the end!
*
* <p>Possible forms:</p>
* <pre>
* smtp://user:password:auth@server:port ConnectionSecurity.NONE
* smtp+tls+://user:password:auth@server:port ConnectionSecurity.STARTTLS_REQUIRED
* smtp+ssl+://user:password:auth@server:port ConnectionSecurity.SSL_TLS_REQUIRED
* </pre>
*/
public static ServerSettings decodeSmtpUri(String uri) {
String host;
int port;
ConnectionSecurity connectionSecurity;
AuthType authType = AuthType.PLAIN;
String username = "";
String password = null;
String clientCertificateAlias = null;
URI smtpUri;
try {
smtpUri = new URI(uri);
} catch (URISyntaxException use) {
throw new IllegalArgumentException("Invalid SmtpTransport URI", use);
}
String scheme = smtpUri.getScheme();
/*
* Currently available schemes are:
* smtp
* smtp+tls+
* smtp+ssl+
*
* The following are obsolete schemes that may be found in pre-existing
* settings from earlier versions or that may be found when imported. We
* continue to recognize them and re-map them appropriately:
* smtp+tls
* smtp+ssl
*/
if (scheme.equals("smtp")) {
connectionSecurity = ConnectionSecurity.NONE;
port = DEFAULT_PORT;
} else if (scheme.startsWith("smtp+tls")) {
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED;
port = DEFAULT_PORT;
} else if (scheme.startsWith("smtp+ssl")) {
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
port = DEFAULT_TLS_PORT;
} else {
throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
}
host = smtpUri.getHost();
if (smtpUri.getPort() != -1) {
port = smtpUri.getPort();
}
if (smtpUri.getUserInfo() != null) {
String[] userInfoParts = smtpUri.getUserInfo().split(":");
if (userInfoParts.length == 1) {
username = decodeUtf8(userInfoParts[0]);
} else if (userInfoParts.length == 2) {
username = decodeUtf8(userInfoParts[0]);
password = decodeUtf8(userInfoParts[1]);
} else if (userInfoParts.length == 3) {
// NOTE: In SmtpTransport URIs, the authType comes last!
authType = AuthType.valueOf(userInfoParts[2]);
username = decodeUtf8(userInfoParts[0]);
if (authType == AuthType.EXTERNAL) {
clientCertificateAlias = decodeUtf8(userInfoParts[1]);
} else {
password = decodeUtf8(userInfoParts[1]);
}
}
}
return new ServerSettings("smtp", host, port, connectionSecurity,
authType, username, password, clientCertificateAlias);
}
private static String decodeUtf8(String s) {
try {
return URLDecoder.decode(s, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("UTF-8 not found");
}
}
}

View file

@ -0,0 +1,120 @@
package com.fsck.k9.preferences.migrations.migration12;
import com.fsck.k9.mail.AuthType;
import com.fsck.k9.mail.ConnectionSecurity;
import com.fsck.k9.mail.ServerSettings;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8;
public class WebDavStoreUriDecoder {
/**
* Decodes a WebDavStore URI.
* <p/>
* <p>Possible forms:</p>
* <pre>
* webdav://user:password@server:port ConnectionSecurity.NONE
* webdav+ssl+://user:password@server:port ConnectionSecurity.SSL_TLS_REQUIRED
* </pre>
*/
public static ServerSettings decode(String uri) {
String host;
int port;
ConnectionSecurity connectionSecurity;
String username = null;
String password = null;
String alias = null;
String path = null;
String authPath = null;
String mailboxPath = null;
URI webDavUri;
try {
webDavUri = new URI(uri);
} catch (URISyntaxException use) {
throw new IllegalArgumentException("Invalid WebDavStore URI", use);
}
String scheme = webDavUri.getScheme();
/*
* Currently available schemes are:
* webdav
* webdav+ssl+
*
* The following are obsolete schemes that may be found in pre-existing
* settings from earlier versions or that may be found when imported. We
* continue to recognize them and re-map them appropriately:
* webdav+tls
* webdav+tls+
* webdav+ssl
*/
if (scheme.equals("webdav")) {
connectionSecurity = ConnectionSecurity.NONE;
} else if (scheme.startsWith("webdav+")) {
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
} else {
throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
}
host = webDavUri.getHost();
if (host.startsWith("http")) {
String[] hostParts = host.split("://", 2);
if (hostParts.length > 1) {
host = hostParts[1];
}
}
port = webDavUri.getPort();
String userInfo = webDavUri.getUserInfo();
if (userInfo != null) {
String[] userInfoParts = userInfo.split(":");
username = decodeUtf8(userInfoParts[0]);
String userParts[] = username.split("\\\\", 2);
if (userParts.length > 1) {
alias = userParts[1];
} else {
alias = username;
}
if (userInfoParts.length > 1) {
password = decodeUtf8(userInfoParts[1]);
}
}
String[] pathParts = webDavUri.getPath().split("\\|");
for (int i = 0, count = pathParts.length; i < count; i++) {
if (i == 0) {
if (pathParts[0] != null &&
pathParts[0].length() > 1) {
path = pathParts[0];
}
} else if (i == 1) {
if (pathParts[1] != null &&
pathParts[1].length() > 1) {
authPath = pathParts[1];
}
} else if (i == 2) {
if (pathParts[2] != null &&
pathParts[2].length() > 1) {
mailboxPath = pathParts[2];
}
}
}
Map<String, String> extra = new HashMap<>();
extra.put("alias", alias);
extra.put("path", path);
extra.put("authPath", authPath);
extra.put("mailboxPath", mailboxPath);
return new ServerSettings("webdav", host, port, connectionSecurity, AuthType.PLAIN, username, password, null, extra);
}
}

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,18 @@
package com.fsck.k9.storage
import com.fsck.k9.mailstore.MessageStoreFactory
import com.fsck.k9.mailstore.SchemaDefinitionFactory
import com.fsck.k9.notification.NotificationStoreProvider
import com.fsck.k9.storage.messages.K9MessageStoreFactory
import com.fsck.k9.storage.notifications.K9NotificationStoreProvider
import org.koin.dsl.module
val storageModule = module {
single<SchemaDefinitionFactory> { K9SchemaDefinitionFactory() }
single<MessageStoreFactory> {
K9MessageStoreFactory(localStoreProvider = get(), storageManager = get(), basicPartInfoExtractor = get())
}
single<NotificationStoreProvider> {
K9NotificationStoreProvider(localStoreProvider = get())
}
}

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.mail.FolderClass;
import com.fsck.k9.mailstore.LockableDatabase.SchemaDefinition;
import com.fsck.k9.mailstore.MigrationsHelper;
import com.fsck.k9.storage.migrations.Migrations;
import timber.log.Timber;
class StoreSchemaDefinition implements SchemaDefinition {
static final int DB_VERSION = 84;
private final MigrationsHelper migrationsHelper;
StoreSchemaDefinition(MigrationsHelper migrationsHelper) {
this.migrationsHelper = migrationsHelper;
}
@Override
public int getVersion() {
return DB_VERSION;
}
@Override
public void doDbUpgrade(final SQLiteDatabase db) {
try {
upgradeDatabase(db);
} catch (Exception e) {
if (K9.DEVELOPER_MODE) {
throw new Error("Exception while upgrading database", e);
}
Timber.e(e, "Exception while upgrading database. Resetting the DB to v0");
db.setVersion(0);
upgradeDatabase(db);
}
}
private void upgradeDatabase(final SQLiteDatabase db) {
Timber.i("Upgrading database from version %d to version %d", db.getVersion(), DB_VERSION);
db.beginTransaction();
try {
if (db.getVersion() > DB_VERSION) {
String accountUuid = migrationsHelper.getAccount().getUuid();
throw new AssertionError("Database downgrades are not supported. " +
"Please fix the account database '" + accountUuid + "' manually or " +
"clear app data.");
}
// We only support upgrades from K-9 Mail 5.301. For upgrades from earlier versions we start from scratch.
if (db.getVersion() < 61) {
dbCreateDatabaseFromScratch(db);
} else {
Migrations.upgradeDatabase(db, migrationsHelper);
}
db.setVersion(DB_VERSION);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (db.getVersion() != DB_VERSION) {
throw new RuntimeException("Database upgrade failed!");
}
}
private static void dbCreateDatabaseFromScratch(SQLiteDatabase db) {
db.execSQL("DROP TABLE IF EXISTS account_extra_values");
db.execSQL("CREATE TABLE account_extra_values (" +
"name TEXT NOT NULL PRIMARY KEY, " +
"value_text TEXT, " +
"value_integer INTEGER " +
")");
db.execSQL("DROP TABLE IF EXISTS folders");
db.execSQL("CREATE TABLE folders (" +
"id INTEGER PRIMARY KEY," +
"name TEXT, " +
"last_updated INTEGER, " +
"unread_count INTEGER, " +
"visible_limit INTEGER, " +
"status TEXT, " +
"flagged_count INTEGER default 0, " +
"integrate INTEGER, " +
"top_group INTEGER, " +
"poll_class TEXT, " +
"push_class TEXT, " +
"display_class TEXT, " +
"notify_class TEXT default '"+ FolderClass.INHERITED.name() + "', " +
"more_messages TEXT default \"unknown\", " +
"server_id TEXT, " +
"local_only INTEGER, " +
"type TEXT DEFAULT \"regular\"" +
")");
db.execSQL("DROP INDEX IF EXISTS folder_server_id");
db.execSQL("CREATE INDEX folder_server_id ON folders (server_id)");
db.execSQL("DROP TABLE IF EXISTS folder_extra_values");
db.execSQL("CREATE TABLE folder_extra_values (" +
"folder_id INTEGER NOT NULL, " +
"name TEXT NOT NULL, " +
"value_text TEXT, " +
"value_integer INTEGER, " +
"PRIMARY KEY (folder_id, name)" +
")");
db.execSQL("DROP TABLE IF EXISTS messages");
db.execSQL("CREATE TABLE messages (" +
"id INTEGER PRIMARY KEY, " +
"deleted INTEGER default 0, " +
"folder_id INTEGER, " +
"uid TEXT, " +
"subject TEXT, " +
"date INTEGER, " +
"flags TEXT, " +
"sender_list TEXT, " +
"to_list TEXT, " +
"cc_list TEXT, " +
"bcc_list TEXT, " +
"reply_to_list TEXT, " +
"attachment_count INTEGER, " +
"internal_date INTEGER, " +
"message_id TEXT, " +
"preview_type TEXT default \"none\", " +
"preview TEXT, " +
"mime_type TEXT, "+
"normalized_subject_hash INTEGER, " +
"empty INTEGER default 0, " +
"read INTEGER default 0, " +
"flagged INTEGER default 0, " +
"answered INTEGER default 0, " +
"forwarded INTEGER default 0, " +
"message_part_id INTEGER," +
"encryption_type TEXT," +
"new_message INTEGER DEFAULT 0" +
")");
db.execSQL("DROP INDEX IF EXISTS new_messages");
db.execSQL("CREATE INDEX IF NOT EXISTS new_messages ON messages(new_message)");
db.execSQL("CREATE TRIGGER new_message_reset " +
"AFTER UPDATE OF read ON messages " +
"FOR EACH ROW WHEN NEW.read = 1 AND NEW.new_message = 1 " +
"BEGIN " +
"UPDATE messages SET new_message = 0 WHERE ROWID = NEW.ROWID; " +
"END");
db.execSQL("DROP TABLE IF EXISTS message_parts");
db.execSQL("CREATE TABLE message_parts (" +
"id INTEGER PRIMARY KEY, " +
"type INTEGER NOT NULL, " +
"root INTEGER, " +
"parent INTEGER NOT NULL, " +
"seq INTEGER NOT NULL, " +
"mime_type TEXT, " +
"decoded_body_size INTEGER, " +
"display_name TEXT, " +
"header TEXT, " +
"encoding TEXT, " +
"charset TEXT, " +
"data_location INTEGER NOT NULL, " +
"data BLOB, " +
"preamble TEXT, " +
"epilogue TEXT, " +
"boundary TEXT, " +
"content_id TEXT, " +
"server_extra TEXT" +
")");
db.execSQL("CREATE TRIGGER set_message_part_root " +
"AFTER INSERT ON message_parts " +
"BEGIN " +
"UPDATE message_parts SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " +
"END");
db.execSQL("CREATE INDEX IF NOT EXISTS msg_uid ON messages (uid, folder_id)");
db.execSQL("DROP INDEX IF EXISTS msg_folder_id");
db.execSQL("DROP INDEX IF EXISTS msg_folder_id_date");
db.execSQL("CREATE INDEX IF NOT EXISTS msg_folder_id_deleted_date ON messages (folder_id,deleted,internal_date)");
db.execSQL("DROP INDEX IF EXISTS msg_empty");
db.execSQL("CREATE INDEX IF NOT EXISTS msg_empty ON messages (empty)");
db.execSQL("DROP INDEX IF EXISTS msg_read");
db.execSQL("CREATE INDEX IF NOT EXISTS msg_read ON messages (read)");
db.execSQL("DROP INDEX IF EXISTS msg_flagged");
db.execSQL("CREATE INDEX IF NOT EXISTS msg_flagged ON messages (flagged)");
db.execSQL("DROP INDEX IF EXISTS msg_composite");
db.execSQL("CREATE INDEX IF NOT EXISTS msg_composite ON messages (deleted, empty,folder_id,flagged,read)");
db.execSQL("DROP INDEX IF EXISTS message_parts_root");
db.execSQL("CREATE INDEX IF NOT EXISTS message_parts_root ON message_parts (root)");
db.execSQL("DROP TABLE IF EXISTS threads");
db.execSQL("CREATE TABLE threads (" +
"id INTEGER PRIMARY KEY, " +
"message_id INTEGER, " +
"root INTEGER, " +
"parent INTEGER" +
")");
db.execSQL("DROP INDEX IF EXISTS threads_message_id");
db.execSQL("CREATE INDEX IF NOT EXISTS threads_message_id ON threads (message_id)");
db.execSQL("DROP INDEX IF EXISTS threads_root");
db.execSQL("CREATE INDEX IF NOT EXISTS threads_root ON threads (root)");
db.execSQL("DROP INDEX IF EXISTS threads_parent");
db.execSQL("CREATE INDEX IF NOT EXISTS threads_parent ON threads (parent)");
db.execSQL("DROP TRIGGER IF EXISTS set_thread_root");
db.execSQL("CREATE TRIGGER set_thread_root " +
"AFTER INSERT ON threads " +
"BEGIN " +
"UPDATE threads SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " +
"END");
db.execSQL("DROP TABLE IF EXISTS outbox_state");
db.execSQL("CREATE TABLE outbox_state (" +
"message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," +
"send_state TEXT," +
"number_of_send_attempts INTEGER DEFAULT 0," +
"error_timestamp INTEGER DEFAULT 0," +
"error TEXT)");
db.execSQL("DROP TABLE IF EXISTS pending_commands");
db.execSQL("CREATE TABLE pending_commands " +
"(id INTEGER PRIMARY KEY, command TEXT, data TEXT)");
db.execSQL("DROP TRIGGER IF EXISTS delete_folder");
db.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;");
db.execSQL("DROP TRIGGER IF EXISTS delete_folder_extra_values");
db.execSQL("CREATE TRIGGER delete_folder_extra_values " +
"BEFORE DELETE ON folders " +
"BEGIN " +
"DELETE FROM folder_extra_values WHERE old.id = folder_id; " +
"END;");
db.execSQL("DROP TRIGGER IF EXISTS delete_message");
db.execSQL("CREATE TRIGGER delete_message " +
"BEFORE DELETE ON messages " +
"BEGIN " +
"DELETE FROM message_parts WHERE root = OLD.message_part_id; " +
"DELETE FROM messages_fulltext WHERE docid = OLD.id; " +
"DELETE FROM threads WHERE message_id = OLD.id; " +
"END");
db.execSQL("DROP TABLE IF EXISTS messages_fulltext");
db.execSQL("CREATE VIRTUAL TABLE messages_fulltext USING fts4 (fulltext)");
db.execSQL("DROP TABLE IF EXISTS notifications");
db.execSQL("CREATE TABLE notifications (" +
"message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," +
"notification_id INTEGER UNIQUE," +
"timestamp INTEGER NOT NULL" +
")");
db.execSQL("DROP INDEX IF EXISTS notifications_timestamp");
db.execSQL("CREATE INDEX IF NOT EXISTS notifications_timestamp ON notifications(timestamp)");
}
}

View file

@ -0,0 +1,36 @@
package com.fsck.k9.storage.messages
import com.fsck.k9.K9
import com.fsck.k9.helper.FileHelper
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.mailstore.StorageManager.InternalStorageProvider
import java.io.File
import timber.log.Timber
internal class AttachmentFileManager(
private val storageManager: StorageManager,
private val accountUuid: String
) {
fun deleteFile(messagePartId: Long) {
val file = getAttachmentFile(messagePartId)
if (file.exists() && !file.delete() && K9.isDebugLoggingEnabled) {
Timber.w("Couldn't delete message part file: %s", file.absolutePath)
}
}
fun moveTemporaryFile(temporaryFile: File, messagePartId: Long) {
val destinationFile = getAttachmentFile(messagePartId)
FileHelper.renameOrMoveByCopying(temporaryFile, destinationFile)
}
fun copyFile(sourceMessagePartId: Long, destinationMessagePartId: Long) {
val sourceFile = getAttachmentFile(sourceMessagePartId)
val destinationFile = getAttachmentFile(destinationMessagePartId)
sourceFile.copyTo(destinationFile)
}
fun getAttachmentFile(messagePartId: Long): File {
val attachmentDirectory = storageManager.getAttachmentDirectory(accountUuid, InternalStorageProvider.ID)
return File(attachmentDirectory, messagePartId.toString())
}
}

View file

@ -0,0 +1,34 @@
package com.fsck.k9.storage.messages
import com.fsck.k9.mailstore.LockableDatabase
internal class CheckFolderOperations(private val lockableDatabase: LockableDatabase) {
fun areAllIncludedInUnifiedInbox(folderIds: Collection<Long>): Boolean {
return lockableDatabase.execute(false) { database ->
var allIncludedInUnifiedInbox = true
performChunkedOperation(
arguments = folderIds,
argumentTransformation = Long::toString
) { selectionSet, selectionArguments ->
if (allIncludedInUnifiedInbox) {
database.rawQuery(
"SELECT COUNT(id) FROM folders WHERE integrate = 1 AND id $selectionSet",
selectionArguments
).use { cursor ->
if (cursor.moveToFirst()) {
val count = cursor.getInt(0)
if (count != selectionArguments.size) {
allIncludedInUnifiedInbox = false
}
} else {
allIncludedInUnifiedInbox = false
}
}
}
}
allIncludedInUnifiedInbox
}
}
}

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,31 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import com.fsck.k9.mailstore.CreateFolderInfo
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.toDatabaseFolderType
internal class CreateFolderOperations(private val lockableDatabase: LockableDatabase) {
fun createFolders(folders: List<CreateFolderInfo>) {
lockableDatabase.execute(true) { db ->
for (folder in folders) {
val folderSettings = folder.settings
val values = ContentValues().apply {
put("name", folder.name)
put("visible_limit", folderSettings.visibleLimit)
put("integrate", folderSettings.integrate)
put("top_group", folderSettings.inTopGroup)
put("poll_class", folderSettings.syncClass.name)
put("push_class", folderSettings.pushClass.name)
put("display_class", folderSettings.displayClass.name)
put("notify_class", folderSettings.notifyClass.name)
put("server_id", folder.serverId)
put("local_only", false)
put("type", folder.type.toDatabaseFolderType())
}
db.insert("folders", null, values)
}
}
}
}

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,40 @@
package com.fsck.k9.storage.messages
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.StorageManager
import timber.log.Timber
internal class DatabaseOperations(
private val lockableDatabase: LockableDatabase,
val storageManager: StorageManager,
val accountUuid: String
) {
fun getSize(): Long {
val storageProviderId = lockableDatabase.storageProviderId
val attachmentDirectory = storageManager.getAttachmentDirectory(accountUuid, storageProviderId)
return lockableDatabase.execute(false) {
val attachmentFiles = attachmentDirectory.listFiles() ?: emptyArray()
val attachmentsSize = attachmentFiles.asSequence()
.filter { file -> file.exists() }
.fold(initial = 0L) { accumulatedSize, file ->
accumulatedSize + file.length()
}
val databaseFile = storageManager.getDatabase(accountUuid, storageProviderId)
val databaseSize = databaseFile.length()
databaseSize + attachmentsSize
}
}
fun compact() {
Timber.i("Before compaction size = %d", getSize())
lockableDatabase.execute(false) { database ->
database.execSQL("VACUUM")
}
Timber.i("After compaction size = %d", getSize())
}
}

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,287 @@
package com.fsck.k9.storage.messages
import com.fsck.k9.Account.FolderMode
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.FolderClass
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.Header
import com.fsck.k9.mailstore.CreateFolderInfo
import com.fsck.k9.mailstore.FolderDetails
import com.fsck.k9.mailstore.FolderMapper
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.MessageMapper
import com.fsck.k9.mailstore.MessageStore
import com.fsck.k9.mailstore.MoreMessages
import com.fsck.k9.mailstore.SaveMessageData
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
import com.fsck.k9.search.ConditionsTreeNode
import java.util.Date
class K9MessageStore(
database: LockableDatabase,
storageManager: StorageManager,
basicPartInfoExtractor: BasicPartInfoExtractor,
accountUuid: String
) : MessageStore {
private val attachmentFileManager = AttachmentFileManager(storageManager, accountUuid)
private val threadMessageOperations = ThreadMessageOperations()
private val saveMessageOperations = SaveMessageOperations(
database,
attachmentFileManager,
basicPartInfoExtractor,
threadMessageOperations
)
private val copyMessageOperations = CopyMessageOperations(database, attachmentFileManager, threadMessageOperations)
private val moveMessageOperations = MoveMessageOperations(database, threadMessageOperations)
private val flagMessageOperations = FlagMessageOperations(database)
private val updateMessageOperations = UpdateMessageOperations(database)
private val retrieveMessageOperations = RetrieveMessageOperations(database)
private val retrieveMessageListOperations = RetrieveMessageListOperations(database)
private val deleteMessageOperations = DeleteMessageOperations(database, attachmentFileManager)
private val createFolderOperations = CreateFolderOperations(database)
private val retrieveFolderOperations = RetrieveFolderOperations(database)
private val checkFolderOperations = CheckFolderOperations(database)
private val updateFolderOperations = UpdateFolderOperations(database)
private val deleteFolderOperations = DeleteFolderOperations(database, attachmentFileManager)
private val keyValueStoreOperations = KeyValueStoreOperations(database)
private val databaseOperations = DatabaseOperations(database, storageManager, accountUuid)
override fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
saveMessageOperations.saveRemoteMessage(folderId, messageServerId, messageData)
}
override fun saveLocalMessage(folderId: Long, messageData: SaveMessageData, existingMessageId: Long?): Long {
return saveMessageOperations.saveLocalMessage(folderId, messageData, existingMessageId)
}
override fun copyMessage(messageId: Long, destinationFolderId: Long): Long {
return copyMessageOperations.copyMessage(messageId, destinationFolderId)
}
override fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
return moveMessageOperations.moveMessage(messageId, destinationFolderId)
}
override fun setFlag(messageIds: Collection<Long>, flag: Flag, set: Boolean) {
flagMessageOperations.setFlag(messageIds, flag, set)
}
override fun setMessageFlag(folderId: Long, messageServerId: String, flag: Flag, set: Boolean) {
flagMessageOperations.setMessageFlag(folderId, messageServerId, flag, set)
}
override fun setNewMessageState(folderId: Long, messageServerId: String, newMessage: Boolean) {
updateMessageOperations.setNewMessageState(folderId, messageServerId, newMessage)
}
override fun clearNewMessageState() {
updateMessageOperations.clearNewMessageState()
}
override fun getMessageServerId(messageId: Long): String? {
return retrieveMessageOperations.getMessageServerId(messageId)
}
override fun getMessageServerIds(messageIds: Collection<Long>): Map<Long, String> {
return retrieveMessageOperations.getMessageServerIds(messageIds)
}
override fun getMessageServerIds(folderId: Long): Set<String> {
return retrieveMessageOperations.getMessageServerIds(folderId)
}
override fun isMessagePresent(folderId: Long, messageServerId: String): Boolean {
return retrieveMessageOperations.isMessagePresent(folderId, messageServerId)
}
override fun getMessageFlags(folderId: Long, messageServerId: String): Set<Flag> {
return retrieveMessageOperations.getMessageFlags(folderId, messageServerId)
}
override fun getAllMessagesAndEffectiveDates(folderId: Long): Map<String, Long?> {
return retrieveMessageOperations.getAllMessagesAndEffectiveDates(folderId)
}
override fun <T> getMessages(
selection: String,
selectionArgs: Array<String>,
sortOrder: String,
messageMapper: MessageMapper<out T?>
): List<T> {
return retrieveMessageListOperations.getMessages(selection, selectionArgs, sortOrder, messageMapper)
}
override fun <T> getThreadedMessages(
selection: String,
selectionArgs: Array<String>,
sortOrder: String,
messageMapper: MessageMapper<out T?>
): List<T> {
return retrieveMessageListOperations.getThreadedMessages(selection, selectionArgs, sortOrder, messageMapper)
}
override fun <T> getThread(threadId: Long, sortOrder: String, messageMapper: MessageMapper<out T?>): List<T> {
return retrieveMessageListOperations.getThread(threadId, sortOrder, messageMapper)
}
override fun getOldestMessageDate(folderId: Long): Date? {
return retrieveMessageOperations.getOldestMessageDate(folderId)
}
override fun getHeaders(folderId: Long, messageServerId: String): List<Header> {
return retrieveMessageOperations.getHeaders(folderId, messageServerId)
}
override fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set<String>): List<Header> {
return retrieveMessageOperations.getHeaders(folderId, messageServerId, headerNames)
}
override fun destroyMessages(folderId: Long, messageServerIds: Collection<String>) {
deleteMessageOperations.destroyMessages(folderId, messageServerIds)
}
override fun createFolders(folders: List<CreateFolderInfo>) {
createFolderOperations.createFolders(folders)
}
override fun <T> getFolder(folderId: Long, mapper: FolderMapper<T>): T? {
return retrieveFolderOperations.getFolder(folderId, mapper)
}
override fun <T> getFolder(folderServerId: String, mapper: FolderMapper<T>): T? {
return retrieveFolderOperations.getFolder(folderServerId, mapper)
}
override fun <T> getFolders(excludeLocalOnly: Boolean, mapper: FolderMapper<T>): List<T> {
return retrieveFolderOperations.getFolders(excludeLocalOnly, mapper)
}
override fun <T> getDisplayFolders(
displayMode: FolderMode,
outboxFolderId: Long?,
mapper: FolderMapper<T>
): List<T> {
return retrieveFolderOperations.getDisplayFolders(displayMode, outboxFolderId, mapper)
}
override fun areAllIncludedInUnifiedInbox(folderIds: Collection<Long>): Boolean {
return checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
}
override fun getFolderId(folderServerId: String): Long? {
return retrieveFolderOperations.getFolderId(folderServerId)
}
override fun getFolderServerId(folderId: Long): String? {
return retrieveFolderOperations.getFolderServerId(folderId)
}
override fun getMessageCount(folderId: Long): Int {
return retrieveFolderOperations.getMessageCount(folderId)
}
override fun getUnreadMessageCount(folderId: Long): Int {
return retrieveFolderOperations.getUnreadMessageCount(folderId)
}
override fun getUnreadMessageCount(conditions: ConditionsTreeNode?): Int {
return retrieveFolderOperations.getUnreadMessageCount(conditions)
}
override fun getStarredMessageCount(conditions: ConditionsTreeNode?): Int {
return retrieveFolderOperations.getStarredMessageCount(conditions)
}
override fun getSize(): Long {
return databaseOperations.getSize()
}
override fun changeFolder(folderServerId: String, name: String, type: FolderType) {
updateFolderOperations.changeFolder(folderServerId, name, type)
}
override fun updateFolderSettings(folderDetails: FolderDetails) {
updateFolderOperations.updateFolderSettings(folderDetails)
}
override fun setIncludeInUnifiedInbox(folderId: Long, includeInUnifiedInbox: Boolean) {
updateFolderOperations.setIncludeInUnifiedInbox(folderId, includeInUnifiedInbox)
}
override fun setDisplayClass(folderId: Long, folderClass: FolderClass) {
updateFolderOperations.setDisplayClass(folderId, folderClass)
}
override fun setSyncClass(folderId: Long, folderClass: FolderClass) {
updateFolderOperations.setSyncClass(folderId, folderClass)
}
override fun setPushClass(folderId: Long, folderClass: FolderClass) {
updateFolderOperations.setPushClass(folderId, folderClass)
}
override fun setNotificationClass(folderId: Long, folderClass: FolderClass) {
updateFolderOperations.setNotificationClass(folderId, folderClass)
}
override fun hasMoreMessages(folderId: Long): MoreMessages {
return retrieveFolderOperations.hasMoreMessages(folderId)
}
override fun setMoreMessages(folderId: Long, moreMessages: MoreMessages) {
updateFolderOperations.setMoreMessages(folderId, moreMessages)
}
override fun setLastChecked(folderId: Long, timestamp: Long) {
updateFolderOperations.setLastChecked(folderId, timestamp)
}
override fun setStatus(folderId: Long, status: String?) {
updateFolderOperations.setStatus(folderId, status)
}
override fun setVisibleLimit(folderId: Long, visibleLimit: Int) {
updateFolderOperations.setVisibleLimit(folderId, visibleLimit)
}
override fun deleteFolders(folderServerIds: List<String>) {
deleteFolderOperations.deleteFolders(folderServerIds)
}
override fun getExtraString(name: String): String? {
return keyValueStoreOperations.getExtraString(name)
}
override fun setExtraString(name: String, value: String) {
keyValueStoreOperations.setExtraString(name, value)
}
override fun getExtraNumber(name: String): Long? {
return keyValueStoreOperations.getExtraNumber(name)
}
override fun setExtraNumber(name: String, value: Long) {
keyValueStoreOperations.setExtraNumber(name, value)
}
override fun getFolderExtraString(folderId: Long, name: String): String? {
return keyValueStoreOperations.getFolderExtraString(folderId, name)
}
override fun setFolderExtraString(folderId: Long, name: String, value: String?) {
return keyValueStoreOperations.setFolderExtraString(folderId, name, value)
}
override fun getFolderExtraNumber(folderId: Long, name: String): Long? {
return keyValueStoreOperations.getFolderExtraNumber(folderId, name)
}
override fun setFolderExtraNumber(folderId: Long, name: String, value: Long) {
return keyValueStoreOperations.setFolderExtraNumber(folderId, name, value)
}
override fun compact() {
return databaseOperations.compact()
}
}

View file

@ -0,0 +1,22 @@
package com.fsck.k9.storage.messages
import com.fsck.k9.Account
import com.fsck.k9.mailstore.ListenableMessageStore
import com.fsck.k9.mailstore.LocalStoreProvider
import com.fsck.k9.mailstore.MessageStoreFactory
import com.fsck.k9.mailstore.NotifierMessageStore
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
class K9MessageStoreFactory(
private val localStoreProvider: LocalStoreProvider,
private val storageManager: StorageManager,
private val basicPartInfoExtractor: BasicPartInfoExtractor
) : MessageStoreFactory {
override fun create(account: Account): ListenableMessageStore {
val localStore = localStoreProvider.getInstance(account)
val messageStore = K9MessageStore(localStore.database, storageManager, basicPartInfoExtractor, account.uuid)
val notifierMessageStore = NotifierMessageStore(messageStore, localStore)
return ListenableMessageStore(notifierMessageStore)
}
}

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,132 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import app.k9mail.core.android.common.database.getIntOrNull
import app.k9mail.core.android.common.database.getLongOrNull
import app.k9mail.core.android.common.database.getStringOrNull
import com.fsck.k9.K9
import com.fsck.k9.mailstore.LockableDatabase
import java.util.UUID
import timber.log.Timber
internal class MoveMessageOperations(
private val database: LockableDatabase,
private val threadMessageOperations: ThreadMessageOperations
) {
fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
Timber.d("Moving message [ID: $messageId] to folder [ID: $destinationFolderId]")
return database.execute(true) { database ->
val threadInfo = threadMessageOperations.createOrUpdateParentThreadEntries(database, messageId, destinationFolderId)
val destinationMessageId = createMessageEntry(database, messageId, destinationFolderId, threadInfo)
threadMessageOperations.createThreadEntryIfNecessary(database, destinationMessageId, threadInfo)
convertOriginalMessageEntryToPlaceholderEntry(database, messageId)
moveFulltextEntry(database, messageId, destinationMessageId)
destinationMessageId
}
}
private fun moveFulltextEntry(database: SQLiteDatabase, messageId: Long, destinationMessageId: Long) {
val values = ContentValues().apply {
put("docid", destinationMessageId)
}
database.update("messages_fulltext", values, "docid = ?", arrayOf(messageId.toString()))
}
private fun createMessageEntry(
database: SQLiteDatabase,
messageId: Long,
destinationFolderId: Long,
threadInfo: ThreadInfo?
): Long {
val destinationUid = K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString()
val contentValues = database.query(
"messages",
arrayOf(
"subject", "date", "flags", "sender_list", "to_list", "cc_list", "bcc_list", "reply_to_list",
"attachment_count", "internal_date", "message_id", "preview_type", "preview", "mime_type",
"normalized_subject_hash", "read", "flagged", "answered", "forwarded", "message_part_id",
"encryption_type"
),
"id = ?",
arrayOf(messageId.toString()),
null,
null,
null
).use { cursor ->
if (!cursor.moveToFirst()) {
error("Couldn't find local message [ID: $messageId]")
}
ContentValues().apply {
put("uid", destinationUid)
put("folder_id", destinationFolderId)
put("deleted", 0)
put("empty", 0)
put("subject", cursor.getStringOrNull("subject"))
put("date", cursor.getLongOrNull("date"))
put("flags", cursor.getStringOrNull("flags"))
put("sender_list", cursor.getStringOrNull("sender_list"))
put("to_list", cursor.getStringOrNull("to_list"))
put("cc_list", cursor.getStringOrNull("cc_list"))
put("bcc_list", cursor.getStringOrNull("bcc_list"))
put("reply_to_list", cursor.getStringOrNull("reply_to_list"))
put("attachment_count", cursor.getIntOrNull("attachment_count"))
put("internal_date", cursor.getLongOrNull("internal_date"))
put("message_id", cursor.getStringOrNull("message_id"))
put("preview_type", cursor.getStringOrNull("preview_type"))
put("preview", cursor.getStringOrNull("preview"))
put("mime_type", cursor.getStringOrNull("mime_type"))
put("normalized_subject_hash", cursor.getLongOrNull("normalized_subject_hash"))
put("read", cursor.getIntOrNull("read"))
put("flagged", cursor.getIntOrNull("flagged"))
put("answered", cursor.getIntOrNull("answered"))
put("forwarded", cursor.getIntOrNull("forwarded"))
put("message_part_id", cursor.getLongOrNull("message_part_id"))
put("encryption_type", cursor.getStringOrNull("encryption_type"))
}
}
val placeHolderMessageId = threadInfo?.messageId
return if (placeHolderMessageId != null) {
database.update("messages", contentValues, "id = ?", arrayOf(placeHolderMessageId.toString()))
placeHolderMessageId
} else {
database.insert("messages", null, contentValues)
}
}
private fun convertOriginalMessageEntryToPlaceholderEntry(database: SQLiteDatabase, messageId: Long) {
val contentValues = ContentValues().apply {
put("deleted", 1)
put("empty", 0)
put("read", 1)
putNull("subject")
putNull("date")
putNull("flags")
putNull("sender_list")
putNull("to_list")
putNull("cc_list")
putNull("bcc_list")
putNull("reply_to_list")
putNull("attachment_count")
putNull("internal_date")
put("preview_type", "none")
putNull("preview")
putNull("mime_type")
putNull("normalized_subject_hash")
putNull("flagged")
putNull("answered")
putNull("forwarded")
putNull("message_part_id")
putNull("encryption_type")
}
database.update("messages", contentValues, "id = ?", arrayOf(messageId.toString()))
}
}

View file

@ -0,0 +1,284 @@
package com.fsck.k9.storage.messages
import android.database.Cursor
import androidx.core.database.getLongOrNull
import app.k9mail.core.android.common.database.map
import com.fsck.k9.Account.FolderMode
import com.fsck.k9.mail.FolderClass
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mailstore.FolderDetailsAccessor
import com.fsck.k9.mailstore.FolderMapper
import com.fsck.k9.mailstore.FolderNotFoundException
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.MoreMessages
import com.fsck.k9.mailstore.toFolderType
import com.fsck.k9.search.ConditionsTreeNode
import com.fsck.k9.search.SqlQueryBuilder
internal class RetrieveFolderOperations(private val lockableDatabase: LockableDatabase) {
fun <T> getFolder(folderId: Long, mapper: FolderMapper<T>): T? {
return getFolder(
selection = "id = ?",
selectionArguments = arrayOf(folderId.toString()),
mapper = mapper
)
}
fun <T> getFolder(folderServerId: String, mapper: FolderMapper<T>): T? {
return getFolder(
selection = "server_id = ?",
selectionArguments = arrayOf(folderServerId),
mapper = mapper
)
}
private fun <T> getFolder(selection: String, selectionArguments: Array<String>, mapper: FolderMapper<T>): T? {
return lockableDatabase.execute(false) { db ->
db.query(
"folders",
FOLDER_COLUMNS,
selection,
selectionArguments,
null,
null,
null
).use { cursor ->
if (cursor.moveToFirst()) {
val cursorFolderAccessor = CursorFolderAccessor(cursor)
mapper.map(cursorFolderAccessor)
} else {
null
}
}
}
}
fun <T> getFolders(excludeLocalOnly: Boolean = false, mapper: FolderMapper<T>): List<T> {
val selection = if (excludeLocalOnly) "local_only = 0" else null
return lockableDatabase.execute(false) { db ->
db.query("folders", FOLDER_COLUMNS, selection, null, null, null, "id").use { cursor ->
val cursorFolderAccessor = CursorFolderAccessor(cursor)
cursor.map {
mapper.map(cursorFolderAccessor)
}
}
}
}
fun <T> getDisplayFolders(displayMode: FolderMode, outboxFolderId: Long?, mapper: FolderMapper<T>): List<T> {
return lockableDatabase.execute(false) { db ->
val displayModeSelection = getDisplayModeSelection(displayMode)
val outboxFolderIdOrZero = outboxFolderId ?: 0
val query =
"""
SELECT ${FOLDER_COLUMNS.joinToString()}, (
SELECT COUNT(messages.id)
FROM messages
WHERE messages.folder_id = folders.id
AND messages.empty = 0 AND messages.deleted = 0
AND (messages.read = 0 OR folders.id = ?)
), (
SELECT COUNT(messages.id)
FROM messages
WHERE messages.folder_id = folders.id
AND messages.empty = 0 AND messages.deleted = 0
AND messages.flagged = 1
)
FROM folders
$displayModeSelection
"""
db.rawQuery(query, arrayOf(outboxFolderIdOrZero.toString())).use { cursor ->
val cursorFolderAccessor = CursorFolderAccessor(cursor)
cursor.map {
mapper.map(cursorFolderAccessor)
}
}
}
}
private fun getDisplayModeSelection(displayMode: FolderMode): String {
return when (displayMode) {
FolderMode.ALL -> {
""
}
FolderMode.FIRST_CLASS -> {
"WHERE display_class = '${FolderClass.FIRST_CLASS.name}'"
}
FolderMode.FIRST_AND_SECOND_CLASS -> {
"WHERE display_class IN ('${FolderClass.FIRST_CLASS.name}', '${FolderClass.SECOND_CLASS.name}')"
}
FolderMode.NOT_SECOND_CLASS -> {
"WHERE display_class != '${FolderClass.SECOND_CLASS.name}'"
}
FolderMode.NONE -> {
throw AssertionError("Invalid folder display mode: $displayMode")
}
}
}
fun getFolderId(folderServerId: String): Long? {
return lockableDatabase.execute(false) { db ->
db.query(
"folders",
arrayOf("id"),
"server_id = ?",
arrayOf(folderServerId),
null,
null,
null
).use { cursor ->
if (cursor.moveToFirst()) cursor.getLong(0) else null
}
}
}
fun getFolderServerId(folderId: Long): String? {
return lockableDatabase.execute(false) { db ->
db.query(
"folders",
arrayOf("server_id"),
"id = ?",
arrayOf(folderId.toString()),
null,
null,
null
).use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}
}
fun getMessageCount(folderId: Long): Int {
return lockableDatabase.execute(false) { db ->
db.rawQuery(
"SELECT COUNT(id) FROM messages WHERE empty = 0 AND deleted = 0 AND folder_id = ?",
arrayOf(folderId.toString())
).use { cursor ->
if (cursor.moveToFirst()) cursor.getInt(0) else 0
}
}
}
fun getUnreadMessageCount(folderId: Long): Int {
return lockableDatabase.execute(false) { db ->
db.rawQuery(
"SELECT COUNT(id) FROM messages WHERE empty = 0 AND deleted = 0 AND read = 0 AND folder_id = ?",
arrayOf(folderId.toString())
).use { cursor ->
if (cursor.moveToFirst()) cursor.getInt(0) else 0
}
}
}
fun getUnreadMessageCount(conditions: ConditionsTreeNode?): Int {
return getMessageCount(condition = "messages.read = 0", conditions)
}
fun getStarredMessageCount(conditions: ConditionsTreeNode?): Int {
return getMessageCount(condition = "messages.flagged = 1", conditions)
}
private fun getMessageCount(condition: String, extraConditions: ConditionsTreeNode?): Int {
val whereBuilder = StringBuilder()
val queryArgs = mutableListOf<String>()
SqlQueryBuilder.buildWhereClause(extraConditions, whereBuilder, queryArgs)
val where = if (whereBuilder.isNotEmpty()) "AND ($whereBuilder)" else ""
val selectionArgs = queryArgs.toTypedArray()
val query =
"""
SELECT COUNT(messages.id)
FROM messages
JOIN folders ON (folders.id = messages.folder_id)
WHERE (messages.empty = 0 AND messages.deleted = 0 AND $condition) $where
"""
return lockableDatabase.execute(false) { db ->
db.rawQuery(query, selectionArgs).use { cursor ->
if (cursor.moveToFirst()) cursor.getInt(0) else 0
}
}
}
fun hasMoreMessages(folderId: Long): MoreMessages {
return getFolder(folderId) { it.moreMessages } ?: throw FolderNotFoundException(folderId)
}
}
private class CursorFolderAccessor(val cursor: Cursor) : FolderDetailsAccessor {
override val id: Long
get() = cursor.getLong(0)
override val name: String
get() = cursor.getString(1)
override val type: FolderType
get() = cursor.getString(2).toFolderType()
override val serverId: String?
get() = cursor.getString(3)
override val isLocalOnly: Boolean
get() = cursor.getInt(4) == 1
override val isInTopGroup: Boolean
get() = cursor.getInt(5) == 1
override val isIntegrate: Boolean
get() = cursor.getInt(6) == 1
override val syncClass: FolderClass
get() = cursor.getString(7).toFolderClass(FolderClass.INHERITED)
override val displayClass: FolderClass
get() = cursor.getString(8).toFolderClass(FolderClass.NO_CLASS)
override val notifyClass: FolderClass
get() = cursor.getString(9).toFolderClass(FolderClass.INHERITED)
override val pushClass: FolderClass
get() = cursor.getString(10).toFolderClass(FolderClass.SECOND_CLASS)
override val visibleLimit: Int
get() = cursor.getInt(11)
override val moreMessages: MoreMessages
get() = MoreMessages.fromDatabaseName(cursor.getString(12))
override val lastChecked: Long?
get() = cursor.getLongOrNull(13)
override val unreadMessageCount: Int
get() = cursor.getInt(14)
override val starredMessageCount: Int
get() = cursor.getInt(15)
override fun serverIdOrThrow(): String {
return serverId ?: error("No server ID found for folder '$name' ($id)")
}
}
private fun String?.toFolderClass(defaultValue: FolderClass): FolderClass {
return if (this == null) defaultValue else FolderClass.valueOf(this)
}
private val FOLDER_COLUMNS = arrayOf(
"id",
"name",
"type",
"server_id",
"local_only",
"top_group",
"integrate",
"poll_class",
"display_class",
"notify_class",
"push_class",
"visible_limit",
"more_messages",
"last_updated"
)

View file

@ -0,0 +1,242 @@
package com.fsck.k9.storage.messages
import android.database.Cursor
import com.fsck.k9.mail.Address
import com.fsck.k9.mailstore.DatabasePreviewType
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.MessageDetailsAccessor
import com.fsck.k9.mailstore.MessageMapper
import com.fsck.k9.message.extractors.PreviewResult
import com.fsck.k9.search.SqlQueryBuilder
internal class RetrieveMessageListOperations(private val lockableDatabase: LockableDatabase) {
fun <T> getMessages(
selection: String,
selectionArgs: Array<String>,
sortOrder: String,
mapper: MessageMapper<out T?>
): List<T> {
return lockableDatabase.execute(false) { database ->
database.rawQuery(
"""
SELECT
messages.id AS id,
uid,
folder_id,
sender_list,
to_list,
cc_list,
date,
internal_date,
subject,
preview_type,
preview,
read,
flagged,
answered,
forwarded,
attachment_count,
root
FROM messages
JOIN threads ON (threads.message_id = messages.id)
LEFT JOIN FOLDERS ON (folders.id = messages.folder_id)
WHERE
($selection)
AND empty = 0 AND deleted = 0
ORDER BY $sortOrder
""",
selectionArgs
).use { cursor ->
val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = false)
buildList {
while (cursor.moveToNext()) {
val value = mapper.map(cursorMessageAccessor)
if (value != null) {
add(value)
}
}
}
}
}
}
fun <T> getThreadedMessages(
selection: String,
selectionArgs: Array<String>,
sortOrder: String,
mapper: MessageMapper<out T?>
): List<T> {
val orderBy = SqlQueryBuilder.addPrefixToSelection(AGGREGATED_MESSAGES_COLUMNS, "aggregated.", sortOrder)
return lockableDatabase.execute(false) { database ->
database.rawQuery(
"""
SELECT
messages.id AS id,
uid,
folder_id,
sender_list,
to_list,
cc_list,
aggregated.date AS date,
aggregated.internal_date AS internal_date,
subject,
preview_type,
preview,
aggregated.read AS read,
aggregated.flagged AS flagged,
aggregated.answered AS answered,
aggregated.forwarded AS forwarded,
aggregated.attachment_count AS attachment_count,
root,
aggregated.thread_count AS thread_count
FROM (
SELECT
threads.root AS thread_root,
MAX(date) AS date,
MAX(internal_date) AS internal_date,
MIN(read) AS read,
MAX(flagged) AS flagged,
MIN(answered) AS answered,
MIN(forwarded) AS forwarded,
SUM(attachment_count) AS attachment_count,
COUNT(threads.root) AS thread_count
FROM messages
JOIN threads ON (threads.message_id = messages.id)
JOIN folders ON (folders.id = messages.folder_id)
WHERE
threads.root IN (
SELECT threads.root
FROM messages
JOIN threads ON (threads.message_id = messages.id)
WHERE messages.empty = 0 AND messages.deleted = 0
)
AND ($selection)
AND messages.empty = 0 AND messages.deleted = 0
GROUP BY threads.root
) aggregated
JOIN threads ON (threads.root = aggregated.thread_root)
JOIN messages ON (
messages.id = threads.message_id
AND messages.empty = 0 AND messages.deleted = 0
AND messages.date = aggregated.date
)
JOIN folders ON (folders.id = messages.folder_id)
GROUP BY threads.root
ORDER BY $orderBy
""",
selectionArgs
).use { cursor ->
val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = true)
buildList {
while (cursor.moveToNext()) {
val value = mapper.map(cursorMessageAccessor)
if (value != null) {
add(value)
}
}
}
}
}
}
fun <T> getThread(threadId: Long, sortOrder: String, mapper: MessageMapper<out T?>): List<T> {
return lockableDatabase.execute(false) { database ->
database.rawQuery(
"""
SELECT
messages.id AS id,
uid,
folder_id,
sender_list,
to_list,
cc_list,
date,
internal_date,
subject,
preview_type,
preview,
read,
flagged,
answered,
forwarded,
attachment_count,
root
FROM threads
JOIN messages ON (messages.id = threads.message_id)
LEFT JOIN FOLDERS ON (folders.id = messages.folder_id)
WHERE
root = ?
AND empty = 0 AND deleted = 0
ORDER BY $sortOrder
""",
arrayOf(threadId.toString())
).use { cursor ->
val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = false)
buildList {
while (cursor.moveToNext()) {
val value = mapper.map(cursorMessageAccessor)
if (value != null) {
add(value)
}
}
}
}
}
}
}
private class CursorMessageAccessor(val cursor: Cursor, val includesThreadCount: Boolean) : MessageDetailsAccessor {
override val id: Long
get() = cursor.getLong(0)
override val messageServerId: String
get() = cursor.getString(1)
override val folderId: Long
get() = cursor.getLong(2)
override val fromAddresses: List<Address>
get() = Address.unpack(cursor.getString(3)).toList()
override val toAddresses: List<Address>
get() = Address.unpack(cursor.getString(4)).toList()
override val ccAddresses: List<Address>
get() = Address.unpack(cursor.getString(5)).toList()
override val messageDate: Long
get() = cursor.getLong(6)
override val internalDate: Long
get() = cursor.getLong(7)
override val subject: String?
get() = cursor.getString(8)
override val preview: PreviewResult
get() {
return when (DatabasePreviewType.fromDatabaseValue(cursor.getString(9))) {
DatabasePreviewType.NONE -> PreviewResult.none()
DatabasePreviewType.TEXT -> PreviewResult.text(cursor.getString(10))
DatabasePreviewType.ENCRYPTED -> PreviewResult.encrypted()
DatabasePreviewType.ERROR -> PreviewResult.error()
}
}
override val isRead: Boolean
get() = cursor.getInt(11) == 1
override val isStarred: Boolean
get() = cursor.getInt(12) == 1
override val isAnswered: Boolean
get() = cursor.getInt(13) == 1
override val isForwarded: Boolean
get() = cursor.getInt(14) == 1
override val hasAttachments: Boolean
get() = cursor.getInt(15) > 0
override val threadRoot: Long
get() = cursor.getLong(16)
override val threadCount: Int
get() = if (includesThreadCount) cursor.getInt(17) else 0
}
private val AGGREGATED_MESSAGES_COLUMNS = arrayOf(
"date",
"internal_date",
"attachment_count",
"read",
"flagged",
"answered",
"forwarded"
)

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 com.fsck.k9.K9
import com.fsck.k9.mail.Address
import com.fsck.k9.mail.Body
import com.fsck.k9.mail.BoundaryGenerator
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Message.RecipientType
import com.fsck.k9.mail.MessageDownloadState
import com.fsck.k9.mail.Multipart
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.filter.CountingOutputStream
import com.fsck.k9.mail.internet.BinaryTempFileBody
import com.fsck.k9.mail.internet.MimeHeader
import com.fsck.k9.mail.internet.MimeUtility
import com.fsck.k9.mail.internet.SizeAware
import com.fsck.k9.mailstore.DatabasePreviewType
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.SaveMessageData
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
import com.fsck.k9.message.extractors.PreviewResult.PreviewType
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.Stack
import java.util.UUID
import org.apache.commons.io.IOUtils
import org.apache.james.mime4j.codec.Base64InputStream
import org.apache.james.mime4j.codec.QuotedPrintableInputStream
import org.apache.james.mime4j.util.MimeUtil
internal const val MAX_BODY_SIZE_FOR_DATABASE = 16 * 1024L
internal class SaveMessageOperations(
private val lockableDatabase: LockableDatabase,
private val attachmentFileManager: AttachmentFileManager,
private val partInfoExtractor: BasicPartInfoExtractor,
private val threadMessageOperations: ThreadMessageOperations
) {
fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
saveMessage(folderId, messageServerId, messageData)
}
fun saveLocalMessage(folderId: Long, messageData: SaveMessageData, existingMessageId: Long?): Long {
return if (existingMessageId == null) {
saveLocalMessage(folderId, messageData)
} else {
replaceLocalMessage(folderId, existingMessageId, messageData)
}
}
private fun saveLocalMessage(folderId: Long, messageData: SaveMessageData): Long {
val fakeServerId = K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString()
return saveMessage(folderId, fakeServerId, messageData)
}
private fun replaceLocalMessage(folderId: Long, messageId: Long, messageData: SaveMessageData): Long {
return lockableDatabase.execute(true) { database ->
val (messageServerId, rootMessagePartId) = getLocalMessageInfo(folderId, messageId)
replaceMessage(
database,
folderId,
messageServerId,
existingMessageId = messageId,
existingRootMessagePartId = rootMessagePartId,
messageData
)
messageId
}
}
private fun saveMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData): Long {
return lockableDatabase.execute(true) { database ->
val message = messageData.message
val existingMessageInfo = getMessage(folderId, messageServerId)
return@execute if (existingMessageInfo != null) {
val (existingMessageId, existingRootMessagePartId) = existingMessageInfo
replaceMessage(
database,
folderId,
messageServerId,
existingMessageId,
existingRootMessagePartId,
messageData
)
existingMessageId
} else {
insertMessage(database, folderId, messageServerId, message, messageData)
}
}
}
private fun insertMessage(
database: SQLiteDatabase,
folderId: Long,
messageServerId: String,
message: Message,
messageData: SaveMessageData
): Long {
val threadInfo = threadMessageOperations.doMessageThreading(database, folderId, message.toThreadHeaders())
val rootMessagePartId = saveMessageParts(database, message)
val messageId = saveMessage(
database,
folderId,
messageServerId,
rootMessagePartId,
messageData,
replaceMessageId = threadInfo?.messageId
)
if (threadInfo?.threadId == null) {
threadMessageOperations.createThreadEntry(database, messageId, threadInfo?.rootId, threadInfo?.parentId)
}
createOrReplaceFulltextEntry(database, messageId, messageData)
return messageId
}
private fun replaceMessage(
database: SQLiteDatabase,
folderId: Long,
messageServerId: String,
existingMessageId: Long,
existingRootMessagePartId: Long?,
messageData: SaveMessageData
) {
if (existingRootMessagePartId != null) {
deleteMessagePartsAndDataFromDisk(database, existingRootMessagePartId)
}
val rootMessagePartId = saveMessageParts(database, messageData.message)
val messageId = saveMessage(
database,
folderId,
messageServerId,
rootMessagePartId,
messageData,
replaceMessageId = existingMessageId
)
createOrReplaceFulltextEntry(database, messageId, messageData)
}
private fun saveMessageParts(database: SQLiteDatabase, message: Message): Long {
val rootPartContainer = PartContainer(parentId = null, part = message)
val rootId = saveMessagePart(database, rootPartContainer, rootId = null, order = 0)
val partsToSave = Stack<PartContainer>()
addChildrenToStack(partsToSave, part = message, parentId = rootId)
var order = 1
while (partsToSave.isNotEmpty()) {
val partContainer = partsToSave.pop()
val messagePartId = saveMessagePart(database, partContainer, rootId, order)
order++
addChildrenToStack(partsToSave, partContainer.part, parentId = messagePartId)
}
return rootId
}
private fun saveMessagePart(
database: SQLiteDatabase,
partContainer: PartContainer,
rootId: Long?,
order: Int
): Long {
val part = partContainer.part
val values = ContentValues().apply {
put("root", rootId)
put("parent", partContainer.parentId ?: -1) // -1 for compatibility with previous code
put("seq", order)
put("server_extra", part.serverExtra)
}
return updateOrInsertMessagePart(database, values, part, existingMessagePartId = null)
}
private fun updateOrInsertMessagePart(
database: SQLiteDatabase,
values: ContentValues,
part: Part,
existingMessagePartId: Long?
): Long {
val headerBytes = getHeaderBytes(part)
values.put("mime_type", part.mimeType)
values.put("header", headerBytes)
values.put("type", MessagePartType.UNKNOWN)
val file: File? = when (val body = part.body) {
is Multipart -> multipartToContentValues(values, body)
is Message -> messageMarkerToContentValues(values)
null -> missingPartToContentValues(values, part)
else -> leafPartToContentValues(values, part, body)
}
val messagePartId = if (existingMessagePartId != null) {
database.update("message_parts", values, "id = ?", arrayOf(existingMessagePartId.toString()))
existingMessagePartId
} else {
database.insertOrThrow("message_parts", null, values)
}
if (file != null) {
attachmentFileManager.moveTemporaryFile(file, messagePartId)
}
return messagePartId
}
private fun multipartToContentValues(values: ContentValues, multipart: Multipart): File? {
values.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
values.put("preamble", multipart.preamble)
values.put("epilogue", multipart.epilogue)
values.put("boundary", multipart.boundary)
return null
}
private fun messageMarkerToContentValues(cv: ContentValues): File? {
cv.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
return null
}
private fun missingPartToContentValues(values: ContentValues, part: Part): File? {
val partInfo = partInfoExtractor.extractPartInfo(part)
values.put("display_name", partInfo.displayName)
values.put("data_location", DataLocation.MISSING)
values.put("decoded_body_size", partInfo.size)
if (MimeUtility.isMultipart(part.mimeType)) {
values.put("boundary", BoundaryGenerator.getInstance().generateBoundary())
}
return null
}
private fun leafPartToContentValues(values: ContentValues, part: Part, body: Body): File? {
val displayName = partInfoExtractor.extractDisplayName(part)
values.put("display_name", displayName)
val encoding = getTransferEncoding(part)
values.put("encoding", encoding)
values.put("content_id", part.contentId)
check(body is SizeAware) { "Body needs to implement SizeAware" }
val sizeAwareBody = body as SizeAware
val fileSize = sizeAwareBody.size
return if (fileSize > MAX_BODY_SIZE_FOR_DATABASE) {
values.put("data_location", DataLocation.ON_DISK)
val file = writeBodyToDiskIfNecessary(part)
val size = decodeAndCountBytes(file, encoding, fileSize)
values.put("decoded_body_size", size)
file
} else {
values.put("data_location", DataLocation.IN_DATABASE)
val bodyData = getBodyBytes(body)
values.put("data", bodyData)
val size = decodeAndCountBytes(bodyData.inputStream(), encoding, bodyData.size.toLong())
values.put("decoded_body_size", size)
null
}
}
private fun writeBodyToDiskIfNecessary(part: Part): File? {
val body = part.body
return if (body is BinaryTempFileBody) {
body.file
} else {
writeBodyToDisk(body)
}
}
private fun writeBodyToDisk(body: Body): File? {
val file = File.createTempFile("body", null, BinaryTempFileBody.getTempDirectory())
FileOutputStream(file).use { outputStream ->
body.writeTo(outputStream)
}
return file
}
private fun decodeAndCountBytes(file: File?, encoding: String, fallbackValue: Long): Long {
return FileInputStream(file).use { inputStream ->
decodeAndCountBytes(inputStream, encoding, fallbackValue)
}
}
private fun decodeAndCountBytes(rawInputStream: InputStream, encoding: String, fallbackValue: Long): Long {
return try {
getDecodingInputStream(rawInputStream, encoding).use { decodingInputStream ->
CountingOutputStream().use { countingOutputStream ->
IOUtils.copy(decodingInputStream, countingOutputStream)
countingOutputStream.count
}
}
} catch (e: IOException) {
fallbackValue
}
}
private fun getDecodingInputStream(rawInputStream: InputStream, encoding: String?): InputStream {
return when (encoding) {
MimeUtil.ENC_BASE64 -> {
object : Base64InputStream(rawInputStream) {
override fun close() {
super.close()
rawInputStream.close()
}
}
}
MimeUtil.ENC_QUOTED_PRINTABLE -> {
object : QuotedPrintableInputStream(rawInputStream) {
override fun close() {
super.close()
rawInputStream.close()
}
}
}
else -> {
rawInputStream
}
}
}
private fun getHeaderBytes(part: Part): ByteArray {
val output = ByteArrayOutputStream()
part.writeHeaderTo(output)
return output.toByteArray()
}
private fun getBodyBytes(body: Body): ByteArray {
val output = ByteArrayOutputStream()
body.writeTo(output)
return output.toByteArray()
}
private fun getTransferEncoding(part: Part): String {
val contentTransferEncoding = part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING).firstOrNull()
return contentTransferEncoding?.lowercase() ?: MimeUtil.ENC_7BIT
}
private fun addChildrenToStack(stack: Stack<PartContainer>, part: Part, parentId: Long) {
when (val body = part.body) {
is Multipart -> {
for (i in body.count - 1 downTo 0) {
val childPart = body.getBodyPart(i)
stack.push(PartContainer(parentId, childPart))
}
}
is Message -> {
stack.push(PartContainer(parentId, body))
}
}
}
private fun saveMessage(
database: SQLiteDatabase,
folderId: Long,
messageServerId: String,
rootMessagePartId: Long,
messageData: SaveMessageData,
replaceMessageId: Long?
): Long {
val message = messageData.message
when (messageData.downloadState) {
MessageDownloadState.ENVELOPE -> Unit
MessageDownloadState.PARTIAL -> message.setFlag(Flag.X_DOWNLOADED_PARTIAL, true)
MessageDownloadState.FULL -> message.setFlag(Flag.X_DOWNLOADED_FULL, true)
}
val values = ContentValues().apply {
put("folder_id", folderId)
put("uid", messageServerId)
put("deleted", 0)
put("empty", 0)
put("message_part_id", rootMessagePartId)
put("date", messageData.date)
put("internal_date", messageData.internalDate)
put("subject", messageData.subject)
put("flags", message.flags.toDatabaseValue())
put("read", message.isSet(Flag.SEEN).toDatabaseValue())
put("flagged", message.isSet(Flag.FLAGGED).toDatabaseValue())
put("answered", message.isSet(Flag.ANSWERED).toDatabaseValue())
put("forwarded", message.isSet(Flag.FORWARDED).toDatabaseValue())
put("sender_list", Address.pack(message.from))
put("to_list", Address.pack(message.getRecipients(RecipientType.TO)))
put("cc_list", Address.pack(message.getRecipients(RecipientType.CC)))
put("bcc_list", Address.pack(message.getRecipients(RecipientType.BCC)))
put("reply_to_list", Address.pack(message.replyTo))
put("attachment_count", messageData.attachmentCount)
put("message_id", message.messageId)
put("mime_type", message.mimeType)
put("encryption_type", messageData.encryptionType)
val previewResult = messageData.previewResult
put("preview_type", previewResult.previewType.toDatabaseValue())
if (previewResult.isPreviewTextAvailable) {
put("preview", previewResult.previewText)
} else {
putNull("preview")
}
}
return if (replaceMessageId != null) {
values.put("id", replaceMessageId)
database.replace("messages", null, values)
replaceMessageId
} else {
database.insert("messages", null, values)
}
}
private fun createOrReplaceFulltextEntry(database: SQLiteDatabase, messageId: Long, messageData: SaveMessageData) {
val fulltext = messageData.textForSearchIndex ?: return
val values = ContentValues().apply {
put("docid", messageId)
put("fulltext", fulltext)
}
database.replace("messages_fulltext", null, values)
}
private fun getMessage(folderId: Long, messageServerId: String): Pair<Long, Long?>? {
return lockableDatabase.execute(false) { db ->
db.query(
"messages",
arrayOf("id", "message_part_id"),
"folder_id = ? AND uid = ?",
arrayOf(folderId.toString(), messageServerId),
null,
null,
null
).use { cursor ->
if (cursor.moveToFirst()) {
val messageId = cursor.getLong(0)
val messagePartId = cursor.getLong(1)
messageId to messagePartId
} else {
null
}
}
}
}
private fun getLocalMessageInfo(folderId: Long, messageId: Long): Pair<String, Long?> {
return lockableDatabase.execute(false) { db ->
db.query(
"messages",
arrayOf("uid", "message_part_id"),
"folder_id = ? AND id = ?",
arrayOf(folderId.toString(), messageId.toString()),
null,
null,
null
).use { cursor ->
if (!cursor.moveToFirst()) error("Local message not found $folderId:$messageId")
val messageServerId = cursor.getString(0)!!
val messagePartId = cursor.getLong(1)
messageServerId to messagePartId
}
}
}
private fun deleteMessagePartsAndDataFromDisk(database: SQLiteDatabase, rootMessagePartId: Long) {
deleteMessageDataFromDisk(database, rootMessagePartId)
deleteMessageParts(database, rootMessagePartId)
}
private fun deleteMessageDataFromDisk(database: SQLiteDatabase, rootMessagePartId: Long) {
database.query(
"message_parts",
arrayOf("id"),
"root = ? AND data_location = " + DataLocation.ON_DISK,
arrayOf(rootMessagePartId.toString()),
null,
null,
null
).use { cursor ->
while (cursor.moveToNext()) {
val messagePartId = cursor.getLong(0)
attachmentFileManager.deleteFile(messagePartId)
}
}
}
private fun deleteMessageParts(database: SQLiteDatabase, rootMessagePartId: Long) {
database.delete("message_parts", "root = ?", arrayOf(rootMessagePartId.toString()))
}
}
private fun Set<Flag>.toDatabaseValue(): String {
return this
.filter { it !in SPECIAL_FLAGS }
.joinToString(separator = ",")
}
private fun Boolean.toDatabaseValue() = if (this) 1 else 0
private fun PreviewType.toDatabaseValue(): String {
return DatabasePreviewType.fromPreviewType(this).databaseValue
}
// Note: The contents of the 'message_parts' table depend on these values.
// TODO: currently unused, might be used for caching at a later point
internal object MessagePartType {
const val UNKNOWN = 0
}
// Note: The contents of the 'message_parts' table depend on these values.
internal object DataLocation {
const val MISSING = 0
const val IN_DATABASE = 1
const val ON_DISK = 2
const val CHILD_PART_CONTAINS_DATA = 3
}
private data class PartContainer(val parentId: Long?, val part: Part)

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,105 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import com.fsck.k9.mail.FolderClass
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mailstore.FolderDetails
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.MoreMessages
import com.fsck.k9.mailstore.toDatabaseFolderType
internal class UpdateFolderOperations(private val lockableDatabase: LockableDatabase) {
fun changeFolder(folderServerId: String, name: String, type: FolderType) {
lockableDatabase.execute(false) { db ->
val values = ContentValues().apply {
put("name", name)
put("type", type.toDatabaseFolderType())
}
db.update("folders", values, "server_id = ?", arrayOf(folderServerId))
}
}
fun updateFolderSettings(folderDetails: FolderDetails) {
lockableDatabase.execute(false) { db ->
val contentValues = ContentValues().apply {
put("top_group", folderDetails.isInTopGroup)
put("integrate", folderDetails.isIntegrate)
put("poll_class", folderDetails.syncClass.name)
put("display_class", folderDetails.displayClass.name)
put("notify_class", folderDetails.notifyClass.name)
put("push_class", folderDetails.pushClass.name)
}
db.update("folders", contentValues, "id = ?", arrayOf(folderDetails.folder.id.toString()))
}
}
fun setIncludeInUnifiedInbox(folderId: Long, includeInUnifiedInbox: Boolean) {
lockableDatabase.execute(false) { db ->
val contentValues = ContentValues().apply {
put("integrate", includeInUnifiedInbox)
}
db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
}
}
fun setDisplayClass(folderId: Long, folderClass: FolderClass) {
setString(folderId = folderId, columnName = "display_class", value = folderClass.name)
}
fun setSyncClass(folderId: Long, folderClass: FolderClass) {
setString(folderId = folderId, columnName = "poll_class", value = folderClass.name)
}
fun setPushClass(folderId: Long, folderClass: FolderClass) {
setString(folderId = folderId, columnName = "push_class", value = folderClass.name)
}
fun setNotificationClass(folderId: Long, folderClass: FolderClass) {
setString(folderId = folderId, columnName = "notify_class", value = folderClass.name)
}
fun setMoreMessages(folderId: Long, moreMessages: MoreMessages) {
setString(folderId = folderId, columnName = "more_messages", value = moreMessages.databaseName)
}
fun setLastChecked(folderId: Long, timestamp: Long) {
lockableDatabase.execute(false) { db ->
val contentValues = ContentValues().apply {
put("last_updated", timestamp)
}
db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
}
}
fun setStatus(folderId: Long, status: String?) {
setString(folderId = folderId, columnName = "status", value = status)
}
fun setVisibleLimit(folderId: Long, visibleLimit: Int) {
lockableDatabase.execute(false) { db ->
val contentValues = ContentValues().apply {
put("visible_limit", visibleLimit)
}
db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
}
}
private fun setString(folderId: Long, columnName: String, value: String?) {
lockableDatabase.execute(false) { db ->
val contentValues = ContentValues().apply {
if (value == null) {
putNull(columnName)
} else {
put(columnName, value)
}
}
db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
}
}
}

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 com.fsck.k9.preferences.Protocols
internal object MigrationTo65 {
@JvmStatic
fun addLocalOnlyColumnToFoldersTable(db: SQLiteDatabase, migrationsHelper: MigrationsHelper) {
db.execSQL("ALTER TABLE folders ADD local_only INTEGER")
if (isPop3Account(migrationsHelper)) {
db.execSQL("UPDATE folders SET local_only = CASE server_id WHEN 'INBOX' THEN 0 ELSE 1 END")
} else {
db.execSQL("UPDATE folders SET local_only = CASE server_id WHEN 'K9MAIL_INTERNAL_OUTBOX' THEN 1 ELSE 0 END")
}
}
private fun isPop3Account(migrationsHelper: MigrationsHelper): Boolean {
return migrationsHelper.account.incomingServerSettings.type == Protocols.POP3
}
}

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,93 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mail.FolderClass
internal class MigrationTo70(private val db: SQLiteDatabase) {
fun removePushState() {
renameFoldersTable()
createNewFoldersTable()
copyFoldersData()
dropOldFoldersTable()
recreateFoldersIndex()
recreateFoldersTriggers()
}
private fun renameFoldersTable() {
db.execSQL("ALTER TABLE folders RENAME TO folders_old")
}
private fun createNewFoldersTable() {
db.execSQL(
"CREATE TABLE folders (" +
"id INTEGER PRIMARY KEY," +
"name TEXT, " +
"last_updated INTEGER, " +
"unread_count INTEGER, " +
"visible_limit INTEGER, " +
"status TEXT, " +
"flagged_count INTEGER default 0, " +
"integrate INTEGER, " +
"top_group INTEGER, " +
"poll_class TEXT, " +
"push_class TEXT, " +
"display_class TEXT, " +
"notify_class TEXT default '" + FolderClass.INHERITED.name + "', " +
"more_messages TEXT default \"unknown\", " +
"server_id TEXT, " +
"local_only INTEGER, " +
"type TEXT DEFAULT \"regular\"" +
")"
)
}
private fun copyFoldersData() {
db.execSQL(
"""
INSERT INTO folders
SELECT
id,
name,
last_updated,
unread_count,
visible_limit,
status,
flagged_count,
integrate,
top_group,
poll_class,
push_class,
display_class,
notify_class,
more_messages,
server_id,
local_only,
type
FROM folders_old
""".trimIndent()
)
}
private fun dropOldFoldersTable() {
db.execSQL("DROP TABLE folders_old")
}
private fun recreateFoldersIndex() {
db.execSQL("DROP INDEX IF EXISTS folder_server_id")
db.execSQL("CREATE INDEX folder_server_id ON folders (server_id)")
}
private fun recreateFoldersTriggers() {
db.execSQL("DROP TRIGGER IF EXISTS delete_folder")
db.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;")
db.execSQL("DROP TRIGGER IF EXISTS delete_folder_extra_values")
db.execSQL(
"CREATE TRIGGER delete_folder_extra_values " +
"BEFORE DELETE ON folders " +
"BEGIN " +
"DELETE FROM folder_extra_values WHERE old.id = folder_id; " +
"END;"
)
}
}

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 timber.log.Timber
internal class MigrationTo73(private val db: SQLiteDatabase) {
private val serializer = PendingCommandSerializer.getInstance()
private val moshi = Moshi.Builder().build()
private val legacyAdapters = listOf(
"append" to moshi.adapter(LegacyPendingAppend::class.java),
"mark_all_as_read" to moshi.adapter(LegacyPendingMarkAllAsRead::class.java),
"set_flag" to moshi.adapter(LegacyPendingSetFlag::class.java),
"delete" to moshi.adapter(LegacyPendingDelete::class.java),
"expunge" to moshi.adapter(LegacyPendingExpunge::class.java),
"move_or_copy" to moshi.adapter(LegacyPendingMoveOrCopy::class.java),
"move_and_mark_as_read" to moshi.adapter(LegacyPendingMoveAndMarkAsRead::class.java)
).toMap()
fun rewritePendingCommandsToUseFolderIds() {
val pendingCommands = loadPendingCommands()
rewritePendingCommands(pendingCommands)
}
private fun loadPendingCommands(): Map<Long, LegacyPendingCommand> {
return db.rawQuery(
"SELECT id, command, data FROM pending_commands WHERE command != 'empty_trash'",
null
).use { cursor ->
cursor.map {
val commandId = cursor.getLong(0)
val command = cursor.getString(1)
val data = cursor.getString(2)
val pendingCommand = deserialize(command, data)
commandId to pendingCommand
}.toMap()
}
}
private fun rewritePendingCommands(pendingCommands: Map<Long, LegacyPendingCommand>) {
for ((commandId, pendingCommand) in pendingCommands) {
when (pendingCommand) {
is LegacyPendingAppend -> rewritePendingAppend(commandId, pendingCommand)
is LegacyPendingMarkAllAsRead -> rewritePendingMarkAllAsRead(commandId, pendingCommand)
is LegacyPendingSetFlag -> rewritePendingSetFlag(commandId, pendingCommand)
is LegacyPendingDelete -> rewritePendingDelete(commandId, pendingCommand)
is LegacyPendingExpunge -> rewritePendingExpunge(commandId, pendingCommand)
is LegacyPendingMoveOrCopy -> rewritePendingMoveOrCopy(commandId, pendingCommand)
is LegacyPendingMoveAndMarkAsRead -> rewritePendingMoveAndMarkAsRead(commandId, pendingCommand)
}
}
}
private fun rewritePendingAppend(commandId: Long, legacyPendingCommand: LegacyPendingAppend) {
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
PendingAppend.create(folderId, legacyPendingCommand.uid)
}
}
private fun rewritePendingMarkAllAsRead(commandId: Long, legacyPendingCommand: LegacyPendingMarkAllAsRead) {
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
PendingMarkAllAsRead.create(folderId)
}
}
private fun rewritePendingSetFlag(commandId: Long, legacyPendingCommand: LegacyPendingSetFlag) {
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
PendingSetFlag.create(
folderId,
legacyPendingCommand.newState,
legacyPendingCommand.flag,
legacyPendingCommand.uids
)
}
}
private fun rewritePendingDelete(commandId: Long, legacyPendingCommand: LegacyPendingDelete) {
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
PendingDelete.create(folderId, legacyPendingCommand.uids)
}
}
private fun rewritePendingExpunge(commandId: Long, legacyPendingCommand: LegacyPendingExpunge) {
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
PendingExpunge.create(folderId)
}
}
private fun rewritePendingMoveOrCopy(commandId: Long, legacyPendingCommand: LegacyPendingMoveOrCopy) {
rewriteOrRemovePendingCommand(
commandId,
legacyPendingCommand.srcFolder,
legacyPendingCommand.destFolder
) { (srcFolderId, destFolderId) ->
PendingMoveOrCopy.create(
srcFolderId,
destFolderId,
legacyPendingCommand.isCopy,
legacyPendingCommand.newUidMap
)
}
}
private fun rewritePendingMoveAndMarkAsRead(commandId: Long, legacyPendingCommand: LegacyPendingMoveAndMarkAsRead) {
rewriteOrRemovePendingCommand(
commandId,
legacyPendingCommand.srcFolder,
legacyPendingCommand.destFolder
) { (srcFolderId, destFolderId) ->
PendingMoveAndMarkAsRead.create(srcFolderId, destFolderId, legacyPendingCommand.newUidMap)
}
}
private fun rewriteOrRemovePendingCommand(
commandId: Long,
vararg folderServerIds: String,
convertPendingCommand: (folderIds: List<Long>) -> PendingCommand
) {
val folderIds = folderServerIds.map {
loadFolderId(it)
}
if (folderIds.any { it == null }) {
Timber.w("Couldn't find folder ID for pending command with database ID $commandId. Removing entry.")
removePendingCommand(commandId)
} else {
val pendingCommand = convertPendingCommand(folderIds.filterNotNull())
updatePendingCommand(commandId, pendingCommand)
}
}
private fun updatePendingCommand(commandId: Long, pendingCommand: PendingCommand) {
val contentValues = ContentValues().apply {
put("data", serializer.serialize(pendingCommand))
}
db.update("pending_commands", contentValues, "id = ?", arrayOf(commandId.toString()))
}
private fun removePendingCommand(commandId: Long) {
db.delete("pending_commands", "id = ?", arrayOf(commandId.toString()))
}
private fun loadFolderId(folderServerId: String): Long? {
return db.rawQuery("SELECT id from folders WHERE server_id = ?", arrayOf(folderServerId)).use { cursor ->
if (cursor.moveToFirst()) {
cursor.getLong(0)
} else {
null
}
}
}
private fun deserialize(commandName: String, data: String): LegacyPendingCommand {
val adapter = legacyAdapters[commandName] ?: error("Unsupported pending command type!")
return adapter.fromJson(data) ?: error("Error deserializing pending command")
}
}

View file

@ -0,0 +1,124 @@
package com.fsck.k9.storage.migrations
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.Account
import com.fsck.k9.Account.DeletePolicy
/**
* Remove all placeholder entries in 'messages' table
*
* When the user moves or deletes an email, the row in the 'messages' table is first updated with 'deleted = 1', turning
* it into a placeholder message. After the remote operation has completed, the row is removed.
*
* Placeholder messages are created to prevent an email from being downloaded again during a sync before the remote
* operation has finished. The placeholder message is also necessary to remember deleted messages when using a delete
* policy other than "delete from server".
*
* Previously these placeholder messages often weren't removed when they should have been. This will slowly grow the
* database.
* Here we remove all placeholder messages when the delete policy is "delete from server". This might also include
* placeholder messages that shouldn't be removed, because the remote operation hasn't been performed yet.
* However, since the app tries to execute all pending remote operations before doing a message sync, it's unlikely that
* deleted messages are re-downloaded. And if they are, the next sync after the remote operation has completed will
* remove them again.
*/
internal class MigrationTo74(private val db: SQLiteDatabase, private val account: Account) {
fun removeDeletedMessages() {
if (account.deletePolicy != DeletePolicy.ON_DELETE) return
db.query("messages", arrayOf("id"), "deleted = 1", null, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
destroyMessage(messageId = cursor.getLong(0))
}
}
}
private fun destroyMessage(messageId: Long) {
if (hasThreadChildren(messageId)) {
// This message has children in the thread structure so we need to make it an empty message.
val cv = ContentValues().apply {
put("deleted", 0)
put("empty", 1)
put("preview_type", "none")
put("read", 0)
put("flagged", 0)
put("answered", 0)
put("forwarded", 0)
putNull("subject")
putNull("sender_list")
putNull("date")
putNull("to_list")
putNull("cc_list")
putNull("bcc_list")
putNull("preview")
putNull("reply_to_list")
putNull("message_part_id")
putNull("flags")
putNull("attachment_count")
putNull("internal_date")
putNull("mime_type")
putNull("encryption_type")
}
db.update("messages", cv, "id = ?", arrayOf(messageId.toString()))
// Nothing else to do
return
}
// Get the message ID of the parent message if it's empty
var currentId = getEmptyThreadParent(messageId)
// Delete the placeholder message
deleteMessageRow(messageId)
// Walk the thread tree to delete all empty parents without children
while (currentId != -1L) {
if (hasThreadChildren(currentId)) {
// We made sure there are no empty leaf nodes and can stop now.
break
}
// Get ID of the (empty) parent for the next iteration
val newId = getEmptyThreadParent(currentId)
// Delete the empty message
deleteMessageRow(currentId)
currentId = newId
}
}
private fun hasThreadChildren(messageId: Long): Boolean {
return db.rawQuery(
"SELECT COUNT(t2.id) " +
"FROM threads t1 " +
"JOIN threads t2 ON (t2.parent = t1.id) " +
"WHERE t1.message_id = ?",
arrayOf(messageId.toString())
).use { cursor ->
cursor.moveToFirst() && !cursor.isNull(0) && cursor.getLong(0) > 0L
}
}
private fun getEmptyThreadParent(messageId: Long): Long {
return db.rawQuery(
"SELECT m.id " +
"FROM threads t1 " +
"JOIN threads t2 ON (t1.parent = t2.id) " +
"LEFT JOIN messages m ON (t2.message_id = m.id) " +
"WHERE t1.message_id = ? AND m.empty = 1",
arrayOf(messageId.toString())
).use { cursor ->
if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else -1
}
}
private fun deleteMessageRow(messageId: Long) {
// Delete the message
db.delete("messages", "id = ?", arrayOf(messageId.toString()))
// Delete row in 'threads' table
db.delete("threads", "message_id = ?", arrayOf(messageId.toString()))
}
}

View file

@ -0,0 +1,36 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mailstore.MigrationsHelper
internal class MigrationTo75(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
fun updateAccountWithSpecialFolderIds() {
val account = migrationsHelper.account
account.inboxFolderId = getFolderId(account.legacyInboxFolder)
account.outboxFolderId = getFolderId("K9MAIL_INTERNAL_OUTBOX")
account.draftsFolderId = getFolderId(account.importedDraftsFolder)
account.sentFolderId = getFolderId(account.importedSentFolder)
account.trashFolderId = getFolderId(account.importedTrashFolder)
account.archiveFolderId = getFolderId(account.importedArchiveFolder)
account.spamFolderId = getFolderId(account.importedSpamFolder)
account.autoExpandFolderId = getFolderId(account.importedAutoExpandFolder)
account.importedDraftsFolder = null
account.importedSentFolder = null
account.importedTrashFolder = null
account.importedArchiveFolder = null
account.importedSpamFolder = null
account.importedAutoExpandFolder = null
migrationsHelper.saveAccount()
}
private fun getFolderId(serverId: String?): Long? {
if (serverId == null) return null
return db.query("folders", arrayOf("id"), "server_id = ?", arrayOf(serverId), null, null, null).use { cursor ->
if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else null
}
}
}

View file

@ -0,0 +1,131 @@
package com.fsck.k9.storage.migrations
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import app.k9mail.core.android.common.database.map
import com.fsck.k9.Account
import com.fsck.k9.mailstore.MigrationsHelper
import com.fsck.k9.preferences.Protocols
import timber.log.Timber
/**
* Clean up special local folders
*
* In the past local special folders were not always created. For example, when importing settings or when setting up
* an account, but checking the server settings didn't succeed and the user decided to continue anyway.
*
* Clicking "Next" in the incoming server settings screen would check the server settings and, in the case of success,
* create new special local folders even if they already existed. So it's also possible existing installations have
* multiple special local folders of one type.
*
* Here, we clean up local special folders to have exactly one of each type. Messages in additional folders will be
* moved to the folder we keep and then the other folders will be deleted. An exception are messages in old Outbox
* folders. They will be deleted and not be moved to the new/current Outbox folder because this would cause potentially
* very old messages to be sent. The right thing would be to move them to the Drafts folder. But this is much more
* complicated. They'd have to be uploaded if the Drafts folder is not a local folder. It's also not clear what should
* happen if there is no Drafts folder configured.
*/
internal class MigrationTo76(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
fun cleanUpSpecialLocalFolders() {
val account = migrationsHelper.account
Timber.v("Cleaning up Outbox folder")
val outboxFolderId = account.outboxFolderId ?: createFolder("Outbox", "K9MAIL_INTERNAL_OUTBOX", OUTBOX_FOLDER_TYPE)
deleteOtherOutboxFolders(outboxFolderId)
account.outboxFolderId = outboxFolderId
if (account.isPop3()) {
Timber.v("Cleaning up Drafts folder")
val draftsFolderId = account.draftsFolderId ?: createFolder("Drafts", "Drafts", DRAFTS_FOLDER_TYPE)
moveMessages(DRAFTS_FOLDER_TYPE, draftsFolderId)
account.draftsFolderId = draftsFolderId
Timber.v("Cleaning up Sent folder")
val sentFolderId = account.sentFolderId ?: createFolder("Sent", "Sent", SENT_FOLDER_TYPE)
moveMessages(SENT_FOLDER_TYPE, sentFolderId)
account.sentFolderId = sentFolderId
Timber.v("Cleaning up Trash folder")
val trashFolderId = account.trashFolderId ?: createFolder("Trash", "Trash", TRASH_FOLDER_TYPE)
moveMessages(TRASH_FOLDER_TYPE, trashFolderId)
account.trashFolderId = trashFolderId
}
migrationsHelper.saveAccount()
}
private fun createFolder(name: String, serverId: String, type: String): Long {
Timber.v(" Creating new local folder (name=$name, serverId=$serverId, type=$type)…")
val values = ContentValues().apply {
put("name", name)
put("visible_limit", 25)
put("integrate", 0)
put("top_group", 0)
put("poll_class", "NO_CLASS")
put("push_class", "SECOND_CLASS")
put("display_class", "NO_CLASS")
put("server_id", serverId)
put("local_only", 1)
put("type", type)
}
val folderId = db.insert("folders", null, values)
Timber.v(" Created folder with ID $folderId")
return folderId
}
private fun deleteOtherOutboxFolders(outboxFolderId: Long) {
val otherFolderIds = getOtherFolders(OUTBOX_FOLDER_TYPE, outboxFolderId)
for (folderId in otherFolderIds) {
deleteFolder(folderId)
}
}
private fun getOtherFolders(folderType: String, excludeFolderId: Long): List<Long> {
return db.query(
"folders",
arrayOf("id"),
"local_only = 1 AND type = ? AND id != ?",
arrayOf(folderType, excludeFolderId.toString()),
null,
null,
null
).use { cursor ->
cursor.map { cursor.getLong(0) }
}
}
private fun moveMessages(folderType: String, destinationFolderId: Long) {
val sourceFolderIds = getOtherFolders(folderType, destinationFolderId)
for (sourceFolderId in sourceFolderIds) {
moveMessages(sourceFolderId, destinationFolderId)
deleteFolder(sourceFolderId)
}
}
private fun moveMessages(sourceFolderId: Long, destinationFolderId: Long) {
Timber.v(" Moving messages from folder [$sourceFolderId] to folder [$destinationFolderId]…")
val values = ContentValues().apply {
put("folder_id", destinationFolderId)
}
val rows = db.update("messages", values, "folder_id = ?", arrayOf(sourceFolderId.toString()))
Timber.v(" $rows messages moved.")
}
private fun deleteFolder(folderId: Long) {
Timber.v(" Deleting folder [$folderId]")
db.delete("folders", "id = ?", arrayOf(folderId.toString()))
}
private fun Account.isPop3() = incomingServerSettings.type == Protocols.POP3
companion object {
private const val OUTBOX_FOLDER_TYPE = "outbox"
private const val DRAFTS_FOLDER_TYPE = "drafts"
private const val SENT_FOLDER_TYPE = "sent"
private const val TRASH_FOLDER_TYPE = "trash"
}
}

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,59 @@
package com.fsck.k9.storage.migrations
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import androidx.core.database.getStringOrNull
import app.k9mail.core.android.common.database.map
/**
* Write the address fields to use ASCII 1 instead of ASCII 0 as separator.
* Separator was previously ASCII 0 but this caused problems with LIKE and searching.
*/
internal class MigrationTo84(private val db: SQLiteDatabase) {
fun rewriteAddresses() {
val addressSets = db.rawQuery(
"SELECT id, to_list, cc_list, bcc_list, reply_to_list, sender_list FROM messages WHERE empty = 0 AND deleted = 0",
null
).use { cursor ->
cursor.map {
val messageId = it.getLong(0)
messageId to AddressSet(
toList = it.getStringOrNull(1),
ccList = it.getStringOrNull(2),
bccList = it.getStringOrNull(3),
replyToList = it.getStringOrNull(4),
senderList = it.getStringOrNull(5)
)
}.toMap()
}
for ((messageId, addressSet) in addressSets) {
rewriteAddresses(messageId, addressSet)
}
}
private fun rewriteAddress(inAddress: String?): String? {
return inAddress?.replace(oldChar = '\u0000', newChar = '\u0001')
}
private fun rewriteAddresses(messageId: Long, addressSet: AddressSet) {
val cv = ContentValues().apply {
put("to_list", rewriteAddress(addressSet.toList))
put("cc_list", rewriteAddress(addressSet.ccList))
put("bcc_list", rewriteAddress(addressSet.bccList))
put("reply_to_list", rewriteAddress(addressSet.replyToList))
put("sender_list", rewriteAddress(addressSet.senderList))
}
db.update("messages", cv, "id = ?", arrayOf(messageId.toString()))
}
}
private data class AddressSet(
val toList: String?,
val ccList: String?,
val bccList: String?,
val replyToList: String?,
val senderList: String?
)

View file

@ -0,0 +1,34 @@
package com.fsck.k9.storage.migrations
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.mailstore.MigrationsHelper
object Migrations {
@JvmStatic
fun upgradeDatabase(db: SQLiteDatabase, migrationsHelper: MigrationsHelper) {
val oldVersion = db.version
if (oldVersion < 62) MigrationTo62.addServerIdColumnToFoldersTable(db)
if (oldVersion < 64) MigrationTo64.addExtraValuesTables(db)
if (oldVersion < 65) MigrationTo65.addLocalOnlyColumnToFoldersTable(db, migrationsHelper)
if (oldVersion < 66) MigrationTo66.addEncryptionTypeColumnToMessagesTable(db)
if (oldVersion < 67) MigrationTo67.addTypeColumnToFoldersTable(db, migrationsHelper)
if (oldVersion < 68) MigrationTo68.addOutboxStateTable(db)
if (oldVersion < 69) MigrationTo69(db).createPendingDelete()
if (oldVersion < 70) MigrationTo70(db).removePushState()
if (oldVersion < 71) MigrationTo71(db).cleanUpFolderClass()
if (oldVersion < 72) MigrationTo72(db).createMessagePartsRootIndex()
if (oldVersion < 73) MigrationTo73(db).rewritePendingCommandsToUseFolderIds()
if (oldVersion < 74) MigrationTo74(db, migrationsHelper.account).removeDeletedMessages()
if (oldVersion < 75) MigrationTo75(db, migrationsHelper).updateAccountWithSpecialFolderIds()
if (oldVersion < 76) MigrationTo76(db, migrationsHelper).cleanUpSpecialLocalFolders()
// 77: No longer necessary
if (oldVersion < 78) MigrationTo78(db).removeServerIdFromLocalFolders()
if (oldVersion < 79) MigrationTo79(db).updateDeleteMessageTrigger()
if (oldVersion < 80) MigrationTo80(db).rewriteLastUpdatedColumn()
if (oldVersion < 81) MigrationTo81(db).addNotificationsTable()
if (oldVersion < 82) MigrationTo82(db).addNewMessageColumn()
if (oldVersion < 83) MigrationTo83(db, migrationsHelper).rewriteHighestKnownUid()
if (oldVersion < 84) MigrationTo84(db).rewriteAddresses()
}
}

View file

@ -0,0 +1,72 @@
package com.fsck.k9.storage.notifications
import android.database.sqlite.SQLiteDatabase
import com.fsck.k9.controller.MessageReference
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.notification.NotificationStore
import com.fsck.k9.notification.NotificationStoreOperation
class K9NotificationStore(private val lockableDatabase: LockableDatabase) : NotificationStore {
override fun persistNotificationChanges(operations: List<NotificationStoreOperation>) {
lockableDatabase.execute(true) { db ->
for (operation in operations) {
when (operation) {
is NotificationStoreOperation.Add -> {
addNotification(db, operation.messageReference, operation.notificationId, operation.timestamp)
}
is NotificationStoreOperation.ChangeToActive -> {
setNotificationId(db, operation.messageReference, operation.notificationId)
}
is NotificationStoreOperation.ChangeToInactive -> {
clearNotificationId(db, operation.messageReference)
}
is NotificationStoreOperation.Remove -> {
removeNotification(db, operation.messageReference)
}
}
}
}
}
override fun clearNotifications() {
lockableDatabase.execute(false) { db ->
db.delete("notifications", null, null)
}
}
private fun addNotification(
database: SQLiteDatabase,
messageReference: MessageReference,
notificationId: Int,
timestamp: Long
) {
database.execSQL(
"INSERT INTO notifications(message_id, notification_id, timestamp) " +
"SELECT id, ?, ? FROM messages WHERE folder_id = ? AND uid = ?",
arrayOf(notificationId, timestamp, messageReference.folderId, messageReference.uid)
)
}
private fun setNotificationId(database: SQLiteDatabase, messageReference: MessageReference, notificationId: Int) {
database.execSQL(
"UPDATE notifications SET notification_id = ? WHERE message_id IN " +
"(SELECT id FROM messages WHERE folder_id = ? AND uid = ?)",
arrayOf(notificationId, messageReference.folderId, messageReference.uid)
)
}
private fun clearNotificationId(database: SQLiteDatabase, messageReference: MessageReference) {
database.execSQL(
"UPDATE notifications SET notification_id = NULL WHERE message_id IN " +
"(SELECT id FROM messages WHERE folder_id = ? AND uid = ?)",
arrayOf(messageReference.folderId, messageReference.uid)
)
}
private fun removeNotification(database: SQLiteDatabase, messageReference: MessageReference) {
database.execSQL(
"DELETE FROM notifications WHERE message_id IN (SELECT id FROM messages WHERE folder_id = ? AND uid = ?)",
arrayOf(messageReference.folderId, messageReference.uid)
)
}
}

View file

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

View file

@ -0,0 +1,207 @@
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 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 StorageEditorTest : K9RobolectricTest() {
private val storage: Storage = Storage(mapOf("storage-key" to "storage-value"))
private val storageUpdater = TestStorageUpdater(storage)
private val storagePersister = mock<K9StoragePersister>()
private val storagePersisterOps = mock<StoragePersistOperations>()
private val editor = K9StorageEditor(storageUpdater, storagePersister)
private val workingMap = mutableMapOf<String, String>()
private val newValues: Map<String, String>
get() = storageUpdater.newStorage!!.all
@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.all)
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)
Unit
}
}
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)
}
}

View file

@ -0,0 +1,120 @@
package com.fsck.k9.preferences
import android.content.Context
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations
import com.fsck.k9.storage.K9RobolectricTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.inOrder
import org.mockito.kotlin.never
import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoMoreInteractions
import org.robolectric.RuntimeEnvironment
class StoragePersisterTest : K9RobolectricTest() {
private var context: Context = RuntimeEnvironment.getApplication()
private var storagePersister = K9StoragePersister(context)
@Test
fun doInTransaction_order() {
val operationCallback = prepareCallback()
storagePersister.doInTransaction(operationCallback)
inOrder(operationCallback) {
verify(operationCallback).beforePersistTransaction(any())
verify(operationCallback).persist(any())
verify(operationCallback).onPersistTransactionSuccess(any())
}
verifyNoMoreInteractions(operationCallback)
}
@Test
fun doInTransaction_put() {
val operationCallback = prepareCallback(
persistOp = { ops -> ops.put("x", "y") },
onSuccess = { map ->
assertEquals(1, map.size)
assertEquals("y", map["x"])
}
)
storagePersister.doInTransaction(operationCallback)
val values = storagePersister.loadValues().all
assertEquals(1, values.size)
assertEquals("y", values["x"])
}
@Test
fun doInTransaction_putAndThrow() {
val exception = Exception("boom")
val operationCallback = prepareCallback(
persistOp = { ops ->
ops.put("x", "y")
throw exception
}
)
try {
storagePersister.doInTransaction(operationCallback)
fail("expected exception")
} catch (e: Exception) {
assertSame(exception, e)
}
val values = storagePersister.loadValues()
assertTrue(values.isEmpty())
verify(operationCallback, never()).onPersistTransactionSuccess(any())
}
@Test
fun doInTransaction_remove() {
val operationCallback = prepareCallback(
before = { map -> map["x"] = "y" },
persistOp = { ops -> ops.remove("x") },
onSuccess = { map -> assertTrue(map.isEmpty()) }
)
storagePersister.doInTransaction(operationCallback)
val values = storagePersister.loadValues()
assertTrue(values.isEmpty())
}
@Test
fun doInTransaction_before_preserveButNotPersist() {
val operationCallback = prepareCallback(
before = { map -> map["x"] = "y" },
onSuccess = { map -> assertEquals("y", map["x"]) }
)
storagePersister.doInTransaction(operationCallback)
val values = storagePersister.loadValues()
assertTrue(values.isEmpty())
}
private fun prepareCallback(
persistOp: ((StoragePersistOperations) -> Unit)? = null,
before: ((MutableMap<String, String>) -> Unit)? = null,
onSuccess: ((Map<String, String>) -> Unit)? = null
): StoragePersistOperationCallback = spy(object : StoragePersistOperationCallback {
override fun beforePersistTransaction(workingStorage: MutableMap<String, String>) {
before?.invoke(workingStorage)
}
override fun persist(ops: StoragePersistOperations) {
persistOp?.invoke(ops)
}
override fun onPersistTransactionSuccess(workingStorage: Map<String, String>) {
onSuccess?.invoke(workingStorage)
}
})
}

View file

@ -0,0 +1,16 @@
package com.fsck.k9.storage
import android.app.Application
import org.junit.runner.RunWith
import org.koin.test.AutoCloseKoinTest
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* A Robolectric test that creates an instance of our [Application] test class [TestApp].
*
* See also [RobolectricTest].
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = TestApp::class)
abstract class K9RobolectricTest : AutoCloseKoinTest()

View file

@ -0,0 +1,15 @@
package com.fsck.k9.storage
import android.app.Application
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* A Robolectric test that does not create an instance of our [Application] class.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = EmptyApplication::class)
abstract class RobolectricTest
class EmptyApplication : Application()

View file

@ -0,0 +1,424 @@
package com.fsck.k9.storage;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import android.app.Application;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.text.TextUtils;
import com.fsck.k9.Account;
import com.fsck.k9.core.BuildConfig;
import com.fsck.k9.mail.AuthType;
import com.fsck.k9.mail.ConnectionSecurity;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.ServerSettings;
import com.fsck.k9.mailstore.LocalStore;
import com.fsck.k9.mailstore.LockableDatabase;
import com.fsck.k9.mailstore.MigrationsHelper;
import com.fsck.k9.mailstore.StorageManager;
import org.junit.Before;
import org.junit.Test;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.shadows.ShadowLog;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class StoreSchemaDefinitionTest extends K9RobolectricTest {
private StoreSchemaDefinition storeSchemaDefinition;
@Before
public void setUp() throws MessagingException {
ShadowLog.stream = System.out;
Application application = RuntimeEnvironment.getApplication();
StorageManager.getInstance(application);
storeSchemaDefinition = createStoreSchemaDefinition();
}
@Test
public void getVersion_shouldReturnCurrentDatabaseVersion() {
int version = storeSchemaDefinition.getVersion();
assertEquals(StoreSchemaDefinition.DB_VERSION, version);
}
@Test
public void doDbUpgrade_withEmptyDatabase_shouldSetsDatabaseVersion() {
SQLiteDatabase database = SQLiteDatabase.create(null);
storeSchemaDefinition.doDbUpgrade(database);
assertEquals(StoreSchemaDefinition.DB_VERSION, database.getVersion());
}
@Test
public void doDbUpgrade_withBadDatabase_shouldThrowInDebugBuild() {
if (BuildConfig.DEBUG) {
SQLiteDatabase database = SQLiteDatabase.create(null);
database.setVersion(61);
try {
storeSchemaDefinition.doDbUpgrade(database);
fail("Expected Error");
} catch (Error e) {
assertEquals("Exception while upgrading database", e.getMessage());
}
}
}
@Test
public void doDbUpgrade_withV61_shouldUpgradeDatabaseToLatestVersion() {
SQLiteDatabase database = createV61Database();
storeSchemaDefinition.doDbUpgrade(database);
assertEquals(StoreSchemaDefinition.DB_VERSION, database.getVersion());
}
@Test
public void doDbUpgrade_withV61() {
SQLiteDatabase database = createV61Database();
insertMessageWithSubject(database, "Test Email");
storeSchemaDefinition.doDbUpgrade(database);
assertMessageWithSubjectExists(database, "Test Email");
}
@Test
public void doDbUpgrade_fromV61_shouldResultInSameTables() {
SQLiteDatabase newDatabase = createNewDatabase();
SQLiteDatabase upgradedDatabase = createV61Database();
storeSchemaDefinition.doDbUpgrade(upgradedDatabase);
assertDatabaseTablesEquals(newDatabase, upgradedDatabase);
}
@Test
public void doDbUpgrade_fromV61_shouldResultInSameTriggers() {
SQLiteDatabase newDatabase = createNewDatabase();
SQLiteDatabase upgradedDatabase = createV61Database();
storeSchemaDefinition.doDbUpgrade(upgradedDatabase);
assertDatabaseTriggersEquals(newDatabase, upgradedDatabase);
}
@Test
public void doDbUpgrade_fromV61_shouldResultInSameIndexes() {
SQLiteDatabase newDatabase = createNewDatabase();
SQLiteDatabase upgradedDatabase = createV61Database();
storeSchemaDefinition.doDbUpgrade(upgradedDatabase);
assertDatabaseIndexesEquals(newDatabase, upgradedDatabase);
}
private SQLiteDatabase createV61Database() {
SQLiteDatabase database = SQLiteDatabase.create(null);
initV61Database(database);
return database;
}
private void initV61Database(SQLiteDatabase db) {
db.beginTransaction();
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, " +
"push_state TEXT, " +
"last_pushed INTEGER, " +
"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\"" +
")");
db.execSQL("CREATE INDEX IF NOT EXISTS folder_name ON folders (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" +
")");
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 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 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_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; " +
"END");
db.execSQL("DROP TABLE IF EXISTS messages_fulltext");
db.execSQL("CREATE VIRTUAL TABLE messages_fulltext USING fts4 (fulltext)");
db.setVersion(61);
db.setTransactionSuccessful();
db.endTransaction();
}
private void assertMessageWithSubjectExists(SQLiteDatabase database, String subject) {
Cursor cursor = database.query("messages", new String[] { "subject" }, null, null, null, null, null);
try {
assertTrue(cursor.moveToFirst());
assertEquals(subject, cursor.getString(0));
} finally {
cursor.close();
}
}
private void assertDatabaseTablesEquals(SQLiteDatabase expected, SQLiteDatabase actual) {
List<String> tablesInNewDatabase = tablesInDatabase(expected);
Collections.sort(tablesInNewDatabase);
List<String> tablesInUpgradedDatabase = tablesInDatabase(actual);
Collections.sort(tablesInUpgradedDatabase);
assertEquals(tablesInNewDatabase, tablesInUpgradedDatabase);
}
private void assertDatabaseTriggersEquals(SQLiteDatabase expected, SQLiteDatabase actual) {
List<String> triggersInNewDatabase = triggersInDatabase(expected);
Collections.sort(triggersInNewDatabase);
List<String> triggersInUpgradedDatabase = triggersInDatabase(actual);
Collections.sort(triggersInUpgradedDatabase);
assertEquals(triggersInNewDatabase, triggersInUpgradedDatabase);
}
private void assertDatabaseIndexesEquals(SQLiteDatabase expected, SQLiteDatabase actual) {
List<String> indexesInNewDatabase = indexesInDatabase(expected);
Collections.sort(indexesInNewDatabase);
List<String> indexesInUpgradedDatabase = indexesInDatabase(actual);
Collections.sort(indexesInUpgradedDatabase);
assertEquals(indexesInNewDatabase, indexesInUpgradedDatabase);
}
private List<String> tablesInDatabase(SQLiteDatabase db) {
return objectsInDatabase(db, "table");
}
private List<String> triggersInDatabase(SQLiteDatabase db) {
return objectsInDatabase(db, "trigger");
}
private List<String> indexesInDatabase(SQLiteDatabase db) {
return objectsInDatabase(db, "index");
}
private List<String> objectsInDatabase(SQLiteDatabase db, String type) {
List<String> databaseObjects = new ArrayList<>();
Cursor cursor = db.rawQuery("SELECT sql FROM sqlite_master WHERE type = ? AND sql IS NOT NULL",
new String[] { type });
try {
while (cursor.moveToNext()) {
String sql = cursor.getString(cursor.getColumnIndex("sql"));
String resortedSql = "table".equals(type) ? sortTableColumns(sql) : sql;
databaseObjects.add(resortedSql);
}
} finally {
cursor.close();
}
return databaseObjects;
}
private String sortTableColumns(String sql) {
int positionOfColumnDefinitions = sql.indexOf('(');
String columnDefinitionsSql = sql.substring(positionOfColumnDefinitions + 1, sql.length() - 1);
String[] columnDefinitions = columnDefinitionsSql.split(" *, *(?![^(]*\\))");
Arrays.sort(columnDefinitions);
String sqlPrefix = sql.substring(0, positionOfColumnDefinitions + 1);
String sortedColumnDefinitionsSql = TextUtils.join(", ", columnDefinitions);
return sqlPrefix + sortedColumnDefinitionsSql + ")";
}
private void insertMessageWithSubject(SQLiteDatabase database, String subject) {
ContentValues data = new ContentValues();
data.put("subject", subject);
long rowId = database.insert("messages", null, data);
assertNotEquals(-1, rowId);
}
private StoreSchemaDefinition createStoreSchemaDefinition() throws MessagingException {
final Account account = createAccount();
final LockableDatabase lockableDatabase = createLockableDatabase();
final LocalStore localStore = mock(LocalStore.class);
when(localStore.getDatabase()).thenReturn(lockableDatabase);
MigrationsHelper migrationsHelper = new MigrationsHelper() {
@Override
public Account getAccount() {
return account;
}
@Override
public void saveAccount() {
// Do nothing
}
};
return new StoreSchemaDefinition(migrationsHelper);
}
private LockableDatabase createLockableDatabase() throws MessagingException {
LockableDatabase lockableDatabase = mock(LockableDatabase.class);
when(lockableDatabase.execute(anyBoolean(), any(LockableDatabase.DbCallback.class))).thenReturn(false);
return lockableDatabase;
}
private Account createAccount() {
Account account = mock(Account.class);
when(account.getLegacyInboxFolder()).thenReturn("Inbox");
when(account.getImportedTrashFolder()).thenReturn("Trash");
when(account.getImportedDraftsFolder()).thenReturn("Drafts");
when(account.getImportedSpamFolder()).thenReturn("Spam");
when(account.getImportedSentFolder()).thenReturn("Sent");
when(account.getImportedArchiveFolder()).thenReturn(null);
when(account.getLocalStorageProviderId()).thenReturn(StorageManager.InternalStorageProvider.ID);
ServerSettings incomingServerSettings = new ServerSettings("dummy", "", -1, ConnectionSecurity.NONE,
AuthType.AUTOMATIC, "", "", null);
when(account.getIncomingServerSettings()).thenReturn(incomingServerSettings);
return account;
}
private SQLiteDatabase createNewDatabase() {
SQLiteDatabase database = SQLiteDatabase.create(null);
storeSchemaDefinition.doDbUpgrade(database);
return database;
}
}

View file

@ -0,0 +1,35 @@
package com.fsck.k9.storage
import android.app.Application
import com.fsck.k9.AppConfig
import com.fsck.k9.Core
import com.fsck.k9.CoreResourceProvider
import com.fsck.k9.DI
import com.fsck.k9.K9
import com.fsck.k9.backend.BackendManager
import com.fsck.k9.coreModules
import com.fsck.k9.crypto.EncryptionExtractor
import com.fsck.k9.preferences.K9StoragePersister
import com.fsck.k9.preferences.StoragePersister
import org.koin.dsl.module
import org.mockito.kotlin.mock
class TestApp : Application() {
override fun onCreate() {
Core.earlyInit()
super.onCreate()
DI.start(this, coreModules + storageModule + testModule)
K9.init(this)
Core.init(this)
}
}
val testModule = module {
single { AppConfig(emptyList()) }
single { mock<CoreResourceProvider>() }
single { mock<EncryptionExtractor>() }
single<StoragePersister> { K9StoragePersister(get()) }
single { mock<BackendManager>() }
}

View file

@ -0,0 +1,65 @@
package com.fsck.k9.storage.messages
import assertk.assertThat
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import com.fsck.k9.storage.RobolectricTest
import org.junit.Test
class CheckFolderOperationsTest : RobolectricTest() {
private val sqliteDatabase = createDatabase()
private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
private val checkFolderOperations = CheckFolderOperations(lockableDatabase)
@Test
fun `single folder not included in Unified Inbox`() {
val folderIds = listOf(sqliteDatabase.createFolder(integrate = false))
val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
assertThat(result).isFalse()
}
@Test
fun `single folder included in Unified Inbox`() {
val folderIds = listOf(sqliteDatabase.createFolder(integrate = true))
val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
assertThat(result).isTrue()
}
@Test
fun `not all folders included in Unified Inbox`() {
val folderIds = listOf(
sqliteDatabase.createFolder(integrate = true),
sqliteDatabase.createFolder(integrate = false)
)
val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
assertThat(result).isFalse()
}
@Test
fun `1000 folders included in Unified Inbox`() {
val folderIds = List(1000) {
sqliteDatabase.createFolder(integrate = true)
}
val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
assertThat(result).isTrue()
}
@Test
fun `999 of 1000 folders included in Unified Inbox`() {
val folderIds = List(999) {
sqliteDatabase.createFolder(integrate = true)
} + sqliteDatabase.createFolder(integrate = false)
val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
assertThat(result).isFalse()
}
}

View file

@ -0,0 +1,114 @@
package com.fsck.k9.storage.messages
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import org.junit.Assert.fail
import org.junit.Test
class ChunkedDatabaseOperationsTest {
@Test(expected = IllegalArgumentException::class)
fun `empty list`() {
performChunkedOperation(
arguments = emptyList(),
argumentTransformation = Int::toString,
operation = ::failCallback
)
}
@Test(expected = IllegalArgumentException::class)
fun `chunkSize = 0`() {
performChunkedOperation(
arguments = listOf(1),
argumentTransformation = Int::toString,
chunkSize = 0,
operation = ::failCallback
)
}
@Test(expected = IllegalArgumentException::class)
fun `chunkSize = 1001`() {
performChunkedOperation(
arguments = listOf(1),
argumentTransformation = Int::toString,
chunkSize = 1001,
operation = ::failCallback
)
}
@Test
fun `single item`() {
val chunks = mutableListOf<Pair<String, Array<String>>>()
performChunkedOperation(
arguments = listOf(1),
argumentTransformation = Int::toString
) { selectionSet, selectionArguments ->
chunks.add(selectionSet to selectionArguments)
Unit
}
assertThat(chunks).hasSize(1)
with(chunks.first()) {
assertThat(first).isEqualTo("IN (?)")
assertThat(second).isEqualTo(arrayOf("1"))
}
}
@Test
fun `2 items with chunk size of 1`() {
val chunks = mutableListOf<Pair<String, Array<String>>>()
performChunkedOperation(
arguments = listOf(1, 2),
argumentTransformation = Int::toString,
chunkSize = 1
) { selectionSet, selectionArguments ->
chunks.add(selectionSet to selectionArguments)
Unit
}
assertThat(chunks).hasSize(2)
with(chunks[0]) {
assertThat(first).isEqualTo("IN (?)")
assertThat(second).isEqualTo(arrayOf("1"))
}
with(chunks[1]) {
assertThat(first).isEqualTo("IN (?)")
assertThat(second).isEqualTo(arrayOf("2"))
}
}
@Test
fun `14 items with chunk size of 5`() {
val chunks = mutableListOf<Pair<String, Array<String>>>()
performChunkedOperation(
arguments = (1..14).toList(),
argumentTransformation = Int::toString,
chunkSize = 5
) { selectionSet, selectionArguments ->
chunks.add(selectionSet to selectionArguments)
Unit
}
assertThat(chunks).hasSize(3)
with(chunks[0]) {
assertThat(first).isEqualTo("IN (?,?,?,?,?)")
assertThat(second).isEqualTo(arrayOf("1", "2", "3", "4", "5"))
}
with(chunks[1]) {
assertThat(first).isEqualTo("IN (?,?,?,?,?)")
assertThat(second).isEqualTo(arrayOf("6", "7", "8", "9", "10"))
}
with(chunks[2]) {
assertThat(first).isEqualTo("IN (?,?,?,?)")
assertThat(second).isEqualTo(arrayOf("11", "12", "13", "14"))
}
}
@Suppress("UNUSED_PARAMETER")
private fun failCallback(selectionSet: String, selectionArguments: Array<String>) {
fail("'operation' callback called when it shouldn't")
}
}

View file

@ -0,0 +1,252 @@
package com.fsck.k9.storage.messages
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isNotIn
import assertk.assertions.isNull
import com.fsck.k9.mail.crlf
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.storage.RobolectricTest
import okio.buffer
import okio.sink
import okio.source
import org.junit.After
import org.junit.Test
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000"
class CopyMessageOperationsTest : RobolectricTest() {
private val messagePartDirectory = createRandomTempDirectory()
private val sqliteDatabase = createDatabase()
private val storageManager = mock<StorageManager> {
on { getAttachmentDirectory(eq(ACCOUNT_UUID), anyOrNull()) } doReturn messagePartDirectory
}
private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
private val attachmentFileManager = AttachmentFileManager(storageManager, ACCOUNT_UUID)
private val threadMessageOperations = ThreadMessageOperations()
private val copyMessageOperations = CopyMessageOperations(
lockableDatabase,
attachmentFileManager,
threadMessageOperations
)
@After
fun tearDown() {
messagePartDirectory.deleteRecursively()
}
@Test
fun `copy message that is part of a thread`() {
val sourceMessagePartId1 = sqliteDatabase.createMessagePart(
seq = 0,
dataLocation = DataLocation.CHILD_PART_CONTAINS_DATA,
mimeType = "multipart/mixed",
boundary = "--boundary",
header = "Message-ID: <msg0002@domain.example>\nIn-Reply-To: <msg0001@domain.example>\n".crlf()
)
val sourceMessagePartId2 = sqliteDatabase.createMessagePart(
seq = 1,
root = sourceMessagePartId1,
parent = sourceMessagePartId1,
mimeType = "text/plain",
dataLocation = DataLocation.IN_DATABASE,
data = "Text part".toByteArray()
)
val sourceMessagePartId3 = sqliteDatabase.createMessagePart(
seq = 2,
root = sourceMessagePartId1,
parent = sourceMessagePartId1,
mimeType = "application/octet-stream",
dataLocation = DataLocation.ON_DISK
)
attachmentFileManager.getAttachmentFile(sourceMessagePartId3).sink().buffer().use { sink ->
sink.writeUtf8("Part contents")
}
val messageId1 = sqliteDatabase.createMessage(
folderId = 1,
empty = true,
messageIdHeader = "<msg0001@domain.example>"
)
val messageId2 = sqliteDatabase.createMessage(
folderId = 1,
empty = false,
messageIdHeader = "<msg0002@domain.example>",
messagePartId = sourceMessagePartId1
)
val messageId3 = sqliteDatabase.createMessage(
folderId = 1,
empty = false,
messageIdHeader = "<msg0003@domain.example>"
)
val threadId1 = sqliteDatabase.createThread(messageId1)
val threadId2 = sqliteDatabase.createThread(messageId2, root = threadId1, parent = threadId1)
val threadId3 = sqliteDatabase.createThread(messageId3, root = threadId1, parent = threadId2)
val destinationMessageId = copyMessageOperations.copyMessage(messageId = messageId2, destinationFolderId = 2)
assertThat(destinationMessageId).isNotIn(setOf(messageId1, messageId2, messageId3))
val threads = sqliteDatabase.readThreads()
assertThat(threads).hasSize(3 + 2)
val destinationMessageThread = threads.first { it.messageId == destinationMessageId }
assertThat(destinationMessageThread.id).isNotIn(setOf(threadId1, threadId2, threadId3))
assertThat(destinationMessageThread.parent).isEqualTo(destinationMessageThread.root)
val destinationRootThread = threads.first { it.id == destinationMessageThread.root }
assertThat(destinationRootThread.messageId).isNotIn(setOf(messageId1, messageId2, messageId3))
assertThat(destinationRootThread.root).isEqualTo(destinationRootThread.id)
assertThat(destinationRootThread.parent).isNull()
val messages = sqliteDatabase.readMessages()
val destinationRootThreadMessage = messages.first { it.id == destinationRootThread.messageId }
assertThat(destinationRootThreadMessage.empty).isEqualTo(1)
assertThat(destinationRootThreadMessage.folderId).isEqualTo(2)
assertThat(destinationRootThreadMessage.messageId).isEqualTo("<msg0001@domain.example>")
val destinationMessage = messages.first { it.id == destinationMessageThread.messageId }
val sourceMessage = messages.first { it.id == messageId2 }
assertThat(destinationMessage).isEqualTo(
sourceMessage.copy(
id = destinationMessageId,
uid = destinationMessage.uid,
folderId = 2,
messagePartId = destinationMessage.messagePartId
)
)
val messageParts = sqliteDatabase.readMessageParts()
assertThat(messageParts).hasSize(3 + 3)
val sourceMessagePart1 = messageParts.first { it.id == sourceMessagePartId1 }
val sourceMessagePart2 = messageParts.first { it.id == sourceMessagePartId2 }
val sourceMessagePart3 = messageParts.first { it.id == sourceMessagePartId3 }
val destinationMessagePart1 = messageParts.first { it.id == destinationMessage.messagePartId }
val destinationMessagePart2 = messageParts.first { it.root == destinationMessage.messagePartId && it.seq == 1 }
val destinationMessagePart3 = messageParts.first { it.root == destinationMessage.messagePartId && it.seq == 2 }
assertThat(destinationMessagePart1).isNotIn(setOf(sourceMessagePart1, sourceMessagePart2, sourceMessagePart3))
assertThat(destinationMessagePart1).isEqualTo(
sourceMessagePart1.copy(
id = destinationMessagePart1.id,
root = destinationMessagePart1.id,
parent = -1
)
)
assertThat(destinationMessagePart2).isNotIn(setOf(sourceMessagePart1, sourceMessagePart2, sourceMessagePart3))
assertThat(destinationMessagePart2).isEqualTo(
sourceMessagePart2.copy(
id = destinationMessagePart2.id,
root = destinationMessagePart1.id,
parent = destinationMessagePart1.id
)
)
assertThat(destinationMessagePart3).isNotIn(setOf(sourceMessagePart1, sourceMessagePart2, sourceMessagePart3))
assertThat(destinationMessagePart3).isEqualTo(
sourceMessagePart3.copy(
id = destinationMessagePart3.id,
root = destinationMessagePart1.id,
parent = destinationMessagePart1.id
)
)
val files = messagePartDirectory.list()?.toList() ?: emptyList()
assertThat(files).hasSize(2)
attachmentFileManager.getAttachmentFile(destinationMessagePart3.id!!).source().buffer().use { source ->
assertThat(source.readUtf8()).isEqualTo("Part contents")
}
}
@Test
fun `copy message into an existing thread`() {
val sourceMessagePartId = sqliteDatabase.createMessagePart(
header = "Message-ID: <msg0002@domain.example>\nIn-Reply-To: <msg0001@domain.example>\n".crlf(),
mimeType = "text/plain",
dataLocation = DataLocation.IN_DATABASE,
data = "Text part".toByteArray()
)
attachmentFileManager.getAttachmentFile(sourceMessagePartId).sink().buffer().use { sink ->
sink.writeUtf8("Part contents")
}
val sourceMessageId = sqliteDatabase.createMessage(
folderId = 1,
empty = false,
messageIdHeader = "<msg0002@domain.example>",
messagePartId = sourceMessagePartId
)
val destinationMessageId = sqliteDatabase.createMessage(
folderId = 2,
empty = true,
messageIdHeader = "<msg0002@domain.example>"
)
val otherDestinationMessageId = sqliteDatabase.createMessage(
folderId = 2,
empty = false,
messageIdHeader = "<msg0003@domain.example>"
)
val destinationThreadId = sqliteDatabase.createThread(destinationMessageId)
val otherDestinationThreadId = sqliteDatabase.createThread(
otherDestinationMessageId,
root = destinationThreadId,
parent = destinationThreadId
)
val resultMessageId = copyMessageOperations.copyMessage(messageId = sourceMessageId, destinationFolderId = 2)
assertThat(resultMessageId).isEqualTo(destinationMessageId)
val threads = sqliteDatabase.readThreads()
assertThat(threads).hasSize(2 + 1)
val destinationThread = threads.first { it.messageId == destinationMessageId }
assertThat(destinationThread.id).isEqualTo(destinationThreadId)
assertThat(destinationThread.parent).isEqualTo(destinationThread.root)
val destinationRootThread = threads.first { it.id == destinationThread.root }
assertThat(destinationRootThread.root).isEqualTo(destinationRootThread.id)
assertThat(destinationRootThread.parent).isNull()
val otherDestinationThread = threads.first { it.id == otherDestinationThreadId }
assertThat(otherDestinationThread.root).isEqualTo(destinationRootThread.id)
assertThat(otherDestinationThread.parent).isEqualTo(destinationThread.id)
val messages = sqliteDatabase.readMessages()
assertThat(messages).hasSize(3 + 1)
val destinationRootThreadMessage = messages.first { it.id == destinationRootThread.messageId }
assertThat(destinationRootThreadMessage.empty).isEqualTo(1)
assertThat(destinationRootThreadMessage.folderId).isEqualTo(2)
assertThat(destinationRootThreadMessage.messageId).isEqualTo("<msg0001@domain.example>")
val destinationMessage = messages.first { it.id == destinationMessageId }
val sourceMessage = messages.first { it.id == sourceMessageId }
assertThat(destinationMessage).isEqualTo(
sourceMessage.copy(
id = destinationMessageId,
uid = destinationMessage.uid,
folderId = 2,
messagePartId = destinationMessage.messagePartId
)
)
val messageParts = sqliteDatabase.readMessageParts()
assertThat(messageParts).hasSize(1 + 1)
val sourceMessagePart = messageParts.first { it.id == sourceMessagePartId }
val destinationMessagePart = messageParts.first { it.id == destinationMessage.messagePartId }
assertThat(destinationMessagePart).isEqualTo(
sourceMessagePart.copy(
id = destinationMessagePart.id,
root = destinationMessagePart.id,
parent = -1
)
)
}
}

View file

@ -0,0 +1,91 @@
package com.fsck.k9.storage.messages
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import com.fsck.k9.mail.FolderClass
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mailstore.CreateFolderInfo
import com.fsck.k9.mailstore.FolderSettings
import com.fsck.k9.storage.RobolectricTest
import org.junit.Test
class CreateFolderOperationsTest : RobolectricTest() {
private val sqliteDatabase = createDatabase()
private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
private val createFolderOperations = CreateFolderOperations(lockableDatabase)
@Test
fun `create single folder`() {
createFolderOperations.createFolders(
listOf(
CreateFolderInfo(
serverId = "archived_messages",
name = "Archive",
type = FolderType.ARCHIVE,
settings = FolderSettings(
visibleLimit = 10,
displayClass = FolderClass.FIRST_CLASS,
syncClass = FolderClass.SECOND_CLASS,
notifyClass = FolderClass.NO_CLASS,
pushClass = FolderClass.NO_CLASS,
inTopGroup = true,
integrate = false
)
)
)
)
val folders = sqliteDatabase.readFolders()
assertThat(folders).hasSize(1)
val folder = folders.first()
assertThat(folder.serverId).isEqualTo("archived_messages")
assertThat(folder.name).isEqualTo("Archive")
assertThat(folder.type).isEqualTo("archive")
assertThat(folder.visibleLimit).isEqualTo(10)
assertThat(folder.displayClass).isEqualTo("FIRST_CLASS")
assertThat(folder.syncClass).isEqualTo("SECOND_CLASS")
assertThat(folder.notifyClass).isEqualTo("NO_CLASS")
assertThat(folder.pushClass).isEqualTo("NO_CLASS")
assertThat(folder.inTopGroup).isEqualTo(1)
assertThat(folder.integrate).isEqualTo(0)
}
@Test
fun `create multiple folders`() {
createFolderOperations.createFolders(
listOf(
createCreateFolderInfo(serverId = "folder1", name = "Inbox"),
createCreateFolderInfo(serverId = "folder2", name = "Sent"),
createCreateFolderInfo(serverId = "folder3", name = "Trash")
)
)
val folders = sqliteDatabase.readFolders()
assertThat(folders).hasSize(3)
assertThat(folders.map { it.serverId to it.name }.toSet()).isEqualTo(
setOf(
"folder1" to "Inbox",
"folder2" to "Sent",
"folder3" to "Trash"
)
)
}
fun createCreateFolderInfo(serverId: String, name: String): CreateFolderInfo {
return CreateFolderInfo(
serverId = serverId,
name = name,
type = FolderType.REGULAR,
settings = FolderSettings(
visibleLimit = 25,
displayClass = FolderClass.NO_CLASS,
syncClass = FolderClass.INHERITED,
notifyClass = FolderClass.INHERITED,
pushClass = FolderClass.NO_CLASS,
inTopGroup = false,
integrate = false
)
)
}
}

View file

@ -0,0 +1,62 @@
package com.fsck.k9.storage.messages
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.extracting
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.storage.RobolectricTest
import org.junit.After
import org.junit.Test
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000"
class DeleteFolderOperationsTest : RobolectricTest() {
private val messagePartDirectory = createRandomTempDirectory()
private val sqliteDatabase = createDatabase()
private val storageManager = mock<StorageManager> {
on { getAttachmentDirectory(eq(ACCOUNT_UUID), anyOrNull()) } doReturn messagePartDirectory
}
private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
private val attachmentFileManager = AttachmentFileManager(storageManager, ACCOUNT_UUID)
private val deleteFolderOperations = DeleteFolderOperations(lockableDatabase, attachmentFileManager)
@After
fun tearDown() {
messagePartDirectory.deleteRecursively()
}
@Test
fun `delete folder should remove message part files`() {
createFolderWithMessage("delete", "message1")
val messagePartId = createFolderWithMessage("retain", "message2")
deleteFolderOperations.deleteFolders(listOf("delete"))
val folders = sqliteDatabase.readFolders()
assertThat(folders).hasSize(1)
assertThat(folders.first().serverId).isEqualTo("retain")
val messages = sqliteDatabase.readMessages()
assertThat(messages).hasSize(1)
assertThat(messages.first().uid).isEqualTo("message2")
val messagePartFiles = messagePartDirectory.listFiles()
assertThat(messagePartFiles).isNotNull()
.extracting { it.name }.containsExactly(messagePartId.toString())
}
private fun createFolderWithMessage(folderServerId: String, messageServerId: String): Long {
val folderId = sqliteDatabase.createFolder(serverId = folderServerId)
val messagePartId = sqliteDatabase.createMessagePart(dataLocation = 2, directory = messagePartDirectory)
sqliteDatabase.createMessage(folderId = folderId, uid = messageServerId, messagePartId = messagePartId)
return messagePartId
}
}

View file

@ -0,0 +1,196 @@
package com.fsck.k9.storage.messages
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.extracting
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import com.fsck.k9.mailstore.StorageManager
import com.fsck.k9.storage.RobolectricTest
import org.junit.After
import org.junit.Test
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000"
class DeleteMessageOperationsTest : RobolectricTest() {
private val messagePartDirectory = createRandomTempDirectory()
private val sqliteDatabase = createDatabase()
private val storageManager = mock<StorageManager> {
on { getAttachmentDirectory(eq(ACCOUNT_UUID), anyOrNull()) } doReturn messagePartDirectory
}
private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
private val attachmentFileManager = AttachmentFileManager(storageManager, ACCOUNT_UUID)
private val deleteMessageOperations = DeleteMessageOperations(lockableDatabase, attachmentFileManager)
@After
fun tearDown() {
messagePartDirectory.deleteRecursively()
}
@Test
fun `destroy message with empty parent messages`() {
val folderId = sqliteDatabase.createFolder()
val messageId1 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "empty1",
empty = true,
messageIdHeader = "msg001@domain.example"
)
val messageId2 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "empty2",
empty = true,
messageIdHeader = "msg002@domain.example"
)
val messageId3 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "delete",
empty = false,
messageIdHeader = "msg003@domain.example"
)
val threadId1 = sqliteDatabase.createThread(messageId = messageId1)
val threadId2 = sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1)
sqliteDatabase.createThread(messageId = messageId3, root = threadId1, parent = threadId2)
deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
assertThat(sqliteDatabase.readMessages()).isEmpty()
assertThat(sqliteDatabase.readThreads()).isEmpty()
}
@Test
fun `destroy message with empty parent message that has another child`() {
val folderId = sqliteDatabase.createFolder()
val messageId1 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "empty",
empty = true,
messageIdHeader = "msg001@domain.example"
)
val messageId2 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "child1",
empty = false,
messageIdHeader = "msg002@domain.example"
)
val messageId3 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "delete",
empty = false,
messageIdHeader = "msg003@domain.example"
)
val threadId1 = sqliteDatabase.createThread(messageId = messageId1)
val threadId2 = sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1)
sqliteDatabase.createThread(messageId = messageId3, root = threadId1, parent = threadId1)
deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
val messages = sqliteDatabase.readMessages()
assertThat(messages).hasSize(2)
assertThat(messages.map { it.id }.toSet()).isEqualTo(setOf(messageId1, messageId2))
val threads = sqliteDatabase.readThreads()
assertThat(threads).hasSize(2)
assertThat(threads.map { it.id }.toSet()).isEqualTo(setOf(threadId1, threadId2))
}
@Test
fun `destroy message with non-empty parent message`() {
val folderId = sqliteDatabase.createFolder()
val messageId1 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "parent",
empty = false,
messageIdHeader = "msg001@domain.example"
)
val messageId2 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "delete",
empty = false,
messageIdHeader = "msg002@domain.example"
)
val threadId1 = sqliteDatabase.createThread(messageId = messageId1)
sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1)
deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
val messages = sqliteDatabase.readMessages()
assertThat(messages).hasSize(1)
assertThat(messages.first().id).isEqualTo(messageId1)
val threads = sqliteDatabase.readThreads()
assertThat(threads).hasSize(1)
assertThat(threads.first().id).isEqualTo(threadId1)
}
@Test
fun `destroy message without parent message`() {
val folderId = sqliteDatabase.createFolder()
val messageId1 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "delete",
empty = false,
messageIdHeader = "msg001@domain.example"
)
sqliteDatabase.createThread(messageId = messageId1)
deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
assertThat(sqliteDatabase.readMessages()).isEmpty()
assertThat(sqliteDatabase.readThreads()).isEmpty()
}
@Test
fun `destroy message with child message should convert to empty message`() {
val folderId = sqliteDatabase.createFolder()
val messageId1 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "delete",
empty = false,
messageIdHeader = "msg001@domain.example"
)
val messageId2 = sqliteDatabase.createMessage(
folderId = folderId,
uid = "child",
empty = false,
messageIdHeader = "msg002@domain.example"
)
val threadId1 = sqliteDatabase.createThread(messageId = messageId1)
val threadId2 = sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1)
deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete"))
val messages = sqliteDatabase.readMessages()
assertThat(messages).hasSize(2)
val message1 = messages.first { it.id == messageId1 }
assertThat(message1.empty).isEqualTo(1)
assertThat(message1.subject).isNull()
assertThat(message1.date).isNull()
assertThat(message1.flags).isNull()
assertThat(message1.senderList).isNull()
assertThat(message1.toList).isNull()
assertThat(message1.ccList).isNull()
assertThat(message1.bccList).isNull()
assertThat(message1.replyToList).isNull()
assertThat(message1.attachmentCount).isNull()
assertThat(message1.internalDate).isNull()
assertThat(message1.previewType).isEqualTo("none")
assertThat(message1.preview).isNull()
assertThat(message1.mimeType).isNull()
assertThat(message1.normalizedSubjectHash).isNull()
assertThat(message1.messagePartId).isNull()
assertThat(message1.encryptionType).isNull()
assertThat(messages.firstOrNull { it.id == messageId2 }).isNotNull()
val threads = sqliteDatabase.readThreads()
assertThat(threads).hasSize(2)
assertThat(threads).extracting { it.id }.containsExactlyInAnyOrder(threadId1, threadId2)
}
}

View file

@ -0,0 +1,10 @@
package com.fsck.k9.storage.messages
import java.io.File
import java.util.UUID
@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
fun createRandomTempDirectory(): File {
val tempDirectory = File(System.getProperty("java.io.tmpdir", "."))
return File(tempDirectory, UUID.randomUUID().toString()).also { it.mkdir() }
}

View file

@ -0,0 +1,100 @@
package com.fsck.k9.storage.messages
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isTrue
import com.fsck.k9.mail.Flag
import com.fsck.k9.storage.RobolectricTest
import org.junit.Test
class FlagMessageOperationsTest : RobolectricTest() {
private val sqliteDatabase = createDatabase()
private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
private val flagMessageOperations = FlagMessageOperations(lockableDatabase)
@Test(expected = IllegalArgumentException::class)
fun `empty messageIds list`() {
flagMessageOperations.setFlag(emptyList(), Flag.SEEN, true)
}
@Test
fun `mark one message as answered`() {
val messageId = sqliteDatabase.createMessage(folderId = 1, uid = "uid1", answered = false)
sqliteDatabase.createMessage(folderId = 1, uid = "uid2", answered = false)
val messageIds = listOf(messageId)
flagMessageOperations.setFlag(messageIds, Flag.ANSWERED, true)
val messages = sqliteDatabase.readMessages()
val message = messages.find { it.id == messageId } ?: error("Original message not found")
assertThat(message.answered).isEqualTo(1)
val otherMessages = messages.filterNot { it.id == messageId }
assertThat(otherMessages).hasSize(1)
assertThat(otherMessages.all { it.answered == 0 }).isTrue()
}
@Test
fun `mark multiple messages as read`() {
val messageId1 = sqliteDatabase.createMessage(folderId = 1, uid = "uid1", read = false)
val messageId2 = sqliteDatabase.createMessage(folderId = 1, uid = "uid2", read = false)
val messageId3 = sqliteDatabase.createMessage(folderId = 1, uid = "uid3", read = false)
sqliteDatabase.createMessage(folderId = 1, uid = "uidx", read = false)
val messageIds = listOf(messageId1, messageId2, messageId3)
flagMessageOperations.setFlag(messageIds, Flag.SEEN, true)
val messages = sqliteDatabase.readMessages()
val affectedMessages = messages.filter { it.id in messageIds }
assertThat(affectedMessages).hasSize(3)
assertThat(affectedMessages.all { it.read == 1 }).isTrue()
val otherMessages = messages.filterNot { it.id in messageIds }
assertThat(otherMessages).hasSize(1)
assertThat(otherMessages.all { it.read == 0 }).isTrue()
}
@Test
fun `mark message as read`() {
sqliteDatabase.createMessage(folderId = 1, uid = "uid1", read = false)
flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.SEEN, true)
val message = sqliteDatabase.readMessages().first()
assertThat(message.read).isEqualTo(1)
}
@Test
fun `mark message as unread`() {
sqliteDatabase.createMessage(folderId = 1, uid = "uid1", read = true)
flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.SEEN, false)
val message = sqliteDatabase.readMessages().first()
assertThat(message.read).isEqualTo(0)
}
@Test
fun `mark message as X_DOWNLOADED_FULL`() {
sqliteDatabase.createMessage(folderId = 1, uid = "uid1", flags = "X_SUBJECT_DECRYPTED")
flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.X_DOWNLOADED_FULL, true)
val message = sqliteDatabase.readMessages().first()
val readFlags = message.flags!!.split(',').toSet()
assertThat(readFlags).isEqualTo(setOf("X_SUBJECT_DECRYPTED", "X_DOWNLOADED_FULL"))
}
@Test
fun `remove X_DOWNLOADED_FULL flag`() {
sqliteDatabase.createMessage(folderId = 1, uid = "uid1", flags = "X_DOWNLOADED_FULL")
flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.X_DOWNLOADED_FULL, false)
val message = sqliteDatabase.readMessages().first()
assertThat(message.flags).isEqualTo("")
}
}

View file

@ -0,0 +1,95 @@
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 app.k9mail.core.android.common.database.map
fun SQLiteDatabase.createFolder(
name: String = "irrelevant",
type: String = "regular",
serverId: String? = null,
isLocalOnly: Boolean = true,
integrate: Boolean = false,
inTopGroup: Boolean = false,
displayClass: String = "NO_CLASS",
syncClass: String? = "INHERITED",
notifyClass: String? = "INHERITED",
pushClass: String? = "SECOND_CLASS",
lastUpdated: Long = 0L,
unreadCount: Int = 0,
visibleLimit: Int = 25,
status: String? = null,
flaggedCount: Int = 0,
moreMessages: String = "unknown"
): Long {
val values = ContentValues().apply {
put("name", name)
put("type", type)
put("server_id", serverId)
put("local_only", isLocalOnly)
put("integrate", integrate)
put("top_group", inTopGroup)
put("display_class", displayClass)
put("poll_class", syncClass)
put("notify_class", notifyClass)
put("push_class", pushClass)
put("last_updated", lastUpdated)
put("unread_count", unreadCount)
put("visible_limit", visibleLimit)
put("status", status)
put("flagged_count", flaggedCount)
put("more_messages", moreMessages)
}
return insert("folders", null, values)
}
fun SQLiteDatabase.readFolders(): List<FolderEntry> {
val cursor = rawQuery("SELECT * FROM folders", null)
return cursor.use {
cursor.map {
FolderEntry(
id = cursor.getLongOrNull("id"),
name = cursor.getStringOrNull("name"),
type = cursor.getStringOrNull("type"),
serverId = cursor.getStringOrNull("server_id"),
isLocalOnly = cursor.getIntOrNull("local_only"),
integrate = cursor.getIntOrNull("integrate"),
inTopGroup = cursor.getIntOrNull("top_group"),
displayClass = cursor.getStringOrNull("display_class"),
syncClass = cursor.getStringOrNull("poll_class"),
notifyClass = cursor.getStringOrNull("notify_class"),
pushClass = cursor.getStringOrNull("push_class"),
lastUpdated = cursor.getLongOrNull("last_updated"),
unreadCount = cursor.getIntOrNull("unread_count"),
visibleLimit = cursor.getIntOrNull("visible_limit"),
status = cursor.getStringOrNull("status"),
flaggedCount = cursor.getIntOrNull("flagged_count"),
moreMessages = cursor.getStringOrNull("more_messages")
)
}
}
}
data class FolderEntry(
val id: Long?,
val name: String?,
val type: String?,
val serverId: String?,
val isLocalOnly: Int?,
val integrate: Int?,
val inTopGroup: Int?,
val displayClass: String?,
val syncClass: String?,
val notifyClass: String?,
val pushClass: String?,
val lastUpdated: Long?,
val unreadCount: Int?,
val visibleLimit: Int?,
val status: String?,
val flaggedCount: Int?,
val moreMessages: String?
)

View file

@ -0,0 +1,77 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import app.k9mail.core.android.common.database.getLongOrNull
import app.k9mail.core.android.common.database.getStringOrNull
import app.k9mail.core.android.common.database.map
fun SQLiteDatabase.createExtraValue(
name: String = "irrelevant",
text: String? = null,
number: Long? = null
): Long {
val values = ContentValues().apply {
put("name", name)
put("value_text", text)
put("value_integer", number)
}
return insert("account_extra_values", null, values)
}
fun SQLiteDatabase.readExtraValues(): List<ExtraValueEntry> {
val cursor = rawQuery("SELECT * FROM account_extra_values", null)
return cursor.use {
cursor.map {
ExtraValueEntry(
name = cursor.getStringOrNull("name"),
text = cursor.getStringOrNull("value_text"),
number = cursor.getLongOrNull("value_integer")
)
}
}
}
data class ExtraValueEntry(
val name: String?,
val text: String?,
val number: Long?
)
fun SQLiteDatabase.createFolderExtraValue(
folderId: Long,
name: String = "irrelevant",
text: String? = null,
number: Long? = null
): Long {
val values = ContentValues().apply {
put("folder_id", folderId)
put("name", name)
put("value_text", text)
put("value_integer", number)
}
return insert("folder_extra_values", null, values)
}
fun SQLiteDatabase.readFolderExtraValues(): List<FolderExtraValueEntry> {
val cursor = rawQuery("SELECT * FROM folder_extra_values", null)
return cursor.use {
cursor.map {
FolderExtraValueEntry(
folderId = cursor.getLongOrNull("folder_id"),
name = cursor.getStringOrNull("name"),
text = cursor.getStringOrNull("value_text"),
number = cursor.getLongOrNull("value_integer")
)
}
}
}
data class FolderExtraValueEntry(
val folderId: Long?,
val name: String?,
val text: String?,
val number: Long?
)

View file

@ -0,0 +1,210 @@
package com.fsck.k9.storage.messages
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import com.fsck.k9.storage.RobolectricTest
import org.junit.Test
class KeyValueStoreOperationsTest : RobolectricTest() {
private val sqliteDatabase = createDatabase()
private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase)
private val keyValueStoreOperations = KeyValueStoreOperations(lockableDatabase)
@Test
fun `get extra string`() {
sqliteDatabase.createExtraValue(name = "test", text = "Wurstsalat")
val result = keyValueStoreOperations.getExtraString("test")
assertThat(result).isEqualTo("Wurstsalat")
}
@Test
fun `get non-existent extra string`() {
val result = keyValueStoreOperations.getExtraString("test")
assertThat(result).isNull()
}
@Test
fun `create extra string`() {
keyValueStoreOperations.setExtraString("jmapState", "ABC42")
val extraValues = sqliteDatabase.readExtraValues()
assertThat(extraValues).hasSize(1)
assertThat(extraValues.first()).isEqualTo(
ExtraValueEntry(
name = "jmapState",
text = "ABC42",
number = null
)
)
}
@Test
fun `update extra string`() {
sqliteDatabase.createExtraValue(name = "jmapState", text = "XYZ23")
keyValueStoreOperations.setExtraString("jmapState", "ABC42")
val extraValues = sqliteDatabase.readExtraValues()
assertThat(extraValues).hasSize(1)
assertThat(extraValues.first()).isEqualTo(
ExtraValueEntry(
name = "jmapState",
text = "ABC42",
number = null
)
)
}
@Test
fun `get extra number`() {
sqliteDatabase.createExtraValue(name = "test", number = 23)
val result = keyValueStoreOperations.getExtraNumber("test")
assertThat(result).isEqualTo(23)
}
@Test
fun `get non-existent extra number`() {
val result = keyValueStoreOperations.getExtraNumber("test")
assertThat(result).isNull()
}
@Test
fun `create extra number`() {
keyValueStoreOperations.setExtraNumber("lastChanged", 123L)
val extraValues = sqliteDatabase.readExtraValues()
assertThat(extraValues).hasSize(1)
assertThat(extraValues.first()).isEqualTo(
ExtraValueEntry(
name = "lastChanged",
text = null,
number = 123L
)
)
}
@Test
fun `update extra number`() {
sqliteDatabase.createExtraValue(name = "lastChanged", number = 0L)
keyValueStoreOperations.setExtraNumber("lastChanged", 42L)
val extraValues = sqliteDatabase.readExtraValues()
assertThat(extraValues).hasSize(1)
assertThat(extraValues.first()).isEqualTo(
ExtraValueEntry(
name = "lastChanged",
text = null,
number = 42
)
)
}
@Test
fun `get extra folder string`() {
sqliteDatabase.createFolderExtraValue(folderId = 1, name = "test", text = "Wurstsalat")
val result = keyValueStoreOperations.getFolderExtraString(folderId = 1, name = "test")
assertThat(result).isEqualTo("Wurstsalat")
}
@Test
fun `get non-existent extra folder string`() {
val result = keyValueStoreOperations.getFolderExtraString(folderId = 1, name = "test")
assertThat(result).isNull()
}
@Test
fun `create extra folder string`() {
keyValueStoreOperations.setFolderExtraString(folderId = 1, name = "imapUidValidity", value = "1")
val folderExtraValues = sqliteDatabase.readFolderExtraValues()
assertThat(folderExtraValues).hasSize(1)
assertThat(folderExtraValues.first()).isEqualTo(
FolderExtraValueEntry(
folderId = 1,
name = "imapUidValidity",
text = "1",
number = null
)
)
}
@Test
fun `update extra folder string`() {
sqliteDatabase.createFolderExtraValue(folderId = 1, name = "imapUidValidity", text = "23")
keyValueStoreOperations.setFolderExtraString(folderId = 1, name = "imapUidValidity", value = "42")
val folderExtraValues = sqliteDatabase.readFolderExtraValues()
assertThat(folderExtraValues).hasSize(1)
assertThat(folderExtraValues.first()).isEqualTo(
FolderExtraValueEntry(
folderId = 1,
name = "imapUidValidity",
text = "42",
number = null
)
)
}
@Test
fun `get extra folder number`() {
sqliteDatabase.createFolderExtraValue(folderId = 1, name = "test", number = 23)
val result = keyValueStoreOperations.getFolderExtraNumber(folderId = 1, name = "test")
assertThat(result).isEqualTo(23)
}
@Test
fun `get non-existent extra folder number`() {
val result = keyValueStoreOperations.getFolderExtraNumber(folderId = 1, name = "test")
assertThat(result).isNull()
}
@Test
fun `create extra folder number`() {
keyValueStoreOperations.setFolderExtraNumber(folderId = 1, name = "lastChanged", value = 123L)
val folderExtraValues = sqliteDatabase.readFolderExtraValues()
assertThat(folderExtraValues).hasSize(1)
assertThat(folderExtraValues.first()).isEqualTo(
FolderExtraValueEntry(
folderId = 1,
name = "lastChanged",
text = null,
number = 123L
)
)
}
@Test
fun `update extra folder number`() {
sqliteDatabase.createFolderExtraValue(folderId = 1, name = "lastChanged", number = 0L)
keyValueStoreOperations.setFolderExtraNumber(folderId = 1, name = "lastChanged", value = 42L)
val folderExtraValues = sqliteDatabase.readFolderExtraValues()
assertThat(folderExtraValues).hasSize(1)
assertThat(folderExtraValues.first()).isEqualTo(
FolderExtraValueEntry(
folderId = 1,
name = "lastChanged",
text = null,
number = 42
)
)
}
}

View file

@ -0,0 +1,213 @@
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 app.k9mail.core.android.common.database.map
import com.fsck.k9.mailstore.DatabasePreviewType
import com.fsck.k9.mailstore.LockableDatabase
import com.fsck.k9.mailstore.MigrationsHelper
import com.fsck.k9.storage.K9SchemaDefinitionFactory
import java.io.File
import org.mockito.ArgumentMatchers
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.mock
fun createLockableDatabaseMock(sqliteDatabase: SQLiteDatabase): LockableDatabase {
return mock {
on { execute(ArgumentMatchers.anyBoolean(), any<LockableDatabase.DbCallback<Any>>()) } doAnswer { stubbing ->
val callback: LockableDatabase.DbCallback<Any> = stubbing.getArgument(1)
callback.doDbWork(sqliteDatabase)
}
}
}
fun createDatabase(): SQLiteDatabase {
val migrationsHelper = mock<MigrationsHelper>()
val sqliteDatabase = SQLiteDatabase.create(null)
val schemaDefinitionFactory = K9SchemaDefinitionFactory()
val schemaDefinition = schemaDefinitionFactory.createSchemaDefinition(migrationsHelper)
schemaDefinition.doDbUpgrade(sqliteDatabase)
return sqliteDatabase
}
fun SQLiteDatabase.createMessage(
folderId: Long,
deleted: Boolean = false,
uid: String? = null,
subject: String = "",
date: Long = 0L,
flags: String = "",
senderList: String = "",
toList: String = "",
ccList: String = "",
bccList: String = "",
replyToList: String = "",
attachmentCount: Int = 0,
internalDate: Long = 0L,
messageIdHeader: String? = null,
previewType: DatabasePreviewType = DatabasePreviewType.NONE,
preview: String = "",
mimeType: String = "text/plain",
normalizedSubjectHash: Long = 0L,
empty: Boolean = false,
read: Boolean = false,
flagged: Boolean = false,
answered: Boolean = false,
forwarded: Boolean = false,
messagePartId: Long = 0L,
encryptionType: String? = null,
newMessage: Boolean = false
): Long {
val values = ContentValues().apply {
put("deleted", if (deleted) 1 else 0)
put("folder_id", folderId)
put("uid", uid)
put("subject", subject)
put("date", date)
put("flags", flags)
put("sender_list", senderList)
put("to_list", toList)
put("cc_list", ccList)
put("bcc_list", bccList)
put("reply_to_list", replyToList)
put("attachment_count", attachmentCount)
put("internal_date", internalDate)
put("message_id", messageIdHeader)
put("preview_type", previewType.databaseValue)
put("preview", preview)
put("mime_type", mimeType)
put("normalized_subject_hash", normalizedSubjectHash)
put("empty", if (empty) 1 else 0)
put("read", if (read) 1 else 0)
put("flagged", if (flagged) 1 else 0)
put("answered", if (answered) 1 else 0)
put("forwarded", if (forwarded) 1 else 0)
put("message_part_id", messagePartId)
put("encryption_type", encryptionType)
put("new_message", if (newMessage) 1 else 0)
}
return insert("messages", null, values)
}
fun SQLiteDatabase.readMessages(): List<MessageEntry> {
val cursor = rawQuery("SELECT * FROM messages", null)
return cursor.use {
cursor.map {
MessageEntry(
id = cursor.getLongOrNull("id"),
deleted = cursor.getIntOrNull("deleted"),
folderId = cursor.getLongOrNull("folder_id"),
uid = cursor.getStringOrNull("uid"),
subject = cursor.getStringOrNull("subject"),
date = cursor.getLongOrNull("date"),
flags = cursor.getStringOrNull("flags"),
senderList = cursor.getStringOrNull("sender_list"),
toList = cursor.getStringOrNull("to_list"),
ccList = cursor.getStringOrNull("cc_list"),
bccList = cursor.getStringOrNull("bcc_list"),
replyToList = cursor.getStringOrNull("reply_to_list"),
attachmentCount = cursor.getIntOrNull("attachment_count"),
internalDate = cursor.getLongOrNull("internal_date"),
messageId = cursor.getStringOrNull("message_id"),
previewType = cursor.getStringOrNull("preview_type"),
preview = cursor.getStringOrNull("preview"),
mimeType = cursor.getStringOrNull("mime_type"),
normalizedSubjectHash = cursor.getLongOrNull("normalized_subject_hash"),
empty = cursor.getIntOrNull("empty"),
read = cursor.getIntOrNull("read"),
flagged = cursor.getIntOrNull("flagged"),
answered = cursor.getIntOrNull("answered"),
forwarded = cursor.getIntOrNull("forwarded"),
messagePartId = cursor.getLongOrNull("message_part_id"),
encryptionType = cursor.getStringOrNull("encryption_type"),
newMessage = cursor.getIntOrNull("new_message")
)
}
}
}
data class MessageEntry(
val id: Long?,
val deleted: Int?,
val folderId: Long?,
val uid: String?,
val subject: String?,
val date: Long?,
val flags: String?,
val senderList: String?,
val toList: String?,
val ccList: String?,
val bccList: String?,
val replyToList: String?,
val attachmentCount: Int?,
val internalDate: Long?,
val messageId: String?,
val previewType: String?,
val preview: String?,
val mimeType: String?,
val normalizedSubjectHash: Long?,
val empty: Int?,
val read: Int?,
val flagged: Int?,
val answered: Int?,
val forwarded: Int?,
val messagePartId: Long?,
val encryptionType: String?,
val newMessage: Int?
)
fun SQLiteDatabase.createMessagePart(
type: Int = 0,
root: Int? = null,
parent: Int = -1,
seq: Int = 0,
mimeType: String = "text/plain",
decodedBodySize: Int = 0,
displayName: String? = null,
header: String? = null,
encoding: String = "7bit",
charset: String? = null,
dataLocation: Int = 0,
data: ByteArray? = null,
preamble: String? = null,
epilogue: String? = null,
boundary: String? = null,
contentId: String? = null,
serverExtra: String? = null,
directory: File? = null
): Long {
val values = ContentValues().apply {
put("type", type)
put("root", root)
put("parent", parent)
put("seq", seq)
put("mime_type", mimeType)
put("decoded_body_size", decodedBodySize)
put("display_name", displayName)
put("header", header?.toByteArray())
put("encoding", encoding)
put("charset", charset)
put("data_location", dataLocation)
put("data", data)
put("preamble", preamble)
put("epilogue", epilogue)
put("boundary", boundary)
put("content_id", contentId)
put("server_extra", serverExtra)
}
return insert("message_parts", null, values).also { messagePartId ->
if (dataLocation == DATA_LOCATION_ON_DISK) {
requireNotNull(directory) { "Argument 'directory' can't be null when 'dataLocation = 2'" }
File(directory, messagePartId.toString()).createNewFile()
}
}
}

View file

@ -0,0 +1,182 @@
package com.fsck.k9.storage.messages
import android.content.ContentValues
import android.database.Cursor
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 app.k9mail.core.android.common.database.map
fun SQLiteDatabase.createMessagePart(
type: Int = MessagePartType.UNKNOWN,
root: Long? = null,
parent: Long = -1,
seq: Int = 0,
mimeType: String? = null,
decodedBodySize: Int? = null,
displayName: String? = null,
header: String? = null,
encoding: String? = null,
charset: String? = null,
dataLocation: Int = DataLocation.MISSING,
data: ByteArray? = null,
preamble: String? = null,
epilogue: String? = null,
boundary: String? = null,
contentId: String? = null,
serverExtra: String? = null
): Long {
val values = ContentValues().apply {
put("type", type)
put("root", root)
put("parent", parent)
put("seq", seq)
put("mime_type", mimeType)
put("decoded_body_size", decodedBodySize)
put("display_name", displayName)
put("header", header?.toByteArray())
put("encoding", encoding)
put("charset", charset)
put("data_location", dataLocation)
put("data", data)
put("preamble", preamble)
put("epilogue", epilogue)
put("boundary", boundary)
put("content_id", contentId)
put("server_extra", serverExtra)
}
return insert("message_parts", null, values)
}
fun SQLiteDatabase.readMessageParts(): List<MessagePartEntry> {
return rawQuery("SELECT * FROM message_parts", null).use { cursor ->
cursor.map {
MessagePartEntry(
id = cursor.getLongOrNull("id"),
type = cursor.getIntOrNull("type"),
root = cursor.getLongOrNull("root"),
parent = cursor.getLongOrNull("parent"),
seq = cursor.getIntOrNull("seq"),
mimeType = cursor.getStringOrNull("mime_type"),
decodedBodySize = cursor.getIntOrNull("decoded_body_size"),
displayName = cursor.getStringOrNull("display_name"),
header = cursor.getBlobOrNull("header"),
encoding = cursor.getStringOrNull("encoding"),
charset = cursor.getStringOrNull("charset"),
dataLocation = cursor.getIntOrNull("data_location"),
data = cursor.getBlobOrNull("data"),
preamble = cursor.getStringOrNull("preamble"),
epilogue = cursor.getStringOrNull("epilogue"),
boundary = cursor.getStringOrNull("boundary"),
contentId = cursor.getStringOrNull("content_id"),
serverExtra = cursor.getStringOrNull("server_extra")
)
}
}
}
data class MessagePartEntry(
val id: Long?,
val type: Int?,
val root: Long?,
val parent: Long?,
val seq: Int?,
val mimeType: String?,
val decodedBodySize: Int?,
val displayName: String?,
val header: ByteArray?,
val encoding: String?,
val charset: String?,
val dataLocation: Int?,
val data: ByteArray?,
val preamble: String?,
val epilogue: String?,
val boundary: String?,
val contentId: String?,
val serverExtra: String?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MessagePartEntry
if (id != other.id) return false
if (type != other.type) return false
if (root != other.root) return false
if (parent != other.parent) return false
if (seq != other.seq) return false
if (mimeType != other.mimeType) return false
if (decodedBodySize != other.decodedBodySize) return false
if (displayName != other.displayName) return false
if (header != null) {
if (other.header == null) return false
if (!header.contentEquals(other.header)) return false
} else if (other.header != null) return false
if (encoding != other.encoding) return false
if (charset != other.charset) return false
if (dataLocation != other.dataLocation) return false
if (data != null) {
if (other.data == null) return false
if (!data.contentEquals(other.data)) return false
} else if (other.data != null) return false
if (preamble != other.preamble) return false
if (epilogue != other.epilogue) return false
if (boundary != other.boundary) return false
if (contentId != other.contentId) return false
if (serverExtra != other.serverExtra) return false
return true
}
override fun hashCode(): Int {
var result = id?.hashCode() ?: 0
result = 31 * result + (type ?: 0)
result = 31 * result + (root?.hashCode() ?: 0)
result = 31 * result + (parent?.hashCode() ?: 0)
result = 31 * result + (seq ?: 0)
result = 31 * result + (mimeType?.hashCode() ?: 0)
result = 31 * result + (decodedBodySize ?: 0)
result = 31 * result + (displayName?.hashCode() ?: 0)
result = 31 * result + (header?.contentHashCode() ?: 0)
result = 31 * result + (encoding?.hashCode() ?: 0)
result = 31 * result + (charset?.hashCode() ?: 0)
result = 31 * result + (dataLocation ?: 0)
result = 31 * result + (data?.contentHashCode() ?: 0)
result = 31 * result + (preamble?.hashCode() ?: 0)
result = 31 * result + (epilogue?.hashCode() ?: 0)
result = 31 * result + (boundary?.hashCode() ?: 0)
result = 31 * result + (contentId?.hashCode() ?: 0)
result = 31 * result + (serverExtra?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "MessagePartEntry(" +
"id=$id, " +
"type=$type, " +
"root=$root, " +
"parent=$parent, " +
"seq=$seq, " +
"mimeType=$mimeType, " +
"decodedBodySize=$decodedBodySize, " +
"displayName=$displayName, " +
"header=${header?.decodeToString()}, " +
"encoding=$encoding, " +
"charset=$charset, " +
"dataLocation=$dataLocation, " +
"data=${data?.decodeToString()}, " +
"preamble=$preamble, " +
"epilogue=$epilogue, " +
"boundary=$boundary, " +
"contentId=$contentId, " +
"serverExtra=$serverExtra)"
}
}
private fun Cursor.getBlobOrNull(columnName: String): ByteArray? {
val columnIndex = getColumnIndex(columnName)
return if (isNull(columnIndex)) null else getBlob(columnIndex)
}

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