Repo created
This commit is contained in:
parent
a629de6271
commit
3cef7c5092
2161 changed files with 246605 additions and 2 deletions
23
app/storage/build.gradle.kts
Normal file
23
app/storage/build.gradle.kts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.koin.core)
|
||||
|
||||
implementation(projects.app.core)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.mime4j.core)
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.moshi)
|
||||
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(projects.app.testing)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.commons.io)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fsck.k9.storage"
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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?)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
18
app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt
Normal file
18
app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
internal const val DATA_LOCATION_ON_DISK = 2
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.storage.migrations;
|
||||
|
||||
class LegacyPendingAppend extends LegacyPendingCommand {
|
||||
public String folder;
|
||||
public String uid;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package com.fsck.k9.storage.migrations;
|
||||
|
||||
abstract class LegacyPendingCommand {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9.storage.migrations;
|
||||
|
||||
class LegacyPendingExpunge extends LegacyPendingCommand {
|
||||
public String folder;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9.storage.migrations;
|
||||
|
||||
class LegacyPendingMarkAllAsRead extends LegacyPendingCommand {
|
||||
public String folder;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'")
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
35
app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt
Normal file
35
app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt
Normal 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>() }
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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("")
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue