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 b288ab96dfd120e690f555d09265adfe0a6f7ba6..ada0509098134b03910670248bf42f595c02e09f 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 @@ -642,7 +642,7 @@ public class MessagingController { if (commandException != null && !syncListener.syncFailed) { String rootMessage = getRootCauseMessage(commandException); Timber.e("Root cause failure in %s:%s was '%s'", account, folderServerId, rootMessage); - updateFolderStatus(account, folderServerId, rootMessage); + updateFolderStatus(account, folderId, rootMessage); listener.synchronizeMailboxFailed(account, folderId, rootMessage); } } @@ -657,14 +657,9 @@ public class MessagingController { SYNC_FLAGS); } - private void updateFolderStatus(Account account, String folderServerId, String status) { - try { - LocalStore localStore = localStoreProvider.getInstance(account); - LocalFolder localFolder = localStore.getFolder(folderServerId); - localFolder.setStatus(status); - } catch (MessagingException e) { - Timber.w(e, "Couldn't update folder status for folder %s", folderServerId); - } + private void updateFolderStatus(Account account, long folderId, String status) { + MessageStore messageStore = messageStoreManager.getMessageStore(account); + messageStore.setStatus(folderId, status); } public void handleAuthenticationFailure(Account account, boolean incoming) { @@ -1260,51 +1255,37 @@ public class MessagingController { ); } - private void loadMessageRemoteSynchronous(Account account, long folderId, String uid, + private void loadMessageRemoteSynchronous(Account account, long folderId, String messageServerId, MessagingListener listener, boolean loadPartialFromSearch) { try { - LocalStore localStore = localStoreProvider.getInstance(account); - LocalFolder localFolder = localStore.getFolder(folderId); - localFolder.open(); - String folderServerId = localFolder.getServerId(); - - LocalMessage message = localFolder.getMessage(uid); + if (messageServerId.startsWith(K9.LOCAL_UID_PREFIX)) { + throw new IllegalArgumentException("Must not be called with a local UID"); + } - if (uid.startsWith(K9.LOCAL_UID_PREFIX)) { - Timber.w("Message has local UID so cannot download fully."); - // ASH move toast - android.widget.Toast.makeText(context, - "Message has local UID so cannot download fully", - android.widget.Toast.LENGTH_LONG).show(); - // TODO: Using X_DOWNLOADED_FULL is wrong because it's only a partial message. But - // one we can't download completely. Maybe add a new flag; X_PARTIAL_MESSAGE ? - message.setFlag(Flag.X_DOWNLOADED_FULL, true); - message.setFlag(Flag.X_DOWNLOADED_PARTIAL, false); - } else { - Backend backend = getBackend(account); + MessageStore messageStore = messageStoreManager.getMessageStore(account); - if (loadPartialFromSearch) { - SyncConfig syncConfig = createSyncConfig(account); - backend.downloadMessage(syncConfig, folderServerId, uid); - } else { - backend.downloadCompleteMessage(folderServerId, uid); - } + String folderServerId = messageStore.getFolderServerId(folderId); + if (folderServerId == null) { + throw new IllegalStateException("Folder not found (ID: " + folderId + ")"); + } - message = localFolder.getMessage(uid); + Backend backend = getBackend(account); - if (!loadPartialFromSearch) { - message.setFlag(Flag.X_DOWNLOADED_FULL, true); - } + if (loadPartialFromSearch) { + SyncConfig syncConfig = createSyncConfig(account); + backend.downloadMessage(syncConfig, folderServerId, messageServerId); + } else { + backend.downloadCompleteMessage(folderServerId, messageServerId); } - // now that we have the full message, refresh the headers for (MessagingListener l : getListeners(listener)) { - l.loadMessageRemoteFinished(account, folderId, uid); + l.loadMessageRemoteFinished(account, folderId, messageServerId); } } catch (Exception e) { for (MessagingListener l : getListeners(listener)) { - l.loadMessageRemoteFailed(account, folderId, uid, e); + l.loadMessageRemoteFailed(account, folderId, messageServerId, e); } + notifyUserIfCertificateProblem(account, e, true); Timber.e(e, "Error while loading remote message"); } @@ -1959,25 +1940,18 @@ public class MessagingController { }); } - public void deleteDraft(final Account account, long id) { - try { - Long folderId = account.getDraftsFolderId(); - if (folderId == null) { - Timber.w("No Drafts folder configured. Can't delete draft."); - return; - } - - LocalStore localStore = localStoreProvider.getInstance(account); - LocalFolder localFolder = localStore.getFolder(folderId); - localFolder.open(); - String uid = localFolder.getMessageUidById(id); - if (uid != null) { - MessageReference messageReference = new MessageReference(account.getUuid(), folderId, uid); - deleteMessage(messageReference); - } - } catch (MessagingException me) { - Timber.e(me, "Error deleting draft"); + public void deleteDraft(Account account, long messageId) { + Long folderId = account.getDraftsFolderId(); + if (folderId == null) { + Timber.w("No Drafts folder configured. Can't delete draft."); + return; } + + MessageStore messageStore = messageStoreManager.getMessageStore(account); + String messageServerId = messageStore.getMessageServerId(messageId); + MessageReference messageReference = new MessageReference(account.getUuid(), folderId, messageServerId); + + deleteMessage(messageReference); } public void deleteThreads(final List messages) { diff --git a/app/core/src/main/java/com/fsck/k9/helper/CollectionExtensions.kt b/app/core/src/main/java/com/fsck/k9/helper/CollectionExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..b9964069522680379d4c231af1ab915a5de3bbb2 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/helper/CollectionExtensions.kt @@ -0,0 +1,40 @@ +package com.fsck.k9.helper + +/** + * Returns a [Set] containing the results of applying the given [transform] function to each element in the original + * collection. + * + * If you know the size of the output or can make an educated guess, specify [expectedSize] as an optimization. + * The initial capacity of the `Set` will be derived from this value. + */ +inline fun Iterable.mapToSet(expectedSize: Int? = null, transform: (T) -> R): Set { + return if (expectedSize != null) { + mapTo(LinkedHashSet(setCapacity(expectedSize)), transform) + } else { + mapTo(mutableSetOf(), transform) + } +} + +/** + * Returns a [Set] containing the results of applying the given [transform] function to each element in the original + * collection. + * + * The size of the output is expected to be equal to the size of the input. If that's not the case, please use + * [mapToSet] instead. + */ +inline fun Collection.mapCollectionToSet(transform: (T) -> R): Set { + return mapToSet(expectedSize = size, transform) +} + +// A copy of Kotlin's internal mapCapacity() for the JVM +fun setCapacity(expectedSize: Int): Int = when { + // We are not coercing the value to a valid one and not throwing an exception. It is up to the caller to + // properly handle negative values. + expectedSize < 0 -> expectedSize + expectedSize < 3 -> expectedSize + 1 + expectedSize < INT_MAX_POWER_OF_TWO -> ((expectedSize / 0.75F) + 1.0F).toInt() + // any large value + else -> Int.MAX_VALUE +} + +private const val INT_MAX_POWER_OF_TWO: Int = 1 shl (Int.SIZE_BITS - 2) diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/LocalFolder.java b/app/core/src/main/java/com/fsck/k9/mailstore/LocalFolder.java index 406b29bcf1676b28dfec0110022243f2c28a6721..98ba7ba9f3ea22ab33c0c33815cc304504efe571 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/LocalFolder.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/LocalFolder.java @@ -117,10 +117,6 @@ public class LocalFolder { return lastChecked; } - public String getStatus() { - return status; - } - public long getDatabaseId() { return databaseId; } @@ -297,11 +293,6 @@ public class LocalFolder { } } - public void setStatus(final String status) throws MessagingException { - this.status = status; - updateFolderColumn("status", status); - } - private void updateFolderColumn(final String column, final Object value) throws MessagingException { this.localStore.getDatabase().execute(false, new DbCallback() { @Override diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 43b2e2e619c94529251dc19fd7e7720855e9397f..ce28af792bea39db00662d3b1f6099cf8a7194d3 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 33003 - versionName '6.303' + versionCode 33005 + versionName '6.305' // 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 470d3a0d1c0f9ccedd8995599f9e5367f5975ec1..4eb82940e31c2f24aef17b58e2eba1714975efaa 100644 --- a/app/k9mail/src/main/AndroidManifest.xml +++ b/app/k9mail/src/main/AndroidManifest.xml @@ -223,10 +223,6 @@ - - @@ -365,16 +361,6 @@ android:name=".service.DatabaseUpgradeService" android:exported="false"/> - - - - - - - diff --git a/app/k9mail/src/main/java/com/fsck/k9/directshare/K9ChooserTargetService.java b/app/k9mail/src/main/java/com/fsck/k9/directshare/K9ChooserTargetService.java deleted file mode 100644 index c74a5ec96f098ee1321dde6886956b40ca0c0ca3..0000000000000000000000000000000000000000 --- a/app/k9mail/src/main/java/com/fsck/k9/directshare/K9ChooserTargetService.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.fsck.k9.directshare; - - -import java.util.ArrayList; -import java.util.List; - -import android.annotation.TargetApi; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.graphics.Bitmap; -import android.graphics.drawable.Icon; -import android.os.Build; -import android.os.Bundle; -import android.service.chooser.ChooserTarget; -import android.service.chooser.ChooserTargetService; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.fsck.k9.activity.MessageCompose; -import com.fsck.k9.activity.compose.RecipientLoader; -import com.fsck.k9.activity.misc.ContactPicture; -import com.fsck.k9.contacts.ContactPictureLoader; -import com.fsck.k9.mail.Address; -import com.fsck.k9.view.RecipientSelectView.Recipient; - - -@TargetApi(Build.VERSION_CODES.M) -public class K9ChooserTargetService extends ChooserTargetService { - private static final int MAX_TARGETS = 5; - - private RecipientLoader recipientLoader; - private ContactPictureLoader contactPictureLoader; - - @Override - public void onCreate() { - super.onCreate(); - - Context applicationContext = getApplicationContext(); - recipientLoader = RecipientLoader.getMostContactedRecipientLoader(applicationContext, MAX_TARGETS); - contactPictureLoader = ContactPicture.getContactPictureLoader(); - } - - @Override - public List onGetChooserTargets(ComponentName targetActivityName, IntentFilter matchedFilter) { - List recipients = recipientLoader.loadInBackground(); - - return createChooserTargets(recipients); - } - - @NonNull - private List createChooserTargets(List recipients) { - float score = 1.0f; - - List targets = new ArrayList<>(); - ComponentName componentName = new ComponentName(this, MessageCompose.class); - for (Recipient recipient : recipients) { - Bundle intentExtras = prepareIntentExtras(recipient); - Icon icon = loadRecipientIcon(recipient); - - ChooserTarget chooserTarget = - new ChooserTarget(recipient.getDisplayNameOrAddress(), icon, score, componentName, intentExtras); - targets.add(chooserTarget); - - score -= 0.1; - } - - return targets; - } - - @NonNull - private Bundle prepareIntentExtras(Recipient recipient) { - Address address = recipient.address; - - Bundle extras = new Bundle(); - extras.putStringArray(Intent.EXTRA_EMAIL, new String[] { address.toString() }); - extras.putStringArray(Intent.EXTRA_CC, new String[0]); - extras.putStringArray(Intent.EXTRA_BCC, new String[0]); - - return extras; - } - - @Nullable - private Icon loadRecipientIcon(Recipient recipient) { - Bitmap bitmap = contactPictureLoader.getContactPicture(recipient); - if (bitmap == null) { - return null; - } - - return Icon.createWithBitmap(bitmap); - } -} diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt index 91b32d7d1d4c3f31d10522ffaf3c9da729169ab7..ca4f4305b3a32cf32d84cdd1f73f268aeb3ed05c 100644 --- a/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt @@ -12,16 +12,21 @@ import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat +import androidx.core.widget.doOnTextChanged import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.get import com.google.android.material.textfield.TextInputLayout /** * Configures a [TextInputLayout] so the password can only be revealed after authentication. + * + * **IMPORTANT**: Only call this after the instance state has been restored! Otherwise, restoring the previous state + * after the initial state has been set will be detected as replacing the whole text. In that case showing the password + * will be allowed without authentication. */ fun TextInputLayout.configureAuthenticatedPasswordToggle( activity: FragmentActivity, @@ -29,7 +34,7 @@ fun TextInputLayout.configureAuthenticatedPasswordToggle( subtitle: String, needScreenLockMessage: String, ) { - val viewModel = ViewModelProvider(activity).get(AuthenticatedPasswordToggleViewModel::class.java) + val viewModel = ViewModelProvider(activity).get() viewModel.textInputLayout = this viewModel.activity = activity @@ -69,9 +74,16 @@ fun TextInputLayout.configureAuthenticatedPasswordToggle( val editText = this.editText ?: error("TextInputLayout.editText == null") + editText.doOnTextChanged { text, _, before, count -> + // Check if the password field is empty or if all of the previous text was replaced + if (text != null && before > 0 && (text.isEmpty() || text.length - count == 0)) { + viewModel.isNewPassword = true + } + } + setEndIconOnClickListener { if (editText.isPasswordHidden) { - if (viewModel.isAuthenticated) { + if (viewModel.isShowPasswordAllowed) { activity.setSecure(true) editText.showPassword() } else { @@ -102,15 +114,18 @@ private fun FragmentActivity.setSecure(secure: Boolean) { @SuppressLint("StaticFieldLeak") class AuthenticatedPasswordToggleViewModel : ViewModel() { + val isShowPasswordAllowed: Boolean + get() = isAuthenticated || isNewPassword + + var isNewPassword = false var isAuthenticated = false var textInputLayout: TextInputLayout? = null var activity: FragmentActivity? = null set(value) { field = value - value?.lifecycle?.addObserver(object : LifecycleObserver { - @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) - fun removeReferences() { + value?.lifecycle?.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { textInputLayout = null field = null } 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 927d20833130363d0b9e5f81033902781f1ddcd0..9a625169ad8bbe9a566be4bf2fee3d4b10aee241 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 @@ -35,6 +35,7 @@ import android.view.animation.AnimationUtils import android.widget.ProgressBar import android.widget.Toast import androidx.appcompat.app.ActionBar +import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat @@ -122,7 +123,10 @@ open class MessageList : private val permissionUiHelper: PermissionUiHelper = K9PermissionUiHelper(this) private var actionBar: ActionBar? = null - private lateinit var searchView: SearchView + private var searchView: SearchView? = null + private var initialSearchViewQuery: String? = null + private var initialSearchViewIconified: Boolean = true + private var drawer: K9Drawer? = null private var openFolderTransaction: FragmentTransaction? = null private var progressBar: ProgressBar? = null @@ -588,6 +592,10 @@ open class MessageList : outState.putSerializable(STATE_DISPLAY_MODE, displayMode) outState.putBoolean(STATE_MESSAGE_VIEW_ONLY, messageViewOnly) outState.putBoolean(STATE_MESSAGE_LIST_WAS_DISPLAYED, messageListWasDisplayed) + searchView?.let { searchView -> + outState.putBoolean(STATE_SEARCH_VIEW_ICONIFIED, searchView.isIconified) + outState.putString(STATE_SEARCH_VIEW_QUERY, searchView.query?.toString()) + } } public override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -595,6 +603,8 @@ open class MessageList : messageViewOnly = savedInstanceState.getBoolean(STATE_MESSAGE_VIEW_ONLY) messageListWasDisplayed = savedInstanceState.getBoolean(STATE_MESSAGE_LIST_WAS_DISPLAYED) + initialSearchViewIconified = savedInstanceState.getBoolean(STATE_SEARCH_VIEW_ICONIFIED) + initialSearchViewQuery = savedInstanceState.getString(STATE_SEARCH_VIEW_QUERY) } private fun initializeActionBar() { @@ -702,7 +712,7 @@ open class MessageList : override fun dispatchKeyEvent(event: KeyEvent): Boolean { var eventHandled = false - if (event.action == KeyEvent.ACTION_DOWN && ::searchView.isInitialized && searchView.isIconified) { + if (event.action == KeyEvent.ACTION_DOWN && isSearchViewCollapsed()) { eventHandled = onCustomKeyDown(event) } @@ -722,8 +732,8 @@ open class MessageList : } else { showMessageList() } - } else if (this::searchView.isInitialized && !searchView.isIconified) { - searchView.isIconified = true + } else if (!isSearchViewCollapsed()) { + collapseSearchView() } else { if (isDrawerEnabled && account != null && supportFragmentManager.backStackEntryCount == 0) { if (K9.isShowUnifiedInbox) { @@ -933,6 +943,7 @@ open class MessageList : if (drawer!!.isOpen) { drawer!!.close() } else { + collapseSearchView() drawer!!.open() } } else { @@ -950,20 +961,32 @@ open class MessageList : override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.message_list_option, menu) - // setup search view val searchItem = menu.findItem(R.id.search) - searchView = searchItem.actionView as SearchView + initializeSearchMenuItem(searchItem) + + return true + } + + private fun initializeSearchMenuItem(searchItem: MenuItem) { + // Reuse existing SearchView if available + searchView?.let { searchView -> + searchItem.actionView = searchView + return + } + + val searchView = searchItem.actionView as SearchView searchView.maxWidth = Int.MAX_VALUE searchView.queryHint = resources.getString(R.string.search_action) val searchEditText = searchView.findViewById(androidx.appcompat.R.id.search_src_text) as SearchView.SearchAutoComplete val searchIcon = ContextCompat.getDrawable(this, R.drawable.ic_magnify) searchEditText.setCompoundDrawablesWithIntrinsicBounds(searchIcon, null, null, null) searchEditText.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.search_icon_searchView_padding) - val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager + val searchManager = getSystemService(SEARCH_SERVICE) as SearchManager searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName)) searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { messageListFragment?.onSearchRequested(query) + collapseSearchView() return true } @@ -972,7 +995,19 @@ open class MessageList : } }) - return true + searchView.isIconified = initialSearchViewIconified + searchView.setQuery(initialSearchViewQuery, false) + + this.searchView = searchView + } + + private fun isSearchViewCollapsed(): Boolean = searchView?.isIconified == true + + private fun collapseSearchView() { + searchView?.let { searchView -> + searchView.setQuery(null, false) + searchView.isIconified = true + } } fun setActionBarTitle(title: String, subtitle: String? = null) { @@ -1019,6 +1054,8 @@ open class MessageList : showMessageView() } } + + collapseSearchView() } override fun onForward(messageReference: MessageReference, decryptionResultForReply: Parcelable?) { @@ -1101,6 +1138,11 @@ open class MessageList : return true } + override fun startSupportActionMode(callback: ActionMode.Callback): ActionMode? { + collapseSearchView() + return super.startSupportActionMode(callback) + } + override fun showThread(account: Account, threadRootId: Long) { showMessageViewPlaceHolder() @@ -1443,6 +1485,8 @@ open class MessageList : private const val STATE_DISPLAY_MODE = "displayMode" private const val STATE_MESSAGE_VIEW_ONLY = "messageViewOnly" private const val STATE_MESSAGE_LIST_WAS_DISPLAYED = "messageListWasDisplayed" + private const val STATE_SEARCH_VIEW_ICONIFIED = "searchViewIconified" + private const val STATE_SEARCH_VIEW_QUERY = "searchViewQuery" private const val FIRST_FRAGMENT_TRANSACTION = "first" private const val FRAGMENT_TAG_MESSAGE_VIEW_CONTAINER = "MessageViewContainerFragment" 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 b81fccdc0da35cae0719b2d60d6140fd1fba88e0..f3c961298dde0d3de4ec99b5ec42a17c2ddc56bb 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 @@ -88,6 +88,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener private CheckBox mSubscribedFoldersOnly; private AuthTypeAdapter mAuthTypeAdapter; private ConnectionSecurity[] mConnectionSecurityChoices = ConnectionSecurity.values(); + private boolean editSettings; public static void actionIncomingSettings(Activity context, Account account, boolean makeDefault) { Intent i = new Intent(context, AccountSetupIncoming.class); @@ -173,16 +174,8 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener mAuthTypeAdapter = AuthTypeAdapter.get(this, oAuthSupported); mAuthTypeView.setAdapter(mAuthTypeAdapter); - boolean editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction()); + editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction()); if (editSettings) { - TextInputLayoutHelper.configureAuthenticatedPasswordToggle( - mPasswordLayoutView, - this, - getString(R.string.account_setup_basics_show_password_biometrics_title), - getString(R.string.account_setup_basics_show_password_biometrics_subtitle), - getString(R.string.account_setup_basics_show_password_need_lock) - ); - if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); } @@ -384,6 +377,16 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener mPasswordView.addTextChangedListener(validationTextWatcher); mServerView.addTextChangedListener(validationTextWatcher); mPortView.addTextChangedListener(validationTextWatcher); + + if (editSettings) { + TextInputLayoutHelper.configureAuthenticatedPasswordToggle( + mPasswordLayoutView, + this, + getString(R.string.account_setup_basics_show_password_biometrics_title), + getString(R.string.account_setup_basics_show_password_biometrics_subtitle), + getString(R.string.account_setup_basics_show_password_need_lock) + ); + } } @Override @@ -536,7 +539,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener } if (resultCode == RESULT_OK) { - if (Intent.ACTION_EDIT.equals(getIntent().getAction())) { + if (editSettings) { Preferences.getPreferences().saveAccount(mAccount); finish(); } else { 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 0cf310c823df569cfd5ff6a3a016859485835e57..3d5bab22c2bc71aa7774eff3bae2618d6dfaf9fc 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 @@ -74,6 +74,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, private AuthTypeAdapter mAuthTypeAdapter; private Button mNextButton; private Account mAccount; + private boolean editSettings; public static void actionOutgoingSettings(Context context, Account account) { Intent i = new Intent(context, AccountSetupOutgoing.class); @@ -150,16 +151,8 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, mAccount = Preferences.getPreferences().getAccount(accountUuid); } - boolean editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction()); + editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction()); if (editSettings) { - TextInputLayoutHelper.configureAuthenticatedPasswordToggle( - mPasswordLayoutView, - this, - getString(R.string.account_setup_basics_show_password_biometrics_title), - getString(R.string.account_setup_basics_show_password_biometrics_subtitle), - getString(R.string.account_setup_basics_show_password_need_lock) - ); - if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); } @@ -320,6 +313,16 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, mPasswordView.addTextChangedListener(validationTextWatcher); mServerView.addTextChangedListener(validationTextWatcher); mPortView.addTextChangedListener(validationTextWatcher); + + if (editSettings) { + TextInputLayoutHelper.configureAuthenticatedPasswordToggle( + mPasswordLayoutView, + this, + getString(R.string.account_setup_basics_show_password_biometrics_title), + getString(R.string.account_setup_basics_show_password_biometrics_subtitle), + getString(R.string.account_setup_basics_show_password_need_lock) + ); + } } @Override @@ -493,7 +496,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, } if (resultCode == RESULT_OK) { - if (Intent.ACTION_EDIT.equals(getIntent().getAction())) { + if (editSettings) { Preferences.getPreferences().saveAccount(mAccount); finish(); } else { 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 d09be1b11e18e90138444406435182780a1f538f..ee8452af784ce88676aa0803bee19200b5010028 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 @@ -58,20 +58,37 @@ class MessageListAdapter internal constructor( set(value) { field = value messagesMap = value.associateBy { it.uniqueId } - notifyDataSetChanged() - } - var isInSelectionMode: Boolean = false - set(value) { - field = value + if (selected.isNotEmpty()) { + val uniqueIds = messagesMap.keys + selected = selected.intersect(uniqueIds) + } + notifyDataSetChanged() } + private var isInSelectionMode: Boolean = false + private var messagesMap = emptyMap() var activeMessage: MessageReference? = null var selected: Set = emptySet() + private set(value) { + field = value + selectedCount = calculateSelectionCount() + isInSelectionMode = (selectedCount != 0) + notifyDataSetChanged() + } + + val selectedMessages: List + get() = selected.map { messagesMap[it]!! } + + val isAllSelected: Boolean + get() = selected.isNotEmpty() && selected.size == messages.size + + var selectedCount: Int = 0 + private set private inline val subjectViewFontSize: Int get() = if (appearance.senderAboveSubject) { @@ -111,6 +128,18 @@ class MessageListAdapter internal constructor( return messagesMap[uniqueId]!! } + fun getItem(messageReference: MessageReference): MessageListItem? { + return messages.firstOrNull { + it.account.uuid == messageReference.accountUuid && + it.folderId == messageReference.folderId && + it.messageUid == messageReference.uid + } + } + + fun getPosition(messageListItem: MessageListItem): Int? { + return messages.indexOf(messageListItem).takeIf { it != -1 } + } + override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { val message = getItem(position) val view: View = convertView ?: newView(parent) @@ -306,6 +335,65 @@ class MessageListAdapter internal constructor( item.folderId == activeMessage.folderId && item.messageUid == activeMessage.uid } + + fun toggleSelection(item: MessageListItem) { + if (messagesMap[item.uniqueId] == null) { + // MessageListItem is no longer in the list + return + } + + if (item.uniqueId in selected) { + deselectMessage(item) + } else { + selectMessage(item) + } + } + + private fun selectMessage(item: MessageListItem) { + selected = selected + item.uniqueId + } + + private fun deselectMessage(item: MessageListItem) { + selected = selected - item.uniqueId + } + + fun selectAll() { + val uniqueIds = messagesMap.keys.toSet() + selected = uniqueIds + } + + fun clearSelected() { + selected = emptySet() + } + + fun restoreSelected(selectedIds: Set) { + if (selectedIds.isEmpty()) { + clearSelected() + } else { + val uniqueIds = messagesMap.keys + selected = selectedIds.intersect(uniqueIds) + } + } + + fun checkSelectedMode() { + isInSelectionMode = (selectedCount != 0) + notifyDataSetChanged() + } + + private fun calculateSelectionCount(): Int { + if (selected.isEmpty()) { + return 0 + } + + if (!appearance.showingThreadedList) { + return selected.size + } + + return messages + .asSequence() + .filter { it.uniqueId in selected } + .sumOf { it.threadCount.coerceAtLeast(1) } + } } interface MessageListItemActionListener { 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 347e10a95b05abaea345a2ed3c624efbac59f53b..eb49e9b1493453a0ae344fcd69350eab705fdcea 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 @@ -17,7 +17,6 @@ import android.widget.AdapterView.OnItemLongClickListener import android.widget.ListView import android.widget.TextView import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.core.os.bundleOf import androidx.fragment.app.Fragment @@ -37,6 +36,7 @@ import com.fsck.k9.controller.SimpleMessagingListener import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener import com.fsck.k9.fragment.MessageListFragment.MessageListFragmentListener.Companion.MAX_PROGRESS import com.fsck.k9.helper.Utility +import com.fsck.k9.helper.mapToSet import com.fsck.k9.mail.Flag import com.fsck.k9.mail.MessagingException import com.fsck.k9.search.LocalSearch @@ -102,8 +102,6 @@ class MessageListFragment : private var sortType = SortType.SORT_DATE private var sortAscending = true private var sortDateAscending = false - private var selectedCount = 0 - private var selected: MutableSet = HashSet() private var actionMode: ActionMode? = null private var hasConnectivity: Boolean? = null @@ -117,6 +115,7 @@ class MessageListFragment : private var showingThreadedList = false private var isThreadDisplay = false private var activeMessage: MessageReference? = null + private var rememberedSelected: Set? = null lateinit var localSearch: LocalSearch private set @@ -200,10 +199,7 @@ class MessageListFragment : } private fun restoreSelectedMessages(savedInstanceState: Bundle) { - val selectedIds = savedInstanceState.getLongArray(STATE_SELECTED_MESSAGES) ?: return - for (id in selectedIds) { - selected.add(id) - } + rememberedSelected = savedInstanceState.getLongArray(STATE_SELECTED_MESSAGES)?.toSet() } fun restoreListState(savedListState: Parcelable) { @@ -408,7 +404,8 @@ class MessageListFragment : if (view === footerView) { handleFooterClick() } else { - handleListItemClick(position) + val messageListItem = adapter.getItem(position) + handleListItemClick(messageListItem) } } @@ -443,17 +440,14 @@ class MessageListFragment : } } - private fun handleListItemClick(position: Int) { - if (selectedCount > 0) { - toggleMessageSelect(position) + private fun handleListItemClick(messageListItem: MessageListItem) { + if (adapter.selectedCount > 0) { + toggleMessageSelect(messageListItem) } else { - val adapterPosition = listViewToAdapterPosition(position) - val messageListItem = adapter.getItem(adapterPosition) - if (showingThreadedList && messageListItem.threadCount > 1) { fragmentListener.showThread(messageListItem.account, messageListItem.threadRoot) } else { - openMessageAtPosition(adapterPosition) + openMessage(messageListItem.messageReference) } } } @@ -461,7 +455,9 @@ class MessageListFragment : override fun onItemLongClick(parent: AdapterView<*>?, view: View, position: Int, id: Long): Boolean { if (view === footerView) return false - toggleMessageSelect(position) + val messageListItem = adapter.getItem(position) + toggleMessageSelect(messageListItem) + return true } @@ -478,7 +474,7 @@ class MessageListFragment : super.onSaveInstanceState(outState) saveListState(outState) - outState.putLongArray(STATE_SELECTED_MESSAGES, selected.toLongArray()) + outState.putLongArray(STATE_SELECTED_MESSAGES, adapter.selected.toLongArray()) outState.putBoolean(STATE_REMOTE_SEARCH_PERFORMED, isRemoteSearch) outState.putStringArray( STATE_ACTIVE_MESSAGES, @@ -821,14 +817,6 @@ class MessageListFragment : messagingController.sendPendingMessages(account, null) } - private fun listViewToAdapterPosition(position: Int): Int { - return if (position in 0 until adapter.count) position else AdapterView.INVALID_POSITION - } - - private fun adapterToListViewPosition(position: Int): Int { - return if (position in 0 until adapter.count) position else AdapterView.INVALID_POSITION - } - private fun getFooterView(parent: ViewGroup?): View? { return footerView ?: createFooterView(parent).also { footerView = it } } @@ -878,89 +866,37 @@ class MessageListFragment : holder.main.text = text } - private fun setSelectionState(selected: Boolean) { - if (selected) { - if (adapter.count == 0) { - // Nothing to do if there are no messages - return - } - - selectedCount = 0 - for (i in 0 until adapter.count) { - val messageListItem = adapter.getItem(i) - this.selected.add(messageListItem.uniqueId) - - if (showingThreadedList) { - selectedCount += messageListItem.threadCount.coerceAtLeast(1) - } else { - selectedCount++ - } - } - - if (actionMode == null) { - startAndPrepareActionMode() - } - - computeBatchDirection() - updateActionMode() - computeSelectAllVisibility() - } else { - this.selected.clear() - selectedCount = 0 - - actionMode?.finish() - actionMode = null + private fun selectAll() { + if (adapter.messages.isEmpty()) { + // Nothing to do if there are no messages + return } - adapter.isInSelectionMode = (selectedCount != 0) - } + adapter.selectAll() - private fun toggleMessageSelect(listViewPosition: Int) { - val adapterPosition = listViewToAdapterPosition(listViewPosition) - if (adapterPosition == AdapterView.INVALID_POSITION) return + if (actionMode == null) { + startAndPrepareActionMode() + } - val messageListItem = adapter.getItem(adapterPosition) - toggleMessageSelect(messageListItem) + computeBatchDirection() + updateActionMode() } private fun toggleMessageSelect(messageListItem: MessageListItem) { - val uniqueId = messageListItem.uniqueId - val selected = selected.contains(uniqueId) - if (!selected) { - this.selected.add(uniqueId) - } else { - this.selected.remove(uniqueId) - } + adapter.toggleSelection(messageListItem) - var selectedCountDelta = 1 - if (showingThreadedList) { - val threadCount = messageListItem.threadCount - if (threadCount > 1) { - selectedCountDelta = threadCount - } + if (adapter.selectedCount == 0) { + actionMode?.finish() + actionMode = null + return } - if (actionMode != null) { - if (selected && selectedCount - selectedCountDelta == 0) { - actionMode?.finish() - actionMode = null - return - } - } else { + if (actionMode == null) { startAndPrepareActionMode() } - if (selected) { - selectedCount -= selectedCountDelta - } else { - selectedCount += selectedCountDelta - } - computeBatchDirection() updateActionMode() - computeSelectAllVisibility() - - adapter.isInSelectionMode = (selectedCount != 0) } override fun onToggleMessageSelection(item: MessageListItem) { @@ -973,36 +909,19 @@ class MessageListFragment : private fun updateActionMode() { val actionMode = actionMode ?: error("actionMode == null") - actionMode.title = getString(R.string.actionbar_selected, selectedCount) - actionMode.invalidate() - } + actionMode.title = getString(R.string.actionbar_selected, adapter.selectedCount) + actionModeCallback.showSelectAll(!adapter.isAllSelected) - private fun computeSelectAllVisibility() { - actionModeCallback.showSelectAll(selected.size != adapter.count) + actionMode.invalidate() } private fun computeBatchDirection() { - var isBatchFlag = false - var isBatchRead = false - for (i in 0 until adapter.count) { - val messageListItem = adapter.getItem(i) - if (selected.contains(messageListItem.uniqueId)) { - if (!messageListItem.isStarred) { - isBatchFlag = true - } - - if (!messageListItem.isRead) { - isBatchRead = true - } + val selectedMessages = adapter.selectedMessages + val notAllRead = !selectedMessages.all { it.isRead } + val notAllStarred = !selectedMessages.all { it.isStarred } - if (isBatchFlag && isBatchRead) { - break - } - } - } - - actionModeCallback.showMarkAsRead(isBatchRead) - actionModeCallback.showFlag(isBatchFlag) + actionModeCallback.showMarkAsRead(notAllRead) + actionModeCallback.showFlag(notAllStarred) } private fun setFlag(messageListItem: MessageListItem, flag: Flag, newState: Boolean) { @@ -1019,25 +938,22 @@ class MessageListFragment : } private fun setFlagForSelected(flag: Flag, newState: Boolean) { - if (selected.isEmpty()) return + if (adapter.selected.isEmpty()) return - val messageMap: MutableMap> = mutableMapOf() - val threadMap: MutableMap> = mutableMapOf() - val accounts: MutableSet = mutableSetOf() + val messageMap = mutableMapOf>() + val threadMap = mutableMapOf>() + val accounts = mutableSetOf() - for (position in 0 until adapter.count) { - val messageListItem = adapter.getItem(position) + for (messageListItem in adapter.selectedMessages) { val account = messageListItem.account - if (messageListItem.uniqueId in selected) { - accounts.add(account) + accounts.add(account) - if (showingThreadedList && messageListItem.threadCount > 1) { - val threadRootIdList = threadMap.getOrPut(account) { mutableListOf() } - threadRootIdList.add(messageListItem.threadRoot) - } else { - val messageIdList = messageMap.getOrPut(account) { mutableListOf() } - messageIdList.add(messageListItem.databaseId) - } + if (showingThreadedList && messageListItem.threadCount > 1) { + val threadRootIdList = threadMap.getOrPut(account) { mutableListOf() } + threadRootIdList.add(messageListItem.threadRoot) + } else { + val messageIdList = messageMap.getOrPut(account) { mutableListOf() } + messageIdList.add(messageListItem.databaseId) } } @@ -1304,10 +1220,6 @@ class MessageListFragment : super.onStop() } - fun selectAll() { - setSelectionState(true) - } - fun onMoveUp() { var currentPosition = listView.selectedItemPosition if (currentPosition == AdapterView.INVALID_POSITION || listView.isInTouchMode) { @@ -1330,28 +1242,6 @@ class MessageListFragment : } } - private fun getReferenceForPosition(position: Int): MessageReference { - val item = adapter.getItem(position) - return MessageReference(item.account.uuid, item.folderId, item.messageUid) - } - - private fun openMessageAtPosition(position: Int) { - // Scroll message into view if necessary - val listViewPosition = adapterToListViewPosition(position) - if (listViewPosition != AdapterView.INVALID_POSITION && - (listViewPosition < listView.firstVisiblePosition || listViewPosition > listView.lastVisiblePosition) - ) { - listView.setSelection(listViewPosition) - } - - val messageReference = getReferenceForPosition(position) - - // For some reason the listView.setSelection() above won't do anything when we call - // onOpenMessage() (and consequently adapter.notifyDataSetChanged()) right away. So we - // defer the call using MessageListHandler. - handler.openMessage(messageReference) - } - fun openMessage(messageReference: MessageReference) { fragmentListener.openMessage(messageReference) } @@ -1361,27 +1251,16 @@ class MessageListFragment : } private val selectedMessage: MessageReference? - get() { - val listViewPosition = listView.selectedItemPosition - val adapterPosition = listViewToAdapterPosition(listViewPosition) - if (adapterPosition == AdapterView.INVALID_POSITION) return null - return getReferenceForPosition(adapterPosition) - } + get() = selectedMessageListItem?.messageReference - private val adapterPositionForSelectedMessage: Int + private val selectedMessageListItem: MessageListItem? get() { - val listViewPosition = listView.selectedItemPosition - return listViewToAdapterPosition(listViewPosition) + val position = listView.selectedItemPosition + return if (position !in 0 until adapter.count) null else adapter.getItem(position) } - private val checkedMessages: List - get() { - return adapter.messages - .asSequence() - .filter { it.uniqueId in selected } - .map { MessageReference(it.account.uuid, it.folderId, it.messageUid) } - .toList() - } + private val selectedMessages: List + get() = adapter.selectedMessages.map { it.messageReference } fun onDelete() { selectedMessage?.let { message -> @@ -1390,29 +1269,21 @@ class MessageListFragment : } fun toggleMessageSelect() { - toggleMessageSelect(listView.selectedItemPosition) + selectedMessageListItem?.let { messageListItem -> + toggleMessageSelect(messageListItem) + } } fun onToggleFlagged() { - toggleFlag(Flag.FLAGGED) + selectedMessageListItem?.let { messageListItem -> + setFlag(messageListItem, Flag.FLAGGED, !messageListItem.isStarred) + } } fun onToggleRead() { - toggleFlag(Flag.SEEN) - } - - private fun toggleFlag(flag: Flag) { - val adapterPosition = adapterPositionForSelectedMessage - if (adapterPosition == ListView.INVALID_POSITION) return - - val messageListItem = adapter.getItem(adapterPosition) - val flagState = when (flag) { - Flag.SEEN -> messageListItem.isRead - Flag.FLAGGED -> messageListItem.isStarred - else -> false + selectedMessageListItem?.let { messageListItem -> + setFlag(messageListItem, Flag.SEEN, !messageListItem.isRead) } - - setFlag(messageListItem, flag, !flagState) } fun onMove() { @@ -1512,14 +1383,15 @@ class MessageListFragment : } } - cleanupSelected(messageListItems) - adapter.selected = selected - adapter.messages = messageListItems + rememberedSelected?.let { + rememberedSelected = null + adapter.restoreSelected(it) + } + resetActionMode() computeBatchDirection() - computeSelectAllVisibility() if (savedListState != null) { handler.restoreListPosition(savedListState) @@ -1532,22 +1404,13 @@ class MessageListFragment : currentFolder.moreMessages = messageListInfo.hasMoreMessages updateFooterView() } - adapter.isInSelectionMode = (selectedCount != 0) - } - - private fun cleanupSelected(messageListItems: List) { - if (selected.isEmpty()) return - - selected = messageListItems.asSequence() - .map { it.uniqueId } - .filter { it in selected } - .toMutableSet() + adapter.checkSelectedMode() } private fun resetActionMode() { if (!isResumed) return - if (!isActive || selected.isEmpty()) { + if (!isActive || adapter.selected.isEmpty()) { actionMode?.finish() actionMode = null return @@ -1557,28 +1420,14 @@ class MessageListFragment : startAndPrepareActionMode() } - recalculateSelectionCount() updateActionMode() } private fun startAndPrepareActionMode() { - val activity = requireActivity() as AppCompatActivity - actionMode = activity.startSupportActionMode(actionModeCallback) + actionMode = fragmentListener.startSupportActionMode(actionModeCallback) actionMode?.invalidate() } - private fun recalculateSelectionCount() { - if (!showingThreadedList) { - selectedCount = selected.size - return - } - - selectedCount = adapter.messages - .asSequence() - .filter { it.uniqueId in selected } - .sumOf { it.threadCount.coerceAtLeast(1) } - } - fun remoteSearchFinished() { remoteSearchFuture = null } @@ -1619,8 +1468,7 @@ class MessageListFragment : if (sortType != SortType.SORT_UNREAD && sortType != SortType.SORT_FLAGGED) return - val position = getPosition(messageReference) - val messageListItem = adapter.getItem(position) + val messageListItem = adapter.getItem(messageReference) ?: return val existingEntry = messageSortOverrides.firstOrNull { it.first == messageReference } if (existingEntry != null) { @@ -1637,20 +1485,11 @@ class MessageListFragment : } private fun scrollToMessage(messageReference: MessageReference) { - val position = getPosition(messageReference) - val viewPosition = adapterToListViewPosition(position) - if (viewPosition != AdapterView.INVALID_POSITION && - (viewPosition <= listView.firstVisiblePosition || viewPosition >= listView.lastVisiblePosition) - ) { - listView.smoothScrollToPosition(viewPosition) - } - } + val messageListItem = adapter.getItem(messageReference) ?: return + val position = adapter.getPosition(messageListItem) ?: return - private fun getPosition(messageReference: MessageReference): Int { - return adapter.messages.indexOfFirst { messageListItem -> - messageListItem.account.uuid == messageReference.accountUuid && - messageListItem.folderId == messageReference.folderId && - messageListItem.messageUid == messageReference.uid + if (position <= listView.firstVisiblePosition || position >= listView.lastVisiblePosition) { + listView.smoothScrollToPosition(position) } } @@ -1876,12 +1715,7 @@ class MessageListFragment : } private val accountUuidsForSelected: Set - get() { - return adapter.messages.asSequence() - .filter { it.uniqueId in selected } - .map { it.account.uuid } - .toSet() - } + get() = adapter.selectedMessages.mapToSet { it.account.uuid } override fun onDestroyActionMode(mode: ActionMode) { actionMode = null @@ -1891,7 +1725,7 @@ class MessageListFragment : flag = null unflag = null - setSelectionState(false) + adapter.clearSelected() } override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { @@ -1972,42 +1806,58 @@ class MessageListFragment : override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { // In the following we assume that we can't move or copy mails to the same folder. Also that spam isn't // available if we are in the spam folder, same for archive. - when (item.itemId) { + + val endSelectionMode = when (item.itemId) { R.id.delete -> { - val messages = checkedMessages - onDelete(messages) - selectedCount = 0 + onDelete(selectedMessages) + true + } + R.id.mark_as_read -> { + setFlagForSelected(Flag.SEEN, true) + false + } + R.id.mark_as_unread -> { + setFlagForSelected(Flag.SEEN, false) + false + } + R.id.flag -> { + setFlagForSelected(Flag.FLAGGED, true) + false + } + R.id.unflag -> { + setFlagForSelected(Flag.FLAGGED, false) + false + } + R.id.select_all -> { + selectAll() + false } - R.id.mark_as_read -> setFlagForSelected(Flag.SEEN, true) - R.id.mark_as_unread -> setFlagForSelected(Flag.SEEN, false) - R.id.flag -> setFlagForSelected(Flag.FLAGGED, true) - R.id.unflag -> setFlagForSelected(Flag.FLAGGED, false) - R.id.select_all -> selectAll() R.id.archive -> { - onArchive(checkedMessages) + onArchive(selectedMessages) // TODO: Only finish action mode if all messages have been moved. - selectedCount = 0 + true } R.id.spam -> { - onSpam(checkedMessages) + onSpam(selectedMessages) // TODO: Only finish action mode if all messages have been moved. - selectedCount = 0 + true } R.id.move -> { - onMove(checkedMessages) - selectedCount = 0 + onMove(selectedMessages) + true } R.id.move_to_drafts -> { - onMoveToDraftsFolder(checkedMessages) - selectedCount = 0 + onMoveToDraftsFolder(selectedMessages) + true } R.id.copy -> { - onCopy(checkedMessages) - selectedCount = 0 + onCopy(selectedMessages) + true } + else -> return false } - if (selectedCount == 0) { + if (endSelectionMode) { mode.finish() } @@ -2031,6 +1881,7 @@ class MessageListFragment : fun setMessageListTitle(title: String, subtitle: String?) fun onCompose(account: Account?) fun startSearch(query: String, account: Account?, folderId: Long?): Boolean + fun startSupportActionMode(callback: ActionMode.Callback): ActionMode? fun goBack() fun onFolderNotFoundError() diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListHandler.java b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListHandler.java index a6de78d14ccf32b903c5f86adf09353fa5d9a9df..d62e7b138ba1659fd231d9e33c6101c20d9e3d0f 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListHandler.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListHandler.java @@ -7,8 +7,6 @@ import android.app.Activity; import android.os.Handler; import android.os.Parcelable; -import com.fsck.k9.controller.MessageReference; - /** * This class is used to run operations that modify UI elements in the UI thread. * @@ -25,7 +23,6 @@ public class MessageListHandler extends Handler { private static final int ACTION_REMOTE_SEARCH_FINISHED = 4; private static final int ACTION_GO_BACK = 5; private static final int ACTION_RESTORE_LIST_POSITION = 6; - private static final int ACTION_OPEN_MESSAGE = 7; private WeakReference mFragment; @@ -79,12 +76,6 @@ public class MessageListHandler extends Handler { } } - public void openMessage(MessageReference messageReference) { - android.os.Message msg = android.os.Message.obtain(this, ACTION_OPEN_MESSAGE, - messageReference); - sendMessage(msg); - } - @Override public void handleMessage(android.os.Message msg) { MessageListFragment fragment = mFragment.get(); @@ -131,11 +122,6 @@ public class MessageListHandler extends Handler { fragment.restoreListState(savedListState); break; } - case ACTION_OPEN_MESSAGE: { - MessageReference messageReference = (MessageReference) msg.obj; - fragment.openMessage(messageReference); - break; - } } } } 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 53d759261661dc3c3211355cfad5bc29e87eec1f..4501c25075e904c1e1ae8489407842ee93265b20 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 @@ -8,8 +8,8 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.DividerItemDecoration import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.ViewPager2 import com.fsck.k9.controller.MessageReference import com.fsck.k9.ui.R @@ -88,10 +88,13 @@ class MessageViewContainerFragment : Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.message_view_container, container, false) + val resources = inflater.context.resources + val pageMargin = resources.getDimension(R.dimen.message_view_pager_page_margin).toInt() + viewPager = view.findViewById(R.id.message_viewpager) viewPager.isUserInputEnabled = true viewPager.offscreenPageLimit = ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT - viewPager.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.HORIZONTAL)) + viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { // The message list is updated each time the active message is changed. To avoid message list updates // during the animation, we only set the active message after the animation has finished. 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 714ff744dc1eac930a8c4973352db53e40b9b163..ade4f65174229c5c7ecc920056fb143a139450a1 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,16 @@ Locale-specific versions are kept in res/raw-/changelog.xml. --> + + Fixed a bug that could lead to a crash when opening the message list + + + Allow unmasking the password field without authentication as soon as the original password in incoming/outgoing server settings has been overwritten completely + Don't crash when IMAP servers send attachment names that contain non-ASCII characters + Fixed a bug where the search input field would collapse back into its toolbar icon while it was being used + Removed support for Direct Share because the app can no longer use the number of times a person has been contacted to make useful suggestions + More internal changes + Fixed a bug that lead to search being broken Fixed error reporting for (old) send failures diff --git a/app/ui/legacy/src/main/res/values/dimensions.xml b/app/ui/legacy/src/main/res/values/dimensions.xml index b0aea872c4fd34aefb643b087ee497002dd3b79e..3ac09d34d77fc6b86a0f65709e234f2b71429007 100644 --- a/app/ui/legacy/src/main/res/values/dimensions.xml +++ b/app/ui/legacy/src/main/res/values/dimensions.xml @@ -9,4 +9,6 @@ 12dp 14dp + + 16dp diff --git a/fastlane/metadata/android/en-US/changelogs/33004.txt b/fastlane/metadata/android/en-US/changelogs/33004.txt new file mode 100644 index 0000000000000000000000000000000000000000..08b6e2249b9d9a39bd05f5f95371b8a530a79f66 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33004.txt @@ -0,0 +1,5 @@ +- Allow unmasking the password field without authentication as soon as the original password in incoming/outgoing server settings has been overwritten completely +- Don't crash when IMAP servers send attachment names that contain non-ASCII characters +- Fixed a bug where the search input field would collapse back into its toolbar icon while it was being used +- Removed support for Direct Share because the app can no longer use the number of times a person has been contacted to make useful suggestions +- More internal changes diff --git a/fastlane/metadata/android/en-US/changelogs/33005.txt b/fastlane/metadata/android/en-US/changelogs/33005.txt new file mode 100644 index 0000000000000000000000000000000000000000..6d1d2d1b97b05e490ecc0c74396475ee3f6b47a4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33005.txt @@ -0,0 +1 @@ +- Fixed a bug that could lead to a crash when opening the message list diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeExtensions.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeExtensions.kt index 093a57cbfaf1ec95306526e2f94df0c3fc858768..779518b901e8e3f7143014ae3122d4f70323a6b7 100644 --- a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeExtensions.kt +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeExtensions.kt @@ -26,6 +26,7 @@ internal const val SEMICOLON = ';' internal const val EQUALS_SIGN = '=' internal const val ASTERISK = '*' internal const val SINGLE_QUOTE = '\'' +internal const val BACKSLASH = '\\' internal fun Char.isTSpecial() = this in TSPECIALS diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt index 51e0505ac7fea2d0a9e71b8fb093ee7777372543..fe6b861a4a3edc131fb5613bb820ee6c52a1ce2f 100644 --- a/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt +++ b/mail/common/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt @@ -145,7 +145,7 @@ object MimeParameterEncoder { private fun String.isQuotable() = all { it.isQuotable() } - fun String.quoted(): String { + private fun String.quoted(): String { // quoted-string = [CFWS] DQUOTE *([FWS] qcontent) [FWS] DQUOTE [CFWS] // qcontent = qtext / quoted-pair // quoted-pair = ("\" (VCHAR / WSP)) @@ -165,6 +165,22 @@ object MimeParameterEncoder { } } + // RFC 6532-style header values + // Right now we only create such values for internal use (see IMAP BODYSTRUCTURE response parsing code) + fun String.quotedUtf8(): String { + return buildString(capacity = length + 16) { + append(DQUOTE) + for (c in this@quotedUtf8) { + if (c == DQUOTE || c == BACKSLASH) { + append('\\').append(c) + } else { + append(c) + } + } + append(DQUOTE) + } + } + private fun String.quotedLength(): Int { var length = 2 /* start and end quote */ for (c in this) { 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 def1fe9890d9d2d61c1f2e24b6348b81da34d819..4f16aba01270069f626bf6cf9f65825f74b8a3cf 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 @@ -378,6 +378,14 @@ class MimeParameterDecoderTest { assertThat(mimeValue.ignoredParameters).isEmpty() } + @Test + fun `UTF-8 data in header value`() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name=\"filenäme.ext\"") + + assertThat(mimeValue.parameters).containsExactlyEntries("name" to "filenäme.ext") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + 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 3150d7d9a88eb5feb609e69479874fbc6a4c8068..8585496e55db9c4f0bab16c02b0f5f3655b3df4f 100644 --- a/mail/protocols/imap/build.gradle +++ b/mail/protocols/imap/build.gradle @@ -17,6 +17,7 @@ dependencies { implementation "com.jcraft:jzlib:1.0.7" implementation "com.beetstra.jutf7:jutf7:1.0.0" implementation "commons-io:commons-io:${versions.commonsIo}" + implementation "com.squareup.okio:okio:${versions.okio}" testImplementation project(":mail:testing") testImplementation "junit:junit:${versions.junit}" diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java index 64c2058006aea890e46c29a738d5c6ac0c14ca4c..8a2e9a8994c243b95509b043c668d5da174b440a 100644 --- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java @@ -10,6 +10,7 @@ import com.fsck.k9.logging.Timber; import com.fsck.k9.mail.K9MailLib; import com.fsck.k9.mail.filter.FixedLengthInputStream; import com.fsck.k9.mail.filter.PeekableInputStream; +import okio.Buffer; import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_IMAP; @@ -396,7 +397,7 @@ class ImapResponseParser { private String parseQuoted() throws IOException { expect('"'); - StringBuilder sb = new StringBuilder(); + Buffer buffer = new Buffer(); int ch; boolean escape = false; while ((ch = inputStream.read()) != -1) { @@ -404,12 +405,13 @@ class ImapResponseParser { // Found the escape character escape = true; } else if (!escape && ch == '"') { - return sb.toString(); + return buffer.readUtf8(); } else { - sb.append((char) ch); + buffer.writeByte(ch); escape = false; } } + throw new IOException("parseQuoted(): end of stream reached"); } diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt index c67605f645d7f8e0a958517e89cc8f3e88e85508..58a888ddd3543f0bcc9fd43dd9c6970b6eb6b4a1 100644 --- a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt @@ -16,14 +16,12 @@ import com.fsck.k9.mail.internet.MimeHeader import com.fsck.k9.mail.internet.MimeMessageHelper import com.fsck.k9.mail.internet.MimeMultipart import com.fsck.k9.mail.internet.MimeParameterEncoder.isToken -import com.fsck.k9.mail.internet.MimeParameterEncoder.quoted +import com.fsck.k9.mail.internet.MimeParameterEncoder.quotedUtf8 import com.fsck.k9.mail.internet.MimeUtility import java.io.IOException import java.io.InputStream import java.text.SimpleDateFormat import java.util.Date -import java.util.HashMap -import java.util.LinkedHashSet import java.util.Locale import kotlin.math.max import kotlin.math.min @@ -891,7 +889,7 @@ internal class RealImapFolder( for (i in bodyParams.indices step 2) { val paramName = bodyParams.getString(i) val paramValue = bodyParams.getString(i + 1) - val encodedValue = if (paramValue.isToken()) paramValue else paramValue.quoted() + val encodedValue = if (paramValue.isToken()) paramValue else paramValue.quotedUtf8() contentType.append(String.format(";\r\n %s=%s", paramName, encodedValue)) } } @@ -918,7 +916,7 @@ internal class RealImapFolder( for (i in bodyDispositionParams.indices step 2) { val paramName = bodyDispositionParams.getString(i).lowercase() val paramValue = bodyDispositionParams.getString(i + 1) - val encodedValue = if (paramValue.isToken()) paramValue else paramValue.quoted() + val encodedValue = if (paramValue.isToken()) paramValue else paramValue.quotedUtf8() contentDisposition.append(String.format(";\r\n %s=%s", paramName, encodedValue)) } } diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.java b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.java index f06eda131a649d938e8b73bb30874d3ae209e941..40feb704af6394cfda6790bef3f792c25082eb7a 100644 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.java +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.java @@ -8,6 +8,7 @@ import java.util.List; import com.fsck.k9.mail.filter.FixedLengthInputStream; import com.fsck.k9.mail.filter.PeekableInputStream; +import kotlin.text.Charsets; import org.junit.Test; import static java.util.Arrays.asList; @@ -346,6 +347,16 @@ public class ImapResponseParserTest { assertEquals("qu\"oted", response.getString(0)); } + @Test + public void utf8InQuotedString() throws Exception { + ImapResponseParser parser = createParser("* \"quöted\"\r\n"); + + ImapResponse response = parser.readResponse(); + + assertEquals(1, response.size()); + assertEquals("quöted", response.getString(0)); + } + @Test(expected = IOException.class) public void testParseQuotedToEndOfStream() throws Exception { ImapResponseParser parser = createParser("* \"abc"); @@ -484,7 +495,7 @@ public class ImapResponseParserTest { } private ImapResponseParser createParser(String response) { - ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(response.getBytes()); + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(response.getBytes(Charsets.UTF_8)); peekableInputStream = new PeekableInputStream(byteArrayInputStream); return new ImapResponseParser(peekableInputStream); } 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 73a7744e8f4e777c060155f88aebca45a05327a1..c74fccfcbac72b9b2e859498b4dbe6174dc867da 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 @@ -770,6 +770,15 @@ class RealImapFolderTest { ) } + @Test + fun `fetch() with UTF-8 encoded content type parameter`() { + testHeaderFromBodyStructure( + bodyStructure = """("text" "plain" ("name" "filenäme.ext") NIL NIL "7bit" 42 23)""", + headerName = MimeHeader.HEADER_CONTENT_TYPE, + expectedHeaderValue = "text/plain;\r\n name=\"filenäme.ext\"" + ) + } + @Test fun `fetch() with simple content disposition parameter`() { testHeaderFromBodyStructure( @@ -810,6 +819,16 @@ class RealImapFolderTest { ) } + @Test + fun `fetch() with UTF-8 encoded content disposition parameter`() { + testHeaderFromBodyStructure( + bodyStructure = """("application" "octet-stream" NIL NIL NIL "8bit" 23 NIL """ + + """("attachment" ("filename" "filenäme.ext")) NIL NIL)""", + headerName = MimeHeader.HEADER_CONTENT_DISPOSITION, + expectedHeaderValue = "attachment;\r\n filename=\"filenäme.ext\";\r\n size=23" + ) + } + @Test fun fetch_withBodySaneFetchProfile_shouldIssueRespectiveCommand() { val folder = createFolder("Folder")