diff --git a/.editorconfig b/.editorconfig index b759ad0601c3f5b6223ba4ab2045d2483c35979c..73a670c902dda2ec2ee795cc1b6e3f74507fad1b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,2 +1,2 @@ [*.{kt,kts}] -kotlin_imports_layout = *,^* +ij_kotlin_imports_layout = *,^* diff --git a/app/autodiscovery/api/build.gradle b/app/autodiscovery/api/build.gradle index 55914b0cc00c4b6fa9b1cff67914897bdd7b8ae7..ce98344e01023ac59477b8984ffdc040b642cb88 100644 --- a/app/autodiscovery/api/build.gradle +++ b/app/autodiscovery/api/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' +apply plugin: 'com.android.lint' java { sourceCompatibility = javaVersion diff --git a/app/autodiscovery/srvrecords/build.gradle b/app/autodiscovery/srvrecords/build.gradle index 36a5976850f6e10665675d80e7fb26ccbe7a72b0..a0574af3194b6a2611bcb1bcda3b9679b5324288 100644 --- a/app/autodiscovery/srvrecords/build.gradle +++ b/app/autodiscovery/srvrecords/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' +apply plugin: 'com.android.lint' java { sourceCompatibility = javaVersion diff --git a/app/autodiscovery/thunderbird/build.gradle b/app/autodiscovery/thunderbird/build.gradle index dc48d2c50f1b6808269bcffc252fc95629292080..8e280484893c403642b12c2a6b4df658023e2d16 100644 --- a/app/autodiscovery/thunderbird/build.gradle +++ b/app/autodiscovery/thunderbird/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' +apply plugin: 'com.android.lint' java { sourceCompatibility = javaVersion diff --git a/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt b/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt index 2483fbeb9b76e2f4ebcc06aedef76975a0513a74..e827460425df7812d20e08dc42929e8148fee139 100644 --- a/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt +++ b/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt @@ -523,11 +523,11 @@ class AccountPreferenceSerializer( defaultEnum } else { try { - java.lang.Enum.valueOf(defaultEnum.declaringClass, stringPref) + java.lang.Enum.valueOf(defaultEnum.declaringJavaClass, stringPref) } catch (ex: IllegalArgumentException) { Timber.w( ex, "Unable to convert preference key [%s] value [%s] to enum of type %s", - key, stringPref, defaultEnum.declaringClass + key, stringPref, defaultEnum.declaringJavaClass ) defaultEnum diff --git a/app/core/src/main/java/com/fsck/k9/Core.kt b/app/core/src/main/java/com/fsck/k9/Core.kt index 7282a86526de2dc9da6e27d4d8ddc2e16e66b0e5..75e8201ec3938ee41528fd4e4a46fe576c28ff49 100644 --- a/app/core/src/main/java/com/fsck/k9/Core.kt +++ b/app/core/src/main/java/com/fsck/k9/Core.kt @@ -45,7 +45,7 @@ object Core : EarlyInit { @JvmStatic fun setServicesEnabled(context: Context) { val appContext = context.applicationContext - val acctLength = Preferences.getPreferences(appContext).accounts.size + val acctLength = Preferences.getPreferences().accounts.size val enable = acctLength > 0 setServicesEnabled(appContext, enable) diff --git a/app/core/src/main/java/com/fsck/k9/LocalKeyStoreManager.kt b/app/core/src/main/java/com/fsck/k9/LocalKeyStoreManager.kt index c07364668968e32747d84784a7fde6829f65cffb..4933879bdaa48718bf1c15b1ec1992152fa9957b 100644 --- a/app/core/src/main/java/com/fsck/k9/LocalKeyStoreManager.kt +++ b/app/core/src/main/java/com/fsck/k9/LocalKeyStoreManager.kt @@ -48,11 +48,11 @@ class LocalKeyStoreManager( * certificates for the incoming and outgoing servers. */ fun deleteCertificates(account: Account) { - account.incomingServerSettings?.let { serverSettings -> + account.incomingServerSettings.let { serverSettings -> localKeyStore.deleteCertificate(serverSettings.host!!, serverSettings.port) } - account.outgoingServerSettings?.let { serverSettings -> + account.outgoingServerSettings.let { serverSettings -> localKeyStore.deleteCertificate(serverSettings.host!!, serverSettings.port) } } diff --git a/app/core/src/main/java/com/fsck/k9/Preferences.kt b/app/core/src/main/java/com/fsck/k9/Preferences.kt index 4120025c893ce12826fbbaae503c25688a9482d0..7e66d18f85a32b87588b3f0038f855898e38fc2c 100644 --- a/app/core/src/main/java/com/fsck/k9/Preferences.kt +++ b/app/core/src/main/java/com/fsck/k9/Preferences.kt @@ -1,6 +1,5 @@ package com.fsck.k9 -import android.content.Context import androidx.annotation.GuardedBy import androidx.annotation.RestrictTo import com.fsck.k9.mail.MessagingException @@ -291,8 +290,8 @@ class Preferences internal constructor( companion object { @JvmStatic - fun getPreferences(context: Context): Preferences { - return DI.get(Preferences::class.java) + fun getPreferences(): Preferences { + return DI.get() } } } diff --git a/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCache.java b/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCache.java deleted file mode 100644 index cc12fde40c7c7b33e93bb4e342d971f2e7f04834..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCache.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.fsck.k9.cache; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import android.content.Context; -import android.net.Uri; - -import com.fsck.k9.mailstore.LocalMessage; -import com.fsck.k9.provider.EmailProvider; - -/** - * Cache to bridge the time needed to write (user-initiated) changes to the database. - */ -public class EmailProviderCache { - private static Context sContext; - private static Map sInstances = - new HashMap<>(); - - public static synchronized EmailProviderCache getCache(String accountUuid, Context context) { - - if (sContext == null) { - sContext = context.getApplicationContext(); - } - - EmailProviderCache instance = sInstances.get(accountUuid); - if (instance == null) { - instance = new EmailProviderCache(accountUuid); - sInstances.put(accountUuid, instance); - } - - return instance; - } - - - private String mAccountUuid; - private final Map> mMessageCache = new HashMap<>(); - private final Map> mThreadCache = new HashMap<>(); - private final Map mHiddenMessageCache = new HashMap<>(); - - - private EmailProviderCache(String accountUuid) { - mAccountUuid = accountUuid; - } - - public String getValueForMessage(Long messageId, String columnName) { - synchronized (mMessageCache) { - Map map = mMessageCache.get(messageId); - return (map == null) ? null : map.get(columnName); - } - } - - public String getValueForThread(Long threadRootId, String columnName) { - synchronized (mThreadCache) { - Map map = mThreadCache.get(threadRootId); - return (map == null) ? null : map.get(columnName); - } - } - - public void setValueForMessages(List messageIds, String columnName, String value) { - synchronized (mMessageCache) { - for (Long messageId : messageIds) { - Map map = mMessageCache.get(messageId); - if (map == null) { - map = new HashMap<>(); - mMessageCache.put(messageId, map); - } - map.put(columnName, value); - } - } - - notifyChange(); - } - - public void setValueForThreads(List threadRootIds, String columnName, String value) { - synchronized (mThreadCache) { - for (Long threadRootId : threadRootIds) { - Map map = mThreadCache.get(threadRootId); - if (map == null) { - map = new HashMap<>(); - mThreadCache.put(threadRootId, map); - } - map.put(columnName, value); - } - } - - notifyChange(); - } - - public void removeValueForMessages(List messageIds, String columnName) { - synchronized (mMessageCache) { - for (Long messageId : messageIds) { - Map map = mMessageCache.get(messageId); - if (map != null) { - map.remove(columnName); - if (map.isEmpty()) { - mMessageCache.remove(messageId); - } - } - } - } - } - - public void removeValueForThreads(List threadRootIds, String columnName) { - synchronized (mThreadCache) { - for (Long threadRootId : threadRootIds) { - Map map = mThreadCache.get(threadRootId); - if (map != null) { - map.remove(columnName); - if (map.isEmpty()) { - mThreadCache.remove(threadRootId); - } - } - } - } - } - - public void hideMessages(List messages) { - synchronized (mHiddenMessageCache) { - for (LocalMessage message : messages) { - long messageId = message.getDatabaseId(); - mHiddenMessageCache.put(messageId, message.getFolder().getDatabaseId()); - } - } - - notifyChange(); - } - - public boolean isMessageHidden(Long messageId, long folderId) { - synchronized (mHiddenMessageCache) { - Long hiddenInFolder = mHiddenMessageCache.get(messageId); - return (hiddenInFolder != null && hiddenInFolder == folderId); - } - } - - public void unhideMessages(List messages) { - synchronized (mHiddenMessageCache) { - for (LocalMessage message : messages) { - long messageId = message.getDatabaseId(); - long folderId = message.getFolder().getDatabaseId(); - Long hiddenInFolder = mHiddenMessageCache.get(messageId); - - if (hiddenInFolder != null && hiddenInFolder == folderId) { - mHiddenMessageCache.remove(messageId); - } - } - } - } - - private void notifyChange() { - Uri uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/" + mAccountUuid + - "/messages"); - sContext.getContentResolver().notifyChange(uri, null); - } -} diff --git a/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCacheCursor.java b/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCacheCursor.java deleted file mode 100644 index e695eea7fbb1cc4d0edd7e967468147fa87fab66..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/cache/EmailProviderCacheCursor.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.fsck.k9.cache; - -import java.util.ArrayList; -import java.util.List; - -import com.fsck.k9.provider.EmailProvider.MessageColumns; -import com.fsck.k9.provider.EmailProvider.ThreadColumns; - -import android.content.Context; -import android.database.Cursor; -import android.database.CursorWrapper; - -/** - * A {@link CursorWrapper} that utilizes {@link EmailProviderCache}. - */ -public class EmailProviderCacheCursor extends CursorWrapper { - private EmailProviderCache mCache; - private List mHiddenRows = new ArrayList<>(); - private int mMessageIdColumn; - private int mFolderIdColumn; - private int mThreadRootColumn; - - /** - * The cursor's current position. - * - * Note: This is only used when {@link #mHiddenRows} isn't empty. - */ - private int mPosition; - - - public EmailProviderCacheCursor(String accountUuid, Cursor cursor, Context context) { - super(cursor); - - mCache = EmailProviderCache.getCache(accountUuid, context); - - mMessageIdColumn = cursor.getColumnIndex(MessageColumns.ID); - mFolderIdColumn = cursor.getColumnIndex(MessageColumns.FOLDER_ID); - mThreadRootColumn = cursor.getColumnIndex(ThreadColumns.ROOT); - - if (mMessageIdColumn == -1 || mFolderIdColumn == -1 || mThreadRootColumn == -1) { - throw new IllegalArgumentException("The supplied cursor needs to contain the " + - "following columns: " + MessageColumns.ID + ", " + MessageColumns.FOLDER_ID + - ", " + ThreadColumns.ROOT); - } - - while (cursor.moveToNext()) { - long messageId = cursor.getLong(mMessageIdColumn); - long folderId = cursor.getLong(mFolderIdColumn); - if (mCache.isMessageHidden(messageId, folderId)) { - mHiddenRows.add(cursor.getPosition()); - } - } - - // Reset the cursor position - cursor.moveToFirst(); - cursor.moveToPrevious(); - } - - @Override - public int getInt(int columnIndex) { - long messageId = getLong(mMessageIdColumn); - long threadRootId = getLong(mThreadRootColumn); - - String columnName = getColumnName(columnIndex); - String value = mCache.getValueForMessage(messageId, columnName); - - if (value != null) { - return Integer.parseInt(value); - } - - value = mCache.getValueForThread(threadRootId, columnName); - if (value != null) { - return Integer.parseInt(value); - } - - return super.getInt(columnIndex); - } - - @Override - public int getCount() { - return super.getCount() - mHiddenRows.size(); - } - - @Override - public boolean moveToFirst() { - return moveToPosition(0); - } - - @Override - public boolean moveToLast() { - return moveToPosition(getCount()); - } - - @Override - public boolean moveToNext() { - return moveToPosition(getPosition() + 1); - } - - @Override - public boolean moveToPrevious() { - return moveToPosition(getPosition() - 1); - } - - @Override - public boolean move(int offset) { - return moveToPosition(getPosition() + offset); - } - - @Override - public boolean moveToPosition(int position) { - if (mHiddenRows.isEmpty()) { - return super.moveToPosition(position); - } - - mPosition = position; - int newPosition = position; - for (int hiddenRow : mHiddenRows) { - if (hiddenRow > newPosition) { - break; - } - newPosition++; - } - - return super.moveToPosition(newPosition); - } - - @Override - public int getPosition() { - if (mHiddenRows.isEmpty()) { - return super.getPosition(); - } - - return mPosition; - } - - @Override - public boolean isLast() { - if (mHiddenRows.isEmpty()) { - return super.isLast(); - } - - return (mPosition == getCount() - 1); - } -} diff --git a/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java b/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java index 8419ef4ff4ced6a6d2e1a41816916a70c31b383c..b288ab96dfd120e690f555d09265adfe0a6f7ba6 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java +++ b/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java @@ -40,7 +40,6 @@ import com.fsck.k9.backend.api.Backend; import com.fsck.k9.backend.api.BuildConfig; import com.fsck.k9.backend.api.SyncConfig; import com.fsck.k9.backend.api.SyncListener; -import com.fsck.k9.cache.EmailProviderCache; import com.fsck.k9.controller.ControllerExtension.ControllerInternals; import com.fsck.k9.controller.MessagingControllerCommands.PendingAppend; import com.fsck.k9.controller.MessagingControllerCommands.PendingCommand; @@ -72,6 +71,7 @@ import com.fsck.k9.mailstore.LocalFolder; import com.fsck.k9.mailstore.LocalMessage; import com.fsck.k9.mailstore.LocalStore; import com.fsck.k9.mailstore.LocalStoreProvider; +import com.fsck.k9.mailstore.MessageListCache; import com.fsck.k9.mailstore.MessageStore; import com.fsck.k9.mailstore.MessageStoreManager; import com.fsck.k9.mailstore.OutboxState; @@ -324,12 +324,12 @@ public class MessagingController { private void suppressMessages(Account account, List messages) { - EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); + MessageListCache cache = MessageListCache.getCache(account.getUuid()); cache.hideMessages(messages); } private void unsuppressMessages(Account account, List messages) { - EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); + MessageListCache cache = MessageListCache.getCache(account.getUuid()); cache.unhideMessages(messages); } @@ -337,42 +337,36 @@ public class MessagingController { long messageId = message.getDatabaseId(); long folderId = message.getFolder().getDatabaseId(); - EmailProviderCache cache = EmailProviderCache.getCache(message.getFolder().getAccountUuid(), context); + MessageListCache cache = MessageListCache.getCache(message.getFolder().getAccountUuid()); return cache.isMessageHidden(messageId, folderId); } private void setFlagInCache(final Account account, final List messageIds, final Flag flag, final boolean newState) { - EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); - String columnName = LocalStore.getColumnNameForFlag(flag); - String value = Integer.toString((newState) ? 1 : 0); - cache.setValueForMessages(messageIds, columnName, value); + MessageListCache cache = MessageListCache.getCache(account.getUuid()); + cache.setFlagForMessages(messageIds, flag, newState); } private void removeFlagFromCache(final Account account, final List messageIds, final Flag flag) { - EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); - String columnName = LocalStore.getColumnNameForFlag(flag); - cache.removeValueForMessages(messageIds, columnName); + MessageListCache cache = MessageListCache.getCache(account.getUuid()); + cache.removeFlagForMessages(messageIds, flag); } private void setFlagForThreadsInCache(final Account account, final List threadRootIds, final Flag flag, final boolean newState) { - EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); - String columnName = LocalStore.getColumnNameForFlag(flag); - String value = Integer.toString((newState) ? 1 : 0); - cache.setValueForThreads(threadRootIds, columnName, value); + MessageListCache cache = MessageListCache.getCache(account.getUuid()); + cache.setValueForThreads(threadRootIds, flag, newState); } private void removeFlagForThreadsFromCache(final Account account, final List messageIds, final Flag flag) { - EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); - String columnName = LocalStore.getColumnNameForFlag(flag); - cache.removeValueForThreads(messageIds, columnName); + MessageListCache cache = MessageListCache.getCache(account.getUuid()); + cache.removeFlagForThreads(messageIds, flag); } public void refreshFolderList(final Account account) { @@ -1529,7 +1523,6 @@ public class MessagingController { @VisibleForTesting protected void sendPendingMessagesSynchronous(final Account account) { Exception lastFailure = null; - boolean wasPermanentFailure = false; try { if (isAuthenticationProblem(account, false)) { Timber.d("Authentication will fail. Skip sending messages."); @@ -1579,10 +1572,16 @@ public class MessagingController { long messageId = message.getDatabaseId(); OutboxState outboxState = outboxStateRepository.getOutboxState(messageId); - if (outboxState.getSendState() != SendState.READY) { - Timber.v("Skipping sending message %s", message.getUid()); - notificationController.showSendFailedNotification(account, - new MessagingException(message.getSubject())); + SendState sendState = outboxState.getSendState(); + if (sendState != SendState.READY) { + Timber.v("Skipping sending message %s (reason: %s)", message.getUid(), + sendState.getDatabaseName()); + + if (sendState == SendState.RETRIES_EXCEEDED) { + lastFailure = new MessagingException("Retries exceeded", true); + } else { + lastFailure = new MessagingException(outboxState.getSendError(), true); + } continue; } @@ -1619,22 +1618,19 @@ public class MessagingController { } catch (AuthenticationFailedException e) { outboxStateRepository.decrementSendAttempts(messageId); lastFailure = e; - wasPermanentFailure = false; handleAuthenticationFailure(account, false, e); handleSendFailure(account, localFolder, message, e); } catch (CertificateValidationException e) { outboxStateRepository.decrementSendAttempts(messageId); lastFailure = e; - wasPermanentFailure = false; notifyUserIfCertificateProblem(account, e, false); handleSendFailure(account, localFolder, message, e); } catch (MessagingException e) { lastFailure = e; - wasPermanentFailure = e.isPermanentFailure(); - if (wasPermanentFailure) { + if (e.isPermanentFailure()) { String errorMessage = e.getMessage(); outboxStateRepository.setSendAttemptError(messageId, errorMessage); } else if (outboxState.getNumberOfSendAttempts() + 1 >= MAX_SEND_ATTEMPTS) { @@ -1644,24 +1640,19 @@ public class MessagingController { handleSendFailure(account, localFolder, message, e); } catch (Exception e) { lastFailure = e; - wasPermanentFailure = true; handleSendFailure(account, localFolder, message, e); } } catch (Exception e) { lastFailure = e; - wasPermanentFailure = false; + Timber.e(e, "Failed to fetch message for sending"); notifySynchronizeMailboxFailed(account, localFolder, e); } } if (lastFailure != null) { - if (wasPermanentFailure) { - notificationController.showSendFailedNotification(account, lastFailure); - } else { - notificationController.showSendFailedNotification(account, lastFailure); - } + notificationController.showSendFailedNotification(account, lastFailure); } } catch (Exception e) { Timber.v(e, "Failed to send pending messages"); diff --git a/app/core/src/main/java/com/fsck/k9/helper/MergeCursor.java b/app/core/src/main/java/com/fsck/k9/helper/MergeCursor.java deleted file mode 100644 index f0424f9568f1df192cbc7d86e9ddbd3389d45eda..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/helper/MergeCursor.java +++ /dev/null @@ -1,462 +0,0 @@ -/* - * Copyright (C) 2012 The K-9 Dog Walkers - * Copyright (C) 2006 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.fsck.k9.helper; - -import java.util.Comparator; - -import android.annotation.TargetApi; -import android.content.ContentResolver; -import android.database.CharArrayBuffer; -import android.database.ContentObserver; -import android.database.Cursor; -import android.database.DataSetObserver; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; - - -/** - * This class can be used to combine multiple {@link Cursor}s into one. - */ -public class MergeCursor implements Cursor { - /** - * List of the cursors combined in this object. - */ - protected final Cursor[] mCursors; - - /** - * The currently active cursor. - */ - protected Cursor mActiveCursor; - - /** - * The index of the currently active cursor in {@link #mCursors}. - * - * @see #mActiveCursor - */ - protected int mActiveCursorIndex; - - /** - * The cursor's current position. - */ - protected int mPosition; - - /** - * Used to cache the value of {@link #getCount()}. - */ - private int mCount = -1; - - /** - * The comparator that is used to decide how the individual cursors are merged. - */ - private final Comparator mComparator; - - - /** - * Constructor - * - * @param cursors - * The list of cursors this {@code MultiCursor} should combine. - * @param comparator - * A comparator that is used to decide in what order the individual cursors are merged. - */ - public MergeCursor(Cursor[] cursors, Comparator comparator) { - mCursors = cursors.clone(); - mComparator = comparator; - - resetCursors(); - } - - private void resetCursors() { - mActiveCursorIndex = -1; - mActiveCursor = null; - mPosition = -1; - - for (int i = 0, len = mCursors.length; i < len; i++) { - Cursor cursor = mCursors[i]; - if (cursor != null) { - cursor.moveToPosition(-1); - - if (mActiveCursor == null) { - mActiveCursorIndex = i; - mActiveCursor = mCursors[mActiveCursorIndex]; - } - } - } - } - - @Override - public void close() { - for (Cursor cursor : mCursors) { - if (cursor != null) { - cursor.close(); - } - } - } - - @Override - public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) { - mActiveCursor.copyStringToBuffer(columnIndex, buffer); - } - - @Override - public void deactivate() { - for (Cursor cursor : mCursors) { - if (cursor != null) { - cursor.deactivate(); - } - } - } - - @Override - public byte[] getBlob(int columnIndex) { - return mActiveCursor.getBlob(columnIndex); - } - - @Override - public int getColumnCount() { - return mActiveCursor.getColumnCount(); - } - - @Override - public int getColumnIndex(String columnName) { - return mActiveCursor.getColumnIndex(columnName); - } - - @Override - public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException { - return mActiveCursor.getColumnIndexOrThrow(columnName); - } - - @Override - public String getColumnName(int columnIndex) { - return mActiveCursor.getColumnName(columnIndex); - } - - @Override - public String[] getColumnNames() { - return mActiveCursor.getColumnNames(); - } - - @Override - public int getCount() { - // CursorLoaders seem to call getCount() a lot. So we're caching the aggregated count. - if (mCount == -1) { - int count = 0; - for (Cursor cursor : mCursors) { - if (cursor != null) { - count += cursor.getCount(); - } - } - - mCount = count; - } - - return mCount; - } - - @Override - public double getDouble(int columnIndex) { - return mActiveCursor.getDouble(columnIndex); - } - - @Override - public float getFloat(int columnIndex) { - return mActiveCursor.getFloat(columnIndex); - } - - @Override - public int getInt(int columnIndex) { - return mActiveCursor.getInt(columnIndex); - } - - @Override - public long getLong(int columnIndex) { - return mActiveCursor.getLong(columnIndex); - } - - @Override - public int getPosition() { - return mPosition; - } - - @Override - public short getShort(int columnIndex) { - return mActiveCursor.getShort(columnIndex); - } - - @Override - public String getString(int columnIndex) { - return mActiveCursor.getString(columnIndex); - } - - @Override - public int getType(int columnIndex) { - return mActiveCursor.getType(columnIndex); - } - - @Override - public boolean getWantsAllOnMoveCalls() { - return mActiveCursor.getWantsAllOnMoveCalls(); - } - - @TargetApi(Build.VERSION_CODES.M) - @Override - public void setExtras(Bundle extras) { - mActiveCursor.setExtras(extras); - } - - @Override - public boolean isAfterLast() { - int count = getCount(); - return count == 0 || mPosition == count; - - } - - @Override - public boolean isBeforeFirst() { - return getCount() == 0 || mPosition == -1; - - } - - @Override - public boolean isClosed() { - return mActiveCursor.isClosed(); - } - - @Override - public boolean isFirst() { - return getCount() != 0 && mPosition == 0; - - } - - @Override - public boolean isLast() { - int count = getCount(); - return count != 0 && mPosition == count - 1; - - } - - @Override - public boolean isNull(int columnIndex) { - return mActiveCursor.isNull(columnIndex); - } - - @Override - public boolean move(int offset) { - return moveToPosition(mPosition + offset); - } - - @Override - public boolean moveToFirst() { - return moveToPosition(0); - } - - @Override - public boolean moveToLast() { - return moveToPosition(getCount() - 1); - } - - @Override - public boolean moveToNext() { - int count = getCount(); - if (mPosition == count) { - return false; - } - - if (mPosition == count - 1) { - mActiveCursor.moveToNext(); - mPosition++; - return false; - } - - int smallest = -1; - for (int i = 0, len = mCursors.length; i < len; i++) { - if (mCursors[i] == null || mCursors[i].getCount() == 0 || mCursors[i].isLast()) { - continue; - } - - if (smallest == -1) { - smallest = i; - mCursors[smallest].moveToNext(); - continue; - } - - Cursor left = mCursors[smallest]; - Cursor right = mCursors[i]; - - right.moveToNext(); - - int result = mComparator.compare(left, right); - if (result > 0) { - smallest = i; - left.moveToPrevious(); - } else { - right.moveToPrevious(); - } - } - - mPosition++; - if (smallest != -1) { - mActiveCursorIndex = smallest; - mActiveCursor = mCursors[mActiveCursorIndex]; - } - - return true; - } - - @Override - public boolean moveToPosition(int position) { - // Make sure position isn't past the end of the cursor - final int count = getCount(); - if (position >= count) { - mPosition = count; - return false; - } - - // Make sure position isn't before the beginning of the cursor - if (position < 0) { - mPosition = -1; - return false; - } - - // Check for no-op moves, and skip the rest of the work for them - if (position == mPosition) { - return true; - } - - if (position > mPosition) { - for (int i = 0, end = position - mPosition; i < end; i++) { - if (!moveToNext()) { - return false; - } - } - } else { - for (int i = 0, end = mPosition - position; i < end; i++) { - if (!moveToPrevious()) { - return false; - } - } - } - - return true; - } - - @Override - public boolean moveToPrevious() { - if (mPosition < 0) { - return false; - } - - mActiveCursor.moveToPrevious(); - - if (mPosition == 0) { - mPosition = -1; - return false; - } - - int greatest = -1; - for (int i = 0, len = mCursors.length; i < len; i++) { - if (mCursors[i] == null || mCursors[i].isBeforeFirst()) { - continue; - } - - if (greatest == -1) { - greatest = i; - continue; - } - - Cursor left = mCursors[greatest]; - Cursor right = mCursors[i]; - - int result = mComparator.compare(left, right); - if (result <= 0) { - greatest = i; - } - } - - mPosition--; - if (greatest != -1) { - mActiveCursorIndex = greatest; - mActiveCursor = mCursors[mActiveCursorIndex]; - } - - return true; - } - - @Override - public void registerContentObserver(ContentObserver observer) { - for (Cursor cursor : mCursors) { - cursor.registerContentObserver(observer); - } - } - - @Override - public void registerDataSetObserver(DataSetObserver observer) { - for (Cursor cursor : mCursors) { - cursor.registerDataSetObserver(observer); - } - } - - @Deprecated - @Override - public boolean requery() { - boolean success = true; - for (Cursor cursor : mCursors) { - success &= cursor.requery(); - } - - return success; - } - - @Override - public void setNotificationUri(ContentResolver cr, Uri uri) { - for (Cursor cursor : mCursors) { - cursor.setNotificationUri(cr, uri); - } - } - - @Override - public void unregisterContentObserver(ContentObserver observer) { - for (Cursor cursor : mCursors) { - cursor.unregisterContentObserver(observer); - } - } - - @Override - public void unregisterDataSetObserver(DataSetObserver observer) { - for (Cursor cursor : mCursors) { - cursor.unregisterDataSetObserver(observer); - } - } - - @Override - public Bundle getExtras() { - throw new RuntimeException("Not implemented"); - } - - @Override - public Bundle respond(Bundle extras) { - throw new RuntimeException("Not implemented"); - } - - @Override - public Uri getNotificationUri() { - return null; - } -} diff --git a/app/core/src/main/java/com/fsck/k9/helper/MergeCursorWithUniqueId.java b/app/core/src/main/java/com/fsck/k9/helper/MergeCursorWithUniqueId.java deleted file mode 100644 index 111d49fbbb19b19ca79a75c2fb0cf4110c080b35..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/helper/MergeCursorWithUniqueId.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.fsck.k9.helper; - -import java.util.Comparator; - -import android.database.Cursor; - - -public class MergeCursorWithUniqueId extends MergeCursor { - private static final int SHIFT = 48; - private static final long MAX_ID = (1L << SHIFT) - 1; - private static final long MAX_CURSORS = 1L << (63 - SHIFT); - - private int mColumnCount = -1; - private int mIdColumnIndex = -1; - - - public MergeCursorWithUniqueId(Cursor[] cursors, Comparator comparator) { - super(cursors, comparator); - - if (cursors.length > MAX_CURSORS) { - throw new IllegalArgumentException("This class only supports up to " + - MAX_CURSORS + " cursors"); - } - } - - @Override - public int getColumnCount() { - if (mColumnCount == -1) { - mColumnCount = super.getColumnCount(); - } - - return mColumnCount + 1; - } - - @Override - public int getColumnIndex(String columnName) { - if ("_id".equals(columnName)) { - return getUniqueIdColumnIndex(); - } - - return super.getColumnIndexOrThrow(columnName); - } - - @Override - public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException { - if ("_id".equals(columnName)) { - return getUniqueIdColumnIndex(); - } - - return super.getColumnIndexOrThrow(columnName); - } - - @Override - public long getLong(int columnIndex) { - if (columnIndex == getUniqueIdColumnIndex()) { - long id = getPerCursorId(); - if (id > MAX_ID) { - throw new RuntimeException("Sorry, " + this.getClass().getName() + - " can only handle '_id' values up to " + SHIFT + " bits."); - } - - return (((long) mActiveCursorIndex) << SHIFT) + id; - } - - return super.getLong(columnIndex); - } - - protected int getUniqueIdColumnIndex() { - if (mColumnCount == -1) { - mColumnCount = super.getColumnCount(); - } - - return mColumnCount; - } - - protected long getPerCursorId() { - if (mIdColumnIndex == -1) { - mIdColumnIndex = super.getColumnIndexOrThrow("_id"); - } - - return super.getLong(mIdColumnIndex); - } -} diff --git a/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java b/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java index cb9e865dad8414b646010f883d4965780bbf6710..a01c25477c197c08f267f473a4ab9f7ee0806959 100644 --- a/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java +++ b/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java @@ -1,6 +1,7 @@ package com.fsck.k9.helper; +import java.util.List; import java.util.regex.Pattern; import android.content.Context; @@ -68,7 +69,7 @@ public class MessageHelper { return new SpannableStringBuilder(resourceProvider.contactDisplayNamePrefix()).append(recipients); } - public boolean toMe(Account account, Address[] toAddrs) { + public boolean toMe(Account account, List
toAddrs) { for (Address address : toAddrs) { if (account.isAnIdentity(address)) { return true; diff --git a/app/core/src/main/java/com/fsck/k9/helper/jsoup/AdvancedNodeTraversor.java b/app/core/src/main/java/com/fsck/k9/helper/jsoup/AdvancedNodeTraversor.java index cfcab37a4cc186a1cb8e5bcbba89201b44de7427..41cd5e245ff2ecd9fcaa1558d0e4722b413961ba 100644 --- a/app/core/src/main/java/com/fsck/k9/helper/jsoup/AdvancedNodeTraversor.java +++ b/app/core/src/main/java/com/fsck/k9/helper/jsoup/AdvancedNodeTraversor.java @@ -125,7 +125,7 @@ public class AdvancedNodeTraversor { Node prev = node; node = node.nextSibling(); - if (headResult == HeadFilterDecision.REMOVE) { + if (headResult == HeadFilterDecision.REMOVE || tailResult == TailFilterDecision.REMOVE) { prev.remove(); } diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/CacheAwareMessageMapper.kt b/app/core/src/main/java/com/fsck/k9/mailstore/CacheAwareMessageMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..775effd710f4936a97fa8bcebec68148afc0a8f9 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/mailstore/CacheAwareMessageMapper.kt @@ -0,0 +1,53 @@ +package com.fsck.k9.mailstore + +import com.fsck.k9.mail.Flag + +internal class CacheAwareMessageMapper( + private val cache: MessageListCache, + private val messageMapper: MessageMapper +) : MessageMapper { + override fun map(message: MessageDetailsAccessor): T? { + val messageId = message.id + val folderId = message.folderId + + if (cache.isMessageHidden(messageId, folderId)) { + return null + } + + val cachedMessage = CacheAwareMessageDetailsAccessor(cache, message) + return messageMapper.map(cachedMessage) + } +} + +private class CacheAwareMessageDetailsAccessor( + private val cache: MessageListCache, + private val message: MessageDetailsAccessor +) : MessageDetailsAccessor by message { + override val isRead: Boolean + get() { + return cache.getFlagForMessage(message.id, Flag.SEEN) + ?: cache.getFlagForThread(message.threadRoot, Flag.SEEN) + ?: message.isRead + } + + override val isStarred: Boolean + get() { + return cache.getFlagForMessage(message.id, Flag.FLAGGED) + ?: cache.getFlagForThread(message.threadRoot, Flag.FLAGGED) + ?: message.isStarred + } + + override val isAnswered: Boolean + get() { + return cache.getFlagForMessage(message.id, Flag.ANSWERED) + ?: cache.getFlagForThread(message.threadRoot, Flag.ANSWERED) + ?: message.isAnswered + } + + override val isForwarded: Boolean + get() { + return cache.getFlagForMessage(message.id, Flag.FORWARDED) + ?: cache.getFlagForThread(message.threadRoot, Flag.FORWARDED) + ?: message.isForwarded + } +} diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/DatabasePreviewType.java b/app/core/src/main/java/com/fsck/k9/mailstore/DatabasePreviewType.java index 40a262caaee47dcfea6cbd9c07121d889dfb3fea..e0627bfb8b35c407aae0e799ed33a43620afcdab 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/DatabasePreviewType.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/DatabasePreviewType.java @@ -2,6 +2,7 @@ package com.fsck.k9.mailstore; import com.fsck.k9.message.extractors.PreviewResult.PreviewType; +import org.jetbrains.annotations.NotNull; public enum DatabasePreviewType { @@ -20,6 +21,7 @@ public enum DatabasePreviewType { this.previewType = previewType; } + @NotNull public static DatabasePreviewType fromDatabaseValue(String databaseValue) { for (DatabasePreviewType databasePreviewType : values()) { if (databasePreviewType.getDatabaseValue().equals(databaseValue)) { diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/FolderNotFoundException.kt b/app/core/src/main/java/com/fsck/k9/mailstore/FolderNotFoundException.kt new file mode 100644 index 0000000000000000000000000000000000000000..34dcf1ca00a611f7361e31457798c9759bba2eab --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/mailstore/FolderNotFoundException.kt @@ -0,0 +1,3 @@ +package com.fsck.k9.mailstore + +class FolderNotFoundException(val folderId: Long) : RuntimeException("Folder not found: $folderId") diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt index 5f657ecaa410c21e0329eef956d8c7b95087df87..8575c3bf55fc94f5b0531955cb67ca5bde7391d3 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt @@ -34,4 +34,5 @@ val mailStoreModule = module { attachmentCounter = get() ) } + single { MessageListRepository(messageStoreManager = get()) } } diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java b/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java index 1fb427d6cd0550ac2da71b18d05e7686d2e3560c..3f846a6c6c8e1b96f3aeda29742a2958bb26b5cf 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java @@ -18,12 +18,10 @@ import java.util.Locale; import java.util.Map; import java.util.Stack; -import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; -import android.net.Uri; import androidx.annotation.Nullable; import android.text.TextUtils; @@ -51,8 +49,6 @@ import com.fsck.k9.mailstore.LockableDatabase.DbCallback; import com.fsck.k9.mailstore.LockableDatabase.SchemaDefinition; import com.fsck.k9.mailstore.StorageManager.InternalStorageProvider; import com.fsck.k9.message.extractors.AttachmentInfoExtractor; -import com.fsck.k9.provider.EmailProvider; -import com.fsck.k9.provider.EmailProvider.MessageColumns; import com.fsck.k9.search.LocalSearch; import com.fsck.k9.search.SearchSpecification.Attribute; import com.fsck.k9.search.SearchSpecification.SearchField; @@ -166,7 +162,6 @@ public class LocalStore { private static final int THREAD_FLAG_UPDATE_BATCH_SIZE = 500; private final Context context; - private final ContentResolver contentResolver; private final PendingCommandSerializer pendingCommandSerializer; private final AttachmentInfoExtractor attachmentInfoExtractor; @@ -184,7 +179,6 @@ public class LocalStore { */ private LocalStore(final Account account, final Context context) throws MessagingException { this.context = context; - this.contentResolver = context.getContentResolver(); pendingCommandSerializer = PendingCommandSerializer.getInstance(); attachmentInfoExtractor = DI.get(AttachmentInfoExtractor.class); @@ -227,7 +221,7 @@ public class LocalStore { } protected Preferences getPreferences() { - return Preferences.getPreferences(context); + return Preferences.getPreferences(); } public OutboxStateRepository getOutboxStateRepository() { @@ -726,8 +720,8 @@ public class LocalStore { } public void notifyChange() { - Uri uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/" + account.getUuid() + "/messages"); - contentResolver.notifyChange(uri, null); + MessageListRepository messageListRepository = DI.get(MessageListRepository.class); + messageListRepository.notifyMessageListChanged(account.getUuid()); } /** diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageColumns.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageColumns.kt new file mode 100644 index 0000000000000000000000000000000000000000..a9af58a25aa977b81507be163f7d0dd611deff1c --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageColumns.kt @@ -0,0 +1,24 @@ +package com.fsck.k9.mailstore + +object MessageColumns { + const val ID = "id" + const val UID = "uid" + const val INTERNAL_DATE = "internal_date" + const val SUBJECT = "subject" + const val DATE = "date" + const val MESSAGE_ID = "message_id" + const val SENDER_LIST = "sender_list" + const val TO_LIST = "to_list" + const val CC_LIST = "cc_list" + const val BCC_LIST = "bcc_list" + const val REPLY_TO_LIST = "reply_to_list" + const val FLAGS = "flags" + const val ATTACHMENT_COUNT = "attachment_count" + const val FOLDER_ID = "folder_id" + const val PREVIEW_TYPE = "preview_type" + const val PREVIEW = "preview" + const val READ = "read" + const val FLAGGED = "flagged" + const val ANSWERED = "answered" + const val FORWARDED = "forwarded" +} diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageListCache.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageListCache.kt new file mode 100644 index 0000000000000000000000000000000000000000..51c1a8575c76d2cbd6af1d0bebac37e466c488b6 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageListCache.kt @@ -0,0 +1,147 @@ +package com.fsck.k9.mailstore + +import com.fsck.k9.DI +import com.fsck.k9.mail.Flag +import kotlin.collections.set + +typealias MessageId = Long +typealias ThreadId = Long +typealias FolderId = Long +typealias FlagValue = Boolean +typealias AccountUuid = String + +/** + * Cache to bridge the time needed to write (user-initiated) changes to the database. + */ +class MessageListCache private constructor(private val accountUuid: String) { + private val messageCache = mutableMapOf>() + private val threadCache = mutableMapOf>() + private val hiddenMessageCache = mutableMapOf() + + fun getFlagForMessage(messageId: Long, flag: Flag): Boolean? { + synchronized(messageCache) { + val columnMap = messageCache[messageId] + return columnMap?.get(flag) + } + } + + fun getFlagForThread(threadRootId: Long, flag: Flag): Boolean? { + synchronized(threadCache) { + val columnMap = threadCache[threadRootId] + return columnMap?.get(flag) + } + } + + fun setFlagForMessages(messageIds: List, flag: Flag, value: Boolean) { + synchronized(messageCache) { + for (messageId in messageIds) { + val columnMap = messageCache.getOrPut(messageId) { mutableMapOf() } + columnMap[flag] = value + } + } + + notifyChange() + } + + fun setValueForThreads(threadRootIds: List, flag: Flag, value: Boolean) { + synchronized(threadCache) { + for (threadRootId in threadRootIds) { + val columnMap = threadCache.getOrPut(threadRootId) { mutableMapOf() } + columnMap[flag] = value + } + } + + notifyChange() + } + + fun removeFlagForMessages(messageIds: List, flag: Flag) { + synchronized(messageCache) { + for (messageId in messageIds) { + val columnMap = messageCache[messageId] + if (columnMap != null) { + columnMap.remove(flag) + if (columnMap.isEmpty()) { + messageCache.remove(messageId) + } + } + } + } + } + + fun removeFlagForThreads(threadRootIds: List, flag: Flag) { + synchronized(threadCache) { + for (threadRootId in threadRootIds) { + val columnMap = threadCache[threadRootId] + if (columnMap != null) { + columnMap.remove(flag) + if (columnMap.isEmpty()) { + threadCache.remove(threadRootId) + } + } + } + } + } + + fun hideMessages(messages: List) { + synchronized(hiddenMessageCache) { + for (message in messages) { + val messageId = message.databaseId + val folderId = message.folder.databaseId + hiddenMessageCache[messageId] = folderId + } + } + + notifyChange() + } + + fun isMessageHidden(messageId: Long, folderId: Long): Boolean { + synchronized(hiddenMessageCache) { + val hiddenInFolder = hiddenMessageCache[messageId] + return hiddenInFolder == folderId + } + } + + fun unhideMessages(messages: List) { + synchronized(hiddenMessageCache) { + for (message in messages) { + val messageId = message.databaseId + val folderId = message.folder.databaseId + val hiddenInFolder = hiddenMessageCache[messageId] + if (hiddenInFolder == folderId) { + hiddenMessageCache.remove(messageId) + } + } + } + } + + fun isEmpty(): Boolean { + return isMessageCacheEmpty() && isThreadCacheEmpty() && isHiddenMessageCacheEmpty() + } + + private fun isMessageCacheEmpty(): Boolean { + return synchronized(messageCache) { messageCache.isEmpty() } + } + + private fun isThreadCacheEmpty(): Boolean { + return synchronized(threadCache) { threadCache.isEmpty() } + } + + private fun isHiddenMessageCacheEmpty(): Boolean { + return synchronized(hiddenMessageCache) { hiddenMessageCache.isEmpty() } + } + + private fun notifyChange() { + val messageListRepository = DI.get() + messageListRepository.notifyMessageListChanged(accountUuid) + } + + companion object { + private val instances = mutableMapOf() + + @JvmStatic + @Synchronized + fun getCache(accountUuid: String): MessageListCache { + return instances.getOrPut(accountUuid) { MessageListCache(accountUuid) } + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageListRepository.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageListRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..5a748ee2728e85dfabc2d99f1fda6b004e019330 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageListRepository.kt @@ -0,0 +1,80 @@ +package com.fsck.k9.mailstore + +import java.util.concurrent.CopyOnWriteArraySet + +class MessageListRepository( + private val messageStoreManager: MessageStoreManager +) { + private val listeners = CopyOnWriteArraySet>() + + fun addListener(accountUuid: String, listener: MessageListChangedListener) { + listeners.add(accountUuid to listener) + } + + fun removeListener(listener: MessageListChangedListener) { + val entries = listeners.filter { it.second == listener }.toSet() + listeners.removeAll(entries) + } + + fun notifyMessageListChanged(accountUuid: String) { + for (listener in listeners) { + if (listener.first == accountUuid) { + listener.second.onMessageListChanged() + } + } + } + + /** + * Retrieve list of messages from [MessageStore] but override values with data from [MessageListCache]. + */ + fun getMessages( + accountUuid: String, + selection: String, + selectionArgs: Array, + sortOrder: String, + messageMapper: MessageMapper + ): List { + val messageStore = messageStoreManager.getMessageStore(accountUuid) + val cache = MessageListCache.getCache(accountUuid) + + val mapper = if (cache.isEmpty()) messageMapper else CacheAwareMessageMapper(cache, messageMapper) + return messageStore.getMessages(selection, selectionArgs, sortOrder, mapper) + } + + /** + * Retrieve threaded list of messages from [MessageStore] but override values with data from [MessageListCache]. + */ + fun getThreadedMessages( + accountUuid: String, + selection: String, + selectionArgs: Array, + sortOrder: String, + messageMapper: MessageMapper + ): List { + val messageStore = messageStoreManager.getMessageStore(accountUuid) + val cache = MessageListCache.getCache(accountUuid) + + val mapper = if (cache.isEmpty()) messageMapper else CacheAwareMessageMapper(cache, messageMapper) + return messageStore.getThreadedMessages(selection, selectionArgs, sortOrder, mapper) + } + + /** + * Retrieve list of messages in a thread from [MessageStore] but override values with data from [MessageListCache]. + */ + fun getThread( + accountUuid: String, + threadId: Long, + sortOrder: String, + messageMapper: MessageMapper + ): List { + val messageStore = messageStoreManager.getMessageStore(accountUuid) + val cache = MessageListCache.getCache(accountUuid) + + val mapper = if (cache.isEmpty()) messageMapper else CacheAwareMessageMapper(cache, messageMapper) + return messageStore.getThread(threadId, sortOrder, mapper) + } +} + +fun interface MessageListChangedListener { + fun onMessageListChanged() +} diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageMapper.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..a8c15d96c3a9a27efe266cc6c9ef7df71ff7597d --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageMapper.kt @@ -0,0 +1,28 @@ +package com.fsck.k9.mailstore + +import com.fsck.k9.mail.Address +import com.fsck.k9.message.extractors.PreviewResult + +fun interface MessageMapper { + fun map(message: MessageDetailsAccessor): T +} + +interface MessageDetailsAccessor { + val id: Long + val messageServerId: String + val folderId: Long + val fromAddresses: List
+ val toAddresses: List
+ val ccAddresses: List
+ val messageDate: Long + val internalDate: Long + val subject: String? + val preview: PreviewResult + val isRead: Boolean + val isStarred: Boolean + val isAnswered: Boolean + val isForwarded: Boolean + val hasAttachments: Boolean + val threadRoot: Long + val threadCount: Int +} diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt index 75aeefed38f264f1f76ed4a464b331c51cd92b29..eecbbd96454ac25f4a38f8b6dd2aa8b1911114f3 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt @@ -120,6 +120,31 @@ interface MessageStore { */ fun getAllMessagesAndEffectiveDates(folderId: Long): Map + /** + * Retrieve list of messages. + */ + fun getMessages( + selection: String, + selectionArgs: Array, + sortOrder: String, + messageMapper: MessageMapper + ): List + + /** + * Retrieve threaded list of messages. + */ + fun getThreadedMessages( + selection: String, + selectionArgs: Array, + sortOrder: String, + messageMapper: MessageMapper + ): List + + /** + * Retrieve list of messages in a thread. + */ + fun getThread(threadId: Long, sortOrder: String, messageMapper: MessageMapper): List + /** * Retrieve the date of the oldest message in the given folder. */ @@ -231,6 +256,11 @@ interface MessageStore { */ fun setNotificationClass(folderId: Long, folderClass: FolderClass) + /** + * Get the 'more messages' state of a folder. + */ + fun hasMoreMessages(folderId: Long): MoreMessages? + /** * Update the 'more messages' state of a folder. */ diff --git a/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.java b/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.java deleted file mode 100644 index 175623b05d2929e102d15971eaf7464daed7e6ba..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.fsck.k9.message.signature; - - -import java.util.regex.Pattern; - -import androidx.annotation.NonNull; -import com.fsck.k9.helper.jsoup.AdvancedNodeTraversor; -import com.fsck.k9.helper.jsoup.NodeFilter; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.jsoup.nodes.Node; -import org.jsoup.nodes.TextNode; -import org.jsoup.parser.Tag; - - -public class HtmlSignatureRemover { - public static String stripSignature(String content) { - return new HtmlSignatureRemover().stripSignatureInternal(content); - } - - private String stripSignatureInternal(String content) { - Document document = Jsoup.parse(content); - - AdvancedNodeTraversor nodeTraversor = new AdvancedNodeTraversor(new StripSignatureFilter()); - nodeTraversor.filter(document.body()); - - return toCompactString(document); - } - - private String toCompactString(Document document) { - document.outputSettings() - .prettyPrint(false) - .indentAmount(0); - - return document.html(); - } - - - static class StripSignatureFilter implements NodeFilter { - private static final Pattern DASH_SIGNATURE_HTML = Pattern.compile("\\s*-- \\s*", Pattern.CASE_INSENSITIVE); - private static final Tag BLOCKQUOTE = Tag.valueOf("blockquote"); - private static final Tag BR = Tag.valueOf("br"); - private static final Tag P = Tag.valueOf("p"); - - - private boolean signatureFound = false; - private boolean lastElementCausedLineBreak = false; - private Element brElementPrecedingDashes; - - - @NonNull - @Override - public HeadFilterDecision head(Node node, int depth) { - if (signatureFound) { - return HeadFilterDecision.REMOVE; - } - - if (node instanceof Element) { - lastElementCausedLineBreak = false; - - Element element = (Element) node; - if (element.tag().equals(BLOCKQUOTE)) { - return HeadFilterDecision.SKIP_ENTIRELY; - } - } else if (node instanceof TextNode) { - TextNode textNode = (TextNode) node; - if (lastElementCausedLineBreak && DASH_SIGNATURE_HTML.matcher(textNode.getWholeText()).matches()) { - Node nextNode = node.nextSibling(); - if (nextNode instanceof Element && ((Element) nextNode).tag().equals(BR)) { - signatureFound = true; - if (brElementPrecedingDashes != null) { - brElementPrecedingDashes.remove(); - brElementPrecedingDashes = null; - } - - return HeadFilterDecision.REMOVE; - } - } - } - - return HeadFilterDecision.CONTINUE; - } - - @NonNull - @Override - public TailFilterDecision tail(Node node, int depth) { - if (signatureFound) { - return TailFilterDecision.CONTINUE; - } - - if (node instanceof Element) { - Element element = (Element) node; - boolean elementIsBr = element.tag().equals(BR); - if (elementIsBr || element.tag().equals(P)) { - lastElementCausedLineBreak = true; - brElementPrecedingDashes = elementIsBr ? element : null; - return TailFilterDecision.CONTINUE; - } - } - - lastElementCausedLineBreak = false; - return TailFilterDecision.CONTINUE; - } - } -} diff --git a/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt b/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt new file mode 100644 index 0000000000000000000000000000000000000000..07746c20dcbdc37d28527e382257854a50cbe5d4 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/signature/HtmlSignatureRemover.kt @@ -0,0 +1,142 @@ +package com.fsck.k9.message.signature + +import com.fsck.k9.helper.jsoup.AdvancedNodeTraversor +import com.fsck.k9.helper.jsoup.NodeFilter +import com.fsck.k9.helper.jsoup.NodeFilter.HeadFilterDecision +import com.fsck.k9.helper.jsoup.NodeFilter.TailFilterDecision +import java.util.Stack +import java.util.regex.Pattern +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.parser.Tag + +class HtmlSignatureRemover { + private fun stripSignatureInternal(content: String): String { + val document = Jsoup.parse(content) + + val nodeTraversor = AdvancedNodeTraversor(StripSignatureFilter()) + nodeTraversor.filter(document.body()) + + return toCompactString(document) + } + + private fun toCompactString(document: Document): String { + document.outputSettings() + .prettyPrint(false) + .indentAmount(0) + + return document.html() + } + + private class StripSignatureFilter : NodeFilter { + private var signatureFound = false + private var signatureParentNode: Node? = null + + override fun head(node: Node, depth: Int): HeadFilterDecision { + if (signatureFound) return HeadFilterDecision.REMOVE + + if (node.isBlockquote()) { + return HeadFilterDecision.SKIP_ENTIRELY + } else if (node.isSignatureDelimiter()) { + val precedingLineBreak = node.findPrecedingLineBreak() + if (precedingLineBreak != null && node.isFollowedByLineBreak()) { + signatureFound = true + signatureParentNode = node.parent() + precedingLineBreak.takeIf { it.isBR() }?.remove() + + return HeadFilterDecision.REMOVE + } + } + + return HeadFilterDecision.CONTINUE + } + + override fun tail(node: Node, depth: Int): TailFilterDecision { + if (signatureFound) { + val signatureParentNode = this.signatureParentNode + if (node == signatureParentNode) { + return if (signatureParentNode.isEmpty()) { + this.signatureParentNode = signatureParentNode.parent() + TailFilterDecision.REMOVE + } else { + TailFilterDecision.STOP + } + } + } + + return TailFilterDecision.CONTINUE + } + + private fun Node.isBlockquote(): Boolean { + return this is Element && tag() == BLOCKQUOTE + } + + private fun Node.isSignatureDelimiter(): Boolean { + return this is TextNode && DASH_SIGNATURE_HTML.matcher(wholeText).matches() + } + + private fun Node.findPrecedingLineBreak(): Node? { + val stack = Stack() + stack.push(this) + + while (stack.isNotEmpty()) { + val node = stack.pop() + val previousSibling = node.previousSibling() + if (previousSibling == null) { + val parent = node.parent() + if (parent is Element && parent.isBlock) { + return parent + } else { + stack.push(parent) + } + } else if (previousSibling.isLineBreak()) { + return previousSibling + } + } + + return null + } + + private fun Node.isFollowedByLineBreak(): Boolean { + val stack = Stack() + stack.push(this) + + while (stack.isNotEmpty()) { + val node = stack.pop() + val nextSibling = node.nextSibling() + if (nextSibling == null) { + val parent = node.parent() + if (parent is Element && parent.isBlock) { + return true + } else { + stack.push(parent) + } + } else if (nextSibling.isLineBreak()) { + return true + } + } + + return false + } + + private fun Node?.isBR() = this is Element && tag() == BR + + private fun Node?.isLineBreak() = isBR() || (this is Element && this.isBlock) + + private fun Node.isEmpty(): Boolean = childNodeSize() == 0 + } + + companion object { + private val DASH_SIGNATURE_HTML = Pattern.compile("\\s*-- \\s*", Pattern.CASE_INSENSITIVE) + private val BLOCKQUOTE = Tag.valueOf("blockquote") + private val BR = Tag.valueOf("br") + + @JvmStatic + fun stripSignature(content: String): String { + return HtmlSignatureRemover().stripSignatureInternal(content) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java index 5e18598c3755327bfeffacbb024f5fa38b48b701..e00d161ddee968a337a130fb155688102882ffad 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java +++ b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java @@ -188,7 +188,7 @@ public class SettingsImporter { Imported imported = parseSettings(inputStream, globalSettings, accountUuids, false); - Preferences preferences = Preferences.getPreferences(context); + Preferences preferences = Preferences.getPreferences(); Storage storage = preferences.getStorage(); if (globalSettings) { @@ -331,7 +331,7 @@ public class SettingsImporter { AccountDescription original = new AccountDescription(account.name, account.uuid); - Preferences prefs = Preferences.getPreferences(context); + Preferences prefs = Preferences.getPreferences(); List accounts = prefs.getAccounts(); String uuid = account.uuid; diff --git a/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java b/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java index 21a91725b760ea0ba15956da26974525477e1364..36e0644245ae7110e7e6d09c813d440cb8757818 100644 --- a/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java +++ b/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java @@ -96,7 +96,7 @@ public class AttachmentProvider extends ContentProvider { final AttachmentInfo attachmentInfo; try { - final Account account = Preferences.getPreferences(getContext()).getAccount(accountUuid); + final Account account = Preferences.getPreferences().getAccount(accountUuid); attachmentInfo = DI.get(LocalStoreProvider.class).getInstance(account).getAttachmentInfo(id); } catch (MessagingException e) { Timber.e(e, "Unable to retrieve attachment info from local store for ID: %s", id); @@ -143,7 +143,7 @@ public class AttachmentProvider extends ContentProvider { private String getType(String accountUuid, String id, String mimeType) { String type; - final Account account = Preferences.getPreferences(getContext()).getAccount(accountUuid); + final Account account = Preferences.getPreferences().getAccount(accountUuid); try { final LocalStore localStore = DI.get(LocalStoreProvider.class).getInstance(account); @@ -182,7 +182,7 @@ public class AttachmentProvider extends ContentProvider { @Nullable private OpenPgpDataSource getAttachmentDataSource(String accountUuid, String attachmentId) throws MessagingException { - final Account account = Preferences.getPreferences(getContext()).getAccount(accountUuid); + final Account account = Preferences.getPreferences().getAccount(accountUuid); LocalStore localStore = DI.get(LocalStoreProvider.class).getInstance(account); return localStore.getAttachmentDataSource(attachmentId); } diff --git a/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java b/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java deleted file mode 100644 index f24173dbe59697cba1c612956b60a91b7c7c5ae7..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java +++ /dev/null @@ -1,721 +0,0 @@ -package com.fsck.k9.provider; - - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import android.content.ContentProvider; -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.content.UriMatcher; -import android.database.Cursor; -import android.database.CursorWrapper; -import android.database.sqlite.SQLiteDatabase; -import android.net.Uri; -import android.text.TextUtils; - -import com.fsck.k9.Account; -import com.fsck.k9.DI; -import com.fsck.k9.Preferences; -import com.fsck.k9.cache.EmailProviderCacheCursor; -import com.fsck.k9.helper.Utility; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mailstore.LocalStore; -import com.fsck.k9.mailstore.LocalStoreProvider; -import com.fsck.k9.mailstore.LockableDatabase; -import com.fsck.k9.mailstore.LockableDatabase.DbCallback; -import com.fsck.k9.search.SqlQueryBuilder; - - -/** - * Content Provider used to display the message list etc. - * - *

- * For now this content provider is for internal use only. In the future we may allow third-party - * apps to access K-9 Mail content using this content provider. - *

- */ -/* - * TODO: - * - add support for account list and folder list - */ -public class EmailProvider extends ContentProvider { - public static String AUTHORITY; - public static Uri CONTENT_URI; - - public static Uri getNotificationUri(String accountUuid) { - return Uri.withAppendedPath(CONTENT_URI, "account/" + accountUuid + "/messages"); - } - - private UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); - - - /* - * Constants that are used for the URI matching. - */ - private static final int MESSAGE_BASE = 0; - private static final int MESSAGES = MESSAGE_BASE; - private static final int MESSAGES_THREADED = MESSAGE_BASE + 1; - private static final int MESSAGES_THREAD = MESSAGE_BASE + 2; - - - private static final String MESSAGES_TABLE = "messages"; - - private static final Map THREAD_AGGREGATION_FUNCS = new HashMap<>(); - static { - THREAD_AGGREGATION_FUNCS.put(MessageColumns.DATE, "MAX"); - THREAD_AGGREGATION_FUNCS.put(MessageColumns.INTERNAL_DATE, "MAX"); - THREAD_AGGREGATION_FUNCS.put(MessageColumns.ATTACHMENT_COUNT, "SUM"); - THREAD_AGGREGATION_FUNCS.put(MessageColumns.READ, "MIN"); - THREAD_AGGREGATION_FUNCS.put(MessageColumns.FLAGGED, "MAX"); - THREAD_AGGREGATION_FUNCS.put(MessageColumns.ANSWERED, "MIN"); - THREAD_AGGREGATION_FUNCS.put(MessageColumns.FORWARDED, "MIN"); - } - - private static final String[] FIXUP_MESSAGES_COLUMNS = { - MessageColumns.ID - }; - - private static final String[] FIXUP_AGGREGATED_MESSAGES_COLUMNS = { - MessageColumns.DATE, - MessageColumns.INTERNAL_DATE, - MessageColumns.ATTACHMENT_COUNT, - MessageColumns.READ, - MessageColumns.FLAGGED, - MessageColumns.ANSWERED, - MessageColumns.FORWARDED - }; - - private static final String FOLDERS_TABLE = "folders"; - - private static final String[] FOLDERS_COLUMNS = { - FolderColumns.ID, - FolderColumns.NAME, - FolderColumns.LAST_UPDATED, - FolderColumns.UNREAD_COUNT, - FolderColumns.VISIBLE_LIMIT, - FolderColumns.STATUS, - FolderColumns.PUSH_STATE, - FolderColumns.LAST_PUSHED, - FolderColumns.FLAGGED_COUNT, - FolderColumns.INTEGRATE, - FolderColumns.TOP_GROUP, - FolderColumns.POLL_CLASS, - FolderColumns.PUSH_CLASS, - FolderColumns.DISPLAY_CLASS, - FolderColumns.SERVER_ID - }; - - private static final String THREADS_TABLE = "threads"; - - public interface SpecialColumns { - String ACCOUNT_UUID = "account_uuid"; - - String THREAD_COUNT = "thread_count"; - - String FOLDER_SERVER_ID = "server_id"; - String INTEGRATE = "integrate"; - } - - public interface MessageColumns { - String ID = "id"; - String UID = "uid"; - String INTERNAL_DATE = "internal_date"; - String SUBJECT = "subject"; - String DATE = "date"; - String MESSAGE_ID = "message_id"; - String SENDER_LIST = "sender_list"; - String TO_LIST = "to_list"; - String CC_LIST = "cc_list"; - String BCC_LIST = "bcc_list"; - String REPLY_TO_LIST = "reply_to_list"; - String FLAGS = "flags"; - String ATTACHMENT_COUNT = "attachment_count"; - String FOLDER_ID = "folder_id"; - String PREVIEW_TYPE = "preview_type"; - String PREVIEW = "preview"; - String READ = "read"; - String FLAGGED = "flagged"; - String ANSWERED = "answered"; - String FORWARDED = "forwarded"; - } - - private interface InternalMessageColumns extends MessageColumns { - String DELETED = "deleted"; - String EMPTY = "empty"; - String MIME_TYPE = "mime_type"; - } - - public interface FolderColumns { - String ID = "id"; - String NAME = "name"; - String LAST_UPDATED = "last_updated"; - String UNREAD_COUNT = "unread_count"; - String VISIBLE_LIMIT = "visible_limit"; - String STATUS = "status"; - String PUSH_STATE = "push_state"; - String LAST_PUSHED = "last_pushed"; - String FLAGGED_COUNT = "flagged_count"; - String INTEGRATE = "integrate"; - String TOP_GROUP = "top_group"; - String POLL_CLASS = "poll_class"; - String PUSH_CLASS = "push_class"; - String DISPLAY_CLASS = "display_class"; - String SERVER_ID = "server_id"; - } - - public interface ThreadColumns { - String ID = "id"; - String MESSAGE_ID = "message_id"; - String ROOT = "root"; - String PARENT = "parent"; - } - - public interface StatsColumns { - String UNREAD_COUNT = "unread_count"; - String FLAGGED_COUNT = "flagged_count"; - } - - - private Preferences mPreferences; - - - @Override - public boolean onCreate() { - String packageName = getContext().getPackageName(); - AUTHORITY = packageName + ".provider.email"; - CONTENT_URI = Uri.parse("content://" + AUTHORITY); - - uriMatcher.addURI(AUTHORITY, "account/*/messages", MESSAGES); - uriMatcher.addURI(AUTHORITY, "account/*/messages/threaded", MESSAGES_THREADED); - uriMatcher.addURI(AUTHORITY, "account/*/thread/#", MESSAGES_THREAD); - - return true; - } - - @Override - public String getType(Uri uri) { - throw new RuntimeException("not implemented yet"); - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { - int match = uriMatcher.match(uri); - if (match < 0) { - throw new IllegalArgumentException("Unknown URI: " + uri); - } - - ContentResolver contentResolver = getContext().getContentResolver(); - Cursor cursor = null; - switch (match) { - case MESSAGES: - case MESSAGES_THREADED: - case MESSAGES_THREAD: { - List segments = uri.getPathSegments(); - String accountUuid = segments.get(1); - - List dbColumnNames = new ArrayList<>(projection.length); - Map specialColumns = new HashMap<>(); - for (String columnName : projection) { - if (SpecialColumns.ACCOUNT_UUID.equals(columnName)) { - specialColumns.put(SpecialColumns.ACCOUNT_UUID, accountUuid); - } else { - dbColumnNames.add(columnName); - } - } - - String[] dbProjection = dbColumnNames.toArray(new String[0]); - - if (match == MESSAGES) { - cursor = getMessages(accountUuid, dbProjection, selection, selectionArgs, sortOrder); - } else if (match == MESSAGES_THREADED) { - cursor = getThreadedMessages(accountUuid, dbProjection, selection, selectionArgs, sortOrder); - } else if (match == MESSAGES_THREAD) { - String threadId = segments.get(3); - cursor = getThread(accountUuid, dbProjection, threadId, sortOrder); - } else { - throw new RuntimeException("Not implemented"); - } - - cursor.setNotificationUri(contentResolver, getNotificationUri(accountUuid)); - - cursor = new SpecialColumnsCursor(new IdTrickeryCursor(cursor), projection, specialColumns); - cursor = new EmailProviderCacheCursor(accountUuid, cursor, getContext()); - break; - } - } - - return cursor; - } - - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - throw new RuntimeException("not implemented yet"); - } - - @Override - public Uri insert(Uri uri, ContentValues values) { - throw new RuntimeException("not implemented yet"); - } - - @Override - public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { - throw new RuntimeException("not implemented yet"); - } - - protected Cursor getMessages(String accountUuid, final String[] projection, final String selection, - final String[] selectionArgs, final String sortOrder) { - - Account account = getAccount(accountUuid); - LockableDatabase database = getDatabase(account); - - try { - return database.execute(false, new DbCallback() { - @Override - public Cursor doDbWork(SQLiteDatabase db) { - - String where; - if (TextUtils.isEmpty(selection)) { - where = InternalMessageColumns.DELETED + " = 0 AND " + InternalMessageColumns.EMPTY + " = 0"; - } else { - where = "(" + selection + ") AND " + - InternalMessageColumns.DELETED + " = 0 AND " + InternalMessageColumns.EMPTY + " = 0"; - } - - final Cursor cursor; - if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) { - StringBuilder query = new StringBuilder(); - query.append("SELECT "); - boolean first = true; - for (String columnName : projection) { - if (!first) { - query.append(","); - } else { - first = false; - } - - if (MessageColumns.ID.equals(columnName)) { - query.append("m."); - query.append(MessageColumns.ID); - query.append(" AS "); - query.append(MessageColumns.ID); - } else { - query.append(columnName); - } - } - - query.append(" FROM messages m " + - "JOIN threads t ON (t.message_id = m.id) " + - "LEFT JOIN folders f ON (m.folder_id = f.id) " + - "WHERE "); - query.append(SqlQueryBuilder.addPrefixToSelection(FIXUP_MESSAGES_COLUMNS, "m.", where)); - query.append(" ORDER BY "); - query.append(SqlQueryBuilder.addPrefixToSelection(FIXUP_MESSAGES_COLUMNS, "m.", sortOrder)); - - cursor = db.rawQuery(query.toString(), selectionArgs); - } else { - cursor = db.query(MESSAGES_TABLE, projection, where, selectionArgs, null, null, sortOrder); - } - - return cursor; - } - }); - } catch (MessagingException e) { - throw new RuntimeException("messaging exception", e); - } - } - - protected Cursor getThreadedMessages(String accountUuid, final String[] projection, final String selection, - final String[] selectionArgs, final String sortOrder) { - - Account account = getAccount(accountUuid); - LockableDatabase database = getDatabase(account); - - try { - return database.execute(false, new DbCallback() { - @Override - public Cursor doDbWork(SQLiteDatabase db) { - - StringBuilder query = new StringBuilder(); - - query.append("SELECT "); - boolean first = true; - for (String columnName : projection) { - if (!first) { - query.append(","); - } else { - first = false; - } - - final String aggregationFunc = THREAD_AGGREGATION_FUNCS.get(columnName); - - if (MessageColumns.ID.equals(columnName)) { - query.append("m." + MessageColumns.ID + " AS " + MessageColumns.ID); - } else if (aggregationFunc != null) { - query.append("a."); - query.append(columnName); - query.append(" AS "); - query.append(columnName); - } else { - query.append(columnName); - } - } - - query.append(" FROM ("); - - createThreadedSubQuery(projection, selection, query); - - query.append(") a "); - - query.append("JOIN " + THREADS_TABLE + " t " + - "ON (t." + ThreadColumns.ROOT + " = a.thread_root) " + - "JOIN " + MESSAGES_TABLE + " m " + - "ON (m." + MessageColumns.ID + " = t." + ThreadColumns.MESSAGE_ID + " AND " + - "m." + InternalMessageColumns.EMPTY + "=0 AND " + - "m." + InternalMessageColumns.DELETED + "=0 AND " + - "m." + MessageColumns.DATE + " = a." + MessageColumns.DATE + - ") "); - - if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) { - query.append("JOIN " + FOLDERS_TABLE + " f " + - "ON (m." + MessageColumns.FOLDER_ID + " = f." + FolderColumns.ID + ") "); - } - - query.append(" GROUP BY " + ThreadColumns.ROOT); - - if (!TextUtils.isEmpty(sortOrder)) { - query.append(" ORDER BY "); - query.append(SqlQueryBuilder.addPrefixToSelection( - FIXUP_AGGREGATED_MESSAGES_COLUMNS, "a.", sortOrder)); - } - - return db.rawQuery(query.toString(), selectionArgs); - } - }); - } catch (MessagingException e) { - throw new RuntimeException("messaging exception", e); - } - } - - private void createThreadedSubQuery(String[] projection, String selection, StringBuilder query) { - query.append("SELECT t." + ThreadColumns.ROOT + " AS thread_root"); - for (String columnName : projection) { - String aggregationFunc = THREAD_AGGREGATION_FUNCS.get(columnName); - - if (SpecialColumns.THREAD_COUNT.equals(columnName)) { - query.append(",COUNT(t." + ThreadColumns.ROOT + ") AS " + SpecialColumns.THREAD_COUNT); - } else if (aggregationFunc != null) { - query.append(","); - query.append(aggregationFunc); - query.append("("); - query.append(columnName); - query.append(") AS "); - query.append(columnName); - } else { - // Skip - } - } - - query.append( - " FROM " + MESSAGES_TABLE + " m " + - "JOIN " + THREADS_TABLE + " t " + - "ON (t." + ThreadColumns.MESSAGE_ID + " = m." + MessageColumns.ID + ")"); - - if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) { - query.append(" JOIN " + FOLDERS_TABLE + " f " + - "ON (m." + MessageColumns.FOLDER_ID + " = f." + FolderColumns.ID + ")"); - } - - query.append(" WHERE (t." + ThreadColumns.ROOT + " IN (" + - "SELECT " + ThreadColumns.ROOT + " " + - "FROM " + MESSAGES_TABLE + " m " + - "JOIN " + THREADS_TABLE + " t " + - "ON (t." + ThreadColumns.MESSAGE_ID + " = m." + MessageColumns.ID + ") " + - "WHERE " + - "m." + InternalMessageColumns.EMPTY + " = 0 AND " + - "m." + InternalMessageColumns.DELETED + " = 0)"); - - - if (!TextUtils.isEmpty(selection)) { - query.append(" AND ("); - query.append(selection); - query.append(")"); - } - - query.append( - ") AND " + - InternalMessageColumns.DELETED + " = 0 AND " + InternalMessageColumns.EMPTY + " = 0"); - - query.append(" GROUP BY t." + ThreadColumns.ROOT); - } - - protected Cursor getThread(String accountUuid, final String[] projection, final String threadId, - final String sortOrder) { - - Account account = getAccount(accountUuid); - LockableDatabase database = getDatabase(account); - - try { - return database.execute(false, new DbCallback() { - @Override - public Cursor doDbWork(SQLiteDatabase db) { - - StringBuilder query = new StringBuilder(); - query.append("SELECT "); - boolean first = true; - for (String columnName : projection) { - if (!first) { - query.append(","); - } else { - first = false; - } - - if (MessageColumns.ID.equals(columnName)) { - query.append("m." + MessageColumns.ID + " AS " + MessageColumns.ID); - } else { - query.append(columnName); - } - } - - query.append(" FROM " + THREADS_TABLE + " t JOIN " + MESSAGES_TABLE + " m " + - "ON (m." + MessageColumns.ID + " = t." + ThreadColumns.MESSAGE_ID + ") "); - - if (Utility.arrayContainsAny(projection, (Object[]) FOLDERS_COLUMNS)) { - query.append("LEFT JOIN " + FOLDERS_TABLE + " f " + - "ON (m." + MessageColumns.FOLDER_ID + " = f." + FolderColumns.ID + ") "); - } - - query.append("WHERE " + - ThreadColumns.ROOT + " = ? AND " + - InternalMessageColumns.DELETED + " = 0 AND " + InternalMessageColumns.EMPTY + " = 0"); - - query.append(" ORDER BY "); - query.append(SqlQueryBuilder.addPrefixToSelection(FIXUP_MESSAGES_COLUMNS, "m.", sortOrder)); - - return db.rawQuery(query.toString(), new String[] { threadId }); - } - }); - } catch (MessagingException e) { - throw new RuntimeException("messaging exception", e); - } - } - - private Account getAccount(String accountUuid) { - if (mPreferences == null) { - Context appContext = getContext().getApplicationContext(); - mPreferences = Preferences.getPreferences(appContext); - } - - Account account = mPreferences.getAccount(accountUuid); - - if (account == null) { - throw new IllegalArgumentException("Unknown account: " + accountUuid); - } - - return account; - } - - private LockableDatabase getDatabase(Account account) { - LocalStore localStore; - try { - localStore = DI.get(LocalStoreProvider.class).getInstance(account); - } catch (MessagingException e) { - throw new RuntimeException("Couldn't get LocalStore", e); - } - - return localStore.getDatabase(); - } - - /** - * This class is needed to make {@link androidx.cursoradapter.widget.CursorAdapter} work with our database schema. - * - *

- * {@code CursorAdapter} requires a column named {@code "_id"} containing a stable id. We use - * the column name {@code "id"} as primary key in all our tables. So this {@link CursorWrapper} - * maps all queries for {@code "_id"} to {@code "id"}. - *

- * Please note that this only works for the returned {@code Cursor}. When querying the content - * provider you still need to use {@link MessageColumns#ID}. - *

- */ - static class IdTrickeryCursor extends CursorWrapper { - public IdTrickeryCursor(Cursor cursor) { - super(cursor); - } - - @Override - public int getColumnIndex(String columnName) { - if ("_id".equals(columnName)) { - return super.getColumnIndex("id"); - } - - return super.getColumnIndex(columnName); - } - - @Override - public int getColumnIndexOrThrow(String columnName) { - if ("_id".equals(columnName)) { - return super.getColumnIndexOrThrow("id"); - } - - return super.getColumnIndexOrThrow(columnName); - } - } - - static class SpecialColumnsCursor extends CursorWrapper { - private int[] mColumnMapping; - private String[] mSpecialColumnValues; - private String[] mColumnNames; - - public SpecialColumnsCursor(Cursor cursor, String[] allColumnNames, Map specialColumns) { - super(cursor); - - mColumnNames = allColumnNames; - mColumnMapping = new int[allColumnNames.length]; - mSpecialColumnValues = new String[specialColumns.size()]; - for (int i = 0, columnIndex = 0, specialColumnCount = 0, len = allColumnNames.length; i < len; i++) { - String columnName = allColumnNames[i]; - - if (specialColumns.containsKey(columnName)) { - // This is a special column name, so save the value in mSpecialColumnValues - mSpecialColumnValues[specialColumnCount] = specialColumns.get(columnName); - - // Write the index into mSpecialColumnValues negated into mColumnMapping - mColumnMapping[i] = -(specialColumnCount + 1); - specialColumnCount++; - } else { - mColumnMapping[i] = columnIndex++; - } - } - } - - @Override - public byte[] getBlob(int columnIndex) { - int realColumnIndex = mColumnMapping[columnIndex]; - if (realColumnIndex < 0) { - throw new RuntimeException("Special column can only be retrieved as string."); - } - - return super.getBlob(realColumnIndex); - } - - @Override - public int getColumnCount() { - return mColumnMapping.length; - } - - @Override - public int getColumnIndex(String columnName) { - for (int i = 0, len = mColumnNames.length; i < len; i++) { - if (mColumnNames[i].equals(columnName)) { - return i; - } - } - - return super.getColumnIndex(columnName); - } - - @Override - public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException { - int index = getColumnIndex(columnName); - if (index == -1) { - throw new IllegalArgumentException("Unknown column name"); - } - - return index; - } - - @Override - public String getColumnName(int columnIndex) { - return mColumnNames[columnIndex]; - } - - @Override - public String[] getColumnNames() { - return mColumnNames.clone(); - } - - @Override - public double getDouble(int columnIndex) { - int realColumnIndex = mColumnMapping[columnIndex]; - if (realColumnIndex < 0) { - throw new RuntimeException("Special column can only be retrieved as string."); - } - - return super.getDouble(realColumnIndex); - } - - @Override - public float getFloat(int columnIndex) { - int realColumnIndex = mColumnMapping[columnIndex]; - if (realColumnIndex < 0) { - throw new RuntimeException("Special column can only be retrieved as string."); - } - - return super.getFloat(realColumnIndex); - } - - @Override - public int getInt(int columnIndex) { - int realColumnIndex = mColumnMapping[columnIndex]; - if (realColumnIndex < 0) { - throw new RuntimeException("Special column can only be retrieved as string."); - } - - return super.getInt(realColumnIndex); - } - - @Override - public long getLong(int columnIndex) { - int realColumnIndex = mColumnMapping[columnIndex]; - if (realColumnIndex < 0) { - throw new RuntimeException("Special column can only be retrieved as string."); - } - - return super.getLong(realColumnIndex); - } - - @Override - public short getShort(int columnIndex) { - int realColumnIndex = mColumnMapping[columnIndex]; - if (realColumnIndex < 0) { - throw new RuntimeException("Special column can only be retrieved as string."); - } - - return super.getShort(realColumnIndex); - } - - @Override - public String getString(int columnIndex) { - int realColumnIndex = mColumnMapping[columnIndex]; - if (realColumnIndex < 0) { - return mSpecialColumnValues[-realColumnIndex - 1]; - } - - return super.getString(realColumnIndex); - } - - @Override - public int getType(int columnIndex) { - int realColumnIndex = mColumnMapping[columnIndex]; - if (realColumnIndex < 0) { - return FIELD_TYPE_STRING; - } - - return super.getType(realColumnIndex); - } - - @Override - public boolean isNull(int columnIndex) { - int realColumnIndex = mColumnMapping[columnIndex]; - if (realColumnIndex < 0) { - return (mSpecialColumnValues[-realColumnIndex - 1] == null); - } - - return super.isNull(realColumnIndex); - } - } -} diff --git a/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java b/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java index 37fcfddeb1a9da163ff2edcfc33569231bc6e7fd..754bc125cae1e42edf401ef584b9cf37fc7050a3 100644 --- a/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java +++ b/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java @@ -174,7 +174,7 @@ public class RawMessageProvider extends ContentProvider { long folderId = messageReference.getFolderId(); String uid = messageReference.getUid(); - Account account = Preferences.getPreferences(getContext()).getAccount(accountUuid); + Account account = Preferences.getPreferences().getAccount(accountUuid); if (account == null) { Timber.w("Account not found: %s", accountUuid); return null; diff --git a/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java b/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java index 06b14bef874ed817aae3bc7679f164a2951ea6d5..4ce3bcc9a3453f2b0092a640b9c6146a2a2141d9 100644 --- a/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java +++ b/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java @@ -66,7 +66,7 @@ public class SqlQueryBuilder { if (condition.attribute != Attribute.CONTAINS) { Timber.e("message contents can only be matched!"); } - query.append("m.id IN (SELECT docid FROM messages_fulltext WHERE fulltext MATCH ?)"); + query.append("messages.id IN (SELECT docid FROM messages_fulltext WHERE fulltext MATCH ?)"); selectionArgs.add(fulltextQueryString); break; } diff --git a/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java b/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java index efc8bf620876e1df7d44a074ce983d04e54a91c0..6befea43a8672ef68aaed040e61bb5a02ec9e927 100644 --- a/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java +++ b/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java @@ -179,7 +179,7 @@ public class DatabaseUpgradeService extends Service { * Upgrade the accounts' databases. */ private void upgradeDatabases() { - Preferences preferences = Preferences.getPreferences(this); + Preferences preferences = Preferences.getPreferences(); List accounts = preferences.getAccounts(); mProgressEnd = accounts.size(); diff --git a/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderParserTest.java b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderParserTest.java index 283d0841f3c604fb0d3bed9d655930c1c1e37448..56f45561bf89feccff3af8bfee7de388495d47ee 100644 --- a/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderParserTest.java +++ b/app/core/src/test/java/com/fsck/k9/autocrypt/AutocryptHeaderParserTest.java @@ -23,7 +23,7 @@ public class AutocryptHeaderParserTest extends RobolectricTest { @Before public void setUp() throws Exception { - BinaryTempFileBody.setTempDirectory(RuntimeEnvironment.application.getCacheDir()); + BinaryTempFileBody.setTempDirectory(RuntimeEnvironment.getApplication().getCacheDir()); } // Test cases taken from: https://github.com/mailencrypt/autocrypt/tree/master/src/tests/data diff --git a/app/core/src/test/java/com/fsck/k9/cache/EmailProviderCacheTest.java b/app/core/src/test/java/com/fsck/k9/cache/EmailProviderCacheTest.java deleted file mode 100644 index 4c2b75ada2db866cc7fe51890aeab86f1d7123bc..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/cache/EmailProviderCacheTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.fsck.k9.cache; - - -import java.util.Collections; -import java.util.UUID; - -import android.net.Uri; - -import com.fsck.k9.RobolectricTest; -import com.fsck.k9.mailstore.LocalFolder; -import com.fsck.k9.mailstore.LocalMessage; -import com.fsck.k9.provider.EmailProvider; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.robolectric.RuntimeEnvironment; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.when; - - -public class EmailProviderCacheTest extends RobolectricTest { - - private EmailProviderCache cache; - @Mock - private LocalMessage mockLocalMessage; - @Mock - private LocalFolder mockLocalMessageFolder; - private Long localMessageId = 1L; - private Long localMessageFolderId = 2L; - - @Before - public void before() { - MockitoAnnotations.initMocks(this); - EmailProvider.CONTENT_URI = Uri.parse("content://test.provider.email"); - - cache = EmailProviderCache.getCache(UUID.randomUUID().toString(), RuntimeEnvironment.application); - when(mockLocalMessage.getDatabaseId()).thenReturn(localMessageId); - when(mockLocalMessage.getFolder()).thenReturn(mockLocalMessageFolder); - when(mockLocalMessageFolder.getDatabaseId()).thenReturn(localMessageFolderId); - } - - @Test - public void getCache_returnsDifferentCacheForEachUUID() { - EmailProviderCache cache = EmailProviderCache.getCache("u001", RuntimeEnvironment.application); - EmailProviderCache cache2 = EmailProviderCache.getCache("u002", RuntimeEnvironment.application); - - assertNotEquals(cache, cache2); - } - - @Test - public void getCache_returnsSameCacheForAUUID() { - EmailProviderCache cache = EmailProviderCache.getCache("u001", RuntimeEnvironment.application); - EmailProviderCache cache2 = EmailProviderCache.getCache("u001", RuntimeEnvironment.application); - - assertSame(cache, cache2); - } - - @Test - public void getValueForMessage_returnsValueSetForMessage() { - cache.setValueForMessages(Collections.singletonList(1L), "subject", "Subject"); - - String result = cache.getValueForMessage(1L, "subject"); - - assertEquals("Subject", result); - } - - @Test - public void getValueForUnknownMessage_returnsNull() { - String result = cache.getValueForMessage(1L, "subject"); - - assertNull(result); - } - - @Test - public void getValueForUnknownMessage_returnsNullWhenRemoved() { - cache.setValueForMessages(Collections.singletonList(1L), "subject", "Subject"); - cache.removeValueForMessages(Collections.singletonList(1L), "subject"); - - String result = cache.getValueForMessage(1L, "subject"); - - assertNull(result); - } - - @Test - public void getValueForThread_returnsValueSetForThread() { - cache.setValueForThreads(Collections.singletonList(1L), "subject", "Subject"); - - String result = cache.getValueForThread(1L, "subject"); - - assertEquals("Subject", result); - } - - @Test - public void getValueForUnknownThread_returnsNull() { - String result = cache.getValueForThread(1L, "subject"); - - assertNull(result); - } - - @Test - public void getValueForUnknownThread_returnsNullWhenRemoved() { - cache.setValueForThreads(Collections.singletonList(1L), "subject", "Subject"); - cache.removeValueForThreads(Collections.singletonList(1L), "subject"); - - String result = cache.getValueForThread(1L, "subject"); - - assertNull(result); - } - - @Test - public void isMessageHidden_returnsTrueForHiddenMessage() { - cache.hideMessages(Collections.singletonList(mockLocalMessage)); - - boolean result = cache.isMessageHidden(localMessageId, localMessageFolderId); - - assertTrue(result); - } - - @Test - public void isMessageHidden_returnsFalseForUnknownMessage() { - boolean result = cache.isMessageHidden(localMessageId, localMessageFolderId); - - assertFalse(result); - } - - @Test - public void isMessageHidden_returnsFalseForUnhidenMessage() { - cache.hideMessages(Collections.singletonList(mockLocalMessage)); - cache.unhideMessages(Collections.singletonList(mockLocalMessage)); - - boolean result = cache.isMessageHidden(localMessageId, localMessageFolderId); - - assertFalse(result); - } - -} diff --git a/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java b/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java index 978788b78a074b5b5e173683530bebebb3c4578e..641dbc62e0831b98a96a487dfda7355f53064459 100644 --- a/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java +++ b/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java @@ -137,9 +137,9 @@ public class MessagingControllerTest extends K9RobolectricTest { public void setUp() throws MessagingException { ShadowLog.stream = System.out; MockitoAnnotations.initMocks(this); - appContext = RuntimeEnvironment.application; + appContext = RuntimeEnvironment.getApplication(); - preferences = Preferences.getPreferences(appContext); + preferences = Preferences.getPreferences(); controller = new MessagingController(appContext, notificationController, notificationStrategy, localStoreProvider, messageCountsProvider, backendManager, preferences, messageStoreManager, diff --git a/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.java b/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.java index 85347a322004908c4d3df12254c5f9572aa6c7d1..9f003be37836efa5d2735eceddc512b54472a083 100644 --- a/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.java +++ b/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.java @@ -22,7 +22,7 @@ public class MessageHelperTest extends RobolectricTest { @Before public void setUp() throws Exception { - Context context = RuntimeEnvironment.application; + Context context = RuntimeEnvironment.getApplication(); contacts = new Contacts(context); contactsWithFakeContact = new Contacts(context) { @Override public String getNameForAddress(String address) { diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt index 49bc6fc81dc1d75be1da3bca4e96f80fbcf6bb21..ba1488fb017cc035ed98f138ae2c2ae9badf70f4 100644 --- a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt +++ b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt @@ -1,7 +1,6 @@ package com.fsck.k9.mailstore import android.database.sqlite.SQLiteDatabase -import android.net.Uri import androidx.core.content.contentValuesOf import com.fsck.k9.Account import com.fsck.k9.K9RobolectricTest @@ -17,12 +16,10 @@ import com.fsck.k9.mail.MessageDownloadState import com.fsck.k9.mail.internet.MimeMessage import com.fsck.k9.mail.internet.MimeMessageHelper import com.fsck.k9.mail.internet.TextBody -import com.fsck.k9.provider.EmailProvider import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assert.fail -import org.junit.Before import org.junit.Test import org.koin.core.component.inject @@ -36,12 +33,6 @@ class K9BackendFolderTest : K9RobolectricTest() { val backendFolder = createBackendFolder() val database: LockableDatabase = localStoreProvider.getInstance(account).database - @Before - fun setUp() { - // Set EmailProvider.CONTENT_URI so LocalStore.notifyChange() won't crash - EmailProvider.CONTENT_URI = Uri.parse("content://dummy") - } - @After fun tearDown() { preferences.deleteAccount(account) diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt index 780ceb9cf87ecdf06b43c8a160143a9e9916f102..ed0009a78eb3494b29e3ad520a9a326c8cf6ca1c 100644 --- a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt +++ b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendStorageTest.kt @@ -1,15 +1,12 @@ package com.fsck.k9.mailstore -import android.net.Uri import com.fsck.k9.Account import com.fsck.k9.K9RobolectricTest import com.fsck.k9.Preferences import com.fsck.k9.backend.api.BackendStorage import com.fsck.k9.mail.FolderClass -import com.fsck.k9.provider.EmailProvider import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Before import org.junit.Test import org.koin.core.component.inject import org.mockito.kotlin.any @@ -26,12 +23,6 @@ class K9BackendStorageTest : K9RobolectricTest() { val database: LockableDatabase = localStoreProvider.getInstance(account).database val backendStorage = createBackendStorage() - @Before - fun setUp() { - // Set EmailProvider.CONTENT_URI so LocalStore.notifyChange() won't crash - EmailProvider.CONTENT_URI = Uri.parse("content://dummy") - } - @After fun tearDown() { preferences.deleteAccount(account) diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b23c4ff7e6d5779753d92dba8ff2ca3c0678ec76 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListCacheTest.kt @@ -0,0 +1,141 @@ +package com.fsck.k9.mailstore + +import com.fsck.k9.mail.Flag +import com.google.common.truth.Truth.assertThat +import java.util.UUID +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +private const val MESSAGE_ID = 1L +private const val FOLDER_ID = 2L + +class MessageListCacheTest { + private val localFolder = mock { + on { databaseId } doReturn FOLDER_ID + } + + private val localMessage = mock { + on { databaseId } doReturn MESSAGE_ID + on { folder } doReturn localFolder + } + + private val cache = MessageListCache.getCache(UUID.randomUUID().toString()) + + @Before + fun setUp() { + startKoin { + modules( + module { + single { mock() } + } + ) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun `getCache() returns different cache for each UUID`() { + val cache = MessageListCache.getCache("u001") + + val cache2 = MessageListCache.getCache("u002") + + assertThat(cache2).isNotSameInstanceAs(cache) + } + + @Test + fun `getCache() returns same cache for the same UUID`() { + val cache = MessageListCache.getCache("u001") + + val cache2 = MessageListCache.getCache("u001") + + assertThat(cache2).isSameInstanceAs(cache) + } + + @Test + fun `getFlagForMessage() returns value set for message`() { + cache.setFlagForMessages(listOf(1L), Flag.SEEN, true) + + val result = cache.getFlagForMessage(1L, Flag.SEEN) + + assertThat(result).isTrue() + } + + @Test + fun `getFlagForMessage() with unknown message ID returns null`() { + val result = cache.getFlagForMessage(1L, Flag.SEEN) + + assertThat(result).isNull() + } + + @Test + fun `getFlagForMessage() returns null when removed`() { + cache.setFlagForMessages(listOf(1L), Flag.FLAGGED, false) + cache.removeFlagForMessages(listOf(1L), Flag.FLAGGED) + + val result = cache.getFlagForMessage(1L, Flag.FLAGGED) + + assertThat(result).isNull() + } + + @Test + fun `getFlagForThread() returns value set for thread`() { + cache.setValueForThreads(listOf(1L), Flag.SEEN, false) + + val result = cache.getFlagForThread(1L, Flag.SEEN) + + assertThat(result).isFalse() + } + + @Test + fun `getFlagForThread() with unknown message ID returns null`() { + val result = cache.getFlagForThread(1L, Flag.ANSWERED) + + assertThat(result).isNull() + } + + @Test + fun `getFlagForThread() returns null when removed`() { + cache.setValueForThreads(listOf(1L), Flag.SEEN, true) + cache.removeFlagForThreads(listOf(1L), Flag.SEEN) + + val result = cache.getFlagForThread(1L, Flag.SEEN) + + assertThat(result).isNull() + } + + @Test + fun `isMessageHidden() returns true for hidden message`() { + cache.hideMessages(listOf(localMessage)) + + val result = cache.isMessageHidden(MESSAGE_ID, FOLDER_ID) + + assertThat(result).isTrue() + } + + @Test + fun `isMessageHidden() returns false for unknown message`() { + val result = cache.isMessageHidden(MESSAGE_ID, FOLDER_ID) + + assertThat(result).isFalse() + } + + @Test + fun `isMessageHidden() returns false for unhidden message`() { + cache.hideMessages(listOf(localMessage)) + cache.unhideMessages(listOf(localMessage)) + + val result = cache.isMessageHidden(MESSAGE_ID, FOLDER_ID) + + assertThat(result).isFalse() + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..300356f22f480216c0d83145aee4316683561143 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageListRepositoryTest.kt @@ -0,0 +1,433 @@ +package com.fsck.k9.mailstore + +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Flag +import com.fsck.k9.message.extractors.PreviewResult +import com.google.common.truth.Truth.assertThat +import java.util.UUID +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub + +private const val MESSAGE_ID = 1L +private const val MESSAGE_ID_2 = 2L +private const val MESSAGE_ID_3 = 3L +private const val FOLDER_ID = 20L +private const val FOLDER_ID_2 = 21L +private const val THREAD_ROOT = 30L +private const val THREAD_ROOT_2 = 31L + +private const val SELECTION = "irrelevant" +private val SELECTION_ARGS = arrayOf("irrelevant") +private const val SORT_ORDER = "irrelevant" + +class MessageListRepositoryTest { + private val accountUuid = UUID.randomUUID().toString() + + private val messageStore = mock() + private val messageStoreManager = mock { + on { getMessageStore(accountUuid) } doReturn messageStore + } + + private val messageListRepository = MessageListRepository(messageStoreManager) + + @Before + fun setUp() { + startKoin { + modules( + module { + single { messageListRepository } + } + ) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun `adding and removing listener`() { + var messageListChanged = 0 + val listener = MessageListChangedListener { + messageListChanged++ + } + messageListRepository.addListener(accountUuid, listener) + + messageListRepository.notifyMessageListChanged(accountUuid) + + assertThat(messageListChanged).isEqualTo(1) + + messageListRepository.removeListener(listener) + + messageListRepository.notifyMessageListChanged(accountUuid) + + assertThat(messageListChanged).isEqualTo(1) + } + + @Test + fun `only notify listener when account UUID matches`() { + var messageListChanged = 0 + val listener = MessageListChangedListener { + messageListChanged++ + } + messageListRepository.addListener(accountUuid, listener) + + messageListRepository.notifyMessageListChanged("otherAccountUuid") + + assertThat(messageListChanged).isEqualTo(0) + } + + @Test + fun `notifyMessageListChanged() without any listeners should not throw`() { + messageListRepository.notifyMessageListChanged(accountUuid) + } + + @Test + fun `getMessages() should use flag values from the cache`() { + addMessages( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = true, + isAnswered = false, + isForwarded = true + ) + ) + MessageListCache.getCache(accountUuid).apply { + setFlagForMessages(listOf(MESSAGE_ID), Flag.SEEN, true) + setValueForThreads(listOf(THREAD_ROOT), Flag.FLAGGED, false) + } + + val result = messageListRepository.getMessages(accountUuid, SELECTION, SELECTION_ARGS, SORT_ORDER) { message -> + MessageData( + messageId = message.id, + folderId = message.folderId, + threadRoot = message.threadRoot, + isRead = message.isRead, + isStarred = message.isStarred, + isAnswered = message.isAnswered, + isForwarded = message.isForwarded + ) + } + + assertThat(result).containsExactly( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = true, + isStarred = false, + isAnswered = false, + isForwarded = true + ) + ) + } + + @Test + fun `getMessages() should skip messages marked as hidden in the cache`() { + addMessages( + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT) + ) + hideMessage(MESSAGE_ID, FOLDER_ID) + + val result = messageListRepository.getMessages(accountUuid, SELECTION, SELECTION_ARGS, SORT_ORDER) { message -> + message.id + } + + assertThat(result).containsExactly(MESSAGE_ID_2) + } + + @Test + fun `getMessages() should not skip message when marked as hidden in a different folder`() { + addMessages( + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT) + ) + hideMessage(MESSAGE_ID, FOLDER_ID_2) + + val result = messageListRepository.getMessages(accountUuid, SELECTION, SELECTION_ARGS, SORT_ORDER) { message -> + message.id + } + + assertThat(result).containsExactly(MESSAGE_ID, MESSAGE_ID_2) + } + + @Test + fun `getThreadedMessages() should use flag values from the cache`() { + addThreadedMessages( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = true, + isAnswered = false, + isForwarded = true + ) + ) + MessageListCache.getCache(accountUuid).apply { + setFlagForMessages(listOf(MESSAGE_ID), Flag.SEEN, true) + setValueForThreads(listOf(THREAD_ROOT), Flag.FLAGGED, false) + } + + val result = messageListRepository.getThreadedMessages( + accountUuid, + SELECTION, + SELECTION_ARGS, + SORT_ORDER + ) { message -> + MessageData( + messageId = message.id, + folderId = message.folderId, + threadRoot = message.threadRoot, + isRead = message.isRead, + isStarred = message.isStarred, + isAnswered = message.isAnswered, + isForwarded = message.isForwarded + ) + } + + assertThat(result).containsExactly( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = true, + isStarred = false, + isAnswered = false, + isForwarded = true + ) + ) + } + + @Test + fun `getThreadedMessages() should skip messages marked as hidden in the cache`() { + addThreadedMessages( + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT_2) + ) + hideMessage(MESSAGE_ID, FOLDER_ID) + + val result = messageListRepository.getThreadedMessages( + accountUuid, + SELECTION, + SELECTION_ARGS, + SORT_ORDER + ) { message -> + message.id + } + + assertThat(result).containsExactly(MESSAGE_ID_2) + } + + @Test + fun `getThreadedMessages() should not skip message when marked as hidden in a different folder`() { + addThreadedMessages( + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT_2) + ) + hideMessage(MESSAGE_ID, FOLDER_ID_2) + + val result = messageListRepository.getThreadedMessages( + accountUuid, + SELECTION, + SELECTION_ARGS, + SORT_ORDER + ) { message -> + message.id + } + + assertThat(result).containsExactly(MESSAGE_ID, MESSAGE_ID_2) + } + + @Test + fun `getThread() should use flag values from the cache`() { + addMessagesToThread( + THREAD_ROOT, + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = true, + isAnswered = false, + isForwarded = true + ), + MessageData( + messageId = MESSAGE_ID_2, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = true, + isAnswered = true, + isForwarded = false + ) + ) + MessageListCache.getCache(accountUuid).apply { + setFlagForMessages(listOf(MESSAGE_ID), Flag.SEEN, true) + setValueForThreads(listOf(THREAD_ROOT), Flag.FLAGGED, false) + } + + val result = messageListRepository.getThread( + accountUuid, + THREAD_ROOT, + SORT_ORDER + ) { message -> + MessageData( + messageId = message.id, + folderId = message.folderId, + threadRoot = message.threadRoot, + isRead = message.isRead, + isStarred = message.isStarred, + isAnswered = message.isAnswered, + isForwarded = message.isForwarded + ) + } + + assertThat(result).containsExactly( + MessageData( + messageId = MESSAGE_ID, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = true, + isStarred = false, + isAnswered = false, + isForwarded = true + ), + MessageData( + messageId = MESSAGE_ID_2, + folderId = FOLDER_ID, + threadRoot = THREAD_ROOT, + isRead = false, + isStarred = false, + isAnswered = true, + isForwarded = false + ) + ) + } + + @Test + fun `getThread() should skip messages marked as hidden in the cache`() { + addMessagesToThread( + THREAD_ROOT, + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_3, folderId = FOLDER_ID, threadRoot = THREAD_ROOT) + ) + hideMessage(MESSAGE_ID, FOLDER_ID) + + val result = messageListRepository.getThread(accountUuid, THREAD_ROOT, SORT_ORDER) { message -> message.id } + + assertThat(result).containsExactly(MESSAGE_ID_2, MESSAGE_ID_3) + } + + @Test + fun `getThread() should not skip message when marked as hidden in a different folder`() { + addMessagesToThread( + THREAD_ROOT, + MessageData(messageId = MESSAGE_ID, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_2, folderId = FOLDER_ID, threadRoot = THREAD_ROOT), + MessageData(messageId = MESSAGE_ID_3, folderId = FOLDER_ID, threadRoot = THREAD_ROOT) + ) + hideMessage(MESSAGE_ID, FOLDER_ID_2) + + val result = messageListRepository.getThread(accountUuid, THREAD_ROOT, SORT_ORDER) { message -> message.id } + + assertThat(result).containsExactly(MESSAGE_ID, MESSAGE_ID_2, MESSAGE_ID_3) + } + + private fun addMessages(vararg messages: MessageData) { + messageStore.stub { + on { getMessages(eq(SELECTION), eq(SELECTION_ARGS), eq(SORT_ORDER), any()) } doAnswer { + val mapper: MessageMapper = it.getArgument(3) + + runMessageMapper(messages, mapper) + } + } + } + + private fun addThreadedMessages(vararg messages: MessageData) { + messageStore.stub { + on { getThreadedMessages(eq(SELECTION), eq(SELECTION_ARGS), eq(SORT_ORDER), any()) } doAnswer { + val mapper: MessageMapper = it.getArgument(3) + + runMessageMapper(messages, mapper) + } + } + } + + @Suppress("SameParameterValue") + private fun addMessagesToThread(threadRoot: Long, vararg messages: MessageData) { + messageStore.stub { + on { getThread(eq(threadRoot), eq(SORT_ORDER), any()) } doAnswer { + val mapper: MessageMapper = it.getArgument(2) + + runMessageMapper(messages, mapper) + } + } + } + + private fun runMessageMapper(messages: Array, mapper: MessageMapper): List { + return messages.mapNotNull { message -> + mapper.map(object : MessageDetailsAccessor { + override val id = message.messageId + override val messageServerId = "irrelevant" + override val folderId = message.folderId + override val fromAddresses = emptyList
() + override val toAddresses = emptyList
() + override val ccAddresses = emptyList
() + override val messageDate = 0L + override val internalDate = 0L + override val subject = "irrelevant" + override val preview = PreviewResult.error() + override val isRead = message.isRead + override val isStarred = message.isStarred + override val isAnswered = message.isAnswered + override val isForwarded = message.isForwarded + override val hasAttachments = false + override val threadRoot = message.threadRoot + override val threadCount = 0 + }) + } + } + + @Suppress("SameParameterValue") + private fun hideMessage(messageId: Long, folderId: Long) { + val cache = MessageListCache.getCache(accountUuid) + + val localFolder = mock { + on { databaseId } doReturn folderId + } + + val localMessage = mock { + on { databaseId } doReturn messageId + on { folder } doReturn localFolder + } + + cache.hideMessages(listOf(localMessage)) + } +} + +private data class MessageData( + val messageId: Long, + val folderId: Long, + val threadRoot: Long, + val isRead: Boolean = false, + val isStarred: Boolean = false, + val isAnswered: Boolean = false, + val isForwarded: Boolean = false, +) diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java b/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java index 65a65de70dc1bba7181ed229f0441dc1b1f08711..16fce8e4de9a0ba27f054d708320c9e8d6987719 100644 --- a/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java @@ -73,7 +73,7 @@ public class MessageViewInfoExtractorTest extends K9RobolectricTest { @Before public void setUp() throws Exception { - context = RuntimeEnvironment.application; + context = RuntimeEnvironment.getApplication(); HtmlProcessor htmlProcessor = createFakeHtmlProcessor(); attachmentInfoExtractor = spy(DI.get(AttachmentInfoExtractor.class)); diff --git a/app/core/src/test/java/com/fsck/k9/message/extractors/AttachmentInfoExtractorTest.java b/app/core/src/test/java/com/fsck/k9/message/extractors/AttachmentInfoExtractorTest.java index 3fa4896e378f77329213bfb8276dc5370774ad43..c83476a9b24bb7eaf6fa5c77155ad3eed7723808 100644 --- a/app/core/src/test/java/com/fsck/k9/message/extractors/AttachmentInfoExtractorTest.java +++ b/app/core/src/test/java/com/fsck/k9/message/extractors/AttachmentInfoExtractorTest.java @@ -41,7 +41,7 @@ public class AttachmentInfoExtractorTest extends RobolectricTest { @Before public void setUp() throws Exception { AttachmentProvider.CONTENT_URI = Uri.parse("content://test.attachmentprovider"); - context = RuntimeEnvironment.application; + context = RuntimeEnvironment.getApplication(); attachmentInfoExtractor = new AttachmentInfoExtractor(context); } diff --git a/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.java b/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.java deleted file mode 100644 index bf5f60686095205d38a1827b89ec07f0dfefa6eb..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.fsck.k9.message.signature; - - -import org.junit.Test; - -import static com.fsck.k9.message.html.HtmlHelper.extractText; -import static org.junit.Assert.assertEquals; - - -public class HtmlSignatureRemoverTest { - @Test - public void shouldStripSignatureFromK9StyleHtml() throws Exception { - String html = "This is the body text" + - "
" + - "--
" + - "Sent from my Android device with K-9 Mail. Please excuse my brevity."; - - String withoutSignature = HtmlSignatureRemover.stripSignature(html); - - assertEquals("This is the body text", extractText(withoutSignature)); - } - - @Test - public void shouldStripSignatureFromThunderbirdStyleHtml() throws Exception { - String html = "\r\n" + - " \r\n" + - " \r\n" + - " \r\n" + - " \r\n" + - "

This is the body text
\r\n" + - "

\r\n" + - " --
\r\n" + - "
Sent from my Android device with K-9 Mail." + - " Please excuse my brevity.
\r\n" + - " \r\n" + - ""; - - String withoutSignature = HtmlSignatureRemover.stripSignature(html); - - assertEquals("This is the body text", extractText(withoutSignature)); - } - - @Test - public void shouldStripSignatureBeforeBlockquoteTag() throws Exception { - String html = "" + - "
" + - "This is the body text" + - "
" + - "--
" + - "
" + - "Sent from my Android device with K-9 Mail. Please excuse my brevity." + - "
" + - "
" + - ""; - - String withoutSignature = HtmlSignatureRemover.stripSignature(html); - - assertEquals("" + - "
This is the body text
" + - "", - withoutSignature); - } - - @Test - public void shouldNotStripSignatureInsideBlockquoteTags() throws Exception { - String html = "" + - "
" + - "This is some quoted text" + - "
" + - "--
" + - "Inner signature" + - "
" + - "
" + - "This is the body text" + - "
" + - ""; - - String withoutSignature = HtmlSignatureRemover.stripSignature(html); - - assertEquals("" + - "
" + - "This is some quoted text" + - "
" + - "--
" + - "Inner signature" + - "
" + - "
This is the body text
" + - "", - withoutSignature); - } - - @Test - public void shouldStripSignatureBetweenBlockquoteTags() throws Exception { - String html = "" + - "
" + - "Some quote" + - "
" + - "
" + - "This is the body text" + - "
" + - "--
" + - "
" + - "Sent from my Android device with K-9 Mail. Please excuse my brevity." + - "
" + - "
" + - "--
" + - "Signature inside signature" + - "
" + - ""; - - String withoutSignature = HtmlSignatureRemover.stripSignature(html); - - assertEquals("" + - "
Some quote
" + - "
This is the body text
" + - "", - withoutSignature); - } - - @Test - public void shouldStripSignatureAfterLastBlockquoteTags() throws Exception { - String html = "" + - "This is the body text" + - "
" + - "
" + - "Some quote" + - "
" + - "
" + - "--
" + - "Sent from my Android device with K-9 Mail. Please excuse my brevity." + - ""; - - String withoutSignature = HtmlSignatureRemover.stripSignature(html); - - assertEquals("" + - "This is the body text
" + - "
Some quote
" + - "", - withoutSignature); - } -} diff --git a/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt b/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f9ca1af7d4d848363a831152e038d51d62b5cfc7 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/signature/HtmlSignatureRemoverTest.kt @@ -0,0 +1,184 @@ +package com.fsck.k9.message.signature + +import com.fsck.k9.message.html.HtmlHelper.extractText +import com.fsck.k9.message.signature.HtmlSignatureRemover.Companion.stripSignature +import com.fsck.k9.testing.removeNewlines +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class HtmlSignatureRemoverTest { + @Test + fun `old K-9 Mail signature format`() { + val html = + """This is the body text
--
Sent from my Android device with K-9 Mail. Please excuse my brevity.""" + + val withoutSignature = stripSignature(html) + + assertThat(extractText(withoutSignature)).isEqualTo("This is the body text") + } + + @Test + fun `old Thunderbird signature format`() { + val html = + """ + + + + + +

This is the body text
+

+ --
+
Sent from my Android device with K-9 Mail. Please excuse my brevity.
+ + + """.trimIndent() + + val withoutSignature = stripSignature(html) + + assertThat(extractText(withoutSignature)).isEqualTo("This is the body text") + } + + @Test + fun `signature before blockquote tag`() { + val html = + """ + + + +
+ This is the body text
+ --
+
Sent from my Android device with K-9 Mail. Please excuse my brevity.
+
+ + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo( + """
This is the body text
""" + ) + } + + @Test + fun `should not strip signature inside blockquote tag`() { + val html = + """ + + + +
+ This is some quoted text
+ --
+ Inner signature +
+
+ This is the body text +
+ + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo(html) + } + + @Test + fun `signature between blockquote tags`() { + val html = + """ + + + +
Some quote
+
This is the body text
+ --
+
Sent from my Android device with K-9 Mail. Please excuse my brevity.
+
--
Signature inside signature +
+ + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo( + """ + + + +
Some quote
+
This is the body text
+ + + """.trimIndent().removeNewlines() + ) + } + + @Test + fun `signature after last blockquote tag`() { + val html = + """ + + + + This is the body text
+
Some quote
+
+ --
+ Sent from my Android device with K-9 Mail. Please excuse my brevity. + + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo( + """ + + + + This is the body text
+
Some quote
+ + + """.trimIndent().removeNewlines() + ) + } + + @Test + fun `K-9 Mail signature format`() { + val html = + """ + + + + This is the body text.
+
+
+ --
+ And this is the signature text. +
+ + + """.trimIndent().removeNewlines() + + val withoutSignature = stripSignature(html) + + assertThat(withoutSignature).isEqualTo( + """ + + + + + This is the body text.
+
+ + + """.trimIndent().removeNewlines() + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt b/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt index e3177f825640fd79e8592cb4c355efb9550c16d4..5819c1ad89208042afb954a554dacd0243faf35c 100644 --- a/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt +++ b/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt @@ -15,7 +15,7 @@ import org.mockito.kotlin.mock import org.robolectric.RuntimeEnvironment class SettingsExporterTest : K9RobolectricTest() { - private val contentResolver = RuntimeEnvironment.application.contentResolver + private val contentResolver = RuntimeEnvironment.getApplication().contentResolver private val preferences: Preferences by inject() private val folderSettingsProvider: FolderSettingsProvider by inject() private val folderRepository: FolderRepository by inject() diff --git a/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java b/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java index afd4f4605b9905a48eedd873cbd09d093b0970e2..5ec36956d8a4df56eee62843e517c9f26c8c1c8a 100644 --- a/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java +++ b/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java @@ -6,6 +6,8 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import android.content.Context; + import com.fsck.k9.K9RobolectricTest; import com.fsck.k9.Preferences; import com.fsck.k9.mail.AuthType; @@ -20,6 +22,7 @@ import static org.junit.Assert.assertTrue; public class SettingsImporterTest extends K9RobolectricTest { + private final Context context = RuntimeEnvironment.getApplication(); @Before public void before() { @@ -27,7 +30,7 @@ public class SettingsImporterTest extends K9RobolectricTest { } private void deletePreExistingAccounts() { - Preferences preferences = Preferences.getPreferences(RuntimeEnvironment.application); + Preferences preferences = Preferences.getPreferences(); preferences.clearAccounts(); } @@ -36,7 +39,7 @@ public class SettingsImporterTest extends K9RobolectricTest { InputStream inputStream = inputStreamOf(""); List accountUuids = new ArrayList<>(); - SettingsImporter.importSettings(RuntimeEnvironment.application, inputStream, true, accountUuids, true); + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); } @Test(expected = SettingsImportExportException.class) @@ -44,7 +47,7 @@ public class SettingsImporterTest extends K9RobolectricTest { InputStream inputStream = inputStreamOf(""); List accountUuids = new ArrayList<>(); - SettingsImporter.importSettings(RuntimeEnvironment.application, inputStream, true, accountUuids, true); + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); } @Test(expected = SettingsImportExportException.class) @@ -52,7 +55,7 @@ public class SettingsImporterTest extends K9RobolectricTest { InputStream inputStream = inputStreamOf(""); List accountUuids = new ArrayList<>(); - SettingsImporter.importSettings(RuntimeEnvironment.application, inputStream, true, accountUuids, true); + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); } @Test(expected = SettingsImportExportException.class) @@ -60,7 +63,7 @@ public class SettingsImporterTest extends K9RobolectricTest { InputStream inputStream = inputStreamOf(""); List accountUuids = new ArrayList<>(); - SettingsImporter.importSettings(RuntimeEnvironment.application, inputStream, true, accountUuids, true); + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); } @Test(expected = SettingsImportExportException.class) @@ -68,7 +71,7 @@ public class SettingsImporterTest extends K9RobolectricTest { InputStream inputStream = inputStreamOf(""); List accountUuids = new ArrayList<>(); - SettingsImporter.importSettings(RuntimeEnvironment.application, inputStream, true, accountUuids, true); + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); } @Test(expected = SettingsImportExportException.class) @@ -76,7 +79,7 @@ public class SettingsImporterTest extends K9RobolectricTest { InputStream inputStream = inputStreamOf(""); List accountUuids = new ArrayList<>(); - SettingsImporter.importSettings(RuntimeEnvironment.application, inputStream, true, accountUuids, true); + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); } @Test(expected = SettingsImportExportException.class) @@ -84,7 +87,7 @@ public class SettingsImporterTest extends K9RobolectricTest { InputStream inputStream = inputStreamOf(""); List accountUuids = new ArrayList<>(); - SettingsImporter.importSettings(RuntimeEnvironment.application, inputStream, true, accountUuids, true); + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); } @Test @@ -162,7 +165,7 @@ public class SettingsImporterTest extends K9RobolectricTest { accountUuids.add(validUUID); SettingsImporter.ImportResults results = SettingsImporter.importSettings( - RuntimeEnvironment.application, inputStream, true, accountUuids, false); + context, inputStream, true, accountUuids, false); assertEquals(0, results.erroneousAccounts.size()); assertEquals(1, results.importedAccounts.size()); diff --git a/app/html-cleaner/build.gradle b/app/html-cleaner/build.gradle index eff0c3492f96b7e5c5b867e0db82bea5f0f3e2b3..64fa4b0880acc456e3c79035ff8f300c1b7cd68b 100644 --- a/app/html-cleaner/build.gradle +++ b/app/html-cleaner/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' +apply plugin: 'com.android.lint' java { sourceCompatibility = javaVersion diff --git a/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt index 80fa043e840712a188a2b9933bff8f1f908b5927..3bc44effdc79631440bcf5ee8ef46c879d7782fe 100644 --- a/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt +++ b/app/html-cleaner/src/main/java/app/k9mail/html/cleaner/BodyCleaner.kt @@ -15,6 +15,8 @@ internal class BodyCleaner { val allowList = Safelist.relaxed() .addTags("font", "hr", "ins", "del", "center", "map", "area", "title") .addAttributes("font", "color", "face", "size") + .addAttributes("a", "name") + .addAttributes("div", "align") .addAttributes( "table", "align", "background", "bgcolor", "border", "cellpadding", "cellspacing", "width" @@ -36,7 +38,6 @@ internal class BodyCleaner { .addAttributes("img", "usemap") .addAttributes(":all", "class", "style", "id", "dir") .addProtocols("img", "src", "http", "https", "cid", "data") - // Allow all URI schemes in links .removeProtocols("a", "href", "ftp", "http", "https", "mailto") diff --git a/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt b/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt index 12ea9e797b95d85517e04a56615b7f8493a4d17e..8720a3273d5f9dad9e5a210cc6f6b2eeecc9c7eb 100644 --- a/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt +++ b/app/html-cleaner/src/test/java/app/k9mail/html/cleaner/HtmlSanitizerTest.kt @@ -409,6 +409,42 @@ class HtmlSanitizerTest { ) } + @Test + fun `should keep 'align' attribute on 'div' element`() { + val html = """
text
""" + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo( + """ + + + +
text
+ + + """.trimIndent().trimLineBreaks() + ) + } + + @Test + fun `should keep 'name' attribute on 'a' element`() { + val html = """""" + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo( + """ + + + + + + + """.trimIndent().trimLineBreaks() + ) + } + private fun Document.toCompactString(): String { outputSettings() .prettyPrint(false) diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index d74107b3344dc60bde08a4f10827d3494ae60cb7..43b2e2e619c94529251dc19fd7e7720855e9397f 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -49,8 +49,8 @@ android { applicationId "foundation.e.mail" testApplicationId "foundation.e.mail.tests" - versionCode 33001 - versionName '6.301' + versionCode 33003 + versionName '6.303' // Keep in sync with the resource string array 'supported_languages' resConfigs "in", "br", "ca", "cs", "cy", "da", "de", "et", "en", "en_GB", "es", "eo", "eu", "fr", "gd", "gl", diff --git a/app/k9mail/src/main/AndroidManifest.xml b/app/k9mail/src/main/AndroidManifest.xml index 8da6282696617ebf4fdac95fee2064634f0c6ddf..470d3a0d1c0f9ccedd8995599f9e5367f5975ec1 100644 --- a/app/k9mail/src/main/AndroidManifest.xml +++ b/app/k9mail/src/main/AndroidManifest.xml @@ -412,11 +412,6 @@ android:authorities="${applicationId}.messageprovider" android:exported="false" /> - - accounts = Preferences.getPreferences(context).getAccounts(); + Collection accounts = Preferences.getPreferences().getAccounts(); for (Account account : accounts) { if (account.getAccountNumber() == accountNumber) { diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt index 5690639c177cae257bac97e42c483eb7f5fe689b..4766a43beb6811992ec43932f92b287cf2a91758 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt @@ -35,7 +35,7 @@ class UnreadWidgetDataProvider( private fun loadSearchAccountData(configuration: UnreadWidgetConfiguration): UnreadWidgetData { val searchAccount = getSearchAccount(configuration.accountUuid) - val title = searchAccount.name ?: searchAccount.email + val title = searchAccount.name val unreadCount = messagingController.getUnreadMessageCount(searchAccount) val clickIntent = MessageList.intentDisplaySearch(context, searchAccount.relatedSearch, false, true, true) diff --git a/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt b/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt index 14a763039c75cd0067379e4be481cc5b8c52178f..f9da2d72cae4212292ca0cddd7e410e91ee07f0e 100644 --- a/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt +++ b/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt @@ -1,6 +1,5 @@ package com.fsck.k9 -import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.fsck.k9.ui.changelog.ChangeLogMode import com.fsck.k9.ui.changelog.ChangelogViewModel @@ -26,10 +25,10 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(application = App::class) class DependencyInjectionTest : AutoCloseKoinTest() { - val lifecycleOwner = mock { - on { lifecycle } doReturn mock() + private val lifecycleOwner = mock { + on { lifecycle } doReturn mock() } - val autocryptTransferView = mock() + private val autocryptTransferView = mock() @KoinInternalApi @Test @@ -38,9 +37,9 @@ class DependencyInjectionTest : AutoCloseKoinTest() { getKoin().checkModules { withParameter { lifecycleOwner } - create { parametersOf(lifecycleOwner, autocryptTransferView) } - withParameter { RuntimeEnvironment.application } - withParameter { RuntimeEnvironment.application } + withParameters { parametersOf(lifecycleOwner, autocryptTransferView) } + withParameter { RuntimeEnvironment.getApplication() } + withParameter { RuntimeEnvironment.getApplication() } withParameter { ChangeLogMode.CHANGE_LOG } } } diff --git a/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt b/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt index d191653d271717c271199c7f37ad18f5829b1409..6e782f8a8063b830e0ab339e7b9b9238ad8421a1 100644 --- a/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt +++ b/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt @@ -21,7 +21,7 @@ import org.mockito.kotlin.mock import org.robolectric.RuntimeEnvironment class UnreadWidgetDataProviderTest : AppRobolectricTest() { - val context: Context = RuntimeEnvironment.application + val context: Context = RuntimeEnvironment.getApplication() val account = createAccount() val preferences = createPreferences() val messagingController = createMessagingController() diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/FlagMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/FlagMessageOperations.kt index 589d07aaf90ba39d1df4ef04e03bd3906e612639..3abb91d1778c4435e4d7c448d6cbb70119f0db47 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/FlagMessageOperations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/FlagMessageOperations.kt @@ -15,7 +15,7 @@ internal class FlagMessageOperations(private val lockableDatabase: LockableDatab if (flag in SPECIAL_FLAGS) { setSpecialFlags(messageIds, flag, set) } else { - rebuildFlagsColumnValue(messageIds, flag, set) + throw UnsupportedOperationException("not implemented") } } @@ -54,10 +54,6 @@ internal class FlagMessageOperations(private val lockableDatabase: LockableDatab } } - private fun rebuildFlagsColumnValue(messageIds: Collection, flag: Flag, set: Boolean) { - throw UnsupportedOperationException("not implemented") - } - private fun rebuildFlagsColumnValue(folderId: Long, messageServerId: String, flag: Flag, set: Boolean) { lockableDatabase.execute(true) { database -> val oldFlags = database.readFlagsColumn(folderId, messageServerId) diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt index 2f617fa593b4755e20f91190ea03b19587cd32fb..ee69f27d90a1f8010243918eec80b2f67092b99a 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt @@ -9,6 +9,7 @@ import com.fsck.k9.mailstore.CreateFolderInfo import com.fsck.k9.mailstore.FolderDetails import com.fsck.k9.mailstore.FolderMapper import com.fsck.k9.mailstore.LockableDatabase +import com.fsck.k9.mailstore.MessageMapper import com.fsck.k9.mailstore.MessageStore import com.fsck.k9.mailstore.MoreMessages import com.fsck.k9.mailstore.SaveMessageData @@ -35,6 +36,7 @@ class K9MessageStore( 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) @@ -100,6 +102,28 @@ class K9MessageStore( return retrieveMessageOperations.getAllMessagesAndEffectiveDates(folderId) } + override fun getMessages( + selection: String, + selectionArgs: Array, + sortOrder: String, + messageMapper: MessageMapper + ): List { + return retrieveMessageListOperations.getMessages(selection, selectionArgs, sortOrder, messageMapper) + } + + override fun getThreadedMessages( + selection: String, + selectionArgs: Array, + sortOrder: String, + messageMapper: MessageMapper + ): List { + return retrieveMessageListOperations.getThreadedMessages(selection, selectionArgs, sortOrder, messageMapper) + } + + override fun getThread(threadId: Long, sortOrder: String, messageMapper: MessageMapper): List { + return retrieveMessageListOperations.getThread(threadId, sortOrder, messageMapper) + } + override fun getOldestMessageDate(folderId: Long): Date? { return retrieveMessageOperations.getOldestMessageDate(folderId) } @@ -184,6 +208,10 @@ class K9MessageStore( updateFolderOperations.setNotificationClass(folderId, folderClass) } + override fun hasMoreMessages(folderId: Long): MoreMessages { + return retrieveFolderOperations.hasMoreMessages(folderId) + } + override fun setMoreMessages(folderId: Long, moreMessages: MoreMessages) { updateFolderOperations.setMoreMessages(folderId, moreMessages) } diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt index 7a68605927b61632504a0560a1e363e2f16cc800..44bbba649c9fe36869ea1b186929e2a32e5b2e27 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt @@ -8,6 +8,7 @@ import com.fsck.k9.mail.FolderClass import com.fsck.k9.mail.FolderType import com.fsck.k9.mailstore.FolderDetailsAccessor import com.fsck.k9.mailstore.FolderMapper +import com.fsck.k9.mailstore.FolderNotFoundException import com.fsck.k9.mailstore.LockableDatabase import com.fsck.k9.mailstore.MoreMessages import com.fsck.k9.mailstore.toFolderType @@ -157,6 +158,10 @@ internal class RetrieveFolderOperations(private val lockableDatabase: LockableDa } } } + + fun hasMoreMessages(folderId: Long): MoreMessages { + return getFolder(folderId) { it.moreMessages } ?: throw FolderNotFoundException(folderId) + } } private class CursorFolderAccessor(val cursor: Cursor) : FolderDetailsAccessor { diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageListOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageListOperations.kt new file mode 100644 index 0000000000000000000000000000000000000000..45d8b4340cf7434ec7dbf5cc9ba9f3fd4324fde1 --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageListOperations.kt @@ -0,0 +1,242 @@ +package com.fsck.k9.storage.messages + +import android.database.Cursor +import com.fsck.k9.mail.Address +import com.fsck.k9.mailstore.DatabasePreviewType +import com.fsck.k9.mailstore.LockableDatabase +import com.fsck.k9.mailstore.MessageDetailsAccessor +import com.fsck.k9.mailstore.MessageMapper +import com.fsck.k9.message.extractors.PreviewResult +import com.fsck.k9.search.SqlQueryBuilder + +internal class RetrieveMessageListOperations(private val lockableDatabase: LockableDatabase) { + + fun getMessages( + selection: String, + selectionArgs: Array, + sortOrder: String, + mapper: MessageMapper + ): List { + 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 + """.trimIndent(), + selectionArgs, + ).use { cursor -> + val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = false) + buildList { + while (cursor.moveToNext()) { + val value = mapper.map(cursorMessageAccessor) + if (value != null) { + add(value) + } + } + } + } + } + } + + fun getThreadedMessages( + selection: String, + selectionArgs: Array, + sortOrder: String, + mapper: MessageMapper + ): List { + val orderBy = SqlQueryBuilder.addPrefixToSelection(AGGREGATED_MESSAGES_COLUMNS, "aggregated.", sortOrder) + + return lockableDatabase.execute(false) { database -> + database.rawQuery( + """ + SELECT + messages.id AS id, + uid, + folder_id, + sender_list, + to_list, + cc_list, + aggregated.date AS date, + aggregated.internal_date AS internal_date, + subject, + preview_type, + preview, + aggregated.read AS read, + aggregated.flagged AS flagged, + aggregated.answered AS answered, + aggregated.forwarded AS forwarded, + aggregated.attachment_count AS attachment_count, + root, + aggregated.thread_count AS thread_count + FROM ( + SELECT + threads.root AS thread_root, + MAX(date) AS date, + MAX(internal_date) AS internal_date, + MIN(read) AS read, + MAX(flagged) AS flagged, + MIN(answered) AS answered, + MIN(forwarded) AS forwarded, + SUM(attachment_count) AS attachment_count, + COUNT(threads.root) AS thread_count + FROM messages + JOIN threads ON (threads.message_id = messages.id) + JOIN folders ON (folders.id = messages.folder_id) + WHERE + threads.root IN ( + SELECT threads.root + FROM messages + JOIN threads ON (threads.message_id = messages.id) + WHERE messages.empty = 0 AND messages.deleted = 0 + ) + AND ($selection) + AND messages.empty = 0 AND messages.deleted = 0 + GROUP BY threads.root + ) aggregated + JOIN threads ON (threads.root = aggregated.thread_root) + JOIN messages ON ( + messages.id = threads.message_id + AND messages.empty = 0 AND messages.deleted = 0 + AND messages.date = aggregated.date + ) + JOIN folders ON (folders.id = messages.folder_id) + GROUP BY threads.root + ORDER BY $orderBy + """.trimIndent(), + selectionArgs, + ).use { cursor -> + val cursorMessageAccessor = CursorMessageAccessor(cursor, includesThreadCount = true) + buildList { + while (cursor.moveToNext()) { + val value = mapper.map(cursorMessageAccessor) + if (value != null) { + add(value) + } + } + } + } + } + } + + fun getThread(threadId: Long, sortOrder: String, mapper: MessageMapper): List { + 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 + """.trimIndent(), + 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
+ get() = Address.unpack(cursor.getString(3)).toList() + override val toAddresses: List
+ get() = Address.unpack(cursor.getString(4)).toList() + override val ccAddresses: List
+ get() = Address.unpack(cursor.getString(5)).toList() + override val messageDate: Long + get() = cursor.getLong(6) + override val internalDate: Long + get() = cursor.getLong(7) + override val subject: String? + get() = cursor.getString(8) + override val preview: PreviewResult + get() { + return when (DatabasePreviewType.fromDatabaseValue(cursor.getString(9))) { + DatabasePreviewType.NONE -> PreviewResult.none() + DatabasePreviewType.TEXT -> PreviewResult.text(cursor.getString(10)) + DatabasePreviewType.ENCRYPTED -> PreviewResult.encrypted() + DatabasePreviewType.ERROR -> PreviewResult.error() + } + } + override val isRead: Boolean + get() = cursor.getInt(11) == 1 + override val isStarred: Boolean + get() = cursor.getInt(12) == 1 + override val isAnswered: Boolean + get() = cursor.getInt(13) == 1 + override val isForwarded: Boolean + get() = cursor.getInt(14) == 1 + override val hasAttachments: Boolean + get() = cursor.getInt(15) > 0 + override val threadRoot: Long + get() = cursor.getLong(16) + override val threadCount: Int + get() = if (includesThreadCount) cursor.getInt(17) else 0 +} + +private val AGGREGATED_MESSAGES_COLUMNS = arrayOf( + "date", + "internal_date", + "attachment_count", + "read", + "flagged", + "answered", + "forwarded" +) diff --git a/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt b/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt index 005f997393939351487a71c75de1a48c3a44e11f..95898e16dccbc4d451d2666a4c48fa99512259cc 100644 --- a/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt +++ b/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt @@ -18,7 +18,7 @@ import org.mockito.kotlin.verifyNoMoreInteractions import org.robolectric.RuntimeEnvironment class StoragePersisterTest : K9RobolectricTest() { - private var context: Context = RuntimeEnvironment.application + private var context: Context = RuntimeEnvironment.getApplication() private var storagePersister = K9StoragePersister(context) @Test diff --git a/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java b/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java index 7d103d3378357d9069191208dab00f584cf78635..325eac196502169d916ac8be88367e7d65f42df2 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java +++ b/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java @@ -45,7 +45,7 @@ public class StoreSchemaDefinitionTest extends K9RobolectricTest { public void setUp() throws MessagingException { ShadowLog.stream = System.out; - Application application = RuntimeEnvironment.application; + Application application = RuntimeEnvironment.getApplication(); StorageManager.getInstance(application); storeSchemaDefinition = createStoreSchemaDefinition(); diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt index bd71c62791612425e9748bd8568d3ce5536535b6..0c9bbf74a310f9f168e62139006b723ba80fcf30 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt @@ -38,8 +38,8 @@ fun createDatabase(): SQLiteDatabase { } fun SQLiteDatabase.createMessage( - deleted: Boolean = false, folderId: Long, + deleted: Boolean = false, uid: String? = null, subject: String = "", date: Long = 0L, diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt index 69fc55cbb5860e55b68f831ce86584d0602dc337..234dc350ca5dd787d7e517159d662ca8d08d0b57 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt @@ -3,9 +3,12 @@ package com.fsck.k9.storage.messages import com.fsck.k9.Account.FolderMode import com.fsck.k9.mail.FolderClass import com.fsck.k9.mail.FolderType +import com.fsck.k9.mailstore.FolderNotFoundException +import com.fsck.k9.mailstore.MoreMessages import com.fsck.k9.mailstore.toDatabaseFolderType import com.fsck.k9.storage.RobolectricTest import com.google.common.truth.Truth.assertThat +import org.junit.Assert.fail import org.junit.Test class RetrieveFolderOperationsTest : RobolectricTest() { @@ -347,4 +350,41 @@ class RetrieveFolderOperationsTest : RobolectricTest() { assertThat(result).isEqualTo(2) } + + @Test + fun `get 'more messages' value from non-existent folder`() { + try { + retrieveFolderOperations.hasMoreMessages(23) + fail("Expected exception") + } catch (e: FolderNotFoundException) { + assertThat(e.folderId).isEqualTo(23) + } + } + + @Test + fun `get 'more messages' value from folder with value 'unknown'`() { + val folderId = sqliteDatabase.createFolder(moreMessages = "unknown") + + val result = retrieveFolderOperations.hasMoreMessages(folderId) + + assertThat(result).isEqualTo(MoreMessages.UNKNOWN) + } + + @Test + fun `get 'more messages' value from folder with value 'false'`() { + val folderId = sqliteDatabase.createFolder(moreMessages = "false") + + val result = retrieveFolderOperations.hasMoreMessages(folderId) + + assertThat(result).isEqualTo(MoreMessages.FALSE) + } + + @Test + fun `get 'more messages' value from folder with value 'true'`() { + val folderId = sqliteDatabase.createFolder(moreMessages = "true") + + val result = retrieveFolderOperations.hasMoreMessages(folderId) + + assertThat(result).isEqualTo(MoreMessages.TRUE) + } } diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..45b62c19d59db9a3b269c1ac77cc24c4c3d41ca6 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt @@ -0,0 +1,486 @@ +package com.fsck.k9.storage.messages + +import com.fsck.k9.mail.Address +import com.fsck.k9.mailstore.DatabasePreviewType +import com.fsck.k9.mailstore.MessageMapper +import com.fsck.k9.message.extractors.PreviewResult.PreviewType +import com.fsck.k9.storage.RobolectricTest +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RetrieveMessageListOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val retrieveMessageListOperations = RetrieveMessageListOperations(lockableDatabase) + + @Test + fun `getMessages() on empty folder`() { + val folderId = sqliteDatabase.createFolder() + + val result = getMessagesFromFolder(folderId) { "unexpected" } + + assertThat(result).isEmpty() + } + + @Test + fun `getMessages() with only a deleted message`() { + val folderId = sqliteDatabase.createFolder() + val messageId = sqliteDatabase.createMessage(folderId, uid = "uid1", deleted = true) + sqliteDatabase.createThread(messageId) + + val result = getMessagesFromFolder(folderId) { "unexpected" } + + assertThat(result).isEmpty() + } + + @Test + fun `getMessages() with single message`() { + val folderId = sqliteDatabase.createFolder() + val messageId = sqliteDatabase.createMessage( + folderId, + uid = "uid1", + subject = "subject", + date = 123L, + senderList = Address.pack(Address.parse("from@domain.example")), + toList = Address.pack(Address.parse("to@domain.example")), + ccList = Address.pack(Address.parse("cc1@domain.example, cc2@domain.example")), + attachmentCount = 1, + internalDate = 456L, + previewType = DatabasePreviewType.TEXT, + preview = "preview", + read = true, + flagged = true, + answered = true, + forwarded = true + ) + val threadId = sqliteDatabase.createThread(messageId) + + val result = getMessagesFromFolder(folderId) { message -> + assertThat(message.id).isEqualTo(messageId) + assertThat(message.messageServerId).isEqualTo("uid1") + assertThat(message.folderId).isEqualTo(folderId) + assertThat(message.fromAddresses).containsExactly(Address("from@domain.example")) + assertThat(message.toAddresses).containsExactly(Address("to@domain.example")) + assertThat(message.ccAddresses).containsExactly(Address("cc1@domain.example"), Address("cc2@domain.example")) + assertThat(message.messageDate).isEqualTo(123L) + assertThat(message.internalDate).isEqualTo(456L) + assertThat(message.subject).isEqualTo("subject") + assertThat(message.preview.previewType).isEqualTo(PreviewType.TEXT) + assertThat(message.preview.previewText).isEqualTo("preview") + assertThat(message.isRead).isTrue() + assertThat(message.isStarred).isTrue() + assertThat(message.isAnswered).isTrue() + assertThat(message.isForwarded).isTrue() + assertThat(message.hasAttachments).isTrue() + assertThat(message.threadRoot).isEqualTo(threadId) + assertThat(message.threadCount).isEqualTo(0) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getMessages() with folder containing an empty message`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, empty = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> message.id } + + assertThat(result).containsExactly(messageId2) + } + + @Test + fun `getMessages() with folder containing a deleted message`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, deleted = true) + sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2) + + val result = getThreadedMessagesFromFolder(folderId) { message -> message.id } + + assertThat(result).containsExactly(messageId2) + } + + @Test + fun `getMessages() selecting only unread messages`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = false) + sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = true) + sqliteDatabase.createThread(messageId2) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3", read = false) + sqliteDatabase.createThread(messageId3) + + val result = retrieveMessageListOperations.getThreadedMessages( + selection = "folder_id = ? AND read = 0", + selectionArgs = arrayOf(folderId.toString()), + sortOrder = "date DESC, id DESC" + ) { message -> + message.id + } + + assertThat(result).containsExactly(messageId1, messageId3) + } + + @Test + fun `getThreadedMessages() on empty folder`() { + val folderId = sqliteDatabase.createFolder() + + val result = getThreadedMessagesFromFolder(folderId) { "unexpected" } + + assertThat(result).isEmpty() + } + + @Test + fun `getThreadedMessages() with only a deleted message`() { + val folderId = sqliteDatabase.createFolder() + val messageId = sqliteDatabase.createMessage(folderId, uid = "uid1", deleted = true) + sqliteDatabase.createThread(messageId) + + val result = getThreadedMessagesFromFolder(folderId) { "unexpected" } + + assertThat(result).isEmpty() + } + + @Test + fun `getThreadedMessages() with single message`() { + val folderId = sqliteDatabase.createFolder() + val messageId = sqliteDatabase.createMessage( + folderId, + uid = "uid1", + subject = "subject", + date = 123L, + senderList = Address.pack(Address.parse("from@domain.example")), + toList = Address.pack(Address.parse("to@domain.example")), + ccList = Address.pack(Address.parse("cc1@domain.example, cc2@domain.example")), + attachmentCount = 1, + internalDate = 456L, + previewType = DatabasePreviewType.TEXT, + preview = "preview", + read = true, + flagged = true, + answered = true, + forwarded = true + ) + val threadId = sqliteDatabase.createThread(messageId) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.id).isEqualTo(messageId) + assertThat(message.messageServerId).isEqualTo("uid1") + assertThat(message.folderId).isEqualTo(folderId) + assertThat(message.fromAddresses).containsExactly(Address("from@domain.example")) + assertThat(message.toAddresses).containsExactly(Address("to@domain.example")) + assertThat(message.ccAddresses).containsExactly(Address("cc1@domain.example"), Address("cc2@domain.example")) + assertThat(message.messageDate).isEqualTo(123L) + assertThat(message.internalDate).isEqualTo(456L) + assertThat(message.subject).isEqualTo("subject") + assertThat(message.preview.previewType).isEqualTo(PreviewType.TEXT) + assertThat(message.preview.previewText).isEqualTo("preview") + assertThat(message.isRead).isTrue() + assertThat(message.isStarred).isTrue() + assertThat(message.isAnswered).isTrue() + assertThat(message.isForwarded).isTrue() + assertThat(message.hasAttachments).isTrue() + assertThat(message.threadRoot).isEqualTo(threadId) + assertThat(message.threadCount).isEqualTo(1) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() with thread containing an empty message`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, empty = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.id).isEqualTo(messageId2) + assertThat(message.messageServerId).isEqualTo("uid2") + assertThat(message.threadRoot).isEqualTo(threadId1) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return latest message in thread`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage( + folderId, + uid = "uid1", + date = 1000L, + internalDate = 1001L + ) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage( + folderId, + uid = "uid2", + date = 2000L, + internalDate = 2001L + ) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.id).isEqualTo(messageId2) + assertThat(message.messageDate).isEqualTo(2000L) + assertThat(message.internalDate).isEqualTo(2001L) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'unread' when at least one message in thread is marked as unread`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = false) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isRead).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'read' when all messages in thread are marked as read`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isRead).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'starred' when at least one message in thread is marked as starred`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", flagged = false) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", flagged = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isStarred).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'not starred' when all messages in thread are not marked as starred`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", flagged = false) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", flagged = false) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isStarred).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'not answered' when not all messages in thread are marked as answered`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", answered = false) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", answered = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isAnswered).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'answered' when all messages in thread are marked as answered`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", answered = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", answered = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isAnswered).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'not forwarded' when not all messages in thread are marked as forwarded`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", forwarded = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", forwarded = false) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isForwarded).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'forwarded' when all messages in thread are marked as forwarded`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", forwarded = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", forwarded = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isForwarded).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'has attachment' when at least one message in thread contains an attachment`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", attachmentCount = 1) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", attachmentCount = 0) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.hasAttachments).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'has no attachment' when no message in thread contains an attachment`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", attachmentCount = 0) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", attachmentCount = 0) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.hasAttachments).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() with 3 messages in thread`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1") + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3") + sqliteDatabase.createThread(messageId3, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.threadCount).isEqualTo(3) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should not include empty messages in thread count`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", empty = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3") + sqliteDatabase.createThread(messageId3, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.threadCount).isEqualTo(2) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThread() with empty message as thread root`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, empty = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3") + sqliteDatabase.createThread(messageId3, root = threadId1) + + val result = retrieveMessageListOperations.getThread(threadId = threadId1, sortOrder = "date DESC") { it.id } + + assertThat(result).containsExactly(messageId2, messageId3) + } + + @Test + fun `getThread() should only return messages in thread`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1") + sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + val threadId2 = sqliteDatabase.createThread(messageId2) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3") + sqliteDatabase.createThread(messageId3, root = threadId2) + + val result = retrieveMessageListOperations.getThread(threadId = threadId2, sortOrder = "date DESC") { it.id } + + assertThat(result).containsExactly(messageId2, messageId3) + } + + private fun getMessagesFromFolder(folderId: Long, mapper: MessageMapper): List { + return retrieveMessageListOperations.getMessages( + selection = "folder_id = ?", + selectionArgs = arrayOf(folderId.toString()), + sortOrder = "date DESC, id DESC", + mapper + ) + } + + private fun getThreadedMessagesFromFolder(folderId: Long, mapper: MessageMapper): List { + return retrieveMessageListOperations.getThreadedMessages( + selection = "folder_id = ?", + selectionArgs = arrayOf(folderId.toString()), + sortOrder = "date DESC, id DESC", + mapper + ) + } +} diff --git a/app/testing/src/main/java/com/fsck/k9/testing/StringExtensions.kt b/app/testing/src/main/java/com/fsck/k9/testing/StringExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..2e5a461c19c8f3d8f490a1f04bb4a36449acdbb9 --- /dev/null +++ b/app/testing/src/main/java/com/fsck/k9/testing/StringExtensions.kt @@ -0,0 +1,3 @@ +package com.fsck.k9.testing + +fun String.removeNewlines(): String = replace("([\\r\\n])".toRegex(), "") diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/AccountList.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/AccountList.java index 6e42b8621ca700f2bfe132a5d31b34ecd0027ddf..a80d743bbf3f18993de007045287fe58031de826 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/AccountList.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/AccountList.java @@ -155,7 +155,7 @@ public abstract class AccountList extends K9ListActivity implements OnItemClickL class LoadAccounts extends AsyncTask> { @Override protected List doInBackground(Void... params) { - return Preferences.getPreferences(getApplicationContext()).getAccounts(); + return Preferences.getPreferences().getAccounts(); } @Override diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/ChooseIdentity.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/ChooseIdentity.java index df88e54f9ac652682be02a145c6bdd61c14e1a2a..898d6d64838fa5539b1c95c94e73438e41ff2242 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/ChooseIdentity.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/ChooseIdentity.java @@ -37,7 +37,7 @@ public class ChooseIdentity extends K9ListActivity { getListView().setChoiceMode(ListView.CHOICE_MODE_NONE); Intent intent = getIntent(); String accountUuid = intent.getStringExtra(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1); diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt index 1992c1cb27ef6c52370c183332f2863a9eabe15d..149f9ebbfe3a5e99b1bd233eb8d51f3cfa1a2332 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt @@ -35,7 +35,7 @@ class EditIdentity : K9Activity() { identityIndex = intent.getIntExtra(EXTRA_IDENTITY_INDEX, -1) val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT) ?: error("Missing account UUID") - account = Preferences.getPreferences(this).getAccount(accountUuid) ?: error("Couldn't find account") + account = Preferences.getPreferences().getAccount(accountUuid) ?: error("Couldn't find account") identity = when { savedInstanceState != null -> savedInstanceState.getParcelable(EXTRA_IDENTITY) ?: error("Missing state") @@ -91,7 +91,7 @@ class EditIdentity : K9Activity() { identities.add(identityIndex, identity) } - Preferences.getPreferences(applicationContext).saveAccount(account) + Preferences.getPreferences().saveAccount(account) finish() } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/ManageIdentities.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/ManageIdentities.java index 5e5b89804178a5098df11d20ffdc17dd5d5fb16a..501188a87157af0909b2ec2f753a2bb709dbbac0 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/ManageIdentities.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/ManageIdentities.java @@ -139,7 +139,7 @@ public class ManageIdentities extends ChooseIdentity { private void saveIdentities() { if (mIdentitiesChanged) { mAccount.setIdentities(identities); - Preferences.getPreferences(getApplicationContext()).saveAccount(mAccount); + Preferences.getPreferences().saveAccount(mAccount); } finish(); } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java index a4d54a71a2d444a6b50be0f056082e4e67b759d9..b873e5ea248c05ed2e5d6cedb9f2ff7853b11267 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java @@ -940,7 +940,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, updateSignature(); updateMessageFormat(); replyToPresenter.setIdentity(identity); - recipientPresenter.onSwitchIdentity(identity); + recipientPresenter.onSwitchIdentity(); } private void updateFrom() { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt index 8735a87ad1306cf3dda3bc14f5d111801d9d6818..927d20833130363d0b9e5f81033902781f1ddcd0 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt @@ -366,8 +366,6 @@ open class MessageList : showMessageViewPlaceHolder() } else { messageViewContainerFragment.isActive = true - val activeMessage = messageViewContainerFragment.messageReference - messageListFragment.setActiveMessage(activeMessage) } } @@ -992,8 +990,8 @@ open class MessageList : progressBar?.visibility = if (enable) View.VISIBLE else View.INVISIBLE } - override fun setMessageListProgress(progress: Int) { - progressBar?.progress = progress + override fun setMessageListProgress(level: Int) { + progressBar?.progress = level } override fun openMessage(messageReference: MessageReference) { @@ -1004,8 +1002,6 @@ open class MessageList : if (draftsFolderId != null && folderId == draftsFolderId) { MessageActions.actionEditDraft(this, messageReference) } else { - messageListFragment?.setActiveMessage(messageReference) - val fragment = MessageViewContainerFragment.newInstance(messageReference) supportFragmentManager.commitNow { replace(R.id.message_view_container, fragment, FRAGMENT_TAG_MESSAGE_VIEW_CONTAINER) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelper.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelper.java index 229b481034d044221f023be0953780008e9f4edc..a591aeed5cdd7ada6061bec5783dd65fb10d1265 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelper.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelper.java @@ -115,7 +115,7 @@ public class MessageLoaderHelper { public void asyncStartOrResumeLoadingMessage(MessageReference messageReference, Parcelable cachedDecryptionResult) { onlyLoadMetadata = false; this.messageReference = messageReference; - this.account = Preferences.getPreferences(context).getAccount(messageReference.getAccountUuid()); + this.account = Preferences.getPreferences().getAccount(messageReference.getAccountUuid()); if (cachedDecryptionResult != null) { if (cachedDecryptionResult instanceof OpenPgpDecryptionResult) { @@ -132,7 +132,7 @@ public class MessageLoaderHelper { public void asyncStartOrResumeLoadingMessageMetadata(MessageReference messageReference) { onlyLoadMetadata = true; this.messageReference = messageReference; - this.account = Preferences.getPreferences(context).getAccount(messageReference.getAccountUuid()); + this.account = Preferences.getPreferences().getAccount(messageReference.getAccountUuid()); startOrResumeLocalMessageLoader(); } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/UpgradeDatabases.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/UpgradeDatabases.java index 52e90bc51d65cd327f1e1bb6bbc441152ff8e0b8..92f9c30af8ba8585a3cbe638ccb47afb55bf31e4 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/UpgradeDatabases.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/UpgradeDatabases.java @@ -115,7 +115,7 @@ public class UpgradeDatabases extends K9Activity { return; } - mPreferences = Preferences.getPreferences(getApplicationContext()); + mPreferences = Preferences.getPreferences(); initializeLayout(); diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/IdentityAdapter.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/IdentityAdapter.java index 0215dc446c2d8bb1a167e2686078d14a77de8d46..cd6f347eab9f1faf68c043fb539ed3143f701551 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/IdentityAdapter.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/IdentityAdapter.java @@ -34,7 +34,7 @@ public class IdentityAdapter extends BaseAdapter { Context.LAYOUT_INFLATER_SERVICE); List items = new ArrayList<>(); - Preferences prefs = Preferences.getPreferences(context.getApplicationContext()); + Preferences prefs = Preferences.getPreferences(); List accounts = prefs.getAccounts(); for (int i = 0; i < accounts.size(); i++) { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/MessageActions.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/MessageActions.java index 6c929d48584bf2621933752157a55e9426e2ffd9..e1cd9899acb9492728eb225868f54bde58497922 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/MessageActions.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/MessageActions.java @@ -17,7 +17,7 @@ public class MessageActions { * activity. */ public static void actionCompose(Context context, Account account) { - Account defaultAccount = Preferences.getPreferences(context).getDefaultAccount(); + Account defaultAccount = Preferences.getPreferences().getDefaultAccount(); if (account == null && defaultAccount == null) { AccountSetupBasics.actionNewAccount(context); } else { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt index 6ee1e68a2c3d30dfcd5a7a5bd34edd64a8523b8a..14c74e7a543aa8f612d5caa3039bc889110a13aa 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt @@ -13,7 +13,6 @@ import android.view.Menu import androidx.core.content.ContextCompat import androidx.loader.app.LoaderManager import com.fsck.k9.Account -import com.fsck.k9.Identity import com.fsck.k9.K9 import com.fsck.k9.Preferences import com.fsck.k9.activity.compose.ComposeCryptoStatus.AttachErrorState @@ -331,7 +330,7 @@ class RecipientPresenter( openPgpApiManager.setOpenPgpProvider(openPgpProvider, openPgpCallback) } - fun onSwitchIdentity(identity: Identity) { + fun onSwitchIdentity() { // TODO decide what actually to do on identity switch? asyncUpdateCryptoStatus() } @@ -713,14 +712,12 @@ class RecipientPresenter( SendErrorState.ENABLED_ERROR -> recipientMvpView.showOpenPgpEnabledErrorDialog(false) SendErrorState.PROVIDER_ERROR -> recipientMvpView.showErrorOpenPgpConnection() SendErrorState.KEY_CONFIG_ERROR -> recipientMvpView.showOpenPgpUserKeySetupDialog() - else -> throw AssertionError("not all error states handled, this is a bug!") } } fun showPgpAttachError(attachErrorState: AttachErrorState) { when (attachErrorState) { AttachErrorState.IS_INLINE -> recipientMvpView.showErrorInlineAttach() - else -> throw AssertionError("not all error states handled, this is a bug!") } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentContentLoader.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentContentLoader.java index 6a420c812aa895800eebf4164c664a0340bcc79d..19ceff5b66b49b617b259d026de0809f8051eadb 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentContentLoader.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentContentLoader.java @@ -3,7 +3,6 @@ package com.fsck.k9.activity.loader; import java.io.File; import java.io.FileOutputStream; -import java.io.IOException; import java.io.InputStream; import android.content.ContentResolver; @@ -88,7 +87,7 @@ public class AttachmentContentLoader extends AsyncTaskLoader { cachedResultAttachment = sourceAttachment.deriveWithLoadComplete(file.getAbsolutePath()); return cachedResultAttachment; - } catch (IOException e) { + } catch (Exception e) { Timber.e(e, "Error saving attachment!"); } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentInfoLoader.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentInfoLoader.java index 6750dd52b3ca111976b51273f4211d9e49da41e8..9bbf2e61dc05fdad886f4bdfde771fac54ca5648 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentInfoLoader.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentInfoLoader.java @@ -46,63 +46,70 @@ public class AttachmentInfoLoader extends AsyncTaskLoader { @Override public Attachment loadInBackground() { - Uri uri = sourceAttachment.uri; - String contentType = sourceAttachment.contentType; - - long size = -1; - String name = null; - - ContentResolver contentResolver = getContext().getContentResolver(); - - Cursor metadataCursor = contentResolver.query( - uri, - new String[] { OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }, - null, - null, - null); - - if (metadataCursor != null) { - try { - if (metadataCursor.moveToFirst()) { - name = metadataCursor.getString(0); - size = metadataCursor.getInt(1); + try { + Uri uri = sourceAttachment.uri; + String contentType = sourceAttachment.contentType; + + long size = -1; + String name = null; + + ContentResolver contentResolver = getContext().getContentResolver(); + + Cursor metadataCursor = contentResolver.query( + uri, + new String[] { OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }, + null, + null, + null); + + if (metadataCursor != null) { + try { + if (metadataCursor.moveToFirst()) { + name = metadataCursor.getString(0); + size = metadataCursor.getInt(1); + } + } finally { + metadataCursor.close(); } - } finally { - metadataCursor.close(); } - } - if (name == null) { - name = uri.getLastPathSegment(); - } + if (name == null) { + name = uri.getLastPathSegment(); + } - String usableContentType = contentResolver.getType(uri); - if (usableContentType == null && contentType != null && contentType.indexOf('*') != -1) { - usableContentType = contentType; - } + String usableContentType = contentResolver.getType(uri); + if (usableContentType == null && contentType != null && contentType.indexOf('*') != -1) { + usableContentType = contentType; + } - if (usableContentType == null) { - usableContentType = MimeTypeUtil.getMimeTypeByExtension(name); - } + if (usableContentType == null) { + usableContentType = MimeTypeUtil.getMimeTypeByExtension(name); + } - if (!sourceAttachment.allowMessageType && MimeUtility.isMessageType(usableContentType)) { - usableContentType = MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE; - } + if (!sourceAttachment.allowMessageType && MimeUtility.isMessageType(usableContentType)) { + usableContentType = MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE; + } - if (size <= 0) { - String uriString = uri.toString(); - if (uriString.startsWith("file://")) { - File f = new File(uriString.substring("file://".length())); - size = f.length(); + if (size <= 0) { + String uriString = uri.toString(); + if (uriString.startsWith("file://")) { + File f = new File(uriString.substring("file://".length())); + size = f.length(); + } else { + Timber.v("Not a file: %s", uriString); + } } else { - Timber.v("Not a file: %s", uriString); + Timber.v("old attachment.size: %d", size); } - } else { - Timber.v("old attachment.size: %d", size); - } - Timber.v("new attachment.size: %d", size); + Timber.v("new attachment.size: %d", size); + + cachedResultAttachment = sourceAttachment.deriveWithMetadataLoaded(usableContentType, name, size); + return cachedResultAttachment; + } catch (Exception e) { + Timber.e(e, "Error getting attachment meta data"); - cachedResultAttachment = sourceAttachment.deriveWithMetadataLoaded(usableContentType, name, size); - return cachedResultAttachment; + cachedResultAttachment = sourceAttachment.deriveWithLoadCancelled(); + return cachedResultAttachment; + } } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java index 10315606fd087ca4e6211251fa105bb2c34f3b79..b463ea7c69fe46912a0e3bc7f74f1e8c863e818c 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java @@ -46,7 +46,7 @@ public class AccountSetupComposition extends K9Activity { super.onCreate(savedInstanceState); String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); setLayout(R.layout.account_setup_composition); setTitle(R.string.account_settings_composition_title); @@ -61,7 +61,7 @@ public class AccountSetupComposition extends K9Activity { */ if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) { accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); } mAccountName = findViewById(R.id.account_name); @@ -129,7 +129,7 @@ public class AccountSetupComposition extends K9Activity { mAccount.setSignatureBeforeQuotedText(isSignatureBeforeQuotedText); } - Preferences.getPreferences(getApplicationContext()).saveAccount(mAccount); + Preferences.getPreferences().saveAccount(mAccount); } @Override diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java index b8f971010e03ce364b519c83822356f54b13ecd7..b81fccdc0da35cae0719b2d60d6140fd1fba88e0 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java @@ -157,7 +157,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener mPortView.setKeyListener(DigitsKeyListener.getInstance("0123456789")); String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); mMakeDefault = getIntent().getBooleanExtra(EXTRA_MAKE_DEFAULT, false); /* @@ -166,7 +166,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener */ if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) { accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); } boolean oAuthSupported = mAccount.getIncomingServerSettings().type.equals(Protocols.IMAP); @@ -537,7 +537,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener if (resultCode == RESULT_OK) { if (Intent.ACTION_EDIT.equals(getIntent().getAction())) { - Preferences.getPreferences(getApplicationContext()).saveAccount(mAccount); + Preferences.getPreferences().saveAccount(mAccount); finish(); } else { /* diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupNames.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupNames.java index f9f222a70f0ef6fc64abd0ea57fd3487dae70170..603232baeebd9fa366361542dc74f3786239e30b 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupNames.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupNames.java @@ -65,7 +65,7 @@ public class AccountSetupNames extends K9Activity implements OnClickListener { mName.setKeyListener(TextKeyListener.getInstance(false, Capitalize.WORDS)); String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); String senderName = mAccount.getSenderName(); if (senderName != null) { @@ -88,7 +88,7 @@ public class AccountSetupNames extends K9Activity implements OnClickListener { } mAccount.setSenderName(mName.getText().toString()); mAccount.markSetupFinished(); - Preferences.getPreferences(getApplicationContext()).saveAccount(mAccount); + Preferences.getPreferences().saveAccount(mAccount); finishAffinity(); MessageList.launch(this, mAccount); } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOptions.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOptions.java index 5d746b9f7b25ee09d54ac588f4814180d5b4f501..eb785d3f5c52d2fbec323f518b7778f9f82cdb21 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOptions.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOptions.java @@ -93,7 +93,7 @@ public class AccountSetupOptions extends K9Activity implements OnClickListener { mDisplayCountView.setAdapter(displayCountsAdapter); String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); mNotifyView.setChecked(mAccount.isNotifyNewMail()); SpinnerOption.setSpinnerOptionValue(mCheckFrequencyView, mAccount @@ -112,7 +112,7 @@ public class AccountSetupOptions extends K9Activity implements OnClickListener { mAccount.setFolderPushMode(Account.FolderMode.NONE); - Preferences.getPreferences(getApplicationContext()).saveAccount(mAccount); + Preferences.getPreferences().saveAccount(mAccount); Core.setServicesEnabled(this); AccountSetupNames.actionSetNames(this, mAccount); } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOutgoing.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOutgoing.java index 61bf5212392a2610adda24e39405e0a214ef5fd0..0cf310c823df569cfd5ff6a3a016859485835e57 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOutgoing.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOutgoing.java @@ -102,7 +102,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, setTitle(R.string.account_setup_outgoing_title); String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); ServerSettings incomingServerSettings = mAccount.getIncomingServerSettings(); if (incomingServerSettings.type.equals(Protocols.WEBDAV)) { @@ -139,7 +139,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, //FIXME: get Account object again? accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); /* * If we're being reloaded we override the original account with the one @@ -147,7 +147,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, */ if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_ACCOUNT)) { accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); + mAccount = Preferences.getPreferences().getAccount(accountUuid); } boolean editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction()); @@ -494,7 +494,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, if (resultCode == RESULT_OK) { if (Intent.ACTION_EDIT.equals(getIntent().getAction())) { - Preferences.getPreferences(getApplicationContext()).saveAccount(mAccount); + Preferences.getPreferences().saveAccount(mAccount); finish(); } else { AccountSetupOptions.actionOptions(this, mAccount); diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/EeloAccountCreator.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/EeloAccountCreator.java index e210ee0f1fec6991177e0a8a3aa15ff7853384b8..bcfe1ec6d6784d9a859a5b4e38ef4b0e3c3bfbeb 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/EeloAccountCreator.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/EeloAccountCreator.java @@ -99,7 +99,7 @@ public class EeloAccountCreator { private static void updateAccountNameIfMissing(@NonNull Context context, String emailId, Account account) { if (account.getName() == null) { // we need to fix an old bug account.setName(emailId); - Preferences.getPreferences(context).saveAccount(account); + Preferences.getPreferences().saveAccount(account); } } @@ -135,7 +135,7 @@ public class EeloAccountCreator { } private static void createAccount(Context context, String emailId, String password, @Nullable String authState) { - Preferences preferences = Preferences.getPreferences(context); + Preferences preferences = Preferences.getPreferences(); Account account = preferences.newAccount(); account.setChipColor(accountCreator.pickColor()); diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MLFProjectionInfo.java b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MLFProjectionInfo.java deleted file mode 100644 index f38fa2d61eb9e282a2fb3eb67d930081cae2c77a..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MLFProjectionInfo.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.fsck.k9.fragment; - - -import java.util.Arrays; - -import com.fsck.k9.provider.EmailProvider.MessageColumns; -import com.fsck.k9.provider.EmailProvider.SpecialColumns; -import com.fsck.k9.provider.EmailProvider.ThreadColumns; - - -public final class MLFProjectionInfo { - - public static final String[] THREADED_PROJECTION = { - MessageColumns.ID, - MessageColumns.UID, - MessageColumns.INTERNAL_DATE, - MessageColumns.SUBJECT, - MessageColumns.DATE, - MessageColumns.SENDER_LIST, - MessageColumns.TO_LIST, - MessageColumns.CC_LIST, - MessageColumns.READ, - MessageColumns.FLAGGED, - MessageColumns.ANSWERED, - MessageColumns.FORWARDED, - MessageColumns.ATTACHMENT_COUNT, - MessageColumns.FOLDER_ID, - MessageColumns.PREVIEW_TYPE, - MessageColumns.PREVIEW, - ThreadColumns.ROOT, - SpecialColumns.ACCOUNT_UUID, - SpecialColumns.FOLDER_SERVER_ID, - - SpecialColumns.THREAD_COUNT, - }; - - public static final int ID_COLUMN = 0; - public static final int UID_COLUMN = 1; - public static final int INTERNAL_DATE_COLUMN = 2; - public static final int SUBJECT_COLUMN = 3; - public static final int DATE_COLUMN = 4; - public static final int SENDER_LIST_COLUMN = 5; - public static final int TO_LIST_COLUMN = 6; - public static final int CC_LIST_COLUMN = 7; - public static final int READ_COLUMN = 8; - public static final int FLAGGED_COLUMN = 9; - public static final int ANSWERED_COLUMN = 10; - public static final int FORWARDED_COLUMN = 11; - public static final int ATTACHMENT_COUNT_COLUMN = 12; - public static final int FOLDER_ID_COLUMN = 13; - public static final int PREVIEW_TYPE_COLUMN = 14; - public static final int PREVIEW_COLUMN = 15; - public static final int THREAD_ROOT_COLUMN = 16; - public static final int ACCOUNT_UUID_COLUMN = 17; - public static final int FOLDER_SERVER_ID_COLUMN = 18; - public static final int THREAD_COUNT_COLUMN = 19; - - public static final String[] PROJECTION = Arrays.copyOf(THREADED_PROJECTION, - THREAD_COUNT_COLUMN); -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt index 197f1dea1af1ba8f04d993047a3adc9d2164d7a8..d09be1b11e18e90138444406435182780a1f538f 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt @@ -57,6 +57,7 @@ class MessageListAdapter internal constructor( var messages: List = emptyList() set(value) { field = value + messagesMap = value.associateBy { it.uniqueId } notifyDataSetChanged() } @@ -66,6 +67,8 @@ class MessageListAdapter internal constructor( notifyDataSetChanged() } + private var messagesMap = emptyMap() + var activeMessage: MessageReference? = null var selected: Set = emptySet() @@ -79,14 +82,14 @@ class MessageListAdapter internal constructor( private val flagClickListener = OnClickListener { view: View -> val messageViewHolder = view.tag as MessageViewHolder - val messageListItem = getItem(messageViewHolder.position) + val messageListItem = getItemById(messageViewHolder.uniqueId) listItemListener.onToggleMessageFlag(messageListItem) } private val contactPictureClickListener = OnClickListener { view: View -> val parentView = view.parent.parent as View val messageViewHolder = parentView.tag as MessageViewHolder - val messageListItem = getItem(messageViewHolder.position) + val messageListItem = getItemById(messageViewHolder.uniqueId) listItemListener.onToggleMessageSelection(messageListItem) } @@ -104,6 +107,10 @@ class MessageListAdapter internal constructor( override fun getItem(position: Int): MessageListItem = messages[position] + private fun getItemById(uniqueId: Long): MessageListItem { + return messagesMap[uniqueId]!! + } + override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { val message = getItem(position) val view: View = convertView ?: newView(parent) @@ -184,8 +191,7 @@ class MessageListAdapter internal constructor( } else { holder.flagged.isVisible = false } - holder.position = position - + holder.uniqueId = uniqueId setBackgroundColor(view, isSelected, isActive) holder.unreadMessageIndicator.visibility = if (isRead) INVISIBLE else VISIBLE val beforePreviewText = if (appearance.senderAboveSubject) subject else displayName diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt index c7e3d66a81efdbff6a642e0b1cced76cef1b863d..347e10a95b05abaea345a2ed3c624efbac59f53b 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt @@ -53,6 +53,7 @@ import com.fsck.k9.ui.messagelist.MessageListConfig import com.fsck.k9.ui.messagelist.MessageListInfo import com.fsck.k9.ui.messagelist.MessageListItem import com.fsck.k9.ui.messagelist.MessageListViewModel +import com.fsck.k9.ui.messagelist.MessageSortOverride import java.util.concurrent.Future import net.jcip.annotations.GuardedBy import org.koin.android.ext.android.inject @@ -60,6 +61,8 @@ import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import timber.log.Timber +private const val MAXIMUM_MESSAGE_SORT_OVERRIDES = 3 + class MessageListFragment : Fragment(), OnItemClickListener, @@ -326,7 +329,8 @@ class MessageListFragment : sortType, sortAscending, sortDateAscending, - activeMessage + activeMessage, + viewModel.messageSortOverrides.toMap() ) viewModel.loadMessageList(config) } @@ -1582,6 +1586,8 @@ class MessageListFragment : fun setActiveMessage(messageReference: MessageReference?) { activeMessage = messageReference + rememberSortOverride(messageReference) + // Reload message list with modified query that always includes the active message if (isAdded) { loadMessageList() @@ -1598,6 +1604,38 @@ class MessageListFragment : } } + // For the last N displayed messages we remember the original 'read' and 'starred' state of the messages. We pass + // this information to MessageListLoader so messages can be sorted according to these remembered values and not the + // current state. This way messages, that are marked as read/unread or starred/not starred while being displayed, + // won't immediately change position in the message list if the list is sorted by these fields. + // The main benefit is that the swipe to next/previous message feature will work in a less surprising way. + private fun rememberSortOverride(messageReference: MessageReference?) { + val messageSortOverrides = viewModel.messageSortOverrides + + if (messageReference == null) { + messageSortOverrides.clear() + return + } + + if (sortType != SortType.SORT_UNREAD && sortType != SortType.SORT_FLAGGED) return + + val position = getPosition(messageReference) + val messageListItem = adapter.getItem(position) + + val existingEntry = messageSortOverrides.firstOrNull { it.first == messageReference } + if (existingEntry != null) { + messageSortOverrides.remove(existingEntry) + messageSortOverrides.addLast(existingEntry) + } else { + messageSortOverrides.addLast( + messageReference to MessageSortOverride(messageListItem.isRead, messageListItem.isStarred) + ) + if (messageSortOverrides.size > MAXIMUM_MESSAGE_SORT_OVERRIDES) { + messageSortOverrides.removeFirst() + } + } + } + private fun scrollToMessage(messageReference: MessageReference) { val position = getPosition(messageReference) val viewPosition = adapterToListViewPosition(position) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragmentComparators.java b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragmentComparators.java deleted file mode 100644 index 61d67806e4182a734f3fad6f0189c9fdc8ef916a..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragmentComparators.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.fsck.k9.fragment; - -import android.database.Cursor; - -import java.util.Comparator; -import java.util.List; - -/** - * A set of {@link Comparator} classes used for {@link Cursor} data comparison. - */ -public class MessageListFragmentComparators { - /** - * Reverses the result of a {@link Comparator}. - * - * @param - */ - public static class ReverseComparator implements Comparator { - private Comparator mDelegate; - - /** - * @param delegate - * Never {@code null}. - */ - public ReverseComparator(final Comparator delegate) { - mDelegate = delegate; - } - - @Override - public int compare(final T object1, final T object2) { - // arg1 & 2 are mixed up, this is done on purpose - return mDelegate.compare(object2, object1); - } - } - - /** - * Chains comparator to find a non-0 result. - * - * @param - */ - public static class ComparatorChain implements Comparator { - private List> mChain; - - /** - * @param chain - * Comparator chain. Never {@code null}. - */ - public ComparatorChain(final List> chain) { - mChain = chain; - } - - @Override - public int compare(T object1, T object2) { - int result = 0; - for (final Comparator comparator : mChain) { - result = comparator.compare(object1, object2); - if (result != 0) { - break; - } - } - return result; - } - } - - public static class ReverseIdComparator implements Comparator { - private int mIdColumn = -1; - - @Override - public int compare(Cursor cursor1, Cursor cursor2) { - if (mIdColumn == -1) { - mIdColumn = cursor1.getColumnIndex("_id"); - } - long o1Id = cursor1.getLong(mIdColumn); - long o2Id = cursor2.getLong(mIdColumn); - return (o1Id > o2Id) ? -1 : 1; - } - } - - public static class AttachmentComparator implements Comparator { - - @Override - public int compare(Cursor cursor1, Cursor cursor2) { - int o1HasAttachment = (cursor1.getInt(MLFProjectionInfo.ATTACHMENT_COUNT_COLUMN) > 0) ? 0 : 1; - int o2HasAttachment = (cursor2.getInt(MLFProjectionInfo.ATTACHMENT_COUNT_COLUMN) > 0) ? 0 : 1; - return o1HasAttachment - o2HasAttachment; - } - } - - public static class FlaggedComparator implements Comparator { - - @Override - public int compare(Cursor cursor1, Cursor cursor2) { - int o1IsFlagged = (cursor1.getInt(MLFProjectionInfo.FLAGGED_COLUMN) == 1) ? 0 : 1; - int o2IsFlagged = (cursor2.getInt(MLFProjectionInfo.FLAGGED_COLUMN) == 1) ? 0 : 1; - return o1IsFlagged - o2IsFlagged; - } - } - - public static class UnreadComparator implements Comparator { - - @Override - public int compare(Cursor cursor1, Cursor cursor2) { - int o1IsUnread = cursor1.getInt(MLFProjectionInfo.READ_COLUMN); - int o2IsUnread = cursor2.getInt(MLFProjectionInfo.READ_COLUMN); - return o1IsUnread - o2IsUnread; - } - } - - public static class DateComparator implements Comparator { - - @Override - public int compare(Cursor cursor1, Cursor cursor2) { - long o1Date = cursor1.getLong(MLFProjectionInfo.DATE_COLUMN); - long o2Date = cursor2.getLong(MLFProjectionInfo.DATE_COLUMN); - return Long.compare(o1Date, o2Date); - } - } - - public static class ArrivalComparator implements Comparator { - - @Override - public int compare(Cursor cursor1, Cursor cursor2) { - long o1Date = cursor1.getLong(MLFProjectionInfo.INTERNAL_DATE_COLUMN); - long o2Date = cursor2.getLong(MLFProjectionInfo.INTERNAL_DATE_COLUMN); - return Long.compare(o1Date, o2Date); - } - } - - public static class SubjectComparator implements Comparator { - - @Override - public int compare(Cursor cursor1, Cursor cursor2) { - String subject1 = cursor1.getString(MLFProjectionInfo.SUBJECT_COLUMN); - String subject2 = cursor2.getString(MLFProjectionInfo.SUBJECT_COLUMN); - - if (subject1 == null) { - return (subject2 == null) ? 0 : -1; - } else if (subject2 == null) { - return 1; - } - - return subject1.compareToIgnoreCase(subject2); - } - } - - public static class SenderComparator implements Comparator { - - @Override - public int compare(Cursor cursor1, Cursor cursor2) { - String sender1 = MlfUtils.getSenderAddressFromCursor(cursor1); - String sender2 = MlfUtils.getSenderAddressFromCursor(cursor2); - - if (sender1 == null && sender2 == null) { - return 0; - } else if (sender1 == null) { - return 1; - } else if (sender2 == null) { - return -1; - } else { - return sender1.compareToIgnoreCase(sender2); - } - } - } -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt index a80f654b3a7a0d9d0fbc3248d145d8960af86d18..18e84b2012b67faef4c20de069003a475ce39edd 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageViewHolder.kt @@ -8,7 +8,7 @@ import android.widget.TextView import com.fsck.k9.ui.R class MessageViewHolder(view: View) { - var position = -1 + var uniqueId: Long = -1L val selected: ImageView = view.findViewById(R.id.selected) val subject: TextView = view.findViewById(R.id.subject) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java index af429d8774dba3898494be8dc36f767548e19ee7..eb35a43e3bf8c29d24e68bfde83a974f2cd70ba1 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MlfUtils.java @@ -3,7 +3,6 @@ package com.fsck.k9.fragment; import java.util.List; -import android.database.Cursor; import android.text.TextUtils; import com.fsck.k9.Account; @@ -11,14 +10,11 @@ import com.fsck.k9.DI; import com.fsck.k9.Preferences; import com.fsck.k9.controller.MessageReference; import com.fsck.k9.helper.Utility; -import com.fsck.k9.mail.Address; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mailstore.LocalFolder; import com.fsck.k9.mailstore.LocalStore; import com.fsck.k9.mailstore.LocalStoreProvider; -import static com.fsck.k9.fragment.MLFProjectionInfo.SENDER_LIST_COLUMN; - public class MlfUtils { @@ -35,12 +31,6 @@ public class MlfUtils { account.setLastSelectedFolderId(folderId); } - static String getSenderAddressFromCursor(Cursor cursor) { - String fromList = cursor.getString(SENDER_LIST_COLUMN); - Address[] fromAddrs = Address.unpack(fromList); - return (fromAddrs.length > 0) ? fromAddrs[0].getAddress() : null; - } - static String buildSubject(String subjectFromCursor, String emptySubject, int threadCount) { if (TextUtils.isEmpty(subjectFromCursor)) { return emptySubject; diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt index cb25a0dc8c6c1f54a7af9b4478418a07085c2a2d..488e5c90f61319af97b2a11d07179618e9be418e 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt @@ -1,17 +1,14 @@ package com.fsck.k9.ui.account -import android.content.ContentResolver -import android.database.ContentObserver -import android.os.Handler -import android.os.Looper import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import com.fsck.k9.Account import com.fsck.k9.controller.MessageCounts import com.fsck.k9.controller.MessageCountsProvider +import com.fsck.k9.mailstore.MessageListChangedListener +import com.fsck.k9.mailstore.MessageListRepository import com.fsck.k9.preferences.AccountManager -import com.fsck.k9.provider.EmailProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose @@ -26,7 +23,7 @@ import kotlinx.coroutines.launch class AccountsViewModel( accountManager: AccountManager, private val messageCountsProvider: MessageCountsProvider, - private val contentResolver: ContentResolver + private val messageListRepository: MessageListRepository ) : ViewModel() { private val displayAccountFlow: Flow> = accountManager.getAccountsFlow() .flatMapLatest { accounts -> @@ -47,21 +44,17 @@ class AccountsViewModel( private fun getMessageCountsFlow(account: Account): Flow { return callbackFlow { - val notificationUri = EmailProvider.getNotificationUri(account.uuid) - send(messageCountsProvider.getMessageCounts(account)) - val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean) { - launch { - send(messageCountsProvider.getMessageCounts(account)) - } + val listener = MessageListChangedListener { + launch { + send(messageCountsProvider.getMessageCounts(account)) } } - contentResolver.registerContentObserver(notificationUri, false, contentObserver) + messageListRepository.addListener(account.uuid, listener) awaitClose { - contentResolver.unregisterContentObserver(contentObserver) + messageListRepository.removeListener(listener) } }.flowOn(Dispatchers.IO) } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt index f72738ab320872a115911f6b895aa9127fab3753..426350b5e326c6fae8e2332b749f95b284881977 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt @@ -4,7 +4,9 @@ import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val accountUiModule = module { - viewModel { AccountsViewModel(accountManager = get(), messageCountsProvider = get(), contentResolver = get()) } + viewModel { + AccountsViewModel(accountManager = get(), messageCountsProvider = get(), messageListRepository = get()) + } factory { AccountImageLoader(accountFallbackImageProvider = get()) } factory { AccountFallbackImageProvider(context = get()) } factory { AccountImageModelLoaderFactory(contactPhotoLoader = get(), accountFallbackImageProvider = get()) } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt index c24478518c32e90e637bf9032349ddf0ee05e8b4..16be0694ae9097c10e160c5583e8c70cea9b82ea 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/KoinModule.kt @@ -6,7 +6,15 @@ import org.koin.dsl.module val messageListUiModule = module { viewModel { MessageListViewModel(get()) } factory { DefaultFolderProvider() } - factory { MessageListExtractor(get(), get()) } - factory { MessageListLoader(get(), get(), get(), get()) } - factory { MessageListLiveDataFactory(get(), get(), get()) } + factory { + MessageListLoader( + preferences = get(), + localStoreProvider = get(), + messageListRepository = get(), + messageHelper = get() + ) + } + factory { + MessageListLiveDataFactory(messageListLoader = get(), preferences = get(), messageListRepository = get()) + } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListConfig.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListConfig.kt index 3434954e376672217825481bb7aa8065f897bd53..8d8ad2d2721a83f8f1b674f631bb4514b7f702e4 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListConfig.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListConfig.kt @@ -10,5 +10,11 @@ data class MessageListConfig( val sortType: SortType, val sortAscending: Boolean, val sortDateAscending: Boolean, - val activeMessage: MessageReference? + val activeMessage: MessageReference?, + val sortOverrides: Map +) + +data class MessageSortOverride( + val isRead: Boolean, + val isStarred: Boolean ) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListExtractor.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListExtractor.kt deleted file mode 100644 index 0f241475af8bb833b33c32c2290ce34cec99f09a..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListExtractor.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.fsck.k9.ui.messagelist - -import android.database.Cursor -import com.fsck.k9.Preferences -import com.fsck.k9.fragment.MLFProjectionInfo -import com.fsck.k9.helper.MessageHelper -import com.fsck.k9.helper.map -import com.fsck.k9.mail.Address -import com.fsck.k9.mailstore.DatabasePreviewType -import com.fsck.k9.ui.helper.DisplayAddressHelper - -class MessageListExtractor( - private val preferences: Preferences, - private val messageHelper: MessageHelper -) { - fun extractMessageList(cursor: Cursor, uniqueIdColumn: Int, threadCountIncluded: Boolean): List { - return cursor.map { extractMessageListItem(it, uniqueIdColumn, threadCountIncluded) } - } - - private fun extractMessageListItem( - cursor: Cursor, - uniqueIdColumn: Int, - threadCountIncluded: Boolean - ): MessageListItem { - val position = cursor.position - val accountUuid = cursor.getString(MLFProjectionInfo.ACCOUNT_UUID_COLUMN) - val account = preferences.getAccount(accountUuid) ?: error("Account $accountUuid not found") - - val fromList = cursor.getString(MLFProjectionInfo.SENDER_LIST_COLUMN) - val toList = cursor.getString(MLFProjectionInfo.TO_LIST_COLUMN) - val ccList = cursor.getString(MLFProjectionInfo.CC_LIST_COLUMN) - val fromAddresses = Address.unpack(fromList) - val toAddresses = Address.unpack(toList) - val ccAddresses = Address.unpack(ccList) - val toMe = messageHelper.toMe(account, toAddresses) - val ccMe = messageHelper.toMe(account, ccAddresses) - val messageDate = cursor.getLong(MLFProjectionInfo.DATE_COLUMN) - val threadCount = if (threadCountIncluded) cursor.getInt(MLFProjectionInfo.THREAD_COUNT_COLUMN) else 0 - val subject = cursor.getString(MLFProjectionInfo.SUBJECT_COLUMN) - val isRead = cursor.getBoolean(MLFProjectionInfo.READ_COLUMN) - val isStarred = cursor.getBoolean(MLFProjectionInfo.FLAGGED_COLUMN) - val isAnswered = cursor.getBoolean(MLFProjectionInfo.ANSWERED_COLUMN) - val isForwarded = cursor.getBoolean(MLFProjectionInfo.FORWARDED_COLUMN) - val hasAttachments = cursor.getInt(MLFProjectionInfo.ATTACHMENT_COUNT_COLUMN) > 0 - val previewTypeString = cursor.getString(MLFProjectionInfo.PREVIEW_TYPE_COLUMN) - val previewType = DatabasePreviewType.fromDatabaseValue(previewTypeString) - val isMessageEncrypted = previewType == DatabasePreviewType.ENCRYPTED - val previewText = getPreviewText(previewType, cursor) - val uniqueId = cursor.getLong(uniqueIdColumn) - val folderId = cursor.getLong(MLFProjectionInfo.FOLDER_ID_COLUMN) - val messageUid = cursor.getString(MLFProjectionInfo.UID_COLUMN) - val databaseId = cursor.getLong(MLFProjectionInfo.ID_COLUMN) - val threadRoot = cursor.getLong(MLFProjectionInfo.THREAD_ROOT_COLUMN) - val showRecipients = DisplayAddressHelper.shouldShowRecipients(account, folderId) - val displayAddress = if (showRecipients) toAddresses.firstOrNull() else fromAddresses.firstOrNull() - val displayName = if (showRecipients) { - messageHelper.getRecipientDisplayNames(toAddresses) - } else { - messageHelper.getSenderDisplayName(displayAddress) - } - - return MessageListItem( - position, - account, - subject, - threadCount, - messageDate, - displayName, - displayAddress, - toMe, - ccMe, - previewText, - isMessageEncrypted, - isRead, - isStarred, - isAnswered, - isForwarded, - hasAttachments, - uniqueId, - folderId, - messageUid, - databaseId, - threadRoot - ) - } - - private fun getPreviewText(previewType: DatabasePreviewType?, cursor: Cursor): String { - return if (previewType == DatabasePreviewType.TEXT) { - cursor.getString(MLFProjectionInfo.PREVIEW_COLUMN) ?: "" - } else { - "" - } - } - - private fun Cursor.getBoolean(columnIndex: Int): Boolean = getInt(columnIndex) == 1 -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItem.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItem.kt index 3797bea8a11a2ca46fbbf9733f54e7d9ffd27516..4d7f67303fe1938fc467a89c63b88e0dc077bb7d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItem.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItem.kt @@ -1,14 +1,15 @@ package com.fsck.k9.ui.messagelist import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference import com.fsck.k9.mail.Address data class MessageListItem( - val position: Int, val account: Account, val subject: String?, val threadCount: Int, val messageDate: Long, + val internalDate: Long, val displayName: CharSequence, val displayAddress: Address?, val toMe: Boolean, @@ -25,4 +26,7 @@ data class MessageListItem( val messageUid: String, val databaseId: Long, val threadRoot: Long -) +) { + val messageReference: MessageReference + get() = MessageReference(account.uuid, folderId, messageUid) +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemMapper.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..5840804cdd005436c19be3dff67d78fd08f21b5f --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemMapper.kt @@ -0,0 +1,60 @@ +package com.fsck.k9.ui.messagelist + +import com.fsck.k9.Account +import com.fsck.k9.helper.MessageHelper +import com.fsck.k9.mailstore.MessageDetailsAccessor +import com.fsck.k9.mailstore.MessageMapper +import com.fsck.k9.message.extractors.PreviewResult.PreviewType +import com.fsck.k9.ui.helper.DisplayAddressHelper + +class MessageListItemMapper( + private val messageHelper: MessageHelper, + private val account: Account +) : MessageMapper { + + override fun map(message: MessageDetailsAccessor): MessageListItem { + val fromAddresses = message.fromAddresses + val toAddresses = message.toAddresses + val toMe = messageHelper.toMe(account, toAddresses) + val ccMe = messageHelper.toMe(account, message.ccAddresses) + val previewResult = message.preview + val isMessageEncrypted = previewResult.previewType == PreviewType.ENCRYPTED + val previewText = if (previewResult.isPreviewTextAvailable) previewResult.previewText else "" + val uniqueId = createUniqueId(account, message.id) + val showRecipients = DisplayAddressHelper.shouldShowRecipients(account, message.folderId) + val displayAddress = if (showRecipients) toAddresses.firstOrNull() else fromAddresses.firstOrNull() + val displayName = if (showRecipients) { + messageHelper.getRecipientDisplayNames(toAddresses.toTypedArray()) + } else { + messageHelper.getSenderDisplayName(displayAddress) + } + + return MessageListItem( + account, + message.subject, + message.threadCount, + message.messageDate, + message.internalDate, + displayName, + displayAddress, + toMe, + ccMe, + previewText, + isMessageEncrypted, + message.isRead, + message.isStarred, + message.isAnswered, + message.isForwarded, + message.hasAttachments, + uniqueId, + message.folderId, + message.messageServerId, + message.id, + message.threadRoot + ) + } + + private fun createUniqueId(account: Account, messageId: Long): Long { + return ((account.accountNumber + 1).toLong() shl 52) + messageId + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveData.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveData.kt index aaf36a1b08d4f35a080857f516411081ed8d61c4..bf757521d24ff89ef52ac88bf86691e44c068281 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveData.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveData.kt @@ -1,12 +1,9 @@ package com.fsck.k9.ui.messagelist -import android.content.ContentResolver -import android.database.ContentObserver -import android.net.Uri -import android.os.Handler import androidx.lifecycle.LiveData import com.fsck.k9.Preferences -import com.fsck.k9.provider.EmailProvider +import com.fsck.k9.mailstore.MessageListChangedListener +import com.fsck.k9.mailstore.MessageListRepository import com.fsck.k9.search.getAccountUuids import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -16,53 +13,43 @@ import kotlinx.coroutines.withContext class MessageListLiveData( private val messageListLoader: MessageListLoader, private val preferences: Preferences, - private val contentResolver: ContentResolver, + private val messageListRepository: MessageListRepository, private val coroutineScope: CoroutineScope, val config: MessageListConfig ) : LiveData() { - private val contentObserver = object : ContentObserver(Handler()) { - override fun onChange(selfChange: Boolean) { - loadMessageListAsync() - } + private val messageListChangedListener = MessageListChangedListener { + loadMessageListAsync() } private fun loadMessageListAsync() { coroutineScope.launch(Dispatchers.Main) { - value = withContext(Dispatchers.IO) { + val messageList = withContext(Dispatchers.IO) { messageListLoader.getMessageList(config) } + value = messageList } } override fun onActive() { super.onActive() - registerContentObserverAsync() + registerMessageListChangedListenerAsync() loadMessageListAsync() } override fun onInactive() { super.onInactive() - contentResolver.unregisterContentObserver(contentObserver) + messageListRepository.removeListener(messageListChangedListener) } - private fun registerContentObserverAsync() { - coroutineScope.launch(Dispatchers.Main) { - val notificationUris = withContext(Dispatchers.IO) { - getNotificationUris() - } + private fun registerMessageListChangedListenerAsync() { + coroutineScope.launch(Dispatchers.IO) { + val accountUuids = config.search.getAccountUuids(preferences) - for (notificationUri in notificationUris) { - contentResolver.registerContentObserver(notificationUri, false, contentObserver) + for (accountUuid in accountUuids) { + messageListRepository.addListener(accountUuid, messageListChangedListener) } } } - - private fun getNotificationUris(): List { - val accountUuids = config.search.getAccountUuids(preferences) - return accountUuids.map { accountUuid -> - EmailProvider.getNotificationUri(accountUuid) - } - } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveDataFactory.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveDataFactory.kt index 315a0199c4abb74f0e25ade09a5a5712ea697299..af995910f10bc08f7b2885f774e2102603fe7ce7 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveDataFactory.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLiveDataFactory.kt @@ -1,15 +1,15 @@ package com.fsck.k9.ui.messagelist -import android.content.ContentResolver import com.fsck.k9.Preferences +import com.fsck.k9.mailstore.MessageListRepository import kotlinx.coroutines.CoroutineScope class MessageListLiveDataFactory( private val messageListLoader: MessageListLoader, private val preferences: Preferences, - private val contentResolver: ContentResolver + private val messageListRepository: MessageListRepository ) { fun create(coroutineScope: CoroutineScope, config: MessageListConfig): MessageListLiveData { - return MessageListLiveData(messageListLoader, preferences, contentResolver, coroutineScope, config) + return MessageListLiveData(messageListLoader, preferences, messageListRepository, coroutineScope, config) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt index 7a5b6806b63259a0ad42584f30cff4f50b6dbb7e..0409309cbf1105ca81267dee22dbd0c36674c79d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt @@ -1,42 +1,23 @@ package com.fsck.k9.ui.messagelist -import android.annotation.SuppressLint -import android.content.ContentResolver -import android.database.Cursor -import android.database.MatrixCursor -import android.database.sqlite.SQLiteException -import android.net.Uri import com.fsck.k9.Account import com.fsck.k9.Account.SortType -import com.fsck.k9.K9 import com.fsck.k9.Preferences -import com.fsck.k9.fragment.MLFProjectionInfo -import com.fsck.k9.fragment.MessageListFragmentComparators.ArrivalComparator -import com.fsck.k9.fragment.MessageListFragmentComparators.AttachmentComparator -import com.fsck.k9.fragment.MessageListFragmentComparators.ComparatorChain -import com.fsck.k9.fragment.MessageListFragmentComparators.DateComparator -import com.fsck.k9.fragment.MessageListFragmentComparators.FlaggedComparator -import com.fsck.k9.fragment.MessageListFragmentComparators.ReverseComparator -import com.fsck.k9.fragment.MessageListFragmentComparators.ReverseIdComparator -import com.fsck.k9.fragment.MessageListFragmentComparators.SenderComparator -import com.fsck.k9.fragment.MessageListFragmentComparators.SubjectComparator -import com.fsck.k9.fragment.MessageListFragmentComparators.UnreadComparator -import com.fsck.k9.helper.MergeCursorWithUniqueId +import com.fsck.k9.helper.MessageHelper import com.fsck.k9.mailstore.LocalStoreProvider -import com.fsck.k9.provider.EmailProvider +import com.fsck.k9.mailstore.MessageColumns +import com.fsck.k9.mailstore.MessageListRepository import com.fsck.k9.search.LocalSearch import com.fsck.k9.search.SearchSpecification.SearchField import com.fsck.k9.search.SqlQueryBuilder import com.fsck.k9.search.getAccounts -import java.util.ArrayList -import java.util.Comparator import timber.log.Timber class MessageListLoader( private val preferences: Preferences, - private val contentResolver: ContentResolver, private val localStoreProvider: LocalStoreProvider, - private val messageListExtractor: MessageListExtractor + private val messageListRepository: MessageListRepository, + private val messageHelper: MessageHelper ) { fun getMessageList(config: MessageListConfig): MessageListInfo { @@ -52,113 +33,76 @@ class MessageListLoader( private fun getMessageListInfo(config: MessageListConfig): MessageListInfo { val accounts = config.search.getAccounts(preferences) - val cursors = accounts - .mapNotNull { loadMessageListForAccount(it, config) } - .toTypedArray() - - if (cursors.isEmpty()) { - Timber.w("Couldn't get message list") - return MessageListInfo(messageListItems = emptyList(), hasMoreMessages = false) - } - - val cursor: Cursor - val uniqueIdColumn: Int - if (cursors.size == 1) { - cursor = cursors[0] - uniqueIdColumn = MLFProjectionInfo.ID_COLUMN - } else { - cursor = MergeCursorWithUniqueId(cursors, getComparator(config)) - uniqueIdColumn = cursor.getColumnIndex("_id") - } + val messageListItems = accounts + .flatMap { account -> + loadMessageListForAccount(account, config) + } + .sortedWith(config) - val messageListItems = cursor.use { - messageListExtractor.extractMessageList( - cursor, - uniqueIdColumn, - threadCountIncluded = config.showingThreadedList - ) - } val hasMoreMessages = loadHasMoreMessages(accounts, config.search.folderIds) return MessageListInfo(messageListItems, hasMoreMessages) } - @SuppressLint("Recycle") - private fun loadMessageListForAccount(account: Account, config: MessageListConfig): Cursor? { + private fun loadMessageListForAccount(account: Account, config: MessageListConfig): List { val accountUuid = account.uuid - val threadId: String? = getThreadId(config.search) + val threadId = getThreadId(config.search) + val sortOrder = buildSortOrder(config) + val mapper = MessageListItemMapper(messageHelper, account) - val uri: Uri - val projection: Array - val needConditions: Boolean - when { + return when { threadId != null -> { - uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/$accountUuid/thread/$threadId") - projection = MLFProjectionInfo.PROJECTION - needConditions = false + messageListRepository.getThread(accountUuid, threadId, sortOrder, mapper) } config.showingThreadedList -> { - uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/$accountUuid/messages/threaded") - projection = MLFProjectionInfo.THREADED_PROJECTION - needConditions = true + val (selection, selectionArgs) = buildSelection(account, config) + messageListRepository.getThreadedMessages(accountUuid, selection, selectionArgs, sortOrder, mapper) } else -> { - uri = Uri.withAppendedPath(EmailProvider.CONTENT_URI, "account/$accountUuid/messages") - projection = MLFProjectionInfo.PROJECTION - needConditions = true + val (selection, selectionArgs) = buildSelection(account, config) + messageListRepository.getMessages(accountUuid, selection, selectionArgs, sortOrder, mapper) } } + } + private fun buildSelection(account: Account, config: MessageListConfig): Pair> { val query = StringBuilder() - val queryArgs: MutableList = ArrayList() - if (needConditions) { - val activeMessage = config.activeMessage - val selectActive = activeMessage != null && activeMessage.accountUuid == accountUuid - if (selectActive && activeMessage != null) { - query.append("(${EmailProvider.MessageColumns.UID} = ? AND ${EmailProvider.MessageColumns.FOLDER_ID} = ?) OR (") - queryArgs.add(activeMessage.uid) - queryArgs.add(activeMessage.folderId.toString()) - } + val queryArgs = mutableListOf() + + val activeMessage = config.activeMessage + val selectActive = activeMessage != null && activeMessage.accountUuid == account.uuid + if (selectActive && activeMessage != null) { + query.append("(${MessageColumns.UID} = ? AND ${MessageColumns.FOLDER_ID} = ?) OR (") + queryArgs.add(activeMessage.uid) + queryArgs.add(activeMessage.folderId.toString()) + } - SqlQueryBuilder.buildWhereClause(account, config.search.conditions, query, queryArgs) + SqlQueryBuilder.buildWhereClause(account, config.search.conditions, query, queryArgs) - if (selectActive) { - query.append(')') - } + if (selectActive) { + query.append(')') } val selection = query.toString() val selectionArgs = queryArgs.toTypedArray() - val sortOrder: String = buildSortOrder(config) - - return try { - contentResolver.query(uri, projection, selection, selectionArgs, sortOrder) - } catch (e: SQLiteException) { - Timber.e(e, "Error querying EmailProvider") - - if (K9.DEVELOPER_MODE && e.message?.contains("malformed MATCH expression") == false) { - throw e - } - - return MatrixCursor(projection) - } + return selection to selectionArgs } - private fun getThreadId(search: LocalSearch): String? { - return search.leafSet.firstOrNull { it.condition.field == SearchField.THREAD_ID }?.condition?.value + private fun getThreadId(search: LocalSearch): Long? { + return search.leafSet.firstOrNull { it.condition.field == SearchField.THREAD_ID }?.condition?.value?.toLong() } private fun buildSortOrder(config: MessageListConfig): String { val sortColumn = when (config.sortType) { - SortType.SORT_ARRIVAL -> EmailProvider.MessageColumns.INTERNAL_DATE - SortType.SORT_ATTACHMENT -> "(${EmailProvider.MessageColumns.ATTACHMENT_COUNT} < 1)" - SortType.SORT_FLAGGED -> "(${EmailProvider.MessageColumns.FLAGGED} != 1)" - SortType.SORT_SENDER -> EmailProvider.MessageColumns.SENDER_LIST // FIXME - SortType.SORT_SUBJECT -> "${EmailProvider.MessageColumns.SUBJECT} COLLATE NOCASE" - SortType.SORT_UNREAD -> EmailProvider.MessageColumns.READ - SortType.SORT_DATE -> EmailProvider.MessageColumns.DATE - else -> EmailProvider.MessageColumns.DATE + SortType.SORT_ARRIVAL -> MessageColumns.INTERNAL_DATE + SortType.SORT_ATTACHMENT -> "(${MessageColumns.ATTACHMENT_COUNT} < 1)" + SortType.SORT_FLAGGED -> "(${MessageColumns.FLAGGED} != 1)" + SortType.SORT_SENDER -> MessageColumns.SENDER_LIST // FIXME + SortType.SORT_SUBJECT -> "${MessageColumns.SUBJECT} COLLATE NOCASE" + SortType.SORT_UNREAD -> MessageColumns.READ + SortType.SORT_DATE -> MessageColumns.DATE + else -> MessageColumns.DATE } val sortDirection = if (config.sortAscending) " ASC" else " DESC" @@ -166,41 +110,48 @@ class MessageListLoader( "" } else { if (config.sortDateAscending) { - "${EmailProvider.MessageColumns.DATE} ASC, " + "${MessageColumns.DATE} ASC, " } else { - "${EmailProvider.MessageColumns.DATE} DESC, " + "${MessageColumns.DATE} DESC, " } } - return "$sortColumn$sortDirection, $secondarySort${EmailProvider.MessageColumns.ID} DESC" + return "$sortColumn$sortDirection, $secondarySort${MessageColumns.ID} DESC" } - private fun getComparator(config: MessageListConfig): Comparator? { - val chain: MutableList> = ArrayList(3 /* we add 3 comparators at most */) - - // Add the specified comparator - val comparator = SORT_COMPARATORS.getValue(config.sortType) - if (config.sortAscending) { - chain.add(comparator) - } else { - chain.add(ReverseComparator(comparator)) - } - - // Add the date comparator if not already specified - if (config.sortType != SortType.SORT_DATE && config.sortType != SortType.SORT_ARRIVAL) { - val dateComparator = SORT_COMPARATORS.getValue(SortType.SORT_DATE) - if (config.sortDateAscending) { - chain.add(dateComparator) - } else { - chain.add(ReverseComparator(dateComparator)) + private fun List.sortedWith(config: MessageListConfig): List { + val comparator = when (config.sortType) { + SortType.SORT_DATE -> { + compareBy(config.sortAscending) { it.messageDate } } - } - - // Add the id comparator - chain.add(ReverseIdComparator()) + SortType.SORT_ARRIVAL -> { + compareBy(config.sortAscending) { it.internalDate } + } + SortType.SORT_SUBJECT -> { + compareStringBy(config.sortAscending) { it.subject.orEmpty() } + .thenByDate(config) + } + SortType.SORT_SENDER -> { + compareStringBy(config.sortAscending) { it.displayName.toString() } + .thenByDate(config) + } + SortType.SORT_UNREAD -> { + compareBy(config.sortAscending) { + config.sortOverrides[it.messageReference]?.isRead ?: it.isRead + }.thenByDate(config) + } + SortType.SORT_FLAGGED -> { + compareBy(!config.sortAscending) { + config.sortOverrides[it.messageReference]?.isStarred ?: it.isStarred + }.thenByDate(config) + } + SortType.SORT_ATTACHMENT -> { + compareBy(!config.sortAscending) { it.hasAttachments } + .thenByDate(config) + } + }.thenByDescending { it.databaseId } - // Build the comparator chain - return ComparatorChain(chain) + return this.sortedWith(comparator) } private fun loadHasMoreMessages(accounts: List, folderIds: List): Boolean { @@ -215,17 +166,29 @@ class MessageListLoader( false } } +} + +private inline fun compareBy(sortAscending: Boolean, crossinline selector: (T) -> Comparable<*>?): Comparator { + return if (sortAscending) { + compareBy(selector) + } else { + compareByDescending(selector) + } +} + +private inline fun compareStringBy(sortAscending: Boolean, crossinline selector: (T) -> String): Comparator { + return if (sortAscending) { + compareBy(String.CASE_INSENSITIVE_ORDER, selector) + } else { + compareByDescending(String.CASE_INSENSITIVE_ORDER, selector) + } +} - companion object { - private val SORT_COMPARATORS = mapOf( - SortType.SORT_ATTACHMENT to AttachmentComparator(), - SortType.SORT_DATE to DateComparator(), - SortType.SORT_ARRIVAL to ArrivalComparator(), - SortType.SORT_FLAGGED to FlaggedComparator(), - SortType.SORT_SUBJECT to SubjectComparator(), - SortType.SORT_SENDER to SenderComparator(), - SortType.SORT_UNREAD to UnreadComparator() - ) +private fun Comparator.thenByDate(config: MessageListConfig): Comparator { + return if (config.sortDateAscending) { + thenBy { it.messageDate } + } else { + thenByDescending { it.messageDate } } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListViewModel.kt index e394fdd05cc20e55b390101e2b878babe8504403..253a18a7fc0fc5fd132b83e6dc16f98a6d65e07e 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListViewModel.kt @@ -4,11 +4,15 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.fsck.k9.controller.MessageReference +import java.util.LinkedList class MessageListViewModel(private val messageListLiveDataFactory: MessageListLiveDataFactory) : ViewModel() { private var currentMessageListLiveData: MessageListLiveData? = null private val messageListLiveData = MediatorLiveData() + val messageSortOverrides = LinkedList>() + fun getMessageListLiveData(): LiveData { return messageListLiveData } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java index 7eaf4167826ea336d1283b4d03ab48a927f46452..83d1431adbb6cd51ca946064ba2a940f7b31738e 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java @@ -86,7 +86,7 @@ public class AttachmentController { private void downloadAttachment(LocalPart localPart, final Runnable attachmentDownloadedCallback) { String accountUuid = localPart.getAccountUuid(); - Account account = Preferences.getPreferences(context).getAccount(accountUuid); + Account account = Preferences.getPreferences().getAccount(accountUuid); LocalMessage message = localPart.getMessage(); messageViewFragment.showAttachmentLoadingDialog(); @@ -220,11 +220,6 @@ public class AttachmentController { private class ViewAttachmentAsyncTask extends AsyncTask { - @Override - protected void onPreExecute() { - messageViewFragment.disableAttachmentButtons(attachment); - } - @Override protected Intent doInBackground(Void... params) { return getBestViewIntent(); @@ -233,7 +228,6 @@ public class AttachmentController { @Override protected void onPostExecute(Intent intent) { viewAttachment(intent); - messageViewFragment.enableAttachmentButtons(attachment); } private void viewAttachment(Intent intent) { @@ -250,11 +244,6 @@ public class AttachmentController { private class SaveAttachmentAsyncTask extends AsyncTask { - @Override - protected void onPreExecute() { - messageViewFragment.disableAttachmentButtons(attachment); - } - @Override protected Boolean doInBackground(Uri... params) { try { @@ -269,7 +258,6 @@ public class AttachmentController { @Override protected void onPostExecute(Boolean success) { - messageViewFragment.enableAttachmentButtons(attachment); if (!success) { displayAttachmentNotSavedMessage(); } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt index ee83695a9107f738202d211f14fe628a17be5a57..53d759261661dc3c3211355cfad5bc29e87eec1f 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt @@ -32,6 +32,8 @@ class MessageViewContainerFragment : Fragment() { lateinit var messageReference: MessageReference private set + private var activeMessageReference: MessageReference? = null + var lastDirection: Direction? = null private set @@ -140,22 +142,23 @@ class MessageViewContainerFragment : Fragment() { } private fun setActiveMessage(position: Int) { - rememberNavigationDirection(position, messageReference) + // If the position of current message changes (e.g. because messages were added or removed from the list), we + // keep track of the new position but otherwise ignore the event. + val newMessageReference = adapter.getMessageReference(position) + if (newMessageReference == activeMessageReference) { + currentPosition = position + return + } + + rememberNavigationDirection(position) messageReference = adapter.getMessageReference(position) + activeMessageReference = messageReference fragmentListener.setActiveMessage(messageReference) } - private fun rememberNavigationDirection(newPosition: Int, currentMessageReference: MessageReference) { - // When messages are added or removed from the list, the current position will change even though we're still - // displaying the same message. In those cases we don't want to update `lastDirection`. - val newMessageReference = adapter.getMessageReference(newPosition) - if (newMessageReference == currentMessageReference) { - currentPosition = newPosition - return - } - + private fun rememberNavigationDirection(newPosition: Int) { currentPosition?.let { currentPosition -> lastDirection = if (newPosition < currentPosition) Direction.PREVIOUS else Direction.NEXT } @@ -310,6 +313,3 @@ class MessageViewContainerFragment : Fragment() { } } } - -private val MessageListItem.messageReference: MessageReference - get() = MessageReference(account.uuid, folderId, messageUid) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt index 9eec05e6f5314cc3ca2bd01237e4f27097609306..a7de139a0cc746eb57896b9e03682aabfed6a095 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt @@ -278,6 +278,8 @@ class MessageViewFragment : } override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (message == null) return false + when (item.itemId) { R.id.toggle_message_view_theme -> onToggleTheme() R.id.delete -> onDelete() @@ -736,10 +738,6 @@ class MessageViewFragment : startActivity(intent) } - fun disableAttachmentButtons(attachment: AttachmentViewInfo?) = Unit - - fun enableAttachmentButtons(attachment: AttachmentViewInfo?) = Unit - fun runOnMainThread(runnable: Runnable) { requireActivity().runOnUiThread(runnable) } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/OpenPgpAppSelectDialog.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/OpenPgpAppSelectDialog.java index d9045bc4fa590413cffe410e317abe32e3b3e886..18af4fcf6df8c16543a120529c20ebf5f587da71 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/OpenPgpAppSelectDialog.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/OpenPgpAppSelectDialog.java @@ -66,7 +66,7 @@ public class OpenPgpAppSelectDialog extends K9Activity { super.onCreate(savedInstanceState); String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - account = Preferences.getPreferences(this).getAccount(accountUuid); + account = Preferences.getPreferences().getAccount(accountUuid); } @Override @@ -283,7 +283,7 @@ public class OpenPgpAppSelectDialog extends K9Activity { private void persistOpenPgpProviderSetting(String selectedPackage) { account.setOpenPgpProvider(selectedPackage); - Preferences.getPreferences(getApplicationContext()).saveAccount(account); + Preferences.getPreferences().saveAccount(account); } private static class OpenPgpProviderEntry { diff --git a/app/ui/legacy/src/main/res/raw/changelog_master.xml b/app/ui/legacy/src/main/res/raw/changelog_master.xml index 44d538b120dcb28dd8d85d21c8b78d3ca029e353..714ff744dc1eac930a8c4973352db53e40b9b163 100644 --- a/app/ui/legacy/src/main/res/raw/changelog_master.xml +++ b/app/ui/legacy/src/main/res/raw/changelog_master.xml @@ -5,6 +5,19 @@ Locale-specific versions are kept in res/raw-/changelog.xml. --> + + Fixed a bug that lead to search being broken + Fixed error reporting for (old) send failures + Fixed "strip signatures on reply" + Fixed a crash when tapping a toolbar action in message view before loading the message has finished + + + Fixed moving to next/previous message when sorting the message list by read/unread or starred/unstarred + Fixed a crash when a third-party app shared a file to K-9 Mail without granting access to it + Keep some more attributes when sanitizing HTML + A lot of internal changes and improvements + Updated translations + Fixed crash when viewing a message and OpenKeychain needed to display its user interface, e.g. to ask for a password When composing a message containing consecutive spaces convert them to non-breaking spaces in the generated HTML part of the message diff --git a/app/ui/legacy/src/main/res/values-ar/strings.xml b/app/ui/legacy/src/main/res/values-ar/strings.xml index db9fa0b16d8c6b3ade02259540fbe1994293f217..13ac633234c621edb07addbae6a15282a250aefe 100644 --- a/app/ui/legacy/src/main/res/values-ar/strings.xml +++ b/app/ui/legacy/src/main/res/values-ar/strings.xml @@ -118,7 +118,7 @@ %d رسالة %d رسالة جديدة - %d غير مقروئة (%s) + %1$d غير مقروئة (%2$s) رُد إعتبارها مقروءة إعتبار الكل كمقروء @@ -133,7 +133,7 @@ فشل مصادقة %s. يرجى تحديث إعدادات الخادم. - تفقد البريد: %s:%s + تفقد البريد: %1$s:%2$s التحقق من البريد إرسال البريد: %s إرسال البريد @@ -174,7 +174,7 @@ حذف النص المقتبَس تعديل النص المقتبَس إزالة المُرفَق - من: %s <%s> + من: %1$s <%2$s> إلى : نسخة كربونية : نسخة مخفية: diff --git a/app/ui/legacy/src/main/res/values-be/strings.xml b/app/ui/legacy/src/main/res/values-be/strings.xml index 371f91eb3002c8f552edd51e8db84bb4e298d3d7..9f2082bef171c4f1b3bbb8a25de58ecc47fec9b3 100644 --- a/app/ui/legacy/src/main/res/values-be/strings.xml +++ b/app/ui/legacy/src/main/res/values-be/strings.xml @@ -122,7 +122,7 @@ %d новых лістоў %d новых лістоў - %d новых (%s) + %1$d новых (%2$s) + %1$d яшчэ ў %2$s Адказаць Пазначыць прачытаным @@ -138,7 +138,7 @@ Няўдалая аўтарызацыя для %s. Змяніце налады сервера. - Праверка пошты: %s:%s + Праверка пошты: %1$s:%2$s Праверка пошты Адпраўка пошты: %s Адпраўленне пошты @@ -180,7 +180,7 @@ Выдаліць цытату Рэдагаваць цытату Выдаліць далучаныя файлы - Ад: %s <%s> + Ад: %1$s <%2$s> Каму: Копія: Схаваная копія: diff --git a/app/ui/legacy/src/main/res/values-bg/strings.xml b/app/ui/legacy/src/main/res/values-bg/strings.xml index a693106dde4724da5d5e29a3044dd778fedf6d4f..288c25d96dcee58b72299df3fd742427828836e8 100644 --- a/app/ui/legacy/src/main/res/values-bg/strings.xml +++ b/app/ui/legacy/src/main/res/values-bg/strings.xml @@ -124,7 +124,7 @@ %d ново съобщение %d нови съобщения - %d Непрочетени (%s) + %1$d Непрочетени (%2$s) + %1$d в %2$s Отговори Маркирай като прочетено @@ -141,7 +141,7 @@ Идентификацията за %s е неуспешна. Обновете сървърните настройки. - Проверка на поща: %s:%s + Проверка на поща: %1$s:%2$s Проверка на поща Изпраща писмо: %s Изпраща писмо @@ -185,7 +185,7 @@ Премахни цитираният текст Редактирай цитираният текст Премахни прикачени файлове - От: %s <%s> + От: %1$s <%2$s> До: Копие: Bcc: diff --git a/app/ui/legacy/src/main/res/values-br/strings.xml b/app/ui/legacy/src/main/res/values-br/strings.xml index 83bdc2c38130974b6b4a3f54f70ad85dfb9c86d3..58f4f51c7db6dfe3f8b17f0dcbac6a9e547a09dd 100644 --- a/app/ui/legacy/src/main/res/values-br/strings.xml +++ b/app/ui/legacy/src/main/res/values-br/strings.xml @@ -109,7 +109,7 @@ %d a gemennadennoù nevez %d a gemennadennoù nevez - %d Anlennet (%s) + %1$d Anlennet (%2$s) + %1$d ouzhpenn war %2$s Respont Merkañ evel lennet @@ -125,7 +125,7 @@ C’hwitadenn war an dilesa evit %s. Hizivait an arventennoù dafariad. - O kerc’hat ar posteloù: %s:%s + O kerc’hat ar posteloù: %1$s:%2$s Kerc’hat ar posteloù O kas ar postel: %s O kas ar postel @@ -166,7 +166,7 @@ Enkorfañ ar gemennadenn meneget Dilemel an destenn meneget Embann an destenn meneget - A-berzh: %s &amp;lt;%s&amp;gt; + A-berzh: %1$s <%2$s> Da: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-ca/strings.xml b/app/ui/legacy/src/main/res/values-ca/strings.xml index fc5d0102d5930894a213b0e632c449b27454ad7d..5f9ae6ad8a1559dcb7487c256d89638dcebb1a01 100644 --- a/app/ui/legacy/src/main/res/values-ca/strings.xml +++ b/app/ui/legacy/src/main/res/values-ca/strings.xml @@ -127,7 +127,7 @@ %d nous missatges %d nous missatges - %d no llegit(s) (%s) + %1$d no llegit(s) (%2$s) + %1$d més sobre %2$s Respon Marca com a llegit @@ -146,7 +146,7 @@ Error de notificació S\'ha produït un error en intentar crear una notificació del sistema per a un missatge nou. El motiu més probable és que falti un so de notificació.\n\nToqueu per obrir la configuració de notificació. - S\'està comprovant el correu: %s:%s + S\'està comprovant el correu: %1$s:%2$s S\'està comprovant el correu S\'està enviant correu: %s S\'està enviant correu @@ -193,7 +193,7 @@ Elimina el text citat Edita el text citat Suprimeix l\'adjunt - De: %s <%s> + De: %1$s <%2$s> A: A/c: Bcc: diff --git a/app/ui/legacy/src/main/res/values-cs/strings.xml b/app/ui/legacy/src/main/res/values-cs/strings.xml index 528c61989127705ddea407ff499bd1aefc8d35e5..4db2e74bc21a49b602bd756d08b3e30ad567dff9 100644 --- a/app/ui/legacy/src/main/res/values-cs/strings.xml +++ b/app/ui/legacy/src/main/res/values-cs/strings.xml @@ -133,7 +133,7 @@ %d nových zpráv %d nové zprávy - %d Nepřečteno (%s) + %1$d Nepřečteno (%2$s) + %1$d více na %2$s Odpovědět Přečteno @@ -152,7 +152,7 @@ Chyba upozornění Vyskytla se chyba při pokusu o vytvoření systémového upozornění pro novou zprávu. Důvodem je nejspíš chybějící zvuk upozornění.\n\nKlepnutím otevřete nastavení upozorňování. - Zjišťování pošty: %s:%s + Zjišťování pošty: %1$s:%2$s Zjišťování pošty Odesílání pošty: %s Odesílání pošty @@ -199,7 +199,7 @@ Odstranit citovaný text Upravit citovaný text Odebrat přílohu - Odesílatel: %s <%s> + Odesílatel: %1$s <%2$s> Komu: Kopie: Skrytá kopie (Bcc): diff --git a/app/ui/legacy/src/main/res/values-cy/strings.xml b/app/ui/legacy/src/main/res/values-cy/strings.xml index 3ff0de56b5c28ee3fca908a41293e9785bdb8853..f864081190b9bf9036431d0265f3038e17949cfa 100644 --- a/app/ui/legacy/src/main/res/values-cy/strings.xml +++ b/app/ui/legacy/src/main/res/values-cy/strings.xml @@ -130,7 +130,7 @@ %d neges newydd %d neges newydd - %d Heb eu darllen (%s) + %1$d Heb eu darllen (%2$s) + %1$d yn rhagor ar %2$s Ateb Nodi wedi ei Darllen @@ -147,7 +147,7 @@ Methodd y dilysiad ar gyfer %s. Diweddara dy osodiadau gweinydd. - Yn gwirio am negeseuon: %s:%s + Yn gwirio am negeseuon: %1$s:%2$s Yn gwirio am negeseuon Yn anfon negeseuon: %s Yn anfon neges @@ -194,7 +194,7 @@ Tynnu\'r testun wedi ei ddyfynnu Golygu\'r testun wedi ei ddyfynnu Tynnu atodiad - Oddi wrth: %s <%s> + Oddi wrth: %1$s <%2$s> At: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-da/strings.xml b/app/ui/legacy/src/main/res/values-da/strings.xml index 52de27134569c8cb9b0feb7269214ecdc2afed56..2083a127fa20332a3f8fa3a99146fa21eeaa0aee 100644 --- a/app/ui/legacy/src/main/res/values-da/strings.xml +++ b/app/ui/legacy/src/main/res/values-da/strings.xml @@ -126,7 +126,7 @@ %dnyt brev %d nye meddelelser - %d ulæst(e) (%s) + %1$d ulæst(e) (%2$s) + %1$d mere på %2$s Svar Markér som læst @@ -143,7 +143,7 @@ Godkendelse mislykkedes for %s. Opdater dine serverindstillinger. - Synkroniserer mail: %s:%s + Synkroniserer mail: %1$s:%2$s Kontrollere post Sender mail: %s Sender post @@ -189,7 +189,7 @@ Fjerne citeret tekst Redigere citeret tekst Fjern vedhæftning - Fra: %s <%s> + Fra: %1$s <%2$s> Til: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-el/strings.xml b/app/ui/legacy/src/main/res/values-el/strings.xml index fdcc6554cd697bbec5d542ad81d5814e63a6750e..3064cc558954f7a4b1321a95176451d971714474 100644 --- a/app/ui/legacy/src/main/res/values-el/strings.xml +++ b/app/ui/legacy/src/main/res/values-el/strings.xml @@ -123,7 +123,7 @@ %d νέο μήνυμα %d νέα μηνύματα - %d μη αναγνωσμένα (%s) + %1$d μη αναγνωσμένα (%2$s) + %1$d περισσότερα για %2$s Απάντηση Ανάγνωση @@ -139,7 +139,7 @@ Αποτυχία πιστοποίησης %s. Ενημερώστε τις ρυθμίσεις του εξυπηρετητή σας. - Έλεγχος μηνύματος: %s:%s + Έλεγχος μηνύματος: %1$s:%2$s Έλεγχος μηνύματος Αποστολή μηνύματος: %s Αποστολή μηνύματος @@ -183,7 +183,7 @@ Αφαίρεση κειμένου προηγ. μηνύματος Αλλαγή κειμένου προηγ. μηνύματος Αφαίρεση συνημμένου - Από: %s <%s> + Από: %1$s <%2$s> Προς: Κοιν: Κρυφή Κοιν: diff --git a/app/ui/legacy/src/main/res/values-eo/strings.xml b/app/ui/legacy/src/main/res/values-eo/strings.xml index 8dcf3daa6d1ff07baa92e0f2f87ae7036b314b61..75ac3c9ef14682c137d82a51942f756c7b56eb2e 100644 --- a/app/ui/legacy/src/main/res/values-eo/strings.xml +++ b/app/ui/legacy/src/main/res/values-eo/strings.xml @@ -124,7 +124,7 @@ %d nova mesaĝo %d novaj mesaĝoj - %d novaj (%s) + %1$d novaj (%2$s) + %1$d pliaj en %2$s Respondi Marki kiel legitan @@ -140,7 +140,7 @@ Aŭtentigo por %s malsukcesis. Aktualigu agordojn de servilo. - Kontrolado de retpoŝto: %s:%s + Kontrolado de retpoŝto: %1$s:%2$s Kontrolado de retpoŝto Sendado de retletero: %s Sendado de retletero @@ -184,7 +184,7 @@ Forigi citatan tekston Redakti citatan tekston Forigi kunsendaĵon - De: %s <%s> + De: %1$s <%2$s> Al: Kopio: Kaŝkopio: diff --git a/app/ui/legacy/src/main/res/values-es/strings.xml b/app/ui/legacy/src/main/res/values-es/strings.xml index ac29513195091bef9bc108dcae0645c47d792458..46aaea9afe641d6b90da19c078bb860cf1957324 100644 --- a/app/ui/legacy/src/main/res/values-es/strings.xml +++ b/app/ui/legacy/src/main/res/values-es/strings.xml @@ -127,7 +127,7 @@ %d mensajes nuevos %d mensajes nuevos - %d Sin Leer (%s) + %1$d Sin leer (%2$s) + %1$d más en %2$s Responder Marcar como leído @@ -141,7 +141,11 @@ Compruebe su configuración del servidor Fallo de autenticación Fallo de autenticación en %s. Actualiza y revisa los ajustes del servidor. - Comprobando correo: %s:%s + + Notificación fallida + + Ha ocurrido un problema al intentar crear una notificación del sistema alertando de un correo nuevo. Seguramente sea por no tener un sonido de alerta puesto.\n\nToca para ver los ajustes de notificaciones. + Comprobando correo: %1$s:%2$s Comprobando correo Enviando correo: %s Enviando correo @@ -185,7 +189,7 @@ Eliminar mensaje original Editar mensaje original Eliminar adjunto - De: %s <%s> + De: %1$s <%2$s> Para: Cc: Cco: diff --git a/app/ui/legacy/src/main/res/values-et/strings.xml b/app/ui/legacy/src/main/res/values-et/strings.xml index 0af97fa412ad4fad5583d7ea6884917873ec4e71..08d94957b90c1bc153b0f07c5e5d0b719bd5c6f0 100644 --- a/app/ui/legacy/src/main/res/values-et/strings.xml +++ b/app/ui/legacy/src/main/res/values-et/strings.xml @@ -121,7 +121,7 @@ %d uus kiri %d uut kirja - %d Lugemata (%s) + %1$d Lugemata (%2$s) + %1$d veel kontol %2$s Vasta Märgi loetuks @@ -138,7 +138,8 @@ Autentimine ebaõnnestus konto %s puhul. Uuenda serveri seadeid. - Kontrollib e-kirju: %s:%s + Uue saabunud sõnumi kohta süsteemise teavituse loomisel tekkis viga. Üks võimalik põhjus on teavitusele määratud heli puudumine.\n\nAva teavituste seadistused. + Kontrollib e-kirju: %1$s:%2$s Kontrollib e-kirju Saadab e-kirja: %s Saadab e-kirja @@ -184,7 +185,7 @@ Eemalda osundatud tekst Muuda osundatud teksti Eemalda manus - Kellelt: %s <%s> + Kellelt: %1$s <%2$s> Kellele: Koopia: Pimekoopia: diff --git a/app/ui/legacy/src/main/res/values-eu/strings.xml b/app/ui/legacy/src/main/res/values-eu/strings.xml index 9b72d3ffb4480f7f8d8cd2142fa58eaafcf88a43..bffdcb77c03d59a1de689a4739ae12dc9b01c850 100644 --- a/app/ui/legacy/src/main/res/values-eu/strings.xml +++ b/app/ui/legacy/src/main/res/values-eu/strings.xml @@ -12,6 +12,8 @@ Apache lizentzia, 2.0 bertsioa Kode Irekiko Proiektua Webgunea + Push Informazioa + Lortu laguntza Erabiltzaileen foroa Fedibertsoa Twitter @@ -46,6 +48,8 @@ Birbidali eranskin gisa Aukeratu Kontua Aukeratu karpeta + Mugitu hona… + Kopiatu hona… %d hautatuta Hurrengoa Aurrekoa @@ -58,7 +62,7 @@ Erantzun denei Ezabatu Artxibatu - Spam + Zabor-posta Birbidali Birbidali eranskin gisa Editatu mezu berri gisa @@ -78,6 +82,7 @@ Bilatu Bilatu leku guztietan Bilaketaren emaitzak + Mezu berriak Ezarpenak Kudeatu karpetak Kontu ezarpenak @@ -88,6 +93,7 @@ Gehitu izarra Kendu izarra Kopiatu + Harpidetza kendu Erakutsi goiburuak Helbidea arbelera kopiatuta @@ -122,7 +128,7 @@ Mezu berri %d %d mezu berri - %d irakurri gabeko (%s) + %1$d irakurri gabeko (%2$s) + %1$d gehiago %2$s-(e)n Erantzun Markatu irakurritako gisa @@ -131,14 +137,17 @@ Ezabatu denak Artxibatu Artxibatu denak - Spam + Zabor-posta + Ziurtagiri errorea %s-(r)entzako ziurtagiri errorea Egiaztatu zerbitzariaren ezarpenak Autentifikazioak huts egin du Autentifikazioak huts egin du %s-(e)rako. Eguneratu zerbitzariaren ezarpenak. + Jakinarazpen errorea - Posta egiaztatzen: %s:%s + Errore bat gertatu da mezu berri baterako sistemaren jakinarazpena sortzen saiatzean. Arrazoia ziurrenik jakinarazpen-soinu bat falta da.\n\nSakatu jakinarazpen-ezarpenak irekitzeko. + Posta egiaztatzen: %1$s:%2$s Posta egiaztatzen Posta bidaltzen: %s Posta bidaltzea @@ -159,7 +168,10 @@ Gaitu arazketarako egunkaria Diagnostikorako informazio gehigarria erregistratu Bereziki babestutako informazioa erregistratu - Saio hasieretan pasahitzak erakuts daitezke. + Log-etan pasahitzak erakuts daitezke. + Esportatu logak + Ongi esportatuta. Log-ek informazio sentikorra eduki dezakate. Kontuz nori bidaltzen diozun. + Esportazioak huts egin du Kargatu mezu gehiago Nori:%s Gaia @@ -182,7 +194,7 @@ Kendu aipua Editatu aipua Kendu eranskina - Nondik: %s <%s> + Nondik: %1$s <%2$s> Nori: Cc: Bcc: @@ -206,6 +218,7 @@ Erabili hartzaileen izenak Kontaktuetatik erabilgarri daudenean Koloreztatu kontaktuak Koloreztatu izenak kontaktu zerrendan + Kontaktu-izenaren kolorea Zabalera finkoko letra-tipoak Erabili zabalera finkoko letra-tipoak testu arrunteko mezuak erakustean Doitu mezuak automatikoki @@ -218,7 +231,7 @@ Erakutsi elkarrizketa bat hautatutako ekintzak egitean Ezabatu Ezabatu Izardunak (mezu ikuspegian) - Spam + Zabor-posta Baztertu mezua Markatu mezu guztiak irakurritako gisa Ezabatu (jakinarazpenetatik) @@ -233,6 +246,7 @@ Jakinarazpenak Blokeatutako Pantailan Jakinarazpenik ez blokeatutako pantailan Aplikazioaren izena + Mezu berrien kopurua Mezu kopurua eta bidaltzaileak Pantaila desblokeatuta dagoenean bezala Alertarik gabeko aldia @@ -244,9 +258,15 @@ Konfiguratu kontu berria Posta helbidea Pasahitza + Posta elektronikoko kontu hau K-9 Mail-ekin erabiltzeko, saioa hasi eta aplikazioari zure mezu elektronikoetarako sarbidea eman behar diozu. + Saioa hasi + Saioa hasi Googlerekin + Pasahitza hemen ikusteko, gaitu pantailaren blokeoa gailu honetan. + Egiaztatu zure identitatea + Desblokeatu zure pasahitza ikusteko Eskuzko konfigurazioa Kontuaren informazioa berreskuratzen\u2026 @@ -266,6 +286,7 @@ Pasahitza, era ez seguruan transmitituta Zifratutako pasahitza Bezero ziurtagiria + OAuth 2.0 Sarrerako zerbitzariaren ezarpenak Erabiltzaile izena Pasahitza @@ -284,6 +305,7 @@ Ez ezabatu zerbitzarian Zerbitzarian ezabatu Zerbitzarian irakurritako gisa markatu + Erabili konpresioa Ezabatu hemen ere zerbitzaritik ezabatutako mezuak Berehala Atzitzean @@ -293,8 +315,8 @@ Zirriborroen karpeta Bidalitakoen karpeta Zakarrontziaren karpeta - Artxiboaren karpeta - Spam karpeta + Artxibatuen karpeta + Zabor-posta karpeta Erakutsi harpidetutako karpetak bakarrik Automatikoki irekitako karpeta OWA bide-izena @@ -348,6 +370,10 @@ Erabiltzaile izen edo pasahitz okerrak.\n(%s) Zerbitzariak SSL ziurtagiri baliogabe bat aurkeztu du. Batzuetan, hau zerbitzariak konfigurazio okerra duelako da. Beste batzuetan norbait zu edo zure posta zerbitzaria erasotzen saiatzen ari delako da. Ez bazaude ziur zer ari den gertatzen, klikatu Baztertu eta jarri harremanetan zure posta zerbitzaria kudeatzen duen jendearekin.\n\n(%s) Ezin zerbitzarira konektatu.\n(%s) + baimena bertan behera utzi da + Baimenak huts egin du errore honekin: %s + OAuth 2.0 une honetan ez dago hornitzaile honekin onartuta. + Aplikazioak ezin izan du zure konturako sarbidea erabiltzeko arakatzailerik aurkitu. Editatu xehetasunak Jarraitu Aurreratua @@ -367,10 +393,15 @@ Erakutsi jakinarazpen bat bidaltzen ditudan mezuetarako Kontaktuak bakarrik Erakutsi kontaktu ezagunen mezuen jakinarazpenak bakarrik + Baztertu txateko mezuak + Ez erakutsi posta elektronikoko txat bateko mezuen jakinarazpenak Markatu irakurritako gisa irekitzean Markatu mezua irakurritako gisa ikusteko irekitzerakoan Markatu irakurritako gisa ezabatzean Markatu mezua irakurritako gisa ikusteko ezabatzean + Jakinarazpen kategoriak + Konfiguratu mezu berrien jakinarazpenak + Konfiguratu akatsen eta egoeraren jakinarazpenak Erakutsi beti Irudiak Ez Kontaktuetatik @@ -453,7 +484,7 @@ 1. eta 2. klaseko karpetak Guztiak 2. klaseko karpetak izan ezik Bat ere ez - Karpeten \'Push\'a + Karpeten \"Push\"a Guztiak 1. klaseko karpetak bakarrik 1. eta 2. klaseko karpetak @@ -483,6 +514,7 @@ Klaserik ez 1. klasea 2. klasea + Inkesta moduaren araberakoak Jakinarazpenen karpeta klasea Klaserik ez 1. klasea @@ -496,11 +528,29 @@ Kontu izena Zure izena Jakinarazpenak + Bibrazioa Bibratu + Bibrazio eredua Lehenetsia + 1 Eredua + 2 Eredua + 3 Eredua + 4 Eredua + 5 Eredua Errepikatu bibrazioa + Desgaitua Posta berrirako txirrin-tonua + Jakinarazpen argia + Desgaitua Kontuaren kolorea + Sistemaren kolore lehenetsia + Txuria + Gorria + Berdea + Urdina + Horia + Zian + Magenta Mezuak idazteko aukerak Idazterakoan lehenetsiak Ezarri Nork, Bcc eta sinadura lehenetsiak @@ -611,6 +661,7 @@ Mezu barruko ikuspegiak Zerrenda barruko ikuspegiak Erakutsi Sarrera Ontzi Bateratua + Erakutsi kontu izardunak Sarrerako ontzi bateratua Karpeta bateratuetako mezu guztiak Bateratu @@ -677,10 +728,10 @@ Zakarrontzia hustu nahi duzu? Bai Ez - Berretsi spam karpetara mugitzea + Berretsi zabor-posta karpetara mugitzea Benetan mezu hau spamera mugitu nahi duzula? - Benetan %1$d mezu spam karpetara mugitu nahi dituzula? + Benetan %1$d mezu zabor-posta karpetara mugitu nahi dituzula? Bai Ez @@ -702,13 +753,16 @@ Sartu pasahitzak + Mesedez saioa hasi + Mesedez saioa hasi eta sartu pasahitza Ezin izan dira ezarpenak inportatu Ezin izan da ezarpenen fitxategia irakurri Ezin izan dira ezarpen batzuk inportatu Ongi inportatuta Pasahitza derrigorrezkoa + Saio-haztea beharrezkoa Inportatu gabe Inportazio hutsegitea Geroago @@ -815,6 +869,7 @@ Bcc Nori Nork + Erantzun honi <Hartzaile ezezaguna> <Bidaltzaile ezezaguna> Etxekoa @@ -953,9 +1008,14 @@ Mezu hau gorde dezakezu eta zure gako sekretuaren babes-kopia gisa erabili. Hau Akats bat gertatu da datuak kargatzean Hasieratzen… e-posta berrien zain + Lo, atzeko planoko sinkronizazioa baimendu arte + Lo, sarea eskuragarri izan arte Sakatu informazio gehiago lortzeko + Push Informazioa Push erabiltzean, K-9 Mailek posta zerbitzariarekiko konexioa mantentzen du. Androidek jakinarazpen bat erakutsi behar du, aplikazioa atzeko planoan martxan dagoen bitartean. %s Androidek ere jakinarazpena ezkutatzen uzten dizu. Informazio gehiago Konfiguratu jakinarazpena + Mezu berriei buruzko berehalako jakinarazpenik behar ez baduzu, Push desgaitu eta Bozketa erabili. Inkestak posta berriak egiaztatzen ditu aldizka eta ez du jakinarazpenik behar. + Desgaitu \"Push\"a diff --git a/app/ui/legacy/src/main/res/values-fa/strings.xml b/app/ui/legacy/src/main/res/values-fa/strings.xml index 55453f3271731c87e19f678aa4cb4cab3ab28521..9a906f4c3085b5d7c74a9f9663738b960689898e 100644 --- a/app/ui/legacy/src/main/res/values-fa/strings.xml +++ b/app/ui/legacy/src/main/res/values-fa/strings.xml @@ -9,6 +9,13 @@ The K-9 Dog Walkers مجوز آپاچی، نسخهٔ ۲٫۰ + پروژهٔ متن‌باز + وبگاه + راهنمای کاربر + کمک گرفتن + انجمن کاربری + فِدیوِرس + توئیتر کتابخانه‌ها مجوز @@ -75,7 +82,8 @@ انتخاب فرستنده افزودن ستاره حذف ستاره - کپی به + کپی + لغو اشتراک نمایش سرایندها نشانی‌ها در بریده‌دان کپی شد @@ -111,7 +119,7 @@ %d نامهٔ جدید %d پیام جدید - %d نخوانده (%s) + %1$d نخوانده (%2$s) + %1$d تای دیگر در %2$s پاسخ خوانده شد @@ -127,8 +135,10 @@ احراز هویت ناموفق بود اعتبارسنجی %s ناموفق بود. تنظیمات کارساز خود را به‌روز کنید. + خطای اعلانها - به‌روزآوری رایانامه‌ها: %s:%s + هنگام ساخت هشدار برای پیام جدید سامانه خطایی رخ داد. اغلب خطا به علت عدم وجود آهنگ هشدارها است. \n\n برای بازکردن تنظیمات اعلانات، بزنید. + به‌روزآوری رایانامه‌ها: %1$s:%2$s به‌روزآوری رایانامه‌ها ارسال رایانامه: %s ارسال رایانامه @@ -175,7 +185,7 @@ حذف نقل‌قول ویرایش نقل‌قول حذف پیوست - از: %s <%s> + از: %1$s <%2$s> به: رونوشت به: مخفیانه به: @@ -242,7 +252,9 @@ گذرواژه جهت استفاده‌ این حساب کاربری از K-9 Mail، نیاز است که وارد حساب کاربری خود شوید و اجازه دسترسی اپلیکیشن به ایمیل‌ها را صادر کنید. + ورود + ورود با حساب گوگل برای دیدن گذرواژه‌تان در این قسمت، قفل صفحه در این دستگاه را فعال کنید. هویت‌تان را تایید کنید @@ -266,6 +278,7 @@ گذرواژه، به‌شکل ناامن ارسال شد گذرواژهٔ رمزنگاری‌شده گواهی کارخواه + OAuth 2.0 تنظیمات کارساز ورودی نام کاربری گذرواژه @@ -284,6 +297,7 @@ در کارساز پاک نشود در کارساز نیز پاک شود در کارساز نشان بزن که خواندم + استفاده از فشرده سازی پیام‌های حذف‌شده را در کارساز پاک کن فوراً در زمان سرکشی @@ -348,6 +362,10 @@ نام کاربری یا گذرواژه اشتباه است.\n(%s) کارساز یک گواهی SSL نامعتبر ارائه داده است. گاهی این مسئله به‌سبب پیکربندی نادرست کارساز است. گاهی اوقات علت آن است که فردی تلاش می‌کند به شما یا کارساز رایانامه حمله کند. اگر اطلاعات کافی ندارید، ردکردن را انتخاب کنید و با پشتیبانی رایانامهٔ خود تماس بگیرید.\n\n(%s) اتصال به کارساز ناموفق بود.\n(%s) + مجوزدهی لغو شد + مجوزدهی به علت این خطا ناتمام ماند: %s + OAuth 2.0 فعلا توسط این ارائه دهنده پشتیبانی نمی شود + برنامه نمی تواند مرورگری را پیدا کند تا دسترسی به حسابتان را بگیرد ویرایش جزئیات ادامه پیشرفته @@ -722,13 +740,16 @@ لطفاً گذرواژه‌ها را وارد کنید + لطفا وارد شوید + لطفا وارد شوید و رمز را وارد کنید درون‌برد تنظیمات ناموفق بود خواندن پروندهٔ تنظیمات ناموفق بود درون‌برد برخی تنظیمات ناموفق بود درون‌برد موفقیت‌آمیز بود گذرواژه لازم است + ورود نیاز است درون‌برد نشده شکست درون‌برد بعداً diff --git a/app/ui/legacy/src/main/res/values-fi/strings.xml b/app/ui/legacy/src/main/res/values-fi/strings.xml index 8b93a933ec66b73987a593c67ef84a718656c1f5..900ea5b2506d1488377ab51fc3501010ffba2f40 100644 --- a/app/ui/legacy/src/main/res/values-fi/strings.xml +++ b/app/ui/legacy/src/main/res/values-fi/strings.xml @@ -152,7 +152,7 @@ Ilmoita virheistä, ota osaa sovelluskehitykseen ja esitä kysymyksiä osoittees %d uusi viesti %d uutta viestiä - %d lukematonta (%s) + %1$d lukematonta (%2$s) + %1$d lisää tilillä %2$s Vastaa Merkitse luetuksi @@ -171,7 +171,7 @@ Ilmoita virheistä, ota osaa sovelluskehitykseen ja esitä kysymyksiä osoittees Ilmoitusvirhe Uuteen viestiin liittyvää järjestelmäilmoitusta luotaessa tapahtui virhe. Syy on mitä luultavimmin puuttuva ilmoitusääni.\n\nNapauta avataksesi ilmoitusasetukset. - Tarkistetaan viestejä: %s:%s + Tarkistetaan viestejä: %1$s:%2$s Tarkistetaan viestejä Lähetetään viestejä: %s Lähetetään viestejä @@ -218,7 +218,7 @@ Ilmoita virheistä, ota osaa sovelluskehitykseen ja esitä kysymyksiä osoittees Poista lainattu teksti Muokkaa lainattua tekstiä Poista liite - Lähettäjä: %s <%s> + Lähettäjä: %1$s <%2$s> Vast.ottaja: Kopio: Piilokopio: diff --git a/app/ui/legacy/src/main/res/values-fr/strings.xml b/app/ui/legacy/src/main/res/values-fr/strings.xml index 2e276d15a148c1e859af17935e23643bd3bc6bce..b3d85e0d444f5b5eb961b18c95286a5b3156b4ba 100644 --- a/app/ui/legacy/src/main/res/values-fr/strings.xml +++ b/app/ui/legacy/src/main/res/values-fr/strings.xml @@ -130,7 +130,7 @@ jusqu’à %d de plus %d nouveaux courriels %d nouveaux courriels - %d non lus (%s) + %1$d non lus (%2$s) + %1$d de plus sur %2$s Répondre Marquer comme lu @@ -149,7 +149,7 @@ jusqu’à %d de plus Erreur de notification Impossible de créer une notification système de nouveau message. Un son de notification manquant en est probablement la cause.\n\nToucher pour ouvrir les paramètres de notification. - Relève des courriels : %s:%s + Relève des courriels : %1$s:%2$s Relève des courriels Envoi du courriel : %s Envoi du courriel @@ -196,7 +196,7 @@ jusqu’à %d de plus Supprimer le texte entre guillemets Modifier le texte entre guillemets Supprimer le fichier joint - De : %s <%s> + De : %1$s <%2$s> À : Cc : Cci : diff --git a/app/ui/legacy/src/main/res/values-fy/strings.xml b/app/ui/legacy/src/main/res/values-fy/strings.xml index 3f42c9df7880e0adf95c6c0a807c74389358f2d5..b28849f4ec5d9172f2e352457ff68b89975f8c52 100644 --- a/app/ui/legacy/src/main/res/values-fy/strings.xml +++ b/app/ui/legacy/src/main/res/values-fy/strings.xml @@ -148,7 +148,7 @@ Graach flaterrapporten stjoere, bydragen foar nije funksjes en fragen stelle op %d nije berjocht %d nije berjochten - %d Net lêzen (%s) + %1$d Net lêzen (%2$s) + %1$d mear by %2$s Beäntwurdzje As lêzen markearje @@ -167,7 +167,7 @@ Graach flaterrapporten stjoere, bydragen foar nije funksjes en fragen stelle op Meldingsflater Der is in flater bard wylst it meitsje fan in systeemmelding foar in nij berjocht. De reden is wierskynlik in ûntbrekken fan in meldingslûd.\n\nTik om meldingsynstellingen te iepenjen. - Berjochten kontrolearje: %s:%s + Berjochten kontrolearje: %1$s:%2$s Berjochten kontrolearje Berjochten ferstjoere: %s Berjochten ferstjoere @@ -214,7 +214,7 @@ Graach flaterrapporten stjoere, bydragen foar nije funksjes en fragen stelle op Sitaattekst fuortsmite Sitaattekst bewurkje Bylage fuortsmite - Fan: %s <%s> + Fan: %1$s <%2$s> Oan: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-gd/strings.xml b/app/ui/legacy/src/main/res/values-gd/strings.xml index a862afd95f4e07a3c54ad905d29875e8d00edaa1..1b3c63b25dab1daa9104329c061900f34e0ec2b3 100644 --- a/app/ui/legacy/src/main/res/values-gd/strings.xml +++ b/app/ui/legacy/src/main/res/values-gd/strings.xml @@ -119,7 +119,7 @@ %d teachdaireachdan ùra %d teachdaireachd ùr - %d gun leughadh (%s) + %1$d gun leughadh (%2$s) %1$d a bharrachd air %2$s Freagair Comharraich gun deach a leughadh @@ -135,7 +135,7 @@ Dh’fhàillig dearbhadh a’ chunntais %s. Ùraich roghainnean an fhrithealaiche agad. - A’ toirt sùil airson post: %s:%s + A’ toirt sùil airson post: %1$s:%2$s A’ toirt sùil airson post A’ cur a’ phuist: %s A’ cur a’ phuist @@ -175,7 +175,7 @@ Thoir an às-earrann air falbh Deasaich an às-earrann Thoir an ceanglachan air falbh - O: %s <%s> + O: %1$s <%2$s> Gu: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-gl-rES/strings.xml b/app/ui/legacy/src/main/res/values-gl-rES/strings.xml index e42e3f0415d0caac25bd01ad5af28c9c9a35ef38..17cf504789792c8119824f024768275f3a7e89b0 100644 --- a/app/ui/legacy/src/main/res/values-gl-rES/strings.xml +++ b/app/ui/legacy/src/main/res/values-gl-rES/strings.xml @@ -77,7 +77,7 @@ Cargar %d máis Correo novo - %d sen ler (%s) + %1$d sen ler (%2$s) + %1$d máis en %2$s Responder Marcar como lida @@ -91,7 +91,7 @@ Comproba a configuración do servidor - Comprobando correo: %s:%s + Comprobando correo: %1$s:%2$s Comprobando correo Enviando correo: %s Enviando correo @@ -126,7 +126,7 @@ Algúns anexos non poden reenviarse porque non foron descargados previamente. Eliminar texto citado Editar texto citado - Desde: %s <%s> + Desde: %1$s <%2$s> Para: Cc: Mostrar imaxes diff --git a/app/ui/legacy/src/main/res/values-gl/strings.xml b/app/ui/legacy/src/main/res/values-gl/strings.xml index 7286ada9af942833eb26c91de388bd4dc485063e..e0c24ed3efca965b3017212f508badafc5841bdc 100644 --- a/app/ui/legacy/src/main/res/values-gl/strings.xml +++ b/app/ui/legacy/src/main/res/values-gl/strings.xml @@ -98,7 +98,7 @@ %d nova mensaxe %d novas mensaxes - %d Sin Ler (%s) + %1$d Sin Ler (%2$s) + %1$d máis en %2$s Responder Marcar Lido @@ -115,7 +115,7 @@ Fallou a autenticación para %s. Actualice os axustes do servidor. - Comprobando correo: %s:%s + Comprobando correo: %1$s:%2$s Comprobando correo Enviando correo: %s Enviando correo @@ -162,7 +162,7 @@ Eliminar texto citado Editar texto citado Eliminar anexo - Dende: %s <%s> + Dende: %1$s <%2$s> Para: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-hr/strings.xml b/app/ui/legacy/src/main/res/values-hr/strings.xml index 63a54d043d5e968d4cafbdcdb62f4b8980e47200..6b067ea6acafc18fa6d165ec6870577f47646a37 100644 --- a/app/ui/legacy/src/main/res/values-hr/strings.xml +++ b/app/ui/legacy/src/main/res/values-hr/strings.xml @@ -109,7 +109,7 @@ %d novih poruka %d novih poruka - %d Nepročitano (%s) + %1$d Nepročitano (%2$s) + %1$d više na %2$s Odgovori Označi Pročitanim @@ -125,7 +125,7 @@ Identifikacija nije uspjela za %s. Ažurirajte vaše postavke servera. - Provjeravam poštu: %s:%s + Provjeravam poštu: %1$s:%2$s Provjeravanje pošte Šaljem poštu: %s Slanje pošte @@ -167,7 +167,7 @@ Maknuti citirani tekst Urediti citirani tekst Ukloni privitak - Od: %s <%s> + Od: %1$s <%2$s> Za: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-hu/strings.xml b/app/ui/legacy/src/main/res/values-hu/strings.xml index 870ecc59bdfb2c266f65f00f040d31085ad0479e..ff9c7eeac4f536f22577e469bdead3d383d6bdf3 100644 --- a/app/ui/legacy/src/main/res/values-hu/strings.xml +++ b/app/ui/legacy/src/main/res/values-hu/strings.xml @@ -12,6 +12,8 @@ Apache licenc, 2.0-s verzió Nyílt forráskódú Projekt Weboldal + Felhasználói kézikönyv + Segítségkérés Felhasználói fórum Födiverzum Twitter @@ -78,6 +80,7 @@ Keresés Keresés mindenhol Keresési eredmények + Új üzenetek Beállítások Mappák kezelése Fiók beállításai @@ -88,6 +91,7 @@ Csillag hozzáadása Csillag eltávolítása Másolás + Leiratkozás Fejlécek megjelenítése Cím a vágólapra másolva @@ -122,7 +126,7 @@ %d új üzenet %d új üzenet - %d Olvasatlan (%s) + %1$d Olvasatlan (%2$s) + %1$d további: %2$s Válasz Megjelölés olvasottként @@ -132,13 +136,16 @@ Archiválás Összes archiválása Levélszemét + Tanúsítványhiba Tanúsítványhiba: %s Ellenőrizzük a kiszolgáló beállításait. A hitelesítés sikertelen. A hitelesítés sikertelen: %s. Frissítse a kiszolgálóbeállításokat. + Értesítési hiba - Levelek ellenőrzése: %s:%s + Hiba történt egy új üzenethez tartozó rendszerértesítés létrehozásának kísérletekor. Az ok valószínűleg egy hiányzó értesítési hang.\n\nKoppintson az értesítési beállítások megnyitásához. + Levelek ellenőrzése: %1$s:%2$s Levelek ellenőrzése Levél küldése: %s Levél küldése @@ -160,7 +167,10 @@ További diagnosztikai információk naplózása Érzékeny információk naplózása A jelszavak láthatóak lehetnek a naplókban. - Többi üzenetek betöltése + Naplók exportálása + Az exportálás sikerült. A naplók érzékeny információkat tartalmazhatnak. Legyen óvatos, hogy kinek küldi el azokat. + Az exportálás sikertelen. + További üzenetek betöltése Címzett:%s Tárgy Üzenet szövege @@ -182,7 +192,7 @@ Idézett szöveg eltávolítása Idézett szöveg szerkesztése Melléklet eltávolítása - Feladó: %s <%s> + Feladó: %1$s <%2$s> Címzett: Másolat: Titkos másolat: @@ -207,6 +217,7 @@ Címzettek neveinek használata a Címjegyzékből, amikor elérhető Partnerek színezése Nevek színezése a partnerlistában + Partner nevének színe Rögzített szélességű betűk Rögzített szélességű betű használata az egyszerű szöveges üzeneteknél Üzenetek automatikus méretezése @@ -246,8 +257,11 @@ Új fiók beállítása Email cím Jelszó + Ezen e-mail-fiók K-9 Maillel történő használatához be kell jelentkeznie, és hozzáférést kell adnia az alkalmazásnak az e-mailjeihez. + Bejelentkezés + Bejelentkezés Google használatával Ha itt szeretné megtekinteni jelszavát, engedélyezze a képernyőzárat ezen az eszközön. Személyazonosság ellenőrzése @@ -271,6 +285,7 @@ Nem biztonságosan átküldött jelszó Titkosított jelszó Ügyféltanúsítvány + OAuth 2.0 Bejövő kiszolgáló beállításai Felhasználónév Jelszó @@ -289,6 +304,7 @@ Nincs törlés a kiszolgálóról Törlés a kiszolgálóról Olvasottnak jelölés a kiszolgálón + Tömörítés használata Törölt üzenetek végleges törlése a kiszolgálóról Azonnal Lekérdezéskor @@ -353,6 +369,10 @@ A felhasználónév vagy a jelszó hibás.\n(%s) A kiszolgáló érvénytelen SSL tanúsítványt mutatott be. Néha ez a kiszolgáló hibás beállításából ered. Néha azért, mert valaki személyes vagy a levelező kiszolgálót érintő támadást hajtott végre. Ha nem vagyunk biztos abban, hogy mi történt, akkor kattintsunk a Visszautasít gombra, és vegyük fel a kapcsolatot a levelező kiszolgáló üzemeltetőivel.\n\n(%s) Nem lehet kapcsolódni a kiszolgálóhoz.\n(%s) + A felhatalmazás megszakítva + A felhatalmazás meghiúsult a következő hibával: %s + Az OAuth 2.0 jelenleg nem támogatott ezzel a szolgáltatóval. + Az alkalmazás nem talált böngészőt a fiókhoz történő hozzáférés megadásához való használathoz. Részletek szerkesztése Tovább Bővített @@ -504,17 +524,29 @@ Fióknév Saját név Értesítések + Rezgés Rezgés - Rezgés mintái - alapértelmezett + Rezgés mintája + Alapértelmezett 1. minta 2. minta 3. minta 4. minta 5. minta Rezgés ismétlése + Letiltva Új levél csengőhangja + Értesítési fény + Letiltva Fiókszín + Rendszer alapértelmezett színe + Fehér + Piros + Zöld + Kék + Sárga + Ciánkék + Bíbor Üzenet írásának beállításai Írási alapbeállítások Alapértelmezett feladó, titkos másolat és aláírás beállítása @@ -716,13 +748,16 @@ Adja meg a jelszavakat + Jelentkezzen be + Jelentkezzen be, és adja meg a jelszavakat Nem sikerült importálni a beállításokat Nem sikerült beolvasni a beállítások fájlt Nem sikerült importálni néhány beállítást Sikeresen importálva Jelszó szükséges + Bejelentkezés szükséges Nincs importálva Importálási hiba Később diff --git a/app/ui/legacy/src/main/res/values-in/strings.xml b/app/ui/legacy/src/main/res/values-in/strings.xml index d46ce5ba414bdaf488f8844cd0602c0260a3484b..0d5fbfda9becd8699268a0609295c12ea7ec0cf9 100644 --- a/app/ui/legacy/src/main/res/values-in/strings.xml +++ b/app/ui/legacy/src/main/res/values-in/strings.xml @@ -116,7 +116,7 @@ %d pesan baru - %d Belum dibaca (%s) + %1$d Belum dibaca (%2$s) + %1$d lagi pada %2$s Balas Tandai Sudah Dibaca @@ -132,7 +132,7 @@ Otentikasi gagal untuk %s. Perbarui pengaturan server Anda. - Memeriksa pesan: %s:%s + Memeriksa pesan: %1$s:%2$s Memeriksa pesan Mengirimkan pesan: %s Mengirimkan pesan @@ -174,7 +174,7 @@ Buang teks kutipan Sunting teks kutipan Buang lampiran - Dari: %s <%s> + Dari: %1$s <%2$s> Kepada: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-is/strings.xml b/app/ui/legacy/src/main/res/values-is/strings.xml index e6310b5e397bf396b100328affad4af0d30d0b0a..ce666398ebe31f7482f67717bc8f73cdfe26d4ad 100644 --- a/app/ui/legacy/src/main/res/values-is/strings.xml +++ b/app/ui/legacy/src/main/res/values-is/strings.xml @@ -125,7 +125,7 @@ %d ný skilaboð %d ný skilaboð - %d ólesin (%s) + %1$d ólesin (%2$s) + %1$d fleiri á %2$s Svara Merkja sem lesið @@ -141,7 +141,7 @@ Auðkenning mistókst fyrir %s. Uppfærðu stillingar póstþjónsins þíns. - Athuga með póst: %s:%s + Athuga með póst: %1$s:%2$s Athuga með póst Sendi póst: %s Sendi póst @@ -185,7 +185,7 @@ Fjarlægja tilvitnun Breyta tilvitnun Fjarlægja viðhengi - Frá: %s <%s> + Frá: %1$s <%2$s> Til: Afrit: Falið afrit: diff --git a/app/ui/legacy/src/main/res/values-it/strings.xml b/app/ui/legacy/src/main/res/values-it/strings.xml index 4d9cca2ef87f8ba7e75b87b5190f910168a55165..76f43cd5ca93e5dd3951689e2690465fadaac3a9 100644 --- a/app/ui/legacy/src/main/res/values-it/strings.xml +++ b/app/ui/legacy/src/main/res/values-it/strings.xml @@ -1017,7 +1017,6 @@ Invia segnalazioni di bug, contribuisci con nuove funzionalità e poni domande s Mostra codice di configurazione Messaggio di configurazione di Autocrypt Questo messaggio contiene tutte le informazioni per trasferire in modo sicuro le tue impostazioni di Autocrypt insieme alla tua chiave segreta dal tuo dispositivo originale. - Per configurare il tuo nuovo dispositivo per Autocrypt, segui le istruzioni che dovrebbero essere presentate dal nuovo dispositivo. Puoi conservare questo messaggio e utilizza come una copia di sicurezza della tua chiave segreta. Se intendi farlo, dovresti annotare la password e conservarla in modo sicuro. diff --git a/app/ui/legacy/src/main/res/values-iw/strings.xml b/app/ui/legacy/src/main/res/values-iw/strings.xml index 2032d717e1925092c4c4ec5bbf0d6b2b7acc1a8a..3536d10463b83727576134a5abc7822661c5b388 100644 --- a/app/ui/legacy/src/main/res/values-iw/strings.xml +++ b/app/ui/legacy/src/main/res/values-iw/strings.xml @@ -118,7 +118,7 @@ %d הודעות חדשות %d הודעות חדשות - %d לא נקראו (%s) + %1$d לא נקראו (%2$s) + %1$d עוד בקבוצה %2$s השב סמן כנקרא @@ -136,7 +136,7 @@ עדכן את הגדרות השרת. - בודק דוא"ל: %s:%s + בודק דואל: %1$s:%2$s בודק דוא\"ל שולח דוא"ל: %s שולח דוא\"ל @@ -179,7 +179,7 @@ מחק טקסט מצוטט ערוך טקסט מצוטט מחק קובץ מצורף - מ: %s <%s> + מ: %1$s <%2$s> ל: עותק: הצג תמונות diff --git a/app/ui/legacy/src/main/res/values-ja/strings.xml b/app/ui/legacy/src/main/res/values-ja/strings.xml index ee1637df432905448d1153ec592af8f6d9a2e2a2..76a90311002b449b22a23786fab127e4493e3525 100644 --- a/app/ui/legacy/src/main/res/values-ja/strings.xml +++ b/app/ui/legacy/src/main/res/values-ja/strings.xml @@ -126,7 +126,7 @@ %d 件の新着メッセージ - %d 未読 (%s) + %1$d 未読 (%2$s) + %1$d 続く %2$s 返信 既読 @@ -145,7 +145,7 @@ 通知エラー 新着メッセージのためのシステム通知の作成中にエラーが発生しました。通知サウンドが不足していることが原因と思われます。\n\nタップして通知設定を開いてください。 - メール確認中: %s:%s + メール確認中: %1$s:%2$s メール確認中 メール送信中: %s メール送信中 @@ -192,7 +192,7 @@ 引用文を削除します 引用文を編集します 添付ファイルの削除 - 送信者: %s <%s> + 送信者: %1$s <%2$s> 宛先: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-ko/strings.xml b/app/ui/legacy/src/main/res/values-ko/strings.xml index 80e2a1e45c3ce0b6023d8e08a926ec2d7578e31a..582512ad63d3c60ada5b05f6a0d28e7af95045d5 100644 --- a/app/ui/legacy/src/main/res/values-ko/strings.xml +++ b/app/ui/legacy/src/main/res/values-ko/strings.xml @@ -101,7 +101,7 @@ %d 새 메세지 - %d 통의 읽지 않은 메일 (%s) + %1$d 통의 읽지 않은 메일 (%2$s) + %1$d more on %2$s 답장 읽음 @@ -117,7 +117,7 @@ %s 계정 인증 오류. 서버 설정을 업데이트하세요. - 메일 체크 중: %s:%s + 메일 체크 중: %1$s:%2$s 메일 체크 중 메일 전송 중: %s 메일 전송 중 @@ -155,7 +155,7 @@ 인용 메세지 포함 인용된 메시지 제거 인용된 메시지 편집 - 발신자: %s <%s> + 발신자: %1$s <%2$s> 수신자: 참조: Bcc: diff --git a/app/ui/legacy/src/main/res/values-lt/strings.xml b/app/ui/legacy/src/main/res/values-lt/strings.xml index 6f129f14658e1e07b482f9866041123af400f09b..4d2da79b8dffdef132154990809d42caa6840c26 100644 --- a/app/ui/legacy/src/main/res/values-lt/strings.xml +++ b/app/ui/legacy/src/main/res/values-lt/strings.xml @@ -126,7 +126,7 @@ %d nauji laiškai %d nauji laiškai - %d Neskaitytų (%s) + %1$d Neskaitytų (%2$s) + %1$d daugiau ant %2$s Atsakyti Pažymėti kaip skaitytą @@ -143,7 +143,7 @@ Nepavyko nustatyti autentiškumo %s. Atnaujinkite serverio nustatymus. - Tikrinamas paštas: %s:%s + Tikrinamas paštas: %1$s:%2$s Tikrinamas paštas Siunčiamas paštas: %s Siunčiamas paštas @@ -191,7 +191,7 @@ Pašalinti cituojamą tekstą Redaguoti cituojamą tekstą Pašalinti priedą - Nuo: %s <%s> + Nuo: %1$s <%2$s> Kam: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-lv/strings.xml b/app/ui/legacy/src/main/res/values-lv/strings.xml index 17343c71c167bec6f13e25e743a5279b49c7fcda..bf06a5d8a905a422e6550a1eda672a4c667c0c07 100644 --- a/app/ui/legacy/src/main/res/values-lv/strings.xml +++ b/app/ui/legacy/src/main/res/values-lv/strings.xml @@ -127,7 +127,7 @@ pat %d vairāk %d jauna vēstule %d jaunas vēstules - %d Nelasītas (%s) + %1$d Nelasītas (%2$s) + %1$d vairāk %2$s Atbildēt Atzīmēt kā izlasītu @@ -143,7 +143,7 @@ pat %d vairāk Identifikācijas pārbaude kontam %s neizdevās. Atjaunojiet servera iestatījumus! - Pārbauda pastu: %s:%s + Pārbauda pastu: %1$s:%2$s Pārbauda pastu Sūta pastu: %s Sūta pastu @@ -190,7 +190,7 @@ pat %d vairāk Noņemt citēto tekstu Redigēt citēto tekstu Noņemt pielikumu - No: %s <%s> + No: %1$s <%2$s> Kam: Cc: Neredzamais: diff --git a/app/ui/legacy/src/main/res/values-ml/strings.xml b/app/ui/legacy/src/main/res/values-ml/strings.xml index fc8c070af06a64782aede1188b852119a0e1cba0..22e7e1b7a673ea5b68508a58a2dfff671b537d5e 100644 --- a/app/ui/legacy/src/main/res/values-ml/strings.xml +++ b/app/ui/legacy/src/main/res/values-ml/strings.xml @@ -125,7 +125,7 @@ പുതിയ %d സന്ദേശം പുതിയ %d സന്ദേശങ്ങൾ - %d വായിക്കാത്തത് (%s) + %1$d വായിക്കാത്തത് (%2$s) +%1$d %2$s ൽ കൂടുതൽ മറുപടി പറയുക വായന അടയാളപ്പെടുത്തുക @@ -142,7 +142,7 @@ %s നായുള്ള പ്രാമാണീകരണം പരാജയപ്പെട്ടു. നിങ്ങളുടെ സെർവർ ക്രമീകരണങ്ങൾ പുതുക്കുക. - മെയിൽ പരിശോധിക്കുന്നു: %s:%s + മെയിൽ പരിശോധിക്കുന്നു: %1$s:%2$s മെയിൽ പരിശോധിക്കുന്നു മെയിൽ അയയ്ക്കുന്നു: %s മെയിൽ അയയ്ക്കുന്നു @@ -189,7 +189,7 @@ ഉദ്ധരിച്ച വാചകം നീക്കംചെയ്യുക ഉദ്ധരിച്ച വാചകം എഡിറ്റുചെയ്യുക അറ്റാച്ചുമെന്റ് നീക്കംചെയ്യുക - പ്രേഷിതാവ്: %s <%s> + പ്രേഷിതാവ്: %1$s <%2$s> സ്വീകർത്താവ്: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-nb/strings.xml b/app/ui/legacy/src/main/res/values-nb/strings.xml index 63ee87589a0fc10d5cc0470b882871eb0cb4c429..af7c004a0cd492570b999c53c9318efd25ab2935 100644 --- a/app/ui/legacy/src/main/res/values-nb/strings.xml +++ b/app/ui/legacy/src/main/res/values-nb/strings.xml @@ -116,7 +116,7 @@ til %d flere %d ny melding %d nye meldinger - %d Ulest(e) (%s) + %1$d Ulest(e) (%2$s) + %1$d flere på %2$s Svar Merk som lest @@ -132,7 +132,7 @@ til %d flere Autentisering feilet for %s. Oppdater serverinnstillingene dine. - Sjekker e-post: %s:%s + Sjekker e-post: %1$s:%2$s Sjekker e-post Sender e-post: %s Sender e-post @@ -174,7 +174,7 @@ til %d flere Fjern sitert tekst Rediger sitert tekst Fjern vedlegg - Fra: %s <%s> + Fra: %1$s <%2$s> Til: Kopi: Blindkopi: diff --git a/app/ui/legacy/src/main/res/values-nl/strings.xml b/app/ui/legacy/src/main/res/values-nl/strings.xml index 1490e23ba7f58d9258c64ba7f2deb1afd88eb355..d074866944a696bacecf51dcc3b7dab9e1f9f833 100644 --- a/app/ui/legacy/src/main/res/values-nl/strings.xml +++ b/app/ui/legacy/src/main/res/values-nl/strings.xml @@ -955,7 +955,6 @@ Setup Code weergeven Autocrypt Setup bericht Dit bericht bevat alle informatie om jouw Autocrypt instellingen met geheime sleutel beveiligd over te brengen vanaf jouw oorspronkelijke apparaat. - Volg de instructies op uw nieuwe apparaat om daarop Autocrypt in te stellen. U kunt dit bericht bewaren als backup voor uw geheime sleutel. Als u dit wilt doen, schrijf dan het wachtwoord op en bewaar het op een veilige plek. diff --git a/app/ui/legacy/src/main/res/values-pl/strings.xml b/app/ui/legacy/src/main/res/values-pl/strings.xml index c5b0db993269d4e4df80fea431c4f9d418551331..c64e8e560345ae0f67d3c3840cdcff304d8c5329 100644 --- a/app/ui/legacy/src/main/res/values-pl/strings.xml +++ b/app/ui/legacy/src/main/res/values-pl/strings.xml @@ -128,7 +128,7 @@ %d nowych wiadomości %d nowych wiadomości - Nowe: %d (%s) + Nowe: %1$d (%2$s) + %1$d więcej na %2$s Odpowiedz Oznacz jako przeczytane @@ -147,7 +147,7 @@ Błąd powiadomienia Wystąpił błąd podczas próby utworzenia powiadomienia systemowego dla nowej wiadomości. Powodem jest najprawdopodobniej brak dźwięku powiadomienia.\n\nStuknij, aby otworzyć ustawienia powiadomień. - Sprawdzam: %s:%s + Sprawdzam: %1$s:%2$s Sprawdzam Wysyłam: %s Wysyłam @@ -194,7 +194,7 @@ Usuń cytowany tekst Edytuj cytowany tekst Usuń załącznik - Od: %s <%s> + Od: %1$s <%2$s> Do: DW: UDW: diff --git a/app/ui/legacy/src/main/res/values-pt-rBR/strings.xml b/app/ui/legacy/src/main/res/values-pt-rBR/strings.xml index 95125fd05dcbdf67d48f7a665907e7c4a25b4cea..37d56b6febe6f5fb4b47e6b47e281e831c896c43 100644 --- a/app/ui/legacy/src/main/res/values-pt-rBR/strings.xml +++ b/app/ui/legacy/src/main/res/values-pt-rBR/strings.xml @@ -130,7 +130,7 @@ %d novas mensagens %d novas mensagens - %d não lidas (%s) + %1$d não lidas (%2$s) + %1$d a mais em %2$s Responder Marcar como lida @@ -149,8 +149,8 @@ Erro de notificação Ocorreu um erro ao tentar criar uma notificação de sistema para uma nova mensagem. O provável motivo é a ausência de um som de notificação.\n\nToque para abrir as configurações de notificação. - Verificando email: %s:%s - Verificando email + Verificando e-mail: %1$s:%2$s + Verificando e-mail Enviando mensagem: %s Enviando mensagem : @@ -196,7 +196,7 @@ Remover o texto citado Editar o texto citado Remover anexo - De: %s <%s> + De: %1$s <%2$s> Para: Cc: Cco: @@ -237,8 +237,8 @@ Descartar mensagem Marcar todas as mensagens como lidas Excluir (na notificação) - Ocultar o cliente de email - Remove Mail User-Agent from mail headers + Ocultar o cliente de e-mail + Remove o User-Agent do Mail dos cabeçalhos das mensagens Ocultar o fuso horário Usar UTC ao invés do fuso horário local nos cabeçalhos das mensagens e das respostas Exibir o botão \'Excluir\' @@ -866,9 +866,9 @@ Usar certificado do cliente Nenhum certificado do cliente Remover seleção do certificado do cliente - Não foi possível recuperar o certificado do cliente para o apelido \"%s\" + Não foi possível obter o certificado do cliente para o apelido \"%s\" Opções avançadas - Certificado do cliente \"%1$s\" expirou ou não é mais válido (%2$s) + O certificado do cliente \"%1$s\" expirou ou ainda não é válido (%2$s) *Criptografado* Adicionar dos contatos diff --git a/app/ui/legacy/src/main/res/values-pt-rPT/strings.xml b/app/ui/legacy/src/main/res/values-pt-rPT/strings.xml index 578aaf4de83834b2b97d27de8b9385910e242ace..861f2ca74c5d661c5d4538b8550dacd919f8fede 100644 --- a/app/ui/legacy/src/main/res/values-pt-rPT/strings.xml +++ b/app/ui/legacy/src/main/res/values-pt-rPT/strings.xml @@ -110,7 +110,7 @@ %d novas mensagens %d novas mensagens - %d não lidos (%s) + %1$d não lidos (%2$s) + %1$d em %2$s Responder Marcar como lido @@ -126,7 +126,7 @@ A autenticação falhou para %s. Atualize as configurações do servidor. - A verificar correio: %s:%s + A verificar correio: %1$s:%2$s A verificar correio A enviar correio: %s A enviar correio @@ -168,7 +168,7 @@ Remover texto citado Editar texto citado Remover anexo - De: %s <%s> + De: %1$s <%2$s> Para: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-ro/strings.xml b/app/ui/legacy/src/main/res/values-ro/strings.xml index 77c18a5565e6206b429d71c8267da569399255eb..42f3dc9e78448b1bff0ee8d7daa53aa5f20a369b 100644 --- a/app/ui/legacy/src/main/res/values-ro/strings.xml +++ b/app/ui/legacy/src/main/res/values-ro/strings.xml @@ -128,7 +128,7 @@ cel mult încă %d %d mesaje noi %d mesaje noi - %d necitite (%s) + %1$d necitite (%2$s) + %1$d mai multe %2$s Răspunde Marchează ca citit @@ -145,7 +145,7 @@ cel mult încă %d Authentificare eșuată pentru %s. Actualizează setările serverului. - Se verifică emailul: %s:%s + Se verifică emailul: %1$s:%2$s Se verifică emailul Se trimite emailul: %s Se trimite emailul @@ -192,7 +192,7 @@ cel mult încă %d Elimină textul citat Editează textul citat Elimină atașamentul - De la: %s <%s> + De la: %1$s <%2$s> La: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-ru/strings.xml b/app/ui/legacy/src/main/res/values-ru/strings.xml index 094cbb30fb0a147df2901e5c80b6f3ac75bc1f54..db665000d032e681e4e7ec6ac2695cfcba5f96f1 100644 --- a/app/ui/legacy/src/main/res/values-ru/strings.xml +++ b/app/ui/legacy/src/main/res/values-ru/strings.xml @@ -154,7 +154,7 @@ Mail — почтовый клиент для Android. %d новых сообщений %d новых сообщений - %d новых (%s) + %1$d новых (%2$s) + ещё %1$d в %2$s Ответить Прочитано @@ -171,7 +171,7 @@ Mail — почтовый клиент для Android. Сбой аутентификации для %s. Измените настройки сервера - Проверка %s:%s + Проверка %1$s:%2$s Проверка почты Отправка %s Отправка почты @@ -218,7 +218,7 @@ Mail — почтовый клиент для Android. Удалить цитату Правка цитаты Удалить вложение - От: %s <%s> + От: %1$s <%2$s> Кому: Копия: Скрытая: diff --git a/app/ui/legacy/src/main/res/values-sk/strings.xml b/app/ui/legacy/src/main/res/values-sk/strings.xml index 22682ea14110d687c1ee0375ffdc0efe44d952f9..9e42ea5cc13d93b087d5eb2efef65e7942c94e8d 100644 --- a/app/ui/legacy/src/main/res/values-sk/strings.xml +++ b/app/ui/legacy/src/main/res/values-sk/strings.xml @@ -121,7 +121,7 @@ %d nových správ %d nových správ - Počet neprečítaných správ: %d v %s + Počet neprečítaných správ: %1$d v %2$s + %1$d ďalších v %2$s Odpovedať Označiť ako prečítané @@ -137,7 +137,7 @@ Overenie pre %s zlyhalo. Aktualizujte nastavenia servera. - Kontrolovanie pošty: %s:%s + Kontrolovanie pošty: %1$s:%2$s Kontrolovanie pošty Odosielanie pošty: %s Odosielanie pošty @@ -179,7 +179,7 @@ Odstrániť citovaný text Upraviť citovaný text Odstrániť prílohu - Od: %s <%s> + Od: %1$s <%2$s> Komu: Kópia: Skrytá kópia (Bcc): diff --git a/app/ui/legacy/src/main/res/values-sl/strings.xml b/app/ui/legacy/src/main/res/values-sl/strings.xml index 45df127258d0b2d42679c1e5c33190241fbead1f..f9a0be9b99abbe06d5fb55f8f4503cd638957585 100644 --- a/app/ui/legacy/src/main/res/values-sl/strings.xml +++ b/app/ui/legacy/src/main/res/values-sl/strings.xml @@ -133,7 +133,7 @@ dodatnih %d sporočil %d nova sporočila %d novih sporočil - Neprebrano: %d (%s) + Neprebrano: %1$d (%2$s) in %1$d na %2$s Odgovori Označi kot prebrano @@ -152,7 +152,7 @@ dodatnih %d sporočil Napaka obveščanja Med poskusom ustvarjanja sistemskega obvestila za novo sporočilo je prišlo do napake. Razlog je najverjetneje manjkajoči zvok obvestila.\n\nTapni, za odpreti nastavitve obvestil. - Preverjanje pošte: %s:%s + Preverjanje pošte: %1$s:%2$s Preverjanje pošte Pošiljanje pošte: %s Pošiljanje pošte @@ -199,7 +199,7 @@ dodatnih %d sporočil Odstrani izvirno besedilo Uredi izvirno besedilo Odstrani prilogo - Od: %s <%s> + Od: %1$s <%2$s> Za: Kp: Skp: diff --git a/app/ui/legacy/src/main/res/values-sq/strings.xml b/app/ui/legacy/src/main/res/values-sq/strings.xml index fc3e742de97ef53b6f975977d79178fb7765d9d1..6cddae61d2517417cb24b97c210a0069f981d8d9 100644 --- a/app/ui/legacy/src/main/res/values-sq/strings.xml +++ b/app/ui/legacy/src/main/res/values-sq/strings.xml @@ -129,7 +129,7 @@ %d mesazh i ri %d mesazhe të rinj - %d Të palexuar te (%s) + %1$d Të palexuar te (%2$s) + %1$d më tepër te %2$s Përgjigjuni Shënoje Si të Lexuar @@ -148,7 +148,7 @@ Gabim njoftimi Ndodhi një gabim teksa provohej të krijohej një njoftim sistemi për një mesazh të ri. Arsyeja ka shumë gjasa të jetë mungesa e një tingulli njoftimesh.\n\nPrekeni që të hapen rregullimet për njoftimet. - Po merret postë: %s:%s + Po merret postë: %1$s:%2$s Marrje poste Po dërgohet postë: %s Dërgim poste @@ -195,7 +195,7 @@ Hiqe tekstin e cituar Përpunoni tekstin e cituar Hiqe bashkëngjitjen - Nga: %s <%s> + Nga: %1$s <%2$s> Për: Cc: Bcc: diff --git a/app/ui/legacy/src/main/res/values-sr/strings.xml b/app/ui/legacy/src/main/res/values-sr/strings.xml index bd9b52dd6444eff4ceea2aa7bed307d9b1f08159..cec3fa94dbb9574187157ce1a19cd3480a529e31 100644 --- a/app/ui/legacy/src/main/res/values-sr/strings.xml +++ b/app/ui/legacy/src/main/res/values-sr/strings.xml @@ -120,7 +120,7 @@ %d нове поруке %d нових порука - %d непрочитаних (%s) + %1$d непрочитаних (%2$s) + %1$d још на %2$s Одговори Означи прочитаним @@ -136,7 +136,7 @@ Аутентификација није успела за %s. Ажурирајте поставке сервера. - Проверавам пошту: %s:%s + Проверавам пошту: %1$s:%2$s Проверавам пошту Шаљем пошту: %s Шаљем пошту @@ -178,7 +178,7 @@ Уклони цитирани текст Уреди цитирани текст Уклони прилог - Од: %s <%s> + Од: %1$s <%2$s> За: Коп: СКоп: diff --git a/app/ui/legacy/src/main/res/values-sv/strings.xml b/app/ui/legacy/src/main/res/values-sv/strings.xml index ecdf2d61787bafccd9efea9e2314c65b09d82727..01e4bd3932b9eb998db3fb5e1e93f10ce146d745 100644 --- a/app/ui/legacy/src/main/res/values-sv/strings.xml +++ b/app/ui/legacy/src/main/res/values-sv/strings.xml @@ -129,7 +129,7 @@ %d nytt meddelande %d nya meddelanden - %d olästa (%s) + %1$d olästa (%2$s) + %1$d fler på %2$s Svara Markera som läst @@ -148,7 +148,7 @@ Aviseringsfel Ett fel uppstod vid försök att skapa en systemavisering för ett nytt meddelande. Anledningen är troligen att ett aviseringsljud saknas.\n\nTryck för att öppna aviseringsinställningarna. - Kontrollerar e-post: %s:%s + Kontrollerar e-post: %1$s:%2$s Kontrollerar e-post Skickar e-post: %s Skickar e-post @@ -195,7 +195,7 @@ Ta bort citerad text Redigera citerad text Ta bort bilaga - Från: %s <%s> + Från: %1$s <%2$s> Till: Kopia: Blindkopia: diff --git a/app/ui/legacy/src/main/res/values-tr/strings.xml b/app/ui/legacy/src/main/res/values-tr/strings.xml index c89903ca2cd4f22512f4d9148f090472f7b85f1e..80a23753ed06a538b430928dda54ebd6776b30ad 100644 --- a/app/ui/legacy/src/main/res/values-tr/strings.xml +++ b/app/ui/legacy/src/main/res/values-tr/strings.xml @@ -124,7 +124,7 @@ %d yeni mesaj %d yeni ileti - %d Okunmadı (%s) + %1$d Okunmadı (%2$s) %1$d daha fazla %2$s Yanıtla Okundu olarak işaretle @@ -141,7 +141,7 @@ %s için oturum açılamadı. Sunucu ayarlarınızı güncelleyin. - Posta kontrol ediliyor: %s:%s + Posta kontrol ediliyor: %1$s:%2$s Posta kontrol ediliyor Posta gönderiliyor: %s Posta gönderiliyor @@ -188,7 +188,7 @@ Alıntı yapılan metni sil Alıntı yapılan metni düzenle Eki kaldır - Kimden: %s <%s> + Kimden: %1$s <%2$s> Kime: Bilgi: Gizli Kopya: diff --git a/app/ui/legacy/src/main/res/values-uk/strings.xml b/app/ui/legacy/src/main/res/values-uk/strings.xml index 71f52c6aea5d1e0599075f2e2e07a6b1eff82663..fc684c2f2e5db4510f3ae28ef45a5993b641fbb0 100644 --- a/app/ui/legacy/src/main/res/values-uk/strings.xml +++ b/app/ui/legacy/src/main/res/values-uk/strings.xml @@ -12,6 +12,8 @@ Ліцензія Apache версії 2.0 Проект з відкритим кодом Сайт + Інструкція користувача + Отримати допомогу Форум Соцмережі Twitter @@ -91,6 +93,7 @@ Додати зірочку Прибрати зірочку Копіювати + Відписатися Показати заголовки Адресу скопійовано до комірки обміну @@ -130,7 +133,7 @@ %d нових повідомлень %d нових повідомлень - %d непрочитане(-их) (%s) + %1$d непрочитане(-их) (%2$s) ще %1$d у %2$s Відповісти Позначити прочитаним @@ -146,8 +149,10 @@ Помилка автентифікації Помилка автентифікації для %s. Змініть налаштування сервера. + Збій сповіщення - Перевірка пошти: %s:%s + Під час створення системного сповіщення щодо нового повідомлення виникла помилка. Найчастіше це стається через відсутність звуку для сповіщення.\n\nТоркніться для переходу до налаштування сповіщення. + Перевірка пошти: %1$s:%2$s Перевірка пошти Надсилання пошти: %s Надсилання пошти @@ -194,7 +199,7 @@ Видалити цитований текст Редагувати цитований текст Видалити вкладення - Від: %s <%s> + Від: %1$s <%2$s> Кому: Копія: Прихована копія: @@ -258,8 +263,11 @@ Налаштувати новий обліковий запис Адреса електронної пошти Пароль + Для використання цього поштового облікового запису у K-9 Mail Вам потрібно увійти в обліковий запис та надати доступ до ел.листів. + Увійти + Увійти з Google Щоб переглянути пароль, налаштуйте цьому пристрою блокування екрану. Звірте свою особу @@ -283,6 +291,7 @@ Пароль, що передається незахищено Зашифрований пароль Сертифікат клієнта + OAuth 2.0 Налаштування сервера вхідних повідомлень Ім’я користувача Пароль @@ -366,6 +375,10 @@ Ім\'я користувача або пароль невірні.\n(%s) Сервер надав недійсний сертифікат SSL. Це може бути зумовлено неналежним налаштуванням сервера або тим, що хтось намагається атакувати вас чи ваш поштовий сервер. Якщо ви не впевнені в тому, що відбувається, натисність "Відхилити" та зв’яжіться з адміністраторами вашого поштового серверу.\n\n(%s) Не можу з\'єднатися з сервером.\n(%s) + Авторизація скасована + Авторизація на вдалася з таких причин: %s + OAuth 2.0 наразі цим провайдером не підтримується. + Програма не може знайти браузер для надання доступу до Вашого облікового запису. Редагувати деталі Продовжити Додатково @@ -749,13 +762,16 @@ Введіть паролі + Увійдіть, будь ласка + Будь ласка, увійдіть та наберіть паролі Не вдалося імпортувати налаштування Не вдалося прочитати файл з налаштуваннями Не вдалося імпортувати деякі налаштування Успішно імпортовано Потрібен пароль + Потрібно увійти Не імпортовано Збій імпортування Пізніше diff --git a/app/ui/legacy/src/main/res/values-zh-rCN/strings.xml b/app/ui/legacy/src/main/res/values-zh-rCN/strings.xml index 1dfcb8640c641811b18e9700958fbfe436cfb62e..354a7d13db78acaa4eb599590606f6d0a31a0492 100644 --- a/app/ui/legacy/src/main/res/values-zh-rCN/strings.xml +++ b/app/ui/legacy/src/main/res/values-zh-rCN/strings.xml @@ -15,7 +15,7 @@ 用户手册 获取帮助 用户论坛 - 多样性 + Fediverse 推特 许可 @@ -126,7 +126,7 @@ %d 条新消息 - 您有%d封未读邮件(%s + 您有%1$d封未读邮件(%2$s 加载 %2$s 上的更多 %1$d 条消息 回复 标记为已读 @@ -145,7 +145,7 @@ 通知错误 尝试创建新消息的系统通知时出错。最有可能的原因是缺少通知声音。\n\n轻按打开通知设置。 - 正在检查邮件:%s%s + 正在检查邮件:%1$s%2$s 正在检查邮件 正在发送邮件:%s 发送邮件 @@ -192,7 +192,7 @@ 删除引用文本 编辑引用文本 移除附件 - 发件人:%s <%s> + 发件人:%1$s <%2$s> 收件人: 抄送: 密送: diff --git a/app/ui/legacy/src/main/res/values-zh-rTW/strings.xml b/app/ui/legacy/src/main/res/values-zh-rTW/strings.xml index 5f05d37b99a07a1139513ef9a028f2857bf65062..83b9877976aa455223bd5226353a5e1418e25bd1 100644 --- a/app/ui/legacy/src/main/res/values-zh-rTW/strings.xml +++ b/app/ui/legacy/src/main/res/values-zh-rTW/strings.xml @@ -124,7 +124,7 @@ %d 條新訊息 - 您有%d封未讀郵件(%s + 您有%1$d封未讀郵件(%2$s + 來自%2$s已超過%1$d則訊息 回覆 標示為已讀取 @@ -140,8 +140,10 @@ 身份驗證失敗 %s 登入失敗。請更新你的伺服器設定。 + 通知錯誤 - 正在檢查郵件:%s:%s + 嘗試建立新訊息的系統通知時發生錯誤。最有可能的原因是缺少通知聲音。\n\n按一下開啟通知設定。 + 正在檢查郵件:%1$s:%2$s 正在檢查郵件 正在寄送郵件:%s 正在寄送郵件 @@ -188,7 +190,7 @@ 清除引用文字內容 編輯引用文字內容 移除附件 - 寄件人:%s <%s> + 寄件人:%1$s <%2$s> 收件人: 副本: 密件副本: diff --git a/app/ui/legacy/src/main/res/values/strings.xml b/app/ui/legacy/src/main/res/values/strings.xml index 6f5075db60958ef7153ae34087ea8011b49897f0..72168bf6e17912b78b21767af3c335c918a6a2b2 100644 --- a/app/ui/legacy/src/main/res/values/strings.xml +++ b/app/ui/legacy/src/main/res/values/strings.xml @@ -158,7 +158,7 @@ %d new message %d new messages - %d Unread (%s) + %1$d Unread (%2$s) + %1$d more on %2$s Reply @@ -181,7 +181,7 @@ An error has occurred while trying to create a system notification for a new message. The reason is most likely a missing notification sound.\n\nTap to open notification settings. - Checking mail: %s:%s + Checking mail: %1$s:%2$s Checking mail Sending mail: %s Sending mail @@ -235,7 +235,7 @@ Edit quoted text Remove attachment - From: %s <%s> + From: %1$s <%2$s> To: Cc: Bcc: diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientLoaderTest.java b/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientLoaderTest.java index e2753934a01cb52640f6f5d103bfa15447ea02d6..6d989455222af3f5161073fbd642e111f201626d 100644 --- a/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientLoaderTest.java +++ b/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientLoaderTest.java @@ -77,7 +77,7 @@ public class RecipientLoaderTest extends RobolectricTest { @Before public void setUp() throws Exception { - Application application = RuntimeEnvironment.application; + Application application = RuntimeEnvironment.getApplication(); shadowApp = Shadows.shadowOf(application); shadowApp.grantPermissions(Manifest.permission.READ_CONTACTS); shadowApp.grantPermissions(Manifest.permission.WRITE_CONTACTS); diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt b/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt index 029b1421e2c8efc14016b0b5a1153b8ca90a63f8..168f2bc6888e830b4617afdc938c0fd4a221c979 100644 --- a/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt +++ b/app/ui/legacy/src/test/java/com/fsck/k9/fragment/MessageListAdapterTest.kt @@ -469,11 +469,11 @@ class MessageListAdapterTest : RobolectricTest() { } fun createMessageListItem( - position: Int = 0, account: Account = Account(SOME_ACCOUNT_UUID), subject: String? = "irrelevant", threadCount: Int = 0, messageDate: Long = 0L, + internalDate: Long = 0L, displayName: CharSequence = "irrelevant", displayAddress: Address? = Address.parse("irrelevant@domain.example").first(), toMe: Boolean = false, @@ -492,11 +492,11 @@ class MessageListAdapterTest : RobolectricTest() { threadRoot: Long = 0L ): MessageListItem { return MessageListItem( - position, account, subject, threadCount, messageDate, + internalDate, displayName, displayAddress, toMe, diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/message/PgpMessageBuilderTest.kt b/app/ui/legacy/src/test/java/com/fsck/k9/message/PgpMessageBuilderTest.kt index b0f96c5daecf76c0c96cc2dd39b3363cc84d6b5a..a2c91597ee1be54d1f1263d4d500b9cc1671d150 100644 --- a/app/ui/legacy/src/test/java/com/fsck/k9/message/PgpMessageBuilderTest.kt +++ b/app/ui/legacy/src/test/java/com/fsck/k9/message/PgpMessageBuilderTest.kt @@ -46,11 +46,11 @@ import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyInt import org.mockito.ArgumentMatchers.eq import org.mockito.ArgumentMatchers.same -import org.mockito.Mockito.`when` import org.mockito.Mockito.mock import org.mockito.Mockito.spy import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.Mockito.`when` import org.mockito.kotlin.anyOrNull import org.openintents.openpgp.OpenPgpApiManager.OpenPgpProviderState import org.openintents.openpgp.OpenPgpError @@ -81,7 +81,7 @@ class PgpMessageBuilderTest : K9RobolectricTest() { @Before @Throws(Exception::class) fun setUp() { - BinaryTempFileBody.setTempDirectory(RuntimeEnvironment.application.cacheDir) + BinaryTempFileBody.setTempDirectory(RuntimeEnvironment.getApplication().cacheDir) `when`(autocryptOpenPgpApiInteractor.getKeyMaterialForKeyId(openPgpApi, TEST_KEY_ID, SENDER_EMAIL)) .thenReturn(AUTOCRYPT_KEY_MATERIAL) } diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/ui/K9DrawerTest.kt b/app/ui/legacy/src/test/java/com/fsck/k9/ui/K9DrawerTest.kt index 3ffd67391fd1a8cec371d564c54d0b93b3e844ff..48c04348c1f0dff43677290cf857760afc478696 100644 --- a/app/ui/legacy/src/test/java/com/fsck/k9/ui/K9DrawerTest.kt +++ b/app/ui/legacy/src/test/java/com/fsck/k9/ui/K9DrawerTest.kt @@ -8,7 +8,7 @@ import org.robolectric.RuntimeEnvironment class K9DrawerTest : RobolectricTest() { @Test fun testAccountColorLengthEqualsDrawerColorLength() { - val resources = RuntimeEnvironment.application.resources + val resources = RuntimeEnvironment.getApplication().resources val lightColors = resources.getIntArray(R.array.account_colors) val darkColors = resources.getIntArray(R.array.drawer_account_accent_color_dark_theme) diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/ui/crypto/MessageCryptoHelperTest.java b/app/ui/legacy/src/test/java/com/fsck/k9/ui/crypto/MessageCryptoHelperTest.java index 86d08c9149592fc3153f0ca66837f20c8afb2788..42b3d36a090a84d01b0ab707ab0dfa61bb64b9e0 100644 --- a/app/ui/legacy/src/test/java/com/fsck/k9/ui/crypto/MessageCryptoHelperTest.java +++ b/app/ui/legacy/src/test/java/com/fsck/k9/ui/crypto/MessageCryptoHelperTest.java @@ -70,7 +70,7 @@ public class MessageCryptoHelperTest extends RobolectricTest { when(openPgpApiFactory.createOpenPgpApi(any(Context.class), nullable(IOpenPgpService2.class))) .thenReturn(openPgpApi); - messageCryptoHelper = new MessageCryptoHelper(RuntimeEnvironment.application, openPgpApiFactory, + messageCryptoHelper = new MessageCryptoHelper(RuntimeEnvironment.getApplication(), openPgpApiFactory, autocryptOperations, "org.example.dummy"); messageCryptoCallback = mock(MessageCryptoCallback.class); } diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/RelativeDateTimeFormatterTest.kt b/app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/RelativeDateTimeFormatterTest.kt index f0258c304a9a31e83f480f3a1dceecc1c5687dfe..b38bf13634afc7fb2a626505f77f93fd935f89fd 100644 --- a/app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/RelativeDateTimeFormatterTest.kt +++ b/app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/RelativeDateTimeFormatterTest.kt @@ -16,7 +16,7 @@ import org.robolectric.annotation.Config @Config(qualifiers = "en") class RelativeDateTimeFormatterTest : RobolectricTest() { - private val context = RuntimeEnvironment.application.applicationContext + private val context = RuntimeEnvironment.getApplication().applicationContext private val clock = TestClock() private val dateTimeFormatter = RelativeDateTimeFormatter(context, clock) diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/SizeFormatterTest.kt b/app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/SizeFormatterTest.kt index 9c009965ae64336ca9ff3d9cb7e55786bc81df83..9af9595784f15e68d2254bf6a647ea3fd1cf94ac 100644 --- a/app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/SizeFormatterTest.kt +++ b/app/ui/legacy/src/test/java/com/fsck/k9/ui/helper/SizeFormatterTest.kt @@ -8,7 +8,7 @@ import org.robolectric.annotation.Config @Config(qualifiers = "en") class SizeFormatterTest : RobolectricTest() { - private val sizeFormatter = SizeFormatter(RuntimeEnvironment.application.resources) + private val sizeFormatter = SizeFormatter(RuntimeEnvironment.getApplication().resources) @Test fun bytes_lower_bound() { diff --git a/backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt b/backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt index 2be2f601ede1a9322cf66a1bdf32796706c7bce3..5d657469a8d4c09d9a9dced190dd31083235dceb 100644 --- a/backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt +++ b/backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt @@ -21,7 +21,7 @@ interface Backend { fun refreshFolderList() // TODO: Add a way to cancel the sync process - fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) + fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) @Throws(MessagingException::class) fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackend.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackend.kt index 510db124eb5a3990bfa0c673d3ba20c6669415cd..a0bc860c916166614add175d180413bc9d524c5d 100644 --- a/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackend.kt +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackend.kt @@ -51,8 +51,8 @@ class ImapBackend( commandRefreshFolderList.refreshFolderList() } - override fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) { - imapSync.sync(folder, syncConfig, listener) + override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) { + imapSync.sync(folderServerId, syncConfig, listener) } override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) { diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt index 86987316ef0a8134e5a5f40cd8d8e946638e40b2..92b1ddaa750c9fd3f602d674fe874f30b6d63e8b 100644 --- a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt @@ -45,8 +45,8 @@ class JmapBackend( commandRefreshFolderList.refreshFolderList() } - override fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) { - commandSync.sync(folder, syncConfig, listener) + override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) { + commandSync.sync(folderServerId, syncConfig, listener) } override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) { diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt index 12a6cfc47a0ef7250db2a711f1b35357a1cfa028..0fcc19604d3cc7782d2fc91afec6cbe93f31bc9b 100644 --- a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt @@ -18,6 +18,7 @@ internal inline fun JmapRequest.Call.getMainRespons return methodResponses.getMainResponseBlocking() } +@Suppress("NOTHING_TO_INLINE") internal inline fun ListenableFuture.futureGetOrThrow(): T { return try { get() diff --git a/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Backend.kt b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Backend.kt index fa1de215ebeac76cf7457ef06ab8847e3d6a8088..64308ee0d9dbb7bfe9a472f74e0ece9db5acb9a7 100644 --- a/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Backend.kt +++ b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Backend.kt @@ -38,8 +38,8 @@ class Pop3Backend( commandRefreshFolderList.refreshFolderList() } - override fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) { - pop3Sync.sync(folder, syncConfig, listener) + override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) { + pop3Sync.sync(folderServerId, syncConfig, listener) } override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) { diff --git a/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendStorage.kt b/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendStorage.kt index 53c0e20daaf1bf7ce960ccc6308871f6e9ddf84c..909477c0c1d8f4335139229ba07593de4e79a762 100644 --- a/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendStorage.kt +++ b/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendStorage.kt @@ -35,11 +35,13 @@ class InMemoryBackendStorage : BackendStorage { } private inner class InMemoryBackendFolderUpdater : BackendFolderUpdater { - override fun createFolders(foldersToCreate: List) { - foldersToCreate.forEach { folder -> - if (folders.containsKey(folder.serverId)) error("Folder ${folder.serverId} already present") + override fun createFolders(folders: List) { + folders.forEach { folder -> + if (this@InMemoryBackendStorage.folders.containsKey(folder.serverId)) { + error("Folder ${folder.serverId} already present") + } - folders[folder.serverId] = InMemoryBackendFolder(folder.name, folder.type) + this@InMemoryBackendStorage.folders[folder.serverId] = InMemoryBackendFolder(folder.name, folder.type) } } diff --git a/backend/webdav/src/main/java/com/fsck/k9/backend/webdav/WebDavBackend.kt b/backend/webdav/src/main/java/com/fsck/k9/backend/webdav/WebDavBackend.kt index 9f119ab8be4de81ae1837a4e1d5737eff9ad6a19..e0321d60a0e779bdd748cd30305e322c2e6e37cb 100644 --- a/backend/webdav/src/main/java/com/fsck/k9/backend/webdav/WebDavBackend.kt +++ b/backend/webdav/src/main/java/com/fsck/k9/backend/webdav/WebDavBackend.kt @@ -42,8 +42,8 @@ class WebDavBackend( commandGetFolders.refreshFolderList() } - override fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) { - webDavSync.sync(folder, syncConfig, listener) + override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) { + webDavSync.sync(folderServerId, syncConfig, listener) } override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) { diff --git a/build.gradle b/build.gradle index 1b7a67dea0ee635df29151d5bd2c7a1410bfc18e..d1dae12636d8a51552135199cc0264e68560009f 100644 --- a/build.gradle +++ b/build.gradle @@ -3,18 +3,20 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinCompile buildscript { ext { buildConfig = [ - 'compileSdk': 31, + 'compileSdk': 33, 'targetSdk': 31, 'minSdk': 21, 'buildTools': '32.0.0', 'robolectricSdk': 31 ] + // Judging the impact of newer library versions on the app requires being intimately familiar with the code + // base. Please don't open pull requests upgrading dependencies if you're a new contributor. versions = [ 'kotlin': '1.7.10', 'kotlinCoroutines': '1.6.4', 'jetbrainsAnnotations': '23.0.0', - 'androidxAppCompat': '1.4.2', + 'androidxAppCompat': '1.5.0', 'androidxActivity': '1.5.1', 'androidxRecyclerView': '1.2.1', 'androidxLifecycle': '2.5.1', @@ -23,7 +25,7 @@ buildscript { 'androidxNavigation': '2.5.1', 'androidxConstraintLayout': '2.1.4', 'androidxWorkManager': '2.7.1', - 'androidxFragment': '1.5.1', + 'androidxFragment': '1.5.2', 'androidxLocalBroadcastManager': '1.1.0', 'androidxCore': '1.8.0', 'androidxCardView': '1.0.0', @@ -36,22 +38,27 @@ buildscript { 'moshi': '1.13.0', 'timber': '5.0.1', 'koin': '3.2.0', + // We can't upgrade Commons IO beyond this version because starting with 2.7 it is using Java 8 API + // that is not available until Android API 26 (even with desugaring enabled). + // See https://issuetracker.google.com/issues/160484830 'commonsIo': '2.6', 'mime4j': '0.8.6', 'okhttp': '4.10.0', - 'minidns': '1.0.3', + 'minidns': '1.0.4', 'glide': '4.13.2', - 'jsoup': '1.15.2', 'retrofit': '2.9.0', + 'jsoup': '1.15.3', + 'httpClient': '4.5.13', + 'androidxTestRunner': '1.4.0', 'junit': '4.13.2', - 'robolectric': '4.8.1', - 'mockito': '4.6.1', + 'robolectric': '4.8.2', + 'mockito': '4.7.0', 'mockitoKotlin': '4.0.0', 'truth': '1.1.3', 'turbine': '0.9.0', - 'ktlint': '0.40.0', - 'leakcanary': '2.9.1' + 'leakcanary': '2.9.1', + 'ktlint': '0.44.0' ] javaVersion = JavaVersion.VERSION_11 @@ -62,13 +69,12 @@ buildscript { mavenCentral() google() maven { url "https://plugins.gradle.org/m2/" } - jcenter() } dependencies { classpath 'com.android.tools.build:gradle:7.2.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" - classpath "org.jlleitschuh.gradle:ktlint-gradle:10.0.0" + classpath "org.jlleitschuh.gradle:ktlint-gradle:11.0.0" } } @@ -80,7 +86,6 @@ subprojects { repositories { mavenCentral() google() - jcenter() maven { url 'https://jitpack.io' } } @@ -104,7 +109,7 @@ subprojects { tasks.withType(KotlinCompile) { kotlinOptions { - freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" + freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" } } diff --git a/fastlane/metadata/android/en-US/changelogs/33002.txt b/fastlane/metadata/android/en-US/changelogs/33002.txt new file mode 100644 index 0000000000000000000000000000000000000000..3cb99e7ca03d625d95e625499626ef32eabdb4a5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33002.txt @@ -0,0 +1,5 @@ +- Fixed moving to next/previous message when sorting the message list by read/unread or starred/unstarred +- Fixed a crash when a third-party app shared a file to K-9 Mail without granting access to it +- Keep some more attributes when sanitizing HTML +- A lot of internal changes and improvements +- Updated translations diff --git a/fastlane/metadata/android/en-US/changelogs/33003.txt b/fastlane/metadata/android/en-US/changelogs/33003.txt new file mode 100644 index 0000000000000000000000000000000000000000..75acd8d2101df61041a27e84b5029f6840735ac6 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33003.txt @@ -0,0 +1,4 @@ +- Fixed a bug that lead to search being broken +- Fixed error reporting for (old) send failures +- Fixed "strip signatures on reply" +- Fixed a crash when tapping a toolbar action in message view before loading the message has finished diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2ec77e51a9c9fdad7b06dcd6b4bf75a36b92f6d9..73abeb3d1a28a3a86511826cbb5f35c60e898d54 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Mon Sep 12 09:45:54 BDT 2022 distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip -zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/mail/common/build.gradle b/mail/common/build.gradle index bf282669e6a83905b67a82f2e5f80cf4043e1378..7b4ad7508dc511c6539021ecf25b5b43326423d9 100644 --- a/mail/common/build.gradle +++ b/mail/common/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' +apply plugin: 'com.android.lint' if (rootProject.testCoverage) { apply plugin: 'jacoco' diff --git a/mail/common/src/main/java/com/fsck/k9/logging/Timber.kt b/mail/common/src/main/java/com/fsck/k9/logging/Timber.kt index e0912ec943e4bda2e4ba2040313a468c5dcfabc2..4e77f392cc76f560080f9d13309772e55ef05f6c 100644 --- a/mail/common/src/main/java/com/fsck/k9/logging/Timber.kt +++ b/mail/common/src/main/java/com/fsck/k9/logging/Timber.kt @@ -68,7 +68,7 @@ object Timber { @JvmStatic fun e(message: String?, vararg args: Any?) { - logger.e(message) + logger.e(message, *args) } @JvmStatic diff --git a/mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.java b/mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.java deleted file mode 100644 index 87c46327328353914cdaa64d938e5ded784d3e0b..0000000000000000000000000000000000000000 --- a/mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.fsck.k9.mail; - - -import java.util.Random; - -import org.jetbrains.annotations.VisibleForTesting; - - -public class BoundaryGenerator { - private static final BoundaryGenerator INSTANCE = new BoundaryGenerator(new Random()); - - private static final int BOUNDARY_CHARACTER_COUNT = 30; - private static final char[] BASE36_MAP = { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', - 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', - 'U', 'V', 'W', 'X', 'Y', 'Z' - }; - - - private final Random random; - - - public static BoundaryGenerator getInstance() { - return INSTANCE; - } - - @VisibleForTesting - BoundaryGenerator(Random random) { - this.random = random; - } - - public String generateBoundary() { - StringBuilder builder = new StringBuilder(4 + BOUNDARY_CHARACTER_COUNT); - builder.append("----"); - - for (int i = 0; i < BOUNDARY_CHARACTER_COUNT; i++) { - builder.append(BASE36_MAP[random.nextInt(36)]); - } - - return builder.toString(); - } -} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.kt b/mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.kt new file mode 100644 index 0000000000000000000000000000000000000000..a8ce9fe8759b1233f333da869919a07f40f07f4e --- /dev/null +++ b/mail/common/src/main/java/com/fsck/k9/mail/BoundaryGenerator.kt @@ -0,0 +1,33 @@ +package com.fsck.k9.mail + +import java.util.Random +import org.jetbrains.annotations.VisibleForTesting + +class BoundaryGenerator @VisibleForTesting internal constructor(private val random: Random) { + + fun generateBoundary(): String { + return buildString(4 + BOUNDARY_CHARACTER_COUNT) { + append("----") + + repeat(BOUNDARY_CHARACTER_COUNT) { + append(BASE36_MAP[random.nextInt(36)]) + } + } + } + + companion object { + private const val BOUNDARY_CHARACTER_COUNT = 30 + + private val BASE36_MAP = charArrayOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', + 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', + 'U', 'V', 'W', 'X', 'Y', 'Z' + ) + + private val INSTANCE = BoundaryGenerator(Random()) + + @JvmStatic + fun getInstance() = INSTANCE + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.java b/mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.java deleted file mode 100644 index eca4606b7e610f06fe1f905a18cd27b75814ee77..0000000000000000000000000000000000000000 --- a/mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.fsck.k9.mail; - - -import java.util.Random; - -import org.junit.Test; -import org.mockito.stubbing.OngoingStubbing; - -import static org.junit.Assert.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - - -public class BoundaryGeneratorTest { - @Test - public void generateBoundary_allZeros() throws Exception { - Random random = createRandom(0); - BoundaryGenerator boundaryGenerator = new BoundaryGenerator(random); - - String result = boundaryGenerator.generateBoundary(); - - assertEquals("----000000000000000000000000000000", result); - } - - @Test - public void generateBoundary() throws Exception { - Random random = createRandom(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, - 23, 24, 25, 26, 27, 28, 35); - BoundaryGenerator boundaryGenerator = new BoundaryGenerator(random); - - String result = boundaryGenerator.generateBoundary(); - - assertEquals("----0123456789ABCDEFGHIJKLMNOPQRSZ", result); - } - - private Random createRandom(int... values) { - Random random = mock(Random.class); - - OngoingStubbing ongoingStubbing = when(random.nextInt(36)); - for (int value : values) { - ongoingStubbing = ongoingStubbing.thenReturn(value); - } - - return random; - } -} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..990a410062c01e2a1b1e89713febb0f049c3bc2d --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.kt @@ -0,0 +1,39 @@ +package com.fsck.k9.mail + +import com.google.common.truth.Truth.assertThat +import java.util.Random +import org.junit.Test +import org.mockito.kotlin.mock + +class BoundaryGeneratorTest { + @Test + fun `generateBoundary() with all zeros`() { + val random = createRandom(0) + val boundaryGenerator = BoundaryGenerator(random) + + val result = boundaryGenerator.generateBoundary() + + assertThat(result).isEqualTo("----000000000000000000000000000000") + } + + @Test + fun generateBoundary() { + val random = createRandom( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 35 + ) + val boundaryGenerator = BoundaryGenerator(random) + + val result = boundaryGenerator.generateBoundary() + + assertThat(result).isEqualTo("----0123456789ABCDEFGHIJKLMNOPQRSZ") + } + + private fun createRandom(vararg values: Int): Random { + return mock { + var ongoingStubbing = on { nextInt(36) } + for (value in values) { + ongoingStubbing = ongoingStubbing.thenReturn(value) + } + } + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterDecoderTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterDecoderTest.kt index a5cc35e2321dbc0462441f37892a4d03cd97abdb..def1fe9890d9d2d61c1f2e24b6348b81da34d819 100644 --- a/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterDecoderTest.kt +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterDecoderTest.kt @@ -1,7 +1,8 @@ package com.fsck.k9.mail.internet -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue +import com.google.common.truth.MapSubject +import com.google.common.truth.Ordered +import com.google.common.truth.Truth.assertThat import org.junit.Test class MimeParameterDecoderTest { @@ -9,18 +10,18 @@ class MimeParameterDecoderTest { fun rfc2045_example1() { val mimeValue = MimeParameterDecoder.decode("text/plain; charset=us-ascii (Plain text)") - assertEquals("text/plain", mimeValue.value) - assertParametersEquals(mimeValue, "charset" to "us-ascii") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.value).isEqualTo("text/plain") + assertThat(mimeValue.parameters).containsExactlyEntries("charset" to "us-ascii") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun rfc2045_example2() { val mimeValue = MimeParameterDecoder.decode("text/plain; charset=\"us-ascii\"") - assertEquals("text/plain", mimeValue.value) - assertParametersEquals(mimeValue, "charset" to "us-ascii") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.value).isEqualTo("text/plain") + assertThat(mimeValue.parameters).containsExactlyEntries("charset" to "us-ascii") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -31,13 +32,12 @@ class MimeParameterDecoderTest { " URL*1=\"cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar\"" ) - assertEquals("message/external-body", mimeValue.value) - assertParametersEquals( - mimeValue, + assertThat(mimeValue.value).isEqualTo("message/external-body") + assertThat(mimeValue.parameters).containsExactlyEntries( "access-type" to "URL", "url" to "ftp://cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar" ) - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -47,13 +47,12 @@ class MimeParameterDecoderTest { " URL=\"ftp://cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar\"" ) - assertEquals("message/external-body", mimeValue.value) - assertParametersEquals( - mimeValue, + assertThat(mimeValue.value).isEqualTo("message/external-body") + assertThat(mimeValue.parameters).containsExactlyEntries( "access-type" to "URL", "url" to "ftp://cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar" ) - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -63,9 +62,9 @@ class MimeParameterDecoderTest { " name*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A" ) - assertEquals("application/x-stuff", mimeValue.value) - assertParametersEquals(mimeValue, "name" to "This is ***fun***") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.value).isEqualTo("application/x-stuff") + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "This is ***fun***") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -77,9 +76,9 @@ class MimeParameterDecoderTest { " name*2=\"isn't it!\"" ) - assertEquals("application/x-stuff", mimeValue.value) - assertParametersEquals(mimeValue, "name" to "This is even more ***fun*** isn't it!") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.value).isEqualTo("application/x-stuff") + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "This is even more ***fun*** isn't it!") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -91,8 +90,8 @@ class MimeParameterDecoderTest { " name*0=\"[one]\"" ) - assertParametersEquals(mimeValue, "name" to "[one][two][three]") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "[one][two][three]") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -100,12 +99,12 @@ class MimeParameterDecoderTest { val mimeValue = MimeParameterDecoder.decode( "application/x-stuff;\r\n" + " name*0=\"[one]\";\r\n" + - " name*1=\"[two]\";\r\n" + - " name*2=\"[three]\"" + " NAME*1=\"[two]\";\r\n" + + " nAmE*2=\"[three]\"" ) - assertParametersEquals(mimeValue, "name" to "[one][two][three]") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "[one][two][three]") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -120,8 +119,8 @@ class MimeParameterDecoderTest { " name*5=six" ) - assertParametersEquals(mimeValue, "name" to "[one][two][three][four][five]six") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "[one][two][three][four][five]six") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -132,8 +131,8 @@ class MimeParameterDecoderTest { " name*=utf-8''filen%C3%A4me.ext" ) - assertParametersEquals(mimeValue, "name" to "filenäme.ext") - assertIgnoredParametersEquals(mimeValue, "name" to "filename.ext") + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "filenäme.ext") + assertThat(mimeValue.ignoredParameters).containsExactly("name" to "filename.ext") } @Test @@ -145,8 +144,8 @@ class MimeParameterDecoderTest { " name=two" ) - assertParametersEquals(mimeValue, "extra" to "something") - assertIgnoredParametersEquals(mimeValue, "name" to "one", "name" to "two") + assertThat(mimeValue.parameters).containsExactlyEntries("extra" to "something") + assertThat(mimeValue.ignoredParameters).containsExactly("name" to "one", "name" to "two") } @Test @@ -158,26 +157,26 @@ class MimeParameterDecoderTest { " NAME=two" ) - assertParametersEquals(mimeValue, "extra" to "something") - assertIgnoredParametersEquals(mimeValue, "name" to "one", "name" to "two") + assertThat(mimeValue.parameters).containsExactlyEntries("extra" to "something") + assertThat(mimeValue.ignoredParameters).containsExactly("name" to "one", "name" to "two") } @Test fun name_only_parameter() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; parameter") - assertEquals(30, mimeValue.parserErrorIndex) - assertTrue(mimeValue.parameters.isEmpty()) - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parserErrorIndex).isEqualTo(30) + assertThat(mimeValue.parameters).isEmpty() + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun missing_parameter_value() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; parameter=") - assertEquals(31, mimeValue.parserErrorIndex) - assertTrue(mimeValue.parameters.isEmpty()) - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parserErrorIndex).isEqualTo(31) + assertThat(mimeValue.parameters).isEmpty() + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -189,72 +188,72 @@ class MimeParameterDecoderTest { " (comment) extra (comment) = (comment) something (comment)" ) - assertParametersEquals(mimeValue, "name" to "one", "extra" to "something") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "one", "extra" to "something") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun iso8859_1_charset() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*=iso-8859-1''filen%E4me.ext") - assertParametersEquals(mimeValue, "name" to "filenäme.ext") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "filenäme.ext") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun missing_charset() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*=''filen%AAme.ext") - assertParametersEquals(mimeValue, "name" to "filen%AAme.ext") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "filen%AAme.ext") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun unknown_charset() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*=foobar''filen%AAme.ext") - assertParametersEquals(mimeValue, "name" to "filen%AAme.ext") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "filen%AAme.ext") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun section_index_missing() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name**=utf-8''filename") - assertParametersEquals(mimeValue, "name**" to "utf-8''filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name**" to "utf-8''filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun section_index_not_a_number() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*x*=filename") - assertParametersEquals(mimeValue, "name*x*" to "filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*x*" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun section_index_prefixed_with_plus() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*+0=filename") - assertParametersEquals(mimeValue, "name*+0" to "filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*+0" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun section_index_prefixed_with_minus() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*-0=filename") - assertParametersEquals(mimeValue, "name*-0" to "filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*-0" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun section_index_with_two_zeros() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*00=filename") - assertParametersEquals(mimeValue, "name*00" to "filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*00" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -265,8 +264,8 @@ class MimeParameterDecoderTest { " name*01=two" ) - assertParametersEquals(mimeValue, "name" to "one", "name*01" to "two") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "one", "name*01" to "two") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -276,80 +275,80 @@ class MimeParameterDecoderTest { " name*10000000000000000000=filename" ) - assertParametersEquals(mimeValue, "name*10000000000000000000" to "filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*10000000000000000000" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun extended_parameter_name_with_additional_asterisk() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0**=utf-8''filename") - assertParametersEquals(mimeValue, "name*0**" to "utf-8''filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*0**" to "utf-8''filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun extended_parameter_name_with_additional_text() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0*x=utf-8''filename") - assertParametersEquals(mimeValue, "name*0*x" to "utf-8''filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*0*x" to "utf-8''filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun extended_parameter_value_with_quoted_string() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0*=\"utf-8''filename\"") - assertParametersEquals(mimeValue, "name*0*" to "utf-8''filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*0*" to "utf-8''filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun extended_initial_parameter_value_missing_single_quotes() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0*=filename") - assertParametersEquals(mimeValue, "name*0*" to "filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*0*" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun extended_initial_parameter_value_missing_second_single_quote() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0*='") - assertParametersEquals(mimeValue, "name*0*" to "'") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*0*" to "'") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun extended_parameter_value_with_trailing_percent_sign() { val mimeValue = MimeParameterDecoder.decode("attachment; filename*=utf-8''file%") - assertParametersEquals(mimeValue, "filename*" to "utf-8''file%") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("filename*" to "utf-8''file%") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun extended_parameter_value_with_invalid_percent_encoding() { val mimeValue = MimeParameterDecoder.decode("attachment; filename*=UTF-8''f%oo.html") - assertParametersEquals(mimeValue, "filename*" to "UTF-8''f%oo.html") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("filename*" to "UTF-8''f%oo.html") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun section_0_missing() { val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*1=filename") - assertParametersEquals(mimeValue, "name*1" to "filename") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name*1" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test fun semicolon_in_parameter_value() { val mimeValue = MimeParameterDecoder.decode("attachment; filename=\"Here's a semicolon;.txt\"") - assertParametersEquals(mimeValue, "filename" to "Here's a semicolon;.txt") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("filename" to "Here's a semicolon;.txt") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -359,8 +358,8 @@ class MimeParameterDecoderTest { " name=\"=?UTF-8?Q?filn=C3=A4me=2Eext?=\"" ) - assertParametersEquals(mimeValue, "name" to "filnäme.ext") - assertTrue(mimeValue.ignoredParameters.isEmpty()) + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "filnäme.ext") + assertThat(mimeValue.ignoredParameters).isEmpty() } @Test @@ -372,23 +371,14 @@ class MimeParameterDecoderTest { " =?UTF-8?Q?non-ASCII_characters=3A_=C3=A4=E2=82=AC=F0=9F=8C=9E?=\"" ) - assertParametersEquals( - mimeValue, + assertThat(mimeValue.parameters).containsExactlyEntries( "name" to "File name that is so long it likes to be wrapped " + "into multiple lines. Also non-ASCII characters: ä€\uD83C\uDF1E" ) - assertTrue(mimeValue.ignoredParameters.isEmpty()) - } - - private fun assertParametersEquals(mimeValue: MimeValue, vararg expected: Pair) { - assertEquals(expected.toSet(), mimeValue.parameters.toPairSet()) - } - - private fun assertIgnoredParametersEquals(mimeValue: MimeValue, vararg expected: Pair) { - assertEquals(expected.toSet(), mimeValue.ignoredParameters.toSet()) + assertThat(mimeValue.ignoredParameters).isEmpty() } - private fun Map.toPairSet(): Set> { - return this.map { (key, value) -> key to value }.toSet() + private fun MapSubject.containsExactlyEntries(vararg values: Pair): Ordered { + return containsExactlyEntriesIn(values.toMap()) } } diff --git a/mail/protocols/imap/build.gradle b/mail/protocols/imap/build.gradle index f86c05f47f2d58df13961582f2c30b73b2a218f4..3150d7d9a88eb5feb609e69479874fbc6a4c8068 100644 --- a/mail/protocols/imap/build.gradle +++ b/mail/protocols/imap/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' +apply plugin: 'com.android.lint' if (rootProject.testCoverage) { apply plugin: 'jacoco' diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.java deleted file mode 100644 index 948b036741b85cecf5b5ba33bd27f2faf1ba704b..0000000000000000000000000000000000000000 --- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.fsck.k9.mail.store.imap; - - -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.Charset; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CodingErrorAction; - -import com.beetstra.jutf7.CharsetProvider; - - -class FolderNameCodec { - private final Charset modifiedUtf7Charset; - private final Charset asciiCharset; - - - public static FolderNameCodec newInstance() { - return new FolderNameCodec(); - } - - private FolderNameCodec() { - modifiedUtf7Charset = new CharsetProvider().charsetForName("X-RFC-3501"); - asciiCharset = Charset.forName("US-ASCII"); - } - - public String encode(String folderName) { - ByteBuffer byteBuffer = modifiedUtf7Charset.encode(folderName); - byte[] bytes = new byte[byteBuffer.limit()]; - byteBuffer.get(bytes); - - return new String(bytes, asciiCharset); - } - - public String decode(String encodedFolderName) throws CharacterCodingException { - CharsetDecoder decoder = modifiedUtf7Charset.newDecoder().onMalformedInput(CodingErrorAction.REPORT); - ByteBuffer byteBuffer = ByteBuffer.wrap(encodedFolderName.getBytes(asciiCharset)); - CharBuffer charBuffer = decoder.decode(byteBuffer); - - return charBuffer.toString(); - } -} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.kt new file mode 100644 index 0000000000000000000000000000000000000000..9619f654ded6bbfc826caaaa8056d50ccd3f0b20 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.kt @@ -0,0 +1,27 @@ +package com.fsck.k9.mail.store.imap + +import com.beetstra.jutf7.CharsetProvider +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets + +internal class FolderNameCodec { + private val modifiedUtf7Charset = CharsetProvider().charsetForName("X-RFC-3501") + private val asciiCharset = StandardCharsets.US_ASCII + + fun encode(folderName: String): String { + val byteBuffer = modifiedUtf7Charset.encode(folderName) + val bytes = ByteArray(byteBuffer.limit()) + byteBuffer.get(bytes) + + return String(bytes, asciiCharset) + } + + fun decode(encodedFolderName: String): String { + val decoder = modifiedUtf7Charset.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + val byteBuffer = ByteBuffer.wrap(encodedFolderName.toByteArray(asciiCharset)) + val charBuffer = decoder.decode(byteBuffer) + + return charBuffer.toString() + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.java deleted file mode 100644 index 439e3b7d787a432160e80f56b011009116f5a24b..0000000000000000000000000000000000000000 --- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.fsck.k9.mail.store.imap; - - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Set; -import java.util.TreeSet; - - -class IdGrouper { - static GroupedIds groupIds(Set ids) { - if (ids == null || ids.isEmpty()) { - throw new IllegalArgumentException("groupId() must be called with non-empty set of ids"); - } - - if (ids.size() < 2) { - return new GroupedIds(ids, Collections.emptyList()); - } - - TreeSet orderedIds = new TreeSet<>(ids); - Iterator orderedIdIterator = orderedIds.iterator(); - Long previousId = orderedIdIterator.next(); - - TreeSet remainingIds = new TreeSet<>(); - remainingIds.add(previousId); - List idGroups = new ArrayList<>(); - long currentIdGroupStart = -1L; - long currentIdGroupEnd = -1L; - while (orderedIdIterator.hasNext()) { - Long currentId = orderedIdIterator.next(); - if (previousId + 1L == currentId) { - if (currentIdGroupStart == -1L) { - remainingIds.remove(previousId); - currentIdGroupStart = previousId; - currentIdGroupEnd = currentId; - } else { - currentIdGroupEnd = currentId; - } - } else { - if (currentIdGroupStart != -1L) { - idGroups.add(new ContiguousIdGroup(currentIdGroupStart, currentIdGroupEnd)); - currentIdGroupStart = -1L; - } - remainingIds.add(currentId); - } - - previousId = currentId; - } - - if (currentIdGroupStart != -1L) { - idGroups.add(new ContiguousIdGroup(currentIdGroupStart, currentIdGroupEnd)); - } - - return new GroupedIds(remainingIds, idGroups); - } - - - static class GroupedIds { - public final Set ids; - public final List idGroups; - - - GroupedIds(Set ids, List idGroups) { - if (ids.isEmpty() && idGroups.isEmpty()) { - throw new IllegalArgumentException("Must have at least one id"); - } - - this.ids = ids; - this.idGroups = idGroups; - } - } - - static class ContiguousIdGroup { - public final long start; - public final long end; - - - ContiguousIdGroup(long start, long end) { - if (start >= end) { - throw new IllegalArgumentException("start >= end"); - } - - this.start = start; - this.end = end; - } - - @Override - public String toString() { - return start + ":" + end; - } - } -} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.kt new file mode 100644 index 0000000000000000000000000000000000000000..32964742f8ca4e88d6745d8ca461b204d6c2ab48 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.kt @@ -0,0 +1,62 @@ +package com.fsck.k9.mail.store.imap + +private const val NO_VALID_ID = -1L + +internal object IdGrouper { + fun groupIds(ids: Set): GroupedIds { + require(ids.isNotEmpty()) { "groupIds() must be called with non-empty set of IDs" } + + if (ids.size < 2) return GroupedIds(ids, emptyList()) + + val orderedIds = ids.toSortedSet() + val firstId = orderedIds.first() + + val remainingIds = mutableSetOf(firstId) + val idGroups = mutableListOf() + + var previousId = firstId + var currentIdGroupStart = NO_VALID_ID + var currentIdGroupEnd = NO_VALID_ID + for (currentId in orderedIds.asSequence().drop(1)) { + if (previousId + 1L == currentId) { + if (currentIdGroupStart == NO_VALID_ID) { + remainingIds.remove(previousId) + currentIdGroupStart = previousId + currentIdGroupEnd = currentId + } else { + currentIdGroupEnd = currentId + } + } else { + if (currentIdGroupStart != NO_VALID_ID) { + idGroups.add(ContiguousIdGroup(currentIdGroupStart, currentIdGroupEnd)) + currentIdGroupStart = NO_VALID_ID + } + remainingIds.add(currentId) + } + + previousId = currentId + } + + if (currentIdGroupStart != NO_VALID_ID) { + idGroups.add(ContiguousIdGroup(currentIdGroupStart, currentIdGroupEnd)) + } + + return GroupedIds(remainingIds, idGroups) + } +} + +internal class GroupedIds(@JvmField val ids: Set, @JvmField val idGroups: List) { + init { + require(ids.isNotEmpty() || idGroups.isNotEmpty()) { "Must have at least one ID" } + } +} + +internal class ContiguousIdGroup(val start: Long, val end: Long) { + init { + require(start < end) { "start >= end" } + } + + override fun toString(): String { + return "$start:$end" + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapCommandSplitter.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapCommandSplitter.java index 486b6a83846461e95bc71eae170a1d1cb197e09a..91bbcd715b8763e2603e14c1ab249081d62d2cb6 100644 --- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapCommandSplitter.java +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapCommandSplitter.java @@ -6,9 +6,6 @@ import java.util.List; import java.util.Set; import java.util.TreeSet; -import com.fsck.k9.mail.store.imap.IdGrouper.ContiguousIdGroup; -import com.fsck.k9.mail.store.imap.IdGrouper.GroupedIds; - class ImapCommandSplitter { static List splitCommand(String prefix, String suffix, GroupedIds groupedIds, int lengthLimit) { diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.java deleted file mode 100644 index d5c5e1efbae2c0b52f442e1cec00c881c18effbe..0000000000000000000000000000000000000000 --- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.fsck.k9.mail.store.imap; - -import com.fsck.k9.mail.AuthType; -import com.fsck.k9.mail.ConnectionSecurity; - - -/** - * Settings source for IMAP. Implemented in order to remove coupling between {@link ImapStore} and {@link ImapConnection}. - */ -interface ImapSettings { - String getHost(); - - int getPort(); - - ConnectionSecurity getConnectionSecurity(); - - AuthType getAuthType(); - - String getUsername(); - - String getPassword(); - - String getClientCertificateAlias(); - - boolean useCompression(); - - String getPathPrefix(); - - void setPathPrefix(String prefix); - - String getPathDelimiter(); - - void setPathDelimiter(String delimiter); - - void setCombinedPrefix(String prefix); -} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.kt new file mode 100644 index 0000000000000000000000000000000000000000..03e7582b5b12dc0cce8fdadfa5d927057a7aabcc --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.kt @@ -0,0 +1,22 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity + +/** + * Settings source for IMAP. Implemented in order to remove coupling between [ImapStore] and [ImapConnection]. + */ +internal interface ImapSettings { + val host: String + val port: Int + val connectionSecurity: ConnectionSecurity + val authType: AuthType + val username: String + val password: String? + val clientCertificateAlias: String? + val useCompression: Boolean + + var pathPrefix: String? + var pathDelimiter: String? + fun setCombinedPrefix(prefix: String?) +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.java deleted file mode 100644 index 64cbd4cda3edd4b3a7e8b7ff8e561b0858f663de..0000000000000000000000000000000000000000 --- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.java +++ /dev/null @@ -1,929 +0,0 @@ -package com.fsck.k9.mail.store.imap; - - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.ConnectException; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.net.SocketAddress; -import java.net.SocketException; -import java.security.GeneralSecurityException; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.Security; -import java.security.cert.CertificateException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Pattern; -import java.util.zip.Inflater; -import java.util.zip.InflaterInputStream; - -import com.fsck.k9.logging.Timber; -import com.fsck.k9.mail.Authentication; -import com.fsck.k9.mail.AuthenticationFailedException; -import com.fsck.k9.mail.CertificateValidationException; -import com.fsck.k9.mail.ConnectionSecurity; -import com.fsck.k9.mail.K9MailLib; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.filter.Base64; -import com.fsck.k9.mail.filter.PeekableInputStream; -import com.fsck.k9.mail.oauth.OAuth2TokenProvider; -import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser; -import com.fsck.k9.mail.ssl.TrustedSocketFactory; -import com.fsck.k9.mail.store.imap.IdGrouper.GroupedIds; -import com.fsck.k9.sasl.OAuthBearer; -import com.jcraft.jzlib.JZlib; -import com.jcraft.jzlib.ZOutputStream; -import javax.net.ssl.SSLException; -import org.apache.commons.io.IOUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import static com.fsck.k9.mail.ConnectionSecurity.STARTTLS_REQUIRED; -import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_IMAP; -import static com.fsck.k9.mail.NetworkTimeouts.SOCKET_CONNECT_TIMEOUT; -import static com.fsck.k9.mail.NetworkTimeouts.SOCKET_READ_TIMEOUT; -import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; - - -/** - * A cacheable class that stores the details for a single IMAP connection. - */ -class RealImapConnection implements ImapConnection { - private static final int BUFFER_SIZE = 1024; - - /* The below limits are 20 octets less than the recommended limits, in order to compensate for - * the length of the command tag, the space after the tag and the CRLF at the end of the command - * (these are not taken into account when calculating the length of the command). For more - * information, refer to section 4 of RFC 7162. - * - * The length limit for servers supporting the CONDSTORE extension is large in order to support - * the QRESYNC parameter to the SELECT/EXAMINE commands, which accept a list of known message - * sequence numbers as well as their corresponding UIDs. - */ - private static final int LENGTH_LIMIT_WITHOUT_CONDSTORE = 980; - private static final int LENGTH_LIMIT_WITH_CONDSTORE = 8172; - - - private final OAuth2TokenProvider oauthTokenProvider; - private final TrustedSocketFactory socketFactory; - private final int socketConnectTimeout; - private final int socketReadTimeout; - private final int connectionGeneration; - - private Socket socket; - private PeekableInputStream inputStream; - private OutputStream outputStream; - private ImapResponseParser responseParser; - private int nextCommandTag; - private Set capabilities = new HashSet<>(); - private ImapSettings settings; - private Exception stacktraceForClose; - private boolean open = false; - private boolean retryOAuthWithNewToken = true; - - - public RealImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory, - OAuth2TokenProvider oauthTokenProvider, int connectionGeneration) { - this.settings = settings; - this.socketFactory = socketFactory; - this.oauthTokenProvider = oauthTokenProvider; - this.socketConnectTimeout = SOCKET_CONNECT_TIMEOUT; - this.socketReadTimeout = SOCKET_READ_TIMEOUT; - this.connectionGeneration = connectionGeneration; - } - - public RealImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory, - OAuth2TokenProvider oauthTokenProvider, int socketConnectTimeout, int socketReadTimeout, - int connectionGeneration) { - this.settings = settings; - this.socketFactory = socketFactory; - this.oauthTokenProvider = oauthTokenProvider; - this.socketConnectTimeout = socketConnectTimeout; - this.socketReadTimeout = socketReadTimeout; - this.connectionGeneration = connectionGeneration; - } - - @Override - public synchronized void open() throws IOException, MessagingException { - if (open) { - return; - } else if (stacktraceForClose != null) { - throw new IllegalStateException("open() called after close(). " + - "Check wrapped exception to see where close() was called.", stacktraceForClose); - } - - open = true; - boolean authSuccess = false; - nextCommandTag = 1; - - adjustDNSCacheTTL(); - - try { - socket = connect(); - configureSocket(); - setUpStreamsAndParserFromSocket(); - - readInitialResponse(); - requestCapabilitiesIfNecessary(); - - upgradeToTlsIfNecessary(); - - List responses = authenticate(); - authSuccess = true; - - extractOrRequestCapabilities(responses); - - enableCompressionIfRequested(); - - retrievePathPrefixIfNecessary(); - retrievePathDelimiterIfNecessary(); - - } catch (SSLException e) { - handleSslException(e); - } catch (ConnectException e) { - handleConnectException(e); - } catch (GeneralSecurityException e) { - throw new MessagingException("Unable to open connection to IMAP server due to security error.", e); - } finally { - if (!authSuccess) { - Timber.e("Failed to login, closing connection for %s", getLogId()); - close(); - } - } - } - - private void handleSslException(SSLException e) throws CertificateValidationException, SSLException { - if (e.getCause() instanceof CertificateException) { - throw new CertificateValidationException(e.getMessage(), e); - } else { - throw e; - } - } - - private void handleConnectException(ConnectException e) throws ConnectException { - String message = e.getMessage(); - String[] tokens = message.split("-"); - - if (tokens.length > 1 && tokens[1] != null) { - Timber.e(e, "Stripping host/port from ConnectionException for %s", getLogId()); - throw new ConnectException(tokens[1].trim()); - } else { - throw e; - } - } - - @Override - public synchronized boolean isConnected() { - return inputStream != null && outputStream != null && socket != null && - socket.isConnected() && !socket.isClosed(); - } - - private void adjustDNSCacheTTL() { - try { - Security.setProperty("networkaddress.cache.ttl", "0"); - } catch (Exception e) { - Timber.w(e, "Could not set DNS ttl to 0 for %s", getLogId()); - } - - try { - Security.setProperty("networkaddress.cache.negative.ttl", "0"); - } catch (Exception e) { - Timber.w(e, "Could not set DNS negative ttl to 0 for %s", getLogId()); - } - } - - private Socket connect() throws GeneralSecurityException, MessagingException, IOException { - Exception connectException = null; - - InetAddress[] inetAddresses = InetAddress.getAllByName(settings.getHost()); - for (InetAddress address : inetAddresses) { - try { - return connectToAddress(address); - } catch (IOException e) { - Timber.w(e, "Could not connect to %s", address); - connectException = e; - } - } - - throw new MessagingException("Cannot connect to host", connectException); - } - - private Socket connectToAddress(InetAddress address) throws NoSuchAlgorithmException, KeyManagementException, - MessagingException, IOException { - - String host = settings.getHost(); - int port = settings.getPort(); - String clientCertificateAlias = settings.getClientCertificateAlias(); - - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { - Timber.d("Connecting to %s as %s", host, address); - } - - SocketAddress socketAddress = new InetSocketAddress(address, port); - - Socket socket; - if (settings.getConnectionSecurity() == ConnectionSecurity.SSL_TLS_REQUIRED) { - socket = socketFactory.createSocket(null, host, port, clientCertificateAlias); - } else { - socket = new Socket(); - } - - socket.connect(socketAddress, socketConnectTimeout); - - return socket; - } - - private void configureSocket() throws SocketException { - setSocketDefaultReadTimeout(); - } - - @Override - public void setSocketDefaultReadTimeout() throws SocketException { - setSocketReadTimeout(socketReadTimeout); - } - - @Override - public synchronized void setSocketReadTimeout(int timeout) throws SocketException { - if (socket != null) { - socket.setSoTimeout(timeout); - } - } - - private void setUpStreamsAndParserFromSocket() throws IOException { - setUpStreamsAndParser(socket.getInputStream(), socket.getOutputStream()); - } - - private void setUpStreamsAndParser(InputStream input, OutputStream output) { - inputStream = new PeekableInputStream(new BufferedInputStream(input, BUFFER_SIZE)); - responseParser = new ImapResponseParser(inputStream); - outputStream = new BufferedOutputStream(output, BUFFER_SIZE); - } - - private void readInitialResponse() throws IOException { - ImapResponse initialResponse = responseParser.readResponse(); - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { - Timber.v("%s <<< %s", getLogId(), initialResponse); - } - extractCapabilities(Collections.singletonList(initialResponse)); - } - - private boolean extractCapabilities(List responses) { - CapabilityResponse capabilityResponse = CapabilityResponse.parse(responses); - if (capabilityResponse == null) { - return false; - } - - Set receivedCapabilities = capabilityResponse.getCapabilities(); - Timber.d("Saving %s capabilities for %s", receivedCapabilities, getLogId()); - capabilities = receivedCapabilities; - - return true; - } - - private void extractOrRequestCapabilities(List responses) throws IOException, MessagingException { - if (!extractCapabilities(responses)) { - Timber.i("Did not get capabilities in post-auth banner, requesting CAPABILITY for %s", getLogId()); - requestCapabilities(); - } - } - - private void requestCapabilitiesIfNecessary() throws IOException, MessagingException { - if (!capabilities.isEmpty()) { - return; - } - if (K9MailLib.isDebug()) { - Timber.i("Did not get capabilities in banner, requesting CAPABILITY for %s", getLogId()); - } - requestCapabilities(); - } - - private void requestCapabilities() throws IOException, MessagingException { - if (!extractCapabilities(executeSimpleCommand(Commands.CAPABILITY))) { - throw new MessagingException("Invalid CAPABILITY response received"); - } - } - - private void upgradeToTlsIfNecessary() throws IOException, MessagingException, GeneralSecurityException { - if (settings.getConnectionSecurity() == STARTTLS_REQUIRED) { - upgradeToTls(); - } - } - - private void upgradeToTls() throws IOException, MessagingException, GeneralSecurityException { - if (!hasCapability(Capabilities.STARTTLS)) { - /* - * This exception triggers a "Certificate error" - * notification that takes the user to the incoming - * server settings for review. This might be needed if - * the account was configured with an obsolete - * "STARTTLS (if available)" setting. - */ - throw new CertificateValidationException("STARTTLS connection security not available"); - } - - startTLS(); - } - - private void startTLS() throws IOException, MessagingException, GeneralSecurityException { - executeSimpleCommand(Commands.STARTTLS); - - String host = settings.getHost(); - int port = settings.getPort(); - String clientCertificateAlias = settings.getClientCertificateAlias(); - - socket = socketFactory.createSocket(socket, host, port, clientCertificateAlias); - configureSocket(); - setUpStreamsAndParserFromSocket(); - - // Per RFC 2595 (3.1): Once TLS has been started, reissue CAPABILITY command - if (K9MailLib.isDebug()) { - Timber.i("Updating capabilities after STARTTLS for %s", getLogId()); - } - - requestCapabilities(); - } - - private List authenticate() throws MessagingException, IOException { - switch (settings.getAuthType()) { - case XOAUTH2: - if (oauthTokenProvider == null) { - throw new MessagingException("No OAuthToken Provider available."); - } else if (!hasCapability(Capabilities.SASL_IR)) { - throw new MessagingException("SASL-IR capability is missing."); - } else if (hasCapability(Capabilities.AUTH_OAUTHBEARER)) { - return authWithOAuthToken(OAuthMethod.OAUTHBEARER); - } else if (hasCapability(Capabilities.AUTH_XOAUTH2)) { - return authWithOAuthToken(OAuthMethod.XOAUTH2); - } else { - throw new MessagingException("Server doesn't support SASL OAUTHBEARER or XOAUTH2."); - } - case CRAM_MD5: { - if (hasCapability(Capabilities.AUTH_CRAM_MD5)) { - return authCramMD5(); - } else { - throw new MessagingException("Server doesn't support encrypted passwords using CRAM-MD5."); - } - } - case PLAIN: { - if (hasCapability(Capabilities.AUTH_PLAIN)) { - return saslAuthPlainWithLoginFallback(); - } else if (!hasCapability(Capabilities.LOGINDISABLED)) { - return login(); - } else { - throw new MessagingException("Server doesn't support unencrypted passwords using AUTH=PLAIN " + - "and LOGIN is disabled."); - } - } - case EXTERNAL: { - if (hasCapability(Capabilities.AUTH_EXTERNAL)) { - return saslAuthExternal(); - } else { - // Provide notification to user of a problem authenticating using client certificates - throw new CertificateValidationException(CertificateValidationException.Reason.MissingCapability); - } - } - default: { - throw new MessagingException("Unhandled authentication method found in the server settings (bug)."); - } - } - } - - private List authWithOAuthToken(OAuthMethod method) throws IOException, MessagingException { - retryOAuthWithNewToken = true; - try { - return attemptOAuth(method); - } catch (NegativeImapResponseException e) { - //TODO: Check response code so we don't needlessly invalidate the token. - oauthTokenProvider.invalidateToken(); - - if (!retryOAuthWithNewToken) { - throw handlePermanentOAuthFailure(e); - } else { - return handleTemporaryOAuthFailure(method, e); - } - } - } - - private AuthenticationFailedException handlePermanentOAuthFailure(NegativeImapResponseException e) { - Timber.v(e, "Permanent failure during authentication using OAuth token"); - return new AuthenticationFailedException(e.getMessage(), e, e.getAlertText()); - } - - private List handleTemporaryOAuthFailure(OAuthMethod method, NegativeImapResponseException e) - throws IOException, MessagingException { - //We got a response indicating a retry might succeed after token refresh - //We could avoid this if we had a reasonable chance of knowing - //if a token was invalid before use (e.g. due to expiry). But we don't - //This is the intended behaviour per AccountManager - - Timber.v(e, "Temporary failure - retrying with new token"); - try { - return attemptOAuth(method); - } catch (NegativeImapResponseException e2) { - //Okay, we failed on a new token. - //Invalidate the token anyway but assume it's permanent. - Timber.v(e, "Authentication exception for new token, permanent error assumed"); - oauthTokenProvider.invalidateToken(); - throw handlePermanentOAuthFailure(e2); - } - } - - private List attemptOAuth(OAuthMethod method) throws MessagingException, IOException { - String token = oauthTokenProvider.getToken(settings.getUsername(), OAuth2TokenProvider.OAUTH2_TIMEOUT); - String authString = method.buildInitialClientResponse(settings.getUsername(), token); - String tag = sendSaslIrCommand(method.getCommand(), authString, true); - - return responseParser.readStatusResponse(tag, method.getCommand(), getLogId(), - new UntaggedHandler() { - @Override - public void handleAsyncUntaggedResponse(ImapResponse response) throws IOException { - handleOAuthUntaggedResponse(response); - } - }); - } - - private void handleOAuthUntaggedResponse(ImapResponse response) throws IOException { - if (!response.isContinuationRequested()) { - return; - } - - if (response.isString(0)) { - retryOAuthWithNewToken = XOAuth2ChallengeParser.shouldRetry(response.getString(0), settings.getHost()); - } - - outputStream.write("\r\n".getBytes()); - outputStream.flush(); - } - - private List authCramMD5() throws MessagingException, IOException { - String command = Commands.AUTHENTICATE_CRAM_MD5; - String tag = sendCommand(command, false); - - ImapResponse response = readContinuationResponse(tag); - if (response.size() != 1 || !(response.get(0) instanceof String)) { - throw new MessagingException("Invalid Cram-MD5 nonce received"); - } - - byte[] b64Nonce = response.getString(0).getBytes(); - byte[] b64CRAM = Authentication.computeCramMd5Bytes(settings.getUsername(), settings.getPassword(), b64Nonce); - - outputStream.write(b64CRAM); - outputStream.write('\r'); - outputStream.write('\n'); - outputStream.flush(); - - try { - return responseParser.readStatusResponse(tag, command, getLogId(), null); - } catch (NegativeImapResponseException e) { - throw handleAuthenticationFailure(e); - } - } - - private List saslAuthPlainWithLoginFallback() throws IOException, MessagingException { - try { - return saslAuthPlain(); - } catch (AuthenticationFailedException e) { - if (!isConnected()) { - throw e; - } - - return login(); - } - } - - private List saslAuthPlain() throws IOException, MessagingException { - String command = Commands.AUTHENTICATE_PLAIN; - String tag = sendCommand(command, false); - - readContinuationResponse(tag); - - String credentials = "\000" + settings.getUsername() + "\000" + settings.getPassword(); - byte[] encodedCredentials = Base64.encodeBase64(credentials.getBytes()); - - outputStream.write(encodedCredentials); - outputStream.write('\r'); - outputStream.write('\n'); - outputStream.flush(); - - try { - return responseParser.readStatusResponse(tag, command, getLogId(), null); - } catch (NegativeImapResponseException e) { - throw handleAuthenticationFailure(e); - } - } - - private List login() throws IOException, MessagingException { - /* - * Use quoted strings which permit spaces and quotes. (Using IMAP - * string literals would be better, but some servers are broken - * and don't parse them correctly.) - */ - - // escape double-quotes and backslash characters with a backslash - Pattern p = Pattern.compile("[\\\\\"]"); - String replacement = "\\\\$0"; - String username = p.matcher(settings.getUsername()).replaceAll(replacement); - String password = p.matcher(settings.getPassword()).replaceAll(replacement); - - try { - String command = String.format(Commands.LOGIN + " \"%s\" \"%s\"", username, password); - return executeSimpleCommand(command, true); - } catch (NegativeImapResponseException e) { - throw handleAuthenticationFailure(e); - } - } - - private List saslAuthExternal() throws IOException, MessagingException { - try { - String command = Commands.AUTHENTICATE_EXTERNAL + " " + Base64.encode(settings.getUsername()); - return executeSimpleCommand(command, false); - } catch (NegativeImapResponseException e) { - /* - * Provide notification to the user of a problem authenticating - * using client certificates. We don't use an - * AuthenticationFailedException because that would trigger a - * "Username or password incorrect" notification in - * AccountSetupCheckSettings. - */ - throw new CertificateValidationException(e.getMessage()); - } - } - - private MessagingException handleAuthenticationFailure(NegativeImapResponseException e) { - ImapResponse lastResponse = e.getLastResponse(); - String responseCode = ResponseCodeExtractor.getResponseCode(lastResponse); - - // If there's no response code we simply assume it was an authentication failure. - if (responseCode == null || responseCode.equals(ResponseCodeExtractor.AUTHENTICATION_FAILED)) { - if (e.wasByeResponseReceived()) { - close(); - } - - return new AuthenticationFailedException(e.getMessage()); - } else { - close(); - return e; - } - } - - private void enableCompressionIfRequested() throws IOException, MessagingException { - if (hasCapability(Capabilities.COMPRESS_DEFLATE) && settings.useCompression()) { - enableCompression(); - } - } - - private void enableCompression() throws IOException, MessagingException { - try { - executeSimpleCommand(Commands.COMPRESS_DEFLATE); - } catch (NegativeImapResponseException e) { - Timber.d(e, "Unable to negotiate compression: "); - return; - } - - try { - InflaterInputStream input = new InflaterInputStream(socket.getInputStream(), new Inflater(true)); - ZOutputStream output = new ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true); - output.setFlushMode(JZlib.Z_PARTIAL_FLUSH); - - setUpStreamsAndParser(input, output); - - if (K9MailLib.isDebug()) { - Timber.i("Compression enabled for %s", getLogId()); - } - } catch (IOException e) { - close(); - Timber.e(e, "Error enabling compression"); - } - } - - private void retrievePathPrefixIfNecessary() throws IOException, MessagingException { - if (settings.getPathPrefix() != null) { - return; - } - - if (hasCapability(Capabilities.NAMESPACE)) { - if (K9MailLib.isDebug()) { - Timber.i("pathPrefix is unset and server has NAMESPACE capability"); - } - handleNamespace(); - } else { - if (K9MailLib.isDebug()) { - Timber.i("pathPrefix is unset but server does not have NAMESPACE capability"); - } - settings.setPathPrefix(""); - } - } - - private void handleNamespace() throws IOException, MessagingException { - List responses = executeSimpleCommand(Commands.NAMESPACE); - - NamespaceResponse namespaceResponse = NamespaceResponse.parse(responses); - if (namespaceResponse != null) { - String prefix = namespaceResponse.getPrefix(); - String hierarchyDelimiter = namespaceResponse.getHierarchyDelimiter(); - - settings.setPathPrefix(prefix); - settings.setPathDelimiter(hierarchyDelimiter); - settings.setCombinedPrefix(null); - - if (K9MailLib.isDebug()) { - Timber.d("Got path '%s' and separator '%s'", prefix, hierarchyDelimiter); - } - } - } - - private void retrievePathDelimiterIfNecessary() throws IOException, MessagingException { - if (settings.getPathDelimiter() == null) { - retrievePathDelimiter(); - } - } - - private void retrievePathDelimiter() throws IOException, MessagingException { - List listResponses; - try { - listResponses = executeSimpleCommand(Commands.LIST + " \"\" \"\""); - } catch (NegativeImapResponseException e) { - Timber.d(e, "Error getting path delimiter using LIST command"); - return; - } - - for (ImapResponse response : listResponses) { - if (isListResponse(response)) { - String hierarchyDelimiter = response.getString(2); - settings.setPathDelimiter(hierarchyDelimiter); - settings.setCombinedPrefix(null); - - if (K9MailLib.isDebug()) { - Timber.d("Got path delimiter '%s' for %s", settings.getPathDelimiter(), getLogId()); - } - - break; - } - } - } - - private boolean isListResponse(ImapResponse response) { - boolean responseTooShort = response.size() < 4; - if (responseTooShort) { - return false; - } - - boolean isListResponse = equalsIgnoreCase(response.get(0), Responses.LIST); - boolean hierarchyDelimiterValid = response.get(2) instanceof String; - - return isListResponse && hierarchyDelimiterValid; - } - - @Override - public boolean hasCapability(@NotNull String capability) throws IOException, MessagingException { - if (!open) { - open(); - } - - return capabilities.contains(capability.toUpperCase(Locale.US)); - } - - public boolean isCondstoreCapable() throws IOException, MessagingException { - return hasCapability(Capabilities.CONDSTORE); - } - - @Override - public boolean isIdleCapable() { - if (K9MailLib.isDebug()) { - Timber.v("Connection %s has %d capabilities", getLogId(), capabilities.size()); - } - - return capabilities.contains(Capabilities.IDLE); - } - - @Override - public boolean isUidPlusCapable() { - return capabilities.contains(Capabilities.UID_PLUS); - } - - @Override - public synchronized void close() { - if (!open) { - return; - } - - open = false; - stacktraceForClose = new Exception(); - - IOUtils.closeQuietly(inputStream); - IOUtils.closeQuietly(outputStream); - IOUtils.closeQuietly(socket); - - inputStream = null; - outputStream = null; - socket = null; - } - - @Override - @NotNull - public synchronized OutputStream getOutputStream() { - return outputStream; - } - - @Override - @NotNull - public String getLogId() { - return "conn" + hashCode(); - } - - @Override - @NotNull - public synchronized List executeSimpleCommand(@NotNull String command) - throws IOException, MessagingException { - return executeSimpleCommand(command, false); - } - - public List executeSimpleCommand(String command, boolean sensitive) throws IOException, - MessagingException { - String commandToLog = command; - - if (sensitive && !K9MailLib.isDebugSensitive()) { - commandToLog = "*sensitive*"; - } - - String tag = sendCommand(command, sensitive); - - try { - return responseParser.readStatusResponse(tag, commandToLog, getLogId(), null); - } catch (IOException e) { - close(); - throw e; - } - } - - @Override - @NotNull - public synchronized List executeCommandWithIdSet(@NotNull String commandPrefix, - @NotNull String commandSuffix, @NotNull Set ids) throws IOException, MessagingException { - - GroupedIds groupedIds = IdGrouper.groupIds(ids); - List splitCommands = ImapCommandSplitter.splitCommand( - commandPrefix, commandSuffix, groupedIds, getLineLengthLimit()); - - List responses = new ArrayList<>(); - for (String splitCommand : splitCommands) { - responses.addAll(executeSimpleCommand(splitCommand)); - } - - return responses; - } - - public String sendSaslIrCommand(String command, String initialClientResponse, boolean sensitive) - throws IOException, MessagingException { - try { - open(); - - String tag = Integer.toString(nextCommandTag++); - String commandToSend = tag + " " + command + " " + initialClientResponse + "\r\n"; - outputStream.write(commandToSend.getBytes()); - outputStream.flush(); - - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { - if (sensitive && !K9MailLib.isDebugSensitive()) { - Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", getLogId()); - } else { - Timber.v("%s>>> %s %s %s", getLogId(), tag, command, initialClientResponse); - } - } - - return tag; - } catch (IOException | MessagingException e) { - close(); - throw e; - } - } - - @Override - @NotNull - public synchronized String sendCommand(@NotNull String command, boolean sensitive) - throws MessagingException, IOException { - try { - open(); - - String tag = Integer.toString(nextCommandTag++); - String commandToSend = tag + " " + command + "\r\n"; - outputStream.write(commandToSend.getBytes()); - outputStream.flush(); - - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { - if (sensitive && !K9MailLib.isDebugSensitive()) { - Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", getLogId()); - } else { - Timber.v("%s>>> %s %s", getLogId(), tag, command); - } - } - - return tag; - } catch (IOException | MessagingException e) { - close(); - throw e; - } - } - - @Override - public synchronized void sendContinuation(@NotNull String continuation) throws IOException { - outputStream.write(continuation.getBytes()); - outputStream.write('\r'); - outputStream.write('\n'); - outputStream.flush(); - - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { - Timber.v("%s>>> %s", getLogId(), continuation); - } - } - - @Override - @NotNull - public ImapResponse readResponse() throws IOException { - return readResponse(null); - } - - @Override - @NotNull - public ImapResponse readResponse(@Nullable ImapResponseCallback callback) throws IOException { - try { - ImapResponse response = responseParser.readResponse(callback); - - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { - Timber.v("%s<<<%s", getLogId(), response); - } - - return response; - } catch (IOException e) { - close(); - throw e; - } - } - - private ImapResponse readContinuationResponse(String tag) throws IOException, MessagingException { - ImapResponse response; - do { - response = readResponse(); - - String responseTag = response.getTag(); - if (responseTag != null) { - if (responseTag.equalsIgnoreCase(tag)) { - throw new MessagingException("Command continuation aborted: " + response); - } else { - Timber.w("After sending tag %s, got tag response from previous command %s for %s", - tag, response, getLogId()); - } - } - } while (!response.isContinuationRequested()); - - return response; - } - - int getLineLengthLimit() throws IOException, MessagingException { - return isCondstoreCapable() ? LENGTH_LIMIT_WITH_CONDSTORE : LENGTH_LIMIT_WITHOUT_CONDSTORE; - } - - @Override - public int getConnectionGeneration() { - return connectionGeneration; - } - - - private enum OAuthMethod { - XOAUTH2 { - @Override - String getCommand() { - return Commands.AUTHENTICATE_XOAUTH2; - } - - @Override - String buildInitialClientResponse(String username, String token) { - return Authentication.computeXoauth(username, token); - } - }, - OAUTHBEARER { - @Override - String getCommand() { - return Commands.AUTHENTICATE_OAUTHBEARER; - } - - @Override - String buildInitialClientResponse(String username, String token) { - return OAuthBearer.buildOAuthBearerInitialClientResponse(username, token); - } - }; - - abstract String getCommand(); - abstract String buildInitialClientResponse(String username, String token); - } -} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt new file mode 100644 index 0000000000000000000000000000000000000000..657f585c16c9d102a6a7c3ebaa3e551161c40b85 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt @@ -0,0 +1,881 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.logging.Timber +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.Authentication +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.CertificateValidationException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.K9MailLib +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.NetworkTimeouts.SOCKET_CONNECT_TIMEOUT +import com.fsck.k9.mail.NetworkTimeouts.SOCKET_READ_TIMEOUT +import com.fsck.k9.mail.filter.Base64 +import com.fsck.k9.mail.filter.PeekableInputStream +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import com.fsck.k9.sasl.buildOAuthBearerInitialClientResponse +import com.jcraft.jzlib.JZlib +import com.jcraft.jzlib.ZOutputStream +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketAddress +import java.security.GeneralSecurityException +import java.security.Security +import java.security.cert.CertificateException +import java.util.regex.Pattern +import java.util.zip.Inflater +import java.util.zip.InflaterInputStream +import javax.net.ssl.SSLException +import org.apache.commons.io.IOUtils + +/** + * A cacheable class that stores the details for a single IMAP connection. + */ +internal class RealImapConnection( + private val settings: ImapSettings, + private val socketFactory: TrustedSocketFactory, + private val oauthTokenProvider: OAuth2TokenProvider?, + override val connectionGeneration: Int, + private val socketConnectTimeout: Int = SOCKET_CONNECT_TIMEOUT, + private val socketReadTimeout: Int = SOCKET_READ_TIMEOUT +) : ImapConnection { + private var socket: Socket? = null + private var inputStream: PeekableInputStream? = null + private var imapOutputStream: OutputStream? = null + private var responseParser: ImapResponseParser? = null + private var nextCommandTag = 0 + private var capabilities = emptySet() + private var stacktraceForClose: Exception? = null + private var open = false + private var retryOAuthWithNewToken = true + + @get:Synchronized + override val outputStream: OutputStream + get() = checkNotNull(imapOutputStream) + + @Synchronized + @Throws(IOException::class, MessagingException::class) + override fun open() { + if (open) { + return + } else if (stacktraceForClose != null) { + throw IllegalStateException( + "open() called after close(). Check wrapped exception to see where close() was called.", + stacktraceForClose + ) + } + + open = true + var authSuccess = false + nextCommandTag = 1 + + adjustDNSCacheTTL() + + try { + socket = connect() + configureSocket() + setUpStreamsAndParserFromSocket() + + readInitialResponse() + requestCapabilitiesIfNecessary() + + upgradeToTlsIfNecessary() + + val responses = authenticate() + authSuccess = true + + extractOrRequestCapabilities(responses) + + enableCompressionIfRequested() + + retrievePathPrefixIfNecessary() + retrievePathDelimiterIfNecessary() + } catch (e: SSLException) { + handleSslException(e) + } catch (e: GeneralSecurityException) { + throw MessagingException("Unable to open connection to IMAP server due to security error.", e) + } finally { + if (!authSuccess) { + Timber.e("Failed to login, closing connection for %s", logId) + close() + } + } + } + + private fun handleSslException(e: SSLException) { + if (e.cause is CertificateException) { + throw CertificateValidationException(e.message, e) + } else { + throw e + } + } + + @get:Synchronized + override val isConnected: Boolean + get() { + return inputStream != null && imapOutputStream != null && + socket.let { socket -> + socket != null && socket.isConnected && !socket.isClosed + } + } + + private fun adjustDNSCacheTTL() { + try { + Security.setProperty("networkaddress.cache.ttl", "0") + } catch (e: Exception) { + Timber.w(e, "Could not set DNS ttl to 0 for %s", logId) + } + + try { + Security.setProperty("networkaddress.cache.negative.ttl", "0") + } catch (e: Exception) { + Timber.w(e, "Could not set DNS negative ttl to 0 for %s", logId) + } + } + + private fun connect(): Socket { + val inetAddresses = InetAddress.getAllByName(settings.host) + + var connectException: Exception? = null + for (address in inetAddresses) { + connectException = try { + return connectToAddress(address) + } catch (e: IOException) { + Timber.w(e, "Could not connect to %s", address) + e + } + } + + throw MessagingException("Cannot connect to host", connectException) + } + + private fun connectToAddress(address: InetAddress): Socket { + val host = settings.host + val port = settings.port + val clientCertificateAlias = settings.clientCertificateAlias + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + Timber.d("Connecting to %s as %s", host, address) + } + + val socketAddress: SocketAddress = InetSocketAddress(address, port) + val socket = if (settings.connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) { + socketFactory.createSocket(null, host, port, clientCertificateAlias) + } else { + Socket() + } + + socket.connect(socketAddress, socketConnectTimeout) + + return socket + } + + private fun configureSocket() { + setSocketDefaultReadTimeout() + } + + override fun setSocketDefaultReadTimeout() { + setSocketReadTimeout(socketReadTimeout) + } + + @Synchronized + override fun setSocketReadTimeout(timeout: Int) { + socket?.soTimeout = timeout + } + + private fun setUpStreamsAndParserFromSocket() { + val socket = checkNotNull(socket) + + setUpStreamsAndParser(socket.getInputStream(), socket.getOutputStream()) + } + + private fun setUpStreamsAndParser(input: InputStream, output: OutputStream) { + inputStream = PeekableInputStream(BufferedInputStream(input, BUFFER_SIZE)) + responseParser = ImapResponseParser(inputStream) + imapOutputStream = BufferedOutputStream(output, BUFFER_SIZE) + } + + private fun readInitialResponse() { + val responseParser = checkNotNull(responseParser) + + val initialResponse = responseParser.readResponse() + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + Timber.v("%s <<< %s", logId, initialResponse) + } + + extractCapabilities(listOf(initialResponse)) + } + + private fun extractCapabilities(responses: List): Boolean { + val capabilityResponse = CapabilityResponse.parse(responses) ?: return false + val receivedCapabilities = capabilityResponse.capabilities + + Timber.d("Saving %s capabilities for %s", receivedCapabilities, logId) + capabilities = receivedCapabilities + + return true + } + + private fun extractOrRequestCapabilities(responses: List) { + if (!extractCapabilities(responses)) { + Timber.i("Did not get capabilities in post-auth banner, requesting CAPABILITY for %s", logId) + requestCapabilities() + } + } + + private fun requestCapabilitiesIfNecessary() { + if (capabilities.isNotEmpty()) return + + if (K9MailLib.isDebug()) { + Timber.i("Did not get capabilities in banner, requesting CAPABILITY for %s", logId) + } + + requestCapabilities() + } + + private fun requestCapabilities() { + val responses = executeSimpleCommand(Commands.CAPABILITY) + + if (!extractCapabilities(responses)) { + throw MessagingException("Invalid CAPABILITY response received") + } + } + + private fun upgradeToTlsIfNecessary() { + if (settings.connectionSecurity == ConnectionSecurity.STARTTLS_REQUIRED) { + upgradeToTls() + } + } + + private fun upgradeToTls() { + if (!hasCapability(Capabilities.STARTTLS)) { + /* + * This exception triggers a "Certificate error" + * notification that takes the user to the incoming + * server settings for review. This might be needed if + * the account was configured with an obsolete + * "STARTTLS (if available)" setting. + */ + throw CertificateValidationException("STARTTLS connection security not available") + } + + startTls() + } + + private fun startTls() { + executeSimpleCommand(Commands.STARTTLS) + + val host = settings.host + val port = settings.port + val clientCertificateAlias = settings.clientCertificateAlias + socket = socketFactory.createSocket(socket, host, port, clientCertificateAlias) + + configureSocket() + setUpStreamsAndParserFromSocket() + + // Per RFC 2595 (3.1): Once TLS has been started, reissue CAPABILITY command + if (K9MailLib.isDebug()) { + Timber.i("Updating capabilities after STARTTLS for %s", logId) + } + + requestCapabilities() + } + + private fun authenticate(): List { + return when (settings.authType) { + AuthType.XOAUTH2 -> { + if (oauthTokenProvider == null) { + throw MessagingException("No OAuthToken Provider available.") + } else if (!hasCapability(Capabilities.SASL_IR)) { + throw MessagingException("SASL-IR capability is missing.") + } else if (hasCapability(Capabilities.AUTH_OAUTHBEARER)) { + authWithOAuthToken(OAuthMethod.OAUTHBEARER) + } else if (hasCapability(Capabilities.AUTH_XOAUTH2)) { + authWithOAuthToken(OAuthMethod.XOAUTH2) + } else { + throw MessagingException("Server doesn't support SASL OAUTHBEARER or XOAUTH2.") + } + } + AuthType.CRAM_MD5 -> { + if (hasCapability(Capabilities.AUTH_CRAM_MD5)) { + authCramMD5() + } else { + throw MessagingException("Server doesn't support encrypted passwords using CRAM-MD5.") + } + } + AuthType.PLAIN -> { + if (hasCapability(Capabilities.AUTH_PLAIN)) { + saslAuthPlainWithLoginFallback() + } else if (!hasCapability(Capabilities.LOGINDISABLED)) { + login() + } else { + throw MessagingException( + "Server doesn't support unencrypted passwords using AUTH=PLAIN and LOGIN is disabled." + ) + } + } + AuthType.EXTERNAL -> { + if (hasCapability(Capabilities.AUTH_EXTERNAL)) { + saslAuthExternal() + } else { + // Provide notification to user of a problem authenticating using client certificates + throw CertificateValidationException(CertificateValidationException.Reason.MissingCapability) + } + } + else -> { + throw MessagingException("Unhandled authentication method found in the server settings (bug).") + } + } + } + + private fun authWithOAuthToken(method: OAuthMethod): List { + val oauthTokenProvider = checkNotNull(oauthTokenProvider) + retryOAuthWithNewToken = true + + return try { + attemptOAuth(method) + } catch (e: NegativeImapResponseException) { + // TODO: Check response code so we don't needlessly invalidate the token. + oauthTokenProvider.invalidateToken() + + if (!retryOAuthWithNewToken) { + throw handlePermanentOAuthFailure(e) + } else { + handleTemporaryOAuthFailure(method, e) + } + } + } + + private fun handlePermanentOAuthFailure(e: NegativeImapResponseException): AuthenticationFailedException { + Timber.v(e, "Permanent failure during authentication using OAuth token") + + return AuthenticationFailedException(message = e.message!!, throwable = e, messageFromServer = e.alertText) + } + + private fun handleTemporaryOAuthFailure(method: OAuthMethod, e: NegativeImapResponseException): List { + val oauthTokenProvider = checkNotNull(oauthTokenProvider) + + // We got a response indicating a retry might succeed after token refresh + // We could avoid this if we had a reasonable chance of knowing + // if a token was invalid before use (e.g. due to expiry). But we don't + // This is the intended behaviour per AccountManager + Timber.v(e, "Temporary failure - retrying with new token") + + return try { + attemptOAuth(method) + } catch (e2: NegativeImapResponseException) { + // Okay, we failed on a new token. + // Invalidate the token anyway but assume it's permanent. + Timber.v(e, "Authentication exception for new token, permanent error assumed") + + oauthTokenProvider.invalidateToken() + + throw handlePermanentOAuthFailure(e2) + } + } + + private fun attemptOAuth(method: OAuthMethod): List { + val oauthTokenProvider = checkNotNull(oauthTokenProvider) + val responseParser = checkNotNull(responseParser) + + val token = oauthTokenProvider.getToken(settings.username, OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong()) + + val authString = method.buildInitialClientResponse(settings.username, token) + val tag = sendSaslIrCommand(method.command, authString, true) + + return responseParser.readStatusResponse(tag, method.command, logId, ::handleOAuthUntaggedResponse) + } + + private fun handleOAuthUntaggedResponse(response: ImapResponse) { + if (!response.isContinuationRequested) return + + val imapOutputStream = checkNotNull(imapOutputStream) + + if (response.isString(0)) { + retryOAuthWithNewToken = XOAuth2ChallengeParser.shouldRetry(response.getString(0), settings.host) + } + + imapOutputStream.write('\r'.code) + imapOutputStream.write('\n'.code) + imapOutputStream.flush() + } + + private fun authCramMD5(): List { + val command = Commands.AUTHENTICATE_CRAM_MD5 + val tag = sendCommand(command, false) + + val imapOutputStream = checkNotNull(imapOutputStream) + val responseParser = checkNotNull(responseParser) + + val response = readContinuationResponse(tag) + if (response.size != 1 || !response.isString(0)) { + throw MessagingException("Invalid Cram-MD5 nonce received") + } + + val b64Nonce = response.getString(0).toByteArray() + val b64CRAM = Authentication.computeCramMd5Bytes(settings.username, settings.password, b64Nonce) + + imapOutputStream.write(b64CRAM) + imapOutputStream.write('\r'.code) + imapOutputStream.write('\n'.code) + imapOutputStream.flush() + + return try { + responseParser.readStatusResponse(tag, command, logId, null) + } catch (e: NegativeImapResponseException) { + throw handleAuthenticationFailure(e) + } + } + + private fun saslAuthPlainWithLoginFallback(): List { + return try { + saslAuthPlain() + } catch (e: AuthenticationFailedException) { + if (!isConnected) { + throw e + } + + login() + } + } + + private fun saslAuthPlain(): List { + val command = Commands.AUTHENTICATE_PLAIN + val tag = sendCommand(command, false) + + val imapOutputStream = checkNotNull(imapOutputStream) + val responseParser = checkNotNull(responseParser) + + readContinuationResponse(tag) + + val credentials = "\u0000" + settings.username + "\u0000" + settings.password + val encodedCredentials = Base64.encodeBase64(credentials.toByteArray()) + + imapOutputStream.write(encodedCredentials) + imapOutputStream.write('\r'.code) + imapOutputStream.write('\n'.code) + imapOutputStream.flush() + + return try { + responseParser.readStatusResponse(tag, command, logId, null) + } catch (e: NegativeImapResponseException) { + throw handleAuthenticationFailure(e) + } + } + + private fun login(): List { + val password = checkNotNull(settings.password) + + /* + * Use quoted strings which permit spaces and quotes. (Using IMAP + * string literals would be better, but some servers are broken + * and don't parse them correctly.) + */ + + // escape double-quotes and backslash characters with a backslash + val pattern = Pattern.compile("[\\\\\"]") + val replacement = "\\\\$0" + val encodedUsername = pattern.matcher(settings.username).replaceAll(replacement) + val encodedPassword = pattern.matcher(password).replaceAll(replacement) + + return try { + val command = String.format(Commands.LOGIN + " \"%s\" \"%s\"", encodedUsername, encodedPassword) + executeSimpleCommand(command, true) + } catch (e: NegativeImapResponseException) { + throw handleAuthenticationFailure(e) + } + } + + private fun saslAuthExternal(): List { + return try { + val command = Commands.AUTHENTICATE_EXTERNAL + " " + Base64.encode(settings.username) + executeSimpleCommand(command, false) + } catch (e: NegativeImapResponseException) { + /* + * Provide notification to the user of a problem authenticating + * using client certificates. We don't use an + * AuthenticationFailedException because that would trigger a + * "Username or password incorrect" notification in + * AccountSetupCheckSettings. + */ + throw CertificateValidationException(e.message) + } + } + + private fun handleAuthenticationFailure( + negativeResponseException: NegativeImapResponseException + ): MessagingException { + val lastResponse = negativeResponseException.lastResponse + val responseCode = ResponseCodeExtractor.getResponseCode(lastResponse) + + // If there's no response code we simply assume it was an authentication failure. + return if (responseCode == null || responseCode == ResponseCodeExtractor.AUTHENTICATION_FAILED) { + if (negativeResponseException.wasByeResponseReceived()) { + close() + } + + AuthenticationFailedException(negativeResponseException.message!!) + } else { + close() + + negativeResponseException + } + } + + private fun enableCompressionIfRequested() { + if (hasCapability(Capabilities.COMPRESS_DEFLATE) && settings.useCompression) { + enableCompression() + } + } + + private fun enableCompression() { + try { + executeSimpleCommand(Commands.COMPRESS_DEFLATE) + } catch (e: NegativeImapResponseException) { + Timber.d(e, "Unable to negotiate compression: ") + return + } + + try { + val socket = checkNotNull(socket) + val input = InflaterInputStream(socket.getInputStream(), Inflater(true)) + val output = ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true) + output.flushMode = JZlib.Z_PARTIAL_FLUSH + + setUpStreamsAndParser(input, output) + + if (K9MailLib.isDebug()) { + Timber.i("Compression enabled for %s", logId) + } + } catch (e: IOException) { + close() + Timber.e(e, "Error enabling compression") + } + } + + private fun retrievePathPrefixIfNecessary() { + if (settings.pathPrefix != null) return + + if (hasCapability(Capabilities.NAMESPACE)) { + if (K9MailLib.isDebug()) { + Timber.i("pathPrefix is unset and server has NAMESPACE capability") + } + + handleNamespace() + } else { + if (K9MailLib.isDebug()) { + Timber.i("pathPrefix is unset but server does not have NAMESPACE capability") + } + + settings.pathPrefix = "" + } + } + + private fun handleNamespace() { + val responses = executeSimpleCommand(Commands.NAMESPACE) + + val namespaceResponse = NamespaceResponse.parse(responses) ?: return + + settings.pathPrefix = namespaceResponse.prefix + settings.pathDelimiter = namespaceResponse.hierarchyDelimiter + settings.setCombinedPrefix(null) + + if (K9MailLib.isDebug()) { + Timber.d("Got path '%s' and separator '%s'", namespaceResponse.prefix, namespaceResponse.hierarchyDelimiter) + } + } + + private fun retrievePathDelimiterIfNecessary() { + if (settings.pathDelimiter == null) { + retrievePathDelimiter() + } + } + + private fun retrievePathDelimiter() { + val listResponses = try { + executeSimpleCommand(Commands.LIST + " \"\" \"\"") + } catch (e: NegativeImapResponseException) { + Timber.d(e, "Error getting path delimiter using LIST command") + return + } + + for (response in listResponses) { + if (isListResponse(response)) { + val hierarchyDelimiter = response.getString(2) + + settings.pathDelimiter = hierarchyDelimiter + settings.setCombinedPrefix(null) + + if (K9MailLib.isDebug()) { + Timber.d("Got path delimiter '%s' for %s", hierarchyDelimiter, logId) + } + + break + } + } + } + + private fun isListResponse(response: ImapResponse): Boolean { + if (response.size < 4) return false + + val isListResponse = ImapResponseParser.equalsIgnoreCase(response[0], Responses.LIST) + val hierarchyDelimiterValid = response.isString(2) + + return isListResponse && hierarchyDelimiterValid + } + + override fun hasCapability(capability: String): Boolean { + if (!open) { + open() + } + + return capabilities.contains(capability.uppercase()) + } + + private val isCondstoreCapable: Boolean + get() = hasCapability(Capabilities.CONDSTORE) + + override val isIdleCapable: Boolean + get() { + if (K9MailLib.isDebug()) { + Timber.v("Connection %s has %d capabilities", logId, capabilities.size) + } + + return capabilities.contains(Capabilities.IDLE) + } + + override val isUidPlusCapable: Boolean + get() = capabilities.contains(Capabilities.UID_PLUS) + + @Synchronized + override fun close() { + if (!open) return + + open = false + + stacktraceForClose = Exception() + + IOUtils.closeQuietly(inputStream) + IOUtils.closeQuietly(imapOutputStream) + IOUtils.closeQuietly(socket) + + inputStream = null + imapOutputStream = null + socket = null + } + + override val logId: String + get() = "conn" + hashCode() + + @Synchronized + @Throws(IOException::class, MessagingException::class) + override fun executeSimpleCommand(command: String): List { + return executeSimpleCommand(command, false) + } + + @Throws(IOException::class, MessagingException::class) + fun executeSimpleCommand(command: String, sensitive: Boolean): List { + var commandToLog = command + if (sensitive && !K9MailLib.isDebugSensitive()) { + commandToLog = "*sensitive*" + } + + val tag = sendCommand(command, sensitive) + + val responseParser = checkNotNull(responseParser) + return try { + responseParser.readStatusResponse(tag, commandToLog, logId, null) + } catch (e: IOException) { + close() + throw e + } + } + + @Synchronized + @Throws(IOException::class, MessagingException::class) + override fun executeCommandWithIdSet( + commandPrefix: String, + commandSuffix: String, + ids: Set + ): List { + val groupedIds = IdGrouper.groupIds(ids) + val splitCommands = ImapCommandSplitter.splitCommand( + commandPrefix, commandSuffix, groupedIds, lineLengthLimit + ) + + return splitCommands.flatMap { splitCommand -> + executeSimpleCommand(splitCommand) + } + } + + @Throws(IOException::class, MessagingException::class) + fun sendSaslIrCommand(command: String, initialClientResponse: String, sensitive: Boolean): String { + try { + open() + + val outputStream = checkNotNull(imapOutputStream) + + val tag = (nextCommandTag++).toString() + val commandToSend = "$tag $command $initialClientResponse\r\n" + + outputStream.write(commandToSend.toByteArray()) + outputStream.flush() + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + if (sensitive && !K9MailLib.isDebugSensitive()) { + Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", logId) + } else { + Timber.v("%s>>> %s %s %s", logId, tag, command, initialClientResponse) + } + } + + return tag + } catch (e: IOException) { + close() + throw e + } catch (e: MessagingException) { + close() + throw e + } + } + + @Synchronized + @Throws(MessagingException::class, IOException::class) + override fun sendCommand(command: String, sensitive: Boolean): String { + try { + open() + + val outputStream = checkNotNull(imapOutputStream) + + val tag = (nextCommandTag++).toString() + val commandToSend = "$tag $command\r\n" + + outputStream.write(commandToSend.toByteArray()) + outputStream.flush() + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + if (sensitive && !K9MailLib.isDebugSensitive()) { + Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", logId) + } else { + Timber.v("%s>>> %s %s", logId, tag, command) + } + } + + return tag + } catch (e: IOException) { + close() + throw e + } catch (e: MessagingException) { + close() + throw e + } + } + + @Synchronized + @Throws(IOException::class) + override fun sendContinuation(continuation: String) { + val outputStream = checkNotNull(imapOutputStream) + + outputStream.write(continuation.toByteArray()) + outputStream.write('\r'.code) + outputStream.write('\n'.code) + outputStream.flush() + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + Timber.v("%s>>> %s", logId, continuation) + } + } + + @Throws(IOException::class) + override fun readResponse(): ImapResponse { + return readResponse(null) + } + + @Throws(IOException::class) + override fun readResponse(callback: ImapResponseCallback?): ImapResponse { + try { + val responseParser = checkNotNull(responseParser) + + val response = responseParser.readResponse(callback) + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + Timber.v("%s<<<%s", logId, response) + } + + return response + } catch (e: IOException) { + close() + throw e + } + } + + private fun readContinuationResponse(tag: String): ImapResponse { + var response: ImapResponse + do { + response = readResponse() + + val responseTag = response.tag + if (responseTag != null) { + if (responseTag.equals(tag, ignoreCase = true)) { + throw MessagingException("Command continuation aborted: $response") + } else { + Timber.w( + "After sending tag %s, got tag response from previous command %s for %s", + tag, response, logId + ) + } + } + } while (!response.isContinuationRequested) + + return response + } + + @get:Throws(IOException::class, MessagingException::class) + val lineLengthLimit: Int + get() = if (isCondstoreCapable) LENGTH_LIMIT_WITH_CONDSTORE else LENGTH_LIMIT_WITHOUT_CONDSTORE + + private enum class OAuthMethod { + XOAUTH2 { + override val command: String = Commands.AUTHENTICATE_XOAUTH2 + + override fun buildInitialClientResponse(username: String, token: String): String { + return Authentication.computeXoauth(username, token) + } + }, + OAUTHBEARER { + override val command: String = Commands.AUTHENTICATE_OAUTHBEARER + + override fun buildInitialClientResponse(username: String, token: String): String { + return buildOAuthBearerInitialClientResponse(username, token) + } + }; + + abstract val command: String + abstract fun buildInitialClientResponse(username: String, token: String): String + } + + companion object { + private const val BUFFER_SIZE = 1024 + + /* The below limits are 20 octets less than the recommended limits, in order to compensate for + * the length of the command tag, the space after the tag and the CRLF at the end of the command + * (these are not taken into account when calculating the length of the command). For more + * information, refer to section 4 of RFC 7162. + * + * The length limit for servers supporting the CONDSTORE extension is large in order to support + * the QRESYNC parameter to the SELECT/EXAMINE commands, which accept a list of known message + * sequence numbers as well as their corresponding UIDs. + */ + private const val LENGTH_LIMIT_WITHOUT_CONDSTORE = 980 + private const val LENGTH_LIMIT_WITH_CONDSTORE = 8172 + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.java deleted file mode 100644 index 947cc7732b32e8aeed15bdc136260bb276471408..0000000000000000000000000000000000000000 --- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.java +++ /dev/null @@ -1,411 +0,0 @@ -package com.fsck.k9.mail.store.imap; - - -import java.io.IOException; -import java.nio.charset.CharacterCodingException; -import java.util.ArrayList; -import java.util.Deque; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import com.fsck.k9.logging.Timber; -import com.fsck.k9.mail.AuthType; -import com.fsck.k9.mail.AuthenticationFailedException; -import com.fsck.k9.mail.ConnectionSecurity; -import com.fsck.k9.mail.Flag; -import com.fsck.k9.mail.FolderType; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.ServerSettings; -import com.fsck.k9.mail.oauth.OAuth2TokenProvider; -import com.fsck.k9.mail.ssl.TrustedSocketFactory; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - - -/** - *
- * TODO Need a default response handler for things like folder updates
- * 
- */ -class RealImapStore implements ImapStore, ImapConnectionManager, InternalImapStore { - private final ImapStoreConfig config; - private final TrustedSocketFactory trustedSocketFactory; - private Set permanentFlagsIndex = EnumSet.noneOf(Flag.class); - private OAuth2TokenProvider oauthTokenProvider; - - private String host; - private int port; - private String username; - private String password; - private String clientCertificateAlias; - private ConnectionSecurity connectionSecurity; - private AuthType authType; - private String pathPrefix; - private String combinedPrefix = null; - private String pathDelimiter = null; - private final Deque connections = new LinkedList<>(); - private FolderNameCodec folderNameCodec; - private volatile int connectionGeneration = 1; - - - public RealImapStore(ServerSettings serverSettings, ImapStoreConfig config, - TrustedSocketFactory trustedSocketFactory, OAuth2TokenProvider oauthTokenProvider) { - this.config = config; - this.trustedSocketFactory = trustedSocketFactory; - - host = serverSettings.host; - port = serverSettings.port; - - connectionSecurity = serverSettings.connectionSecurity; - this.oauthTokenProvider = oauthTokenProvider; - - authType = serverSettings.authenticationType; - username = serverSettings.username; - password = serverSettings.password; - clientCertificateAlias = serverSettings.clientCertificateAlias; - - boolean autoDetectNamespace = ImapStoreSettings.getAutoDetectNamespace(serverSettings); - String pathPrefixSetting = ImapStoreSettings.getPathPrefix(serverSettings); - - // Make extra sure pathPrefix is null if "auto-detect namespace" is configured - pathPrefix = autoDetectNamespace ? null : pathPrefixSetting; - - folderNameCodec = FolderNameCodec.newInstance(); - } - - public ImapFolder getFolder(String name) { - return new RealImapFolder(this, this, name, folderNameCodec); - } - - @Override - @NotNull - public String getCombinedPrefix() { - if (combinedPrefix == null) { - if (pathPrefix != null) { - String tmpPrefix = pathPrefix.trim(); - String tmpDelim = (pathDelimiter != null ? pathDelimiter.trim() : ""); - if (tmpPrefix.endsWith(tmpDelim)) { - combinedPrefix = tmpPrefix; - } else if (tmpPrefix.length() > 0) { - combinedPrefix = tmpPrefix + tmpDelim; - } else { - combinedPrefix = ""; - } - } else { - combinedPrefix = ""; - } - } - - return combinedPrefix; - } - - public List getFolders() throws MessagingException { - ImapConnection connection = getConnection(); - - try { - List folders = listFolders(connection, false); - - if (!config.isSubscribedFoldersOnly()) { - return folders; - } - - List subscribedFolders = listFolders(connection, true); - return limitToSubscribedFolders(folders, subscribedFolders); - } catch (AuthenticationFailedException e) { - connection.close(); - throw e; - } catch (IOException | MessagingException ioe) { - connection.close(); - throw new MessagingException("Unable to get folder list.", ioe); - } finally { - releaseConnection(connection); - } - } - - private List limitToSubscribedFolders(List folders, - List subscribedFolders) { - Set subscribedFolderNames = new HashSet<>(subscribedFolders.size()); - for (FolderListItem subscribedFolder : subscribedFolders) { - subscribedFolderNames.add(subscribedFolder.getServerId()); - } - - List filteredFolders = new ArrayList<>(); - for (FolderListItem folder : folders) { - if (subscribedFolderNames.contains(folder.getServerId())) { - filteredFolders.add(folder); - } - } - - return filteredFolders; - } - - private List listFolders(ImapConnection connection, boolean subscribedOnly) throws IOException, - MessagingException { - - String commandFormat; - if (subscribedOnly) { - commandFormat = "LSUB \"\" %s"; - } else if (connection.hasCapability(Capabilities.SPECIAL_USE) && - connection.hasCapability(Capabilities.LIST_EXTENDED)) { - commandFormat = "LIST \"\" %s RETURN (SPECIAL-USE)"; - } else { - commandFormat = "LIST \"\" %s"; - } - - String encodedListPrefix = ImapUtility.encodeString(getCombinedPrefix() + "*"); - List responses = connection.executeSimpleCommand(String.format(commandFormat, encodedListPrefix)); - - List listResponses = (subscribedOnly) ? - ListResponse.parseLsub(responses) : - ListResponse.parseList(responses); - - Map folderMap = new HashMap<>(listResponses.size()); - for (ListResponse listResponse : listResponses) { - String serverId = listResponse.getName(); - - if (pathDelimiter == null) { - pathDelimiter = listResponse.getHierarchyDelimiter(); - combinedPrefix = null; - } - - if (RealImapFolder.INBOX.equalsIgnoreCase(serverId)) { - continue; - } else if (listResponse.hasAttribute("\\NoSelect")) { - continue; - } - - String name = getFolderDisplayName(serverId); - String oldServerId = getOldServerId(serverId); - - FolderType type; - if (listResponse.hasAttribute("\\Archive") || listResponse.hasAttribute("\\All")) { - type = FolderType.ARCHIVE; - } else if (listResponse.hasAttribute("\\Drafts")) { - type = FolderType.DRAFTS; - } else if (listResponse.hasAttribute("\\Sent")) { - type = FolderType.SENT; - } else if (listResponse.hasAttribute("\\Junk")) { - type = FolderType.SPAM; - } else if (listResponse.hasAttribute("\\Trash")) { - type = FolderType.TRASH; - } else { - type = FolderType.REGULAR; - } - - FolderListItem existingItem = folderMap.get(serverId); - if (existingItem == null || existingItem.getType() == FolderType.REGULAR) { - folderMap.put(serverId, new FolderListItem(serverId, name, type, oldServerId)); - } - } - - List folders = new ArrayList<>(folderMap.size() + 1); - folders.add(new FolderListItem(RealImapFolder.INBOX, RealImapFolder.INBOX, FolderType.INBOX, RealImapFolder.INBOX)); - folders.addAll(folderMap.values()); - - return folders; - } - - private String getFolderDisplayName(String serverId) { - String decodedFolderName; - try { - decodedFolderName = folderNameCodec.decode(serverId); - } catch (CharacterCodingException e) { - Timber.w(e, "Folder name not correctly encoded with the UTF-7 variant as defined by RFC 3501: %s", - serverId); - - decodedFolderName = serverId; - } - - String folderNameWithoutPrefix = removePrefixFromFolderName(decodedFolderName); - return folderNameWithoutPrefix != null ? folderNameWithoutPrefix : decodedFolderName; - } - - @Nullable - private String getOldServerId(String serverId) { - String decodedFolderName; - try { - decodedFolderName = folderNameCodec.decode(serverId); - } catch (CharacterCodingException e) { - // Previous versions of K-9 Mail ignored folders with invalid UTF-7 encoding - return null; - } - - return removePrefixFromFolderName(decodedFolderName); - } - - @Nullable - private String removePrefixFromFolderName(String folderName) { - String prefix = getCombinedPrefix(); - int prefixLength = prefix.length(); - if (prefixLength == 0) { - return folderName; - } - - if (!folderName.startsWith(prefix)) { - // Folder name doesn't start with our configured prefix. But right now when building commands we prefix all - // folders except the INBOX with the prefix. So we won't be able to use this folder. - return null; - } - - return folderName.substring(prefixLength); - } - - public void checkSettings() throws MessagingException { - try { - ImapConnection connection = createImapConnection(); - - connection.open(); - connection.close(); - } catch (IOException ioe) { - throw new MessagingException("Unable to connect", ioe); - } - } - - @Override - @NotNull - public ImapConnection getConnection() throws MessagingException { - ImapConnection connection; - while ((connection = pollConnection()) != null) { - try { - connection.executeSimpleCommand(Commands.NOOP); - break; - } catch (IOException ioe) { - connection.close(); - } - } - - if (connection == null) { - connection = createImapConnection(); - } - - return connection; - } - - private ImapConnection pollConnection() { - synchronized (connections) { - return connections.poll(); - } - } - - @Override - public void releaseConnection(ImapConnection connection) { - if (connection != null && connection.isConnected()) { - if (connection.getConnectionGeneration() == connectionGeneration) { - synchronized (connections) { - connections.offer(connection); - } - } else { - connection.close(); - } - } - } - - @Override - public void closeAllConnections() { - Timber.v("ImapStore.closeAllConnections()"); - - List connectionsToClose; - synchronized (connections) { - connectionGeneration++; - connectionsToClose = new ArrayList<>(connections); - connections.clear(); - } - - for (ImapConnection connection : connectionsToClose) { - connection.close(); - } - } - - ImapConnection createImapConnection() { - return new RealImapConnection( - new StoreImapSettings(), - trustedSocketFactory, - oauthTokenProvider, - connectionGeneration); - } - - @Override - @NotNull - public String getLogLabel() { - return config.getLogLabel(); - } - - @Override - @NotNull - public Set getPermanentFlagsIndex() { - return permanentFlagsIndex; - } - - - private class StoreImapSettings implements ImapSettings { - @Override - public String getHost() { - return host; - } - - @Override - public int getPort() { - return port; - } - - @Override - public ConnectionSecurity getConnectionSecurity() { - return connectionSecurity; - } - - @Override - public AuthType getAuthType() { - return authType; - } - - @Override - public String getUsername() { - return username; - } - - @Override - public String getPassword() { - return password; - } - - @Override - public String getClientCertificateAlias() { - return clientCertificateAlias; - } - - @Override - public boolean useCompression() { - return config.useCompression(); - } - - @Override - public String getPathPrefix() { - return pathPrefix; - } - - @Override - public void setPathPrefix(String prefix) { - pathPrefix = prefix; - } - - @Override - public String getPathDelimiter() { - return pathDelimiter; - } - - @Override - public void setPathDelimiter(String delimiter) { - pathDelimiter = delimiter; - } - - @Override - public void setCombinedPrefix(String prefix) { - combinedPrefix = prefix; - } - } -} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.kt new file mode 100644 index 0000000000000000000000000000000000000000..70914a6d3ac70a412eb46b73c81765e53ab7ac3a --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.kt @@ -0,0 +1,321 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.logging.Timber +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace +import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix +import java.io.IOException +import java.util.Deque +import java.util.LinkedList + +internal open class RealImapStore( + private val serverSettings: ServerSettings, + private val config: ImapStoreConfig, + private val trustedSocketFactory: TrustedSocketFactory, + private val oauthTokenProvider: OAuth2TokenProvider? +) : ImapStore, ImapConnectionManager, InternalImapStore { + private val folderNameCodec: FolderNameCodec = FolderNameCodec() + + private val host: String = checkNotNull(serverSettings.host) + + private var pathPrefix: String? + private var combinedPrefix: String? = null + private var pathDelimiter: String? = null + + private val permanentFlagsIndex: MutableSet = mutableSetOf() + private val connections: Deque = LinkedList() + + @Volatile + private var connectionGeneration = 1 + + init { + val autoDetectNamespace = serverSettings.autoDetectNamespace + val pathPrefixSetting = serverSettings.pathPrefix + + // Make extra sure pathPrefix is null if "auto-detect namespace" is configured + pathPrefix = if (autoDetectNamespace) null else pathPrefixSetting + } + + override fun getFolder(name: String): ImapFolder { + return RealImapFolder( + internalImapStore = this, + connectionManager = this, + serverId = name, + folderNameCodec = folderNameCodec + ) + } + + override fun getCombinedPrefix(): String { + return combinedPrefix ?: buildCombinedPrefix().also { combinedPrefix = it } + } + + private fun buildCombinedPrefix(): String { + val pathPrefix = pathPrefix ?: return "" + + val trimmedPathPrefix = pathPrefix.trim { it <= ' ' } + val trimmedPathDelimiter = pathDelimiter?.trim { it <= ' ' }.orEmpty() + + return if (trimmedPathPrefix.endsWith(trimmedPathDelimiter)) { + trimmedPathPrefix + } else if (trimmedPathPrefix.isNotEmpty()) { + trimmedPathPrefix + trimmedPathDelimiter + } else { + "" + } + } + + @Throws(MessagingException::class) + override fun getFolders(): List { + val connection = getConnection() + + return try { + val folders = listFolders(connection, false) + if (!config.isSubscribedFoldersOnly()) { + return folders + } + + val subscribedFolders = listFolders(connection, true) + limitToSubscribedFolders(folders, subscribedFolders) + } catch (e: AuthenticationFailedException) { + connection.close() + throw e + } catch (e: IOException) { + connection.close() + throw MessagingException("Unable to get folder list.", e) + } catch (e: MessagingException) { + connection.close() + throw MessagingException("Unable to get folder list.", e) + } finally { + releaseConnection(connection) + } + } + + private fun limitToSubscribedFolders( + folders: List, + subscribedFolders: List + ): List { + val subscribedFolderServerIds = subscribedFolders.map { it.serverId }.toSet() + return folders.filter { it.serverId in subscribedFolderServerIds } + } + + @Throws(IOException::class, MessagingException::class) + private fun listFolders(connection: ImapConnection, subscribedOnly: Boolean): List { + val commandFormat = when { + subscribedOnly -> { + "LSUB \"\" %s" + } + connection.supportsListExtended -> { + "LIST \"\" %s RETURN (SPECIAL-USE)" + } + else -> { + "LIST \"\" %s" + } + } + + val encodedListPrefix = ImapUtility.encodeString(getCombinedPrefix() + "*") + val responses = connection.executeSimpleCommand(String.format(commandFormat, encodedListPrefix)) + + val listResponses = if (subscribedOnly) { + ListResponse.parseLsub(responses) + } else { + ListResponse.parseList(responses) + } + + val folderMap = mutableMapOf() + for (listResponse in listResponses) { + val serverId = listResponse.name + + if (pathDelimiter == null) { + pathDelimiter = listResponse.hierarchyDelimiter + combinedPrefix = null + } + + if (RealImapFolder.INBOX.equals(serverId, ignoreCase = true)) { + continue + } else if (listResponse.hasAttribute("\\NoSelect")) { + continue + } + + val name = getFolderDisplayName(serverId) + val oldServerId = getOldServerId(serverId) + + val type = when { + listResponse.hasAttribute("\\Archive") -> FolderType.ARCHIVE + listResponse.hasAttribute("\\All") -> FolderType.ARCHIVE + listResponse.hasAttribute("\\Drafts") -> FolderType.DRAFTS + listResponse.hasAttribute("\\Sent") -> FolderType.SENT + listResponse.hasAttribute("\\Junk") -> FolderType.SPAM + listResponse.hasAttribute("\\Trash") -> FolderType.TRASH + else -> FolderType.REGULAR + } + + val existingItem = folderMap[serverId] + if (existingItem == null || existingItem.type == FolderType.REGULAR) { + folderMap[serverId] = FolderListItem(serverId, name, type, oldServerId) + } + } + + return buildList { + add(FolderListItem(RealImapFolder.INBOX, RealImapFolder.INBOX, FolderType.INBOX, RealImapFolder.INBOX)) + addAll(folderMap.values) + } + } + + private fun getFolderDisplayName(serverId: String): String { + val decodedFolderName = try { + folderNameCodec.decode(serverId) + } catch (e: CharacterCodingException) { + Timber.w(e, "Folder name not correctly encoded with the UTF-7 variant as defined by RFC 3501: %s", serverId) + serverId + } + + val folderNameWithoutPrefix = removePrefixFromFolderName(decodedFolderName) + return folderNameWithoutPrefix ?: decodedFolderName + } + + private fun getOldServerId(serverId: String): String? { + val decodedFolderName = try { + folderNameCodec.decode(serverId) + } catch (e: CharacterCodingException) { + // Previous versions of K-9 Mail ignored folders with invalid UTF-7 encoding + return null + } + + return removePrefixFromFolderName(decodedFolderName) + } + + private fun removePrefixFromFolderName(folderName: String): String? { + val prefix = getCombinedPrefix() + val prefixLength = prefix.length + if (prefixLength == 0) { + return folderName + } + + if (!folderName.startsWith(prefix)) { + // Folder name doesn't start with our configured prefix. But right now when building commands we prefix all + // folders except the INBOX with the prefix. So we won't be able to use this folder. + return null + } + + return folderName.substring(prefixLength) + } + + @Throws(MessagingException::class) + override fun checkSettings() { + try { + val connection = createImapConnection() + + connection.open() + connection.close() + } catch (e: IOException) { + throw MessagingException("Unable to connect", e) + } + } + + @Throws(MessagingException::class) + override fun getConnection(): ImapConnection { + while (true) { + val connection = pollConnection() ?: return createImapConnection() + + try { + connection.executeSimpleCommand(Commands.NOOP) + + // If the command completes without an error this connection is still usable. + return connection + } catch (ioe: IOException) { + connection.close() + } + } + } + + private fun pollConnection(): ImapConnection? { + return synchronized(connections) { + connections.poll() + } + } + + override fun releaseConnection(connection: ImapConnection?) { + if (connection != null && connection.isConnected) { + if (connection.connectionGeneration == connectionGeneration) { + synchronized(connections) { + connections.offer(connection) + } + } else { + connection.close() + } + } + } + + override fun closeAllConnections() { + Timber.v("ImapStore.closeAllConnections()") + + val connectionsToClose = synchronized(connections) { + val connectionsToClose = connections.toList() + + connectionGeneration++ + connections.clear() + + connectionsToClose + } + + for (connection in connectionsToClose) { + connection.close() + } + } + + open fun createImapConnection(): ImapConnection { + return RealImapConnection( + StoreImapSettings(), + trustedSocketFactory, + oauthTokenProvider, + connectionGeneration + ) + } + + override val logLabel: String + get() = config.logLabel + + override fun getPermanentFlagsIndex(): MutableSet { + return permanentFlagsIndex + } + + private inner class StoreImapSettings : ImapSettings { + override val host: String = this@RealImapStore.host + override val port: Int = serverSettings.port + override val connectionSecurity: ConnectionSecurity = serverSettings.connectionSecurity + override val authType: AuthType = serverSettings.authenticationType + override val username: String = serverSettings.username + override val password: String? = serverSettings.password + override val clientCertificateAlias: String? = serverSettings.clientCertificateAlias + + override val useCompression: Boolean + get() = this@RealImapStore.config.useCompression() + + override var pathPrefix: String? + get() = this@RealImapStore.pathPrefix + set(value) { + this@RealImapStore.pathPrefix = value + } + + override var pathDelimiter: String? + get() = this@RealImapStore.pathDelimiter + set(value) { + this@RealImapStore.pathDelimiter = value + } + + override fun setCombinedPrefix(prefix: String?) { + combinedPrefix = prefix + } + } +} + +private val ImapConnection.supportsListExtended: Boolean + get() = hasCapability(Capabilities.SPECIAL_USE) && hasCapability(Capabilities.LIST_EXTENDED) diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.java deleted file mode 100644 index 78c0aa976dd866e26b1add06c7177bd255e6ee81..0000000000000000000000000000000000000000 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.fsck.k9.mail.store.imap; - - -import java.nio.charset.CharacterCodingException; - -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; - - -public class FolderNameCodecTest { - private FolderNameCodec folderNameCode; - - - @Before - public void setUp() throws Exception { - folderNameCode = FolderNameCodec.newInstance(); - } - - @Test - public void encode_withAsciiArgument_shouldReturnInput() throws Exception { - String folderName = "ASCII"; - - String result = folderNameCode.encode(folderName); - - assertEquals(folderName, result); - } - - @Test - public void encode_withNonAsciiArgument_shouldReturnEncodedString() throws Exception { - String folderName = "über"; - - String result = folderNameCode.encode(folderName); - - assertEquals("&APw-ber", result); - } - - @Test - public void decode_withEncodedArgument_shouldReturnDecodedString() throws Exception { - String encodedFolderName = "&ANw-bergr&APYA3w-entr&AOQ-ger"; - - String result = folderNameCode.decode(encodedFolderName); - - assertEquals("Übergrößenträger", result); - } - - @Test - public void decode_withInvalidEncodedArgument_shouldThrow() throws Exception { - String encodedFolderName = "&12-foo"; - - try { - folderNameCode.decode(encodedFolderName); - fail("Expected exception"); - } catch (CharacterCodingException ignored) { - } - } -} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b1162f81e0c62cf5f3781f52815eaa8bfc0c5cfb --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/FolderNameCodecTest.kt @@ -0,0 +1,28 @@ +package com.fsck.k9.mail.store.imap + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class FolderNameCodecTest { + private var folderNameCode = FolderNameCodec() + + @Test + fun `encode() with ASCII argument should return input`() { + assertThat(folderNameCode.encode("ASCII")).isEqualTo("ASCII") + } + + @Test + fun `encode() with non-ASCII argument should return encoded string`() { + assertThat(folderNameCode.encode("über")).isEqualTo("&APw-ber") + } + + @Test + fun `decode() with encoded argument should return decoded string`() { + assertThat(folderNameCode.decode("&ANw-bergr&APYA3w-entr&AOQ-ger")).isEqualTo("Übergrößenträger") + } + + @Test(expected = CharacterCodingException::class) + fun `decode() with invalid encoded argument should throw`() { + folderNameCode.decode("&12-foo") + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.java deleted file mode 100644 index 375c510aa0b02efc6eecf24f8e28f64556169be9..0000000000000000000000000000000000000000 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.fsck.k9.mail.store.imap; - - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - - -public class IdGrouperTest { - @Test - public void groupIds_withSingleContiguousGroup() throws Exception { - Set ids = newSet(1L, 2L, 3L); - - IdGrouper.GroupedIds groupedIds = IdGrouper.groupIds(ids); - - assertEquals(0, groupedIds.ids.size()); - assertEquals(1, groupedIds.idGroups.size()); - assertEquals("1:3", groupedIds.idGroups.get(0).toString()); - } - - @Test - public void groupIds_withoutContiguousGroup() throws Exception { - Set ids = newSet(23L, 42L, 2L, 5L); - - IdGrouper.GroupedIds groupedIds = IdGrouper.groupIds(ids); - - assertEquals(ids, groupedIds.ids); - assertEquals(0, groupedIds.idGroups.size()); - } - - @Test - public void groupIds_withMultipleContiguousGroups() throws Exception { - Set ids = newSet(1L, 3L, 4L, 5L, 6L, 10L, 12L, 13L, 14L, 23L); - - IdGrouper.GroupedIds groupedIds = IdGrouper.groupIds(ids); - - assertEquals(newSet(1L, 10L, 23L), groupedIds.ids); - assertEquals(2, groupedIds.idGroups.size()); - assertEquals("3:6", groupedIds.idGroups.get(0).toString()); - assertEquals("12:14", groupedIds.idGroups.get(1).toString()); - } - - @Test - public void groupIds_withSingleId() throws Exception { - Set ids = newSet(23L); - - IdGrouper.GroupedIds groupedIds = IdGrouper.groupIds(ids); - - assertEquals(newSet(23L), groupedIds.ids); - assertEquals(0, groupedIds.idGroups.size()); - } - - @Test(expected = IllegalArgumentException.class) - public void groupIds_withEmptySet_shouldThrow() throws Exception { - IdGrouper.groupIds(newSet()); - } - - @Test(expected = IllegalArgumentException.class) - public void groupIds_withNullArgument_shouldThrow() throws Exception { - IdGrouper.groupIds(null); - } - - - private static Set newSet(Long... values) { - HashSet set = new HashSet<>(values.length); - set.addAll(Arrays.asList(values)); - return set; - } -} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..28342a0c35542e0dd376231af6cbe5b8cc3dd5b4 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/IdGrouperTest.kt @@ -0,0 +1,53 @@ +package com.fsck.k9.mail.store.imap + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class IdGrouperTest { + @Test + fun `groupIds() with single contiguous group`() { + val ids = setOf(1L, 2L, 3L) + + val groupedIds = IdGrouper.groupIds(ids) + + assertThat(groupedIds.ids).isEmpty() + assertThat(groupedIds.idGroups.mapToString()).containsExactly("1:3") + } + + @Test + fun `groupIds() without contiguous group`() { + val ids = setOf(23L, 42L, 2L, 5L) + + val groupedIds = IdGrouper.groupIds(ids) + + assertThat(groupedIds.ids).isEqualTo(ids) + assertThat(groupedIds.idGroups).isEmpty() + } + + @Test + fun `groupIds() with multiple contiguous groups`() { + val ids = setOf(1L, 3L, 4L, 5L, 6L, 10L, 12L, 13L, 14L, 23L) + + val groupedIds = IdGrouper.groupIds(ids) + + assertThat(groupedIds.ids).containsExactly(1L, 10L, 23L) + assertThat(groupedIds.idGroups.mapToString()).containsExactly("3:6", "12:14") + } + + @Test + fun `groupIds() with single ID`() { + val ids = setOf(23L) + + val groupedIds = IdGrouper.groupIds(ids) + + assertThat(groupedIds.ids).containsExactly(23L) + assertThat(groupedIds.idGroups).isEmpty() + } + + @Test(expected = IllegalArgumentException::class) + fun `groupIds() with empty set should throw`() { + IdGrouper.groupIds(emptySet()) + } +} + +private fun List.mapToString() = map { it.toString() } diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.java index 1c4e66f90de6d4e1bacf29877f5f776888904512..a65b1c527687db8266b657813e018ea683f66640 100644 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.java +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapCommandSplitterTest.java @@ -6,7 +6,6 @@ import java.util.List; import java.util.Set; import java.util.TreeSet; -import com.fsck.k9.mail.store.imap.IdGrouper.GroupedIds; import com.google.common.collect.Sets; import org.junit.Test; @@ -24,7 +23,7 @@ public class ImapCommandSplitterTest { @Test public void splitCommand_withManyNonContiguousIds_shouldSplitCommand() throws Exception { Set ids = createNonContiguousIdSet(10000, 10500, 2); - GroupedIds groupedIds = new GroupedIds(ids, Collections.emptyList()); + GroupedIds groupedIds = new GroupedIds(ids, Collections.emptyList()); List commands = ImapCommandSplitter.splitCommand(COMMAND_PREFIX, COMMAND_SUFFIX, groupedIds, 980); @@ -39,7 +38,7 @@ public class ImapCommandSplitterTest { Set idSet = Sets.union( createNonContiguousIdSet(10000, 10298, 2), createNonContiguousIdSet(10402, 10500, 2)); - List idGroups = singletonList(new IdGrouper.ContiguousIdGroup(10300L, 10400L)); + List idGroups = singletonList(new ContiguousIdGroup(10300L, 10400L)); GroupedIds groupedIds = new GroupedIds(idSet, idGroups); List commands = ImapCommandSplitter.splitCommand(COMMAND_PREFIX, COMMAND_SUFFIX, groupedIds, 980); @@ -55,7 +54,7 @@ public class ImapCommandSplitterTest { @Test public void splitCommand_withEmptySuffix_shouldCreateCommandWithoutTrailingSpace() throws Exception { Set ids = createNonContiguousIdSet(1, 2, 1); - GroupedIds groupedIds = new GroupedIds(ids, Collections.emptyList()); + GroupedIds groupedIds = new GroupedIds(ids, Collections.emptyList()); List commands = ImapCommandSplitter.splitCommand("UID SEARCH UID", "", groupedIds, 980); diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt index ef4fc9e1e47699ccded3bba58d52e62cc31977e0..d6a004623d5edf3c0328d22a08dc67905e7f3c20 100644 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt @@ -38,10 +38,6 @@ private val OAUTHBEARER_STRING = "n,a=$USERNAME,\u0001auth=Bearer $XOAUTH_TOKEN\ class RealImapConnectionTest { private var socketFactory = TestTrustedSocketFactory.newInstance() private var oAuth2TokenProvider = TestTokenProvider() - private var settings = SimpleImapSettings().apply { - username = USERNAME - password = PASSWORD - } @Before fun setUp() { @@ -646,8 +642,7 @@ class RealImapConnectionTest { @Test fun `open() with connection error should throw`() { - settings.host = "127.1.2.3" - settings.port = 143 + val settings = createImapSettings(host = "127.1.2.3") val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider) try { @@ -661,8 +656,7 @@ class RealImapConnectionTest { @Test fun `open() with invalid hostname should throw`() { - settings.host = "host name" - settings.port = 143 + val settings = createImapSettings(host = "host name") val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider) try { @@ -676,7 +670,6 @@ class RealImapConnectionTest { @Test fun `open() with STARTTLS capability should issue STARTTLS command`() { - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED val server = MockImapServer().apply { preAuthenticationDialog(capabilities = "STARTTLS LOGINDISABLED") expect("2 STARTTLS") @@ -691,7 +684,11 @@ class RealImapConnectionTest { output("* NAMESPACE ((\"\" \"/\")) NIL NIL") output("5 OK command completed") } - val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + val imapConnection = startServerAndCreateImapConnection( + server, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + authType = AuthType.PLAIN + ) imapConnection.open() @@ -701,11 +698,13 @@ class RealImapConnectionTest { @Test fun `open() with STARTTLS but without STARTTLS capability should throw`() { - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED val server = MockImapServer().apply { preAuthenticationDialog() } - val imapConnection = startServerAndCreateImapConnection(server) + val imapConnection = startServerAndCreateImapConnection( + server, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + ) try { imapConnection.open() @@ -721,7 +720,6 @@ class RealImapConnectionTest { @Test fun `open() with untagged CAPABILITY after STARTTLS should not throw`() { - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED val server = MockImapServer().apply { preAuthenticationDialog(capabilities = "STARTTLS LOGINDISABLED") expect("2 STARTTLS") @@ -735,7 +733,11 @@ class RealImapConnectionTest { output("4 OK [CAPABILITY IMAP4REV1] LOGIN completed") simplePostAuthenticationDialog(tag = 5) } - val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + val imapConnection = startServerAndCreateImapConnection( + server, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + authType = AuthType.PLAIN + ) imapConnection.open() @@ -745,13 +747,16 @@ class RealImapConnectionTest { @Test fun `open() with negative response to STARTTLS command should throw`() { - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED val server = MockImapServer().apply { preAuthenticationDialog(capabilities = "STARTTLS") expect("2 STARTTLS") output("2 NO") } - val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN) + val imapConnection = startServerAndCreateImapConnection( + server, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + authType = AuthType.PLAIN + ) try { imapConnection.open() @@ -766,7 +771,6 @@ class RealImapConnectionTest { @Test fun `open() with COMPRESS=DEFLATE capability should enable compression`() { - settings.setUseCompression(true) val server = MockImapServer().apply { simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE") expect("3 COMPRESS DEFLATE") @@ -774,7 +778,7 @@ class RealImapConnectionTest { enableCompression() simplePostAuthenticationDialog(tag = 4) } - val imapConnection = startServerAndCreateImapConnection(server) + val imapConnection = startServerAndCreateImapConnection(server, useCompression = true) imapConnection.open() @@ -784,14 +788,13 @@ class RealImapConnectionTest { @Test fun `open() with negative response to COMPRESS command should continue`() { - settings.setUseCompression(true) val server = MockImapServer().apply { simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE") expect("3 COMPRESS DEFLATE") output("3 NO") simplePostAuthenticationDialog(tag = 4) } - val imapConnection = startServerAndCreateImapConnection(server) + val imapConnection = startServerAndCreateImapConnection(server, useCompression = true) imapConnection.open() @@ -801,13 +804,12 @@ class RealImapConnectionTest { @Test fun `open() with IOException during COMPRESS command should throw`() { - settings.setUseCompression(true) val server = MockImapServer().apply { simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE") expect("3 COMPRESS DEFLATE") closeConnection() } - val imapConnection = startServerAndCreateImapConnection(server) + val imapConnection = startServerAndCreateImapConnection(server, useCompression = true) try { imapConnection.open() @@ -855,6 +857,7 @@ class RealImapConnectionTest { @Test fun `isConnected without previous open() should return false`() { + val settings = createImapSettings() val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider) val result = imapConnection.isConnected @@ -889,6 +892,7 @@ class RealImapConnectionTest { @Test fun `close() without open() should not throw`() { + val settings = createImapSettings() val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider) imapConnection.close() @@ -1007,20 +1011,29 @@ class RealImapConnectionTest { settings, socketFactory, oAuth2TokenProvider, + connectionGeneration, SOCKET_CONNECT_TIMEOUT, - SOCKET_READ_TIMEOUT, - connectionGeneration + SOCKET_READ_TIMEOUT ) } private fun startServerAndCreateImapConnection( server: MockImapServer, - authType: AuthType = AuthType.PLAIN + connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE, + authType: AuthType = AuthType.PLAIN, + useCompression: Boolean = false ): ImapConnection { server.start() - settings.host = server.host - settings.port = server.port - settings.authType = authType + + val settings = SimpleImapSettings( + host = server.host, + port = server.port, + connectionSecurity = connectionSecurity, + authType = authType, + username = USERNAME, + password = PASSWORD, + useCompression = useCompression + ) return createImapConnection(settings, socketFactory, oAuth2TokenProvider) } @@ -1068,11 +1081,19 @@ class RealImapConnectionTest { } private fun MockImapServer.simplePreAuthAndLoginDialog(postAuthCapabilities: String = "") { - settings.authType = AuthType.PLAIN preAuthenticationDialog() expect("2 LOGIN \"$USERNAME\" \"$PASSWORD\"") output("2 OK [CAPABILITY $postAuthCapabilities] LOGIN completed") } + + private fun createImapSettings(host: String = "irrelevant"): ImapSettings { + return SimpleImapSettings( + host = host, + port = 143, + authType = AuthType.PLAIN, + username = "irrelevant" + ) + } } class TestTokenProvider : OAuth2TokenProvider { diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt index 5b2251fc57fa44c070c69945673f8a694f22c962..73a7744e8f4e777c060155f88aebca45a05327a1 100644 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapFolderTest.kt @@ -29,7 +29,6 @@ import org.junit.Before import org.junit.Test import org.mockito.ArgumentMatchers.anySet import org.mockito.ArgumentMatchers.anyString -import org.mockito.ArgumentMatchers.eq import org.mockito.ArgumentMatchers.startsWith import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.times @@ -39,6 +38,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -48,7 +48,7 @@ class RealImapFolderTest { override fun getCombinedPrefix() = "" override fun getPermanentFlagsIndex() = mutableSetOf() } - private val imapConnection = mock() + private val imapConnection = mock() private val testConnectionManager = TestConnectionManager(imapConnection) private lateinit var tempDirectory: File @@ -1096,7 +1096,7 @@ class RealImapFolderTest { private fun extractMessageUids(messages: List) = messages.map { it.uid }.toSet() private fun createFolder(folderName: String): RealImapFolder { - return RealImapFolder(internalImapStore, testConnectionManager, folderName, FolderNameCodec.newInstance()) + return RealImapFolder(internalImapStore, testConnectionManager, folderName, FolderNameCodec()) } private fun createImapMessage(uid: String): ImapMessage { diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.java deleted file mode 100644 index ef95cf5810c6c47a80b0bfcb0ae821195f175e1a..0000000000000000000000000000000000000000 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.java +++ /dev/null @@ -1,489 +0,0 @@ -package com.fsck.k9.mail.store.imap; - - -import java.io.IOException; -import java.util.ArrayDeque; -import java.util.Arrays; -import java.util.Collections; -import java.util.Deque; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import com.fsck.k9.mail.AuthType; -import com.fsck.k9.mail.ConnectionSecurity; -import com.fsck.k9.mail.FolderType; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.ServerSettings; -import com.fsck.k9.mail.oauth.OAuth2TokenProvider; -import com.fsck.k9.mail.ssl.TrustedSocketFactory; -import org.jetbrains.annotations.NotNull; -import org.junit.Before; -import org.junit.Test; -import org.mockito.internal.util.collections.Sets; - -import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.fail; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class RealImapStoreTest { - private ImapStoreConfig config = mock(ImapStoreConfig.class); - private TestImapStore imapStore; - - @Before - public void setUp() throws Exception { - ServerSettings serverSettings = createServerSettings(); - TrustedSocketFactory trustedSocketFactory = mock(TrustedSocketFactory.class); - OAuth2TokenProvider oauth2TokenProvider = mock(OAuth2TokenProvider.class); - - imapStore = new TestImapStore(serverSettings, config, trustedSocketFactory, - oauth2TokenProvider); - } - - @Test - public void checkSettings_shouldCreateImapConnectionAndCallOpen() throws Exception { - ImapConnection imapConnection = createMockConnection(); - imapStore.enqueueImapConnection(imapConnection); - - imapStore.checkSettings(); - - verify(imapConnection).open(); - } - - @Test - public void checkSettings_withOpenThrowing_shouldThrowMessagingException() throws Exception { - ImapConnection imapConnection = createMockConnection(); - doThrow(IOException.class).when(imapConnection).open(); - imapStore.enqueueImapConnection(imapConnection); - - try { - imapStore.checkSettings(); - fail("Expected exception"); - } catch (MessagingException e) { - assertEquals("Unable to connect", e.getMessage()); - assertNotNull(e.getCause()); - assertEquals(IOException.class, e.getCause().getClass()); - } - } - - @Test - public void getFolders_withSpecialUseCapability_shouldReturnSpecialFolderInfo() throws Exception { - ImapConnection imapConnection = createMockConnection(); - when(imapConnection.hasCapability(Capabilities.LIST_EXTENDED)).thenReturn(true); - when(imapConnection.hasCapability(Capabilities.SPECIAL_USE)).thenReturn(true); - List imapResponses = Arrays.asList( - createImapResponse("* LIST (\\HasNoChildren) \"/\" \"INBOX\""), - createImapResponse("* LIST (\\Noselect \\HasChildren) \"/\" \"[Gmail]\""), - createImapResponse("* LIST (\\HasNoChildren \\All) \"/\" \"[Gmail]/All Mail\""), - createImapResponse("* LIST (\\HasNoChildren \\Drafts) \"/\" \"[Gmail]/Drafts\""), - createImapResponse("* LIST (\\HasNoChildren \\Important) \"/\" \"[Gmail]/Important\""), - createImapResponse("* LIST (\\HasNoChildren \\Sent) \"/\" \"[Gmail]/Sent Mail\""), - createImapResponse("* LIST (\\HasNoChildren \\Junk) \"/\" \"[Gmail]/Spam\""), - createImapResponse("* LIST (\\HasNoChildren \\Flagged) \"/\" \"[Gmail]/Starred\""), - createImapResponse("* LIST (\\HasNoChildren \\Trash) \"/\" \"[Gmail]/Trash\""), - createImapResponse("5 OK Success") - ); - when(imapConnection.executeSimpleCommand("LIST \"\" \"*\" RETURN (SPECIAL-USE)")).thenReturn(imapResponses); - imapStore.enqueueImapConnection(imapConnection); - - List folders = imapStore.getFolders(); - - Map folderMap = toFolderMap(folders); - assertEquals(FolderType.INBOX, folderMap.get("INBOX").getType()); - assertEquals(FolderType.DRAFTS, folderMap.get("[Gmail]/Drafts").getType()); - assertEquals(FolderType.SENT, folderMap.get("[Gmail]/Sent Mail").getType()); - assertEquals(FolderType.SPAM, folderMap.get("[Gmail]/Spam").getType()); - assertEquals(FolderType.TRASH, folderMap.get("[Gmail]/Trash").getType()); - assertEquals(FolderType.ARCHIVE, folderMap.get("[Gmail]/All Mail").getType()); - } - - @Test - public void getFolders_withoutSpecialUseCapability_shouldUseSimpleListCommand() throws Exception { - ImapConnection imapConnection = createMockConnection(); - when(imapConnection.hasCapability(Capabilities.LIST_EXTENDED)).thenReturn(true); - when(imapConnection.hasCapability(Capabilities.SPECIAL_USE)).thenReturn(false); - imapStore.enqueueImapConnection(imapConnection); - - imapStore.getFolders(); - - verify(imapConnection, never()).executeSimpleCommand("LIST \"\" \"*\" RETURN (SPECIAL-USE)"); - verify(imapConnection).executeSimpleCommand("LIST \"\" \"*\""); - } - - @Test - public void getFolders_withoutListExtendedCapability_shouldUseSimpleListCommand() throws Exception { - ImapConnection imapConnection = createMockConnection(); - when(imapConnection.hasCapability(Capabilities.LIST_EXTENDED)).thenReturn(false); - when(imapConnection.hasCapability(Capabilities.SPECIAL_USE)).thenReturn(true); - imapStore.enqueueImapConnection(imapConnection); - - imapStore.getFolders(); - - verify(imapConnection, never()).executeSimpleCommand("LIST \"\" \"*\" RETURN (SPECIAL-USE)"); - verify(imapConnection).executeSimpleCommand("LIST \"\" \"*\""); - } - - @Test - public void getFolders_withoutSubscribedFoldersOnly() throws Exception { - when(config.isSubscribedFoldersOnly()).thenReturn(false); - ImapConnection imapConnection = createMockConnection(); - List imapResponses = Arrays.asList( - createImapResponse("* LIST (\\HasNoChildren) \".\" \"INBOX\""), - createImapResponse("* LIST (\\Noselect \\HasChildren) \".\" \"Folder\""), - createImapResponse("* LIST (\\HasNoChildren) \".\" \"Folder.SubFolder\""), - createImapResponse("6 OK Success") - ); - when(imapConnection.executeSimpleCommand("LIST \"\" \"*\"")).thenReturn(imapResponses); - imapStore.enqueueImapConnection(imapConnection); - - List result = imapStore.getFolders(); - - assertNotNull(result); - assertEquals(Sets.newSet("INBOX", "Folder.SubFolder"), extractFolderServerIds(result)); - } - - @Test - public void getFolders_withSubscribedFoldersOnly_shouldOnlyReturnExistingSubscribedFolders() - throws Exception { - when(config.isSubscribedFoldersOnly()).thenReturn(true); - ImapConnection imapConnection = createMockConnection(); - List lsubResponses = Arrays.asList( - createImapResponse("* LSUB (\\HasNoChildren) \".\" \"INBOX\""), - createImapResponse("* LSUB (\\Noselect \\HasChildren) \".\" \"Folder\""), - createImapResponse("* LSUB (\\HasNoChildren) \".\" \"Folder.SubFolder\""), - createImapResponse("* LSUB (\\HasNoChildren) \".\" \"SubscribedFolderThatHasBeenDeleted\""), - createImapResponse("5 OK Success") - ); - when(imapConnection.executeSimpleCommand("LSUB \"\" \"*\"")).thenReturn(lsubResponses); - List imapResponses = Arrays.asList( - createImapResponse("* LIST (\\HasNoChildren) \".\" \"INBOX\""), - createImapResponse("* LIST (\\Noselect \\HasChildren) \".\" \"Folder\""), - createImapResponse("* LIST (\\HasNoChildren) \".\" \"Folder.SubFolder\""), - createImapResponse("6 OK Success") - ); - when(imapConnection.executeSimpleCommand("LIST \"\" \"*\"")).thenReturn(imapResponses); - imapStore.enqueueImapConnection(imapConnection); - - List result = imapStore.getFolders(); - - assertNotNull(result); - assertEquals(Sets.newSet("INBOX", "Folder.SubFolder"), extractFolderServerIds(result)); - } - - @Test - public void getFolders_withNamespacePrefix() throws Exception { - ImapConnection imapConnection = createMockConnection(); - List imapResponses = Arrays.asList( - createImapResponse("* LIST () \".\" \"INBOX\""), - createImapResponse("* LIST () \".\" \"INBOX.FolderOne\""), - createImapResponse("* LIST () \".\" \"INBOX.FolderTwo\""), - createImapResponse("5 OK Success") - ); - when(imapConnection.executeSimpleCommand("LIST \"\" \"INBOX.*\"")).thenReturn(imapResponses); - imapStore.enqueueImapConnection(imapConnection); - imapStore.setTestCombinedPrefix("INBOX."); - - List result = imapStore.getFolders(); - - assertNotNull(result); - assertEquals(Sets.newSet("INBOX", "INBOX.FolderOne", "INBOX.FolderTwo"), extractFolderServerIds(result)); - assertEquals(Sets.newSet("INBOX", "FolderOne", "FolderTwo"), extractFolderNames(result)); - assertEquals(Sets.newSet("INBOX", "FolderOne", "FolderTwo"), extractOldFolderServerIds(result)); - } - - @Test - public void getFolders_withFolderNotMatchingNamespacePrefix() throws Exception { - ImapConnection imapConnection = createMockConnection(); - List imapResponses = Arrays.asList( - createImapResponse("* LIST () \".\" \"INBOX\""), - createImapResponse("* LIST () \".\" \"INBOX.FolderOne\""), - createImapResponse("* LIST () \".\" \"FolderTwo\""), - createImapResponse("5 OK Success") - ); - when(imapConnection.executeSimpleCommand("LIST \"\" \"INBOX.*\"")).thenReturn(imapResponses); - imapStore.enqueueImapConnection(imapConnection); - imapStore.setTestCombinedPrefix("INBOX."); - - List result = imapStore.getFolders(); - - assertNotNull(result); - assertEquals(Sets.newSet("INBOX", "INBOX.FolderOne", "FolderTwo"), extractFolderServerIds(result)); - assertEquals(Sets.newSet("INBOX", "FolderOne", "FolderTwo"), extractFolderNames(result)); - assertEquals(Sets.newSet("INBOX", "FolderOne"), extractOldFolderServerIds(result)); - } - - @Test - public void getFolders_withDuplicateFolderNames_shouldRemoveDuplicatesAndKeepFolderType() - throws Exception { - ImapConnection imapConnection = createMockConnection(); - when(imapConnection.hasCapability(Capabilities.LIST_EXTENDED)).thenReturn(true); - when(imapConnection.hasCapability(Capabilities.SPECIAL_USE)).thenReturn(true); - List imapResponses = Arrays.asList( - createImapResponse("* LIST () \".\" \"INBOX\""), - createImapResponse("* LIST (\\HasNoChildren) \".\" \"Junk\""), - createImapResponse("* LIST (\\Junk) \".\" \"Junk\""), - createImapResponse("* LIST (\\HasNoChildren) \".\" \"Junk\""), - createImapResponse("5 OK Success") - ); - when(imapConnection.executeSimpleCommand("LIST \"\" \"*\" RETURN (SPECIAL-USE)")).thenReturn(imapResponses); - imapStore.enqueueImapConnection(imapConnection); - - List result = imapStore.getFolders(); - - assertNotNull(result); - assertEquals(2, result.size()); - FolderListItem junkFolder = getFolderByServerId(result, "Junk"); - assertNotNull(junkFolder); - assertEquals(FolderType.SPAM, junkFolder.getType()); - } - - @Test - public void getFolders_withoutException_shouldLeaveImapConnectionOpen() throws Exception { - ImapConnection imapConnection = createMockConnection(); - List imapResponses = Collections.singletonList(createImapResponse("5 OK Success")); - when(imapConnection.executeSimpleCommand(anyString())).thenReturn(imapResponses); - imapStore.enqueueImapConnection(imapConnection); - - imapStore.getFolders(); - - verify(imapConnection, never()).close(); - } - - @Test - public void getFolders_withIoException_shouldCloseImapConnection() throws Exception { - ImapConnection imapConnection = createMockConnection(); - doThrow(IOException.class).when(imapConnection).executeSimpleCommand("LIST \"\" \"*\""); - imapStore.enqueueImapConnection(imapConnection); - - try { - imapStore.getFolders(); - fail("Expected exception"); - } catch (MessagingException ignored) { - } - - verify(imapConnection).close(); - } - - @Test - public void getConnection_shouldCreateImapConnection() throws Exception { - ImapConnection imapConnection = createMockConnection(); - imapStore.enqueueImapConnection(imapConnection); - - ImapConnection result = imapStore.getConnection(); - - assertSame(imapConnection, result); - } - - @Test - public void getConnection_calledTwiceWithoutRelease_shouldCreateTwoImapConnection() throws Exception { - ImapConnection imapConnectionOne = createMockConnection(); - ImapConnection imapConnectionTwo = createMockConnection(); - imapStore.enqueueImapConnection(imapConnectionOne); - imapStore.enqueueImapConnection(imapConnectionTwo); - - ImapConnection resultOne = imapStore.getConnection(); - ImapConnection resultTwo = imapStore.getConnection(); - - assertSame(imapConnectionOne, resultOne); - assertSame(imapConnectionTwo, resultTwo); - } - - @Test - public void getConnection_calledAfterRelease_shouldReturnCachedImapConnection() throws Exception { - ImapConnection imapConnection = createMockConnection(); - when(imapConnection.isConnected()).thenReturn(true); - imapStore.enqueueImapConnection(imapConnection); - ImapConnection connection = imapStore.getConnection(); - imapStore.releaseConnection(connection); - - ImapConnection result = imapStore.getConnection(); - - assertSame(imapConnection, result); - } - - @Test - public void getConnection_calledAfterReleaseWithAClosedConnection_shouldReturnNewImapConnectionInstance() - throws Exception { - ImapConnection imapConnectionOne = createMockConnection(); - ImapConnection imapConnectionTwo = createMockConnection(); - imapStore.enqueueImapConnection(imapConnectionOne); - imapStore.enqueueImapConnection(imapConnectionTwo); - imapStore.getConnection(); - when(imapConnectionOne.isConnected()).thenReturn(false); - imapStore.releaseConnection(imapConnectionOne); - - ImapConnection result = imapStore.getConnection(); - - assertSame(imapConnectionTwo, result); - } - - @Test - public void getConnection_withDeadConnectionInPool_shouldReturnNewImapConnectionInstance() throws Exception { - ImapConnection imapConnectionOne = createMockConnection(); - ImapConnection imapConnectionTwo = createMockConnection(); - imapStore.enqueueImapConnection(imapConnectionOne); - imapStore.enqueueImapConnection(imapConnectionTwo); - imapStore.getConnection(); - when(imapConnectionOne.isConnected()).thenReturn(true); - doThrow(IOException.class).when(imapConnectionOne).executeSimpleCommand(Commands.NOOP); - imapStore.releaseConnection(imapConnectionOne); - - ImapConnection result = imapStore.getConnection(); - - assertSame(imapConnectionTwo, result); - } - - @Test - public void getConnection_withConnectionInPoolAndCloseAllConnections_shouldReturnNewImapConnectionInstance() - throws Exception { - ImapConnection imapConnectionOne = createMockConnection(1); - ImapConnection imapConnectionTwo = createMockConnection(2); - imapStore.enqueueImapConnection(imapConnectionOne); - imapStore.enqueueImapConnection(imapConnectionTwo); - imapStore.getConnection(); - when(imapConnectionOne.isConnected()).thenReturn(true); - imapStore.releaseConnection(imapConnectionOne); - imapStore.closeAllConnections(); - - ImapConnection result = imapStore.getConnection(); - - assertSame(imapConnectionTwo, result); - } - - @Test - public void getConnection_withConnectionOutsideOfPoolAndCloseAllConnections_shouldReturnNewImapConnectionInstance() - throws Exception { - ImapConnection imapConnectionOne = createMockConnection(1); - ImapConnection imapConnectionTwo = createMockConnection(2); - imapStore.enqueueImapConnection(imapConnectionOne); - imapStore.enqueueImapConnection(imapConnectionTwo); - imapStore.getConnection(); - when(imapConnectionOne.isConnected()).thenReturn(true); - imapStore.closeAllConnections(); - imapStore.releaseConnection(imapConnectionOne); - - ImapConnection result = imapStore.getConnection(); - - assertSame(imapConnectionTwo, result); - } - - - private ImapConnection createMockConnection() { - ImapConnection imapConnection = mock(ImapConnection.class); - when(imapConnection.getConnectionGeneration()).thenReturn(1); - return imapConnection; - } - - private ImapConnection createMockConnection(int connectionGeneration) { - ImapConnection imapConnection = mock(ImapConnection.class); - when(imapConnection.getConnectionGeneration()).thenReturn(connectionGeneration); - return imapConnection; - } - - - private ServerSettings createServerSettings() { - Map extra = ImapStoreSettings.createExtra(true, null); - return new ServerSettings( - "imap", - "imap.example.org", - 143, - ConnectionSecurity.NONE, - AuthType.PLAIN, - "user", - "password", - null, - extra); - } - - private Set extractFolderServerIds(List folders) { - Set folderServerIds = new HashSet<>(folders.size()); - for (FolderListItem folder : folders) { - folderServerIds.add(folder.getServerId()); - } - - return folderServerIds; - } - - private Set extractFolderNames(List folders) { - Set folderNames = new HashSet<>(folders.size()); - for (FolderListItem folder : folders) { - folderNames.add(folder.getName()); - } - - return folderNames; - } - - private Set extractOldFolderServerIds(List folders) { - Set folderNames = new HashSet<>(folders.size()); - for (FolderListItem folder : folders) { - String oldServerId = folder.getOldServerId(); - if (oldServerId != null) { - folderNames.add(oldServerId); - } - } - - return folderNames; - } - - private FolderListItem getFolderByServerId(List result, String serverId) { - for (FolderListItem imapFolder : result) { - if (imapFolder.getServerId().equals(serverId)) { - return imapFolder; - } - } - return null; - } - - private Map toFolderMap(List folders) { - Map folderMap = new HashMap<>(); - for (FolderListItem folder : folders) { - folderMap.put(folder.getServerId(), folder); - } - - return folderMap; - } - - - static class TestImapStore extends RealImapStore { - private Deque imapConnections = new ArrayDeque<>(); - private String testCombinedPrefix; - - public TestImapStore(ServerSettings serverSettings, ImapStoreConfig config, - TrustedSocketFactory trustedSocketFactory, OAuth2TokenProvider oauth2TokenProvider) { - super(serverSettings, config, trustedSocketFactory, oauth2TokenProvider); - } - - @Override - ImapConnection createImapConnection() { - if (imapConnections.isEmpty()) { - throw new AssertionError("Unexpectedly tried to create an ImapConnection instance"); - } - return imapConnections.pop(); - } - - public void enqueueImapConnection(ImapConnection imapConnection) { - imapConnections.add(imapConnection); - } - - @Override - @NotNull - public String getCombinedPrefix() { - return testCombinedPrefix != null ? testCombinedPrefix : super.getCombinedPrefix(); - } - - void setTestCombinedPrefix(String prefix) { - testCombinedPrefix = prefix; - } - } -} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..113b42f2097bcb2c7d52d152886834a9a7c7f949 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapStoreTest.kt @@ -0,0 +1,441 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse +import com.fsck.k9.mail.store.imap.ImapStoreSettings.createExtra +import com.google.common.truth.Truth.assertThat +import java.io.IOException +import java.util.ArrayDeque +import java.util.Deque +import org.junit.Assert.fail +import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify + +class RealImapStoreTest { + private val imapStore = createTestImapStore() + + @Test + fun `checkSettings() should create ImapConnection and call open()`() { + val imapConnection = createMockConnection() + imapStore.enqueueImapConnection(imapConnection) + + imapStore.checkSettings() + + verify(imapConnection).open() + } + + @Test + fun `checkSettings() with open throwing should throw MessagingException`() { + val imapConnection = createMockConnection().stub { + on { open() } doThrow IOException::class + } + imapStore.enqueueImapConnection(imapConnection) + + try { + imapStore.checkSettings() + fail("Expected exception") + } catch (e: MessagingException) { + assertThat(e).hasMessageThat().isEqualTo("Unable to connect") + assertThat(e).hasCauseThat().isInstanceOf(IOException::class.java) + } + } + + @Test + fun `getFolders() with SPECIAL-USE capability should return special FolderInfo`() { + val imapConnection = createMockConnection().stub { + on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true + on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true + on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf( + createImapResponse("""* LIST (\HasNoChildren) "/" "INBOX""""), + createImapResponse("""* LIST (\Noselect \HasChildren) "/" "[Gmail]""""), + createImapResponse("""* LIST (\HasNoChildren \All) "/" "[Gmail]/All Mail""""), + createImapResponse("""* LIST (\HasNoChildren \Drafts) "/" "[Gmail]/Drafts""""), + createImapResponse("""* LIST (\HasNoChildren \Important) "/" "[Gmail]/Important""""), + createImapResponse("""* LIST (\HasNoChildren \Sent) "/" "[Gmail]/Sent Mail""""), + createImapResponse("""* LIST (\HasNoChildren \Junk) "/" "[Gmail]/Spam""""), + createImapResponse("""* LIST (\HasNoChildren \Flagged) "/" "[Gmail]/Starred""""), + createImapResponse("""* LIST (\HasNoChildren \Trash) "/" "[Gmail]/Trash""""), + createImapResponse("5 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + + val folders = imapStore.getFolders() + + val foldersMap = folders.map { it.serverId to it.type } + assertThat(foldersMap).containsExactly( + "INBOX" to FolderType.INBOX, + "[Gmail]/All Mail" to FolderType.ARCHIVE, + "[Gmail]/Drafts" to FolderType.DRAFTS, + "[Gmail]/Important" to FolderType.REGULAR, + "[Gmail]/Sent Mail" to FolderType.SENT, + "[Gmail]/Spam" to FolderType.SPAM, + "[Gmail]/Starred" to FolderType.REGULAR, + "[Gmail]/Trash" to FolderType.TRASH + ) + } + + @Test + fun `getFolders() without SPECIAL-USE capability should use simple LIST command`() { + val imapConnection = createMockConnection().stub { + on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true + on { hasCapability(Capabilities.SPECIAL_USE) } doReturn false + } + imapStore.enqueueImapConnection(imapConnection) + + imapStore.getFolders() + + verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") + verify(imapConnection).executeSimpleCommand("""LIST "" "*"""") + } + + @Test + fun `getFolders() without LIST-EXTENDED capability should use simple LIST command`() { + val imapConnection = createMockConnection().stub { + on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn false + on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true + } + imapStore.enqueueImapConnection(imapConnection) + + imapStore.getFolders() + + verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") + verify(imapConnection).executeSimpleCommand("""LIST "" "*"""") + } + + @Test + fun `getFolders() with subscribedFoldersOnly = false`() { + val imapStore = createTestImapStore(isSubscribedFoldersOnly = false) + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf( + createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""), + createImapResponse("""* LIST (\Noselect \HasChildren) "." "Folder""""), + createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""), + createImapResponse("6 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + + val folders = imapStore.getFolders() + + assertThat(folders).isNotNull() + assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder") + } + + @Test + fun `getFolders() with subscribedFoldersOnly = true should only return existing subscribed folders`() { + val imapStore = createTestImapStore(isSubscribedFoldersOnly = true) + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LSUB "" "*"""") } doReturn listOf( + createImapResponse("""* LSUB (\HasNoChildren) "." "INBOX""""), + createImapResponse("""* LSUB (\Noselect \HasChildren) "." "Folder""""), + createImapResponse("""* LSUB (\HasNoChildren) "." "Folder.SubFolder""""), + createImapResponse("""* LSUB (\HasNoChildren) "." "SubscribedFolderThatHasBeenDeleted""""), + createImapResponse("5 OK Success") + ) + on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf( + createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""), + createImapResponse("""* LIST (\Noselect \HasChildren) "." "Folder""""), + createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""), + createImapResponse("6 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + + val folders = imapStore.getFolders() + + assertThat(folders).isNotNull() + assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder") + } + + @Test + fun `getFolders() with namespace prefix`() { + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf( + createImapResponse("""* LIST () "." "INBOX""""), + createImapResponse("""* LIST () "." "INBOX.FolderOne""""), + createImapResponse("""* LIST () "." "INBOX.FolderTwo""""), + createImapResponse("5 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + imapStore.setTestCombinedPrefix("INBOX.") + + val folders = imapStore.getFolders() + + assertThat(folders).isNotNull() + assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "INBOX.FolderTwo") + assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo") + assertThat(folders.map { it.oldServerId }).containsExactly("INBOX", "FolderOne", "FolderTwo") + } + + @Test + fun `getFolders() with folder not matching namespace prefix`() { + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf( + createImapResponse("""* LIST () "." "INBOX""""), + createImapResponse("""* LIST () "." "INBOX.FolderOne""""), + createImapResponse("""* LIST () "." "FolderTwo""""), + createImapResponse("5 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + imapStore.setTestCombinedPrefix("INBOX.") + + val folders = imapStore.getFolders() + + assertThat(folders).isNotNull() + assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "FolderTwo") + assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo") + assertThat(folders.mapNotNull { it.oldServerId }).containsExactly("INBOX", "FolderOne") + } + + @Test + fun `getFolders() with duplicate folder names should remove duplicates and keep FolderType`() { + val imapConnection = createMockConnection().stub { + on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true + on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true + on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf( + createImapResponse("""* LIST () "." "INBOX""""), + createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""), + createImapResponse("""* LIST (\Junk) "." "Junk""""), + createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""), + createImapResponse("5 OK Success") + ) + } + imapStore.enqueueImapConnection(imapConnection) + + val folders = imapStore.getFolders() + + assertThat(folders.map { it.serverId to it.type }).containsExactly( + "INBOX" to FolderType.INBOX, + "Junk" to FolderType.SPAM + ) + } + + @Test + fun `getFolders() without exception should leave ImapConnection open`() { + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand(anyString()) } doReturn listOf(createImapResponse("5 OK Success")) + } + imapStore.enqueueImapConnection(imapConnection) + + imapStore.getFolders() + + verify(imapConnection, never()).close() + } + + @Test + fun `getFolders() with IOException should close ImapConnection`() { + val imapConnection = createMockConnection().stub { + on { executeSimpleCommand("""LIST "" "*"""") } doThrow IOException::class + } + imapStore.enqueueImapConnection(imapConnection) + + try { + imapStore.getFolders() + fail("Expected exception") + } catch (ignored: MessagingException) { + } + + verify(imapConnection).close() + } + + @Test + fun `getConnection() should create ImapConnection`() { + val imapConnection = createMockConnection() + imapStore.enqueueImapConnection(imapConnection) + + val result = imapStore.getConnection() + + assertThat(result).isSameInstanceAs(imapConnection) + } + + @Test + fun `getConnection() called twice without release should create two ImapConnection instances`() { + val imapConnectionOne = createMockConnection() + val imapConnectionTwo = createMockConnection() + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + val resultOne = imapStore.getConnection() + val resultTwo = imapStore.getConnection() + + assertThat(resultOne).isSameInstanceAs(imapConnectionOne) + assertThat(resultTwo).isSameInstanceAs(imapConnectionTwo) + } + + @Test + fun `getConnection() called after release should return cached ImapConnection`() { + val imapConnection = createMockConnection().stub { + on { isConnected } doReturn true + } + imapStore.enqueueImapConnection(imapConnection) + + val connection = imapStore.getConnection() + imapStore.releaseConnection(connection) + + val result = imapStore.getConnection() + + assertThat(result).isSameInstanceAs(imapConnection) + } + + @Test + fun `getConnection() called after release with closed connection should return new ImapConnection instance`() { + val imapConnectionOne = createMockConnection() + val imapConnectionTwo = createMockConnection() + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + imapStore.getConnection() + imapConnectionOne.stub { + on { isConnected } doReturn false + } + imapStore.releaseConnection(imapConnectionOne) + + val result = imapStore.getConnection() + + assertThat(result).isSameInstanceAs(imapConnectionTwo) + } + + @Test + fun `getConnection() with dead connection in pool should return new ImapConnection instance`() { + val imapConnectionOne = createMockConnection() + val imapConnectionTwo = createMockConnection() + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + imapStore.getConnection() + imapConnectionOne.stub { + on { isConnected } doReturn true + on { executeSimpleCommand(Commands.NOOP) } doThrow IOException::class + } + imapStore.releaseConnection(imapConnectionOne) + + val result = imapStore.getConnection() + + assertThat(result).isSameInstanceAs(imapConnectionTwo) + } + + @Test + fun `getConnection() with connection in pool and closeAllConnections() should return new ImapConnection instance`() { + val imapConnectionOne = createMockConnection(1) + val imapConnectionTwo = createMockConnection(2) + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + imapStore.getConnection() + imapConnectionOne.stub { + on { isConnected } doReturn true + } + imapStore.releaseConnection(imapConnectionOne) + imapStore.closeAllConnections() + + val result = imapStore.getConnection() + + assertThat(result).isSameInstanceAs(imapConnectionTwo) + } + + @Test + fun `getConnection() with connection outside of pool and closeAllConnections() should return new ImapConnection instance`() { + val imapConnectionOne = createMockConnection(1) + val imapConnectionTwo = createMockConnection(2) + imapStore.enqueueImapConnection(imapConnectionOne) + imapStore.enqueueImapConnection(imapConnectionTwo) + + imapStore.getConnection() + imapConnectionOne.stub { + on { isConnected } doReturn true + } + imapStore.closeAllConnections() + imapStore.releaseConnection(imapConnectionOne) + + val result = imapStore.getConnection() + + assertThat(result).isSameInstanceAs(imapConnectionTwo) + } + + private fun createMockConnection(connectionGeneration: Int = 1): ImapConnection { + return mock { + on { this.connectionGeneration } doReturn connectionGeneration + } + } + + private fun createServerSettings(): ServerSettings { + return ServerSettings( + type = "imap", + host = "imap.example.org", + port = 143, + connectionSecurity = ConnectionSecurity.NONE, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + extra = createExtra(autoDetectNamespace = true, pathPrefix = null) + ) + } + + private fun createTestImapStore( + isSubscribedFoldersOnly: Boolean = false, + useCompression: Boolean = false + ): TestImapStore { + return TestImapStore( + serverSettings = createServerSettings(), + config = createImapStoreConfig(isSubscribedFoldersOnly, useCompression), + trustedSocketFactory = mock(), + oauth2TokenProvider = null + ) + } + + private fun createImapStoreConfig(isSubscribedFoldersOnly: Boolean, useCompression: Boolean): ImapStoreConfig { + return object : ImapStoreConfig { + override val logLabel: String = "irrelevant" + override fun isSubscribedFoldersOnly(): Boolean = isSubscribedFoldersOnly + override fun useCompression(): Boolean = useCompression + } + } + + private class TestImapStore( + serverSettings: ServerSettings, + config: ImapStoreConfig, + trustedSocketFactory: TrustedSocketFactory, + oauth2TokenProvider: OAuth2TokenProvider? + ) : RealImapStore( + serverSettings, config, trustedSocketFactory, oauth2TokenProvider + ) { + private val imapConnections: Deque = ArrayDeque() + private var testCombinedPrefix: String? = null + + override fun createImapConnection(): ImapConnection { + if (imapConnections.isEmpty()) { + throw AssertionError("Unexpectedly tried to create an ImapConnection instance") + } + + return imapConnections.pop() + } + + fun enqueueImapConnection(imapConnection: ImapConnection) { + imapConnections.add(imapConnection) + } + + override fun getCombinedPrefix(): String { + return testCombinedPrefix ?: super.getCombinedPrefix() + } + + fun setTestCombinedPrefix(prefix: String?) { + testCombinedPrefix = prefix + } + } +} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.java deleted file mode 100644 index 391ae35d8a696d9efcac15479a224102625ac1ad..0000000000000000000000000000000000000000 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.fsck.k9.mail.store.imap; - - -import com.fsck.k9.mail.AuthType; -import com.fsck.k9.mail.ConnectionSecurity; - - -class SimpleImapSettings implements ImapSettings { - private String host; - private int port; - private ConnectionSecurity connectionSecurity = ConnectionSecurity.NONE; - private AuthType authType; - private String username; - private String password; - private String pathPrefix; - private String pathDelimiter; - private String combinedPrefix; - private boolean useCompression = false; - - - @Override - public String getHost() { - return host; - } - - @Override - public int getPort() { - return port; - } - - @Override - public ConnectionSecurity getConnectionSecurity() { - return connectionSecurity; - } - - @Override - public AuthType getAuthType() { - return authType; - } - - @Override - public String getUsername() { - return username; - } - - @Override - public String getPassword() { - return password; - } - - @Override - public String getClientCertificateAlias() { - return null; - } - - @Override - public boolean useCompression() { - return useCompression; - } - - @Override - public String getPathPrefix() { - return pathPrefix; - } - - @Override - public void setPathPrefix(String prefix) { - pathPrefix = prefix; - } - - @Override - public String getPathDelimiter() { - return pathDelimiter; - } - - @Override - public void setPathDelimiter(String delimiter) { - pathDelimiter = delimiter; - } - - @Override - public void setCombinedPrefix(String prefix) { - combinedPrefix = prefix; - } - - void setHost(String host) { - this.host = host; - } - - void setPort(int port) { - this.port = port; - } - - void setConnectionSecurity(ConnectionSecurity connectionSecurity) { - this.connectionSecurity = connectionSecurity; - } - - void setAuthType(AuthType authType) { - this.authType = authType; - } - - void setUsername(String username) { - this.username = username; - } - - void setPassword(String password) { - this.password = password; - } - - void setUseCompression(boolean useCompression) { - this.useCompression = useCompression; - } -} diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt new file mode 100644 index 0000000000000000000000000000000000000000..5e75c230d34c81a76e7f5629c3d6d00b4fefd769 --- /dev/null +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/SimpleImapSettings.kt @@ -0,0 +1,21 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity + +internal class SimpleImapSettings( + override val host: String, + override val port: Int = 0, + override val connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE, + override val authType: AuthType, + override val username: String, + override val password: String? = null, + override val useCompression: Boolean = false +) : ImapSettings { + override val clientCertificateAlias: String? = null + + override var pathPrefix: String? = null + override var pathDelimiter: String? = null + + override fun setCombinedPrefix(prefix: String?) = Unit +} diff --git a/mail/protocols/pop3/build.gradle b/mail/protocols/pop3/build.gradle index 8f750863e6688bb488002885c4327848b3acf70f..f798dc66674973431b5677be6db4aafa9f07e4fe 100644 --- a/mail/protocols/pop3/build.gradle +++ b/mail/protocols/pop3/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'java-library' +apply plugin: 'com.android.lint' if (rootProject.testCoverage) { apply plugin: 'jacoco' diff --git a/mail/protocols/smtp/build.gradle b/mail/protocols/smtp/build.gradle index fde2b46a9da4d940da00f0f41307795421238233..e7740641e73daf607a70f96e3e758be05153d898 100644 --- a/mail/protocols/smtp/build.gradle +++ b/mail/protocols/smtp/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' +apply plugin: 'com.android.lint' if (rootProject.testCoverage) { apply plugin: 'jacoco' diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt index 9b813c0ae5ca806c1890c977c9cb4b658ef08ff7..a9d4d3e62ba7609a4a320098072305fd2d069137 100644 --- a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt +++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt @@ -27,7 +27,7 @@ class SmtpResponseParserTest { val input = """ 220-Greetings, stranger 220 smtp.domain.example ESMTP ready - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val response = parser.readGreeting() @@ -125,7 +125,7 @@ class SmtpResponseParserTest { val input = """ 250-smtp.domain.example 220 - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) assertFailsWithMessage("Multi-line response with reply codes not matching: 250 != 220") { @@ -154,7 +154,7 @@ class SmtpResponseParserTest { 250-PIPE_CONNECT 250-AUTH=PLAIN 250 HELP - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val response = parser.readHelloResponse() @@ -183,7 +183,7 @@ class SmtpResponseParserTest { val input = """ 250-smtp.domain.example 250 KEYWORD${" "} - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val response = parser.readHelloResponse() @@ -203,7 +203,7 @@ class SmtpResponseParserTest { 250-smtp.domain.example 250-8BITMIME 250 KEYWORD para${"\t"}meter - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val response = parser.readHelloResponse() @@ -298,7 +298,7 @@ class SmtpResponseParserTest { val input = """ 500-Line one 500 Line two - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val response = parser.readResponse(enhancedStatusCodes = false) @@ -314,7 +314,7 @@ class SmtpResponseParserTest { val input = """ 500- 500 Line two - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val response = parser.readResponse(enhancedStatusCodes = false) @@ -331,7 +331,7 @@ class SmtpResponseParserTest { 500-Line one 500-Line two 500 - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val response = parser.readResponse(enhancedStatusCodes = false) @@ -347,7 +347,7 @@ class SmtpResponseParserTest { val input = """ 250-2.1.0 Sender 250 2.1.0 OK - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val response = parser.readResponse(enhancedStatusCodes = true) @@ -365,7 +365,7 @@ class SmtpResponseParserTest { val input = """ 250 Sender OK 250 Recipient OK - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val responseOne = parser.readResponse(enhancedStatusCodes = false) @@ -387,7 +387,7 @@ class SmtpResponseParserTest { val input = """ 200-Line one 500 Line two - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) assertFailsWithMessage("Multi-line response with reply codes not matching: 200 != 500") { @@ -411,7 +411,7 @@ class SmtpResponseParserTest { val input = """ 200-Line one 500 Line two - """.toPeekableInputStream() + """.toPeekableInputStream() val logger = TestSmtpLogger(isRawProtocolLoggingEnabled = false) val parser = SmtpResponseParser(logger, input) @@ -627,7 +627,7 @@ class SmtpResponseParserTest { val input = """ 550-5.2.1 Request failed 550 Mailbox unavailable - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) assertFailsWithMessage( @@ -643,7 +643,7 @@ class SmtpResponseParserTest { val input = """ 550-Request failed 550 Mailbox unavailable - """.toPeekableInputStream() + """.toPeekableInputStream() val parser = SmtpResponseParser(logger, input) val response = parser.readResponse(enhancedStatusCodes = true) diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt index a0c135f6ff2a5b4f43c73457cd944130fa6e6389..bc8cd8840d8fc4bff41b17d2d5ab8d0b3a9b6f01 100644 --- a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt +++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt @@ -730,7 +730,7 @@ class SmtpTransportTest { assertThat(e).hasMessageThat().isEqualTo("Message too large for server") } - // FIXME: Make sure connection was closed + // FIXME: Make sure connection was closed // server.verifyConnectionClosed(); } diff --git a/mail/protocols/webdav/build.gradle b/mail/protocols/webdav/build.gradle index f7af9dcd2062cb6f82f4c9f63cfc7eb364608827..ee1e5fb27efe3608d305bff2845e8488d2894488 100644 --- a/mail/protocols/webdav/build.gradle +++ b/mail/protocols/webdav/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'java-library' +apply plugin: 'com.android.lint' if (rootProject.testCoverage) { apply plugin: 'jacoco' @@ -13,11 +14,11 @@ dependencies { api project(":mail:common") implementation "commons-io:commons-io:${versions.commonsIo}" - compileOnly "org.apache.httpcomponents:httpclient:4.5.5" + compileOnly "org.apache.httpcomponents:httpclient:${versions.httpClient}" testImplementation project(":mail:testing") testImplementation "junit:junit:${versions.junit}" testImplementation "com.google.truth:truth:${versions.truth}" testImplementation "org.mockito:mockito-inline:${versions.mockito}" - testImplementation "org.apache.httpcomponents:httpclient:4.5.5" + testImplementation "org.apache.httpcomponents:httpclient:${versions.httpClient}" } diff --git a/mail/testing/build.gradle b/mail/testing/build.gradle index c1d44d29a55c2d773faae4c141c536eefe5a776c..709cd1c9aa12348e3afe8d3150e65b61100bdca3 100644 --- a/mail/testing/build.gradle +++ b/mail/testing/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' +apply plugin: 'com.android.lint' if (rootProject.testCoverage) { apply plugin: 'jacoco' diff --git a/plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/OpenPgpApiManager.java b/plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/OpenPgpApiManager.java index aa82dfdcf26b699c99c2267704f04fd7ad495875..57516d0b6d58a0fe89d83a084c971e00a086f5f6 100644 --- a/plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/OpenPgpApiManager.java +++ b/plugins/openpgp-api-lib/openpgp-api/src/main/java/org/openintents/openpgp/OpenPgpApiManager.java @@ -2,6 +2,8 @@ package org.openintents.openpgp; import android.app.PendingIntent; + +import androidx.annotation.NonNull; import androidx.lifecycle.Lifecycle.Event; import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.LifecycleOwner; @@ -224,6 +226,7 @@ public class OpenPgpApiManager implements LifecycleObserver { return openPgpProviderName != null ? openPgpProviderName : openPgpProvider; } + @NonNull public OpenPgpProviderState getOpenPgpProviderState() { return openPgpProviderState; }