Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
30
legacy/storage/build.gradle.kts
Normal file
30
legacy/storage/build.gradle.kts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.koin.core)
|
||||
|
||||
implementation(projects.core.logging.api)
|
||||
|
||||
implementation(projects.legacy.core)
|
||||
// Required for MigrationTo107
|
||||
implementation(projects.mail.common)
|
||||
implementation(projects.mail.protocols.imap)
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.mime4j.core)
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.moshi)
|
||||
|
||||
testImplementation(projects.core.logging.testing)
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(projects.feature.telemetry.noop)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.commons.io)
|
||||
testImplementation(projects.core.featureflag)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fsck.k9.storage"
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
package com.fsck.k9.preferences;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback;
|
||||
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations;
|
||||
import net.thunderbird.core.logging.Logger;
|
||||
import net.thunderbird.core.preference.storage.InMemoryStorage;
|
||||
import net.thunderbird.core.preference.storage.Storage;
|
||||
import net.thunderbird.core.preference.storage.StorageEditor;
|
||||
import net.thunderbird.core.preference.storage.StorageUpdater;
|
||||
|
||||
public class K9StorageEditor implements StorageEditor {
|
||||
private StorageUpdater storageUpdater;
|
||||
private K9StoragePersister storagePersister;
|
||||
|
||||
private Logger logger;
|
||||
|
||||
private Map<String, String> changes = new HashMap<>();
|
||||
private List<String> removals = new ArrayList<>();
|
||||
|
||||
|
||||
public K9StorageEditor(
|
||||
StorageUpdater storageUpdater,
|
||||
K9StoragePersister storagePersister,
|
||||
Logger logger
|
||||
) {
|
||||
this.storageUpdater = storageUpdater;
|
||||
this.storagePersister = storagePersister;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
try {
|
||||
storageUpdater.updateStorage(this::commitChanges);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
logger.error(null, e, () -> "Failed to save preferences");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Storage commitChanges(Storage storage) {
|
||||
long startTime = SystemClock.elapsedRealtime();
|
||||
logger.info(null, null, () -> "Committing preference changes");
|
||||
|
||||
Map<String, String> newValues = new HashMap<>();
|
||||
Map<String, String> oldValues = storage.getAll();
|
||||
StoragePersistOperationCallback committer = new StoragePersistOperationCallback() {
|
||||
@Override
|
||||
public void beforePersistTransaction(Map<String, String> workingStorage) {
|
||||
workingStorage.putAll(oldValues);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void persist(StoragePersistOperations ops) {
|
||||
for (String removeKey : removals) {
|
||||
ops.remove(removeKey);
|
||||
}
|
||||
for (Entry<String, String> entry : changes.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String newValue = entry.getValue();
|
||||
String oldValue = oldValues.get(key);
|
||||
if (removals.contains(key) || !newValue.equals(oldValue)) {
|
||||
ops.put(key, newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPersistTransactionSuccess(Map<String, String> workingStorage) {
|
||||
newValues.putAll(workingStorage);
|
||||
}
|
||||
};
|
||||
storagePersister.doInTransaction(committer);
|
||||
long endTime = SystemClock.elapsedRealtime();
|
||||
logger.info(null, null, () -> String.format("Preferences commit took %d ms", endTime - startTime));
|
||||
|
||||
return new InMemoryStorage(
|
||||
newValues,
|
||||
logger
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public StorageEditor putBoolean(String key,
|
||||
boolean value) {
|
||||
changes.put(key, "" + value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public StorageEditor putInt(String key, int value) {
|
||||
changes.put(key, "" + value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public StorageEditor putLong(String key, long value) {
|
||||
changes.put(key, "" + value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public StorageEditor putString(String key, String value) {
|
||||
if (value == null) {
|
||||
remove(key);
|
||||
} else {
|
||||
changes.put(key, value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public StorageEditor remove(String key) {
|
||||
removals.add(key);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package com.fsck.k9.preferences;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteStatement;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import com.fsck.k9.helper.Utility;
|
||||
import com.fsck.k9.preferences.migration.DefaultStorageMigrationHelper;
|
||||
import com.fsck.k9.preferences.migration.StorageMigrations;
|
||||
import com.fsck.k9.preferences.migration.StorageMigrationHelper;
|
||||
import net.thunderbird.core.logging.Logger;
|
||||
import net.thunderbird.core.preference.storage.InMemoryStorage;
|
||||
import net.thunderbird.core.preference.storage.Storage;
|
||||
import net.thunderbird.core.preference.storage.StorageEditor;
|
||||
import net.thunderbird.core.preference.storage.StoragePersister;
|
||||
import net.thunderbird.core.preference.storage.StorageUpdater;
|
||||
|
||||
public class K9StoragePersister implements StoragePersister {
|
||||
private static final int DB_VERSION = 28;
|
||||
private static final String DB_NAME = "preferences_storage";
|
||||
|
||||
private final Context context;
|
||||
private final Logger logger;
|
||||
private final StorageMigrationHelper migrationHelper = new DefaultStorageMigrationHelper();
|
||||
|
||||
public K9StoragePersister(
|
||||
Context context,
|
||||
Logger logger
|
||||
) {
|
||||
this.context = context;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private SQLiteDatabase openDB() {
|
||||
SQLiteDatabase db = context.openOrCreateDatabase(DB_NAME, Context.MODE_PRIVATE, null);
|
||||
|
||||
db.beginTransaction();
|
||||
try {
|
||||
if (db.getVersion() > DB_VERSION) {
|
||||
throw new AssertionError("Database downgrades are not supported. " +
|
||||
"Please fix the database '" + DB_NAME + "' manually or clear app data.");
|
||||
}
|
||||
|
||||
if (db.getVersion() < 1) {
|
||||
createStorageDatabase(db);
|
||||
} else {
|
||||
StorageMigrations.upgradeDatabase(db, migrationHelper);
|
||||
}
|
||||
|
||||
db.setVersion(DB_VERSION);
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
|
||||
if (db.getVersion() != DB_VERSION) {
|
||||
throw new RuntimeException("Storage database upgrade failed!");
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
private void createStorageDatabase(SQLiteDatabase db) {
|
||||
logger.info(null, null, () -> "Creating Storage database");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS preferences_storage");
|
||||
db.execSQL("CREATE TABLE preferences_storage " +
|
||||
"(primkey TEXT PRIMARY KEY ON CONFLICT REPLACE, value TEXT)");
|
||||
db.setVersion(DB_VERSION);
|
||||
}
|
||||
|
||||
void doInTransaction(StoragePersistOperationCallback operationCallback) {
|
||||
HashMap<String, String> workingStorage = new HashMap<>();
|
||||
SQLiteDatabase workingDb = openDB();
|
||||
|
||||
try {
|
||||
operationCallback.beforePersistTransaction(workingStorage);
|
||||
|
||||
StoragePersistOperations storagePersistOperations = new StoragePersistOperations(workingStorage, workingDb);
|
||||
workingDb.beginTransaction();
|
||||
operationCallback.persist(storagePersistOperations);
|
||||
storagePersistOperations.close();
|
||||
workingDb.setTransactionSuccessful();
|
||||
|
||||
operationCallback.onPersistTransactionSuccess(workingStorage);
|
||||
} finally {
|
||||
workingDb.endTransaction();
|
||||
workingDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public StorageEditor createStorageEditor(@NonNull StorageUpdater storageUpdater) {
|
||||
return new K9StorageEditor(storageUpdater, this, logger);
|
||||
}
|
||||
|
||||
static class StoragePersistOperations {
|
||||
private Map<String, String> workingStorage;
|
||||
private final SQLiteStatement deleteStatement;
|
||||
private final SQLiteStatement insertStatement;
|
||||
|
||||
private StoragePersistOperations(Map<String, String> workingStorage, SQLiteDatabase database) {
|
||||
this.workingStorage = workingStorage;
|
||||
|
||||
insertStatement = database.compileStatement(
|
||||
"INSERT INTO preferences_storage (primkey, value) VALUES (?, ?)");
|
||||
deleteStatement = database.compileStatement(
|
||||
"DELETE FROM preferences_storage WHERE primkey = ?");
|
||||
}
|
||||
|
||||
void put(String key, String value) {
|
||||
insertStatement.bindString(1, key);
|
||||
insertStatement.bindString(2, value);
|
||||
insertStatement.execute();
|
||||
insertStatement.clearBindings();
|
||||
|
||||
workingStorage.put(key, value);
|
||||
}
|
||||
|
||||
void remove(String key) {
|
||||
deleteStatement.bindString(1, key);
|
||||
deleteStatement.executeUpdateDelete();
|
||||
deleteStatement.clearBindings();
|
||||
|
||||
workingStorage.remove(key);
|
||||
}
|
||||
|
||||
private void close() {
|
||||
insertStatement.close();
|
||||
deleteStatement.close();
|
||||
}
|
||||
}
|
||||
|
||||
interface StoragePersistOperationCallback {
|
||||
void beforePersistTransaction(Map<String, String> workingStorage);
|
||||
void persist(StoragePersistOperations ops);
|
||||
void onPersistTransactionSuccess(Map<String, String> workingStorage);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Storage loadValues() {
|
||||
long startTime = SystemClock.elapsedRealtime();
|
||||
logger.info(null, null, () -> "Loading preferences from DB into Storage");
|
||||
|
||||
try (SQLiteDatabase database = openDB()) {
|
||||
return new InMemoryStorage(readAllValues(database), logger);
|
||||
} finally {
|
||||
long endTime = SystemClock.elapsedRealtime();
|
||||
logger.info(null, null, () -> String.format("Preferences load took %d ms", endTime - startTime));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> readAllValues(SQLiteDatabase database) {
|
||||
HashMap<String, String> loadedValues = new HashMap<>();
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = database.rawQuery("SELECT primkey, value FROM preferences_storage", null);
|
||||
while (cursor.moveToNext()) {
|
||||
String key = cursor.getString(0);
|
||||
String value = cursor.getString(1);
|
||||
logger.debug(null, null, () -> String.format("Loading key '%s', value = '%s'", key, value));
|
||||
loadedValues.put(key, value);
|
||||
}
|
||||
} finally {
|
||||
Utility.closeQuietly(cursor);
|
||||
}
|
||||
|
||||
return loadedValues;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.core.content.contentValuesOf
|
||||
import app.k9mail.core.android.common.database.getStringOrThrow
|
||||
import app.k9mail.core.android.common.database.map
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
class DefaultStorageMigrationHelper : StorageMigrationHelper {
|
||||
override fun readAllValues(db: SQLiteDatabase): Map<String, String> {
|
||||
return db.query(TABLE_NAME, arrayOf(KEY_COLUMN, VALUE_COLUMN), null, null, null, null, null).use {
|
||||
it.map { cursor ->
|
||||
val key = cursor.getStringOrThrow(KEY_COLUMN)
|
||||
val value = cursor.getStringOrThrow(VALUE_COLUMN)
|
||||
Log.d("Loading key '%s', value = '%s'", key, value)
|
||||
|
||||
key to value
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
override fun readValue(db: SQLiteDatabase, key: String): String? {
|
||||
return db.query(
|
||||
TABLE_NAME,
|
||||
arrayOf(VALUE_COLUMN),
|
||||
"$KEY_COLUMN = ?",
|
||||
arrayOf(key),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
cursor.getStringOrThrow(VALUE_COLUMN).also { value ->
|
||||
Log.d("Loading key '%s', value = '%s'", key, value)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeValue(db: SQLiteDatabase, key: String, value: String?) {
|
||||
if (value == null) {
|
||||
db.delete(TABLE_NAME, "$KEY_COLUMN = ?", arrayOf(key))
|
||||
return
|
||||
}
|
||||
|
||||
val values = contentValuesOf(
|
||||
KEY_COLUMN to key,
|
||||
VALUE_COLUMN to value,
|
||||
)
|
||||
|
||||
val result = db.update(TABLE_NAME, values, "$KEY_COLUMN = ?", arrayOf(key))
|
||||
|
||||
if (result == -1) {
|
||||
Log.e("Error writing key '%s', value = '%s'", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun insertValue(db: SQLiteDatabase, key: String, value: String?) {
|
||||
if (value == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val values = contentValuesOf(
|
||||
KEY_COLUMN to key,
|
||||
VALUE_COLUMN to value,
|
||||
)
|
||||
|
||||
val result = db.insert(TABLE_NAME, null, values)
|
||||
|
||||
if (result == -1L) {
|
||||
Log.e("Error writing key '%s', value = '%s'", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TABLE_NAME = "preferences_storage"
|
||||
private const val KEY_COLUMN = "primkey"
|
||||
private const val VALUE_COLUMN = "value"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
interface StorageMigrationHelper {
|
||||
fun readAllValues(db: SQLiteDatabase): Map<String, String>
|
||||
fun readValue(db: SQLiteDatabase, key: String): String?
|
||||
fun writeValue(db: SQLiteDatabase, key: String, value: String?)
|
||||
fun insertValue(db: SQLiteDatabase, key: String, value: String?)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Remove saved folder settings
|
||||
*
|
||||
* Folder settings are now only written to 'Storage' when settings are imported. The saved settings will be used when
|
||||
* folders are first created and then the saved settings are discarded.
|
||||
* Since this wasn't the procedure in earlier app versions, we remove any saved folder settings in this migration.
|
||||
*/
|
||||
class StorageMigrationTo10(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
private val folderKeyPattern = Regex("[^.]+\\..+\\.([^.]+)")
|
||||
private val folderSettingKeys = setOf(
|
||||
"displayMode",
|
||||
"notifyMode",
|
||||
"syncMode",
|
||||
"pushMode",
|
||||
"inTopGroup",
|
||||
"integrate",
|
||||
)
|
||||
|
||||
fun removeSavedFolderSettings() {
|
||||
val loadedValues = migrationsHelper.readAllValues(db)
|
||||
|
||||
for (key in loadedValues.keys) {
|
||||
val matches = folderKeyPattern.matchEntire(key) ?: continue
|
||||
val folderKey = matches.groupValues[1]
|
||||
if (folderKey in folderSettingKeys) {
|
||||
migrationsHelper.writeValue(db, key, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fsck.k9.preferences.upgrader.GeneralSettingsUpgraderTo31
|
||||
|
||||
/**
|
||||
* Convert old value for message view content font size to new format.
|
||||
*
|
||||
* This change in formats has been made a long time ago. But never in a migration. So it's possible there are still
|
||||
* installations out there that have the old version in the database. And they would work just fine, because this
|
||||
* conversion was done when loading font size values.
|
||||
*/
|
||||
class StorageMigrationTo11(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun upgradeMessageViewContentFontSize() {
|
||||
val newFontSizeValue = migrationsHelper.readValue(db, "fontSizeMessageViewContentPercent")
|
||||
if (newFontSizeValue != null) return
|
||||
|
||||
val oldFontSizeValue = migrationsHelper.readValue(db, "fontSizeMessageViewContent")?.toIntOrNull() ?: 3
|
||||
val fontSizeValue = GeneralSettingsUpgraderTo31.convertFromOldSize(oldFontSizeValue)
|
||||
migrationsHelper.writeValue(db, "fontSizeMessageViewContentPercent", fontSizeValue.toString())
|
||||
migrationsHelper.writeValue(db, "fontSizeMessageViewContent", null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fsck.k9.mail.filter.Base64
|
||||
import com.fsck.k9.preferences.migration.migration12.ImapStoreUriDecoder
|
||||
import com.fsck.k9.preferences.migration.migration12.Pop3StoreUriDecoder
|
||||
import com.fsck.k9.preferences.migration.migration12.SmtpTransportUriDecoder
|
||||
import com.fsck.k9.preferences.migration.migration12.WebDavStoreUriDecoder
|
||||
import net.thunderbird.feature.account.storage.legacy.serializer.ServerSettingsDtoSerializer
|
||||
|
||||
/**
|
||||
* Convert server settings from the old URI format to the new JSON format
|
||||
*/
|
||||
class StorageMigrationTo12(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
private val serverSettingsDtoSerializer = ServerSettingsDtoSerializer()
|
||||
|
||||
fun removeStoreAndTransportUri() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
convertStoreUri(accountUuid)
|
||||
convertTransportUri(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertStoreUri(accountUuid: String) {
|
||||
val storeUri = migrationsHelper.readValue(db, "$accountUuid.storeUri")?.base64Decode() ?: return
|
||||
|
||||
val serverSettings = when {
|
||||
storeUri.startsWith("imap") -> ImapStoreUriDecoder.decode(storeUri)
|
||||
storeUri.startsWith("pop3") -> Pop3StoreUriDecoder.decode(storeUri)
|
||||
storeUri.startsWith("webdav") -> WebDavStoreUriDecoder.decode(storeUri)
|
||||
else -> error("Unsupported account type")
|
||||
}
|
||||
|
||||
val json = serverSettingsDtoSerializer.serialize(serverSettings)
|
||||
|
||||
migrationsHelper.insertValue(db, "$accountUuid.incomingServerSettings", json)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.storeUri", null)
|
||||
}
|
||||
|
||||
private fun convertTransportUri(accountUuid: String) {
|
||||
val transportUri = migrationsHelper.readValue(db, "$accountUuid.transportUri")?.base64Decode() ?: return
|
||||
|
||||
val serverSettings = when {
|
||||
transportUri.startsWith("smtp") -> SmtpTransportUriDecoder.decodeSmtpUri(transportUri)
|
||||
transportUri.startsWith("webdav") -> WebDavStoreUriDecoder.decode(transportUri)
|
||||
else -> error("Unsupported account type")
|
||||
}
|
||||
|
||||
val json = serverSettingsDtoSerializer.serialize(serverSettings)
|
||||
|
||||
migrationsHelper.insertValue(db, "$accountUuid.outgoingServerSettings", json)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.transportUri", null)
|
||||
}
|
||||
|
||||
private fun String.base64Decode() = Base64.decode(this)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Rename `hideSpecialAccounts` to `showUnifiedInbox` (and invert value).
|
||||
*/
|
||||
class StorageMigrationTo13(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun renameHideSpecialAccounts() {
|
||||
val hideSpecialAccounts = migrationsHelper.readValue(db, "hideSpecialAccounts")?.toBoolean() ?: false
|
||||
val showUnifiedInbox = !hideSpecialAccounts
|
||||
migrationsHelper.insertValue(db, "showUnifiedInbox", showUnifiedInbox.toString())
|
||||
migrationsHelper.writeValue(db, "hideSpecialAccounts", null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import net.thunderbird.core.common.mail.Protocols
|
||||
import net.thunderbird.feature.account.storage.legacy.serializer.ServerSettingsDtoSerializer
|
||||
|
||||
/**
|
||||
* Rewrite 'folderPushMode' value of non-IMAP accounts to 'NONE'.
|
||||
*/
|
||||
class StorageMigrationTo14(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
private val serverSettingsDtoSerializer = ServerSettingsDtoSerializer()
|
||||
|
||||
fun disablePushFoldersForNonImapAccounts() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
disablePushFolders(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun disablePushFolders(accountUuid: String) {
|
||||
val json = migrationsHelper.readValue(db, "$accountUuid.incomingServerSettings") ?: return
|
||||
val serverSettings = serverSettingsDtoSerializer.deserialize(json)
|
||||
if (serverSettings.type != Protocols.IMAP) {
|
||||
migrationsHelper.writeValue(db, "$accountUuid.folderPushMode", "NONE")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
private const val DEFAULT_IDLE_REFRESH_MINUTES = 24
|
||||
private const val MINIMUM_IDLE_REFRESH_MINUTES = 2
|
||||
|
||||
/**
|
||||
* Rewrite 'idleRefreshMinutes' to make sure the minimum value is 2 minutes.
|
||||
*/
|
||||
class StorageMigrationTo15(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun rewriteIdleRefreshInterval() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
rewriteIdleRefreshInterval(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteIdleRefreshInterval(accountUuid: String) {
|
||||
val idleRefreshMinutes = migrationsHelper.readValue(db, "$accountUuid.idleRefreshMinutes")?.toIntOrNull()
|
||||
?: DEFAULT_IDLE_REFRESH_MINUTES
|
||||
|
||||
val newIdleRefreshMinutes = idleRefreshMinutes.coerceAtLeast(MINIMUM_IDLE_REFRESH_MINUTES)
|
||||
if (newIdleRefreshMinutes != idleRefreshMinutes) {
|
||||
migrationsHelper.writeValue(db, "$accountUuid.idleRefreshMinutes", newIdleRefreshMinutes.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Change default value of `registeredNameColor` to have enough contrast in both the light and dark theme.
|
||||
*/
|
||||
class StorageMigrationTo16(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun changeDefaultRegisteredNameColor() {
|
||||
val registeredNameColorValue = migrationsHelper.readValue(db, "registeredNameColor")?.toInt()
|
||||
if (registeredNameColorValue == 0xFF00008F.toInt()) {
|
||||
migrationsHelper.writeValue(db, "registeredNameColor", 0xFF1093F5.toInt().toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Rewrite 'led' and 'ledColor' values to 'notificationLight'.
|
||||
*/
|
||||
class StorageMigrationTo17(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun rewriteNotificationLightSettings() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
rewriteNotificationLightSettings(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteNotificationLightSettings(accountUuid: String) {
|
||||
val isLedEnabled = migrationsHelper.readValue(db, "$accountUuid.led").toBoolean()
|
||||
val ledColor = migrationsHelper.readValue(db, "$accountUuid.ledColor")?.toInt() ?: 0
|
||||
val accountColor = migrationsHelper.readValue(db, "$accountUuid.chipColor")?.toInt() ?: 0
|
||||
|
||||
val notificationLight = convertToNotificationLightValue(isLedEnabled, ledColor, accountColor)
|
||||
|
||||
migrationsHelper.writeValue(db, "$accountUuid.notificationLight", notificationLight)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.led", null)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.ledColor", null)
|
||||
}
|
||||
|
||||
private fun convertToNotificationLightValue(isLedEnabled: Boolean, ledColor: Int, accountColor: Int): String {
|
||||
if (!isLedEnabled) return "Disabled"
|
||||
|
||||
return when (ledColor.rgb) {
|
||||
accountColor.rgb -> "AccountColor"
|
||||
0xFFFFFF -> "White"
|
||||
0xFF0000 -> "Red"
|
||||
0x00FF00 -> "Green"
|
||||
0x0000FF -> "Blue"
|
||||
0xFFFF00 -> "Yellow"
|
||||
0x00FFFF -> "Cyan"
|
||||
0xFF00FF -> "Magenta"
|
||||
else -> "SystemDefaultColor"
|
||||
}
|
||||
}
|
||||
|
||||
private val Int.rgb
|
||||
get() = this and 0x00FFFFFF
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Rewrite the per-network type IMAP compression settings to a single setting.
|
||||
*/
|
||||
class StorageMigrationTo18(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun rewriteImapCompressionSettings() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
rewriteImapCompressionSetting(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteImapCompressionSetting(accountUuid: String) {
|
||||
val useCompressionWifi = migrationsHelper.readValue(db, "$accountUuid.useCompression.WIFI").toBoolean()
|
||||
val useCompressionMobile = migrationsHelper.readValue(db, "$accountUuid.useCompression.MOBILE").toBoolean()
|
||||
val useCompressionOther = migrationsHelper.readValue(db, "$accountUuid.useCompression.OTHER").toBoolean()
|
||||
|
||||
val useCompression = useCompressionWifi && useCompressionMobile && useCompressionOther
|
||||
migrationsHelper.writeValue(db, "$accountUuid.useCompression", useCompression.toString())
|
||||
|
||||
migrationsHelper.writeValue(db, "$accountUuid.useCompression.WIFI", null)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.useCompression.MOBILE", null)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.useCompression.OTHER", null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
|
||||
/**
|
||||
* Mark Gmail accounts.
|
||||
*
|
||||
* Gmail has stopped allowing clients to use the Google account password to authenticate via IMAP/POP3/SMTP. We want to
|
||||
* automatically update the server settings to use OAuth 2.0 for users who can no longer access Gmail because of the
|
||||
* change. However, we don't want to touch accounts that are using an app-specific password and still work fine. Since
|
||||
* we can't distinguish an app-specific password from an account password, we only switch accounts to using OAuth after
|
||||
* an authentication failure. We usually want to avoid automatically changing the user's settings like this. So we only
|
||||
* do it for existing accounts, and only after the first authentication failure.
|
||||
*/
|
||||
class StorageMigrationTo19(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun markGmailAccounts() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
markIfGmailAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markIfGmailAccount(accountUuid: String) {
|
||||
val incomingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.incomingServerSettings") ?: return
|
||||
val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
|
||||
|
||||
val moshi = Moshi.Builder().build()
|
||||
val adapter = moshi.adapter<Map<String, Any?>>(
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
|
||||
)
|
||||
|
||||
val incomingServerSettings = adapter.fromJson(incomingServerSettingsJson) ?: return
|
||||
val outgoingServerSettings = adapter.fromJson(outgoingServerSettingsJson) ?: return
|
||||
|
||||
if (incomingServerSettings["type"] == "imap" &&
|
||||
incomingServerSettings["host"] in setOf("imap.gmail.com", "imap.googlemail.com") &&
|
||||
incomingServerSettings["authenticationType"] != "XOAUTH2" ||
|
||||
outgoingServerSettings["host"] in setOf("smtp.gmail.com", "smtp.googlemail.com") &&
|
||||
outgoingServerSettings["authenticationType"] != "XOAUTH2"
|
||||
) {
|
||||
migrationsHelper.insertValue(db, "$accountUuid.migrateToOAuth", "true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package com.fsck.k9.preferences.migration;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
|
||||
import com.fsck.k9.helper.UrlEncodingHelper;
|
||||
import com.fsck.k9.mail.filter.Base64;
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
|
||||
public class StorageMigrationTo2 {
|
||||
public static void urlEncodeUserNameAndPassword(SQLiteDatabase db, StorageMigrationHelper migrationsHelper) {
|
||||
Log.i("Updating preferences to urlencoded username/password");
|
||||
|
||||
String accountUuids = migrationsHelper.readValue(db, "accountUuids");
|
||||
if (accountUuids != null && accountUuids.length() != 0) {
|
||||
String[] uuids = accountUuids.split(",");
|
||||
for (String uuid : uuids) {
|
||||
try {
|
||||
String storeUriStr = Base64.decode(migrationsHelper.readValue(db, uuid + ".storeUri"));
|
||||
String transportUriStr = Base64.decode(migrationsHelper.readValue(db, uuid + ".transportUri"));
|
||||
|
||||
URI uri = new URI(transportUriStr);
|
||||
String newUserInfo = null;
|
||||
if (transportUriStr != null) {
|
||||
String[] userInfoParts = uri.getUserInfo().split(":");
|
||||
|
||||
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
|
||||
String passwordEnc = "";
|
||||
String authType = "";
|
||||
if (userInfoParts.length > 1) {
|
||||
passwordEnc = ":" + UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
|
||||
}
|
||||
if (userInfoParts.length > 2) {
|
||||
authType = ":" + userInfoParts[2];
|
||||
}
|
||||
|
||||
newUserInfo = usernameEnc + passwordEnc + authType;
|
||||
}
|
||||
|
||||
if (newUserInfo != null) {
|
||||
URI newUri = new URI(uri.getScheme(), newUserInfo, uri.getHost(), uri.getPort(), uri.getPath(),
|
||||
uri.getQuery(), uri.getFragment());
|
||||
String newTransportUriStr = Base64.encode(newUri.toString());
|
||||
migrationsHelper.writeValue(db, uuid + ".transportUri", newTransportUriStr);
|
||||
}
|
||||
|
||||
uri = new URI(storeUriStr);
|
||||
newUserInfo = null;
|
||||
if (storeUriStr.startsWith("imap")) {
|
||||
String[] userInfoParts = uri.getUserInfo().split(":");
|
||||
if (userInfoParts.length == 2) {
|
||||
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
|
||||
String passwordEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
|
||||
|
||||
newUserInfo = usernameEnc + ":" + passwordEnc;
|
||||
} else {
|
||||
String authType = userInfoParts[0];
|
||||
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
|
||||
String passwordEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[2]);
|
||||
|
||||
newUserInfo = authType + ":" + usernameEnc + ":" + passwordEnc;
|
||||
}
|
||||
} else if (storeUriStr.startsWith("pop3")) {
|
||||
String[] userInfoParts = uri.getUserInfo().split(":", 2);
|
||||
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
|
||||
|
||||
String passwordEnc = "";
|
||||
if (userInfoParts.length > 1) {
|
||||
passwordEnc = ":" + UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
|
||||
}
|
||||
|
||||
newUserInfo = usernameEnc + passwordEnc;
|
||||
} else if (storeUriStr.startsWith("webdav")) {
|
||||
String[] userInfoParts = uri.getUserInfo().split(":", 2);
|
||||
String usernameEnc = UrlEncodingHelper.encodeUtf8(userInfoParts[0]);
|
||||
|
||||
String passwordEnc = "";
|
||||
if (userInfoParts.length > 1) {
|
||||
passwordEnc = ":" + UrlEncodingHelper.encodeUtf8(userInfoParts[1]);
|
||||
}
|
||||
|
||||
newUserInfo = usernameEnc + passwordEnc;
|
||||
}
|
||||
|
||||
if (newUserInfo != null) {
|
||||
URI newUri = new URI(uri.getScheme(), newUserInfo, uri.getHost(), uri.getPort(), uri.getPath(),
|
||||
uri.getQuery(), uri.getFragment());
|
||||
String newStoreUriStr = Base64.encode(newUri.toString());
|
||||
migrationsHelper.writeValue(db, uuid + ".storeUri", newStoreUriStr);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(e, "ooops");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fsck.k9.mail.Address
|
||||
|
||||
/**
|
||||
* Clean up [Identity][com.fsck.k9.Identity] properties stored in the database
|
||||
*
|
||||
* Previously, we didn't validate input in the "Edit identity" screen, and so there was no guarantee that the `email`
|
||||
* and `replyTo` values contained a valid email address.
|
||||
*
|
||||
* Additionally, we now rewrite blank values in `description`, `name`, and `replyTo` to `null`.
|
||||
*/
|
||||
class StorageMigrationTo20(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun fixIdentities() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
fixIdentitiesInAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixIdentitiesInAccount(accountUuid: String) {
|
||||
var identityIndex = 0
|
||||
|
||||
while (true) {
|
||||
val email = migrationsHelper.readValue(db, "$accountUuid.email.$identityIndex") ?: break
|
||||
val description = migrationsHelper.readValue(db, "$accountUuid.description.$identityIndex")
|
||||
val name = migrationsHelper.readValue(db, "$accountUuid.name.$identityIndex")
|
||||
val replyTo = migrationsHelper.readValue(db, "$accountUuid.replyTo.$identityIndex")
|
||||
|
||||
val newDescription = description?.takeUnless { it.isBlank() }
|
||||
val newName = name?.takeUnless { it.isBlank() }
|
||||
|
||||
val emailAddress = Address.parse(email).firstOrNull()
|
||||
val newEmail = emailAddress?.address ?: "please.fix@invalid"
|
||||
|
||||
val replyToAddress = Address.parse(replyTo).firstOrNull()
|
||||
val newReplyTo = replyToAddress?.address
|
||||
|
||||
migrationsHelper.writeValue(db, "$accountUuid.description.$identityIndex", newDescription)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.name.$identityIndex", newName)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.email.$identityIndex", newEmail)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.replyTo.$identityIndex", newReplyTo)
|
||||
|
||||
identityIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Combine `messageViewReturnToList` and `messageViewShowNext` into `messageViewPostDeleteAction`.
|
||||
*/
|
||||
class StorageMigrationTo21(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun createPostRemoveNavigationSetting() {
|
||||
val messageViewReturnToList = migrationsHelper.readValue(db, "messageViewReturnToList").toBoolean()
|
||||
val messageViewShowNext = migrationsHelper.readValue(db, "messageViewShowNext").toBoolean()
|
||||
|
||||
val postRemoveNavigation = when {
|
||||
messageViewReturnToList -> "ReturnToMessageList"
|
||||
messageViewShowNext -> "ShowNextMessage"
|
||||
else -> "ShowPreviousMessage"
|
||||
}
|
||||
|
||||
migrationsHelper.writeValue(db, "messageViewPostDeleteAction", postRemoveNavigation)
|
||||
migrationsHelper.writeValue(db, "messageViewReturnToList", null)
|
||||
migrationsHelper.writeValue(db, "messageViewShowNext", null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
|
||||
/**
|
||||
* Fix server settings by removing line breaks from username and password.
|
||||
*/
|
||||
class StorageMigrationTo22(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun fixServerSettings() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
fixServerSettingsForAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixServerSettingsForAccount(accountUuid: String) {
|
||||
val incomingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.incomingServerSettings") ?: return
|
||||
val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
|
||||
|
||||
val adapter = createJsonAdapter()
|
||||
|
||||
adapter.fromJson(incomingServerSettingsJson)?.let { settings ->
|
||||
createFixedServerSettings(settings)?.let { newSettings ->
|
||||
val json = adapter.toJson(newSettings)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.incomingServerSettings", json)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.fromJson(outgoingServerSettingsJson)?.let { settings ->
|
||||
createFixedServerSettings(settings)?.let { newSettings ->
|
||||
val json = adapter.toJson(newSettings)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.outgoingServerSettings", json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFixedServerSettings(serverSettings: Map<String, Any?>): Map<String, Any?>? {
|
||||
val username = serverSettings["username"] as? String
|
||||
val password = serverSettings["password"] as? String
|
||||
val newUsername = username?.stripLineBreaks()
|
||||
val newPassword = password?.stripLineBreaks()
|
||||
|
||||
return if (username != newUsername || password != newPassword) {
|
||||
serverSettings.toMutableMap().apply {
|
||||
this["username"] = newUsername
|
||||
this["password"] = newPassword
|
||||
|
||||
// This is so we don't end up with a port value of e.g. "993.0". It would still work, but it looks odd.
|
||||
this["port"] = (serverSettings["port"] as? Double)?.toInt()
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createJsonAdapter(): JsonAdapter<Map<String, Any?>> {
|
||||
val moshi = Moshi.Builder().build()
|
||||
|
||||
return moshi.adapter<Map<String, Any?>>(
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
|
||||
).serializeNulls()
|
||||
}
|
||||
}
|
||||
|
||||
private val LINE_BREAK = "[\\r\\n]".toRegex()
|
||||
|
||||
private fun String.stripLineBreaks() = replace(LINE_BREAK, replacement = "")
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Rename account setting `sendClientId` to `sendClientInfo`.
|
||||
*/
|
||||
class StorageMigrationTo23(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun renameSendClientId() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
renameSendClientIdForAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renameSendClientIdForAccount(accountUuid: String) {
|
||||
// Write new key with existing value
|
||||
val value = migrationsHelper.readValue(db, "$accountUuid.sendClientId")
|
||||
migrationsHelper.insertValue(db, "$accountUuid.sendClientInfo", value)
|
||||
|
||||
// Remove old key
|
||||
migrationsHelper.writeValue(db, "$accountUuid.sendClientId", null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
|
||||
/**
|
||||
* Clean up the authentication type in outgoing server settings.
|
||||
*
|
||||
* Replaces the authentication value "AUTOMATIC" with "PLAIN" when TLS is used, "CRAM_MD5" otherwise.
|
||||
* Replaces the authentication value "LOGIN" with "PLAIN".
|
||||
*/
|
||||
class StorageMigrationTo24(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun removeLegacyAuthenticationModes() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
removeLegacyAuthenticationModesForAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeLegacyAuthenticationModesForAccount(accountUuid: String) {
|
||||
val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
|
||||
|
||||
val adapter = createJsonAdapter()
|
||||
|
||||
adapter.fromJson(outgoingServerSettingsJson)?.let { settings ->
|
||||
createUpdatedServerSettings(settings)?.let { newSettings ->
|
||||
val json = adapter.toJson(newSettings)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.outgoingServerSettings", json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createUpdatedServerSettings(serverSettings: Map<String, Any?>): Map<String, Any?>? {
|
||||
val isSecure = serverSettings["connectionSecurity"] == "STARTTLS_REQUIRED" ||
|
||||
serverSettings["connectionSecurity"] == "SSL_TLS_REQUIRED"
|
||||
|
||||
return when (serverSettings["authenticationType"]) {
|
||||
"AUTOMATIC" -> {
|
||||
serverSettings.toMutableMap().apply {
|
||||
fixPortType()
|
||||
this["authenticationType"] = if (isSecure) "PLAIN" else "CRAM_MD5"
|
||||
}
|
||||
}
|
||||
|
||||
"LOGIN" -> {
|
||||
serverSettings.toMutableMap().apply {
|
||||
fixPortType()
|
||||
this["authenticationType"] = "PLAIN"
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableMap<String, Any?>.fixPortType() {
|
||||
// This is so we don't end up with a port value of e.g. "993.0". It would still work, but it looks odd.
|
||||
this["port"] = (this["port"] as? Double)?.toInt()
|
||||
}
|
||||
|
||||
private fun createJsonAdapter(): JsonAdapter<Map<String, Any?>> {
|
||||
val moshi = Moshi.Builder().build()
|
||||
|
||||
return moshi.adapter<Map<String, Any?>>(
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
|
||||
).serializeNulls()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
|
||||
/**
|
||||
* Updates outgoing server settings to use an authentication type value of "NONE" when appropriate.
|
||||
*/
|
||||
class StorageMigrationTo25(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun convertToAuthTypeNone() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
convertToAuthTypeNoneForAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertToAuthTypeNoneForAccount(accountUuid: String) {
|
||||
val outgoingServerSettingsJson = migrationsHelper.readValue(db, "$accountUuid.outgoingServerSettings") ?: return
|
||||
|
||||
val adapter = createJsonAdapter()
|
||||
|
||||
adapter.fromJson(outgoingServerSettingsJson)?.let { settings ->
|
||||
createUpdatedServerSettings(settings)?.let { newSettings ->
|
||||
val json = adapter.toJson(newSettings)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.outgoingServerSettings", json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createUpdatedServerSettings(serverSettings: Map<String, Any?>): Map<String, Any?>? {
|
||||
val username = serverSettings["username"] as? String?
|
||||
|
||||
return if (username.isNullOrEmpty()) {
|
||||
serverSettings.toMutableMap().apply {
|
||||
fixPortType()
|
||||
this["authenticationType"] = "NONE"
|
||||
this["username"] = ""
|
||||
this["password"] = null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableMap<String, Any?>.fixPortType() {
|
||||
// This is so we don't end up with a port value of e.g. "993.0". It would still work, but it looks odd.
|
||||
this["port"] = (this["port"] as? Double)?.toInt()
|
||||
}
|
||||
|
||||
private fun createJsonAdapter(): JsonAdapter<Map<String, Any?>> {
|
||||
val moshi = Moshi.Builder().build()
|
||||
|
||||
return moshi.adapter<Map<String, Any?>>(
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
|
||||
).serializeNulls()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fsck.k9.EmailAddressValidator
|
||||
|
||||
/**
|
||||
* Make sure identities are using a syntactically valid email address.
|
||||
*
|
||||
* Previously, we didn't validate input in the "Composition defaults" screen, and so there was no guarantee that the
|
||||
* `email` values contained a valid email address.
|
||||
*/
|
||||
class StorageMigrationTo26(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
private val emailAddressValidator = EmailAddressValidator()
|
||||
|
||||
fun fixIdentities() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
fixIdentitiesInAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixIdentitiesInAccount(accountUuid: String) {
|
||||
var identityIndex = 0
|
||||
|
||||
while (true) {
|
||||
val email = migrationsHelper.readValue(db, "$accountUuid.email.$identityIndex") ?: break
|
||||
|
||||
val trimmedEmail = email.trim()
|
||||
val newEmail = if (emailAddressValidator.isValidAddressOnly(trimmedEmail)) {
|
||||
trimmedEmail
|
||||
} else {
|
||||
"please.edit@invalid"
|
||||
}
|
||||
|
||||
migrationsHelper.writeValue(db, "$accountUuid.email.$identityIndex", newEmail)
|
||||
|
||||
identityIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
|
||||
|
||||
/**
|
||||
* Migration to add avatar monograms for accounts that have the MONOGRAM avatar type
|
||||
* and do not have an existing avatar monogram.
|
||||
*/
|
||||
class StorageMigrationTo27(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun addAvatarMonogram() {
|
||||
val accountUuidsValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsValue.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
addAvatarMonogramToAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addAvatarMonogramToAccount(accountUuid: String) {
|
||||
val avatarType = readAvatarType(accountUuid)
|
||||
val avatarMonogram = readAvatarMonogram(accountUuid)
|
||||
|
||||
if (avatarType == AvatarTypeDto.MONOGRAM.name && avatarMonogram.isEmpty()) {
|
||||
val monogram = generateAvatarMonogram(accountUuid)
|
||||
insertAvatarMonogram(accountUuid, monogram)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateAvatarMonogram(accountUuid: String): String {
|
||||
val name = readName(accountUuid)
|
||||
val email = readEmail(accountUuid)
|
||||
return getAvatarMonogram(name, email)
|
||||
}
|
||||
|
||||
private fun getAvatarMonogram(name: String?, email: String?): String {
|
||||
return if (name != null && name.isNotEmpty()) {
|
||||
composeAvatarMonogram(name)
|
||||
} else if (email != null && email.isNotEmpty()) {
|
||||
composeAvatarMonogram(email)
|
||||
} else {
|
||||
AVATAR_MONOGRAM_DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
private fun composeAvatarMonogram(name: String): String {
|
||||
return name.replace(" ", "").take(2).uppercase()
|
||||
}
|
||||
|
||||
private fun readAvatarType(accountUuid: String): String {
|
||||
return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_TYPE_KEY") ?: ""
|
||||
}
|
||||
|
||||
private fun readAvatarMonogram(accountUuid: String): String {
|
||||
return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY") ?: ""
|
||||
}
|
||||
|
||||
private fun readName(accountUuid: String): String {
|
||||
return migrationsHelper.readValue(db, "$accountUuid.$NAME_KEY") ?: ""
|
||||
}
|
||||
|
||||
private fun readEmail(accountUuid: String): String {
|
||||
return migrationsHelper.readValue(db, "$accountUuid.$EMAIL_KEY") ?: ""
|
||||
}
|
||||
|
||||
private fun insertAvatarMonogram(accountUuid: String, monogram: String) {
|
||||
migrationsHelper.insertValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY", monogram)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NAME_KEY = "name.0"
|
||||
const val EMAIL_KEY = "email.0"
|
||||
const val AVATAR_TYPE_KEY = "avatarType"
|
||||
const val AVATAR_MONOGRAM_KEY = "avatarMonogram"
|
||||
|
||||
private const val AVATAR_MONOGRAM_DEFAULT = "XX"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
|
||||
|
||||
/**
|
||||
* Migration to ensure all accounts have an avatar type set.
|
||||
* This fixes an issue where migration 27 might not have set the avatar type correctly.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class StorageMigrationTo28(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun ensureAvatarSet() {
|
||||
val accountUuidsValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsValue.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
ensureAvatarTypeForAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureAvatarTypeForAccount(accountUuid: String) {
|
||||
var avatarType = readAvatarType(accountUuid)
|
||||
val avatarMonogram = readAvatarMonogram(accountUuid)
|
||||
|
||||
if (avatarType.isEmpty()) {
|
||||
avatarType = AvatarTypeDto.MONOGRAM.name
|
||||
insertAvatarType(accountUuid, avatarType)
|
||||
}
|
||||
|
||||
if (avatarType == AvatarTypeDto.MONOGRAM.name && avatarMonogram.isEmpty()) {
|
||||
val monogram = generateAvatarMonogram(accountUuid)
|
||||
insertAvatarMonogram(accountUuid, monogram)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateAvatarMonogram(accountUuid: String): String {
|
||||
val name = readName(accountUuid)
|
||||
val email = readEmail(accountUuid)
|
||||
return getAvatarMonogram(name, email)
|
||||
}
|
||||
|
||||
private fun getAvatarMonogram(name: String?, email: String?): String {
|
||||
return if (name != null && name.isNotEmpty()) {
|
||||
composeAvatarMonogram(name)
|
||||
} else if (email != null && email.isNotEmpty()) {
|
||||
composeAvatarMonogram(email)
|
||||
} else {
|
||||
AVATAR_MONOGRAM_DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
private fun composeAvatarMonogram(name: String): String {
|
||||
return name.replace(" ", "").take(2).uppercase()
|
||||
}
|
||||
|
||||
private fun readAvatarType(accountUuid: String): String {
|
||||
return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_TYPE_KEY") ?: ""
|
||||
}
|
||||
|
||||
private fun readAvatarMonogram(accountUuid: String): String {
|
||||
return migrationsHelper.readValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY") ?: ""
|
||||
}
|
||||
|
||||
private fun readName(accountUuid: String): String {
|
||||
return migrationsHelper.readValue(db, "$accountUuid.$NAME_KEY") ?: ""
|
||||
}
|
||||
|
||||
private fun readEmail(accountUuid: String): String {
|
||||
return migrationsHelper.readValue(db, "$accountUuid.$EMAIL_KEY") ?: ""
|
||||
}
|
||||
|
||||
private fun insertAvatarType(accountUuid: String, avatarType: String) {
|
||||
migrationsHelper.insertValue(db, "$accountUuid.$AVATAR_TYPE_KEY", avatarType)
|
||||
}
|
||||
|
||||
private fun insertAvatarMonogram(accountUuid: String, monogram: String) {
|
||||
migrationsHelper.insertValue(db, "$accountUuid.$AVATAR_MONOGRAM_KEY", monogram)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NAME_KEY = "name.0"
|
||||
const val EMAIL_KEY = "email.0"
|
||||
const val AVATAR_TYPE_KEY = "avatarType"
|
||||
const val AVATAR_MONOGRAM_KEY = "avatarMonogram"
|
||||
|
||||
private const val AVATAR_MONOGRAM_DEFAULT = "XX"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Rewrite folder name values of "-NONE-" to `null`
|
||||
*/
|
||||
class StorageMigrationTo3(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun rewriteFolderNone() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
rewriteAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteAccount(accountUuid: String) {
|
||||
rewriteFolderValue("$accountUuid.archiveFolderName")
|
||||
rewriteFolderValue("$accountUuid.autoExpandFolderName")
|
||||
rewriteFolderValue("$accountUuid.draftsFolderName")
|
||||
rewriteFolderValue("$accountUuid.sentFolderName")
|
||||
rewriteFolderValue("$accountUuid.spamFolderName")
|
||||
rewriteFolderValue("$accountUuid.trashFolderName")
|
||||
}
|
||||
|
||||
private fun rewriteFolderValue(key: String) {
|
||||
val folderValue = migrationsHelper.readValue(db, key)
|
||||
if (folderValue == OLD_FOLDER_VALUE) {
|
||||
migrationsHelper.writeValue(db, key, NEW_FOLDER_VALUE)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val OLD_FOLDER_VALUE = "-NONE-"
|
||||
private val NEW_FOLDER_VALUE = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Add `*FolderSelection` values of "MANUAL" for existing accounts (default for new accounts is "AUTOMATIC").
|
||||
*/
|
||||
class StorageMigrationTo4(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun insertSpecialFolderSelectionValues() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
insertSpecialFolderSelectionValues(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertSpecialFolderSelectionValues(accountUuid: String) {
|
||||
migrationsHelper.insertValue(db, "$accountUuid.archiveFolderSelection", "MANUAL")
|
||||
migrationsHelper.insertValue(db, "$accountUuid.draftsFolderSelection", "MANUAL")
|
||||
migrationsHelper.insertValue(db, "$accountUuid.sentFolderSelection", "MANUAL")
|
||||
migrationsHelper.insertValue(db, "$accountUuid.spamFolderSelection", "MANUAL")
|
||||
migrationsHelper.insertValue(db, "$accountUuid.trashFolderSelection", "MANUAL")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Rewrite frequencies lower than LOWEST_FREQUENCY_SUPPORTED
|
||||
*/
|
||||
class StorageMigrationTo5(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun fixMailCheckFrequencies() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
fixFrequencyForAccount(accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixFrequencyForAccount(accountUuid: String) {
|
||||
val key = "$accountUuid.automaticCheckIntervalMinutes"
|
||||
val frequencyValue = migrationsHelper.readValue(db, key)?.toIntOrNull()
|
||||
if (frequencyValue != null && frequencyValue > -1 && frequencyValue < LOWEST_FREQUENCY_SUPPORTED) {
|
||||
migrationsHelper.writeValue(db, key, LOWEST_FREQUENCY_SUPPORTED.toString())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// see: https://github.com/evernote/android-job/wiki/FAQ#why-cant-an-interval-be-smaller-than-15-minutes-for-periodic-jobs
|
||||
private const val LOWEST_FREQUENCY_SUPPORTED = 15
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Perform legacy conversions that previously lived in `K9`.
|
||||
*/
|
||||
class StorageMigrationTo6(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun performLegacyMigrations() {
|
||||
rewriteTheme()
|
||||
migrateOpenPgpGlobalToAccountSettings()
|
||||
}
|
||||
|
||||
private fun rewriteTheme() {
|
||||
val theme = migrationsHelper.readValue(db, "theme")?.toInt()
|
||||
|
||||
// We used to save the resource ID of the theme. So convert that to the new format if necessary.
|
||||
val newTheme = if (theme == THEME_ORDINAL_DARK || theme == android.R.style.Theme) {
|
||||
THEME_ORDINAL_DARK
|
||||
} else {
|
||||
THEME_ORDINAL_LIGHT
|
||||
}
|
||||
|
||||
migrationsHelper.writeValue(db, "theme", newTheme.toString())
|
||||
}
|
||||
|
||||
private fun migrateOpenPgpGlobalToAccountSettings() {
|
||||
val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids")
|
||||
if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val openPgpProvider = migrationsHelper.readValue(db, "openPgpProvider") ?: ""
|
||||
val openPgpSupportSignOnly = migrationsHelper.readValue(db, "openPgpSupportSignOnly")?.toBoolean() ?: false
|
||||
val openPgpHideSignOnly = (!openPgpSupportSignOnly).toString()
|
||||
|
||||
val accountUuids = accountUuidsListValue.split(",")
|
||||
for (accountUuid in accountUuids) {
|
||||
migrationsHelper.writeValue(db, "$accountUuid.openPgpProvider", openPgpProvider)
|
||||
migrationsHelper.writeValue(db, "$accountUuid.openPgpHideSignOnly", openPgpHideSignOnly)
|
||||
}
|
||||
|
||||
migrationsHelper.writeValue(db, "openPgpProvider", null)
|
||||
migrationsHelper.writeValue(db, "openPgpSupportSignOnly", null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val THEME_ORDINAL_LIGHT = 0
|
||||
private const val THEME_ORDINAL_DARK = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Rewrite settings to use enum names instead of ordinals.
|
||||
*/
|
||||
class StorageMigrationTo7(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun rewriteEnumOrdinalsToNames() {
|
||||
rewriteTheme()
|
||||
rewriteMessageViewTheme()
|
||||
rewriteMessageComposeTheme()
|
||||
}
|
||||
|
||||
private fun rewriteTheme() {
|
||||
val theme = migrationsHelper.readValue(db, "theme")?.toInt()
|
||||
val newTheme = if (theme == THEME_ORDINAL_DARK) {
|
||||
THEME_DARK
|
||||
} else {
|
||||
THEME_LIGHT
|
||||
}
|
||||
|
||||
migrationsHelper.writeValue(db, "theme", newTheme)
|
||||
}
|
||||
|
||||
private fun rewriteMessageViewTheme() {
|
||||
rewriteScreenTheme("messageViewTheme")
|
||||
}
|
||||
|
||||
private fun rewriteMessageComposeTheme() {
|
||||
rewriteScreenTheme("messageComposeTheme")
|
||||
}
|
||||
|
||||
private fun rewriteScreenTheme(key: String) {
|
||||
val newTheme = when (migrationsHelper.readValue(db, key)?.toInt()) {
|
||||
THEME_ORDINAL_DARK -> THEME_DARK
|
||||
THEME_ORDINAL_USE_GLOBAL -> THEME_USE_GLOBAL
|
||||
else -> THEME_LIGHT
|
||||
}
|
||||
|
||||
migrationsHelper.writeValue(db, key, newTheme)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val THEME_ORDINAL_DARK = 1
|
||||
private const val THEME_ORDINAL_USE_GLOBAL = 2
|
||||
private const val THEME_LIGHT = "LIGHT"
|
||||
private const val THEME_DARK = "DARK"
|
||||
private const val THEME_USE_GLOBAL = "USE_GLOBAL"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Rewrite theme setting to use `FOLLOW_SYSTEM` when it's currently set to `LIGHT`.
|
||||
*/
|
||||
class StorageMigrationTo8(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: StorageMigrationHelper,
|
||||
) {
|
||||
fun rewriteTheme() {
|
||||
val theme = migrationsHelper.readValue(db, "theme")
|
||||
if (theme == THEME_LIGHT) {
|
||||
migrationsHelper.writeValue(db, "theme", THEME_FOLLOW_SYSTEM)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val THEME_LIGHT = "LIGHT"
|
||||
private const val THEME_FOLLOW_SYSTEM = "FOLLOW_SYSTEM"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.fsck.k9.preferences.migration
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
internal object StorageMigrations {
|
||||
@Suppress("MagicNumber", "CyclomaticComplexMethod")
|
||||
@JvmStatic
|
||||
fun upgradeDatabase(db: SQLiteDatabase, migrationsHelper: StorageMigrationHelper) {
|
||||
val oldVersion = db.version
|
||||
|
||||
if (oldVersion < 2) StorageMigrationTo2.urlEncodeUserNameAndPassword(db, migrationsHelper)
|
||||
if (oldVersion < 3) StorageMigrationTo3(db, migrationsHelper).rewriteFolderNone()
|
||||
if (oldVersion < 4) StorageMigrationTo4(db, migrationsHelper).insertSpecialFolderSelectionValues()
|
||||
if (oldVersion < 5) StorageMigrationTo5(db, migrationsHelper).fixMailCheckFrequencies()
|
||||
if (oldVersion < 6) StorageMigrationTo6(db, migrationsHelper).performLegacyMigrations()
|
||||
if (oldVersion < 7) StorageMigrationTo7(db, migrationsHelper).rewriteEnumOrdinalsToNames()
|
||||
if (oldVersion < 8) StorageMigrationTo8(db, migrationsHelper).rewriteTheme()
|
||||
// 9: "Temporarily disable Push" is no longer necessary
|
||||
if (oldVersion < 10) StorageMigrationTo10(db, migrationsHelper).removeSavedFolderSettings()
|
||||
if (oldVersion < 11) StorageMigrationTo11(db, migrationsHelper).upgradeMessageViewContentFontSize()
|
||||
if (oldVersion < 12) StorageMigrationTo12(db, migrationsHelper).removeStoreAndTransportUri()
|
||||
if (oldVersion < 13) StorageMigrationTo13(db, migrationsHelper).renameHideSpecialAccounts()
|
||||
if (oldVersion < 14) StorageMigrationTo14(db, migrationsHelper).disablePushFoldersForNonImapAccounts()
|
||||
if (oldVersion < 15) StorageMigrationTo15(db, migrationsHelper).rewriteIdleRefreshInterval()
|
||||
if (oldVersion < 16) StorageMigrationTo16(db, migrationsHelper).changeDefaultRegisteredNameColor()
|
||||
if (oldVersion < 17) StorageMigrationTo17(db, migrationsHelper).rewriteNotificationLightSettings()
|
||||
if (oldVersion < 18) StorageMigrationTo18(db, migrationsHelper).rewriteImapCompressionSettings()
|
||||
if (oldVersion < 19) StorageMigrationTo19(db, migrationsHelper).markGmailAccounts()
|
||||
if (oldVersion < 20) StorageMigrationTo20(db, migrationsHelper).fixIdentities()
|
||||
if (oldVersion < 21) StorageMigrationTo21(db, migrationsHelper).createPostRemoveNavigationSetting()
|
||||
if (oldVersion < 22) StorageMigrationTo22(db, migrationsHelper).fixServerSettings()
|
||||
if (oldVersion < 23) StorageMigrationTo23(db, migrationsHelper).renameSendClientId()
|
||||
if (oldVersion < 24) StorageMigrationTo24(db, migrationsHelper).removeLegacyAuthenticationModes()
|
||||
if (oldVersion < 25) StorageMigrationTo25(db, migrationsHelper).convertToAuthTypeNone()
|
||||
if (oldVersion < 26) StorageMigrationTo26(db, migrationsHelper).fixIdentities()
|
||||
if (oldVersion < 27) StorageMigrationTo27(db, migrationsHelper).addAvatarMonogram()
|
||||
if (oldVersion < 28) StorageMigrationTo28(db, migrationsHelper).ensureAvatarSet()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.fsck.k9.preferences.migration.migration12;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
import com.fsck.k9.mail.ServerSettings;
|
||||
|
||||
import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8;
|
||||
|
||||
|
||||
public class ImapStoreUriDecoder {
|
||||
private static final int DEFAULT_PORT = 143;
|
||||
private static final int DEFAULT_TLS_PORT = 993;
|
||||
|
||||
/**
|
||||
* Decodes an ImapStore URI.
|
||||
*
|
||||
* <p>Possible forms:</p>
|
||||
* <pre>
|
||||
* imap://auth:user:password@server:port ConnectionSecurity.NONE
|
||||
* imap+tls+://auth:user:password@server:port ConnectionSecurity.STARTTLS_REQUIRED
|
||||
* imap+ssl+://auth:user:password@server:port ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
* </pre>
|
||||
*
|
||||
* NOTE: this method expects the userinfo part of the URI to be encoded twice, due to a bug in the URI creation
|
||||
* code.
|
||||
*
|
||||
* @param uri the store uri.
|
||||
*/
|
||||
public static ServerSettings decode(String uri) {
|
||||
String host;
|
||||
int port;
|
||||
ConnectionSecurity connectionSecurity;
|
||||
AuthType authenticationType = AuthType.PLAIN;
|
||||
String username = "";
|
||||
String password = null;
|
||||
String clientCertificateAlias = null;
|
||||
String pathPrefix = null;
|
||||
boolean autoDetectNamespace = true;
|
||||
|
||||
URI imapUri;
|
||||
try {
|
||||
imapUri = new URI(uri);
|
||||
} catch (URISyntaxException use) {
|
||||
throw new IllegalArgumentException("Invalid ImapStore URI", use);
|
||||
}
|
||||
|
||||
String scheme = imapUri.getScheme();
|
||||
/*
|
||||
* Currently available schemes are:
|
||||
* imap
|
||||
* imap+tls+
|
||||
* imap+ssl+
|
||||
*
|
||||
* The following are obsolete schemes that may be found in pre-existing
|
||||
* settings from earlier versions or that may be found when imported. We
|
||||
* continue to recognize them and re-map them appropriately:
|
||||
* imap+tls
|
||||
* imap+ssl
|
||||
*/
|
||||
if (scheme.equals("imap")) {
|
||||
connectionSecurity = ConnectionSecurity.NONE;
|
||||
port = DEFAULT_PORT;
|
||||
} else if (scheme.startsWith("imap+tls")) {
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED;
|
||||
port = DEFAULT_PORT;
|
||||
} else if (scheme.startsWith("imap+ssl")) {
|
||||
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
|
||||
port = DEFAULT_TLS_PORT;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
|
||||
}
|
||||
|
||||
host = imapUri.getHost();
|
||||
|
||||
if (imapUri.getPort() != -1) {
|
||||
port = imapUri.getPort();
|
||||
}
|
||||
|
||||
if (imapUri.getUserInfo() != null) {
|
||||
String userinfo = imapUri.getUserInfo();
|
||||
String[] userInfoParts = userinfo.split(":");
|
||||
|
||||
if (userinfo.endsWith(":")) {
|
||||
// Last field (password/certAlias) is empty.
|
||||
// For imports e.g.: PLAIN:username: or username:
|
||||
// Or XOAUTH2 where it's a valid config - XOAUTH:username:
|
||||
if (userInfoParts.length > 1) {
|
||||
authenticationType = AuthType.valueOf(userInfoParts[0]);
|
||||
username = decodeUtf8(userInfoParts[1]);
|
||||
} else {
|
||||
username = decodeUtf8(userInfoParts[0]);
|
||||
}
|
||||
} else if (userInfoParts.length == 2) {
|
||||
// Old/standard style of encoding - PLAIN auth only:
|
||||
// username:password
|
||||
username = decodeUtf8(userInfoParts[0]);
|
||||
password = decodeUtf8(userInfoParts[1]);
|
||||
} else if (userInfoParts.length == 3) {
|
||||
// Standard encoding
|
||||
// PLAIN:username:password
|
||||
// EXTERNAL:username:certAlias
|
||||
authenticationType = AuthType.valueOf(userInfoParts[0]);
|
||||
username = decodeUtf8(userInfoParts[1]);
|
||||
|
||||
if (AuthType.EXTERNAL == authenticationType) {
|
||||
clientCertificateAlias = decodeUtf8(userInfoParts[2]);
|
||||
} else {
|
||||
password = decodeUtf8(userInfoParts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String path = imapUri.getPath();
|
||||
if (path != null && path.length() > 1) {
|
||||
// Strip off the leading "/"
|
||||
String cleanPath = path.substring(1);
|
||||
|
||||
if (cleanPath.length() >= 2 && cleanPath.charAt(1) == '|') {
|
||||
autoDetectNamespace = cleanPath.charAt(0) == '1';
|
||||
if (!autoDetectNamespace) {
|
||||
pathPrefix = cleanPath.substring(2);
|
||||
}
|
||||
} else {
|
||||
if (cleanPath.length() > 0) {
|
||||
pathPrefix = cleanPath;
|
||||
autoDetectNamespace = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> extra = new HashMap<>();
|
||||
extra.put("autoDetectNamespace", Boolean.toString(autoDetectNamespace));
|
||||
extra.put("pathPrefix", pathPrefix);
|
||||
|
||||
return new ServerSettings("imap", host, port, connectionSecurity, authenticationType, username,
|
||||
password, clientCertificateAlias, extra);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package com.fsck.k9.preferences.migration.migration12;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
import com.fsck.k9.mail.ServerSettings;
|
||||
|
||||
import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8;
|
||||
|
||||
|
||||
public class Pop3StoreUriDecoder {
|
||||
private static final int DEFAULT_PORT = 110;
|
||||
private static final int DEFAULT_TLS_PORT = 995;
|
||||
|
||||
/**
|
||||
* Decodes a Pop3Store URI.
|
||||
*
|
||||
* <p>Possible forms:</p>
|
||||
* <pre>
|
||||
* pop3://authType:user:password@server:port
|
||||
* ConnectionSecurity.NONE
|
||||
* pop3+tls+://authType:user:password@server:port
|
||||
* ConnectionSecurity.STARTTLS_REQUIRED
|
||||
* pop3+ssl+://authType:user:password@server:port
|
||||
* ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
* </pre>
|
||||
*
|
||||
* e.g.
|
||||
* <pre>pop3://PLAIN:admin:pass123@example.org:12345</pre>
|
||||
*/
|
||||
public static ServerSettings decode(String uri) {
|
||||
String host;
|
||||
int port;
|
||||
ConnectionSecurity connectionSecurity;
|
||||
String username = "";
|
||||
String password = null;
|
||||
String clientCertificateAlias = null;
|
||||
|
||||
URI pop3Uri;
|
||||
try {
|
||||
pop3Uri = new URI(uri);
|
||||
} catch (URISyntaxException use) {
|
||||
throw new IllegalArgumentException("Invalid Pop3Store URI", use);
|
||||
}
|
||||
|
||||
String scheme = pop3Uri.getScheme();
|
||||
/*
|
||||
* Currently available schemes are:
|
||||
* pop3
|
||||
* pop3+tls+
|
||||
* pop3+ssl+
|
||||
*
|
||||
* The following are obsolete schemes that may be found in pre-existing
|
||||
* settings from earlier versions or that may be found when imported. We
|
||||
* continue to recognize them and re-map them appropriately:
|
||||
* pop3+tls
|
||||
* pop3+ssl
|
||||
*/
|
||||
if (scheme.equals("pop3")) {
|
||||
connectionSecurity = ConnectionSecurity.NONE;
|
||||
port = DEFAULT_PORT;
|
||||
} else if (scheme.startsWith("pop3+tls")) {
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED;
|
||||
port = DEFAULT_PORT;
|
||||
} else if (scheme.startsWith("pop3+ssl")) {
|
||||
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
|
||||
port = DEFAULT_TLS_PORT;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
|
||||
}
|
||||
|
||||
host = pop3Uri.getHost();
|
||||
|
||||
if (pop3Uri.getPort() != -1) {
|
||||
port = pop3Uri.getPort();
|
||||
}
|
||||
|
||||
AuthType authType = AuthType.PLAIN;
|
||||
if (pop3Uri.getUserInfo() != null) {
|
||||
int userIndex = 0, passwordIndex = 1;
|
||||
String userinfo = pop3Uri.getUserInfo();
|
||||
String[] userInfoParts = userinfo.split(":");
|
||||
if (userInfoParts.length > 2 || userinfo.endsWith(":") ) {
|
||||
// If 'userinfo' ends with ":" the password is empty. This can only happen
|
||||
// after an account was imported (so authType and username are present).
|
||||
userIndex++;
|
||||
passwordIndex++;
|
||||
authType = AuthType.valueOf(userInfoParts[0]);
|
||||
}
|
||||
username = decodeUtf8(userInfoParts[userIndex]);
|
||||
if (userInfoParts.length > passwordIndex) {
|
||||
if (authType == AuthType.EXTERNAL) {
|
||||
clientCertificateAlias = decodeUtf8(userInfoParts[passwordIndex]);
|
||||
} else {
|
||||
password = decodeUtf8(userInfoParts[passwordIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ServerSettings("pop3", host, port, connectionSecurity, authType, username,
|
||||
password, clientCertificateAlias);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.fsck.k9.preferences.migration.migration12;
|
||||
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLDecoder;
|
||||
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
import com.fsck.k9.mail.ServerSettings;
|
||||
|
||||
|
||||
public class SmtpTransportUriDecoder {
|
||||
private static final int DEFAULT_PORT = 587;
|
||||
private static final int DEFAULT_TLS_PORT = 465;
|
||||
|
||||
/**
|
||||
* Decodes a SmtpTransport URI.
|
||||
*
|
||||
* NOTE: In contrast to ImapStore and Pop3Store, the authType is appended at the end!
|
||||
*
|
||||
* <p>Possible forms:</p>
|
||||
* <pre>
|
||||
* smtp://user:password:auth@server:port ConnectionSecurity.NONE
|
||||
* smtp+tls+://user:password:auth@server:port ConnectionSecurity.STARTTLS_REQUIRED
|
||||
* smtp+ssl+://user:password:auth@server:port ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
* </pre>
|
||||
*/
|
||||
public static ServerSettings decodeSmtpUri(String uri) {
|
||||
String host;
|
||||
int port;
|
||||
ConnectionSecurity connectionSecurity;
|
||||
AuthType authType = AuthType.PLAIN;
|
||||
String username = "";
|
||||
String password = null;
|
||||
String clientCertificateAlias = null;
|
||||
|
||||
URI smtpUri;
|
||||
try {
|
||||
smtpUri = new URI(uri);
|
||||
} catch (URISyntaxException use) {
|
||||
throw new IllegalArgumentException("Invalid SmtpTransport URI", use);
|
||||
}
|
||||
|
||||
String scheme = smtpUri.getScheme();
|
||||
/*
|
||||
* Currently available schemes are:
|
||||
* smtp
|
||||
* smtp+tls+
|
||||
* smtp+ssl+
|
||||
*
|
||||
* The following are obsolete schemes that may be found in pre-existing
|
||||
* settings from earlier versions or that may be found when imported. We
|
||||
* continue to recognize them and re-map them appropriately:
|
||||
* smtp+tls
|
||||
* smtp+ssl
|
||||
*/
|
||||
if (scheme.equals("smtp")) {
|
||||
connectionSecurity = ConnectionSecurity.NONE;
|
||||
port = DEFAULT_PORT;
|
||||
} else if (scheme.startsWith("smtp+tls")) {
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED;
|
||||
port = DEFAULT_PORT;
|
||||
} else if (scheme.startsWith("smtp+ssl")) {
|
||||
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
|
||||
port = DEFAULT_TLS_PORT;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
|
||||
}
|
||||
|
||||
host = smtpUri.getHost();
|
||||
|
||||
if (smtpUri.getPort() != -1) {
|
||||
port = smtpUri.getPort();
|
||||
}
|
||||
|
||||
if (smtpUri.getUserInfo() != null) {
|
||||
String[] userInfoParts = smtpUri.getUserInfo().split(":");
|
||||
if (userInfoParts.length == 1) {
|
||||
username = decodeUtf8(userInfoParts[0]);
|
||||
} else if (userInfoParts.length == 2) {
|
||||
username = decodeUtf8(userInfoParts[0]);
|
||||
password = decodeUtf8(userInfoParts[1]);
|
||||
} else if (userInfoParts.length == 3) {
|
||||
// NOTE: In SmtpTransport URIs, the authType comes last!
|
||||
authType = AuthType.valueOf(userInfoParts[2]);
|
||||
username = decodeUtf8(userInfoParts[0]);
|
||||
if (authType == AuthType.EXTERNAL) {
|
||||
clientCertificateAlias = decodeUtf8(userInfoParts[1]);
|
||||
} else {
|
||||
password = decodeUtf8(userInfoParts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ServerSettings("smtp", host, port, connectionSecurity,
|
||||
authType, username, password, clientCertificateAlias);
|
||||
}
|
||||
|
||||
private static String decodeUtf8(String s) {
|
||||
try {
|
||||
return URLDecoder.decode(s, "UTF-8");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException("UTF-8 not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package com.fsck.k9.preferences.migration.migration12;
|
||||
|
||||
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
import com.fsck.k9.mail.ServerSettings;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8;
|
||||
|
||||
public class WebDavStoreUriDecoder {
|
||||
|
||||
/**
|
||||
* Decodes a WebDavStore URI.
|
||||
* <p/>
|
||||
* <p>Possible forms:</p>
|
||||
* <pre>
|
||||
* webdav://user:password@server:port ConnectionSecurity.NONE
|
||||
* webdav+ssl+://user:password@server:port ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
* </pre>
|
||||
*/
|
||||
public static ServerSettings decode(String uri) {
|
||||
String host;
|
||||
int port;
|
||||
ConnectionSecurity connectionSecurity;
|
||||
String username = null;
|
||||
String password = null;
|
||||
String alias = null;
|
||||
String path = null;
|
||||
String authPath = null;
|
||||
String mailboxPath = null;
|
||||
|
||||
|
||||
URI webDavUri;
|
||||
try {
|
||||
webDavUri = new URI(uri);
|
||||
} catch (URISyntaxException use) {
|
||||
throw new IllegalArgumentException("Invalid WebDavStore URI", use);
|
||||
}
|
||||
|
||||
String scheme = webDavUri.getScheme();
|
||||
/*
|
||||
* Currently available schemes are:
|
||||
* webdav
|
||||
* webdav+ssl+
|
||||
*
|
||||
* The following are obsolete schemes that may be found in pre-existing
|
||||
* settings from earlier versions or that may be found when imported. We
|
||||
* continue to recognize them and re-map them appropriately:
|
||||
* webdav+tls
|
||||
* webdav+tls+
|
||||
* webdav+ssl
|
||||
*/
|
||||
if (scheme.equals("webdav")) {
|
||||
connectionSecurity = ConnectionSecurity.NONE;
|
||||
} else if (scheme.startsWith("webdav+")) {
|
||||
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported protocol (" + scheme + ")");
|
||||
}
|
||||
|
||||
host = webDavUri.getHost();
|
||||
if (host.startsWith("http")) {
|
||||
String[] hostParts = host.split("://", 2);
|
||||
if (hostParts.length > 1) {
|
||||
host = hostParts[1];
|
||||
}
|
||||
}
|
||||
|
||||
port = webDavUri.getPort();
|
||||
|
||||
String userInfo = webDavUri.getUserInfo();
|
||||
if (userInfo != null) {
|
||||
String[] userInfoParts = userInfo.split(":");
|
||||
username = decodeUtf8(userInfoParts[0]);
|
||||
String userParts[] = username.split("\\\\", 2);
|
||||
|
||||
if (userParts.length > 1) {
|
||||
alias = userParts[1];
|
||||
} else {
|
||||
alias = username;
|
||||
}
|
||||
if (userInfoParts.length > 1) {
|
||||
password = decodeUtf8(userInfoParts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
String[] pathParts = webDavUri.getPath().split("\\|");
|
||||
for (int i = 0, count = pathParts.length; i < count; i++) {
|
||||
if (i == 0) {
|
||||
if (pathParts[0] != null &&
|
||||
pathParts[0].length() > 1) {
|
||||
path = pathParts[0];
|
||||
}
|
||||
} else if (i == 1) {
|
||||
if (pathParts[1] != null &&
|
||||
pathParts[1].length() > 1) {
|
||||
authPath = pathParts[1];
|
||||
}
|
||||
} else if (i == 2) {
|
||||
if (pathParts[2] != null &&
|
||||
pathParts[2].length() > 1) {
|
||||
mailboxPath = pathParts[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> extra = new HashMap<>();
|
||||
extra.put("alias", alias);
|
||||
extra.put("path", path);
|
||||
extra.put("authPath", authPath);
|
||||
extra.put("mailboxPath", mailboxPath);
|
||||
|
||||
return new ServerSettings("webdav", host, port, connectionSecurity, AuthType.PLAIN, username, password, null, extra);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.fsck.k9.storage
|
||||
|
||||
import app.k9mail.legacy.mailstore.MessageStoreFactory
|
||||
import com.fsck.k9.mailstore.SchemaDefinitionFactory
|
||||
import com.fsck.k9.notification.NotificationStoreProvider
|
||||
import com.fsck.k9.storage.messages.K9MessageStoreFactory
|
||||
import com.fsck.k9.storage.notifications.K9NotificationStoreProvider
|
||||
import org.koin.dsl.module
|
||||
|
||||
val storageModule = module {
|
||||
single<SchemaDefinitionFactory> { K9SchemaDefinitionFactory() }
|
||||
single<MessageStoreFactory> {
|
||||
K9MessageStoreFactory(
|
||||
localStoreProvider = get(),
|
||||
storageFilesProviderFactory = get(),
|
||||
basicPartInfoExtractor = get(),
|
||||
generalSettingsManager = get(),
|
||||
)
|
||||
}
|
||||
single<NotificationStoreProvider> {
|
||||
K9NotificationStoreProvider(localStoreProvider = get())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
package com.fsck.k9.storage;
|
||||
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
|
||||
import com.fsck.k9.K9;
|
||||
import com.fsck.k9.core.BuildConfig;
|
||||
import com.fsck.k9.mailstore.LockableDatabase.SchemaDefinition;
|
||||
import com.fsck.k9.mailstore.MigrationsHelper;
|
||||
import com.fsck.k9.storage.migrations.Migrations;
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
|
||||
class StoreSchemaDefinition implements SchemaDefinition {
|
||||
static final int DB_VERSION = 90;
|
||||
|
||||
private final MigrationsHelper migrationsHelper;
|
||||
|
||||
|
||||
StoreSchemaDefinition(MigrationsHelper migrationsHelper) {
|
||||
this.migrationsHelper = migrationsHelper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getVersion() {
|
||||
return DB_VERSION;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doDbUpgrade(final SQLiteDatabase db) {
|
||||
try {
|
||||
upgradeDatabase(db);
|
||||
} catch (Exception e) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
throw new Error("Exception while upgrading database", e);
|
||||
}
|
||||
|
||||
Log.e(e, "Exception while upgrading database. Resetting the DB to v0");
|
||||
db.setVersion(0);
|
||||
upgradeDatabase(db);
|
||||
}
|
||||
}
|
||||
|
||||
private void upgradeDatabase(final SQLiteDatabase db) {
|
||||
Log.i("Upgrading database from version %d to version %d", db.getVersion(), DB_VERSION);
|
||||
|
||||
db.beginTransaction();
|
||||
try {
|
||||
if (db.getVersion() > DB_VERSION) {
|
||||
String accountUuid = migrationsHelper.getAccount().getUuid();
|
||||
throw new AssertionError("Database downgrades are not supported. " +
|
||||
"Please fix the account database '" + accountUuid + "' manually or " +
|
||||
"clear app data.");
|
||||
}
|
||||
|
||||
// We only support upgrades from K-9 Mail 5.301. For upgrades from earlier versions we start from scratch.
|
||||
if (db.getVersion() < 61) {
|
||||
dbCreateDatabaseFromScratch(db);
|
||||
} else {
|
||||
Migrations.upgradeDatabase(db, migrationsHelper);
|
||||
}
|
||||
|
||||
db.setVersion(DB_VERSION);
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
|
||||
if (db.getVersion() != DB_VERSION) {
|
||||
throw new RuntimeException("Database upgrade failed!");
|
||||
}
|
||||
}
|
||||
|
||||
private static void dbCreateDatabaseFromScratch(SQLiteDatabase db) {
|
||||
db.execSQL("DROP TABLE IF EXISTS account_extra_values");
|
||||
db.execSQL("CREATE TABLE account_extra_values (" +
|
||||
"name TEXT NOT NULL PRIMARY KEY, " +
|
||||
"value_text TEXT, " +
|
||||
"value_integer INTEGER " +
|
||||
")");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS folders");
|
||||
db.execSQL("CREATE TABLE folders (" +
|
||||
"id INTEGER PRIMARY KEY," +
|
||||
"name TEXT, " +
|
||||
"last_updated INTEGER, " +
|
||||
"unread_count INTEGER, " +
|
||||
"visible_limit INTEGER, " +
|
||||
"status TEXT, " +
|
||||
"flagged_count INTEGER default 0, " +
|
||||
"integrate INTEGER, " +
|
||||
"top_group INTEGER, " +
|
||||
"sync_enabled INTEGER DEFAULT 0, " +
|
||||
"push_enabled INTEGER DEFAULT 0, " +
|
||||
"visible INTEGER DEFAULT 1, " +
|
||||
"notifications_enabled INTEGER DEFAULT 0, " +
|
||||
"more_messages TEXT default \"unknown\", " +
|
||||
"server_id TEXT, " +
|
||||
"local_only INTEGER, " +
|
||||
"type TEXT DEFAULT \"regular\"" +
|
||||
")");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS folder_server_id");
|
||||
db.execSQL("CREATE INDEX folder_server_id ON folders (server_id)");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS folder_extra_values");
|
||||
db.execSQL("CREATE TABLE folder_extra_values (" +
|
||||
"folder_id INTEGER NOT NULL, " +
|
||||
"name TEXT NOT NULL, " +
|
||||
"value_text TEXT, " +
|
||||
"value_integer INTEGER, " +
|
||||
"PRIMARY KEY (folder_id, name)" +
|
||||
")");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS messages");
|
||||
db.execSQL("CREATE TABLE messages (" +
|
||||
"id INTEGER PRIMARY KEY, " +
|
||||
"deleted INTEGER default 0, " +
|
||||
"folder_id INTEGER, " +
|
||||
"uid TEXT, " +
|
||||
"subject TEXT, " +
|
||||
"date INTEGER, " +
|
||||
"flags TEXT, " +
|
||||
"sender_list TEXT, " +
|
||||
"to_list TEXT, " +
|
||||
"cc_list TEXT, " +
|
||||
"bcc_list TEXT, " +
|
||||
"reply_to_list TEXT, " +
|
||||
"attachment_count INTEGER, " +
|
||||
"internal_date INTEGER, " +
|
||||
"message_id TEXT, " +
|
||||
"preview_type TEXT default \"none\", " +
|
||||
"preview TEXT, " +
|
||||
"mime_type TEXT, "+
|
||||
"normalized_subject_hash INTEGER, " +
|
||||
"empty INTEGER default 0, " +
|
||||
"read INTEGER default 0, " +
|
||||
"flagged INTEGER default 0, " +
|
||||
"answered INTEGER default 0, " +
|
||||
"forwarded INTEGER default 0, " +
|
||||
"message_part_id INTEGER," +
|
||||
"encryption_type TEXT," +
|
||||
"new_message INTEGER DEFAULT 0" +
|
||||
")");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS new_messages");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS new_messages ON messages(new_message)");
|
||||
|
||||
db.execSQL("CREATE TRIGGER new_message_reset " +
|
||||
"AFTER UPDATE OF read ON messages " +
|
||||
"FOR EACH ROW WHEN NEW.read = 1 AND NEW.new_message = 1 " +
|
||||
"BEGIN " +
|
||||
"UPDATE messages SET new_message = 0 WHERE ROWID = NEW.ROWID; " +
|
||||
"END");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS message_parts");
|
||||
db.execSQL("CREATE TABLE message_parts (" +
|
||||
"id INTEGER PRIMARY KEY, " +
|
||||
"type INTEGER NOT NULL, " +
|
||||
"root INTEGER, " +
|
||||
"parent INTEGER NOT NULL, " +
|
||||
"seq INTEGER NOT NULL, " +
|
||||
"mime_type TEXT, " +
|
||||
"decoded_body_size INTEGER, " +
|
||||
"display_name TEXT, " +
|
||||
"header TEXT, " +
|
||||
"encoding TEXT, " +
|
||||
"charset TEXT, " +
|
||||
"data_location INTEGER NOT NULL, " +
|
||||
"data BLOB, " +
|
||||
"preamble TEXT, " +
|
||||
"epilogue TEXT, " +
|
||||
"boundary TEXT, " +
|
||||
"content_id TEXT, " +
|
||||
"server_extra TEXT" +
|
||||
")");
|
||||
|
||||
db.execSQL("CREATE TRIGGER set_message_part_root " +
|
||||
"AFTER INSERT ON message_parts " +
|
||||
"BEGIN " +
|
||||
"UPDATE message_parts SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " +
|
||||
"END");
|
||||
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS msg_uid ON messages (uid, folder_id)");
|
||||
db.execSQL("DROP INDEX IF EXISTS msg_folder_id");
|
||||
db.execSQL("DROP INDEX IF EXISTS msg_folder_id_date");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS msg_folder_id_deleted_date ON messages (folder_id,deleted,internal_date)");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS msg_empty");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS msg_empty ON messages (empty)");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS msg_read");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS msg_read ON messages (read)");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS msg_flagged");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS msg_flagged ON messages (flagged)");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS msg_composite");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS msg_composite ON messages (deleted, empty,folder_id,flagged,read)");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS message_parts_root");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS message_parts_root ON message_parts (root)");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS threads");
|
||||
db.execSQL("CREATE TABLE threads (" +
|
||||
"id INTEGER PRIMARY KEY, " +
|
||||
"message_id INTEGER, " +
|
||||
"root INTEGER, " +
|
||||
"parent INTEGER" +
|
||||
")");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS threads_message_id");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS threads_message_id ON threads (message_id)");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS threads_root");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS threads_root ON threads (root)");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS threads_parent");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS threads_parent ON threads (parent)");
|
||||
|
||||
db.execSQL("DROP TRIGGER IF EXISTS set_thread_root");
|
||||
db.execSQL("CREATE TRIGGER set_thread_root " +
|
||||
"AFTER INSERT ON threads " +
|
||||
"BEGIN " +
|
||||
"UPDATE threads SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " +
|
||||
"END");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS outbox_state");
|
||||
db.execSQL("CREATE TABLE outbox_state (" +
|
||||
"message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," +
|
||||
"send_state TEXT," +
|
||||
"number_of_send_attempts INTEGER DEFAULT 0," +
|
||||
"error_timestamp INTEGER DEFAULT 0," +
|
||||
"error TEXT)");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS pending_commands");
|
||||
db.execSQL("CREATE TABLE pending_commands " +
|
||||
"(id INTEGER PRIMARY KEY, command TEXT, data TEXT)");
|
||||
|
||||
db.execSQL("DROP TRIGGER IF EXISTS delete_folder");
|
||||
db.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;");
|
||||
|
||||
db.execSQL("DROP TRIGGER IF EXISTS delete_folder_extra_values");
|
||||
db.execSQL("CREATE TRIGGER delete_folder_extra_values " +
|
||||
"BEFORE DELETE ON folders " +
|
||||
"BEGIN " +
|
||||
"DELETE FROM folder_extra_values WHERE old.id = folder_id; " +
|
||||
"END;");
|
||||
|
||||
db.execSQL("DROP TRIGGER IF EXISTS delete_message");
|
||||
db.execSQL("CREATE TRIGGER delete_message " +
|
||||
"BEFORE DELETE ON messages " +
|
||||
"BEGIN " +
|
||||
"DELETE FROM message_parts WHERE root = OLD.message_part_id; " +
|
||||
"DELETE FROM messages_fulltext WHERE docid = OLD.id; " +
|
||||
"DELETE FROM threads WHERE message_id = OLD.id; " +
|
||||
"END");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS messages_fulltext");
|
||||
db.execSQL("CREATE VIRTUAL TABLE messages_fulltext USING fts4 (fulltext)");
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS notifications");
|
||||
db.execSQL("CREATE TABLE notifications (" +
|
||||
"message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," +
|
||||
"notification_id INTEGER UNIQUE," +
|
||||
"timestamp INTEGER NOT NULL" +
|
||||
")");
|
||||
|
||||
db.execSQL("DROP INDEX IF EXISTS notifications_timestamp");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS notifications_timestamp ON notifications(timestamp)");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import com.fsck.k9.helper.FileHelper
|
||||
import com.fsck.k9.mailstore.StorageFilesProvider
|
||||
import java.io.File
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
|
||||
internal class AttachmentFileManager(
|
||||
private val storageFilesProvider: StorageFilesProvider,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
) {
|
||||
fun deleteFile(messagePartId: Long) {
|
||||
val file = getAttachmentFile(messagePartId)
|
||||
if (file.exists() && !file.delete() && generalSettingsManager.getConfig().debugging.isDebugLoggingEnabled) {
|
||||
Log.w("Couldn't delete message part file: %s", file.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
fun moveTemporaryFile(temporaryFile: File, messagePartId: Long) {
|
||||
val destinationFile = getAttachmentFile(messagePartId)
|
||||
FileHelper.renameOrMoveByCopying(temporaryFile, destinationFile)
|
||||
}
|
||||
|
||||
fun copyFile(sourceMessagePartId: Long, destinationMessagePartId: Long) {
|
||||
val sourceFile = getAttachmentFile(sourceMessagePartId)
|
||||
val destinationFile = getAttachmentFile(destinationMessagePartId)
|
||||
sourceFile.copyTo(destinationFile)
|
||||
}
|
||||
|
||||
fun getAttachmentFile(messagePartId: Long): File {
|
||||
val attachmentDirectory = storageFilesProvider.getAttachmentDirectory()
|
||||
return File(attachmentDirectory, messagePartId.toString())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
|
||||
internal class CheckFolderOperations(private val lockableDatabase: LockableDatabase) {
|
||||
fun areAllIncludedInUnifiedInbox(folderIds: Collection<Long>): Boolean {
|
||||
return lockableDatabase.execute(false) { database ->
|
||||
var allIncludedInUnifiedInbox = true
|
||||
|
||||
performChunkedOperation(
|
||||
arguments = folderIds,
|
||||
argumentTransformation = Long::toString,
|
||||
) { selectionSet, selectionArguments ->
|
||||
if (allIncludedInUnifiedInbox) {
|
||||
database.rawQuery(
|
||||
"SELECT COUNT(id) FROM folders WHERE integrate = 1 AND id $selectionSet",
|
||||
selectionArguments,
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val count = cursor.getInt(0)
|
||||
if (count != selectionArguments.size) {
|
||||
allIncludedInUnifiedInbox = false
|
||||
}
|
||||
} else {
|
||||
allIncludedInUnifiedInbox = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allIncludedInUnifiedInbox
|
||||
}
|
||||
}
|
||||
|
||||
fun hasPushEnabledFolder(): Boolean {
|
||||
return lockableDatabase.execute(false) { database ->
|
||||
database.rawQuery("SELECT id FROM folders WHERE push_enabled = 1 LIMIT 1", null, null).use { cursor ->
|
||||
cursor.count > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,33 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import android.content.ContentValues
|
||||
import app.k9mail.legacy.mailstore.CreateFolderInfo
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import com.fsck.k9.mailstore.toDatabaseFolderType
|
||||
|
||||
internal class CreateFolderOperations(private val lockableDatabase: LockableDatabase) {
|
||||
fun createFolders(folders: List<CreateFolderInfo>): Set<Long> = buildSet {
|
||||
lockableDatabase.execute(true) { db ->
|
||||
for (folder in folders) {
|
||||
val folderSettings = folder.settings
|
||||
val values = ContentValues().apply {
|
||||
put("name", folder.name.replace("\\[(Gmail|Google Mail)]/".toRegex(), ""))
|
||||
put("visible_limit", folderSettings.visibleLimit)
|
||||
put("integrate", folderSettings.integrate)
|
||||
put("top_group", folderSettings.inTopGroup)
|
||||
put("sync_enabled", folderSettings.isSyncEnabled)
|
||||
put("push_enabled", folderSettings.isPushEnabled)
|
||||
put("visible", folderSettings.isVisible)
|
||||
put("notifications_enabled", folderSettings.isNotificationsEnabled)
|
||||
put("server_id", folder.serverId)
|
||||
put("local_only", false)
|
||||
put("type", folder.type.toDatabaseFolderType())
|
||||
}
|
||||
|
||||
db.insert("folders", null, values)
|
||||
.takeIf { it != -1L }
|
||||
?.let(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
internal const val DATA_LOCATION_ON_DISK = 2
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import com.fsck.k9.mailstore.StorageFilesProvider
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
internal class DatabaseOperations(
|
||||
private val lockableDatabase: LockableDatabase,
|
||||
private val storageFilesProvider: StorageFilesProvider,
|
||||
) {
|
||||
fun getSize(): Long {
|
||||
val attachmentDirectory = storageFilesProvider.getAttachmentDirectory()
|
||||
|
||||
return lockableDatabase.execute(false) {
|
||||
val attachmentFiles = attachmentDirectory.listFiles() ?: emptyArray()
|
||||
val attachmentsSize = attachmentFiles.asSequence()
|
||||
.filter { file -> file.exists() }
|
||||
.fold(initial = 0L) { accumulatedSize, file ->
|
||||
accumulatedSize + file.length()
|
||||
}
|
||||
|
||||
val databaseFile = storageFilesProvider.getDatabaseFile()
|
||||
val databaseSize = databaseFile.length()
|
||||
|
||||
databaseSize + attachmentsSize
|
||||
}
|
||||
}
|
||||
|
||||
fun compact() {
|
||||
Log.i("Before compaction size = %d", getSize())
|
||||
|
||||
lockableDatabase.execute(false) { database ->
|
||||
database.execSQL("VACUUM")
|
||||
}
|
||||
|
||||
Log.i("After compaction size = %d", getSize())
|
||||
}
|
||||
}
|
||||
|
|
@ -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,36 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import android.content.ContentValues
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
|
||||
internal class FolderNameSanitizer(private val lockableDatabase: LockableDatabase) {
|
||||
fun removeGmailPrefixFromFolders() {
|
||||
lockableDatabase.execute(false) { db ->
|
||||
val cursor = db.query(
|
||||
"folders",
|
||||
arrayOf("id", "name"),
|
||||
"name LIKE ? OR name LIKE ?",
|
||||
arrayOf("%[Gmail]/%", "%[Google Mail]/%"),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow("id"))
|
||||
val name = cursor.getString(cursor.getColumnIndexOrThrow("name"))
|
||||
val updatedName = name
|
||||
.replace("[Gmail]/", "")
|
||||
.replace("[Google Mail]/", "")
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put("name", updatedName)
|
||||
}
|
||||
|
||||
db.update("folders", values, "id = ?", arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import app.k9mail.legacy.mailstore.CreateFolderInfo
|
||||
import app.k9mail.legacy.mailstore.FolderMapper
|
||||
import app.k9mail.legacy.mailstore.MessageMapper
|
||||
import app.k9mail.legacy.mailstore.MessageStore
|
||||
import app.k9mail.legacy.mailstore.MoreMessages
|
||||
import app.k9mail.legacy.mailstore.SaveMessageData
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.Header
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import com.fsck.k9.mailstore.StorageFilesProvider
|
||||
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
|
||||
import java.util.Date
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
import net.thunderbird.feature.mail.folder.api.FolderDetails
|
||||
import net.thunderbird.feature.search.legacy.SearchConditionTreeNode
|
||||
|
||||
class K9MessageStore(
|
||||
database: LockableDatabase,
|
||||
storageFilesProvider: StorageFilesProvider,
|
||||
basicPartInfoExtractor: BasicPartInfoExtractor,
|
||||
generalSettingsManager: GeneralSettingsManager,
|
||||
) : MessageStore {
|
||||
private val attachmentFileManager = AttachmentFileManager(storageFilesProvider, generalSettingsManager)
|
||||
private val threadMessageOperations = ThreadMessageOperations()
|
||||
private val saveMessageOperations = SaveMessageOperations(
|
||||
database,
|
||||
attachmentFileManager,
|
||||
basicPartInfoExtractor,
|
||||
threadMessageOperations,
|
||||
)
|
||||
private val copyMessageOperations = CopyMessageOperations(database, attachmentFileManager, threadMessageOperations)
|
||||
private val moveMessageOperations = MoveMessageOperations(database, threadMessageOperations)
|
||||
private val flagMessageOperations = FlagMessageOperations(database)
|
||||
private val updateMessageOperations = UpdateMessageOperations(database)
|
||||
private val retrieveMessageOperations = RetrieveMessageOperations(database)
|
||||
private val retrieveMessageListOperations = RetrieveMessageListOperations(database)
|
||||
private val deleteMessageOperations = DeleteMessageOperations(database, attachmentFileManager)
|
||||
private val createFolderOperations = CreateFolderOperations(database)
|
||||
private val retrieveFolderOperations = RetrieveFolderOperations(database)
|
||||
private val checkFolderOperations = CheckFolderOperations(database)
|
||||
private val updateFolderOperations = UpdateFolderOperations(database)
|
||||
private val deleteFolderOperations = DeleteFolderOperations(database, attachmentFileManager)
|
||||
private val keyValueStoreOperations = KeyValueStoreOperations(database)
|
||||
private val databaseOperations = DatabaseOperations(database, storageFilesProvider)
|
||||
|
||||
override fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
|
||||
saveMessageOperations.saveRemoteMessage(folderId, messageServerId, messageData)
|
||||
}
|
||||
|
||||
override fun saveLocalMessage(folderId: Long, messageData: SaveMessageData, existingMessageId: Long?): Long {
|
||||
return saveMessageOperations.saveLocalMessage(folderId, messageData, existingMessageId)
|
||||
}
|
||||
|
||||
override fun copyMessage(messageId: Long, destinationFolderId: Long): Long {
|
||||
return copyMessageOperations.copyMessage(messageId, destinationFolderId)
|
||||
}
|
||||
|
||||
override fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
|
||||
return moveMessageOperations.moveMessage(messageId, destinationFolderId)
|
||||
}
|
||||
|
||||
override fun setFlag(messageIds: Collection<Long>, flag: Flag, set: Boolean) {
|
||||
flagMessageOperations.setFlag(messageIds, flag, set)
|
||||
}
|
||||
|
||||
override fun setMessageFlag(folderId: Long, messageServerId: String, flag: Flag, set: Boolean) {
|
||||
flagMessageOperations.setMessageFlag(folderId, messageServerId, flag, set)
|
||||
}
|
||||
|
||||
override fun setNewMessageState(folderId: Long, messageServerId: String, newMessage: Boolean) {
|
||||
updateMessageOperations.setNewMessageState(folderId, messageServerId, newMessage)
|
||||
}
|
||||
|
||||
override fun clearNewMessageState() {
|
||||
updateMessageOperations.clearNewMessageState()
|
||||
}
|
||||
|
||||
override fun getMessageServerId(messageId: Long): String? {
|
||||
return retrieveMessageOperations.getMessageServerId(messageId)
|
||||
}
|
||||
|
||||
override fun getMessageServerIds(messageIds: Collection<Long>): Map<Long, String> {
|
||||
return retrieveMessageOperations.getMessageServerIds(messageIds)
|
||||
}
|
||||
|
||||
override fun getMessageServerIds(folderId: Long): Set<String> {
|
||||
return retrieveMessageOperations.getMessageServerIds(folderId)
|
||||
}
|
||||
|
||||
override fun isMessagePresent(folderId: Long, messageServerId: String): Boolean {
|
||||
return retrieveMessageOperations.isMessagePresent(folderId, messageServerId)
|
||||
}
|
||||
|
||||
override fun getMessageFlags(folderId: Long, messageServerId: String): Set<Flag> {
|
||||
return retrieveMessageOperations.getMessageFlags(folderId, messageServerId)
|
||||
}
|
||||
|
||||
override fun getAllMessagesAndEffectiveDates(folderId: Long): Map<String, Long?> {
|
||||
return retrieveMessageOperations.getAllMessagesAndEffectiveDates(folderId)
|
||||
}
|
||||
|
||||
override fun <T> getMessages(
|
||||
selection: String,
|
||||
selectionArgs: Array<String>,
|
||||
sortOrder: String,
|
||||
messageMapper: MessageMapper<out T?>,
|
||||
): List<T> {
|
||||
return retrieveMessageListOperations.getMessages(selection, selectionArgs, sortOrder, messageMapper)
|
||||
}
|
||||
|
||||
override fun <T> getThreadedMessages(
|
||||
selection: String,
|
||||
selectionArgs: Array<String>,
|
||||
sortOrder: String,
|
||||
messageMapper: MessageMapper<out T?>,
|
||||
): List<T> {
|
||||
return retrieveMessageListOperations.getThreadedMessages(selection, selectionArgs, sortOrder, messageMapper)
|
||||
}
|
||||
|
||||
override fun <T> getThread(threadId: Long, sortOrder: String, messageMapper: MessageMapper<out T?>): List<T> {
|
||||
return retrieveMessageListOperations.getThread(threadId, sortOrder, messageMapper)
|
||||
}
|
||||
|
||||
override fun getOldestMessageDate(folderId: Long): Date? {
|
||||
return retrieveMessageOperations.getOldestMessageDate(folderId)
|
||||
}
|
||||
|
||||
override fun getHeaders(folderId: Long, messageServerId: String): List<Header> {
|
||||
return retrieveMessageOperations.getHeaders(folderId, messageServerId)
|
||||
}
|
||||
|
||||
override fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set<String>): List<Header> {
|
||||
return retrieveMessageOperations.getHeaders(folderId, messageServerId, headerNames)
|
||||
}
|
||||
|
||||
override fun destroyMessages(folderId: Long, messageServerIds: Collection<String>) {
|
||||
deleteMessageOperations.destroyMessages(folderId, messageServerIds)
|
||||
}
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
override fun createFolders(folders: List<CreateFolderInfo>): Set<Long> =
|
||||
createFolderOperations.createFolders(folders)
|
||||
|
||||
override fun <T> getFolder(folderId: Long, mapper: FolderMapper<T>): T? {
|
||||
return retrieveFolderOperations.getFolder(folderId, mapper)
|
||||
}
|
||||
|
||||
override fun <T> getFolder(folderServerId: String, mapper: FolderMapper<T>): T? {
|
||||
return retrieveFolderOperations.getFolder(folderServerId, mapper)
|
||||
}
|
||||
|
||||
override fun <T> getFolders(excludeLocalOnly: Boolean, mapper: FolderMapper<T>): List<T> {
|
||||
return retrieveFolderOperations.getFolders(excludeLocalOnly, mapper)
|
||||
}
|
||||
|
||||
override fun <T> getDisplayFolders(
|
||||
includeHiddenFolders: Boolean,
|
||||
outboxFolderId: Long?,
|
||||
mapper: FolderMapper<T>,
|
||||
): List<T> {
|
||||
return retrieveFolderOperations.getDisplayFolders(includeHiddenFolders, outboxFolderId, mapper)
|
||||
}
|
||||
|
||||
override fun areAllIncludedInUnifiedInbox(folderIds: Collection<Long>): Boolean {
|
||||
return checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds)
|
||||
}
|
||||
|
||||
override fun getFolderId(folderServerId: String): Long? {
|
||||
return retrieveFolderOperations.getFolderId(folderServerId)
|
||||
}
|
||||
|
||||
override fun getFolderServerId(folderId: Long): String? {
|
||||
return retrieveFolderOperations.getFolderServerId(folderId)
|
||||
}
|
||||
|
||||
override fun getMessageCount(folderId: Long): Int {
|
||||
return retrieveFolderOperations.getMessageCount(folderId)
|
||||
}
|
||||
|
||||
override fun getUnreadMessageCount(folderId: Long): Int {
|
||||
return retrieveFolderOperations.getUnreadMessageCount(folderId)
|
||||
}
|
||||
|
||||
override fun getUnreadMessageCount(conditions: SearchConditionTreeNode?): Int {
|
||||
return retrieveFolderOperations.getUnreadMessageCount(conditions)
|
||||
}
|
||||
|
||||
override fun getStarredMessageCount(conditions: SearchConditionTreeNode?): Int {
|
||||
return retrieveFolderOperations.getStarredMessageCount(conditions)
|
||||
}
|
||||
|
||||
override fun getSize(): Long {
|
||||
return databaseOperations.getSize()
|
||||
}
|
||||
|
||||
override fun changeFolder(folderServerId: String, name: String, type: FolderType) {
|
||||
updateFolderOperations.changeFolder(folderServerId, name, type)
|
||||
}
|
||||
|
||||
override fun updateFolderSettings(folderDetails: FolderDetails) {
|
||||
updateFolderOperations.updateFolderSettings(folderDetails)
|
||||
}
|
||||
|
||||
override fun setIncludeInUnifiedInbox(folderId: Long, includeInUnifiedInbox: Boolean) {
|
||||
updateFolderOperations.setIncludeInUnifiedInbox(folderId, includeInUnifiedInbox)
|
||||
}
|
||||
|
||||
override fun setVisible(folderId: Long, visible: Boolean) {
|
||||
updateFolderOperations.setVisible(folderId, visible)
|
||||
}
|
||||
|
||||
override fun setSyncEnabled(folderId: Long, enable: Boolean) {
|
||||
updateFolderOperations.setSyncEnabled(folderId, enable)
|
||||
}
|
||||
|
||||
override fun setPushEnabled(folderId: Long, enable: Boolean) {
|
||||
updateFolderOperations.setPushEnabled(folderId, enable)
|
||||
}
|
||||
|
||||
override fun setNotificationsEnabled(folderId: Long, enable: Boolean) {
|
||||
updateFolderOperations.setNotificationsEnabled(folderId, enable)
|
||||
}
|
||||
|
||||
override fun hasMoreMessages(folderId: Long): MoreMessages {
|
||||
return retrieveFolderOperations.hasMoreMessages(folderId)
|
||||
}
|
||||
|
||||
override fun setMoreMessages(folderId: Long, moreMessages: MoreMessages) {
|
||||
updateFolderOperations.setMoreMessages(folderId, moreMessages)
|
||||
}
|
||||
|
||||
override fun setLastChecked(folderId: Long, timestamp: Long) {
|
||||
updateFolderOperations.setLastChecked(folderId, timestamp)
|
||||
}
|
||||
|
||||
override fun setStatus(folderId: Long, status: String?) {
|
||||
updateFolderOperations.setStatus(folderId, status)
|
||||
}
|
||||
|
||||
override fun setVisibleLimit(folderId: Long, visibleLimit: Int) {
|
||||
updateFolderOperations.setVisibleLimit(folderId, visibleLimit)
|
||||
}
|
||||
|
||||
override fun setPushDisabled() {
|
||||
updateFolderOperations.setPushDisabled()
|
||||
}
|
||||
|
||||
override fun hasPushEnabledFolder(): Boolean {
|
||||
return checkFolderOperations.hasPushEnabledFolder()
|
||||
}
|
||||
|
||||
override fun deleteFolders(folderServerIds: List<String>) {
|
||||
deleteFolderOperations.deleteFolders(folderServerIds)
|
||||
}
|
||||
|
||||
override fun getExtraString(name: String): String? {
|
||||
return keyValueStoreOperations.getExtraString(name)
|
||||
}
|
||||
|
||||
override fun setExtraString(name: String, value: String) {
|
||||
keyValueStoreOperations.setExtraString(name, value)
|
||||
}
|
||||
|
||||
override fun getExtraNumber(name: String): Long? {
|
||||
return keyValueStoreOperations.getExtraNumber(name)
|
||||
}
|
||||
|
||||
override fun setExtraNumber(name: String, value: Long) {
|
||||
keyValueStoreOperations.setExtraNumber(name, value)
|
||||
}
|
||||
|
||||
override fun getFolderExtraString(folderId: Long, name: String): String? {
|
||||
return keyValueStoreOperations.getFolderExtraString(folderId, name)
|
||||
}
|
||||
|
||||
override fun setFolderExtraString(folderId: Long, name: String, value: String?) {
|
||||
return keyValueStoreOperations.setFolderExtraString(folderId, name, value)
|
||||
}
|
||||
|
||||
override fun getFolderExtraNumber(folderId: Long, name: String): Long? {
|
||||
return keyValueStoreOperations.getFolderExtraNumber(folderId, name)
|
||||
}
|
||||
|
||||
override fun setFolderExtraNumber(folderId: Long, name: String, value: Long) {
|
||||
return keyValueStoreOperations.setFolderExtraNumber(folderId, name, value)
|
||||
}
|
||||
|
||||
override fun compact() {
|
||||
return databaseOperations.compact()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import app.k9mail.legacy.mailstore.ListenableMessageStore
|
||||
import app.k9mail.legacy.mailstore.MessageStoreFactory
|
||||
import com.fsck.k9.mailstore.LocalStoreProvider
|
||||
import com.fsck.k9.mailstore.NotifierMessageStore
|
||||
import com.fsck.k9.mailstore.StorageFilesProviderFactory
|
||||
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
|
||||
class K9MessageStoreFactory(
|
||||
private val localStoreProvider: LocalStoreProvider,
|
||||
private val storageFilesProviderFactory: StorageFilesProviderFactory,
|
||||
private val basicPartInfoExtractor: BasicPartInfoExtractor,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
) : MessageStoreFactory {
|
||||
|
||||
override fun create(account: LegacyAccount): ListenableMessageStore {
|
||||
val localStore = localStoreProvider.getInstance(account)
|
||||
if (account.incomingServerSettings.host.isGoogle() ||
|
||||
account.outgoingServerSettings.host.isGoogle()
|
||||
) {
|
||||
val folderNameSanitizer = FolderNameSanitizer(lockableDatabase = localStore.database)
|
||||
folderNameSanitizer.removeGmailPrefixFromFolders()
|
||||
}
|
||||
val storageFilesProvider = storageFilesProviderFactory.createStorageFilesProvider(account.uuid)
|
||||
val messageStore = K9MessageStore(
|
||||
localStore.database,
|
||||
storageFilesProvider,
|
||||
basicPartInfoExtractor,
|
||||
generalSettingsManager,
|
||||
)
|
||||
val notifierMessageStore = NotifierMessageStore(messageStore, localStore)
|
||||
return ListenableMessageStore(notifierMessageStore)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.isGoogle(): Boolean {
|
||||
val domains = listOf(".gmail.com", ".googlemail.com")
|
||||
return domains.any { this.endsWith(it, ignoreCase = true) }
|
||||
}
|
||||
|
|
@ -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,133 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import app.k9mail.core.android.common.database.getIntOrNull
|
||||
import app.k9mail.core.android.common.database.getLongOrNull
|
||||
import app.k9mail.core.android.common.database.getStringOrNull
|
||||
import com.fsck.k9.K9
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import java.util.UUID
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
internal class MoveMessageOperations(
|
||||
private val database: LockableDatabase,
|
||||
private val threadMessageOperations: ThreadMessageOperations,
|
||||
) {
|
||||
fun moveMessage(messageId: Long, destinationFolderId: Long): Long {
|
||||
Log.d("Moving message [ID: $messageId] to folder [ID: $destinationFolderId]")
|
||||
|
||||
return database.execute(true) { database ->
|
||||
val threadInfo =
|
||||
threadMessageOperations.createOrUpdateParentThreadEntries(database, messageId, destinationFolderId)
|
||||
val destinationMessageId = createMessageEntry(database, messageId, destinationFolderId, threadInfo)
|
||||
threadMessageOperations.createThreadEntryIfNecessary(database, destinationMessageId, threadInfo)
|
||||
|
||||
convertOriginalMessageEntryToPlaceholderEntry(database, messageId)
|
||||
moveFulltextEntry(database, messageId, destinationMessageId)
|
||||
|
||||
destinationMessageId
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveFulltextEntry(database: SQLiteDatabase, messageId: Long, destinationMessageId: Long) {
|
||||
val values = ContentValues().apply {
|
||||
put("docid", destinationMessageId)
|
||||
}
|
||||
|
||||
database.update("messages_fulltext", values, "docid = ?", arrayOf(messageId.toString()))
|
||||
}
|
||||
|
||||
private fun createMessageEntry(
|
||||
database: SQLiteDatabase,
|
||||
messageId: Long,
|
||||
destinationFolderId: Long,
|
||||
threadInfo: ThreadInfo?,
|
||||
): Long {
|
||||
val destinationUid = K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString()
|
||||
|
||||
val contentValues = database.query(
|
||||
"messages",
|
||||
arrayOf(
|
||||
"subject", "date", "flags", "sender_list", "to_list", "cc_list", "bcc_list", "reply_to_list",
|
||||
"attachment_count", "internal_date", "message_id", "preview_type", "preview", "mime_type",
|
||||
"normalized_subject_hash", "read", "flagged", "answered", "forwarded", "message_part_id",
|
||||
"encryption_type",
|
||||
),
|
||||
"id = ?",
|
||||
arrayOf(messageId.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) {
|
||||
error("Couldn't find local message [ID: $messageId]")
|
||||
}
|
||||
|
||||
ContentValues().apply {
|
||||
put("uid", destinationUid)
|
||||
put("folder_id", destinationFolderId)
|
||||
put("deleted", 0)
|
||||
put("empty", 0)
|
||||
put("subject", cursor.getStringOrNull("subject"))
|
||||
put("date", cursor.getLongOrNull("date"))
|
||||
put("flags", cursor.getStringOrNull("flags"))
|
||||
put("sender_list", cursor.getStringOrNull("sender_list"))
|
||||
put("to_list", cursor.getStringOrNull("to_list"))
|
||||
put("cc_list", cursor.getStringOrNull("cc_list"))
|
||||
put("bcc_list", cursor.getStringOrNull("bcc_list"))
|
||||
put("reply_to_list", cursor.getStringOrNull("reply_to_list"))
|
||||
put("attachment_count", cursor.getIntOrNull("attachment_count"))
|
||||
put("internal_date", cursor.getLongOrNull("internal_date"))
|
||||
put("message_id", cursor.getStringOrNull("message_id"))
|
||||
put("preview_type", cursor.getStringOrNull("preview_type"))
|
||||
put("preview", cursor.getStringOrNull("preview"))
|
||||
put("mime_type", cursor.getStringOrNull("mime_type"))
|
||||
put("normalized_subject_hash", cursor.getLongOrNull("normalized_subject_hash"))
|
||||
put("read", cursor.getIntOrNull("read"))
|
||||
put("flagged", cursor.getIntOrNull("flagged"))
|
||||
put("answered", cursor.getIntOrNull("answered"))
|
||||
put("forwarded", cursor.getIntOrNull("forwarded"))
|
||||
put("message_part_id", cursor.getLongOrNull("message_part_id"))
|
||||
put("encryption_type", cursor.getStringOrNull("encryption_type"))
|
||||
}
|
||||
}
|
||||
|
||||
val placeHolderMessageId = threadInfo?.messageId
|
||||
return if (placeHolderMessageId != null) {
|
||||
database.update("messages", contentValues, "id = ?", arrayOf(placeHolderMessageId.toString()))
|
||||
placeHolderMessageId
|
||||
} else {
|
||||
database.insert("messages", null, contentValues)
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertOriginalMessageEntryToPlaceholderEntry(database: SQLiteDatabase, messageId: Long) {
|
||||
val contentValues = ContentValues().apply {
|
||||
put("deleted", 1)
|
||||
put("empty", 0)
|
||||
put("read", 1)
|
||||
putNull("subject")
|
||||
putNull("date")
|
||||
putNull("flags")
|
||||
putNull("sender_list")
|
||||
putNull("to_list")
|
||||
putNull("cc_list")
|
||||
putNull("bcc_list")
|
||||
putNull("reply_to_list")
|
||||
putNull("attachment_count")
|
||||
putNull("internal_date")
|
||||
put("preview_type", "none")
|
||||
putNull("preview")
|
||||
putNull("mime_type")
|
||||
putNull("normalized_subject_hash")
|
||||
putNull("flagged")
|
||||
putNull("answered")
|
||||
putNull("forwarded")
|
||||
putNull("message_part_id")
|
||||
putNull("encryption_type")
|
||||
}
|
||||
|
||||
database.update("messages", contentValues, "id = ?", arrayOf(messageId.toString()))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.core.database.getLongOrNull
|
||||
import app.k9mail.core.android.common.database.map
|
||||
import app.k9mail.legacy.mailstore.FolderDetailsAccessor
|
||||
import app.k9mail.legacy.mailstore.FolderMapper
|
||||
import app.k9mail.legacy.mailstore.MoreMessages
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mailstore.FolderNotFoundException
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import com.fsck.k9.mailstore.toFolderType
|
||||
import net.thunderbird.feature.search.legacy.SearchConditionTreeNode
|
||||
import net.thunderbird.feature.search.legacy.sql.SqlWhereClause
|
||||
|
||||
internal class RetrieveFolderOperations(private val lockableDatabase: LockableDatabase) {
|
||||
fun <T> getFolder(folderId: Long, mapper: FolderMapper<T>): T? {
|
||||
return getFolder(
|
||||
selection = "id = ?",
|
||||
selectionArguments = arrayOf(folderId.toString()),
|
||||
mapper = mapper,
|
||||
)
|
||||
}
|
||||
|
||||
fun <T> getFolder(folderServerId: String, mapper: FolderMapper<T>): T? {
|
||||
return getFolder(
|
||||
selection = "server_id = ?",
|
||||
selectionArguments = arrayOf(folderServerId),
|
||||
mapper = mapper,
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> getFolder(selection: String, selectionArguments: Array<String>, mapper: FolderMapper<T>): T? {
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
db.query(
|
||||
"folders",
|
||||
FOLDER_COLUMNS,
|
||||
selection,
|
||||
selectionArguments,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val cursorFolderAccessor = CursorFolderAccessor(cursor)
|
||||
mapper.map(cursorFolderAccessor)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> getFolders(excludeLocalOnly: Boolean = false, mapper: FolderMapper<T>): List<T> {
|
||||
val selection = if (excludeLocalOnly) "local_only = 0" else null
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
db.query("folders", FOLDER_COLUMNS, selection, null, null, null, "id").use { cursor ->
|
||||
val cursorFolderAccessor = CursorFolderAccessor(cursor)
|
||||
cursor.map {
|
||||
mapper.map(cursorFolderAccessor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> getDisplayFolders(includeHiddenFolders: Boolean, outboxFolderId: Long?, mapper: FolderMapper<T>): List<T> {
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
val displayModeSelection = getDisplayModeSelection(includeHiddenFolders)
|
||||
val outboxFolderIdOrZero = outboxFolderId ?: 0
|
||||
|
||||
val query =
|
||||
"""
|
||||
SELECT ${FOLDER_COLUMNS.joinToString()}, (
|
||||
SELECT COUNT(messages.id)
|
||||
FROM messages
|
||||
WHERE messages.folder_id = folders.id
|
||||
AND messages.empty = 0 AND messages.deleted = 0
|
||||
AND (messages.read = 0 OR folders.id = ?)
|
||||
), (
|
||||
SELECT COUNT(messages.id)
|
||||
FROM messages
|
||||
WHERE messages.folder_id = folders.id
|
||||
AND messages.empty = 0 AND messages.deleted = 0
|
||||
AND messages.flagged = 1
|
||||
)
|
||||
FROM folders
|
||||
$displayModeSelection
|
||||
"""
|
||||
|
||||
db.rawQuery(query, arrayOf(outboxFolderIdOrZero.toString())).use { cursor ->
|
||||
val cursorFolderAccessor = CursorFolderAccessor(cursor)
|
||||
cursor.map {
|
||||
mapper.map(cursorFolderAccessor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayModeSelection(includeHiddenFolders: Boolean): String {
|
||||
return if (includeHiddenFolders) {
|
||||
""
|
||||
} else {
|
||||
"WHERE visible = 1"
|
||||
}
|
||||
}
|
||||
|
||||
fun getFolderId(folderServerId: String): Long? {
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
db.query(
|
||||
"folders",
|
||||
arrayOf("id"),
|
||||
"server_id = ?",
|
||||
arrayOf(folderServerId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getLong(0) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getFolderServerId(folderId: Long): String? {
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
db.query(
|
||||
"folders",
|
||||
arrayOf("server_id"),
|
||||
"id = ?",
|
||||
arrayOf(folderId.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessageCount(folderId: Long): Int {
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(id) FROM messages WHERE empty = 0 AND deleted = 0 AND folder_id = ?",
|
||||
arrayOf(folderId.toString()),
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getInt(0) else 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getUnreadMessageCount(folderId: Long): Int {
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(id) FROM messages WHERE empty = 0 AND deleted = 0 AND read = 0 AND folder_id = ?",
|
||||
arrayOf(folderId.toString()),
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getInt(0) else 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getUnreadMessageCount(conditions: SearchConditionTreeNode?): Int {
|
||||
return getMessageCount(condition = "messages.read = 0", conditions)
|
||||
}
|
||||
|
||||
fun getStarredMessageCount(conditions: SearchConditionTreeNode?): Int {
|
||||
return getMessageCount(condition = "messages.flagged = 1", conditions)
|
||||
}
|
||||
|
||||
private fun getMessageCount(condition: String, extraConditions: SearchConditionTreeNode?): Int {
|
||||
val whereClause = extraConditions?.let {
|
||||
SqlWhereClause.Builder()
|
||||
.withConditions(extraConditions)
|
||||
.build()
|
||||
}
|
||||
|
||||
val where = if (whereClause != null) "AND (${whereClause.selection})" else ""
|
||||
val selectionArgs = whereClause?.selectionArgs?.toTypedArray() ?: emptyArray()
|
||||
|
||||
val query =
|
||||
"""
|
||||
SELECT COUNT(messages.id)
|
||||
FROM messages
|
||||
JOIN folders ON (folders.id = messages.folder_id)
|
||||
WHERE (messages.empty = 0 AND messages.deleted = 0 AND $condition) $where
|
||||
"""
|
||||
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
db.rawQuery(query, selectionArgs).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getInt(0) else 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasMoreMessages(folderId: Long): MoreMessages {
|
||||
return getFolder(folderId) { it.moreMessages } ?: throw FolderNotFoundException(folderId)
|
||||
}
|
||||
}
|
||||
|
||||
private class CursorFolderAccessor(val cursor: Cursor) : FolderDetailsAccessor {
|
||||
override val id: Long
|
||||
get() = cursor.getLong(0)
|
||||
|
||||
override val name: String
|
||||
get() = cursor.getString(1)
|
||||
|
||||
override val type: FolderType
|
||||
get() = cursor.getString(2).toFolderType()
|
||||
|
||||
override val serverId: String?
|
||||
get() = cursor.getString(3)
|
||||
|
||||
override val isLocalOnly: Boolean
|
||||
get() = cursor.getInt(4) == 1
|
||||
|
||||
override val isInTopGroup: Boolean
|
||||
get() = cursor.getInt(5) == 1
|
||||
|
||||
override val isIntegrate: Boolean
|
||||
get() = cursor.getInt(6) == 1
|
||||
|
||||
override val isSyncEnabled: Boolean
|
||||
get() = cursor.getInt(7) == 1
|
||||
|
||||
override val isVisible: Boolean
|
||||
get() = cursor.getInt(8) == 1
|
||||
|
||||
override val isNotificationsEnabled: Boolean
|
||||
get() = cursor.getInt(9) == 1
|
||||
|
||||
override val isPushEnabled: Boolean
|
||||
get() = cursor.getInt(10) == 1
|
||||
|
||||
override val visibleLimit: Int
|
||||
get() = cursor.getInt(11)
|
||||
|
||||
override val moreMessages: MoreMessages
|
||||
get() = MoreMessages.fromDatabaseName(cursor.getString(12))
|
||||
|
||||
override val lastChecked: Long?
|
||||
get() = cursor.getLongOrNull(13)
|
||||
|
||||
override val unreadMessageCount: Int
|
||||
get() = cursor.getInt(14)
|
||||
|
||||
override val starredMessageCount: Int
|
||||
get() = cursor.getInt(15)
|
||||
|
||||
override fun serverIdOrThrow(): String {
|
||||
return serverId ?: error("No server ID found for folder '$name' ($id)")
|
||||
}
|
||||
}
|
||||
|
||||
private val FOLDER_COLUMNS = arrayOf(
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"server_id",
|
||||
"local_only",
|
||||
"top_group",
|
||||
"integrate",
|
||||
"sync_enabled",
|
||||
"visible",
|
||||
"notifications_enabled",
|
||||
"push_enabled",
|
||||
"visible_limit",
|
||||
"more_messages",
|
||||
"last_updated",
|
||||
)
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import android.database.Cursor
|
||||
import app.k9mail.legacy.mailstore.MessageDetailsAccessor
|
||||
import app.k9mail.legacy.mailstore.MessageMapper
|
||||
import app.k9mail.legacy.message.extractors.PreviewResult
|
||||
import com.fsck.k9.mail.Address
|
||||
import com.fsck.k9.mailstore.DatabasePreviewType
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import net.thunderbird.feature.search.legacy.sql.SqlWhereClause
|
||||
|
||||
internal class RetrieveMessageListOperations(private val lockableDatabase: LockableDatabase) {
|
||||
|
||||
fun <T> getMessages(
|
||||
selection: String,
|
||||
selectionArgs: Array<String>,
|
||||
sortOrder: String,
|
||||
mapper: MessageMapper<out T?>,
|
||||
): List<T> {
|
||||
return lockableDatabase.execute(false) { database ->
|
||||
database.rawQuery(
|
||||
"""
|
||||
SELECT
|
||||
messages.id AS id,
|
||||
uid,
|
||||
folder_id,
|
||||
sender_list,
|
||||
to_list,
|
||||
cc_list,
|
||||
date,
|
||||
internal_date,
|
||||
subject,
|
||||
preview_type,
|
||||
preview,
|
||||
read,
|
||||
flagged,
|
||||
answered,
|
||||
forwarded,
|
||||
attachment_count,
|
||||
root
|
||||
FROM messages
|
||||
JOIN threads ON (threads.message_id = messages.id)
|
||||
LEFT JOIN FOLDERS ON (folders.id = messages.folder_id)
|
||||
WHERE
|
||||
($selection)
|
||||
AND empty = 0 AND deleted = 0
|
||||
ORDER BY $sortOrder
|
||||
""",
|
||||
selectionArgs,
|
||||
).use { cursor ->
|
||||
val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = false)
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
val value = mapper.map(cursorMessageAccessor)
|
||||
if (value != null) {
|
||||
add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> getThreadedMessages(
|
||||
selection: String,
|
||||
selectionArgs: Array<String>,
|
||||
sortOrder: String,
|
||||
mapper: MessageMapper<out T?>,
|
||||
): List<T> {
|
||||
val orderBy = SqlWhereClause.addPrefixToSelection(
|
||||
AGGREGATED_MESSAGES_COLUMNS,
|
||||
"aggregated.",
|
||||
sortOrder,
|
||||
)
|
||||
|
||||
return lockableDatabase.execute(false) { database ->
|
||||
database.rawQuery(
|
||||
"""
|
||||
SELECT
|
||||
messages.id AS id,
|
||||
uid,
|
||||
folder_id,
|
||||
sender_list,
|
||||
to_list,
|
||||
cc_list,
|
||||
aggregated.date AS date,
|
||||
aggregated.internal_date AS internal_date,
|
||||
subject,
|
||||
preview_type,
|
||||
preview,
|
||||
aggregated.read AS read,
|
||||
aggregated.flagged AS flagged,
|
||||
aggregated.answered AS answered,
|
||||
aggregated.forwarded AS forwarded,
|
||||
aggregated.attachment_count AS attachment_count,
|
||||
root,
|
||||
aggregated.thread_count AS thread_count
|
||||
FROM (
|
||||
SELECT
|
||||
threads.root AS thread_root,
|
||||
MAX(date) AS date,
|
||||
MAX(internal_date) AS internal_date,
|
||||
MIN(read) AS read,
|
||||
MAX(flagged) AS flagged,
|
||||
MIN(answered) AS answered,
|
||||
MIN(forwarded) AS forwarded,
|
||||
SUM(attachment_count) AS attachment_count,
|
||||
COUNT(threads.root) AS thread_count
|
||||
FROM messages
|
||||
JOIN threads ON (threads.message_id = messages.id)
|
||||
JOIN folders ON (folders.id = messages.folder_id)
|
||||
WHERE
|
||||
threads.root IN (
|
||||
SELECT threads.root
|
||||
FROM messages
|
||||
JOIN threads ON (threads.message_id = messages.id)
|
||||
WHERE messages.empty = 0 AND messages.deleted = 0
|
||||
)
|
||||
AND ($selection)
|
||||
AND messages.empty = 0 AND messages.deleted = 0
|
||||
GROUP BY threads.root
|
||||
) aggregated
|
||||
JOIN threads ON (threads.root = aggregated.thread_root)
|
||||
JOIN messages ON (
|
||||
messages.id = threads.message_id
|
||||
AND messages.empty = 0 AND messages.deleted = 0
|
||||
AND messages.date = aggregated.date
|
||||
)
|
||||
JOIN folders ON (folders.id = messages.folder_id)
|
||||
GROUP BY threads.root
|
||||
ORDER BY $orderBy
|
||||
""",
|
||||
selectionArgs,
|
||||
).use { cursor ->
|
||||
val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = true)
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
val value = mapper.map(cursorMessageAccessor)
|
||||
if (value != null) {
|
||||
add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> getThread(threadId: Long, sortOrder: String, mapper: MessageMapper<out T?>): List<T> {
|
||||
return lockableDatabase.execute(false) { database ->
|
||||
database.rawQuery(
|
||||
"""
|
||||
SELECT
|
||||
messages.id AS id,
|
||||
uid,
|
||||
folder_id,
|
||||
sender_list,
|
||||
to_list,
|
||||
cc_list,
|
||||
date,
|
||||
internal_date,
|
||||
subject,
|
||||
preview_type,
|
||||
preview,
|
||||
read,
|
||||
flagged,
|
||||
answered,
|
||||
forwarded,
|
||||
attachment_count,
|
||||
root
|
||||
FROM threads
|
||||
JOIN messages ON (messages.id = threads.message_id)
|
||||
LEFT JOIN FOLDERS ON (folders.id = messages.folder_id)
|
||||
WHERE
|
||||
root = ?
|
||||
AND empty = 0 AND deleted = 0
|
||||
ORDER BY $sortOrder
|
||||
""",
|
||||
arrayOf(threadId.toString()),
|
||||
).use { cursor ->
|
||||
val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = false)
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
val value = mapper.map(cursorMessageAccessor)
|
||||
if (value != null) {
|
||||
add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CursorMessageAccessor(val cursor: Cursor, val includesThreadCount: Boolean) : MessageDetailsAccessor {
|
||||
override val id: Long
|
||||
get() = cursor.getLong(0)
|
||||
override val messageServerId: String
|
||||
get() = cursor.getString(1)
|
||||
override val folderId: Long
|
||||
get() = cursor.getLong(2)
|
||||
override val fromAddresses: List<Address>
|
||||
get() = Address.unpack(cursor.getString(3)).toList()
|
||||
override val toAddresses: List<Address>
|
||||
get() = Address.unpack(cursor.getString(4)).toList()
|
||||
override val ccAddresses: List<Address>
|
||||
get() = Address.unpack(cursor.getString(5)).toList()
|
||||
override val messageDate: Long
|
||||
get() = cursor.getLong(6)
|
||||
override val internalDate: Long
|
||||
get() = cursor.getLong(7)
|
||||
override val subject: String?
|
||||
get() = cursor.getString(8)
|
||||
override val preview: PreviewResult
|
||||
get() {
|
||||
return when (DatabasePreviewType.fromDatabaseValue(cursor.getString(9))) {
|
||||
DatabasePreviewType.NONE -> PreviewResult.none()
|
||||
DatabasePreviewType.TEXT -> PreviewResult.text(cursor.getString(10))
|
||||
DatabasePreviewType.ENCRYPTED -> PreviewResult.encrypted()
|
||||
DatabasePreviewType.ERROR -> PreviewResult.error()
|
||||
}
|
||||
}
|
||||
override val isRead: Boolean
|
||||
get() = cursor.getInt(11) == 1
|
||||
override val isStarred: Boolean
|
||||
get() = cursor.getInt(12) == 1
|
||||
override val isAnswered: Boolean
|
||||
get() = cursor.getInt(13) == 1
|
||||
override val isForwarded: Boolean
|
||||
get() = cursor.getInt(14) == 1
|
||||
override val hasAttachments: Boolean
|
||||
get() = cursor.getInt(15) > 0
|
||||
override val threadRoot: Long
|
||||
get() = cursor.getLong(16)
|
||||
override val threadCount: Int
|
||||
get() = if (includesThreadCount) cursor.getInt(17) else 0
|
||||
}
|
||||
|
||||
private val AGGREGATED_MESSAGES_COLUMNS = arrayOf(
|
||||
"date",
|
||||
"internal_date",
|
||||
"attachment_count",
|
||||
"read",
|
||||
"flagged",
|
||||
"answered",
|
||||
"forwarded",
|
||||
)
|
||||
|
|
@ -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 app.k9mail.legacy.mailstore.SaveMessageData
|
||||
import app.k9mail.legacy.message.extractors.PreviewResult.PreviewType
|
||||
import com.fsck.k9.K9
|
||||
import com.fsck.k9.mail.Address
|
||||
import com.fsck.k9.mail.Body
|
||||
import com.fsck.k9.mail.BoundaryGenerator
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.Message.RecipientType
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
import com.fsck.k9.mail.Multipart
|
||||
import com.fsck.k9.mail.Part
|
||||
import com.fsck.k9.mail.filter.CountingOutputStream
|
||||
import com.fsck.k9.mail.internet.BinaryTempFileBody
|
||||
import com.fsck.k9.mail.internet.MimeHeader
|
||||
import com.fsck.k9.mail.internet.MimeUtility
|
||||
import com.fsck.k9.mail.internet.SizeAware
|
||||
import com.fsck.k9.mailstore.DatabasePreviewType
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import com.fsck.k9.message.extractors.BasicPartInfoExtractor
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Stack
|
||||
import java.util.UUID
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.james.mime4j.codec.Base64InputStream
|
||||
import org.apache.james.mime4j.codec.QuotedPrintableInputStream
|
||||
import org.apache.james.mime4j.util.MimeUtil
|
||||
|
||||
internal const val MAX_BODY_SIZE_FOR_DATABASE = 16 * 1024L
|
||||
|
||||
internal class SaveMessageOperations(
|
||||
private val lockableDatabase: LockableDatabase,
|
||||
private val attachmentFileManager: AttachmentFileManager,
|
||||
private val partInfoExtractor: BasicPartInfoExtractor,
|
||||
private val threadMessageOperations: ThreadMessageOperations,
|
||||
) {
|
||||
fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) {
|
||||
saveMessage(folderId, messageServerId, messageData)
|
||||
}
|
||||
|
||||
fun saveLocalMessage(folderId: Long, messageData: SaveMessageData, existingMessageId: Long?): Long {
|
||||
return if (existingMessageId == null) {
|
||||
saveLocalMessage(folderId, messageData)
|
||||
} else {
|
||||
replaceLocalMessage(folderId, existingMessageId, messageData)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLocalMessage(folderId: Long, messageData: SaveMessageData): Long {
|
||||
val fakeServerId = K9.LOCAL_UID_PREFIX + UUID.randomUUID().toString()
|
||||
return saveMessage(folderId, fakeServerId, messageData)
|
||||
}
|
||||
|
||||
private fun replaceLocalMessage(folderId: Long, messageId: Long, messageData: SaveMessageData): Long {
|
||||
return lockableDatabase.execute(true) { database ->
|
||||
val (messageServerId, rootMessagePartId) = getLocalMessageInfo(folderId, messageId)
|
||||
|
||||
replaceMessage(
|
||||
database,
|
||||
folderId,
|
||||
messageServerId,
|
||||
existingMessageId = messageId,
|
||||
existingRootMessagePartId = rootMessagePartId,
|
||||
messageData,
|
||||
)
|
||||
|
||||
messageId
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData): Long {
|
||||
return lockableDatabase.execute(true) { database ->
|
||||
val message = messageData.message
|
||||
|
||||
val existingMessageInfo = getMessage(folderId, messageServerId)
|
||||
return@execute if (existingMessageInfo != null) {
|
||||
val (existingMessageId, existingRootMessagePartId) = existingMessageInfo
|
||||
replaceMessage(
|
||||
database,
|
||||
folderId,
|
||||
messageServerId,
|
||||
existingMessageId,
|
||||
existingRootMessagePartId,
|
||||
messageData,
|
||||
)
|
||||
|
||||
existingMessageId
|
||||
} else {
|
||||
insertMessage(database, folderId, messageServerId, message, messageData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertMessage(
|
||||
database: SQLiteDatabase,
|
||||
folderId: Long,
|
||||
messageServerId: String,
|
||||
message: Message,
|
||||
messageData: SaveMessageData,
|
||||
): Long {
|
||||
val threadInfo = threadMessageOperations.doMessageThreading(database, folderId, message.toThreadHeaders())
|
||||
|
||||
val rootMessagePartId = saveMessageParts(database, message)
|
||||
val messageId = saveMessage(
|
||||
database,
|
||||
folderId,
|
||||
messageServerId,
|
||||
rootMessagePartId,
|
||||
messageData,
|
||||
replaceMessageId = threadInfo?.messageId,
|
||||
)
|
||||
|
||||
if (threadInfo?.threadId == null) {
|
||||
threadMessageOperations.createThreadEntry(database, messageId, threadInfo?.rootId, threadInfo?.parentId)
|
||||
}
|
||||
|
||||
createOrReplaceFulltextEntry(database, messageId, messageData)
|
||||
|
||||
return messageId
|
||||
}
|
||||
|
||||
private fun replaceMessage(
|
||||
database: SQLiteDatabase,
|
||||
folderId: Long,
|
||||
messageServerId: String,
|
||||
existingMessageId: Long,
|
||||
existingRootMessagePartId: Long?,
|
||||
messageData: SaveMessageData,
|
||||
) {
|
||||
if (existingRootMessagePartId != null) {
|
||||
deleteMessagePartsAndDataFromDisk(database, existingRootMessagePartId)
|
||||
}
|
||||
|
||||
val rootMessagePartId = saveMessageParts(database, messageData.message)
|
||||
val messageId = saveMessage(
|
||||
database,
|
||||
folderId,
|
||||
messageServerId,
|
||||
rootMessagePartId,
|
||||
messageData,
|
||||
replaceMessageId = existingMessageId,
|
||||
)
|
||||
|
||||
createOrReplaceFulltextEntry(database, messageId, messageData)
|
||||
}
|
||||
|
||||
private fun saveMessageParts(database: SQLiteDatabase, message: Message): Long {
|
||||
val rootPartContainer = PartContainer(parentId = null, part = message)
|
||||
val rootId = saveMessagePart(database, rootPartContainer, rootId = null, order = 0)
|
||||
|
||||
val partsToSave = Stack<PartContainer>()
|
||||
addChildrenToStack(partsToSave, part = message, parentId = rootId)
|
||||
|
||||
var order = 1
|
||||
while (partsToSave.isNotEmpty()) {
|
||||
val partContainer = partsToSave.pop()
|
||||
val messagePartId = saveMessagePart(database, partContainer, rootId, order)
|
||||
order++
|
||||
addChildrenToStack(partsToSave, partContainer.part, parentId = messagePartId)
|
||||
}
|
||||
|
||||
return rootId
|
||||
}
|
||||
|
||||
private fun saveMessagePart(
|
||||
database: SQLiteDatabase,
|
||||
partContainer: PartContainer,
|
||||
rootId: Long?,
|
||||
order: Int,
|
||||
): Long {
|
||||
val part = partContainer.part
|
||||
val values = ContentValues().apply {
|
||||
put("root", rootId)
|
||||
put("parent", partContainer.parentId ?: -1) // -1 for compatibility with previous code
|
||||
put("seq", order)
|
||||
put("server_extra", part.serverExtra)
|
||||
}
|
||||
|
||||
return updateOrInsertMessagePart(database, values, part, existingMessagePartId = null)
|
||||
}
|
||||
|
||||
private fun updateOrInsertMessagePart(
|
||||
database: SQLiteDatabase,
|
||||
values: ContentValues,
|
||||
part: Part,
|
||||
existingMessagePartId: Long?,
|
||||
): Long {
|
||||
val headerBytes = getHeaderBytes(part)
|
||||
values.put("mime_type", part.mimeType)
|
||||
values.put("header", headerBytes)
|
||||
values.put("type", MessagePartType.UNKNOWN)
|
||||
|
||||
val file: File? = when (val body = part.body) {
|
||||
is Multipart -> multipartToContentValues(values, body)
|
||||
is Message -> messageMarkerToContentValues(values)
|
||||
null -> missingPartToContentValues(values, part)
|
||||
else -> leafPartToContentValues(values, part, body)
|
||||
}
|
||||
|
||||
val messagePartId = if (existingMessagePartId != null) {
|
||||
database.update("message_parts", values, "id = ?", arrayOf(existingMessagePartId.toString()))
|
||||
existingMessagePartId
|
||||
} else {
|
||||
database.insertOrThrow("message_parts", null, values)
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
attachmentFileManager.moveTemporaryFile(file, messagePartId)
|
||||
}
|
||||
|
||||
return messagePartId
|
||||
}
|
||||
|
||||
private fun multipartToContentValues(values: ContentValues, multipart: Multipart): File? {
|
||||
values.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
|
||||
values.put("preamble", multipart.preamble)
|
||||
values.put("epilogue", multipart.epilogue)
|
||||
values.put("boundary", multipart.boundary)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun messageMarkerToContentValues(cv: ContentValues): File? {
|
||||
cv.put("data_location", DataLocation.CHILD_PART_CONTAINS_DATA)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun missingPartToContentValues(values: ContentValues, part: Part): File? {
|
||||
val partInfo = partInfoExtractor.extractPartInfo(part)
|
||||
values.put("display_name", partInfo.displayName)
|
||||
values.put("data_location", DataLocation.MISSING)
|
||||
values.put("decoded_body_size", partInfo.size)
|
||||
|
||||
if (MimeUtility.isMultipart(part.mimeType)) {
|
||||
values.put("boundary", BoundaryGenerator.getInstance().generateBoundary())
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun leafPartToContentValues(values: ContentValues, part: Part, body: Body): File? {
|
||||
val displayName = partInfoExtractor.extractDisplayName(part)
|
||||
values.put("display_name", displayName)
|
||||
|
||||
val encoding = getTransferEncoding(part)
|
||||
values.put("encoding", encoding)
|
||||
values.put("content_id", part.contentId)
|
||||
|
||||
check(body is SizeAware) { "Body needs to implement SizeAware" }
|
||||
val sizeAwareBody = body as SizeAware
|
||||
val fileSize = sizeAwareBody.size
|
||||
|
||||
return if (fileSize > MAX_BODY_SIZE_FOR_DATABASE) {
|
||||
values.put("data_location", DataLocation.ON_DISK)
|
||||
val file = writeBodyToDiskIfNecessary(part)
|
||||
val size = decodeAndCountBytes(file, encoding, fileSize)
|
||||
values.put("decoded_body_size", size)
|
||||
|
||||
file
|
||||
} else {
|
||||
values.put("data_location", DataLocation.IN_DATABASE)
|
||||
val bodyData = getBodyBytes(body)
|
||||
values.put("data", bodyData)
|
||||
val size = decodeAndCountBytes(bodyData.inputStream(), encoding, bodyData.size.toLong())
|
||||
values.put("decoded_body_size", size)
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeBodyToDiskIfNecessary(part: Part): File? {
|
||||
val body = part.body
|
||||
return if (body is BinaryTempFileBody) {
|
||||
body.file
|
||||
} else {
|
||||
writeBodyToDisk(body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeBodyToDisk(body: Body): File? {
|
||||
val file = File.createTempFile("body", null, BinaryTempFileBody.getTempDirectory())
|
||||
FileOutputStream(file).use { outputStream ->
|
||||
body.writeTo(outputStream)
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
private fun decodeAndCountBytes(file: File?, encoding: String, fallbackValue: Long): Long {
|
||||
return FileInputStream(file).use { inputStream ->
|
||||
decodeAndCountBytes(inputStream, encoding, fallbackValue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAndCountBytes(rawInputStream: InputStream, encoding: String, fallbackValue: Long): Long {
|
||||
return try {
|
||||
getDecodingInputStream(rawInputStream, encoding).use { decodingInputStream ->
|
||||
CountingOutputStream().use { countingOutputStream ->
|
||||
IOUtils.copy(decodingInputStream, countingOutputStream)
|
||||
countingOutputStream.count
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
fallbackValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDecodingInputStream(rawInputStream: InputStream, encoding: String?): InputStream {
|
||||
return when (encoding) {
|
||||
MimeUtil.ENC_BASE64 -> {
|
||||
object : Base64InputStream(rawInputStream) {
|
||||
override fun close() {
|
||||
super.close()
|
||||
rawInputStream.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
MimeUtil.ENC_QUOTED_PRINTABLE -> {
|
||||
object : QuotedPrintableInputStream(rawInputStream) {
|
||||
override fun close() {
|
||||
super.close()
|
||||
rawInputStream.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
rawInputStream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getHeaderBytes(part: Part): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
part.writeHeaderTo(output)
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun getBodyBytes(body: Body): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
body.writeTo(output)
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun getTransferEncoding(part: Part): String {
|
||||
val contentTransferEncoding = part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING).firstOrNull()
|
||||
return contentTransferEncoding?.lowercase() ?: MimeUtil.ENC_7BIT
|
||||
}
|
||||
|
||||
private fun addChildrenToStack(stack: Stack<PartContainer>, part: Part, parentId: Long) {
|
||||
when (val body = part.body) {
|
||||
is Multipart -> {
|
||||
for (i in body.count - 1 downTo 0) {
|
||||
val childPart = body.getBodyPart(i)
|
||||
stack.push(PartContainer(parentId, childPart))
|
||||
}
|
||||
}
|
||||
is Message -> {
|
||||
stack.push(PartContainer(parentId, body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMessage(
|
||||
database: SQLiteDatabase,
|
||||
folderId: Long,
|
||||
messageServerId: String,
|
||||
rootMessagePartId: Long,
|
||||
messageData: SaveMessageData,
|
||||
replaceMessageId: Long?,
|
||||
): Long {
|
||||
val message = messageData.message
|
||||
|
||||
when (messageData.downloadState) {
|
||||
MessageDownloadState.ENVELOPE -> Unit
|
||||
MessageDownloadState.PARTIAL -> message.setFlag(Flag.X_DOWNLOADED_PARTIAL, true)
|
||||
MessageDownloadState.FULL -> message.setFlag(Flag.X_DOWNLOADED_FULL, true)
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put("folder_id", folderId)
|
||||
put("uid", messageServerId)
|
||||
put("deleted", 0)
|
||||
put("empty", 0)
|
||||
put("message_part_id", rootMessagePartId)
|
||||
put("date", messageData.date)
|
||||
put("internal_date", messageData.internalDate)
|
||||
put("subject", messageData.subject)
|
||||
put("flags", message.flags.toDatabaseValue())
|
||||
put("read", message.isSet(Flag.SEEN).toDatabaseValue())
|
||||
put("flagged", message.isSet(Flag.FLAGGED).toDatabaseValue())
|
||||
put("answered", message.isSet(Flag.ANSWERED).toDatabaseValue())
|
||||
put("forwarded", message.isSet(Flag.FORWARDED).toDatabaseValue())
|
||||
put("sender_list", Address.pack(message.from))
|
||||
put("to_list", Address.pack(message.getRecipients(RecipientType.TO)))
|
||||
put("cc_list", Address.pack(message.getRecipients(RecipientType.CC)))
|
||||
put("bcc_list", Address.pack(message.getRecipients(RecipientType.BCC)))
|
||||
put("reply_to_list", Address.pack(message.replyTo))
|
||||
put("attachment_count", messageData.attachmentCount)
|
||||
put("message_id", message.messageId)
|
||||
put("mime_type", message.mimeType)
|
||||
put("encryption_type", messageData.encryptionType)
|
||||
|
||||
val previewResult = messageData.previewResult
|
||||
put("preview_type", previewResult.previewType.toDatabaseValue())
|
||||
if (previewResult.isPreviewTextAvailable) {
|
||||
put("preview", previewResult.previewText)
|
||||
} else {
|
||||
putNull("preview")
|
||||
}
|
||||
}
|
||||
|
||||
return if (replaceMessageId != null) {
|
||||
values.put("id", replaceMessageId)
|
||||
database.replace("messages", null, values)
|
||||
replaceMessageId
|
||||
} else {
|
||||
database.insert("messages", null, values)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOrReplaceFulltextEntry(database: SQLiteDatabase, messageId: Long, messageData: SaveMessageData) {
|
||||
val fulltext = messageData.textForSearchIndex ?: return
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put("docid", messageId)
|
||||
put("fulltext", fulltext)
|
||||
}
|
||||
|
||||
database.replace("messages_fulltext", null, values)
|
||||
}
|
||||
|
||||
private fun getMessage(folderId: Long, messageServerId: String): Pair<Long, Long?>? {
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
db.query(
|
||||
"messages",
|
||||
arrayOf("id", "message_part_id"),
|
||||
"folder_id = ? AND uid = ?",
|
||||
arrayOf(folderId.toString(), messageServerId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val messageId = cursor.getLong(0)
|
||||
val messagePartId = cursor.getLong(1)
|
||||
messageId to messagePartId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLocalMessageInfo(folderId: Long, messageId: Long): Pair<String, Long?> {
|
||||
return lockableDatabase.execute(false) { db ->
|
||||
db.query(
|
||||
"messages",
|
||||
arrayOf("uid", "message_part_id"),
|
||||
"folder_id = ? AND id = ?",
|
||||
arrayOf(folderId.toString(), messageId.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) error("Local message not found $folderId:$messageId")
|
||||
|
||||
val messageServerId = cursor.getString(0)!!
|
||||
val messagePartId = cursor.getLong(1)
|
||||
messageServerId to messagePartId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteMessagePartsAndDataFromDisk(database: SQLiteDatabase, rootMessagePartId: Long) {
|
||||
deleteMessageDataFromDisk(database, rootMessagePartId)
|
||||
deleteMessageParts(database, rootMessagePartId)
|
||||
}
|
||||
|
||||
private fun deleteMessageDataFromDisk(database: SQLiteDatabase, rootMessagePartId: Long) {
|
||||
database.query(
|
||||
"message_parts",
|
||||
arrayOf("id"),
|
||||
"root = ? AND data_location = " + DataLocation.ON_DISK,
|
||||
arrayOf(rootMessagePartId.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val messagePartId = cursor.getLong(0)
|
||||
attachmentFileManager.deleteFile(messagePartId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteMessageParts(database: SQLiteDatabase, rootMessagePartId: Long) {
|
||||
database.delete("message_parts", "root = ?", arrayOf(rootMessagePartId.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Set<Flag>.toDatabaseValue(): String {
|
||||
return this
|
||||
.filter { it !in SPECIAL_FLAGS }
|
||||
.joinToString(separator = ",")
|
||||
}
|
||||
|
||||
private fun Boolean.toDatabaseValue() = if (this) 1 else 0
|
||||
|
||||
private fun PreviewType.toDatabaseValue(): String {
|
||||
return DatabasePreviewType.fromPreviewType(this).databaseValue
|
||||
}
|
||||
|
||||
// Note: The contents of the 'message_parts' table depend on these values.
|
||||
// TODO: currently unused, might be used for caching at a later point
|
||||
internal object MessagePartType {
|
||||
const val UNKNOWN = 0
|
||||
}
|
||||
|
||||
// Note: The contents of the 'message_parts' table depend on these values.
|
||||
internal object DataLocation {
|
||||
const val MISSING = 0
|
||||
const val IN_DATABASE = 1
|
||||
const val ON_DISK = 2
|
||||
const val CHILD_PART_CONTAINS_DATA = 3
|
||||
}
|
||||
|
||||
private data class PartContainer(val parentId: Long?, val part: Part)
|
||||
|
|
@ -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,118 @@
|
|||
package com.fsck.k9.storage.messages
|
||||
|
||||
import android.content.ContentValues
|
||||
import app.k9mail.legacy.mailstore.MoreMessages
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import com.fsck.k9.mailstore.toDatabaseFolderType
|
||||
import net.thunderbird.feature.mail.folder.api.FolderDetails
|
||||
|
||||
internal class UpdateFolderOperations(private val lockableDatabase: LockableDatabase) {
|
||||
fun changeFolder(folderServerId: String, name: String, type: FolderType) {
|
||||
lockableDatabase.execute(false) { db ->
|
||||
val values = ContentValues().apply {
|
||||
put("name", name)
|
||||
put("type", type.toDatabaseFolderType())
|
||||
}
|
||||
|
||||
db.update("folders", values, "server_id = ?", arrayOf(folderServerId))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFolderSettings(folderDetails: FolderDetails) {
|
||||
lockableDatabase.execute(false) { db ->
|
||||
val contentValues = ContentValues().apply {
|
||||
put("top_group", folderDetails.isInTopGroup)
|
||||
put("integrate", folderDetails.isIntegrate)
|
||||
put("sync_enabled", folderDetails.isSyncEnabled)
|
||||
put("visible", folderDetails.isVisible)
|
||||
put("notifications_enabled", folderDetails.isNotificationsEnabled)
|
||||
put("push_enabled", folderDetails.isPushEnabled)
|
||||
}
|
||||
|
||||
db.update("folders", contentValues, "id = ?", arrayOf(folderDetails.folder.id.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
fun setIncludeInUnifiedInbox(folderId: Long, includeInUnifiedInbox: Boolean) {
|
||||
setBoolean(folderId, columnName = "integrate", value = includeInUnifiedInbox)
|
||||
}
|
||||
|
||||
fun setVisible(folderId: Long, visible: Boolean) {
|
||||
setBoolean(folderId = folderId, columnName = "visible", value = visible)
|
||||
}
|
||||
|
||||
fun setSyncEnabled(folderId: Long, enable: Boolean) {
|
||||
setBoolean(folderId = folderId, columnName = "sync_enabled", value = enable)
|
||||
}
|
||||
|
||||
fun setPushEnabled(folderId: Long, enable: Boolean) {
|
||||
setBoolean(folderId = folderId, columnName = "push_enabled", value = enable)
|
||||
}
|
||||
|
||||
fun setNotificationsEnabled(folderId: Long, enable: Boolean) {
|
||||
setBoolean(folderId, columnName = "notifications_enabled", value = enable)
|
||||
}
|
||||
|
||||
fun setMoreMessages(folderId: Long, moreMessages: MoreMessages) {
|
||||
setString(folderId = folderId, columnName = "more_messages", value = moreMessages.databaseName)
|
||||
}
|
||||
|
||||
fun setLastChecked(folderId: Long, timestamp: Long) {
|
||||
lockableDatabase.execute(false) { db ->
|
||||
val contentValues = ContentValues().apply {
|
||||
put("last_updated", timestamp)
|
||||
}
|
||||
|
||||
db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
fun setStatus(folderId: Long, status: String?) {
|
||||
setString(folderId = folderId, columnName = "status", value = status)
|
||||
}
|
||||
|
||||
fun setVisibleLimit(folderId: Long, visibleLimit: Int) {
|
||||
lockableDatabase.execute(false) { db ->
|
||||
val contentValues = ContentValues().apply {
|
||||
put("visible_limit", visibleLimit)
|
||||
}
|
||||
|
||||
db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
fun setPushDisabled() {
|
||||
lockableDatabase.execute(false) { db ->
|
||||
val contentValues = ContentValues().apply {
|
||||
put("push_enabled", false)
|
||||
}
|
||||
|
||||
db.update("folders", contentValues, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setString(folderId: Long, columnName: String, value: String?) {
|
||||
lockableDatabase.execute(false) { db ->
|
||||
val contentValues = ContentValues().apply {
|
||||
if (value == null) {
|
||||
putNull(columnName)
|
||||
} else {
|
||||
put(columnName, value)
|
||||
}
|
||||
}
|
||||
|
||||
db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setBoolean(folderId: Long, columnName: String, value: Boolean) {
|
||||
lockableDatabase.execute(false) { db ->
|
||||
val contentValues = ContentValues().apply {
|
||||
put(columnName, value)
|
||||
}
|
||||
|
||||
db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 net.thunderbird.core.common.mail.Protocols
|
||||
|
||||
internal object MigrationTo65 {
|
||||
@JvmStatic
|
||||
fun addLocalOnlyColumnToFoldersTable(db: SQLiteDatabase, migrationsHelper: MigrationsHelper) {
|
||||
db.execSQL("ALTER TABLE folders ADD local_only INTEGER")
|
||||
|
||||
if (isPop3Account(migrationsHelper)) {
|
||||
db.execSQL("UPDATE folders SET local_only = CASE server_id WHEN 'INBOX' THEN 0 ELSE 1 END")
|
||||
} else {
|
||||
db.execSQL("UPDATE folders SET local_only = CASE server_id WHEN 'K9MAIL_INTERNAL_OUTBOX' THEN 1 ELSE 0 END")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPop3Account(migrationsHelper: MigrationsHelper): Boolean {
|
||||
return migrationsHelper.account.incomingServerSettings.type == Protocols.POP3
|
||||
}
|
||||
}
|
||||
|
|
@ -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,98 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
internal class MigrationTo70(private val db: SQLiteDatabase) {
|
||||
fun removePushState() {
|
||||
renameFoldersTable()
|
||||
createNewFoldersTable()
|
||||
copyFoldersData()
|
||||
dropOldFoldersTable()
|
||||
recreateFoldersIndex()
|
||||
recreateFoldersTriggers()
|
||||
}
|
||||
|
||||
private fun renameFoldersTable() {
|
||||
db.execSQL("ALTER TABLE folders RENAME TO folders_old")
|
||||
}
|
||||
|
||||
private fun createNewFoldersTable() {
|
||||
db.execSQL(
|
||||
"CREATE TABLE folders (" +
|
||||
"id INTEGER PRIMARY KEY," +
|
||||
"name TEXT, " +
|
||||
"last_updated INTEGER, " +
|
||||
"unread_count INTEGER, " +
|
||||
"visible_limit INTEGER, " +
|
||||
"status TEXT, " +
|
||||
"flagged_count INTEGER default 0, " +
|
||||
"integrate INTEGER, " +
|
||||
"top_group INTEGER, " +
|
||||
"poll_class TEXT, " +
|
||||
"push_class TEXT, " +
|
||||
"display_class TEXT, " +
|
||||
"notify_class TEXT default 'INHERITED', " +
|
||||
"more_messages TEXT default \"unknown\", " +
|
||||
"server_id TEXT, " +
|
||||
"local_only INTEGER, " +
|
||||
"type TEXT DEFAULT \"regular\"" +
|
||||
")",
|
||||
)
|
||||
}
|
||||
|
||||
private fun copyFoldersData() {
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO folders
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
last_updated,
|
||||
unread_count,
|
||||
visible_limit,
|
||||
status,
|
||||
flagged_count,
|
||||
integrate,
|
||||
top_group,
|
||||
poll_class,
|
||||
push_class,
|
||||
display_class,
|
||||
notify_class,
|
||||
more_messages,
|
||||
server_id,
|
||||
local_only,
|
||||
type
|
||||
FROM folders_old
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun dropOldFoldersTable() {
|
||||
db.execSQL("DROP TABLE folders_old")
|
||||
}
|
||||
|
||||
private fun recreateFoldersIndex() {
|
||||
db.execSQL("DROP INDEX IF EXISTS folder_server_id")
|
||||
db.execSQL("CREATE INDEX folder_server_id ON folders (server_id)")
|
||||
}
|
||||
|
||||
private fun recreateFoldersTriggers() {
|
||||
db.execSQL("DROP TRIGGER IF EXISTS delete_folder")
|
||||
db.execSQL(
|
||||
"CREATE TRIGGER delete_folder " +
|
||||
"BEFORE DELETE ON folders " +
|
||||
"BEGIN " +
|
||||
"DELETE FROM messages WHERE old.id = folder_id; " +
|
||||
"END;",
|
||||
)
|
||||
|
||||
db.execSQL("DROP TRIGGER IF EXISTS delete_folder_extra_values")
|
||||
db.execSQL(
|
||||
"CREATE TRIGGER delete_folder_extra_values " +
|
||||
"BEFORE DELETE ON folders " +
|
||||
"BEGIN " +
|
||||
"DELETE FROM folder_extra_values WHERE old.id = folder_id; " +
|
||||
"END;",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
internal class MigrationTo73(private val db: SQLiteDatabase) {
|
||||
private val serializer = PendingCommandSerializer.getInstance()
|
||||
private val moshi = Moshi.Builder().build()
|
||||
private val legacyAdapters = listOf(
|
||||
"append" to moshi.adapter(LegacyPendingAppend::class.java),
|
||||
"mark_all_as_read" to moshi.adapter(LegacyPendingMarkAllAsRead::class.java),
|
||||
"set_flag" to moshi.adapter(LegacyPendingSetFlag::class.java),
|
||||
"delete" to moshi.adapter(LegacyPendingDelete::class.java),
|
||||
"expunge" to moshi.adapter(LegacyPendingExpunge::class.java),
|
||||
"move_or_copy" to moshi.adapter(LegacyPendingMoveOrCopy::class.java),
|
||||
"move_and_mark_as_read" to moshi.adapter(LegacyPendingMoveAndMarkAsRead::class.java),
|
||||
).toMap()
|
||||
|
||||
fun rewritePendingCommandsToUseFolderIds() {
|
||||
val pendingCommands = loadPendingCommands()
|
||||
rewritePendingCommands(pendingCommands)
|
||||
}
|
||||
|
||||
private fun loadPendingCommands(): Map<Long, LegacyPendingCommand> {
|
||||
return db.rawQuery(
|
||||
"SELECT id, command, data FROM pending_commands WHERE command != 'empty_trash'",
|
||||
null,
|
||||
).use { cursor ->
|
||||
cursor.map {
|
||||
val commandId = cursor.getLong(0)
|
||||
val command = cursor.getString(1)
|
||||
val data = cursor.getString(2)
|
||||
|
||||
val pendingCommand = deserialize(command, data)
|
||||
commandId to pendingCommand
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewritePendingCommands(pendingCommands: Map<Long, LegacyPendingCommand>) {
|
||||
for ((commandId, pendingCommand) in pendingCommands) {
|
||||
when (pendingCommand) {
|
||||
is LegacyPendingAppend -> rewritePendingAppend(commandId, pendingCommand)
|
||||
is LegacyPendingMarkAllAsRead -> rewritePendingMarkAllAsRead(commandId, pendingCommand)
|
||||
is LegacyPendingSetFlag -> rewritePendingSetFlag(commandId, pendingCommand)
|
||||
is LegacyPendingDelete -> rewritePendingDelete(commandId, pendingCommand)
|
||||
is LegacyPendingExpunge -> rewritePendingExpunge(commandId, pendingCommand)
|
||||
is LegacyPendingMoveOrCopy -> rewritePendingMoveOrCopy(commandId, pendingCommand)
|
||||
is LegacyPendingMoveAndMarkAsRead -> rewritePendingMoveAndMarkAsRead(commandId, pendingCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewritePendingAppend(commandId: Long, legacyPendingCommand: LegacyPendingAppend) {
|
||||
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
|
||||
PendingAppend.create(folderId, legacyPendingCommand.uid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewritePendingMarkAllAsRead(commandId: Long, legacyPendingCommand: LegacyPendingMarkAllAsRead) {
|
||||
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
|
||||
PendingMarkAllAsRead.create(folderId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewritePendingSetFlag(commandId: Long, legacyPendingCommand: LegacyPendingSetFlag) {
|
||||
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
|
||||
PendingSetFlag.create(
|
||||
folderId,
|
||||
legacyPendingCommand.newState,
|
||||
legacyPendingCommand.flag,
|
||||
legacyPendingCommand.uids,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewritePendingDelete(commandId: Long, legacyPendingCommand: LegacyPendingDelete) {
|
||||
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
|
||||
PendingDelete.create(folderId, legacyPendingCommand.uids)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewritePendingExpunge(commandId: Long, legacyPendingCommand: LegacyPendingExpunge) {
|
||||
rewriteOrRemovePendingCommand(commandId, legacyPendingCommand.folder) { (folderId) ->
|
||||
PendingExpunge.create(folderId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewritePendingMoveOrCopy(commandId: Long, legacyPendingCommand: LegacyPendingMoveOrCopy) {
|
||||
rewriteOrRemovePendingCommand(
|
||||
commandId,
|
||||
legacyPendingCommand.srcFolder,
|
||||
legacyPendingCommand.destFolder,
|
||||
) { (srcFolderId, destFolderId) ->
|
||||
PendingMoveOrCopy.create(
|
||||
srcFolderId,
|
||||
destFolderId,
|
||||
legacyPendingCommand.isCopy,
|
||||
legacyPendingCommand.newUidMap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewritePendingMoveAndMarkAsRead(commandId: Long, legacyPendingCommand: LegacyPendingMoveAndMarkAsRead) {
|
||||
rewriteOrRemovePendingCommand(
|
||||
commandId,
|
||||
legacyPendingCommand.srcFolder,
|
||||
legacyPendingCommand.destFolder,
|
||||
) { (srcFolderId, destFolderId) ->
|
||||
PendingMoveAndMarkAsRead.create(srcFolderId, destFolderId, legacyPendingCommand.newUidMap)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteOrRemovePendingCommand(
|
||||
commandId: Long,
|
||||
vararg folderServerIds: String,
|
||||
convertPendingCommand: (folderIds: List<Long>) -> PendingCommand,
|
||||
) {
|
||||
val folderIds = folderServerIds.map {
|
||||
loadFolderId(it)
|
||||
}
|
||||
|
||||
if (folderIds.any { it == null }) {
|
||||
Log.w("Couldn't find folder ID for pending command with database ID $commandId. Removing entry.")
|
||||
removePendingCommand(commandId)
|
||||
} else {
|
||||
val pendingCommand = convertPendingCommand(folderIds.filterNotNull())
|
||||
updatePendingCommand(commandId, pendingCommand)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePendingCommand(commandId: Long, pendingCommand: PendingCommand) {
|
||||
val contentValues = ContentValues().apply {
|
||||
put("data", serializer.serialize(pendingCommand))
|
||||
}
|
||||
db.update("pending_commands", contentValues, "id = ?", arrayOf(commandId.toString()))
|
||||
}
|
||||
|
||||
private fun removePendingCommand(commandId: Long) {
|
||||
db.delete("pending_commands", "id = ?", arrayOf(commandId.toString()))
|
||||
}
|
||||
|
||||
private fun loadFolderId(folderServerId: String): Long? {
|
||||
return db.rawQuery("SELECT id from folders WHERE server_id = ?", arrayOf(folderServerId)).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getLong(0)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deserialize(commandName: String, data: String): LegacyPendingCommand {
|
||||
val adapter = legacyAdapters[commandName] ?: error("Unsupported pending command type!")
|
||||
return adapter.fromJson(data) ?: error("Error deserializing pending command")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import net.thunderbird.core.android.account.DeletePolicy
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
/**
|
||||
* Remove all placeholder entries in 'messages' table
|
||||
*
|
||||
* When the user moves or deletes an email, the row in the 'messages' table is first updated with 'deleted = 1', turning
|
||||
* it into a placeholder message. After the remote operation has completed, the row is removed.
|
||||
*
|
||||
* Placeholder messages are created to prevent an email from being downloaded again during a sync before the remote
|
||||
* operation has finished. The placeholder message is also necessary to remember deleted messages when using a delete
|
||||
* policy other than "delete from server".
|
||||
*
|
||||
* Previously these placeholder messages often weren't removed when they should have been. This will slowly grow the
|
||||
* database.
|
||||
* Here we remove all placeholder messages when the delete policy is "delete from server". This might also include
|
||||
* placeholder messages that shouldn't be removed, because the remote operation hasn't been performed yet.
|
||||
* However, since the app tries to execute all pending remote operations before doing a message sync, it's unlikely that
|
||||
* deleted messages are re-downloaded. And if they are, the next sync after the remote operation has completed will
|
||||
* remove them again.
|
||||
*/
|
||||
internal class MigrationTo74(private val db: SQLiteDatabase, private val account: LegacyAccount) {
|
||||
|
||||
fun removeDeletedMessages() {
|
||||
if (account.deletePolicy != DeletePolicy.ON_DELETE) return
|
||||
|
||||
db.query("messages", arrayOf("id"), "deleted = 1", null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
destroyMessage(messageId = cursor.getLong(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun destroyMessage(messageId: Long) {
|
||||
if (hasThreadChildren(messageId)) {
|
||||
// This message has children in the thread structure so we need to make it an empty message.
|
||||
val cv = ContentValues().apply {
|
||||
put("deleted", 0)
|
||||
put("empty", 1)
|
||||
put("preview_type", "none")
|
||||
put("read", 0)
|
||||
put("flagged", 0)
|
||||
put("answered", 0)
|
||||
put("forwarded", 0)
|
||||
putNull("subject")
|
||||
putNull("sender_list")
|
||||
putNull("date")
|
||||
putNull("to_list")
|
||||
putNull("cc_list")
|
||||
putNull("bcc_list")
|
||||
putNull("preview")
|
||||
putNull("reply_to_list")
|
||||
putNull("message_part_id")
|
||||
putNull("flags")
|
||||
putNull("attachment_count")
|
||||
putNull("internal_date")
|
||||
putNull("mime_type")
|
||||
putNull("encryption_type")
|
||||
}
|
||||
db.update("messages", cv, "id = ?", arrayOf(messageId.toString()))
|
||||
|
||||
// Nothing else to do
|
||||
return
|
||||
}
|
||||
|
||||
// Get the message ID of the parent message if it's empty
|
||||
var currentId = getEmptyThreadParent(messageId)
|
||||
|
||||
// Delete the placeholder message
|
||||
deleteMessageRow(messageId)
|
||||
|
||||
// Walk the thread tree to delete all empty parents without children
|
||||
while (currentId != -1L) {
|
||||
if (hasThreadChildren(currentId)) {
|
||||
// We made sure there are no empty leaf nodes and can stop now.
|
||||
break
|
||||
}
|
||||
|
||||
// Get ID of the (empty) parent for the next iteration
|
||||
val newId = getEmptyThreadParent(currentId)
|
||||
|
||||
// Delete the empty message
|
||||
deleteMessageRow(currentId)
|
||||
currentId = newId
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasThreadChildren(messageId: Long): Boolean {
|
||||
return db.rawQuery(
|
||||
"SELECT COUNT(t2.id) " +
|
||||
"FROM threads t1 " +
|
||||
"JOIN threads t2 ON (t2.parent = t1.id) " +
|
||||
"WHERE t1.message_id = ?",
|
||||
arrayOf(messageId.toString()),
|
||||
).use { cursor ->
|
||||
cursor.moveToFirst() && !cursor.isNull(0) && cursor.getLong(0) > 0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEmptyThreadParent(messageId: Long): Long {
|
||||
return db.rawQuery(
|
||||
"SELECT m.id " +
|
||||
"FROM threads t1 " +
|
||||
"JOIN threads t2 ON (t1.parent = t2.id) " +
|
||||
"LEFT JOIN messages m ON (t2.message_id = m.id) " +
|
||||
"WHERE t1.message_id = ? AND m.empty = 1",
|
||||
arrayOf(messageId.toString()),
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else -1
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteMessageRow(messageId: Long) {
|
||||
// Delete the message
|
||||
db.delete("messages", "id = ?", arrayOf(messageId.toString()))
|
||||
|
||||
// Delete row in 'threads' table
|
||||
db.delete("threads", "message_id = ?", arrayOf(messageId.toString()))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fsck.k9.mailstore.MigrationsHelper
|
||||
|
||||
internal class MigrationTo75(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
|
||||
fun updateAccountWithSpecialFolderIds() {
|
||||
val account = migrationsHelper.account
|
||||
|
||||
account.inboxFolderId = getFolderId(account.legacyInboxFolder)
|
||||
account.draftsFolderId = getFolderId(account.importedDraftsFolder)
|
||||
account.sentFolderId = getFolderId(account.importedSentFolder)
|
||||
account.trashFolderId = getFolderId(account.importedTrashFolder)
|
||||
account.archiveFolderId = getFolderId(account.importedArchiveFolder)
|
||||
account.spamFolderId = getFolderId(account.importedSpamFolder)
|
||||
account.autoExpandFolderId = getFolderId(account.importedAutoExpandFolder)
|
||||
|
||||
account.importedDraftsFolder = null
|
||||
account.importedSentFolder = null
|
||||
account.importedTrashFolder = null
|
||||
account.importedArchiveFolder = null
|
||||
account.importedSpamFolder = null
|
||||
account.importedAutoExpandFolder = null
|
||||
|
||||
migrationsHelper.saveAccount()
|
||||
}
|
||||
|
||||
private fun getFolderId(serverId: String?): Long? {
|
||||
if (serverId == null) return null
|
||||
|
||||
return db.query("folders", arrayOf("id"), "server_id = ?", arrayOf(serverId), null, null, null).use { cursor ->
|
||||
if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getLong(0) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import app.k9mail.core.android.common.database.map
|
||||
import com.fsck.k9.mailstore.MigrationsHelper
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.common.mail.Protocols
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
/**
|
||||
* Clean up special local folders
|
||||
*
|
||||
* In the past local special folders were not always created. For example, when importing settings or when setting up
|
||||
* an account, but checking the server settings didn't succeed and the user decided to continue anyway.
|
||||
*
|
||||
* Clicking "Next" in the incoming server settings screen would check the server settings and, in the case of success,
|
||||
* create new special local folders even if they already existed. So it's also possible existing installations have
|
||||
* multiple special local folders of one type.
|
||||
*
|
||||
* Here, we clean up local special folders to have exactly one of each type. Messages in additional folders will be
|
||||
* moved to the folder we keep and then the other folders will be deleted. An exception are messages in old Outbox
|
||||
* folders. They will be deleted and not be moved to the new/current Outbox folder because this would cause potentially
|
||||
* very old messages to be sent. The right thing would be to move them to the Drafts folder. But this is much more
|
||||
* complicated. They'd have to be uploaded if the Drafts folder is not a local folder. It's also not clear what should
|
||||
* happen if there is no Drafts folder configured.
|
||||
*/
|
||||
internal class MigrationTo76(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
|
||||
fun cleanUpSpecialLocalFolders() {
|
||||
val account = migrationsHelper.account
|
||||
|
||||
if (account.isPop3()) {
|
||||
Log.v("Cleaning up Drafts folder")
|
||||
val draftsFolderId = account.draftsFolderId ?: createFolder("Drafts", "Drafts", DRAFTS_FOLDER_TYPE)
|
||||
moveMessages(DRAFTS_FOLDER_TYPE, draftsFolderId)
|
||||
account.draftsFolderId = draftsFolderId
|
||||
|
||||
Log.v("Cleaning up Sent folder")
|
||||
val sentFolderId = account.sentFolderId ?: createFolder("Sent", "Sent", SENT_FOLDER_TYPE)
|
||||
moveMessages(SENT_FOLDER_TYPE, sentFolderId)
|
||||
account.sentFolderId = sentFolderId
|
||||
|
||||
Log.v("Cleaning up Trash folder")
|
||||
val trashFolderId = account.trashFolderId ?: createFolder("Trash", "Trash", TRASH_FOLDER_TYPE)
|
||||
moveMessages(TRASH_FOLDER_TYPE, trashFolderId)
|
||||
account.trashFolderId = trashFolderId
|
||||
}
|
||||
|
||||
migrationsHelper.saveAccount()
|
||||
}
|
||||
|
||||
private fun createFolder(name: String, serverId: String, type: String): Long {
|
||||
Log.v(" Creating new local folder (name=$name, serverId=$serverId, type=$type)…")
|
||||
val values = ContentValues().apply {
|
||||
put("name", name)
|
||||
put("visible_limit", 25)
|
||||
put("integrate", 0)
|
||||
put("top_group", 0)
|
||||
put("poll_class", "NO_CLASS")
|
||||
put("push_class", "SECOND_CLASS")
|
||||
put("display_class", "NO_CLASS")
|
||||
put("server_id", serverId)
|
||||
put("local_only", 1)
|
||||
put("type", type)
|
||||
}
|
||||
|
||||
val folderId = db.insert("folders", null, values)
|
||||
Log.v(" Created folder with ID $folderId")
|
||||
|
||||
return folderId
|
||||
}
|
||||
|
||||
private fun getOtherFolders(folderType: String, excludeFolderId: Long): List<Long> {
|
||||
return db.query(
|
||||
"folders",
|
||||
arrayOf("id"),
|
||||
"local_only = 1 AND type = ? AND id != ?",
|
||||
arrayOf(folderType, excludeFolderId.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
).use { cursor ->
|
||||
cursor.map { cursor.getLong(0) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveMessages(folderType: String, destinationFolderId: Long) {
|
||||
val sourceFolderIds = getOtherFolders(folderType, destinationFolderId)
|
||||
for (sourceFolderId in sourceFolderIds) {
|
||||
moveMessages(sourceFolderId, destinationFolderId)
|
||||
deleteFolder(sourceFolderId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveMessages(sourceFolderId: Long, destinationFolderId: Long) {
|
||||
Log.v(" Moving messages from folder [$sourceFolderId] to folder [$destinationFolderId]…")
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put("folder_id", destinationFolderId)
|
||||
}
|
||||
val rows = db.update("messages", values, "folder_id = ?", arrayOf(sourceFolderId.toString()))
|
||||
|
||||
Log.v(" $rows messages moved.")
|
||||
}
|
||||
|
||||
private fun deleteFolder(folderId: Long) {
|
||||
Log.v(" Deleting folder [$folderId]")
|
||||
db.delete("folders", "id = ?", arrayOf(folderId.toString()))
|
||||
}
|
||||
|
||||
private fun LegacyAccount.isPop3() = incomingServerSettings.type == Protocols.POP3
|
||||
|
||||
companion object {
|
||||
private const val DRAFTS_FOLDER_TYPE = "drafts"
|
||||
private const val SENT_FOLDER_TYPE = "sent"
|
||||
private const val TRASH_FOLDER_TYPE = "trash"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,60 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.core.database.getStringOrNull
|
||||
import app.k9mail.core.android.common.database.map
|
||||
|
||||
/**
|
||||
* Write the address fields to use ASCII 1 instead of ASCII 0 as separator.
|
||||
* Separator was previously ASCII 0 but this caused problems with LIKE and searching.
|
||||
*/
|
||||
internal class MigrationTo84(private val db: SQLiteDatabase) {
|
||||
|
||||
fun rewriteAddresses() {
|
||||
val addressSets = db.rawQuery(
|
||||
"SELECT id, to_list, cc_list, bcc_list, reply_to_list, sender_list " +
|
||||
"FROM messages WHERE empty = 0 AND deleted = 0",
|
||||
null,
|
||||
).use { cursor ->
|
||||
cursor.map {
|
||||
val messageId = it.getLong(0)
|
||||
|
||||
messageId to AddressSet(
|
||||
toList = it.getStringOrNull(1),
|
||||
ccList = it.getStringOrNull(2),
|
||||
bccList = it.getStringOrNull(3),
|
||||
replyToList = it.getStringOrNull(4),
|
||||
senderList = it.getStringOrNull(5),
|
||||
)
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
for ((messageId, addressSet) in addressSets) {
|
||||
rewriteAddresses(messageId, addressSet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rewriteAddress(inAddress: String?): String? {
|
||||
return inAddress?.replace(oldChar = '\u0000', newChar = '\u0001')
|
||||
}
|
||||
|
||||
private fun rewriteAddresses(messageId: Long, addressSet: AddressSet) {
|
||||
val cv = ContentValues().apply {
|
||||
put("to_list", rewriteAddress(addressSet.toList))
|
||||
put("cc_list", rewriteAddress(addressSet.ccList))
|
||||
put("bcc_list", rewriteAddress(addressSet.bccList))
|
||||
put("reply_to_list", rewriteAddress(addressSet.replyToList))
|
||||
put("sender_list", rewriteAddress(addressSet.senderList))
|
||||
}
|
||||
db.update("messages", cv, "id = ?", arrayOf(messageId.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
private data class AddressSet(
|
||||
val toList: String?,
|
||||
val ccList: String?,
|
||||
val bccList: String?,
|
||||
val replyToList: String?,
|
||||
val senderList: String?,
|
||||
)
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Build
|
||||
import androidx.core.content.contentValuesOf
|
||||
import com.fsck.k9.mailstore.MigrationsHelper
|
||||
import net.thunderbird.core.android.account.FolderMode
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
internal class MigrationTo85(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
|
||||
fun addFoldersNotificationsEnabledColumn() {
|
||||
addColumn()
|
||||
populateColumn()
|
||||
|
||||
removeNotifyClassColumn()
|
||||
}
|
||||
|
||||
private fun addColumn() {
|
||||
db.execSQL("ALTER TABLE folders ADD notifications_enabled INTEGER DEFAULT 0")
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun populateColumn() {
|
||||
val account = migrationsHelper.account
|
||||
val ignoreFolders = getNotificationIgnoredFolders(account)
|
||||
|
||||
val ignoreFoldersWhereClause = if (ignoreFolders.isNotEmpty()) {
|
||||
"id NOT IN (${ignoreFolders.joinToString(separator = ",") { "?" }})"
|
||||
} else {
|
||||
"1"
|
||||
}
|
||||
|
||||
val whereClause = when (account.folderNotifyNewMailMode) {
|
||||
FolderMode.NONE -> {
|
||||
return
|
||||
}
|
||||
FolderMode.ALL -> {
|
||||
ignoreFoldersWhereClause
|
||||
}
|
||||
FolderMode.FIRST_CLASS -> {
|
||||
"""
|
||||
(
|
||||
notify_class = 'FIRST_CLASS' OR (
|
||||
notify_class = 'INHERITED' AND (
|
||||
push_class = 'FIRST_CLASS' OR (
|
||||
push_class = 'INHERITED' AND (
|
||||
poll_class = 'FIRST_CLASS' OR (
|
||||
poll_class = 'INHERITED' AND display_class = 'FIRST_CLASS'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) AND $ignoreFoldersWhereClause
|
||||
""".trimIndent()
|
||||
}
|
||||
FolderMode.FIRST_AND_SECOND_CLASS -> {
|
||||
"""
|
||||
(
|
||||
notify_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
|
||||
notify_class = 'INHERITED' AND (
|
||||
push_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
|
||||
push_class = 'INHERITED' AND (
|
||||
poll_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
|
||||
poll_class = 'INHERITED' AND display_class IN ('FIRST_CLASS', 'SECOND_CLASS')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) AND $ignoreFoldersWhereClause
|
||||
""".trimIndent()
|
||||
}
|
||||
FolderMode.NOT_SECOND_CLASS -> {
|
||||
"""
|
||||
(
|
||||
notify_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
|
||||
notify_class = 'INHERITED' AND (
|
||||
push_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
|
||||
push_class = 'INHERITED' AND (
|
||||
poll_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
|
||||
poll_class = 'INHERITED' AND display_class IN ('NO_CLASS', 'FIRST_CLASS')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) AND $ignoreFoldersWhereClause
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
db.update("folders", contentValuesOf("notifications_enabled" to true), whereClause, ignoreFolders)
|
||||
}
|
||||
|
||||
private fun getNotificationIgnoredFolders(account: LegacyAccount): Array<String> {
|
||||
val inboxFolderId = account.inboxFolderId
|
||||
|
||||
// These special folders were ignored via K9NotificationStrategy unless they were pointing to the inbox.
|
||||
return listOf(
|
||||
account.trashFolderId,
|
||||
account.draftsFolderId,
|
||||
account.spamFolderId,
|
||||
account.sentFolderId,
|
||||
).asSequence()
|
||||
.filterNotNull()
|
||||
.filterNot { it == inboxFolderId }
|
||||
.map { it.toString() }
|
||||
.toList()
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
private fun removeNotifyClassColumn() {
|
||||
// Support for dropping columns was added in SQLite 3.35.0 (2021-03-12).
|
||||
// See https://www.sqlite.org/releaselog/3_35_5.html
|
||||
//
|
||||
// So a SQLite version containing support for dropping tables is only available starting with API 34.
|
||||
// See https://developer.android.com/reference/android/database/sqlite/package-summary
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
dropNotifyClassColumn()
|
||||
} else {
|
||||
clearNotifyClassColumn()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropNotifyClassColumn() {
|
||||
db.execSQL("ALTER TABLE folders DROP COLUMN notify_class")
|
||||
}
|
||||
|
||||
private fun clearNotifyClassColumn() {
|
||||
db.update("folders", contentValuesOf("notify_class" to null), null, null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Build
|
||||
import androidx.core.content.contentValuesOf
|
||||
import com.fsck.k9.mailstore.MigrationsHelper
|
||||
import net.thunderbird.core.android.account.FolderMode
|
||||
|
||||
internal class MigrationTo86(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
|
||||
fun addFoldersPushEnabledColumn() {
|
||||
addColumn()
|
||||
populateColumn()
|
||||
|
||||
removePushClassColumn()
|
||||
}
|
||||
|
||||
private fun addColumn() {
|
||||
db.execSQL("ALTER TABLE folders ADD push_enabled INTEGER DEFAULT 0")
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun populateColumn() {
|
||||
val account = migrationsHelper.account
|
||||
|
||||
val whereClause = when (account.folderPushMode) {
|
||||
FolderMode.NONE -> {
|
||||
return
|
||||
}
|
||||
FolderMode.ALL -> {
|
||||
""
|
||||
}
|
||||
FolderMode.FIRST_CLASS -> {
|
||||
"""
|
||||
push_class = 'FIRST_CLASS' OR (
|
||||
push_class = 'INHERITED' AND (
|
||||
poll_class = 'FIRST_CLASS' OR (
|
||||
poll_class = 'INHERITED' AND display_class = 'FIRST_CLASS'
|
||||
)
|
||||
)
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
FolderMode.FIRST_AND_SECOND_CLASS -> {
|
||||
"""
|
||||
push_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
|
||||
push_class = 'INHERITED' AND (
|
||||
poll_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
|
||||
poll_class = 'INHERITED' AND display_class IN ('FIRST_CLASS', 'SECOND_CLASS')
|
||||
)
|
||||
)
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
FolderMode.NOT_SECOND_CLASS -> {
|
||||
"""
|
||||
push_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
|
||||
push_class = 'INHERITED' AND (
|
||||
poll_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
|
||||
poll_class = 'INHERITED' AND display_class IN ('NO_CLASS', 'FIRST_CLASS')
|
||||
)
|
||||
)
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
db.update("folders", contentValuesOf("push_enabled" to true), whereClause, null)
|
||||
}
|
||||
|
||||
private fun removePushClassColumn() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
dropPushClassColumn()
|
||||
} else {
|
||||
clearPushClassColumn()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropPushClassColumn() {
|
||||
db.execSQL("ALTER TABLE folders DROP COLUMN push_class")
|
||||
}
|
||||
|
||||
private fun clearPushClassColumn() {
|
||||
db.update("folders", contentValuesOf("push_class" to null), null, null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Build
|
||||
import androidx.core.content.contentValuesOf
|
||||
import com.fsck.k9.mailstore.MigrationsHelper
|
||||
import net.thunderbird.core.android.account.FolderMode
|
||||
|
||||
internal class MigrationTo87(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
|
||||
fun addFoldersSyncEnabledColumn() {
|
||||
addColumn()
|
||||
populateColumn()
|
||||
|
||||
removePollClassColumn()
|
||||
}
|
||||
|
||||
private fun addColumn() {
|
||||
db.execSQL("ALTER TABLE folders ADD sync_enabled INTEGER DEFAULT 0")
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun populateColumn() {
|
||||
val account = migrationsHelper.account
|
||||
|
||||
val whereClause = when (account.folderSyncMode) {
|
||||
FolderMode.NONE -> {
|
||||
return
|
||||
}
|
||||
FolderMode.ALL -> {
|
||||
""
|
||||
}
|
||||
FolderMode.FIRST_CLASS -> {
|
||||
"""
|
||||
poll_class = 'FIRST_CLASS' OR (
|
||||
poll_class = 'INHERITED' AND display_class = 'FIRST_CLASS'
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
FolderMode.FIRST_AND_SECOND_CLASS -> {
|
||||
"""
|
||||
poll_class IN ('FIRST_CLASS', 'SECOND_CLASS') OR (
|
||||
poll_class = 'INHERITED' AND display_class IN ('FIRST_CLASS', 'SECOND_CLASS')
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
FolderMode.NOT_SECOND_CLASS -> {
|
||||
"""
|
||||
poll_class IN ('NO_CLASS', 'FIRST_CLASS') OR (
|
||||
poll_class = 'INHERITED' AND display_class IN ('NO_CLASS', 'FIRST_CLASS')
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
db.update("folders", contentValuesOf("sync_enabled" to true), whereClause, null)
|
||||
}
|
||||
|
||||
private fun removePollClassColumn() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
dropPollClassColumn()
|
||||
} else {
|
||||
clearPollClassColumn()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropPollClassColumn() {
|
||||
db.execSQL("ALTER TABLE folders DROP COLUMN poll_class")
|
||||
}
|
||||
|
||||
private fun clearPollClassColumn() {
|
||||
db.update("folders", contentValuesOf("poll_class" to null), null, null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Build
|
||||
import androidx.core.content.contentValuesOf
|
||||
import com.fsck.k9.mailstore.MigrationsHelper
|
||||
import net.thunderbird.core.android.account.FolderMode
|
||||
|
||||
internal class MigrationTo88(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) {
|
||||
fun addFoldersVisibleColumn() {
|
||||
addColumn()
|
||||
populateColumn()
|
||||
|
||||
removeDisplayClassColumn()
|
||||
}
|
||||
|
||||
private fun addColumn() {
|
||||
db.execSQL("ALTER TABLE folders ADD visible INTEGER DEFAULT 1")
|
||||
}
|
||||
|
||||
private fun populateColumn() {
|
||||
val account = migrationsHelper.account
|
||||
|
||||
// The default is for folders to be visible. So we only update folders that should be hidden.
|
||||
val whereClause = when (account.folderDisplayMode) {
|
||||
FolderMode.NONE -> {
|
||||
""
|
||||
}
|
||||
FolderMode.ALL -> {
|
||||
return
|
||||
}
|
||||
FolderMode.FIRST_CLASS -> {
|
||||
"display_class != 'FIRST_CLASS'"
|
||||
}
|
||||
FolderMode.FIRST_AND_SECOND_CLASS -> {
|
||||
"display_class NOT IN ('FIRST_CLASS', 'SECOND_CLASS')"
|
||||
}
|
||||
FolderMode.NOT_SECOND_CLASS -> {
|
||||
"display_class = 'SECOND_CLASS'"
|
||||
}
|
||||
}
|
||||
|
||||
db.update("folders", contentValuesOf("visible" to false), whereClause, null)
|
||||
}
|
||||
|
||||
private fun removeDisplayClassColumn() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
dropDisplayClassColumn()
|
||||
} else {
|
||||
clearDisplayClassColumn()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropDisplayClassColumn() {
|
||||
db.execSQL("ALTER TABLE folders DROP COLUMN display_class")
|
||||
}
|
||||
|
||||
private fun clearDisplayClassColumn() {
|
||||
db.update("folders", contentValuesOf("display_class" to null), null, null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.oauth.AuthStateStorage
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProviderFactory
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import com.fsck.k9.mail.store.imap.ImapClientInfo
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreConfig
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreFactory
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix
|
||||
import com.fsck.k9.mailstore.MigrationsHelper
|
||||
import net.thunderbird.core.android.account.Expunge
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.common.mail.Protocols
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import okio.IOException
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
private const val TAG = "MigrationTo90"
|
||||
|
||||
internal class MigrationTo90(
|
||||
private val db: SQLiteDatabase,
|
||||
private val migrationsHelper: MigrationsHelper,
|
||||
private val logger: Logger = Log,
|
||||
private val imapStoreFactory: ImapStoreFactory = ImapStore.Companion,
|
||||
) : KoinComponent {
|
||||
private val trustedSocketFactory: TrustedSocketFactory by inject()
|
||||
private val clientInfoAppName: String by inject(named("ClientInfoAppName"))
|
||||
private val clientInfoAppVersion: String by inject(named("ClientInfoAppVersion"))
|
||||
private val oAuth2TokenProviderFactory: OAuth2TokenProviderFactory by inject()
|
||||
|
||||
fun removeImapPrefixFromFolderServerId() {
|
||||
val account = migrationsHelper.account
|
||||
if (account.incomingServerSettings.type != Protocols.IMAP) {
|
||||
logger.verbose(TAG) {
|
||||
"account ${account.uuid} is not an IMAP account, skipping db migration for this account."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logger.verbose(TAG) { "started db migration to version 90 to account ${account.uuid}" }
|
||||
|
||||
val imapStore = createImapStore(account)
|
||||
|
||||
try {
|
||||
logger.verbose(TAG) { "fetching IMAP prefix" }
|
||||
imapStore.fetchImapPrefix()
|
||||
val imapPrefix = imapStore.combinedPrefix
|
||||
|
||||
if (imapPrefix?.isNotBlank() == true) {
|
||||
logger.verbose(TAG) { "Imap Prefix ($imapPrefix) detected, updating folder's server_id" }
|
||||
val query = buildQuery(imapPrefix)
|
||||
db.execSQL(query)
|
||||
} else {
|
||||
logger.verbose(TAG) { "No Imap Prefix detected, skipping db migration" }
|
||||
}
|
||||
|
||||
logger.verbose(TAG) { "completed db migration to version 90 for account ${account.uuid}" }
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
logger.warn(TAG, e) {
|
||||
"failed to fetch IMAP prefix due to authentication error. skipping db migration"
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
logger.warn(TAG, e) {
|
||||
"failed to fetch IMAP prefix due to network error. skipping db migration"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createImapStore(account: LegacyAccount): ImapStore {
|
||||
val serverSettings = account.toImapServerSettings()
|
||||
val oAuth2TokenProvider = if (serverSettings.authenticationType == AuthType.XOAUTH2) {
|
||||
val authStateStorage = object : AuthStateStorage {
|
||||
override fun getAuthorizationState(): String? = account.oAuthState
|
||||
override fun updateAuthorizationState(authorizationState: String?) = Unit
|
||||
}
|
||||
oAuth2TokenProviderFactory.create(authStateStorage)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return imapStoreFactory.create(
|
||||
serverSettings = serverSettings,
|
||||
config = createImapStoreConfig(account),
|
||||
trustedSocketFactory = trustedSocketFactory,
|
||||
oauthTokenProvider = oAuth2TokenProvider,
|
||||
)
|
||||
}
|
||||
|
||||
private fun LegacyAccount.toImapServerSettings(): ServerSettings {
|
||||
val serverSettings = incomingServerSettings
|
||||
return serverSettings.copy(
|
||||
extra = ImapStoreSettings.createExtra(
|
||||
autoDetectNamespace = serverSettings.autoDetectNamespace,
|
||||
pathPrefix = serverSettings.pathPrefix,
|
||||
useCompression = useCompression,
|
||||
sendClientInfo = isSendClientInfoEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createImapStoreConfig(account: LegacyAccount): ImapStoreConfig {
|
||||
return object : ImapStoreConfig {
|
||||
override val logLabel
|
||||
get() = account.uuid
|
||||
|
||||
override fun isSubscribedFoldersOnly() = account.isSubscribedFoldersOnly
|
||||
|
||||
override fun isExpungeImmediately() = account.expungePolicy == Expunge.EXPUNGE_IMMEDIATELY
|
||||
|
||||
override fun clientInfo() = ImapClientInfo(appName = clientInfoAppName, appVersion = clientInfoAppVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@Language("RoomSql")
|
||||
@VisibleForTesting
|
||||
internal fun buildQuery(imapPrefix: String): String {
|
||||
return """
|
||||
|UPDATE folders
|
||||
| SET server_id = REPLACE(server_id, '$imapPrefix', '')
|
||||
|WHERE
|
||||
| server_id IS NOT NULL
|
||||
| AND server_id LIKE '$imapPrefix%'
|
||||
| AND type <> 'outbox'
|
||||
| AND local_only <> 1
|
||||
""".trimMargin()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fsck.k9.storage.migrations
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fsck.k9.mailstore.MigrationsHelper
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
object Migrations {
|
||||
@JvmStatic
|
||||
fun upgradeDatabase(db: SQLiteDatabase, migrationsHelper: MigrationsHelper) {
|
||||
val oldVersion = db.version
|
||||
|
||||
if (oldVersion < 62) MigrationTo62.addServerIdColumnToFoldersTable(db)
|
||||
if (oldVersion < 64) MigrationTo64.addExtraValuesTables(db)
|
||||
if (oldVersion < 65) MigrationTo65.addLocalOnlyColumnToFoldersTable(db, migrationsHelper)
|
||||
if (oldVersion < 66) MigrationTo66.addEncryptionTypeColumnToMessagesTable(db)
|
||||
if (oldVersion < 67) MigrationTo67.addTypeColumnToFoldersTable(db, migrationsHelper)
|
||||
if (oldVersion < 68) MigrationTo68.addOutboxStateTable(db)
|
||||
if (oldVersion < 69) MigrationTo69(db).createPendingDelete()
|
||||
if (oldVersion < 70) MigrationTo70(db).removePushState()
|
||||
if (oldVersion < 71) MigrationTo71(db).cleanUpFolderClass()
|
||||
if (oldVersion < 72) MigrationTo72(db).createMessagePartsRootIndex()
|
||||
if (oldVersion < 73) MigrationTo73(db).rewritePendingCommandsToUseFolderIds()
|
||||
if (oldVersion < 74) MigrationTo74(db, migrationsHelper.account).removeDeletedMessages()
|
||||
if (oldVersion < 75) MigrationTo75(db, migrationsHelper).updateAccountWithSpecialFolderIds()
|
||||
if (oldVersion < 76) MigrationTo76(db, migrationsHelper).cleanUpSpecialLocalFolders()
|
||||
// 77: No longer necessary
|
||||
if (oldVersion < 78) MigrationTo78(db).removeServerIdFromLocalFolders()
|
||||
if (oldVersion < 79) MigrationTo79(db).updateDeleteMessageTrigger()
|
||||
if (oldVersion < 80) MigrationTo80(db).rewriteLastUpdatedColumn()
|
||||
if (oldVersion < 81) MigrationTo81(db).addNotificationsTable()
|
||||
if (oldVersion < 82) MigrationTo82(db).addNewMessageColumn()
|
||||
if (oldVersion < 83) MigrationTo83(db, migrationsHelper).rewriteHighestKnownUid()
|
||||
if (oldVersion < 84) MigrationTo84(db).rewriteAddresses()
|
||||
if (oldVersion < 85) MigrationTo85(db, migrationsHelper).addFoldersNotificationsEnabledColumn()
|
||||
if (oldVersion < 86) MigrationTo86(db, migrationsHelper).addFoldersPushEnabledColumn()
|
||||
if (oldVersion < 87) MigrationTo87(db, migrationsHelper).addFoldersSyncEnabledColumn()
|
||||
if (oldVersion < 88) MigrationTo88(db, migrationsHelper).addFoldersVisibleColumn()
|
||||
if (oldVersion < 90) MigrationTo90(db, migrationsHelper).removeImapPrefixFromFolderServerId()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.fsck.k9.storage.notifications
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import app.k9mail.legacy.message.controller.MessageReference
|
||||
import com.fsck.k9.mailstore.LockableDatabase
|
||||
import com.fsck.k9.notification.NotificationStore
|
||||
import com.fsck.k9.notification.NotificationStoreOperation
|
||||
|
||||
class K9NotificationStore(private val lockableDatabase: LockableDatabase) : NotificationStore {
|
||||
override fun persistNotificationChanges(operations: List<NotificationStoreOperation>) {
|
||||
lockableDatabase.execute(true) { db ->
|
||||
for (operation in operations) {
|
||||
when (operation) {
|
||||
is NotificationStoreOperation.Add -> {
|
||||
addNotification(db, operation.messageReference, operation.notificationId, operation.timestamp)
|
||||
}
|
||||
is NotificationStoreOperation.ChangeToActive -> {
|
||||
setNotificationId(db, operation.messageReference, operation.notificationId)
|
||||
}
|
||||
is NotificationStoreOperation.ChangeToInactive -> {
|
||||
clearNotificationId(db, operation.messageReference)
|
||||
}
|
||||
is NotificationStoreOperation.Remove -> {
|
||||
removeNotification(db, operation.messageReference)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearNotifications() {
|
||||
lockableDatabase.execute(false) { db ->
|
||||
db.delete("notifications", null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addNotification(
|
||||
database: SQLiteDatabase,
|
||||
messageReference: MessageReference,
|
||||
notificationId: Int,
|
||||
timestamp: Long,
|
||||
) {
|
||||
database.execSQL(
|
||||
"INSERT INTO notifications(message_id, notification_id, timestamp) " +
|
||||
"SELECT id, ?, ? FROM messages WHERE folder_id = ? AND uid = ?",
|
||||
arrayOf(notificationId, timestamp, messageReference.folderId, messageReference.uid),
|
||||
)
|
||||
}
|
||||
|
||||
private fun setNotificationId(database: SQLiteDatabase, messageReference: MessageReference, notificationId: Int) {
|
||||
database.execSQL(
|
||||
"UPDATE notifications SET notification_id = ? WHERE message_id IN " +
|
||||
"(SELECT id FROM messages WHERE folder_id = ? AND uid = ?)",
|
||||
arrayOf(notificationId, messageReference.folderId, messageReference.uid),
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearNotificationId(database: SQLiteDatabase, messageReference: MessageReference) {
|
||||
database.execSQL(
|
||||
"UPDATE notifications SET notification_id = NULL WHERE message_id IN " +
|
||||
"(SELECT id FROM messages WHERE folder_id = ? AND uid = ?)",
|
||||
arrayOf(messageReference.folderId, messageReference.uid),
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeNotification(database: SQLiteDatabase, messageReference: MessageReference) {
|
||||
database.execSQL(
|
||||
"DELETE FROM notifications WHERE message_id IN (SELECT id FROM messages WHERE folder_id = ? AND uid = ?)",
|
||||
arrayOf(messageReference.folderId, messageReference.uid),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.fsck.k9.storage.notifications
|
||||
|
||||
import com.fsck.k9.mailstore.LocalStoreProvider
|
||||
import com.fsck.k9.notification.NotificationStore
|
||||
import com.fsck.k9.notification.NotificationStoreProvider
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
class K9NotificationStoreProvider(private val localStoreProvider: LocalStoreProvider) : NotificationStoreProvider {
|
||||
override fun getNotificationStore(account: LegacyAccount): NotificationStore {
|
||||
val localStore = localStoreProvider.getInstance(account)
|
||||
return K9NotificationStore(lockableDatabase = localStore.database)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.fsck.k9.preferences
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
private const val TABLE_NAME = "preferences_storage"
|
||||
private const val PRIMARY_KEY_COLUMN = "primkey"
|
||||
private const val VALUE_COLUMN = "value"
|
||||
|
||||
fun createPreferencesDatabase(): SQLiteDatabase {
|
||||
val database = SQLiteDatabase.create(null)
|
||||
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$PRIMARY_KEY_COLUMN TEXT PRIMARY KEY ON CONFLICT REPLACE,
|
||||
$VALUE_COLUMN TEXT
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
return database
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
package com.fsck.k9.preferences
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback
|
||||
import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations
|
||||
import com.fsck.k9.storage.K9RobolectricTest
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import net.thunderbird.core.preference.storage.InMemoryStorage
|
||||
import net.thunderbird.core.preference.storage.Storage
|
||||
import net.thunderbird.core.preference.storage.StorageUpdater
|
||||
import org.junit.Test
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.doThrow
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stubbing
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.verifyNoMoreInteractions
|
||||
|
||||
class DefaultStorageEditorTest : K9RobolectricTest() {
|
||||
|
||||
private val logger = TestLogger()
|
||||
private val storage: Storage =
|
||||
InMemoryStorage(mapOf("storage-key" to "storage-value"), logger)
|
||||
private val storageUpdater = TestStorageUpdater(storage)
|
||||
private val storagePersister = mock<K9StoragePersister>()
|
||||
private val storagePersisterOps = mock<StoragePersistOperations>()
|
||||
private val editor = K9StorageEditor(storageUpdater, storagePersister, logger)
|
||||
|
||||
private val workingMap = mutableMapOf<String, String>()
|
||||
|
||||
private val newValues: Map<String, String>
|
||||
get() = storageUpdater.newStorage!!.getAll()
|
||||
|
||||
@Test
|
||||
fun commit_exception() {
|
||||
stubbing(storagePersister) {
|
||||
on { doInTransaction(any()) } doThrow RuntimeException()
|
||||
}
|
||||
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commit_trivial() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEqualTo(storage.getAll())
|
||||
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putBoolean() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
editor.putBoolean("x", true)
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "true"))
|
||||
verify(storagePersisterOps).put("x", "true")
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putInt() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
editor.putInt("x", 123)
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "123"))
|
||||
verify(storagePersisterOps).put("x", "123")
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putLong() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
editor.putLong("x", 1234)
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "1234"))
|
||||
verify(storagePersisterOps).put("x", "1234")
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putString() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
editor.putString("x", "y")
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "y"))
|
||||
verify(storagePersisterOps).put("x", "y")
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putString_duplicateSame() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
editor.putString("storage-key", "storage-value")
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value"))
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putString_duplicateOther() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
editor.putString("storage-key", "other-value")
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEqualTo(mapOf("storage-key" to "other-value"))
|
||||
verify(storagePersisterOps).put("storage-key", "other-value")
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putString_removedDuplicate() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
editor.remove("storage-key")
|
||||
editor.putString("storage-key", "storage-value")
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value"))
|
||||
verify(storagePersisterOps).remove("storage-key")
|
||||
verify(storagePersisterOps).put("storage-key", "storage-value")
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove key that doesn't exist`() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
editor.remove("x")
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value"))
|
||||
verify(storagePersisterOps).remove("x")
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remove() {
|
||||
prepareStoragePersisterMock()
|
||||
|
||||
editor.remove("storage-key")
|
||||
val success = editor.commit()
|
||||
|
||||
assertThat(success).isTrue()
|
||||
assertThat(newValues).isEmpty()
|
||||
verify(storagePersisterOps).remove("storage-key")
|
||||
verifyNoMoreInteractions(storagePersisterOps)
|
||||
}
|
||||
|
||||
private fun prepareStoragePersisterMock() {
|
||||
stubbing(storagePersisterOps) {
|
||||
on { put(any(), any()) } doAnswer {
|
||||
val key = it.getArgument<String>(0)
|
||||
val value = it.getArgument<String>(1)
|
||||
workingMap[key] = value
|
||||
}
|
||||
|
||||
on { remove(any()) } doAnswer {
|
||||
val key = it.getArgument<String>(0)
|
||||
workingMap.remove(key)
|
||||
return@doAnswer
|
||||
}
|
||||
}
|
||||
|
||||
stubbing(storagePersister) {
|
||||
on { doInTransaction(any()) } doAnswer {
|
||||
val operationCallback = it.getArgument<StoragePersistOperationCallback>(0)
|
||||
|
||||
operationCallback.beforePersistTransaction(workingMap)
|
||||
operationCallback.persist(storagePersisterOps)
|
||||
operationCallback.onPersistTransactionSuccess(workingMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TestStorageUpdater(private val currentStorage: Storage) : StorageUpdater {
|
||||
var newStorage: Storage? = null
|
||||
|
||||
override fun updateStorage(updater: (currentStorage: Storage) -> Storage) {
|
||||
newStorage = updater(currentStorage)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue