From a0a1ea9d57b6068e7b1c3351c2fbd6ba6c8811b0 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 26 Sep 2022 19:46:58 +0200 Subject: [PATCH 001/121] Prepare for version 6.308 --- app/k9mail/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index f8503a196d..e2162c6cb4 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -51,7 +51,7 @@ android { testApplicationId "com.fsck.k9.tests" versionCode 33007 - versionName '6.307' + versionName '6.308-SNAPSHOT' // 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", -- GitLab From cb92d025df0c0a299279c2c44fa01184304393c5 Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 28 Sep 2022 12:11:02 +0200 Subject: [PATCH 002/121] Update resolution strategy for transitive dependencies So we don't end up with multiple different versions of libraries in the IDE. --- build.gradle | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/build.gradle b/build.gradle index 1b562d1be4..9b9afd8705 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,8 @@ buildscript { 'androidxCore': '1.9.0', 'androidxCardView': '1.0.0', 'androidxPreference': '1.2.0', + 'androidxDrawerLayout': '1.1.1', + 'androidxTransition': '1.4.1', 'androidxTestCore': '1.4.0', 'materialComponents': '1.6.1', 'fastAdapter': '5.6.0', @@ -92,8 +94,21 @@ subprojects { resolutionStrategy.dependencySubstitution { substitute module("androidx.core:core") using module("androidx.core:core:${versions.androidxCore}") substitute module("androidx.activity:activity") using module("androidx.activity:activity:${versions.androidxActivity}") + substitute module("androidx.activity:activity-ktx") using module("androidx.activity:activity-ktx:${versions.androidxActivity}") + substitute module("androidx.fragment:fragment") using module("androidx.fragment:fragment:${versions.androidxFragment}") + substitute module("androidx.fragment:fragment-ktx") using module("androidx.fragment:fragment-ktx:${versions.androidxFragment}") substitute module("androidx.appcompat:appcompat") using module("androidx.appcompat:appcompat:${versions.androidxAppCompat}") substitute module("androidx.preference:preference") using module("androidx.preference:preference:${versions.androidxPreference}") + substitute module("androidx.recyclerview:recyclerview") using module("androidx.recyclerview:recyclerview:${versions.androidxRecyclerView}") + substitute module("androidx.constraintlayout:constraintlayout") using module("androidx.constraintlayout:constraintlayout:${versions.androidxConstraintLayout}") + substitute module("androidx.drawerlayout:drawerlayout") using module("androidx.drawerlayout:drawerlayout:${versions.androidxDrawerLayout}") + substitute module("androidx.lifecycle:lifecycle-livedata") using module("androidx.lifecycle:lifecycle-livedata:${versions.androidxLifecycle}") + substitute module("androidx.transition:transition") using module("androidx.transition:transition:${versions.androidxTransition}") + substitute module("org.jetbrains:annotations") using module("org.jetbrains:annotations:${versions.jetbrainsAnnotations}") + substitute module("org.jetbrains.kotlin:kotlin-stdlib") using module("org.jetbrains.kotlin:kotlin-stdlib:${versions.kotlin}") + substitute module("org.jetbrains.kotlin:kotlin-stdlib-jdk7") using module("org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}") + substitute module("org.jetbrains.kotlin:kotlin-stdlib-jdk8") using module("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}") + substitute module("org.jetbrains.kotlinx:kotlinx-coroutines-android") using module("org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.kotlinCoroutines}") } } -- GitLab From 3d03d0f953aa80348fff1d7bbb70b91156c09316 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 30 Sep 2022 18:51:56 +0200 Subject: [PATCH 003/121] Change message list divider to follow the views opacity --- .../fsck/k9/fragment/MessageListFragment.kt | 4 +- .../messagelist/MessageListItemDecoration.kt | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemDecoration.kt 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 a9652d6640..4308364e16 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 @@ -16,7 +16,6 @@ import androidx.appcompat.view.ActionMode import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout @@ -50,6 +49,7 @@ import com.fsck.k9.ui.messagelist.MessageListAppearance 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.MessageListItemDecoration import com.fsck.k9.ui.messagelist.MessageListViewModel import com.fsck.k9.ui.messagelist.MessageSortOverride import java.util.concurrent.Future @@ -237,7 +237,7 @@ class MessageListFragment : private fun initializeRecyclerView(view: View) { recyclerView = view.findViewById(R.id.message_list) - val itemDecoration = DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL) + val itemDecoration = MessageListItemDecoration(requireContext()) recyclerView.addItemDecoration(itemDecoration) recyclerView.itemAnimator = DefaultItemAnimator().apply { supportsChangeAnimations = false diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemDecoration.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemDecoration.kt new file mode 100644 index 0000000000..68acd830bf --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemDecoration.kt @@ -0,0 +1,52 @@ +package com.fsck.k9.ui.messagelist + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.view.View +import androidx.core.graphics.withSave +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration +import com.fsck.k9.ui.resolveDrawableAttribute +import kotlin.math.roundToInt + +/** + * An [ItemDecoration] that uses the alpha and visibility values of a view when drawing the divider. + * + * Based on [androidx.recyclerview.widget.DividerItemDecoration]. + */ +class MessageListItemDecoration(context: Context) : ItemDecoration() { + private val divider: Drawable = context.theme.resolveDrawableAttribute(android.R.attr.listDivider) + private val bounds = Rect() + + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + if (parent.layoutManager == null) return + + canvas.withSave { + val childCount = parent.childCount + for (i in 0 until childCount) { + val child = parent.getChildAt(i) + if (!child.isVisible) { + continue + } + + parent.getDecoratedBoundsWithMargins(child, bounds) + + val left = 0 + val right = parent.width + val bottom = bounds.bottom + child.translationY.roundToInt() + val top = bottom - divider.intrinsicHeight + + divider.setBounds(left, top, right, bottom) + divider.alpha = (child.alpha * 255).toInt() + divider.draw(canvas) + } + } + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + outRect.set(0, 0, 0, divider.intrinsicHeight) + } +} -- GitLab From 6db5f09ddf7a1b5912ff56f4c7cfca6037468a58 Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 5 Oct 2022 11:55:26 +0200 Subject: [PATCH 004/121] Set app theme before the first Activity is started --- .../java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt | 3 +++ app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt b/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt index 93b5ed8699..adb2c53ff8 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt @@ -40,6 +40,9 @@ internal class RealGeneralSettingsManager( } override fun getSettingsFlow(): Flow { + // Make sure to load settings now if they haven't been loaded already. This will also update settingsFlow. + getSettings() + return settingsFlow.distinctUntilChanged() } diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt index 104c7dbb2f..4eef05baf5 100644 --- a/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt @@ -64,7 +64,7 @@ class ThemeManager( .onEach { updateAppTheme(it) } - .launchIn(appCoroutineScope + Dispatchers.Main) + .launchIn(appCoroutineScope + Dispatchers.Main.immediate) } private fun updateAppTheme(appTheme: AppTheme) { -- GitLab From 44f9efdfd5377e1120ce6e622ac2008aacb2d069 Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 4 Oct 2022 18:08:22 +0200 Subject: [PATCH 005/121] Change 'background sync' default value to 'always' --- app/core/src/main/java/com/fsck/k9/K9.kt | 4 ++-- .../com/fsck/k9/preferences/GeneralSettingsDescriptions.java | 4 +++- app/core/src/main/java/com/fsck/k9/preferences/Settings.java | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/core/src/main/java/com/fsck/k9/K9.kt b/app/core/src/main/java/com/fsck/k9/K9.kt index c0e7a6c53a..efcf8616e0 100644 --- a/app/core/src/main/java/com/fsck/k9/K9.kt +++ b/app/core/src/main/java/com/fsck/k9/K9.kt @@ -123,7 +123,7 @@ object K9 : EarlyInit { val fontSizes = FontSizes() @JvmStatic - var backgroundOps = BACKGROUND_OPS.WHEN_CHECKED_AUTO_SYNC + var backgroundOps = BACKGROUND_OPS.ALWAYS @JvmStatic var isShowAnimations = true @@ -344,7 +344,7 @@ object K9 : EarlyInit { isThreadedViewEnabled = storage.getBoolean("threadedView", true) fontSizes.load(storage) - backgroundOps = storage.getEnum("backgroundOperations", BACKGROUND_OPS.WHEN_CHECKED_AUTO_SYNC) + backgroundOps = storage.getEnum("backgroundOperations", BACKGROUND_OPS.ALWAYS) isColorizeMissingContactPictures = storage.getBoolean("colorizeMissingContactPictures", true) diff --git a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java index 2458bf7800..ce5fa8e1e9 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java +++ b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java @@ -17,6 +17,7 @@ import com.fsck.k9.Account.SortType; import com.fsck.k9.DI; import com.fsck.k9.FontSizes; import com.fsck.k9.K9; +import com.fsck.k9.K9.BACKGROUND_OPS; import com.fsck.k9.K9.NotificationQuickDelete; import com.fsck.k9.K9.SplitViewMode; import com.fsck.k9.core.R; @@ -51,7 +52,8 @@ public class GeneralSettingsDescriptions { new V(1, new BooleanSetting(false)) )); s.put("backgroundOperations", Settings.versions( - new V(1, new EnumSetting<>(K9.BACKGROUND_OPS.class, K9.BACKGROUND_OPS.WHEN_CHECKED_AUTO_SYNC)) + new V(1, new EnumSetting<>(K9.BACKGROUND_OPS.class, K9.BACKGROUND_OPS.WHEN_CHECKED_AUTO_SYNC)), + new V(83, new EnumSetting<>(K9.BACKGROUND_OPS.class, BACKGROUND_OPS.ALWAYS)) )); s.put("changeRegisteredNameColor", Settings.versions( new V(1, new BooleanSetting(false)) diff --git a/app/core/src/main/java/com/fsck/k9/preferences/Settings.java b/app/core/src/main/java/com/fsck/k9/preferences/Settings.java index a82e888a7b..a761d6fa90 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/Settings.java +++ b/app/core/src/main/java/com/fsck/k9/preferences/Settings.java @@ -36,7 +36,7 @@ public class Settings { * * @see SettingsExporter */ - public static final int VERSION = 82; + public static final int VERSION = 83; static Map validate(int version, Map> settings, Map importedSettings, boolean useDefaultValues) { -- GitLab From 8bc631dbda3d1b750c5458da2da04f94717ff4a7 Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 4 Oct 2022 12:11:29 +0200 Subject: [PATCH 006/121] Mark `MessageListFragment` as inactive when it moves to the back stack --- app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt | 2 ++ 1 file changed, 2 insertions(+) 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 a78a336977..53627c9b47 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 @@ -1071,6 +1071,8 @@ open class MessageList : } private fun addMessageListFragment(fragment: MessageListFragment) { + messageListFragment?.isActive = false + supportFragmentManager.commit { replace(R.id.message_list_container, fragment) -- GitLab From 25863d2d74027746cdd552a75a6c266caea0b87a Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 4 Oct 2022 12:13:07 +0200 Subject: [PATCH 007/121] Ignore message click events on an inactive `MessageListFragment` --- .../main/java/com/fsck/k9/fragment/MessageListFragment.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 4308364e16..9454ea67b8 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 @@ -388,6 +388,12 @@ class MessageListFragment : } override fun onMessageClicked(messageListItem: MessageListItem) { + if (!isActive) { + // Ignore click events that are delivered after the Fragment is no longer active. This could happen when + // the user taps two messages at almost the same time and the first tap opens a new MessageListFragment. + return + } + if (adapter.selectedCount > 0) { toggleMessageSelect(messageListItem) } else { -- GitLab From 88c16551570c1042215849dda4507a0b0a0adfe2 Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 4 Oct 2022 12:23:09 +0200 Subject: [PATCH 008/121] Debounce clicks to open a message or thread --- .../main/java/com/fsck/k9/fragment/MessageListFragment.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 9454ea67b8..9f7d5af8a2 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 @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Parcelable +import android.os.SystemClock import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem @@ -59,6 +60,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import timber.log.Timber private const val MAXIMUM_MESSAGE_SORT_OVERRIDES = 3 +private const val MINIMUM_CLICK_INTERVAL = 200L class MessageListFragment : Fragment(), @@ -105,6 +107,7 @@ class MessageListFragment : private var isThreadDisplay = false private var activeMessage: MessageReference? = null private var rememberedSelected: Set? = null + private var lastMessageClick = 0L lateinit var localSearch: LocalSearch private set @@ -394,9 +397,13 @@ class MessageListFragment : return } + val clickTime = SystemClock.elapsedRealtime() + if (clickTime - lastMessageClick < MINIMUM_CLICK_INTERVAL) return + if (adapter.selectedCount > 0) { toggleMessageSelect(messageListItem) } else { + lastMessageClick = clickTime if (showingThreadedList && messageListItem.threadCount > 1) { fragmentListener.showThread(messageListItem.account, messageListItem.threadRoot) } else { -- GitLab From d82bc5844e6e6e3a62afaec6da52e8d26c5f164a Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 5 Oct 2022 13:50:35 +0200 Subject: [PATCH 009/121] Create resource file for material colors --- .../values/arrays_account_settings_values.xml | 38 ++-- .../src/main/res/values/arrays_drawer.xml | 32 +-- .../src/main/res/values/material_colors.xml | 211 ++++++++++++++++++ app/ui/legacy/src/main/res/values/colors.xml | 4 - app/ui/legacy/src/main/res/values/themes.xml | 12 +- 5 files changed, 252 insertions(+), 45 deletions(-) create mode 100644 app/core/src/main/res/values/material_colors.xml diff --git a/app/core/src/main/res/values/arrays_account_settings_values.xml b/app/core/src/main/res/values/arrays_account_settings_values.xml index edb61a17cf..987dcd7382 100644 --- a/app/core/src/main/res/values/arrays_account_settings_values.xml +++ b/app/core/src/main/res/values/arrays_account_settings_values.xml @@ -3,25 +3,25 @@ - 0xFFFFB300 - 0xFFFB8C00 - 0xFFF4511E - 0xFFE53935 - - 0xFFC0CA33 - 0xFF7CB342 - 0xFF388E3C - 0xFF00897B - - 0xFF00ACC1 - 0xFF039BE5 - 0xFF1976D2 - 0xFF3949AB - - 0xFFE91E63 - 0xFF8E24AA - 0xFF5E35B1 - 0xFF455A64 + @color/material_amber_600 + @color/material_orange_600 + @color/material_deep_orange_600 + @color/material_red_600 + + @color/material_lime_600 + @color/material_light_green_600 + @color/material_green_700 + @color/material_teal_600 + + @color/material_cyan_600 + @color/material_light_blue_600 + @color/material_blue_700 + @color/material_indigo_600 + + @color/material_pink_500 + @color/material_purple_600 + @color/material_deep_purple_600 + @color/material_blue_gray_700 diff --git a/app/core/src/main/res/values/arrays_drawer.xml b/app/core/src/main/res/values/arrays_drawer.xml index c83c36f89a..e648372486 100644 --- a/app/core/src/main/res/values/arrays_drawer.xml +++ b/app/core/src/main/res/values/arrays_drawer.xml @@ -3,25 +3,25 @@ - 0xFFFFB300 - 0xFFFF9800 - 0xFFFF7043 - 0xFFEF5350 + @color/material_amber_600 + @color/material_orange_500 + @color/material_deep_orange_400 + @color/material_red_400 - 0xFFC0CA33 - 0xFF7CB342 - 0xFF4CAF50 - 0xFF4DB6AC + @color/material_lime_600 + @color/material_light_green_600 + @color/material_green_500 + @color/material_teal_300 - 0xFF00ACC1 - 0xFF03A9F4 - 0xFF42A5F5 - 0xFF9FA8DA + @color/material_cyan_600 + @color/material_light_blue_500 + @color/material_blue_400 + @color/material_indigo_200 - 0xFFF48FB1 - 0xFFCE93D8 - 0xFFB39DDB - 0xFF90A4AE + @color/material_pink_200 + @color/material_purple_200 + @color/material_deep_purple_200 + @color/material_blue_gray_300 diff --git a/app/core/src/main/res/values/material_colors.xml b/app/core/src/main/res/values/material_colors.xml new file mode 100644 index 0000000000..31c59aac5b --- /dev/null +++ b/app/core/src/main/res/values/material_colors.xml @@ -0,0 +1,211 @@ + + + #FFEBEE + #FFCDD2 + #EF9A9A + #E57373 + #EF5350 + #F44336 + #E53935 + #D32F2F + #C62828 + #B71C1C + + #EDE7F6 + #D1C4E9 + #B39DDB + #9575CD + #7E57C2 + #673AB7 + #5E35B1 + #512DA8 + #4527A0 + #311B92 + + #E1F5FE + #B3E5FC + #81D4FA + #4FC3F7 + #29B6F6 + #03A9F4 + #039BE5 + #0288D1 + #0277BD + #01579B + + #E8F5E9 + #C8E6C9 + #A5D6A7 + #81C784 + #66BB6A + #4CAF50 + #43A047 + #388E3C + #2E7D32 + #1B5E20 + + #FFFDE7 + #FFF9C4 + #FFF59D + #FFF176 + #FFEE58 + #FFEB3B + #FDD835 + #FBC02D + #F9A825 + #F57F17 + + #FBE9E7 + #FFCCBC + #FFAB91 + #FF8A65 + #FF7043 + #FF5722 + #F4511E + #E64A19 + #D84315 + #BF360C + + #ECEFF1 + #CFD8DC + #B0BEC5 + #90A4AE + #78909C + #607D8B + #546E7A + #455A64 + #37474F + #263238 + + #FCE4EC + #F8BBD0 + #F48FB1 + #F06292 + #EC407A + #E91E63 + #D81B60 + #C2185B + #AD1457 + #880E4F + + #E8EAF6 + #C5CAE9 + #9FA8DA + #7986CB + #5C6BC0 + #3F51B5 + #3949AB + #303F9F + #283593 + #1A237E + + #E0F7FA + #B2EBF2 + #80DEEA + #4DD0E1 + #26C6DA + #00BCD4 + #00ACC1 + #0097A7 + #00838F + #006064 + + #F1F8E9 + #DCEDC8 + #C5E1A5 + #AED581 + #9CCC65 + #8BC34A + #7CB342 + #689F38 + #558B2F + #33691E + + #FFF8E1 + #FFECB3 + #FFE082 + #FFD54F + #FFCA28 + #FFC107 + #FFB300 + #FFA000 + #FF8F00 + #FF6F00 + + #EFEBE9 + #D7CCC8 + #BCAAA4 + #A1887F + #8D6E63 + #795548 + #6D4C41 + #5D4037 + #4E342E + #3E2723 + + #F3E5F5 + #E1BEE7 + #CE93D8 + #BA68C8 + #AB47BC + #9C27B0 + #8E24AA + #7B1FA2 + #6A1B9A + #4A148C + + #E3F2FD + #BBDEFB + #90CAF9 + #64B5F6 + #42A5F5 + #2196F3 + #1E88E5 + #1976D2 + #1565C0 + #0D47A1 + + #E0F2F1 + #B2DFDB + #80CBC4 + #4DB6AC + #26A69A + #009688 + #00897B + #00796B + #00695C + #004D40 + + #F9FBE7 + #F0F4C3 + #E6EE9C + #DCE775 + #D4E157 + #CDDC39 + #C0CA33 + #AFB42B + #9E9D24 + #827717 + + #FFF3E0 + #FFE0B2 + #FFCC80 + #FFB74D + #FFA726 + #FF9800 + #FB8C00 + #F57C00 + #EF6C00 + #E65100 + + #FAFAFA + #F5F5F5 + #EEEEEE + #E0E0E0 + #BDBDBD + #9E9E9E + #757575 + #616161 + #424242 + #212121 + diff --git a/app/ui/legacy/src/main/res/values/colors.xml b/app/ui/legacy/src/main/res/values/colors.xml index a569d49d67..d9acb45a68 100644 --- a/app/ui/legacy/src/main/res/values/colors.xml +++ b/app/ui/legacy/src/main/res/values/colors.xml @@ -8,9 +8,5 @@ #f44336 #7bad45 - #FAFAFA - #F5F5F5 - #212121 - #F9F9F9 diff --git a/app/ui/legacy/src/main/res/values/themes.xml b/app/ui/legacy/src/main/res/values/themes.xml index 9c3016a43e..ecc1e66631 100644 --- a/app/ui/legacy/src/main/res/values/themes.xml +++ b/app/ui/legacy/src/main/res/values/themes.xml @@ -12,9 +12,9 @@ + - -- GitLab From c145fe03ea0992566c56ad2175b3bc8ec71de4be Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 28 Sep 2022 22:36:40 +0200 Subject: [PATCH 018/121] Add settings to configure swipe actions --- app/core/src/main/java/com/fsck/k9/K9.kt | 12 ++++++++ .../GeneralSettingsDescriptions.java | 8 +++++- .../values/arrays_general_settings_values.xml | 11 ++++++++ .../java/com/fsck/k9/activity/MessageList.kt | 8 +++--- ...arance.kt => MessageListActivityConfig.kt} | 15 ++++++---- .../k9/ui/messagelist/MessageListFragment.kt | 4 +-- .../general/GeneralSettingsDataStore.kt | 28 +++++++++++++++++++ .../arrays_general_settings_strings.xml | 11 ++++++++ app/ui/legacy/src/main/res/values/strings.xml | 24 ++++++++++++++++ .../src/main/res/xml/general_settings.xml | 23 +++++++++++++++ 10 files changed, 132 insertions(+), 12 deletions(-) rename app/ui/legacy/src/main/java/com/fsck/k9/activity/{MessageListActivityAppearance.kt => MessageListActivityConfig.kt} (88%) diff --git a/app/core/src/main/java/com/fsck/k9/K9.kt b/app/core/src/main/java/com/fsck/k9/K9.kt index efcf8616e0..d52f49dc84 100644 --- a/app/core/src/main/java/com/fsck/k9/K9.kt +++ b/app/core/src/main/java/com/fsck/k9/K9.kt @@ -250,6 +250,12 @@ object K9 : EarlyInit { @JvmStatic var pgpSignOnlyDialogCounter: Int = 0 + @JvmStatic + var swipeRightAction: SwipeAction = SwipeAction.ToggleSelection + + @JvmStatic + var swipeLeftAction: SwipeAction = SwipeAction.ToggleRead + val isQuietTime: Boolean get() { if (!isQuietTimeEnabled) { @@ -358,6 +364,9 @@ object K9 : EarlyInit { pgpSignOnlyDialogCounter = storage.getInt("pgpSignOnlyDialogCounter", 0) k9Language = storage.getString("language", "") + + swipeRightAction = storage.getEnum("swipeRightAction", SwipeAction.ToggleSelection) + swipeLeftAction = storage.getEnum("swipeLeftAction", SwipeAction.ToggleRead) } internal fun save(editor: StorageEditor) { @@ -417,6 +426,9 @@ object K9 : EarlyInit { editor.putInt("pgpInlineDialogCounter", pgpInlineDialogCounter) editor.putInt("pgpSignOnlyDialogCounter", pgpSignOnlyDialogCounter) + editor.putEnum("swipeRightAction", swipeRightAction) + editor.putEnum("swipeLeftAction", swipeLeftAction) + fontSizes.save(editor) } diff --git a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java index ce5fa8e1e9..93dd1d23ca 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java +++ b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java @@ -10,7 +10,6 @@ import java.util.Set; import java.util.TreeMap; import android.content.Context; -import android.graphics.Color; import com.fsck.k9.Account; import com.fsck.k9.Account.SortType; @@ -20,6 +19,7 @@ import com.fsck.k9.K9; import com.fsck.k9.K9.BACKGROUND_OPS; import com.fsck.k9.K9.NotificationQuickDelete; import com.fsck.k9.K9.SplitViewMode; +import com.fsck.k9.SwipeAction; import com.fsck.k9.core.R; import com.fsck.k9.preferences.Settings.BooleanSetting; import com.fsck.k9.preferences.Settings.ColorSetting; @@ -277,6 +277,12 @@ public class GeneralSettingsDescriptions { s.put("showStarredCount", Settings.versions( new V(75, new BooleanSetting(false)) )); + s.put("swipeRightAction", Settings.versions( + new V(83, new EnumSetting<>(SwipeAction.class, SwipeAction.ToggleSelection)) + )); + s.put("swipeLeftAction", Settings.versions( + new V(83, new EnumSetting<>(SwipeAction.class, SwipeAction.ToggleRead)) + )); SETTINGS = Collections.unmodifiableMap(s); diff --git a/app/core/src/main/res/values/arrays_general_settings_values.xml b/app/core/src/main/res/values/arrays_general_settings_values.xml index b5b0f49f67..95fca883f9 100644 --- a/app/core/src/main/res/values/arrays_general_settings_values.xml +++ b/app/core/src/main/res/values/arrays_general_settings_values.xml @@ -210,4 +210,15 @@ spam + + none + toggle_selection + toggle_read + toggle_star + archive + delete + spam + move + + 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 28fba987e4..dfdcd8fad6 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 @@ -119,7 +119,7 @@ open class MessageList : ?: if (K9.isMessageViewShowNext) Direction.NEXT else Direction.PREVIOUS } - private var messageListActivityAppearance: MessageListActivityAppearance? = null + private var messageListActivityConfig: MessageListActivityConfig? = null /** * `true` if the message list should be displayed as flat list (i.e. no threading) @@ -548,9 +548,9 @@ open class MessageList : public override fun onResume() { super.onResume() - if (messageListActivityAppearance == null) { - messageListActivityAppearance = MessageListActivityAppearance.create(generalSettingsManager) - } else if (messageListActivityAppearance != MessageListActivityAppearance.create(generalSettingsManager)) { + if (messageListActivityConfig == null) { + messageListActivityConfig = MessageListActivityConfig.create(generalSettingsManager) + } else if (messageListActivityConfig != MessageListActivityConfig.create(generalSettingsManager)) { recreateCompat() } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt similarity index 88% rename from app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt rename to app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt index fac66de81f..fc2ebd1ec5 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt @@ -1,11 +1,12 @@ package com.fsck.k9.activity import com.fsck.k9.K9 +import com.fsck.k9.SwipeAction import com.fsck.k9.preferences.AppTheme import com.fsck.k9.preferences.GeneralSettingsManager import com.fsck.k9.preferences.SubTheme -data class MessageListActivityAppearance( +data class MessageListActivityConfig( val appTheme: AppTheme, val isShowUnifiedInbox: Boolean, val isShowMessageListStars: Boolean, @@ -31,13 +32,15 @@ data class MessageListActivityAppearance( val fontSizeMessageViewAdditionalHeaders: Int, val fontSizeMessageViewSubject: Int, val fontSizeMessageViewDate: Int, - val fontSizeMessageViewContentAsPercent: Int + val fontSizeMessageViewContentAsPercent: Int, + val swipeRightAction: SwipeAction, + val swipeLeftAction: SwipeAction, ) { companion object { - fun create(generalSettingsManager: GeneralSettingsManager): MessageListActivityAppearance { + fun create(generalSettingsManager: GeneralSettingsManager): MessageListActivityConfig { val settings = generalSettingsManager.getSettings() - return MessageListActivityAppearance( + return MessageListActivityConfig( appTheme = settings.appTheme, isShowUnifiedInbox = K9.isShowUnifiedInbox, isShowMessageListStars = K9.isShowMessageListStars, @@ -63,7 +66,9 @@ data class MessageListActivityAppearance( fontSizeMessageViewAdditionalHeaders = K9.fontSizes.messageViewAdditionalHeaders, fontSizeMessageViewSubject = K9.fontSizes.messageViewSubject, fontSizeMessageViewDate = K9.fontSizes.messageViewDate, - fontSizeMessageViewContentAsPercent = K9.fontSizes.messageViewContentAsPercent + fontSizeMessageViewContentAsPercent = K9.fontSizes.messageViewContentAsPercent, + swipeRightAction = K9.swipeRightAction, + swipeLeftAction = K9.swipeLeftAction, ) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index 66d37b0bd6..5173e52e4a 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -273,8 +273,8 @@ class MessageListFragment : resources, resourceProvider = SwipeResourceProvider(theme), swipeActionSupportProvider, - swipeRightAction = SwipeAction.Archive, - swipeLeftAction = SwipeAction.ToggleRead, + swipeRightAction = K9.swipeRightAction, + swipeLeftAction = K9.swipeLeftAction, adapter, swipeListener ) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt index d712a8cde7..6e1ae5a9c4 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt @@ -2,6 +2,7 @@ package com.fsck.k9.ui.settings.general import androidx.preference.PreferenceDataStore import com.fsck.k9.K9 +import com.fsck.k9.SwipeAction import com.fsck.k9.job.K9JobManager import com.fsck.k9.preferences.AppTheme import com.fsck.k9.preferences.GeneralSettingsManager @@ -125,6 +126,8 @@ class GeneralSettingsDataStore( "message_view_date_font" -> K9.fontSizes.messageViewDate.toString() "message_view_additional_headers_font" -> K9.fontSizes.messageViewAdditionalHeaders.toString() "message_compose_input_font" -> K9.fontSizes.messageComposeInput.toString() + "swipe_action_right" -> swipeActionToString(K9.swipeRightAction) + "swipe_action_left" -> swipeActionToString(K9.swipeLeftAction) else -> defValue } } @@ -164,6 +167,8 @@ class GeneralSettingsDataStore( "message_view_date_font" -> K9.fontSizes.messageViewDate = value.toInt() "message_view_additional_headers_font" -> K9.fontSizes.messageViewAdditionalHeaders = value.toInt() "message_compose_input_font" -> K9.fontSizes.messageComposeInput = value.toInt() + "swipe_action_right" -> K9.swipeRightAction = stringToSwipeAction(value) + "swipe_action_left" -> K9.swipeLeftAction = stringToSwipeAction(value) else -> return } @@ -280,4 +285,27 @@ class GeneralSettingsDataStore( jobManager.scheduleAllMailJobs() } } + + private fun swipeActionToString(action: SwipeAction) = when (action) { + SwipeAction.None -> "none" + SwipeAction.ToggleSelection -> "toggle_selection" + SwipeAction.ToggleRead -> "toggle_read" + SwipeAction.ToggleStar -> "toggle_star" + SwipeAction.Archive -> "archive" + SwipeAction.Delete -> "delete" + SwipeAction.Spam -> "spam" + SwipeAction.Move -> "move" + } + + private fun stringToSwipeAction(action: String) = when (action) { + "none" -> SwipeAction.None + "toggle_selection" -> SwipeAction.ToggleSelection + "toggle_read" -> SwipeAction.ToggleRead + "toggle_star" -> SwipeAction.ToggleStar + "archive" -> SwipeAction.Archive + "delete" -> SwipeAction.Delete + "spam" -> SwipeAction.Spam + "move" -> SwipeAction.Move + else -> throw AssertionError() + } } diff --git a/app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml b/app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml index 45bf2174c2..b81a82e8a3 100644 --- a/app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml +++ b/app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml @@ -157,4 +157,15 @@ @string/spam_action + + @string/general_settings_swipe_action_none + @string/general_settings_swipe_action_toggle_selection + @string/general_settings_swipe_action_toggle_read + @string/general_settings_swipe_action_toggle_star + @string/general_settings_swipe_action_archive + @string/general_settings_swipe_action_delete + @string/general_settings_swipe_action_spam + @string/general_settings_swipe_action_move + + diff --git a/app/ui/legacy/src/main/res/values/strings.xml b/app/ui/legacy/src/main/res/values/strings.xml index cf40aff93f..3b52fa01b1 100644 --- a/app/ui/legacy/src/main/res/values/strings.xml +++ b/app/ui/legacy/src/main/res/values/strings.xml @@ -308,6 +308,30 @@ Please submit bug reports, contribute new features and ask questions at Mark all messages as read Delete (from notification) + + Swipe actions + + Right swipe + + Left swipe + + + None + + Toggle selection + + Mark as read/unread + + Add/remove star + + Archive + + Delete + + Spam + + Move + Hide mail client Remove K-9 User-Agent from mail headers Hide timezone diff --git a/app/ui/legacy/src/main/res/xml/general_settings.xml b/app/ui/legacy/src/main/res/xml/general_settings.xml index cf6933a72c..ebc9be2bd3 100644 --- a/app/ui/legacy/src/main/res/xml/general_settings.xml +++ b/app/ui/legacy/src/main/res/xml/general_settings.xml @@ -382,6 +382,29 @@ android:summary="@string/global_settings_confirm_actions_summary" android:title="@string/global_settings_confirm_actions_title" /> + + + + + + + + Date: Fri, 30 Sep 2022 11:12:13 +0200 Subject: [PATCH 019/121] Change background color when the swipe threshold is crossed Also change the swipe threshold to align with the 72dp keyline. --- .../messagelist/MessageListSwipeCallback.kt | 38 ++++++++++++------- .../ui/messagelist/SwipeResourceProvider.kt | 8 +++- .../legacy/src/main/res/values/dimensions.xml | 1 + 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index dd753d7c76..20e665fe69 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -22,6 +22,7 @@ class MessageListSwipeCallback( private val listener: MessageListSwipeListener ) : ItemTouchHelper.Callback() { private val iconPadding = resources.getDimension(R.dimen.messageListSwipeIconPadding).toInt() + private val swipeThreshold = resources.getDimension(R.dimen.messageListSwipeThreshold) private val backgroundColorPaint = Paint() override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: ViewHolder): Int { @@ -68,6 +69,10 @@ class MessageListSwipeCallback( viewHolder.markAsSwiped(false) } + override fun getSwipeThreshold(viewHolder: ViewHolder): Float { + return swipeThreshold / viewHolder.itemView.width + } + override fun onChildDraw( canvas: Canvas, recyclerView: RecyclerView, @@ -83,23 +88,30 @@ class MessageListSwipeCallback( val holder = viewHolder as MessageViewHolder val item = adapter.getItemById(holder.uniqueId) ?: return@withSave - drawBackground(view, dX, item) + val swipeThreshold = recyclerView.width * getSwipeThreshold(holder) + val swipeThresholdReached = abs(dX) > swipeThreshold + if (swipeThresholdReached) { + val action = if (dX > 0) swipeRightAction else swipeLeftAction + val backgroundColor = resourceProvider.getBackgroundColor(item, action) + drawBackground(view, backgroundColor) + } else { + val backgroundColor = resourceProvider.getBackgroundColor(item, SwipeAction.None) + drawBackground(view, backgroundColor) + } // Stop drawing the icon when the view has been animated all the way off the screen by ItemTouchHelper. // We do this so the icon doesn't switch state when RecyclerView's ItemAnimator animates the view back after // a toggle action (mark as read/unread, add/remove star) was used. if (isCurrentlyActive || abs(dX).toInt() < view.width) { - drawIcon(dX, view, item) + drawIcon(dX, view, item, swipeThresholdReached) } } super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) } - private fun Canvas.drawBackground(view: View, dX: Float, item: MessageListItem) { - val action = if (dX > 0) swipeRightAction else swipeLeftAction - - backgroundColorPaint.color = resourceProvider.getBackgroundColor(item, action) + private fun Canvas.drawBackground(view: View, color: Int) { + backgroundColorPaint.color = color drawRect( view.left.toFloat(), view.top.toFloat(), @@ -109,15 +121,15 @@ class MessageListSwipeCallback( ) } - private fun Canvas.drawIcon(dX: Float, view: View, item: MessageListItem) { + private fun Canvas.drawIcon(dX: Float, view: View, item: MessageListItem, swipeThresholdReached: Boolean) { if (dX > 0) { - drawSwipeRightIcon(view, item) + drawSwipeRightIcon(view, item, swipeThresholdReached) } else { - drawSwipeLeftIcon(view, item) + drawSwipeLeftIcon(view, item, swipeThresholdReached) } } - private fun Canvas.drawSwipeRightIcon(view: View, item: MessageListItem) { + private fun Canvas.drawSwipeRightIcon(view: View, item: MessageListItem, swipeThresholdReached: Boolean) { resourceProvider.getIcon(item, swipeRightAction)?.let { icon -> val iconLeft = iconPadding val iconTop = view.top + ((view.height - icon.intrinsicHeight) / 2) @@ -125,12 +137,12 @@ class MessageListSwipeCallback( val iconBottom = iconTop + icon.intrinsicHeight icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) - icon.setTint(resourceProvider.getIconTint(item, swipeRightAction)) + icon.setTint(resourceProvider.getIconTint(item, swipeRightAction, swipeThresholdReached)) icon.draw(this) } } - private fun Canvas.drawSwipeLeftIcon(view: View, item: MessageListItem) { + private fun Canvas.drawSwipeLeftIcon(view: View, item: MessageListItem, swipeThresholdReached: Boolean) { resourceProvider.getIcon(item, swipeLeftAction)?.let { icon -> val iconRight = view.right - iconPadding val iconLeft = iconRight - icon.intrinsicWidth @@ -138,7 +150,7 @@ class MessageListSwipeCallback( val iconBottom = iconTop + icon.intrinsicHeight icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) - icon.setTint(resourceProvider.getIconTint(item, swipeLeftAction)) + icon.setTint(resourceProvider.getIconTint(item, swipeLeftAction, swipeThresholdReached)) icon.draw(this) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt index 34c01fddce..d61064c9d2 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt @@ -32,7 +32,13 @@ class SwipeResourceProvider(val theme: Theme) { private val spamColor = theme.resolveColorAttribute(R.attr.messageListSwipeSpamBackgroundColor) private val moveColor = theme.resolveColorAttribute(R.attr.messageListSwipeMoveBackgroundColor) - fun getIconTint(item: MessageListItem, action: SwipeAction): Int = iconTint + fun getIconTint(item: MessageListItem, action: SwipeAction, swipeThresholdReached: Boolean): Int { + return if (swipeThresholdReached) { + iconTint + } else { + getBackgroundColor(item, action) + } + } fun getIcon(item: MessageListItem, action: SwipeAction): Drawable? { return when (action) { diff --git a/app/ui/legacy/src/main/res/values/dimensions.xml b/app/ui/legacy/src/main/res/values/dimensions.xml index 40d2fc1891..c46128602b 100644 --- a/app/ui/legacy/src/main/res/values/dimensions.xml +++ b/app/ui/legacy/src/main/res/values/dimensions.xml @@ -9,5 +9,6 @@ 12dp 16dp + 72dp 24dp -- GitLab From f7f0f02aa64c91cd646db8ebb5bfa9b852f25de7 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 6 Oct 2022 19:21:44 +0200 Subject: [PATCH 020/121] Version 6.308 --- app/k9mail/build.gradle | 4 ++-- app/ui/legacy/src/main/res/raw/changelog_master.xml | 7 +++++++ fastlane/metadata/android/en-US/changelogs/33008.txt | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/33008.txt diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index e2162c6cb4..3f4e1d640e 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.fsck.k9" testApplicationId "com.fsck.k9.tests" - versionCode 33007 - versionName '6.308-SNAPSHOT' + versionCode 33008 + versionName '6.308' // 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/ui/legacy/src/main/res/raw/changelog_master.xml b/app/ui/legacy/src/main/res/raw/changelog_master.xml index fc8c273fc7..e23f7d0061 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,13 @@ Locale-specific versions are kept in res/raw-/changelog.xml. --> + + Added swipe actions to the message list screen + Changed the minimum size of the message list widget to 2x2 cells + Fixed a bug where the app would start using the light theme before switching to the dark theme resulting in a brief flash of white + More minor bug fixes and improvements + Updated translations + Fixed the message list background color in the dark theme Fixed a small display issue where "Load up to X more" could have been displayed when it shouldn't have been diff --git a/fastlane/metadata/android/en-US/changelogs/33008.txt b/fastlane/metadata/android/en-US/changelogs/33008.txt new file mode 100644 index 0000000000..269ecaa2e1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33008.txt @@ -0,0 +1,5 @@ +- Added swipe actions to the message list screen +- Changed the minimum size of the message list widget to 2x2 cells +- Fixed a bug where the app would start using the light theme before switching to the dark theme resulting in a brief flash of white +- More minor bug fixes and improvements +- Updated translations -- GitLab From 142124c19a6158aae066fb2c8534a166a5f24271 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 6 Oct 2022 19:38:46 +0200 Subject: [PATCH 021/121] Prepare for version 6.309 --- app/k9mail/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 3f4e1d640e..172b5ae079 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -51,7 +51,7 @@ android { testApplicationId "com.fsck.k9.tests" versionCode 33008 - versionName '6.308' + versionName '6.309-SNAPSHOT' // 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", -- GitLab From 2bd2f22b1f22922b15eec707e50e7ffa4834e3e2 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 7 Oct 2022 17:45:42 +0200 Subject: [PATCH 022/121] Rewrite SMTP connect code to properly handle all network errors Previously e.g. a SocketTimeoutException would exit the loop to test all addresses early. --- .../k9/mail/transport/smtp/SmtpTransport.kt | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt index 43c78cc49b..fbec8e7154 100644 --- a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt +++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt @@ -33,7 +33,6 @@ import java.net.Inet6Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket -import java.net.SocketException import java.security.GeneralSecurityException import java.util.Locale import javax.net.ssl.SSLException @@ -83,35 +82,10 @@ class SmtpTransport( @Throws(MessagingException::class) override fun open() { try { - var secureConnection = false - val addresses = InetAddress.getAllByName(host) - for ((index, address) in addresses.withIndex()) { - try { - val socketAddress = InetSocketAddress(address, port) - if (connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) { - socket = trustedSocketFactory.createSocket(null, host, port, clientCertificateAlias).also { - it.connect(socketAddress, SOCKET_CONNECT_TIMEOUT) - } - secureConnection = true - } else { - socket = Socket().also { - it.connect(socketAddress, SOCKET_CONNECT_TIMEOUT) - } - } - } catch (e: SocketException) { - if (index < addresses.lastIndex) { - // there are still other addresses for that host to try - continue - } - - throw MessagingException("Cannot connect to host", e) - } + var secureConnection = connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED - // connection success - break - } - - val socket = this.socket ?: error("socket == null") + val socket = connect() + this.socket = socket socket.soTimeout = SOCKET_READ_TIMEOUT @@ -263,6 +237,39 @@ class SmtpTransport( } } + private fun connect(): Socket { + val inetAddresses = InetAddress.getAllByName(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 { + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_SMTP) { + Timber.d("Connecting to %s as %s", host, address) + } + + val socketAddress = InetSocketAddress(address, port) + val socket = if (connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) { + trustedSocketFactory.createSocket(null, host, port, clientCertificateAlias) + } else { + Socket() + } + + socket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT) + + return socket + } + private fun readGreeting() { val smtpResponse = responseParser!!.readGreeting() logResponse(smtpResponse) -- GitLab From bd03d070df2fbc6aa39cec040e38f78a081f7a68 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 7 Oct 2022 22:04:24 +0200 Subject: [PATCH 023/121] Clear log buffer before reading EHLO response --- .../mail/transport/smtp/SmtpResponseParser.kt | 2 ++ .../transport/smtp/SmtpResponseParserTest.kt | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt index a7ceefaa10..5428e12b7e 100644 --- a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt +++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt @@ -33,6 +33,8 @@ internal class SmtpResponseParser( } fun readHelloResponse(): SmtpHelloResponse { + logBuffer.clear() + val replyCode = readReplyCode() if (replyCode != 250) { 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 a9d4d3e62b..75923e5f1b 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 @@ -219,6 +219,30 @@ class SmtpResponseParserTest { .isEqualTo("Ignoring EHLO keyword line: KEYWORD para${"\t"}meter") } + @Test + fun `error in EHLO response after successfully reading greeting`() { + val input = """ + 220 Greeting + INVALID + """.toPeekableInputStream() + val parser = SmtpResponseParser(logger, input) + parser.readGreeting() + + assertFailsWithMessage("Unexpected character: I (73)") { + parser.readHelloResponse() + } + + assertThat(logger.logEntries).containsExactly( + LogEntry( + throwable = null, + message = """ + SMTP response data on parser error: + I + """.trimIndent() + ) + ) + } + @Test fun `positive response`() { val input = "200 OK".toPeekableInputStream() -- GitLab From f386888fcd3c892a587c1bd6a9c849a0dd755068 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 7 Oct 2022 22:54:39 +0200 Subject: [PATCH 024/121] SMTP: Don't attempt to send QUIT command when connection is closed --- .../k9/mail/transport/smtp/SmtpTransport.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt index fbec8e7154..4b2d6a44af 100644 --- a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt +++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt @@ -363,7 +363,7 @@ class SmtpTransport( message.removeHeader("Bcc") - close() + ensureClosed() open() // If the message has attachments and our server has told us about a limit on the size of messages, count @@ -434,11 +434,15 @@ class SmtpTransport( } } - override fun close() { - try { - executeCommand("QUIT") - } catch (ignored: Exception) { + private fun ensureClosed() { + if (inputStream != null || outputStream != null || socket != null || responseParser != null) { + Timber.w(RuntimeException(), "SmtpTransport was open when it was expected to be closed") + close() } + } + + override fun close() { + writeQuitCommand() IOUtils.closeQuietly(inputStream) IOUtils.closeQuietly(outputStream) @@ -450,6 +454,14 @@ class SmtpTransport( socket = null } + private fun writeQuitCommand() { + try { + // We don't care about the server's response to the QUIT command + writeLine("QUIT") + } catch (ignored: Exception) { + } + } + private fun writeLine(command: String, sensitive: Boolean = false) { if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_SMTP) { val commandToLog = if (sensitive && !K9MailLib.isDebugSensitive()) { @@ -637,7 +649,7 @@ class SmtpTransport( @Throws(MessagingException::class) fun checkSettings() { - close() + ensureClosed() try { open() -- GitLab From be4a078a4ba4ba1634825660fda7bccbfe3c85b1 Mon Sep 17 00:00:00 2001 From: cketti Date: Sun, 9 Oct 2022 19:52:31 +0200 Subject: [PATCH 025/121] Add logging to `LocalKeyStore.isValidCertificate()` --- .../java/com/fsck/k9/mail/ssl/LocalKeyStore.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt b/mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt index 712fc09d56..280a0d6029 100644 --- a/mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt +++ b/mail/common/src/main/java/com/fsck/k9/mail/ssl/LocalKeyStore.kt @@ -112,8 +112,24 @@ class LocalKeyStore(private val directoryProvider: KeyStoreDirectoryProvider) { return try { val storedCert = keyStore.getCertificate(getCertKey(host, port)) - storedCert == certificate + if (storedCert == null) { + Timber.v("Couldn't find a stored certificate for %s:%d", host, port) + false + } else if (storedCert != certificate) { + Timber.v( + "Stored certificate for %s:%d doesn't match.\nExpected:\n%s\nActual:\n%s", + host, + port, + storedCert, + certificate + ) + false + } else { + Timber.v("Stored certificate for %s:%d matches the server certificate", host, port) + true + } } catch (e: KeyStoreException) { + Timber.w(e, "Error reading from KeyStore") false } } -- GitLab From 738ba9c112126c745cf0bd408427899db9d28d41 Mon Sep 17 00:00:00 2001 From: cketti Date: Sun, 9 Oct 2022 20:24:44 +0200 Subject: [PATCH 026/121] Ignore clicks on the send button once sending the message has been triggered --- .../main/java/com/fsck/k9/activity/MessageCompose.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 81b55765ad..925edd1ed6 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 @@ -241,6 +241,8 @@ public class MessageCompose extends K9Activity implements OnClickListener, private boolean navigateUp; + private boolean sendMessageHasBeenTriggered = false; + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -797,6 +799,12 @@ public class MessageCompose extends K9Activity implements OnClickListener, } public void performSendAfterChecks() { + if (sendMessageHasBeenTriggered) { + return; + } + + sendMessageHasBeenTriggered = true; + currentMessageBuilder = createMessageBuilder(false); if (currentMessageBuilder != null) { changesMadeSinceLastSave = false; @@ -1607,6 +1615,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, @Override public void onMessageBuildCancel() { + sendMessageHasBeenTriggered = false; currentMessageBuilder = null; setProgressBarIndeterminateVisibility(false); } @@ -1616,6 +1625,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, Timber.e(me, "Error sending message"); Toast.makeText(MessageCompose.this, getString(R.string.send_failed_reason, me.getLocalizedMessage()), Toast.LENGTH_LONG).show(); + sendMessageHasBeenTriggered = false; currentMessageBuilder = null; setProgressBarIndeterminateVisibility(false); } -- GitLab From 2c1ef99c7a8dda18bb7084bf9a7543485cd901c5 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 16:27:02 +0200 Subject: [PATCH 027/121] Remove unused interface `Transport` --- .../main/java/com/fsck/k9/mail/Transport.java | 10 ---------- .../fsck/k9/mail/transport/smtp/SmtpTransport.kt | 12 +++++++----- .../fsck/k9/mail/transport/WebDavTransport.java | 16 +--------------- 3 files changed, 8 insertions(+), 30 deletions(-) delete mode 100644 mail/common/src/main/java/com/fsck/k9/mail/Transport.java diff --git a/mail/common/src/main/java/com/fsck/k9/mail/Transport.java b/mail/common/src/main/java/com/fsck/k9/mail/Transport.java deleted file mode 100644 index 6fdf523ecf..0000000000 --- a/mail/common/src/main/java/com/fsck/k9/mail/Transport.java +++ /dev/null @@ -1,10 +0,0 @@ - -package com.fsck.k9.mail; - -public abstract class Transport { - public abstract void open() throws MessagingException; - - public abstract void sendMessage(Message message) throws MessagingException; - - public abstract void close(); -} diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt index 4b2d6a44af..ecebbf811e 100644 --- a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt +++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt @@ -14,7 +14,6 @@ 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.ServerSettings -import com.fsck.k9.mail.Transport import com.fsck.k9.mail.filter.Base64 import com.fsck.k9.mail.filter.EOLConvertingOutputStream import com.fsck.k9.mail.filter.LineWrapOutputStream @@ -37,6 +36,7 @@ import java.security.GeneralSecurityException import java.util.Locale import javax.net.ssl.SSLException import org.apache.commons.io.IOUtils +import org.jetbrains.annotations.VisibleForTesting private const val SOCKET_SEND_MESSAGE_READ_TIMEOUT = 5 * 60 * 1000 // 5 minutes @@ -47,7 +47,7 @@ class SmtpTransport( serverSettings: ServerSettings, private val trustedSocketFactory: TrustedSocketFactory, private val oauthTokenProvider: OAuth2TokenProvider? -) : Transport() { +) { private val host = serverSettings.host private val port = serverSettings.port private val username = serverSettings.username @@ -79,8 +79,10 @@ class SmtpTransport( require(serverSettings.type == "smtp") { "Expected SMTP ServerSettings!" } } + // TODO: Fix tests to not use open() directly + @VisibleForTesting @Throws(MessagingException::class) - override fun open() { + internal fun open() { try { var secureConnection = connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED @@ -342,7 +344,7 @@ class SmtpTransport( } @Throws(MessagingException::class) - override fun sendMessage(message: Message) { + fun sendMessage(message: Message) { val addresses = buildSet { for (address in message.getRecipients(RecipientType.TO)) { add(address.address) @@ -441,7 +443,7 @@ class SmtpTransport( } } - override fun close() { + private fun close() { writeQuitCommand() IOUtils.closeQuietly(inputStream) diff --git a/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/transport/WebDavTransport.java b/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/transport/WebDavTransport.java index eddf97680d..1cf6d38c46 100644 --- a/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/transport/WebDavTransport.java +++ b/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/transport/WebDavTransport.java @@ -8,13 +8,12 @@ import com.fsck.k9.mail.K9MailLib; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.ServerSettings; -import com.fsck.k9.mail.Transport; import com.fsck.k9.mail.ssl.TrustManagerFactory; import com.fsck.k9.mail.store.webdav.DraftsFolderProvider; import com.fsck.k9.mail.store.webdav.SniHostSetter; import com.fsck.k9.mail.store.webdav.WebDavStore; -public class WebDavTransport extends Transport { +public class WebDavTransport { private WebDavStore store; public WebDavTransport(TrustManagerFactory trustManagerFactory, SniHostSetter sniHostSetter, @@ -25,19 +24,6 @@ public class WebDavTransport extends Transport { Timber.d(">>> New WebDavTransport creation complete"); } - @Override - public void open() throws MessagingException { - if (K9MailLib.isDebug()) - Timber.d( ">>> open called on WebDavTransport "); - - store.getHttpClient(); - } - - @Override - public void close() { - } - - @Override public void sendMessage(Message message) throws MessagingException { store.sendMessages(Collections.singletonList(message)); } -- GitLab From 1690781e7a7b289e505d0cd2289a5886f731fec4 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 16:36:43 +0200 Subject: [PATCH 028/121] Merge `WebDavTransport` into `WebDavStore` --- .../fsck/k9/backends/WebDavBackendFactory.kt | 4 +-- .../fsck/k9/backend/webdav/WebDavBackend.kt | 8 ++--- .../k9/mail/store/webdav/WebDavStore.java | 4 ++- .../k9/mail/transport/WebDavTransport.java | 34 ------------------- 4 files changed, 7 insertions(+), 43 deletions(-) delete mode 100644 mail/protocols/webdav/src/main/java/com/fsck/k9/mail/transport/WebDavTransport.java diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt index 0e4e588e33..f8bf8f7bfc 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt @@ -8,7 +8,6 @@ import com.fsck.k9.mail.ssl.TrustManagerFactory import com.fsck.k9.mail.store.webdav.DraftsFolderProvider import com.fsck.k9.mail.store.webdav.SniHostSetter import com.fsck.k9.mail.store.webdav.WebDavStore -import com.fsck.k9.mail.transport.WebDavTransport import com.fsck.k9.mailstore.FolderRepository import com.fsck.k9.mailstore.K9BackendStorageFactory @@ -24,8 +23,7 @@ class WebDavBackendFactory( val serverSettings = account.incomingServerSettings val draftsFolderProvider = createDraftsFolderProvider(account) val webDavStore = WebDavStore(trustManagerFactory, sniHostSetter, serverSettings, draftsFolderProvider) - val webDavTransport = WebDavTransport(trustManagerFactory, sniHostSetter, serverSettings, draftsFolderProvider) - return WebDavBackend(accountName, backendStorage, webDavStore, webDavTransport) + return WebDavBackend(accountName, backendStorage, webDavStore) } private fun createDraftsFolderProvider(account: Account): DraftsFolderProvider { 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 6e855653a5..79a9d666fa 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 @@ -13,13 +13,11 @@ import com.fsck.k9.mail.Message import com.fsck.k9.mail.MessagingException import com.fsck.k9.mail.Part import com.fsck.k9.mail.store.webdav.WebDavStore -import com.fsck.k9.mail.transport.WebDavTransport class WebDavBackend( accountName: String, backendStorage: BackendStorage, - private val webDavStore: WebDavStore, - private val webDavTransport: WebDavTransport + private val webDavStore: WebDavStore ) : Backend { private val webDavSync: WebDavSync = WebDavSync(accountName, backendStorage, webDavStore) private val commandGetFolders = CommandRefreshFolderList(backendStorage, webDavStore) @@ -139,11 +137,11 @@ class WebDavBackend( } override fun sendMessage(message: Message) { - webDavTransport.sendMessage(message) + webDavStore.sendMessage(message) } override fun checkOutgoingServerSettings() { - webDavTransport.checkSettings() + webDavStore.checkSettings() } override fun createPusher(callback: BackendPusherCallback): BackendPusher { diff --git a/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavStore.java b/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavStore.java index 7a6fdada95..aafe206419 100644 --- a/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavStore.java +++ b/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/store/webdav/WebDavStore.java @@ -11,6 +11,7 @@ import java.net.URISyntaxException; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -937,7 +938,8 @@ public class WebDavStore { return dataset; } - public void sendMessages(List messages) throws MessagingException { + public void sendMessage(Message message) throws MessagingException { + List messages = Collections.singletonList(message); WebDavFolder tmpFolder = getFolder(draftsFolderProvider.getDraftsFolder()); try { tmpFolder.open(); diff --git a/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/transport/WebDavTransport.java b/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/transport/WebDavTransport.java deleted file mode 100644 index 1cf6d38c46..0000000000 --- a/mail/protocols/webdav/src/main/java/com/fsck/k9/mail/transport/WebDavTransport.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.fsck.k9.mail.transport; - - -import java.util.Collections; - -import com.fsck.k9.logging.Timber; -import com.fsck.k9.mail.K9MailLib; -import com.fsck.k9.mail.Message; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.ServerSettings; -import com.fsck.k9.mail.ssl.TrustManagerFactory; -import com.fsck.k9.mail.store.webdav.DraftsFolderProvider; -import com.fsck.k9.mail.store.webdav.SniHostSetter; -import com.fsck.k9.mail.store.webdav.WebDavStore; - -public class WebDavTransport { - private WebDavStore store; - - public WebDavTransport(TrustManagerFactory trustManagerFactory, SniHostSetter sniHostSetter, - ServerSettings serverSettings, DraftsFolderProvider draftsFolderProvider) { - store = new WebDavStore(trustManagerFactory, sniHostSetter, serverSettings, draftsFolderProvider); - - if (K9MailLib.isDebug()) - Timber.d(">>> New WebDavTransport creation complete"); - } - - public void sendMessage(Message message) throws MessagingException { - store.sendMessages(Collections.singletonList(message)); - } - - public void checkSettings() throws MessagingException { - store.checkSettings(); - } -} -- GitLab From b4364efa45787107c10477362670b4798aeaaa7e Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 17:08:28 +0200 Subject: [PATCH 029/121] Update Kotlin to version 1.7.20 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9b9afd8705..f4c76b66ac 100644 --- a/build.gradle +++ b/build.gradle @@ -13,7 +13,7 @@ buildscript { // 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', + 'kotlin': '1.7.20', 'kotlinCoroutines': '1.6.4', 'jetbrainsAnnotations': '23.0.0', 'androidxAppCompat': '1.5.1', -- GitLab From afecde41c5dbb84d7a30a7b9d2843a619882cf76 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 17:10:25 +0200 Subject: [PATCH 030/121] Update AndroidX Activity to version 1.6.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f4c76b66ac..9b361d9689 100644 --- a/build.gradle +++ b/build.gradle @@ -17,7 +17,7 @@ buildscript { 'kotlinCoroutines': '1.6.4', 'jetbrainsAnnotations': '23.0.0', 'androidxAppCompat': '1.5.1', - 'androidxActivity': '1.5.1', + 'androidxActivity': '1.6.0', 'androidxRecyclerView': '1.2.1', 'androidxLifecycle': '2.5.1', 'androidxAnnotation': '1.4.0', -- GitLab From cf5cceafd7296d8b09f5b18b9615d4ff061a0bb3 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 17:15:58 +0200 Subject: [PATCH 031/121] Update AndroidX Annotation to version 1.5.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9b361d9689..d67f73df90 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ buildscript { 'androidxActivity': '1.6.0', 'androidxRecyclerView': '1.2.1', 'androidxLifecycle': '2.5.1', - 'androidxAnnotation': '1.4.0', + 'androidxAnnotation': '1.5.0', 'androidxBiometric': '1.1.0', 'androidxNavigation': '2.5.2', 'androidxConstraintLayout': '2.1.4', -- GitLab From d274cd09f824b20d788f3621bdcfb9e6539844b3 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 17:17:50 +0200 Subject: [PATCH 032/121] Update AndroidX Fragment to version 1.5.3 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index d67f73df90..bd28d1cdf6 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ buildscript { 'androidxNavigation': '2.5.2', 'androidxConstraintLayout': '2.1.4', 'androidxWorkManager': '2.7.1', - 'androidxFragment': '1.5.2', + 'androidxFragment': '1.5.3', 'androidxLocalBroadcastManager': '1.1.0', 'androidxCore': '1.9.0', 'androidxCardView': '1.0.0', -- GitLab From a102ecb15eacf8e080ccacafbc19107cc0be17d9 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 17:25:32 +0200 Subject: [PATCH 033/121] Update FastAdapter to version 5.7.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index bd28d1cdf6..81a3afba1c 100644 --- a/build.gradle +++ b/build.gradle @@ -34,7 +34,7 @@ buildscript { 'androidxTransition': '1.4.1', 'androidxTestCore': '1.4.0', 'materialComponents': '1.6.1', - 'fastAdapter': '5.6.0', + 'fastAdapter': '5.7.0', 'preferencesFix': '1.1.0', 'okio': '3.2.0', 'moshi': '1.14.0', -- GitLab From 1d32af67a409aa566d6705270ca8e88f0a929446 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 17:54:51 +0200 Subject: [PATCH 034/121] Update Koin to version 3.2.2 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 81a3afba1c..587b796d83 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ buildscript { 'okio': '3.2.0', 'moshi': '1.14.0', 'timber': '5.0.1', - 'koin': '3.2.1', + 'koin': '3.2.2', // 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 -- GitLab From 51b7ccbb4c76d3ff10c09d785f17f3e7b2008731 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 18:24:25 +0200 Subject: [PATCH 035/121] Update Glide to version 4.14.2 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 587b796d83..7c944d12b2 100644 --- a/build.gradle +++ b/build.gradle @@ -47,7 +47,7 @@ buildscript { 'mime4j': '0.8.6', 'okhttp': '4.10.0', 'minidns': '1.0.4', - 'glide': '4.13.2', + 'glide': '4.14.2', 'jsoup': '1.15.3', 'httpClient': '4.5.13', -- GitLab From 93da1393d451d02e21402ac83bbf8a8a980e5692 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 18:34:09 +0200 Subject: [PATCH 036/121] Update Robolectric to version 4.9 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 7c944d12b2..a6056a3059 100644 --- a/build.gradle +++ b/build.gradle @@ -53,7 +53,7 @@ buildscript { 'androidxTestRunner': '1.4.0', 'junit': '4.13.2', - 'robolectric': '4.8.2', + 'robolectric': '4.9', 'mockito': '4.8.0', 'mockitoKotlin': '4.0.0', 'truth': '1.1.3', -- GitLab From c3b0a21d34e3736c9220bdd88762745d00e29dfa Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 18:36:24 +0200 Subject: [PATCH 037/121] Update Turbine to version 0.11.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a6056a3059..ef6ba76eb3 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ buildscript { 'mockito': '4.8.0', 'mockitoKotlin': '4.0.0', 'truth': '1.1.3', - 'turbine': '0.10.0', + 'turbine': '0.11.0', 'ktlint': '0.44.0' ] -- GitLab From bacf652e3e1b005848f125ad65ee0ffb0a78c2c8 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 10 Oct 2022 20:03:59 +0200 Subject: [PATCH 038/121] Update URL for AndroidX Preference eXtended --- .../src/main/java/com/fsck/k9/ui/settings/AboutFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/AboutFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/AboutFragment.kt index 376e167be8..bfbbc071cb 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/AboutFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/AboutFragment.kt @@ -95,7 +95,7 @@ class AboutFragment : Fragment() { companion object { private val USED_LIBRARIES = arrayOf( Library("Android Jetpack libraries", "https://developer.android.com/jetpack", "Apache License, Version 2.0"), - Library("AndroidX Preference eXtended", "https://github.com/Gericop/Android-Support-Preference-V7-Fix", "Apache License, Version 2.0"), + Library("AndroidX Preference eXtended", "https://github.com/takisoft/preferencex-android", "Apache License, Version 2.0"), Library("AppAuth for Android", "https://github.com/openid/AppAuth-Android", "Apache License, Version 2.0"), Library("CircleImageView", "https://github.com/hdodenhof/CircleImageView", "Apache License, Version 2.0"), Library("ckChangeLog", "https://github.com/cketti/ckChangeLog", "Apache License, Version 2.0"), -- GitLab From fbd7f5c53bcdc3342f596c8cffa13410b955f402 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 29 Sep 2022 16:31:37 +0200 Subject: [PATCH 039/121] Import a copy of `RecyclerView.LinearLayoutManager` Based on RecyclerView 1.2.1 --- app/ui/legacy/build.gradle | 1 + .../k9/ui/messagelist/MessageListFragment.kt | 3 +- .../main/res/layout/message_list_fragment.xml | 2 - settings.gradle | 1 + ui-utils/LinearLayoutManager/build.gradle | 27 + .../linearlayoutmanager/LayoutManager.java | 102 + .../LinearLayoutManager.java | 2580 +++++++++++++++++ .../linearlayoutmanager/ScrollbarHelper.java | 105 + .../linearlayoutmanager/ViewBoundsCheck.java | 269 ++ 9 files changed, 3087 insertions(+), 3 deletions(-) create mode 100644 ui-utils/LinearLayoutManager/build.gradle create mode 100644 ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LayoutManager.java create mode 100644 ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LinearLayoutManager.java create mode 100644 ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ScrollbarHelper.java create mode 100644 ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ViewBoundsCheck.java diff --git a/app/ui/legacy/build.gradle b/app/ui/legacy/build.gradle index 8dd5f7aded..e1e2207bca 100644 --- a/app/ui/legacy/build.gradle +++ b/app/ui/legacy/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation "com.takisoft.preferencex:preferencex-datetimepicker:${versions.preferencesFix}" implementation "com.takisoft.preferencex:preferencex-colorpicker:${versions.preferencesFix}" implementation "androidx.recyclerview:recyclerview:${versions.androidxRecyclerView}" + implementation project(':ui-utils:LinearLayoutManager') implementation "androidx.lifecycle:lifecycle-runtime-ktx:${versions.androidxLifecycle}" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.androidxLifecycle}" implementation "androidx.lifecycle:lifecycle-livedata-ktx:${versions.androidxLifecycle}" diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index 5173e52e4a..af96fef14f 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -17,9 +17,9 @@ import androidx.appcompat.view.ActionMode import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import app.k9mail.ui.utils.linearlayoutmanager.LinearLayoutManager import com.fsck.k9.Account import com.fsck.k9.Account.Expunge import com.fsck.k9.Account.SortType @@ -238,6 +238,7 @@ class MessageListFragment : val itemDecoration = MessageListItemDecoration(requireContext()) recyclerView.addItemDecoration(itemDecoration) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.itemAnimator = MessageListItemAnimator() } diff --git a/app/ui/legacy/src/main/res/layout/message_list_fragment.xml b/app/ui/legacy/src/main/res/layout/message_list_fragment.xml index c0c6b931bd..b5134a16c4 100644 --- a/app/ui/legacy/src/main/res/layout/message_list_fragment.xml +++ b/app/ui/legacy/src/main/res/layout/message_list_fragment.xml @@ -1,7 +1,6 @@ diff --git a/settings.gradle b/settings.gradle index ef71eb5948..37458009bf 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,7 @@ include ':app:autodiscovery:providersxml' include ':app:autodiscovery:srvrecords' include ':app:autodiscovery:thunderbird' include ':app:html-cleaner' +include ':ui-utils:LinearLayoutManager' include ':mail:common' include ':mail:testing' include ':mail:protocols:imap' diff --git a/ui-utils/LinearLayoutManager/build.gradle b/ui-utils/LinearLayoutManager/build.gradle new file mode 100644 index 0000000000..b4dfdb7116 --- /dev/null +++ b/ui-utils/LinearLayoutManager/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'com.android.library' + +dependencies { + api "androidx.recyclerview:recyclerview:${versions.androidxRecyclerView}" +} + +android { + namespace 'app.k9mail.ui.utils.linearlayoutmanager' + + compileSdkVersion buildConfig.compileSdk + buildToolsVersion buildConfig.buildTools + + defaultConfig { + minSdkVersion buildConfig.minSdk + targetSdkVersion buildConfig.robolectricSdk + } + + lintOptions { + abortOnError false + lintConfig file("$rootProject.projectDir/config/lint/lint.xml") + } + + compileOptions { + sourceCompatibility javaVersion + targetCompatibility javaVersion + } +} diff --git a/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LayoutManager.java b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LayoutManager.java new file mode 100644 index 0000000000..2de9c56f48 --- /dev/null +++ b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LayoutManager.java @@ -0,0 +1,102 @@ +package app.k9mail.ui.utils.linearlayoutmanager; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +// Source: https://github.com/SchildiChat/SchildiChat-android/blob/a321d6a79a1dc93bcfea442390e49c557285eabe/vector/src/main/java/de/spiritcroc/recyclerview/widget/LayoutManager.java +/** + * Exposing/replicating some internal functions from RecylerView.LayoutManager + */ +public abstract class LayoutManager extends RecyclerView.LayoutManager { + + + /* + * Exposed things from RecyclerView.java + */ + + /** + * The callback used for retrieving information about a RecyclerView and its children in the + * horizontal direction. + */ + private final ViewBoundsCheck.Callback mHorizontalBoundCheckCallback = + new ViewBoundsCheck.Callback() { + @Override + public View getChildAt(int index) { + return LayoutManager.this.getChildAt(index); + } + + @Override + public int getParentStart() { + return LayoutManager.this.getPaddingLeft(); + } + + @Override + public int getParentEnd() { + return LayoutManager.this.getWidth() - LayoutManager.this.getPaddingRight(); + } + + @Override + public int getChildStart(View view) { + final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) + view.getLayoutParams(); + return LayoutManager.this.getDecoratedLeft(view) - params.leftMargin; + } + + @Override + public int getChildEnd(View view) { + final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) + view.getLayoutParams(); + return LayoutManager.this.getDecoratedRight(view) + params.rightMargin; + } + }; + + /** + * The callback used for retrieving information about a RecyclerView and its children in the + * vertical direction. + */ + private final ViewBoundsCheck.Callback mVerticalBoundCheckCallback = + new ViewBoundsCheck.Callback() { + @Override + public View getChildAt(int index) { + return LayoutManager.this.getChildAt(index); + } + + @Override + public int getParentStart() { + return LayoutManager.this.getPaddingTop(); + } + + @Override + public int getParentEnd() { + return LayoutManager.this.getHeight() + - LayoutManager.this.getPaddingBottom(); + } + + @Override + public int getChildStart(View view) { + final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) + view.getLayoutParams(); + return LayoutManager.this.getDecoratedTop(view) - params.topMargin; + } + + @Override + public int getChildEnd(View view) { + final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) + view.getLayoutParams(); + return LayoutManager.this.getDecoratedBottom(view) + params.bottomMargin; + } + }; + + /** + * Utility objects used to check the boundaries of children against their parent + * RecyclerView. + * + * @see #isViewPartiallyVisible(View, boolean, boolean), + * {@link LinearLayoutManager#findOneVisibleChild(int, int, boolean, boolean)}, + * and {@link LinearLayoutManager#findOnePartiallyOrCompletelyInvisibleChild(int, int)}. + */ + ViewBoundsCheck mHorizontalBoundCheck = new ViewBoundsCheck(mHorizontalBoundCheckCallback); + ViewBoundsCheck mVerticalBoundCheck = new ViewBoundsCheck(mVerticalBoundCheckCallback); + +} diff --git a/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LinearLayoutManager.java b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LinearLayoutManager.java new file mode 100644 index 0000000000..c85e809f5e --- /dev/null +++ b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LinearLayoutManager.java @@ -0,0 +1,2580 @@ +/* + * Copyright 2018 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 app.k9mail.ui.utils.linearlayoutmanager; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.PointF; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; + +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.OrientationHelper; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +/** + * A {@link RecyclerView.LayoutManager} implementation which provides + * similar functionality to {@link android.widget.ListView}. + */ +public class LinearLayoutManager extends LayoutManager implements + ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider { + + private static final String TAG = "LinearLayoutManager"; + + static final boolean DEBUG = false; + + public static final int HORIZONTAL = RecyclerView.HORIZONTAL; + + public static final int VERTICAL = RecyclerView.VERTICAL; + + public static final int INVALID_OFFSET = Integer.MIN_VALUE; + + + /** + * While trying to find next view to focus, LayoutManager will not try to scroll more + * than this factor times the total space of the list. If layout is vertical, total space is the + * height minus padding, if layout is horizontal, total space is the width minus padding. + */ + private static final float MAX_SCROLL_FACTOR = 1 / 3f; + + /** + * Current orientation. Either {@link #HORIZONTAL} or {@link #VERTICAL} + */ + @RecyclerView.Orientation + int mOrientation = RecyclerView.VERTICAL; + + /** + * Helper class that keeps temporary layout state. + * It does not keep state after layout is complete but we still keep a reference to re-use + * the same object. + */ + private LayoutState mLayoutState; + + /** + * Many calculations are made depending on orientation. To keep it clean, this interface + * helps {@link LinearLayoutManager} make those decisions. + */ + OrientationHelper mOrientationHelper; + + /** + * We need to track this so that we can ignore current position when it changes. + */ + private boolean mLastStackFromEnd; + + + /** + * Defines if layout should be calculated from end to start. + * + * @see #mShouldReverseLayout + */ + private boolean mReverseLayout = false; + + /** + * This keeps the final value for how LayoutManager should start laying out views. + * It is calculated by checking {@link #getReverseLayout()} and View's layout direction. + * {@link #onLayoutChildren(RecyclerView.Recycler, RecyclerView.State)} is run. + */ + boolean mShouldReverseLayout = false; + + /** + * Works the same way as {@link android.widget.AbsListView#setStackFromBottom(boolean)} and + * it supports both orientations. + * see {@link android.widget.AbsListView#setStackFromBottom(boolean)} + */ + private boolean mStackFromEnd = false; + + /** + * Works the same way as {@link android.widget.AbsListView#setSmoothScrollbarEnabled(boolean)}. + * see {@link android.widget.AbsListView#setSmoothScrollbarEnabled(boolean)} + */ + private boolean mSmoothScrollbarEnabled = true; + + /** + * When LayoutManager needs to scroll to a position, it sets this variable and requests a + * layout which will check this variable and re-layout accordingly. + */ + int mPendingScrollPosition = RecyclerView.NO_POSITION; + + /** + * Used to keep the offset value when {@link #scrollToPositionWithOffset(int, int)} is + * called. + */ + int mPendingScrollPositionOffset = INVALID_OFFSET; + + private boolean mRecycleChildrenOnDetach; + + SavedState mPendingSavedState = null; + + /** + * Re-used variable to keep anchor information on re-layout. + * Anchor position and coordinate defines the reference point for LLM while doing a layout. + */ + final AnchorInfo mAnchorInfo = new AnchorInfo(); + + /** + * Stashed to avoid allocation, currently only used in #fill() + */ + private final LayoutChunkResult mLayoutChunkResult = new LayoutChunkResult(); + + /** + * Number of items to prefetch when first coming on screen with new data. + */ + private int mInitialPrefetchItemCount = 2; + + // Reusable int array to be passed to method calls that mutate it in order to "return" two ints. + // This should only be used used transiently and should not be used to retain any state over + // time. + private int[] mReusableIntPair = new int[2]; + + /** + * Creates a vertical LinearLayoutManager + * + * @param context Current context, will be used to access resources. + */ + public LinearLayoutManager(Context context) { + this(context, RecyclerView.VERTICAL, false); + } + + /** + * @param context Current context, will be used to access resources. + * @param orientation Layout orientation. Should be {@link #HORIZONTAL} or {@link + * #VERTICAL}. + * @param reverseLayout When set to true, layouts from end to start. + */ + public LinearLayoutManager(Context context, @RecyclerView.Orientation int orientation, + boolean reverseLayout) { + setOrientation(orientation); + setReverseLayout(reverseLayout); + } + + /** + * Constructor used when layout manager is set in XML by RecyclerView attribute + * "layoutManager". Defaults to vertical orientation. + * + * {@link android.R.attr#orientation} + * {@link androidx.recyclerview.R.attr#reverseLayout} + * {@link androidx.recyclerview.R.attr#stackFromEnd} + */ + public LinearLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + Properties properties = getProperties(context, attrs, defStyleAttr, defStyleRes); + setOrientation(properties.orientation); + setReverseLayout(properties.reverseLayout); + setStackFromEnd(properties.stackFromEnd); + } + + @Override + public boolean isAutoMeasureEnabled() { + return true; + } + + /** + * {@inheritDoc} + */ + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + + /** + * Returns whether LayoutManager will recycle its children when it is detached from + * RecyclerView. + * + * @return true if LayoutManager will recycle its children when it is detached from + * RecyclerView. + */ + public boolean getRecycleChildrenOnDetach() { + return mRecycleChildrenOnDetach; + } + + /** + * Set whether LayoutManager will recycle its children when it is detached from + * RecyclerView. + *

+ * If you are using a {@link RecyclerView.RecycledViewPool}, it might be a good idea to set + * this flag to true so that views will be available to other RecyclerViews + * immediately. + *

+ * Note that, setting this flag will result in a performance drop if RecyclerView + * is restored. + * + * @param recycleChildrenOnDetach Whether children should be recycled in detach or not. + */ + public void setRecycleChildrenOnDetach(boolean recycleChildrenOnDetach) { + mRecycleChildrenOnDetach = recycleChildrenOnDetach; + } + + @Override + public void onDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) { + super.onDetachedFromWindow(view, recycler); + if (mRecycleChildrenOnDetach) { + removeAndRecycleAllViews(recycler); + recycler.clear(); + } + } + + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + if (getChildCount() > 0) { + event.setFromIndex(findFirstVisibleItemPosition()); + event.setToIndex(findLastVisibleItemPosition()); + } + } + + @Override + public Parcelable onSaveInstanceState() { + if (mPendingSavedState != null) { + return new SavedState(mPendingSavedState); + } + SavedState state = new SavedState(); + if (getChildCount() > 0) { + ensureLayoutState(); + boolean didLayoutFromEnd = mLastStackFromEnd ^ mShouldReverseLayout; + state.mAnchorLayoutFromEnd = didLayoutFromEnd; + if (didLayoutFromEnd) { + final View refChild = getChildClosestToEnd(); + state.mAnchorOffset = mOrientationHelper.getEndAfterPadding() + - mOrientationHelper.getDecoratedEnd(refChild); + state.mAnchorPosition = getPosition(refChild); + } else { + final View refChild = getChildClosestToStart(); + state.mAnchorPosition = getPosition(refChild); + state.mAnchorOffset = mOrientationHelper.getDecoratedStart(refChild) + - mOrientationHelper.getStartAfterPadding(); + } + } else { + state.invalidateAnchor(); + } + return state; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + if (state instanceof SavedState) { + mPendingSavedState = (SavedState) state; + if (mPendingScrollPosition != RecyclerView.NO_POSITION) { + mPendingSavedState.invalidateAnchor(); + } + requestLayout(); + if (DEBUG) { + Log.d(TAG, "loaded saved state"); + } + } else if (DEBUG) { + Log.d(TAG, "invalid saved state class"); + } + } + + /** + * @return true if {@link #getOrientation()} is {@link #HORIZONTAL} + */ + @Override + public boolean canScrollHorizontally() { + return mOrientation == HORIZONTAL; + } + + /** + * @return true if {@link #getOrientation()} is {@link #VERTICAL} + */ + @Override + public boolean canScrollVertically() { + return mOrientation == VERTICAL; + } + + /** + * Compatibility support for {@link android.widget.AbsListView#setStackFromBottom(boolean)} + */ + public void setStackFromEnd(boolean stackFromEnd) { + assertNotInLayoutOrScroll(null); + if (mStackFromEnd == stackFromEnd) { + return; + } + mStackFromEnd = stackFromEnd; + requestLayout(); + } + + public boolean getStackFromEnd() { + return mStackFromEnd; + } + + /** + * Returns the current orientation of the layout. + * + * @return Current orientation, either {@link #HORIZONTAL} or {@link #VERTICAL} + * @see #setOrientation(int) + */ + @RecyclerView.Orientation + public int getOrientation() { + return mOrientation; + } + + /** + * Sets the orientation of the layout. {@link LinearLayoutManager} + * will do its best to keep scroll position. + * + * @param orientation {@link #HORIZONTAL} or {@link #VERTICAL} + */ + public void setOrientation(@RecyclerView.Orientation int orientation) { + if (orientation != HORIZONTAL && orientation != VERTICAL) { + throw new IllegalArgumentException("invalid orientation:" + orientation); + } + + assertNotInLayoutOrScroll(null); + + if (orientation != mOrientation || mOrientationHelper == null) { + mOrientationHelper = + OrientationHelper.createOrientationHelper(this, orientation); + mAnchorInfo.mOrientationHelper = mOrientationHelper; + mOrientation = orientation; + requestLayout(); + } + } + + /** + * Calculates the view layout order. (e.g. from end to start or start to end) + * RTL layout support is applied automatically. So if layout is RTL and + * {@link #getReverseLayout()} is {@code true}, elements will be laid out starting from left. + */ + private void resolveShouldLayoutReverse() { + // A == B is the same result, but we rather keep it readable + if (mOrientation == VERTICAL || !isLayoutRTL()) { + mShouldReverseLayout = mReverseLayout; + } else { + mShouldReverseLayout = !mReverseLayout; + } + } + + /** + * Returns if views are laid out from the opposite direction of the layout. + * + * @return If layout is reversed or not. + * @see #setReverseLayout(boolean) + */ + public boolean getReverseLayout() { + return mReverseLayout; + } + + /** + * Used to reverse item traversal and layout order. + * This behaves similar to the layout change for RTL views. When set to true, first item is + * laid out at the end of the UI, second item is laid out before it etc. + * + * For horizontal layouts, it depends on the layout direction. + * When set to true, If {@link RecyclerView} is LTR, than it will + * layout from RTL, if {@link RecyclerView}} is RTL, it will layout + * from LTR. + * + * If you are looking for the exact same behavior of + * {@link android.widget.AbsListView#setStackFromBottom(boolean)}, use + * {@link #setStackFromEnd(boolean)} + */ + public void setReverseLayout(boolean reverseLayout) { + assertNotInLayoutOrScroll(null); + if (reverseLayout == mReverseLayout) { + return; + } + mReverseLayout = reverseLayout; + requestLayout(); + } + + /** + * {@inheritDoc} + */ + @Override + public View findViewByPosition(int position) { + final int childCount = getChildCount(); + if (childCount == 0) { + return null; + } + final int firstChild = getPosition(getChildAt(0)); + final int viewPosition = position - firstChild; + if (viewPosition >= 0 && viewPosition < childCount) { + final View child = getChildAt(viewPosition); + if (getPosition(child) == position) { + return child; // in pre-layout, this may not match + } + } + // fallback to traversal. This might be necessary in pre-layout. + return super.findViewByPosition(position); + } + + /** + *

Returns the amount of extra space that should be laid out by LayoutManager.

+ * + *

By default, {@link LinearLayoutManager} lays out 1 extra page + * of items while smooth scrolling and 0 otherwise. You can override this method to implement + * your custom layout pre-cache logic.

+ * + *

Note:Laying out invisible elements generally comes with significant + * performance cost. It's typically only desirable in places like smooth scrolling to an unknown + * location, where 1) the extra content helps LinearLayoutManager know in advance when its + * target is approaching, so it can decelerate early and smoothly and 2) while motion is + * continuous.

+ * + *

Extending the extra layout space is especially expensive if done while the user may change + * scrolling direction. Changing direction will cause the extra layout space to swap to the + * opposite side of the viewport, incurring many rebinds/recycles, unless the cache is large + * enough to handle it.

+ * + * @return The extra space that should be laid out (in pixels). + * @deprecated Use {@link #calculateExtraLayoutSpace(RecyclerView.State, int[])} instead. + */ + @SuppressWarnings("DeprecatedIsStillUsed") + @Deprecated + protected int getExtraLayoutSpace(RecyclerView.State state) { + if (state.hasTargetScrollPosition()) { + return mOrientationHelper.getTotalSpace(); + } else { + return 0; + } + } + + /** + *

Calculates the amount of extra space (in pixels) that should be laid out by {@link + * LinearLayoutManager} and stores the result in {@code extraLayoutSpace}. {@code + * extraLayoutSpace[0]} should be used for the extra space at the top/left, and {@code + * extraLayoutSpace[1]} should be used for the extra space at the bottom/right (depending on the + * orientation). Thus, the side where it is applied is unaffected by {@link + * #getLayoutDirection()} (LTR vs RTL), {@link #getStackFromEnd()} and {@link + * #getReverseLayout()}. Negative values are ignored.

+ * + *

By default, {@code LinearLayoutManager} lays out 1 extra page of items while smooth + * scrolling, in the direction of the scroll, and no extra space is laid out in all other + * situations. You can override this method to implement your own custom pre-cache logic. Use + * {@link RecyclerView.State#hasTargetScrollPosition()} to find out if a smooth scroll to a + * position is in progress, and {@link RecyclerView.State#getTargetScrollPosition()} to find out + * which item it is scrolling to.

+ * + *

Note:Laying out extra items generally comes with significant performance + * cost. It's typically only desirable in places like smooth scrolling to an unknown location, + * where 1) the extra content helps LinearLayoutManager know in advance when its target is + * approaching, so it can decelerate early and smoothly and 2) while motion is continuous.

+ * + *

Extending the extra layout space is especially expensive if done while the user may change + * scrolling direction. In the default implementation, changing direction will cause the extra + * layout space to swap to the opposite side of the viewport, incurring many rebinds/recycles, + * unless the cache is large enough to handle it.

+ */ + protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state, + @NonNull int[] extraLayoutSpace) { + int extraLayoutSpaceStart = 0; + int extraLayoutSpaceEnd = 0; + + // If calculateExtraLayoutSpace is not overridden, call the + // deprecated getExtraLayoutSpace for backwards compatibility + @SuppressWarnings("deprecation") + int extraScrollSpace = getExtraLayoutSpace(state); + if (mLayoutState.mLayoutDirection == LayoutState.LAYOUT_START) { + extraLayoutSpaceStart = extraScrollSpace; + } else { + extraLayoutSpaceEnd = extraScrollSpace; + } + + extraLayoutSpace[0] = extraLayoutSpaceStart; + extraLayoutSpace[1] = extraLayoutSpaceEnd; + } + + @Override + public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, + int position) { + LinearSmoothScroller linearSmoothScroller = + new LinearSmoothScroller(recyclerView.getContext()); + linearSmoothScroller.setTargetPosition(position); + startSmoothScroll(linearSmoothScroller); + } + + @Override + public PointF computeScrollVectorForPosition(int targetPosition) { + if (getChildCount() == 0) { + return null; + } + final int firstChildPos = getPosition(getChildAt(0)); + final int direction = targetPosition < firstChildPos != mShouldReverseLayout ? -1 : 1; + if (mOrientation == HORIZONTAL) { + return new PointF(direction, 0); + } else { + return new PointF(0, direction); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { + // layout algorithm: + // 1) by checking children and other variables, find an anchor coordinate and an anchor + // item position. + // 2) fill towards start, stacking from bottom + // 3) fill towards end, stacking from top + // 4) scroll to fulfill requirements like stack from bottom. + // create layout state + if (DEBUG) { + Log.d(TAG, "is pre layout:" + state.isPreLayout()); + } + if (mPendingSavedState != null || mPendingScrollPosition != RecyclerView.NO_POSITION) { + if (state.getItemCount() == 0) { + removeAndRecycleAllViews(recycler); + return; + } + } + if (mPendingSavedState != null && mPendingSavedState.hasValidAnchor()) { + mPendingScrollPosition = mPendingSavedState.mAnchorPosition; + } + + ensureLayoutState(); + mLayoutState.mRecycle = false; + // resolve layout direction + resolveShouldLayoutReverse(); + + final View focused = getFocusedChild(); + if (!mAnchorInfo.mValid || mPendingScrollPosition != RecyclerView.NO_POSITION + || mPendingSavedState != null) { + mAnchorInfo.reset(); + mAnchorInfo.mLayoutFromEnd = mShouldReverseLayout ^ mStackFromEnd; + // calculate anchor position and coordinate + updateAnchorInfoForLayout(recycler, state, mAnchorInfo); + mAnchorInfo.mValid = true; + } else if (focused != null && (mOrientationHelper.getDecoratedStart(focused) + >= mOrientationHelper.getEndAfterPadding() + || mOrientationHelper.getDecoratedEnd(focused) + <= mOrientationHelper.getStartAfterPadding())) { + // This case relates to when the anchor child is the focused view and due to layout + // shrinking the focused view fell outside the viewport, e.g. when soft keyboard shows + // up after tapping an EditText which shrinks RV causing the focused view (The tapped + // EditText which is the anchor child) to get kicked out of the screen. Will update the + // anchor coordinate in order to make sure that the focused view is laid out. Otherwise, + // the available space in layoutState will be calculated as negative preventing the + // focused view from being laid out in fill. + // Note that we won't update the anchor position between layout passes (refer to + // TestResizingRelayoutWithAutoMeasure), which happens if we were to call + // updateAnchorInfoForLayout for an anchor that's not the focused view (e.g. a reference + // child which can change between layout passes). + mAnchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused)); + } + if (DEBUG) { + Log.d(TAG, "Anchor info:" + mAnchorInfo); + } + + // LLM may decide to layout items for "extra" pixels to account for scrolling target, + // caching or predictive animations. + + mLayoutState.mLayoutDirection = mLayoutState.mLastScrollDelta >= 0 + ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START; + mReusableIntPair[0] = 0; + mReusableIntPair[1] = 0; + calculateExtraLayoutSpace(state, mReusableIntPair); + int extraForStart = Math.max(0, mReusableIntPair[0]) + + mOrientationHelper.getStartAfterPadding(); + int extraForEnd = Math.max(0, mReusableIntPair[1]) + + mOrientationHelper.getEndPadding(); + if (state.isPreLayout() && mPendingScrollPosition != RecyclerView.NO_POSITION + && mPendingScrollPositionOffset != INVALID_OFFSET) { + // if the child is visible and we are going to move it around, we should layout + // extra items in the opposite direction to make sure new items animate nicely + // instead of just fading in + final View existing = findViewByPosition(mPendingScrollPosition); + if (existing != null) { + final int current; + final int upcomingOffset; + if (mShouldReverseLayout) { + current = mOrientationHelper.getEndAfterPadding() + - mOrientationHelper.getDecoratedEnd(existing); + upcomingOffset = current - mPendingScrollPositionOffset; + } else { + current = mOrientationHelper.getDecoratedStart(existing) + - mOrientationHelper.getStartAfterPadding(); + upcomingOffset = mPendingScrollPositionOffset - current; + } + if (upcomingOffset > 0) { + extraForStart += upcomingOffset; + } else { + extraForEnd -= upcomingOffset; + } + } + } + int startOffset; + int endOffset; + final int firstLayoutDirection; + if (mAnchorInfo.mLayoutFromEnd) { + firstLayoutDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_TAIL + : LayoutState.ITEM_DIRECTION_HEAD; + } else { + firstLayoutDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_HEAD + : LayoutState.ITEM_DIRECTION_TAIL; + } + + onAnchorReady(recycler, state, mAnchorInfo, firstLayoutDirection); + detachAndScrapAttachedViews(recycler); + mLayoutState.mInfinite = resolveIsInfinite(); + mLayoutState.mIsPreLayout = state.isPreLayout(); + // noRecycleSpace not needed: recycling doesn't happen in below's fill + // invocations because mScrollingOffset is set to SCROLLING_OFFSET_NaN + mLayoutState.mNoRecycleSpace = 0; + if (mAnchorInfo.mLayoutFromEnd) { + // fill towards start + updateLayoutStateToFillStart(mAnchorInfo); + mLayoutState.mExtraFillSpace = extraForStart; + fill(recycler, mLayoutState, state, false); + startOffset = mLayoutState.mOffset; + final int firstElement = mLayoutState.mCurrentPosition; + if (mLayoutState.mAvailable > 0) { + extraForEnd += mLayoutState.mAvailable; + } + // fill towards end + updateLayoutStateToFillEnd(mAnchorInfo); + mLayoutState.mExtraFillSpace = extraForEnd; + mLayoutState.mCurrentPosition += mLayoutState.mItemDirection; + fill(recycler, mLayoutState, state, false); + endOffset = mLayoutState.mOffset; + + if (mLayoutState.mAvailable > 0) { + // end could not consume all. add more items towards start + extraForStart = mLayoutState.mAvailable; + updateLayoutStateToFillStart(firstElement, startOffset); + mLayoutState.mExtraFillSpace = extraForStart; + fill(recycler, mLayoutState, state, false); + startOffset = mLayoutState.mOffset; + } + } else { + // fill towards end + updateLayoutStateToFillEnd(mAnchorInfo); + mLayoutState.mExtraFillSpace = extraForEnd; + fill(recycler, mLayoutState, state, false); + endOffset = mLayoutState.mOffset; + final int lastElement = mLayoutState.mCurrentPosition; + if (mLayoutState.mAvailable > 0) { + extraForStart += mLayoutState.mAvailable; + } + // fill towards start + updateLayoutStateToFillStart(mAnchorInfo); + mLayoutState.mExtraFillSpace = extraForStart; + mLayoutState.mCurrentPosition += mLayoutState.mItemDirection; + fill(recycler, mLayoutState, state, false); + startOffset = mLayoutState.mOffset; + + if (mLayoutState.mAvailable > 0) { + extraForEnd = mLayoutState.mAvailable; + // start could not consume all it should. add more items towards end + updateLayoutStateToFillEnd(lastElement, endOffset); + mLayoutState.mExtraFillSpace = extraForEnd; + fill(recycler, mLayoutState, state, false); + endOffset = mLayoutState.mOffset; + } + } + + // changes may cause gaps on the UI, try to fix them. + // TODO we can probably avoid this if neither stackFromEnd/reverseLayout/RTL values have + // changed + if (getChildCount() > 0) { + // because layout from end may be changed by scroll to position + // we re-calculate it. + // find which side we should check for gaps. + if (mShouldReverseLayout ^ mStackFromEnd) { + int fixOffset = fixLayoutEndGap(endOffset, recycler, state, true); + startOffset += fixOffset; + endOffset += fixOffset; + fixOffset = fixLayoutStartGap(startOffset, recycler, state, false); + startOffset += fixOffset; + endOffset += fixOffset; + } else { + int fixOffset = fixLayoutStartGap(startOffset, recycler, state, true); + startOffset += fixOffset; + endOffset += fixOffset; + fixOffset = fixLayoutEndGap(endOffset, recycler, state, false); + startOffset += fixOffset; + endOffset += fixOffset; + } + } + layoutForPredictiveAnimations(recycler, state, startOffset, endOffset); + if (!state.isPreLayout()) { + mOrientationHelper.onLayoutComplete(); + } else { + mAnchorInfo.reset(); + } + mLastStackFromEnd = mStackFromEnd; + if (DEBUG) { + validateChildOrder(); + } + } + + @Override + public void onLayoutCompleted(RecyclerView.State state) { + super.onLayoutCompleted(state); + mPendingSavedState = null; // we don't need this anymore + mPendingScrollPosition = RecyclerView.NO_POSITION; + mPendingScrollPositionOffset = INVALID_OFFSET; + mAnchorInfo.reset(); + } + + /** + * Method called when Anchor position is decided. Extending class can setup accordingly or + * even update anchor info if necessary. + * + * @param recycler The recycler for the layout + * @param state The layout state + * @param anchorInfo The mutable POJO that keeps the position and offset. + * @param firstLayoutItemDirection The direction of the first layout filling in terms of adapter + * indices. + */ + void onAnchorReady(RecyclerView.Recycler recycler, RecyclerView.State state, + AnchorInfo anchorInfo, int firstLayoutItemDirection) { + } + + /** + * If necessary, layouts new items for predictive animations + */ + private void layoutForPredictiveAnimations(RecyclerView.Recycler recycler, + RecyclerView.State state, int startOffset, + int endOffset) { + // If there are scrap children that we did not layout, we need to find where they did go + // and layout them accordingly so that animations can work as expected. + // This case may happen if new views are added or an existing view expands and pushes + // another view out of bounds. + if (!state.willRunPredictiveAnimations() || getChildCount() == 0 || state.isPreLayout() + || !supportsPredictiveItemAnimations()) { + return; + } + // to make the logic simpler, we calculate the size of children and call fill. + int scrapExtraStart = 0, scrapExtraEnd = 0; + final List scrapList = recycler.getScrapList(); + final int scrapSize = scrapList.size(); + final int firstChildPos = getPosition(getChildAt(0)); + for (int i = 0; i < scrapSize; i++) { + RecyclerView.ViewHolder scrap = scrapList.get(i); + if (scrap.getBindingAdapterPosition() == RecyclerView.NO_POSITION) { + continue; + } + final int position = scrap.getLayoutPosition(); + final int direction = position < firstChildPos != mShouldReverseLayout + ? LayoutState.LAYOUT_START : LayoutState.LAYOUT_END; + if (direction == LayoutState.LAYOUT_START) { + scrapExtraStart += mOrientationHelper.getDecoratedMeasurement(scrap.itemView); + } else { + scrapExtraEnd += mOrientationHelper.getDecoratedMeasurement(scrap.itemView); + } + } + + if (DEBUG) { + Log.d(TAG, "for unused scrap, decided to add " + scrapExtraStart + + " towards start and " + scrapExtraEnd + " towards end"); + } + mLayoutState.mScrapList = scrapList; + if (scrapExtraStart > 0) { + View anchor = getChildClosestToStart(); + updateLayoutStateToFillStart(getPosition(anchor), startOffset); + mLayoutState.mExtraFillSpace = scrapExtraStart; + mLayoutState.mAvailable = 0; + mLayoutState.assignPositionFromScrapList(); + fill(recycler, mLayoutState, state, false); + } + + if (scrapExtraEnd > 0) { + View anchor = getChildClosestToEnd(); + updateLayoutStateToFillEnd(getPosition(anchor), endOffset); + mLayoutState.mExtraFillSpace = scrapExtraEnd; + mLayoutState.mAvailable = 0; + mLayoutState.assignPositionFromScrapList(); + fill(recycler, mLayoutState, state, false); + } + mLayoutState.mScrapList = null; + } + + private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state, + AnchorInfo anchorInfo) { + if (updateAnchorFromPendingData(state, anchorInfo)) { + if (DEBUG) { + Log.d(TAG, "updated anchor info from pending information"); + } + return; + } + + if (updateAnchorFromChildren(recycler, state, anchorInfo)) { + if (DEBUG) { + Log.d(TAG, "updated anchor info from existing children"); + } + return; + } + if (DEBUG) { + Log.d(TAG, "deciding anchor info for fresh state"); + } + anchorInfo.assignCoordinateFromPadding(); + anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0; + } + + /** + * Finds an anchor child from existing Views. Most of the time, this is the view closest to + * start or end that has a valid position (e.g. not removed). + *

+ * If a child has focus, it is given priority. + */ + private boolean updateAnchorFromChildren(RecyclerView.Recycler recycler, + RecyclerView.State state, AnchorInfo anchorInfo) { + if (getChildCount() == 0) { + return false; + } + final View focused = getFocusedChild(); + if (focused != null && anchorInfo.isViewValidAsAnchor(focused, state)) { + anchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused)); + return true; + } + if (mLastStackFromEnd != mStackFromEnd) { + return false; + } + View referenceChild = + findReferenceChild( + recycler, + state, + anchorInfo.mLayoutFromEnd, + mStackFromEnd); + if (referenceChild != null) { + anchorInfo.assignFromView(referenceChild, getPosition(referenceChild)); + // If all visible views are removed in 1 pass, reference child might be out of bounds. + // If that is the case, offset it back to 0 so that we use these pre-layout children. + if (!state.isPreLayout() && supportsPredictiveItemAnimations()) { + // validate this child is at least partially visible. if not, offset it to start + final int childStart = mOrientationHelper.getDecoratedStart(referenceChild); + final int childEnd = mOrientationHelper.getDecoratedEnd(referenceChild); + final int boundsStart = mOrientationHelper.getStartAfterPadding(); + final int boundsEnd = mOrientationHelper.getEndAfterPadding(); + // b/148869110: usually if childStart >= boundsEnd the child is out of + // bounds, except if the child is 0 pixels! + boolean outOfBoundsBefore = childEnd <= boundsStart && childStart < boundsStart; + boolean outOfBoundsAfter = childStart >= boundsEnd && childEnd > boundsEnd; + if (outOfBoundsBefore || outOfBoundsAfter) { + anchorInfo.mCoordinate = anchorInfo.mLayoutFromEnd ? boundsEnd : boundsStart; + } + } + return true; + } + return false; + } + + /** + * If there is a pending scroll position or saved states, updates the anchor info from that + * data and returns true + */ + private boolean updateAnchorFromPendingData(RecyclerView.State state, AnchorInfo anchorInfo) { + if (state.isPreLayout() || mPendingScrollPosition == RecyclerView.NO_POSITION) { + return false; + } + // validate scroll position + if (mPendingScrollPosition < 0 || mPendingScrollPosition >= state.getItemCount()) { + mPendingScrollPosition = RecyclerView.NO_POSITION; + mPendingScrollPositionOffset = INVALID_OFFSET; + if (DEBUG) { + Log.e(TAG, "ignoring invalid scroll position " + mPendingScrollPosition); + } + return false; + } + + // if child is visible, try to make it a reference child and ensure it is fully visible. + // if child is not visible, align it depending on its virtual position. + anchorInfo.mPosition = mPendingScrollPosition; + if (mPendingSavedState != null && mPendingSavedState.hasValidAnchor()) { + // Anchor offset depends on how that child was laid out. Here, we update it + // according to our current view bounds + anchorInfo.mLayoutFromEnd = mPendingSavedState.mAnchorLayoutFromEnd; + if (anchorInfo.mLayoutFromEnd) { + anchorInfo.mCoordinate = mOrientationHelper.getEndAfterPadding() + - mPendingSavedState.mAnchorOffset; + } else { + anchorInfo.mCoordinate = mOrientationHelper.getStartAfterPadding() + + mPendingSavedState.mAnchorOffset; + } + return true; + } + + if (mPendingScrollPositionOffset == INVALID_OFFSET) { + View child = findViewByPosition(mPendingScrollPosition); + if (child != null) { + final int childSize = mOrientationHelper.getDecoratedMeasurement(child); + if (childSize > mOrientationHelper.getTotalSpace()) { + // item does not fit. fix depending on layout direction + anchorInfo.assignCoordinateFromPadding(); + return true; + } + final int startGap = mOrientationHelper.getDecoratedStart(child) + - mOrientationHelper.getStartAfterPadding(); + if (startGap < 0) { + anchorInfo.mCoordinate = mOrientationHelper.getStartAfterPadding(); + anchorInfo.mLayoutFromEnd = false; + return true; + } + final int endGap = mOrientationHelper.getEndAfterPadding() + - mOrientationHelper.getDecoratedEnd(child); + if (endGap < 0) { + anchorInfo.mCoordinate = mOrientationHelper.getEndAfterPadding(); + anchorInfo.mLayoutFromEnd = true; + return true; + } + anchorInfo.mCoordinate = anchorInfo.mLayoutFromEnd + ? (mOrientationHelper.getDecoratedEnd(child) + mOrientationHelper + .getTotalSpaceChange()) + : mOrientationHelper.getDecoratedStart(child); + } else { // item is not visible. + if (getChildCount() > 0) { + // get position of any child, does not matter + int pos = getPosition(getChildAt(0)); + anchorInfo.mLayoutFromEnd = mPendingScrollPosition < pos + == mShouldReverseLayout; + } + anchorInfo.assignCoordinateFromPadding(); + } + return true; + } + // override layout from end values for consistency + anchorInfo.mLayoutFromEnd = mShouldReverseLayout; + // if this changes, we should update prepareForDrop as well + if (mShouldReverseLayout) { + anchorInfo.mCoordinate = mOrientationHelper.getEndAfterPadding() + - mPendingScrollPositionOffset; + } else { + anchorInfo.mCoordinate = mOrientationHelper.getStartAfterPadding() + + mPendingScrollPositionOffset; + } + return true; + } + + /** + * @return The final offset amount for children + */ + private int fixLayoutEndGap(int endOffset, RecyclerView.Recycler recycler, + RecyclerView.State state, boolean canOffsetChildren) { + int gap = mOrientationHelper.getEndAfterPadding() - endOffset; + int fixOffset = 0; + if (gap > 0) { + fixOffset = -scrollBy(-gap, recycler, state); + } else { + return 0; // nothing to fix + } + // move offset according to scroll amount + endOffset += fixOffset; + if (canOffsetChildren) { + // re-calculate gap, see if we could fix it + gap = mOrientationHelper.getEndAfterPadding() - endOffset; + if (gap > 0) { + mOrientationHelper.offsetChildren(gap); + return gap + fixOffset; + } + } + return fixOffset; + } + + /** + * @return The final offset amount for children + */ + private int fixLayoutStartGap(int startOffset, RecyclerView.Recycler recycler, + RecyclerView.State state, boolean canOffsetChildren) { + int gap = startOffset - mOrientationHelper.getStartAfterPadding(); + int fixOffset = 0; + if (gap > 0) { + // check if we should fix this gap. + fixOffset = -scrollBy(gap, recycler, state); + } else { + return 0; // nothing to fix + } + startOffset += fixOffset; + if (canOffsetChildren) { + // re-calculate gap, see if we could fix it + gap = startOffset - mOrientationHelper.getStartAfterPadding(); + if (gap > 0) { + mOrientationHelper.offsetChildren(-gap); + return fixOffset - gap; + } + } + return fixOffset; + } + + private void updateLayoutStateToFillEnd(AnchorInfo anchorInfo) { + updateLayoutStateToFillEnd(anchorInfo.mPosition, anchorInfo.mCoordinate); + } + + private void updateLayoutStateToFillEnd(int itemPosition, int offset) { + mLayoutState.mAvailable = mOrientationHelper.getEndAfterPadding() - offset; + mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_HEAD : + LayoutState.ITEM_DIRECTION_TAIL; + mLayoutState.mCurrentPosition = itemPosition; + mLayoutState.mLayoutDirection = LayoutState.LAYOUT_END; + mLayoutState.mOffset = offset; + mLayoutState.mScrollingOffset = LayoutState.SCROLLING_OFFSET_NaN; + } + + private void updateLayoutStateToFillStart(AnchorInfo anchorInfo) { + updateLayoutStateToFillStart(anchorInfo.mPosition, anchorInfo.mCoordinate); + } + + private void updateLayoutStateToFillStart(int itemPosition, int offset) { + mLayoutState.mAvailable = offset - mOrientationHelper.getStartAfterPadding(); + mLayoutState.mCurrentPosition = itemPosition; + mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_TAIL : + LayoutState.ITEM_DIRECTION_HEAD; + mLayoutState.mLayoutDirection = LayoutState.LAYOUT_START; + mLayoutState.mOffset = offset; + mLayoutState.mScrollingOffset = LayoutState.SCROLLING_OFFSET_NaN; + + } + + protected boolean isLayoutRTL() { + return getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL; + } + + void ensureLayoutState() { + if (mLayoutState == null) { + mLayoutState = createLayoutState(); + } + } + + /** + * Test overrides this to plug some tracking and verification. + * + * @return A new LayoutState + */ + LayoutState createLayoutState() { + return new LayoutState(); + } + + /** + *

Scroll the RecyclerView to make the position visible.

+ * + *

RecyclerView will scroll the minimum amount that is necessary to make the + * target position visible. If you are looking for a similar behavior to + * {@link android.widget.ListView#setSelection(int)} or + * {@link android.widget.ListView#setSelectionFromTop(int, int)}, use + * {@link #scrollToPositionWithOffset(int, int)}.

+ * + *

Note that scroll position change will not be reflected until the next layout call.

+ * + * @param position Scroll to this adapter position + * @see #scrollToPositionWithOffset(int, int) + */ + @Override + public void scrollToPosition(int position) { + mPendingScrollPosition = position; + mPendingScrollPositionOffset = INVALID_OFFSET; + if (mPendingSavedState != null) { + mPendingSavedState.invalidateAnchor(); + } + requestLayout(); + } + + /** + * Scroll to the specified adapter position with the given offset from resolved layout + * start. Resolved layout start depends on {@link #getReverseLayout()}, + * {@link ViewCompat#getLayoutDirection(android.view.View)} and {@link #getStackFromEnd()}. + *

+ * For example, if layout is {@link #VERTICAL} and {@link #getStackFromEnd()} is true, calling + * scrollToPositionWithOffset(10, 20) will layout such that + * item[10]'s bottom is 20 pixels above the RecyclerView's bottom. + *

+ * Note that scroll position change will not be reflected until the next layout call. + *

+ * If you are just trying to make a position visible, use {@link #scrollToPosition(int)}. + * + * @param position Index (starting at 0) of the reference item. + * @param offset The distance (in pixels) between the start edge of the item view and + * start edge of the RecyclerView. + * @see #setReverseLayout(boolean) + * @see #scrollToPosition(int) + */ + public void scrollToPositionWithOffset(int position, int offset) { + mPendingScrollPosition = position; + mPendingScrollPositionOffset = offset; + if (mPendingSavedState != null) { + mPendingSavedState.invalidateAnchor(); + } + requestLayout(); + } + + + /** + * {@inheritDoc} + */ + @Override + public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, + RecyclerView.State state) { + if (mOrientation == VERTICAL) { + return 0; + } + return scrollBy(dx, recycler, state); + } + + /** + * {@inheritDoc} + */ + @Override + public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, + RecyclerView.State state) { + if (mOrientation == HORIZONTAL) { + return 0; + } + return scrollBy(dy, recycler, state); + } + + @Override + public int computeHorizontalScrollOffset(RecyclerView.State state) { + return computeScrollOffset(state); + } + + @Override + public int computeVerticalScrollOffset(RecyclerView.State state) { + return computeScrollOffset(state); + } + + @Override + public int computeHorizontalScrollExtent(RecyclerView.State state) { + return computeScrollExtent(state); + } + + @Override + public int computeVerticalScrollExtent(RecyclerView.State state) { + return computeScrollExtent(state); + } + + @Override + public int computeHorizontalScrollRange(RecyclerView.State state) { + return computeScrollRange(state); + } + + @Override + public int computeVerticalScrollRange(RecyclerView.State state) { + return computeScrollRange(state); + } + + private int computeScrollOffset(RecyclerView.State state) { + if (getChildCount() == 0) { + return 0; + } + ensureLayoutState(); + return ScrollbarHelper.computeScrollOffset(state, mOrientationHelper, + findFirstVisibleChildClosestToStart(!mSmoothScrollbarEnabled, true), + findFirstVisibleChildClosestToEnd(!mSmoothScrollbarEnabled, true), + this, mSmoothScrollbarEnabled, mShouldReverseLayout); + } + + private int computeScrollExtent(RecyclerView.State state) { + if (getChildCount() == 0) { + return 0; + } + ensureLayoutState(); + return ScrollbarHelper.computeScrollExtent(state, mOrientationHelper, + findFirstVisibleChildClosestToStart(!mSmoothScrollbarEnabled, true), + findFirstVisibleChildClosestToEnd(!mSmoothScrollbarEnabled, true), + this, mSmoothScrollbarEnabled); + } + + private int computeScrollRange(RecyclerView.State state) { + if (getChildCount() == 0) { + return 0; + } + ensureLayoutState(); + return ScrollbarHelper.computeScrollRange(state, mOrientationHelper, + findFirstVisibleChildClosestToStart(!mSmoothScrollbarEnabled, true), + findFirstVisibleChildClosestToEnd(!mSmoothScrollbarEnabled, true), + this, mSmoothScrollbarEnabled); + } + + /** + * When smooth scrollbar is enabled, the position and size of the scrollbar thumb is computed + * based on the number of visible pixels in the visible items. This however assumes that all + * list items have similar or equal widths or heights (depending on list orientation). + * If you use a list in which items have different dimensions, the scrollbar will change + * appearance as the user scrolls through the list. To avoid this issue, you need to disable + * this property. + * + * When smooth scrollbar is disabled, the position and size of the scrollbar thumb is based + * solely on the number of items in the adapter and the position of the visible items inside + * the adapter. This provides a stable scrollbar as the user navigates through a list of items + * with varying widths / heights. + * + * @param enabled Whether or not to enable smooth scrollbar. + * @see #setSmoothScrollbarEnabled(boolean) + */ + public void setSmoothScrollbarEnabled(boolean enabled) { + mSmoothScrollbarEnabled = enabled; + } + + /** + * Returns the current state of the smooth scrollbar feature. It is enabled by default. + * + * @return True if smooth scrollbar is enabled, false otherwise. + * @see #setSmoothScrollbarEnabled(boolean) + */ + public boolean isSmoothScrollbarEnabled() { + return mSmoothScrollbarEnabled; + } + + private void updateLayoutState(int layoutDirection, int requiredSpace, + boolean canUseExistingSpace, RecyclerView.State state) { + // If parent provides a hint, don't measure unlimited. + mLayoutState.mInfinite = resolveIsInfinite(); + mLayoutState.mLayoutDirection = layoutDirection; + mReusableIntPair[0] = 0; + mReusableIntPair[1] = 0; + calculateExtraLayoutSpace(state, mReusableIntPair); + int extraForStart = Math.max(0, mReusableIntPair[0]); + int extraForEnd = Math.max(0, mReusableIntPair[1]); + boolean layoutToEnd = layoutDirection == LayoutState.LAYOUT_END; + mLayoutState.mExtraFillSpace = layoutToEnd ? extraForEnd : extraForStart; + mLayoutState.mNoRecycleSpace = layoutToEnd ? extraForStart : extraForEnd; + int scrollingOffset; + if (layoutToEnd) { + mLayoutState.mExtraFillSpace += mOrientationHelper.getEndPadding(); + // get the first child in the direction we are going + final View child = getChildClosestToEnd(); + // the direction in which we are traversing children + mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_HEAD + : LayoutState.ITEM_DIRECTION_TAIL; + mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection; + mLayoutState.mOffset = mOrientationHelper.getDecoratedEnd(child); + // calculate how much we can scroll without adding new children (independent of layout) + scrollingOffset = mOrientationHelper.getDecoratedEnd(child) + - mOrientationHelper.getEndAfterPadding(); + + } else { + final View child = getChildClosestToStart(); + mLayoutState.mExtraFillSpace += mOrientationHelper.getStartAfterPadding(); + mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_TAIL + : LayoutState.ITEM_DIRECTION_HEAD; + mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection; + mLayoutState.mOffset = mOrientationHelper.getDecoratedStart(child); + scrollingOffset = -mOrientationHelper.getDecoratedStart(child) + + mOrientationHelper.getStartAfterPadding(); + } + mLayoutState.mAvailable = requiredSpace; + if (canUseExistingSpace) { + mLayoutState.mAvailable -= scrollingOffset; + } + mLayoutState.mScrollingOffset = scrollingOffset; + } + + boolean resolveIsInfinite() { + return mOrientationHelper.getMode() == View.MeasureSpec.UNSPECIFIED + && mOrientationHelper.getEnd() == 0; + } + + void collectPrefetchPositionsForLayoutState(RecyclerView.State state, LayoutState layoutState, + LayoutPrefetchRegistry layoutPrefetchRegistry) { + final int pos = layoutState.mCurrentPosition; + if (pos >= 0 && pos < state.getItemCount()) { + layoutPrefetchRegistry.addPosition(pos, Math.max(0, layoutState.mScrollingOffset)); + } + } + + @Override + public void collectInitialPrefetchPositions(int adapterItemCount, + LayoutPrefetchRegistry layoutPrefetchRegistry) { + final boolean fromEnd; + final int anchorPos; + if (mPendingSavedState != null && mPendingSavedState.hasValidAnchor()) { + // use restored state, since it hasn't been resolved yet + fromEnd = mPendingSavedState.mAnchorLayoutFromEnd; + anchorPos = mPendingSavedState.mAnchorPosition; + } else { + resolveShouldLayoutReverse(); + fromEnd = mShouldReverseLayout; + if (mPendingScrollPosition == RecyclerView.NO_POSITION) { + anchorPos = fromEnd ? adapterItemCount - 1 : 0; + } else { + anchorPos = mPendingScrollPosition; + } + } + + final int direction = fromEnd + ? LayoutState.ITEM_DIRECTION_HEAD + : LayoutState.ITEM_DIRECTION_TAIL; + int targetPos = anchorPos; + for (int i = 0; i < mInitialPrefetchItemCount; i++) { + if (targetPos >= 0 && targetPos < adapterItemCount) { + layoutPrefetchRegistry.addPosition(targetPos, 0); + } else { + break; // no more to prefetch + } + targetPos += direction; + } + } + + /** + * Sets the number of items to prefetch in + * {@link #collectInitialPrefetchPositions(int, LayoutPrefetchRegistry)}, which defines + * how many inner items should be prefetched when this LayoutManager's RecyclerView + * is nested inside another RecyclerView. + * + *

Set this value to the number of items this inner LayoutManager will display when it is + * first scrolled into the viewport. RecyclerView will attempt to prefetch that number of items + * so they are ready, avoiding jank as the inner RecyclerView is scrolled into the viewport.

+ * + *

For example, take a vertically scrolling RecyclerView with horizontally scrolling inner + * RecyclerViews. The rows always have 4 items visible in them (or 5 if not aligned). Passing + * 4 to this method for each inner RecyclerView's LinearLayoutManager will enable + * RecyclerView's prefetching feature to do create/bind work for 4 views within a row early, + * before it is scrolled on screen, instead of just the default 2.

+ * + *

Calling this method does nothing unless the LayoutManager is in a RecyclerView + * nested in another RecyclerView.

+ * + *

Note: Setting this value to be larger than the number of + * views that will be visible in this view can incur unnecessary bind work, and an increase to + * the number of Views created and in active use.

+ * + * @param itemCount Number of items to prefetch + * @see #isItemPrefetchEnabled() + * @see #getInitialPrefetchItemCount() + * @see #collectInitialPrefetchPositions(int, LayoutPrefetchRegistry) + */ + public void setInitialPrefetchItemCount(int itemCount) { + mInitialPrefetchItemCount = itemCount; + } + + /** + * Gets the number of items to prefetch in + * {@link #collectInitialPrefetchPositions(int, LayoutPrefetchRegistry)}, which defines + * how many inner items should be prefetched when this LayoutManager's RecyclerView + * is nested inside another RecyclerView. + * + * @return number of items to prefetch. + * @see #isItemPrefetchEnabled() + * @see #setInitialPrefetchItemCount(int) + * @see #collectInitialPrefetchPositions(int, LayoutPrefetchRegistry) + */ + public int getInitialPrefetchItemCount() { + return mInitialPrefetchItemCount; + } + + @Override + public void collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state, + LayoutPrefetchRegistry layoutPrefetchRegistry) { + int delta = (mOrientation == HORIZONTAL) ? dx : dy; + if (getChildCount() == 0 || delta == 0) { + // can't support this scroll, so don't bother prefetching + return; + } + + ensureLayoutState(); + final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START; + final int absDelta = Math.abs(delta); + updateLayoutState(layoutDirection, absDelta, true, state); + collectPrefetchPositionsForLayoutState(state, mLayoutState, layoutPrefetchRegistry); + } + + int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) { + if (getChildCount() == 0 || delta == 0) { + return 0; + } + ensureLayoutState(); + mLayoutState.mRecycle = true; + final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START; + final int absDelta = Math.abs(delta); + updateLayoutState(layoutDirection, absDelta, true, state); + final int consumed = mLayoutState.mScrollingOffset + + fill(recycler, mLayoutState, state, false); + if (consumed < 0) { + if (DEBUG) { + Log.d(TAG, "Don't have any more elements to scroll"); + } + return 0; + } + final int scrolled = absDelta > consumed ? layoutDirection * consumed : delta; + mOrientationHelper.offsetChildren(-scrolled); + if (DEBUG) { + Log.d(TAG, "scroll req: " + delta + " scrolled: " + scrolled); + } + mLayoutState.mLastScrollDelta = scrolled; + return scrolled; + } + + @Override + public void assertNotInLayoutOrScroll(String message) { + if (mPendingSavedState == null) { + super.assertNotInLayoutOrScroll(message); + } + } + + /** + * Recycles children between given indices. + * + * @param startIndex inclusive + * @param endIndex exclusive + */ + private void recycleChildren(RecyclerView.Recycler recycler, int startIndex, int endIndex) { + if (startIndex == endIndex) { + return; + } + if (DEBUG) { + Log.d(TAG, "Recycling " + Math.abs(startIndex - endIndex) + " items"); + } + if (endIndex > startIndex) { + for (int i = endIndex - 1; i >= startIndex; i--) { + removeAndRecycleViewAt(i, recycler); + } + } else { + for (int i = startIndex; i > endIndex; i--) { + removeAndRecycleViewAt(i, recycler); + } + } + } + + /** + * Recycles views that went out of bounds after scrolling towards the end of the layout. + *

+ * Checks both layout position and visible position to guarantee that the view is not visible. + * + * @param recycler Recycler instance of {@link RecyclerView} + * @param scrollingOffset This can be used to add additional padding to the visible area. This + * is used to detect children that will go out of bounds after scrolling, + * without actually moving them. + * @param noRecycleSpace Extra space that should be excluded from recycling. This is the space + * from {@code extraLayoutSpace[0]}, calculated in {@link + * #calculateExtraLayoutSpace}. + */ + private void recycleViewsFromStart(RecyclerView.Recycler recycler, int scrollingOffset, + int noRecycleSpace) { + if (scrollingOffset < 0) { + if (DEBUG) { + Log.d(TAG, "Called recycle from start with a negative value. This might happen" + + " during layout changes but may be sign of a bug"); + } + return; + } + // ignore padding, ViewGroup may not clip children. + final int limit = scrollingOffset - noRecycleSpace; + final int childCount = getChildCount(); + if (mShouldReverseLayout) { + for (int i = childCount - 1; i >= 0; i--) { + View child = getChildAt(i); + if (mOrientationHelper.getDecoratedEnd(child) > limit + || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) { + // stop here + recycleChildren(recycler, childCount - 1, i); + return; + } + } + } else { + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + if (mOrientationHelper.getDecoratedEnd(child) > limit + || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) { + // stop here + recycleChildren(recycler, 0, i); + return; + } + } + } + } + + + /** + * Recycles views that went out of bounds after scrolling towards the start of the layout. + *

+ * Checks both layout position and visible position to guarantee that the view is not visible. + * + * @param recycler Recycler instance of {@link RecyclerView} + * @param scrollingOffset This can be used to add additional padding to the visible area. This + * is used to detect children that will go out of bounds after scrolling, + * without actually moving them. + * @param noRecycleSpace Extra space that should be excluded from recycling. This is the space + * from {@code extraLayoutSpace[1]}, calculated in {@link + * #calculateExtraLayoutSpace}. + */ + private void recycleViewsFromEnd(RecyclerView.Recycler recycler, int scrollingOffset, + int noRecycleSpace) { + final int childCount = getChildCount(); + if (scrollingOffset < 0) { + if (DEBUG) { + Log.d(TAG, "Called recycle from end with a negative value. This might happen" + + " during layout changes but may be sign of a bug"); + } + return; + } + final int limit = mOrientationHelper.getEnd() - scrollingOffset + noRecycleSpace; + if (mShouldReverseLayout) { + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + if (mOrientationHelper.getDecoratedStart(child) < limit + || mOrientationHelper.getTransformedStartWithDecoration(child) < limit) { + // stop here + recycleChildren(recycler, 0, i); + return; + } + } + } else { + for (int i = childCount - 1; i >= 0; i--) { + View child = getChildAt(i); + if (mOrientationHelper.getDecoratedStart(child) < limit + || mOrientationHelper.getTransformedStartWithDecoration(child) < limit) { + // stop here + recycleChildren(recycler, childCount - 1, i); + return; + } + } + } + } + + /** + * Helper method to call appropriate recycle method depending on current layout direction + * + * @param recycler Current recycler that is attached to RecyclerView + * @param layoutState Current layout state. Right now, this object does not change but + * we may consider moving it out of this view so passing around as a + * parameter for now, rather than accessing {@link #mLayoutState} + * @see #recycleViewsFromStart(RecyclerView.Recycler, int, int) + * @see #recycleViewsFromEnd(RecyclerView.Recycler, int, int) + * @see LinearLayoutManager.LayoutState#mLayoutDirection + */ + private void recycleByLayoutState(RecyclerView.Recycler recycler, LayoutState layoutState) { + if (!layoutState.mRecycle || layoutState.mInfinite) { + return; + } + int scrollingOffset = layoutState.mScrollingOffset; + int noRecycleSpace = layoutState.mNoRecycleSpace; + if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) { + recycleViewsFromEnd(recycler, scrollingOffset, noRecycleSpace); + } else { + recycleViewsFromStart(recycler, scrollingOffset, noRecycleSpace); + } + } + + /** + * The magic functions :). Fills the given layout, defined by the layoutState. This is fairly + * independent from the rest of the {@link LinearLayoutManager} + * and with little change, can be made publicly available as a helper class. + * + * @param recycler Current recycler that is attached to RecyclerView + * @param layoutState Configuration on how we should fill out the available space. + * @param state Context passed by the RecyclerView to control scroll steps. + * @param stopOnFocusable If true, filling stops in the first focusable new child + * @return Number of pixels that it added. Useful for scroll functions. + */ + int fill(RecyclerView.Recycler recycler, LayoutState layoutState, + RecyclerView.State state, boolean stopOnFocusable) { + // max offset we should set is mFastScroll + available + final int start = layoutState.mAvailable; + if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) { + // TODO ugly bug fix. should not happen + if (layoutState.mAvailable < 0) { + layoutState.mScrollingOffset += layoutState.mAvailable; + } + recycleByLayoutState(recycler, layoutState); + } + int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace; + LayoutChunkResult layoutChunkResult = mLayoutChunkResult; + while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) { + layoutChunkResult.resetInternal(); + layoutChunk(recycler, state, layoutState, layoutChunkResult); + if (layoutChunkResult.mFinished) { + break; + } + layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection; + /** + * Consume the available space if: + * * layoutChunk did not request to be ignored + * * OR we are laying out scrap children + * * OR we are not doing pre-layout + */ + if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null + || !state.isPreLayout()) { + layoutState.mAvailable -= layoutChunkResult.mConsumed; + // we keep a separate remaining space because mAvailable is important for recycling + remainingSpace -= layoutChunkResult.mConsumed; + } + + if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) { + layoutState.mScrollingOffset += layoutChunkResult.mConsumed; + if (layoutState.mAvailable < 0) { + layoutState.mScrollingOffset += layoutState.mAvailable; + } + recycleByLayoutState(recycler, layoutState); + } + if (stopOnFocusable && layoutChunkResult.mFocusable) { + break; + } + } + if (DEBUG) { + validateChildOrder(); + } + return start - layoutState.mAvailable; + } + + void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state, + LayoutState layoutState, LayoutChunkResult result) { + View view = layoutState.next(recycler); + if (view == null) { + if (DEBUG && layoutState.mScrapList == null) { + throw new RuntimeException("received null view when unexpected"); + } + // if we are laying out views in scrap, this may return null which means there is + // no more items to layout. + result.mFinished = true; + return; + } + RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams(); + if (layoutState.mScrapList == null) { + if (mShouldReverseLayout == (layoutState.mLayoutDirection + == LayoutState.LAYOUT_START)) { + addView(view); + } else { + addView(view, 0); + } + } else { + if (mShouldReverseLayout == (layoutState.mLayoutDirection + == LayoutState.LAYOUT_START)) { + addDisappearingView(view); + } else { + addDisappearingView(view, 0); + } + } + measureChildWithMargins(view, 0, 0); + result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view); + int left, top, right, bottom; + if (mOrientation == VERTICAL) { + if (isLayoutRTL()) { + right = getWidth() - getPaddingRight(); + left = right - mOrientationHelper.getDecoratedMeasurementInOther(view); + } else { + left = getPaddingLeft(); + right = left + mOrientationHelper.getDecoratedMeasurementInOther(view); + } + if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) { + bottom = layoutState.mOffset; + top = layoutState.mOffset - result.mConsumed; + } else { + top = layoutState.mOffset; + bottom = layoutState.mOffset + result.mConsumed; + } + } else { + top = getPaddingTop(); + bottom = top + mOrientationHelper.getDecoratedMeasurementInOther(view); + + if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) { + right = layoutState.mOffset; + left = layoutState.mOffset - result.mConsumed; + } else { + left = layoutState.mOffset; + right = layoutState.mOffset + result.mConsumed; + } + } + // We calculate everything with View's bounding box (which includes decor and margins) + // To calculate correct layout position, we subtract margins. + layoutDecoratedWithMargins(view, left, top, right, bottom); + if (DEBUG) { + Log.d(TAG, "laid out child at position " + getPosition(view) + ", with l:" + + (left + params.leftMargin) + ", t:" + (top + params.topMargin) + ", r:" + + (right - params.rightMargin) + ", b:" + (bottom - params.bottomMargin)); + } + // Consume the available space if the view is not removed OR changed + if (params.isItemRemoved() || params.isItemChanged()) { + result.mIgnoreConsumed = true; + } + result.mFocusable = view.hasFocusable(); + } + + /** + * Converts a focusDirection to orientation. + * + * @param focusDirection One of {@link View#FOCUS_UP}, {@link View#FOCUS_DOWN}, + * {@link View#FOCUS_LEFT}, {@link View#FOCUS_RIGHT}, + * {@link View#FOCUS_BACKWARD}, {@link View#FOCUS_FORWARD} + * or 0 for not applicable + * @return {@link LayoutState#LAYOUT_START} or {@link LayoutState#LAYOUT_END} if focus direction + * is applicable to current state, {@link LayoutState#INVALID_LAYOUT} otherwise. + */ + int convertFocusDirectionToLayoutDirection(int focusDirection) { + switch (focusDirection) { + case View.FOCUS_BACKWARD: + if (mOrientation == VERTICAL) { + return LayoutState.LAYOUT_START; + } else if (isLayoutRTL()) { + return LayoutState.LAYOUT_END; + } else { + return LayoutState.LAYOUT_START; + } + case View.FOCUS_FORWARD: + if (mOrientation == VERTICAL) { + return LayoutState.LAYOUT_END; + } else if (isLayoutRTL()) { + return LayoutState.LAYOUT_START; + } else { + return LayoutState.LAYOUT_END; + } + case View.FOCUS_UP: + return mOrientation == VERTICAL ? LayoutState.LAYOUT_START + : LayoutState.INVALID_LAYOUT; + case View.FOCUS_DOWN: + return mOrientation == VERTICAL ? LayoutState.LAYOUT_END + : LayoutState.INVALID_LAYOUT; + case View.FOCUS_LEFT: + return mOrientation == HORIZONTAL ? LayoutState.LAYOUT_START + : LayoutState.INVALID_LAYOUT; + case View.FOCUS_RIGHT: + return mOrientation == HORIZONTAL ? LayoutState.LAYOUT_END + : LayoutState.INVALID_LAYOUT; + default: + if (DEBUG) { + Log.d(TAG, "Unknown focus request:" + focusDirection); + } + return LayoutState.INVALID_LAYOUT; + } + + } + + /** + * Convenience method to find the child closes to start. Caller should check it has enough + * children. + * + * @return The child closes to start of the layout from user's perspective. + */ + private View getChildClosestToStart() { + return getChildAt(mShouldReverseLayout ? getChildCount() - 1 : 0); + } + + /** + * Convenience method to find the child closes to end. Caller should check it has enough + * children. + * + * @return The child closes to end of the layout from user's perspective. + */ + private View getChildClosestToEnd() { + return getChildAt(mShouldReverseLayout ? 0 : getChildCount() - 1); + } + + /** + * Convenience method to find the visible child closes to start. Caller should check if it has + * enough children. + * + * @param completelyVisible Whether child should be completely visible or not + * @return The first visible child closest to start of the layout from user's perspective. + */ + View findFirstVisibleChildClosestToStart(boolean completelyVisible, + boolean acceptPartiallyVisible) { + if (mShouldReverseLayout) { + return findOneVisibleChild(getChildCount() - 1, -1, completelyVisible, + acceptPartiallyVisible); + } else { + return findOneVisibleChild(0, getChildCount(), completelyVisible, + acceptPartiallyVisible); + } + } + + /** + * Convenience method to find the visible child closes to end. Caller should check if it has + * enough children. + * + * @param completelyVisible Whether child should be completely visible or not + * @return The first visible child closest to end of the layout from user's perspective. + */ + View findFirstVisibleChildClosestToEnd(boolean completelyVisible, + boolean acceptPartiallyVisible) { + if (mShouldReverseLayout) { + return findOneVisibleChild(0, getChildCount(), completelyVisible, + acceptPartiallyVisible); + } else { + return findOneVisibleChild(getChildCount() - 1, -1, completelyVisible, + acceptPartiallyVisible); + } + } + + // overridden by GridLayoutManager + + /** + * Finds a suitable anchor child. + *

+ * Due to ambiguous adapter updates or children being removed, some children's positions may be + * invalid. This method is a best effort to find a position within adapter bounds if possible. + *

+ * It also prioritizes children from best to worst in this order: + *

    + *
  1. An in bounds child. + *
  2. An out of bounds child. + *
  3. An invalid child. + *
+ * + * @param layoutFromEnd True if the RV scrolls in the reverse direction, which is the same as + * (reverseLayout ^ stackFromEnd). + * @param traverseChildrenInReverseOrder True if the children should be traversed in reverse + * order (stackFromEnd). + * @return A View that can be used an an anchor View. + */ + View findReferenceChild(RecyclerView.Recycler recycler, RecyclerView.State state, + boolean layoutFromEnd, boolean traverseChildrenInReverseOrder) { + ensureLayoutState(); + + // Determine which direction through the view children we are going iterate. + int start = 0; + int end = getChildCount(); + int diff = 1; + if (traverseChildrenInReverseOrder) { + start = getChildCount() - 1; + end = -1; + diff = -1; + } + + int itemCount = state.getItemCount(); + + final int boundsStart = mOrientationHelper.getStartAfterPadding(); + final int boundsEnd = mOrientationHelper.getEndAfterPadding(); + + View invalidMatch = null; + View bestFirstFind = null; + View bestSecondFind = null; + + for (int i = start; i != end; i += diff) { + final View view = getChildAt(i); + final int position = getPosition(view); + final int childStart = mOrientationHelper.getDecoratedStart(view); + final int childEnd = mOrientationHelper.getDecoratedEnd(view); + if (position >= 0 && position < itemCount) { + if (((RecyclerView.LayoutParams) view.getLayoutParams()).isItemRemoved()) { + if (invalidMatch == null) { + invalidMatch = view; // removed item, least preferred + } + } else { + // b/148869110: usually if childStart >= boundsEnd the child is out of + // bounds, except if the child is 0 pixels! + boolean outOfBoundsBefore = childEnd <= boundsStart && childStart < boundsStart; + boolean outOfBoundsAfter = childStart >= boundsEnd && childEnd > boundsEnd; + if (outOfBoundsBefore || outOfBoundsAfter) { + // The item is out of bounds. + // We want to find the items closest to the in bounds items and because we + // are always going through the items linearly, the 2 items we want are the + // last out of bounds item on the side we start searching on, and the first + // out of bounds item on the side we are ending on. The side that we are + // ending on ultimately takes priority because we want items later in the + // layout to move forward if no in bounds anchors are found. + if (layoutFromEnd) { + if (outOfBoundsAfter) { + bestFirstFind = view; + } else if (bestSecondFind == null) { + bestSecondFind = view; + } + } else { + if (outOfBoundsBefore) { + bestFirstFind = view; + } else if (bestSecondFind == null) { + bestSecondFind = view; + } + } + } else { + // We found an in bounds item, greedily return it. + return view; + } + } + } + } + // We didn't find an in bounds item so we will settle for an item in this order: + // 1. bestSecondFind + // 2. bestFirstFind + // 3. invalidMatch + return bestSecondFind != null ? bestSecondFind : + (bestFirstFind != null ? bestFirstFind : invalidMatch); + } + + // returns the out-of-bound child view closest to RV's end bounds. An out-of-bound child is + // defined as a child that's either partially or fully invisible (outside RV's padding area). + private View findPartiallyOrCompletelyInvisibleChildClosestToEnd() { + return mShouldReverseLayout ? findFirstPartiallyOrCompletelyInvisibleChild() + : findLastPartiallyOrCompletelyInvisibleChild(); + } + + // returns the out-of-bound child view closest to RV's starting bounds. An out-of-bound child is + // defined as a child that's either partially or fully invisible (outside RV's padding area). + private View findPartiallyOrCompletelyInvisibleChildClosestToStart() { + return mShouldReverseLayout ? findLastPartiallyOrCompletelyInvisibleChild() : + findFirstPartiallyOrCompletelyInvisibleChild(); + } + + private View findFirstPartiallyOrCompletelyInvisibleChild() { + return findOnePartiallyOrCompletelyInvisibleChild(0, getChildCount()); + } + + private View findLastPartiallyOrCompletelyInvisibleChild() { + return findOnePartiallyOrCompletelyInvisibleChild(getChildCount() - 1, -1); + } + + /** + * Returns the adapter position of the first visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + *

+ * Note that, this value is not affected by layout orientation or item order traversal. + * ({@link #setReverseLayout(boolean)}). Views are sorted by their positions in the adapter, + * not in the layout. + *

+ * If RecyclerView has item decorators, they will be considered in calculations as well. + *

+ * LayoutManager may pre-cache some views that are not necessarily visible. Those views + * are ignored in this method. + * + * @return The adapter position of the first visible item or {@link RecyclerView#NO_POSITION} if + * there aren't any visible items. + * @see #findFirstCompletelyVisibleItemPosition() + * @see #findLastVisibleItemPosition() + */ + public int findFirstVisibleItemPosition() { + final View child = findOneVisibleChild(0, getChildCount(), false, true); + return child == null ? RecyclerView.NO_POSITION : getPosition(child); + } + + /** + * Returns the adapter position of the first fully visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + *

+ * Note that bounds check is only performed in the current orientation. That means, if + * LayoutManager is horizontal, it will only check the view's left and right edges. + * + * @return The adapter position of the first fully visible item or + * {@link RecyclerView#NO_POSITION} if there aren't any visible items. + * @see #findFirstVisibleItemPosition() + * @see #findLastCompletelyVisibleItemPosition() + */ + public int findFirstCompletelyVisibleItemPosition() { + final View child = findOneVisibleChild(0, getChildCount(), true, false); + return child == null ? RecyclerView.NO_POSITION : getPosition(child); + } + + /** + * Returns the adapter position of the last visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + *

+ * Note that, this value is not affected by layout orientation or item order traversal. + * ({@link #setReverseLayout(boolean)}). Views are sorted by their positions in the adapter, + * not in the layout. + *

+ * If RecyclerView has item decorators, they will be considered in calculations as well. + *

+ * LayoutManager may pre-cache some views that are not necessarily visible. Those views + * are ignored in this method. + * + * @return The adapter position of the last visible view or {@link RecyclerView#NO_POSITION} if + * there aren't any visible items. + * @see #findLastCompletelyVisibleItemPosition() + * @see #findFirstVisibleItemPosition() + */ + public int findLastVisibleItemPosition() { + final View child = findOneVisibleChild(getChildCount() - 1, -1, false, true); + return child == null ? RecyclerView.NO_POSITION : getPosition(child); + } + + /** + * Returns the adapter position of the last fully visible view. This position does not include + * adapter changes that were dispatched after the last layout pass. + *

+ * Note that bounds check is only performed in the current orientation. That means, if + * LayoutManager is horizontal, it will only check the view's left and right edges. + * + * @return The adapter position of the last fully visible view or + * {@link RecyclerView#NO_POSITION} if there aren't any visible items. + * @see #findLastVisibleItemPosition() + * @see #findFirstCompletelyVisibleItemPosition() + */ + public int findLastCompletelyVisibleItemPosition() { + final View child = findOneVisibleChild(getChildCount() - 1, -1, true, false); + return child == null ? RecyclerView.NO_POSITION : getPosition(child); + } + + // Returns the first child that is visible in the provided index range, i.e. either partially or + // fully visible depending on the arguments provided. Completely invisible children are not + // acceptable by this method, but could be returned + // using #findOnePartiallyOrCompletelyInvisibleChild + View findOneVisibleChild(int fromIndex, int toIndex, boolean completelyVisible, + boolean acceptPartiallyVisible) { + ensureLayoutState(); + @ViewBoundsCheck.ViewBounds int preferredBoundsFlag = 0; + @ViewBoundsCheck.ViewBounds int acceptableBoundsFlag = 0; + if (completelyVisible) { + preferredBoundsFlag = (ViewBoundsCheck.FLAG_CVS_GT_PVS | ViewBoundsCheck.FLAG_CVS_EQ_PVS + | ViewBoundsCheck.FLAG_CVE_LT_PVE | ViewBoundsCheck.FLAG_CVE_EQ_PVE); + } else { + preferredBoundsFlag = (ViewBoundsCheck.FLAG_CVS_LT_PVE + | ViewBoundsCheck.FLAG_CVE_GT_PVS); + } + if (acceptPartiallyVisible) { + acceptableBoundsFlag = (ViewBoundsCheck.FLAG_CVS_LT_PVE + | ViewBoundsCheck.FLAG_CVE_GT_PVS); + } + return (mOrientation == HORIZONTAL) ? mHorizontalBoundCheck + .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, + acceptableBoundsFlag) : mVerticalBoundCheck + .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, + acceptableBoundsFlag); + } + + View findOnePartiallyOrCompletelyInvisibleChild(int fromIndex, int toIndex) { + ensureLayoutState(); + final int next = toIndex > fromIndex ? 1 : (toIndex < fromIndex ? -1 : 0); + if (next == 0) { + return getChildAt(fromIndex); + } + @ViewBoundsCheck.ViewBounds int preferredBoundsFlag = 0; + @ViewBoundsCheck.ViewBounds int acceptableBoundsFlag = 0; + if (mOrientationHelper.getDecoratedStart(getChildAt(fromIndex)) + < mOrientationHelper.getStartAfterPadding()) { + preferredBoundsFlag = (ViewBoundsCheck.FLAG_CVS_LT_PVS | ViewBoundsCheck.FLAG_CVE_LT_PVE + | ViewBoundsCheck.FLAG_CVE_GT_PVS); + acceptableBoundsFlag = (ViewBoundsCheck.FLAG_CVS_LT_PVS + | ViewBoundsCheck.FLAG_CVE_LT_PVE); + } else { + preferredBoundsFlag = (ViewBoundsCheck.FLAG_CVE_GT_PVE | ViewBoundsCheck.FLAG_CVS_GT_PVS + | ViewBoundsCheck.FLAG_CVS_LT_PVE); + acceptableBoundsFlag = (ViewBoundsCheck.FLAG_CVE_GT_PVE + | ViewBoundsCheck.FLAG_CVS_GT_PVS); + } + return (mOrientation == HORIZONTAL) ? mHorizontalBoundCheck + .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, + acceptableBoundsFlag) : mVerticalBoundCheck + .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, + acceptableBoundsFlag); + } + + @Override + public View onFocusSearchFailed(View focused, int focusDirection, + RecyclerView.Recycler recycler, RecyclerView.State state) { + resolveShouldLayoutReverse(); + if (getChildCount() == 0) { + return null; + } + + final int layoutDir = convertFocusDirectionToLayoutDirection(focusDirection); + if (layoutDir == LayoutState.INVALID_LAYOUT) { + return null; + } + ensureLayoutState(); + final int maxScroll = (int) (MAX_SCROLL_FACTOR * mOrientationHelper.getTotalSpace()); + updateLayoutState(layoutDir, maxScroll, false, state); + mLayoutState.mScrollingOffset = LayoutState.SCROLLING_OFFSET_NaN; + mLayoutState.mRecycle = false; + fill(recycler, mLayoutState, state, true); + + // nextCandidate is the first child view in the layout direction that's partially + // within RV's bounds, i.e. part of it is visible or it's completely invisible but still + // touching RV's bounds. This will be the unfocusable candidate view to become visible onto + // the screen if no focusable views are found in the given layout direction. + final View nextCandidate; + if (layoutDir == LayoutState.LAYOUT_START) { + nextCandidate = findPartiallyOrCompletelyInvisibleChildClosestToStart(); + } else { + nextCandidate = findPartiallyOrCompletelyInvisibleChildClosestToEnd(); + } + // nextFocus is meaningful only if it refers to a focusable child, in which case it + // indicates the next view to gain focus. + final View nextFocus; + if (layoutDir == LayoutState.LAYOUT_START) { + nextFocus = getChildClosestToStart(); + } else { + nextFocus = getChildClosestToEnd(); + } + if (nextFocus.hasFocusable()) { + if (nextCandidate == null) { + return null; + } + return nextFocus; + } + return nextCandidate; + } + + /** + * Used for debugging. + * Logs the internal representation of children to default logger. + */ + private void logChildren() { + Log.d(TAG, "internal representation of views on the screen"); + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + Log.d(TAG, "item " + getPosition(child) + ", coord:" + + mOrientationHelper.getDecoratedStart(child)); + } + Log.d(TAG, "=============="); + } + + /** + * Used for debugging. + * Validates that child views are laid out in correct order. This is important because rest of + * the algorithm relies on this constraint. + * + * In default layout, child 0 should be closest to screen position 0 and last child should be + * closest to position WIDTH or HEIGHT. + * In reverse layout, last child should be closes to screen position 0 and first child should + * be closest to position WIDTH or HEIGHT + */ + void validateChildOrder() { + Log.d(TAG, "validating child count " + getChildCount()); + if (getChildCount() < 1) { + return; + } + int lastPos = getPosition(getChildAt(0)); + int lastScreenLoc = mOrientationHelper.getDecoratedStart(getChildAt(0)); + if (mShouldReverseLayout) { + for (int i = 1; i < getChildCount(); i++) { + View child = getChildAt(i); + int pos = getPosition(child); + int screenLoc = mOrientationHelper.getDecoratedStart(child); + if (pos < lastPos) { + logChildren(); + throw new RuntimeException("detected invalid position. loc invalid? " + + (screenLoc < lastScreenLoc)); + } + if (screenLoc > lastScreenLoc) { + logChildren(); + throw new RuntimeException("detected invalid location"); + } + } + } else { + for (int i = 1; i < getChildCount(); i++) { + View child = getChildAt(i); + int pos = getPosition(child); + int screenLoc = mOrientationHelper.getDecoratedStart(child); + if (pos < lastPos) { + logChildren(); + throw new RuntimeException("detected invalid position. loc invalid? " + + (screenLoc < lastScreenLoc)); + } + if (screenLoc < lastScreenLoc) { + logChildren(); + throw new RuntimeException("detected invalid location"); + } + } + } + } + + @Override + public boolean supportsPredictiveItemAnimations() { + return mPendingSavedState == null && mLastStackFromEnd == mStackFromEnd; + } + + /** + * {@inheritDoc} + */ + // This method is only intended to be called (and should only ever be called) by + // ItemTouchHelper. + @Override + public void prepareForDrop(@NonNull View view, @NonNull View target, int x, int y) { + assertNotInLayoutOrScroll("Cannot drop a view during a scroll or layout calculation"); + ensureLayoutState(); + resolveShouldLayoutReverse(); + final int myPos = getPosition(view); + final int targetPos = getPosition(target); + final int dropDirection = myPos < targetPos ? LayoutState.ITEM_DIRECTION_TAIL + : LayoutState.ITEM_DIRECTION_HEAD; + if (mShouldReverseLayout) { + if (dropDirection == LayoutState.ITEM_DIRECTION_TAIL) { + scrollToPositionWithOffset(targetPos, + mOrientationHelper.getEndAfterPadding() + - (mOrientationHelper.getDecoratedStart(target) + + mOrientationHelper.getDecoratedMeasurement(view))); + } else { + scrollToPositionWithOffset(targetPos, + mOrientationHelper.getEndAfterPadding() + - mOrientationHelper.getDecoratedEnd(target)); + } + } else { + if (dropDirection == LayoutState.ITEM_DIRECTION_HEAD) { + scrollToPositionWithOffset(targetPos, mOrientationHelper.getDecoratedStart(target)); + } else { + scrollToPositionWithOffset(targetPos, + mOrientationHelper.getDecoratedEnd(target) + - mOrientationHelper.getDecoratedMeasurement(view)); + } + } + } + + /** + * Helper class that keeps temporary state while {LayoutManager} is filling out the empty + * space. + */ + static class LayoutState { + + static final String TAG = "LLM#LayoutState"; + + static final int LAYOUT_START = -1; + + static final int LAYOUT_END = 1; + + static final int INVALID_LAYOUT = Integer.MIN_VALUE; + + static final int ITEM_DIRECTION_HEAD = -1; + + static final int ITEM_DIRECTION_TAIL = 1; + + static final int SCROLLING_OFFSET_NaN = Integer.MIN_VALUE; + + /** + * We may not want to recycle children in some cases (e.g. layout) + */ + boolean mRecycle = true; + + /** + * Pixel offset where layout should start + */ + int mOffset; + + /** + * Number of pixels that we should fill, in the layout direction. + */ + int mAvailable; + + /** + * Current position on the adapter to get the next item. + */ + int mCurrentPosition; + + /** + * Defines the direction in which the data adapter is traversed. + * Should be {@link #ITEM_DIRECTION_HEAD} or {@link #ITEM_DIRECTION_TAIL} + */ + int mItemDirection; + + /** + * Defines the direction in which the layout is filled. + * Should be {@link #LAYOUT_START} or {@link #LAYOUT_END} + */ + int mLayoutDirection; + + /** + * Used when LayoutState is constructed in a scrolling state. + * It should be set the amount of scrolling we can make without creating a new view. + * Settings this is required for efficient view recycling. + */ + int mScrollingOffset; + + /** + * Used if you want to pre-layout items that are not yet visible. + * The difference with {@link #mAvailable} is that, when recycling, distance laid out for + * {@link #mExtraFillSpace} is not considered to avoid recycling visible children. + */ + int mExtraFillSpace = 0; + + /** + * Contains the {@link #calculateExtraLayoutSpace(RecyclerView.State, int[])} extra layout + * space} that should be excluded for recycling when cleaning up the tail of the list during + * a smooth scroll. + */ + int mNoRecycleSpace = 0; + + /** + * Equal to {@link RecyclerView.State#isPreLayout()}. When consuming scrap, if this value + * is set to true, we skip removed views since they should not be laid out in post layout + * step. + */ + boolean mIsPreLayout = false; + + /** + * The most recent {@link #scrollBy(int, RecyclerView.Recycler, RecyclerView.State)} + * amount. + */ + int mLastScrollDelta; + + /** + * When LLM needs to layout particular views, it sets this list in which case, LayoutState + * will only return views from this list and return null if it cannot find an item. + */ + List mScrapList = null; + + /** + * Used when there is no limit in how many views can be laid out. + */ + boolean mInfinite; + + /** + * @return true if there are more items in the data adapter + */ + boolean hasMore(RecyclerView.State state) { + return mCurrentPosition >= 0 && mCurrentPosition < state.getItemCount(); + } + + /** + * Gets the view for the next element that we should layout. + * Also updates current item index to the next item, based on {@link #mItemDirection} + * + * @return The next element that we should layout. + */ + View next(RecyclerView.Recycler recycler) { + if (mScrapList != null) { + return nextViewFromScrapList(); + } + final View view = recycler.getViewForPosition(mCurrentPosition); + mCurrentPosition += mItemDirection; + return view; + } + + /** + * Returns the next item from the scrap list. + *

+ * Upon finding a valid VH, sets current item position to VH.itemPosition + mItemDirection + * + * @return View if an item in the current position or direction exists if not null. + */ + private View nextViewFromScrapList() { + final int size = mScrapList.size(); + for (int i = 0; i < size; i++) { + final View view = mScrapList.get(i).itemView; + final RecyclerView.LayoutParams lp = + (RecyclerView.LayoutParams) view.getLayoutParams(); + if (lp.isItemRemoved()) { + continue; + } + if (mCurrentPosition == lp.getViewLayoutPosition()) { + assignPositionFromScrapList(view); + return view; + } + } + return null; + } + + public void assignPositionFromScrapList() { + assignPositionFromScrapList(null); + } + + public void assignPositionFromScrapList(View ignore) { + final View closest = nextViewInLimitedList(ignore); + if (closest == null) { + mCurrentPosition = RecyclerView.NO_POSITION; + } else { + mCurrentPosition = ((RecyclerView.LayoutParams) closest.getLayoutParams()) + .getViewLayoutPosition(); + } + } + + public View nextViewInLimitedList(View ignore) { + int size = mScrapList.size(); + View closest = null; + int closestDistance = Integer.MAX_VALUE; + if (DEBUG && mIsPreLayout) { + throw new IllegalStateException("Scrap list cannot be used in pre layout"); + } + for (int i = 0; i < size; i++) { + View view = mScrapList.get(i).itemView; + final RecyclerView.LayoutParams lp = + (RecyclerView.LayoutParams) view.getLayoutParams(); + if (view == ignore || lp.isItemRemoved()) { + continue; + } + final int distance = (lp.getViewLayoutPosition() - mCurrentPosition) + * mItemDirection; + if (distance < 0) { + continue; // item is not in current direction + } + if (distance < closestDistance) { + closest = view; + closestDistance = distance; + if (distance == 0) { + break; + } + } + } + return closest; + } + + void log() { + Log.d(TAG, "avail:" + mAvailable + ", ind:" + mCurrentPosition + ", dir:" + + mItemDirection + ", offset:" + mOffset + ", layoutDir:" + mLayoutDirection); + } + } + + /** + * @hide + */ + @RestrictTo(LIBRARY) + @SuppressLint("BanParcelableUsage") + public static class SavedState implements Parcelable { + + int mAnchorPosition; + + int mAnchorOffset; + + boolean mAnchorLayoutFromEnd; + + public SavedState() { + + } + + SavedState(Parcel in) { + mAnchorPosition = in.readInt(); + mAnchorOffset = in.readInt(); + mAnchorLayoutFromEnd = in.readInt() == 1; + } + + public SavedState(SavedState other) { + mAnchorPosition = other.mAnchorPosition; + mAnchorOffset = other.mAnchorOffset; + mAnchorLayoutFromEnd = other.mAnchorLayoutFromEnd; + } + + boolean hasValidAnchor() { + return mAnchorPosition >= 0; + } + + void invalidateAnchor() { + mAnchorPosition = RecyclerView.NO_POSITION; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mAnchorPosition); + dest.writeInt(mAnchorOffset); + dest.writeInt(mAnchorLayoutFromEnd ? 1 : 0); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + /** + * Simple data class to keep Anchor information + */ + static class AnchorInfo { + OrientationHelper mOrientationHelper; + int mPosition; + int mCoordinate; + boolean mLayoutFromEnd; + boolean mValid; + + AnchorInfo() { + reset(); + } + + void reset() { + mPosition = RecyclerView.NO_POSITION; + mCoordinate = INVALID_OFFSET; + mLayoutFromEnd = false; + mValid = false; + } + + /** + * assigns anchor coordinate from the RecyclerView's padding depending on current + * layoutFromEnd value + */ + void assignCoordinateFromPadding() { + mCoordinate = mLayoutFromEnd + ? mOrientationHelper.getEndAfterPadding() + : mOrientationHelper.getStartAfterPadding(); + } + + @Override + public String toString() { + return "AnchorInfo{" + + "mPosition=" + mPosition + + ", mCoordinate=" + mCoordinate + + ", mLayoutFromEnd=" + mLayoutFromEnd + + ", mValid=" + mValid + + '}'; + } + + boolean isViewValidAsAnchor(View child, RecyclerView.State state) { + RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams(); + return !lp.isItemRemoved() && lp.getViewLayoutPosition() >= 0 + && lp.getViewLayoutPosition() < state.getItemCount(); + } + + public void assignFromViewAndKeepVisibleRect(View child, int position) { + final int spaceChange = mOrientationHelper.getTotalSpaceChange(); + if (spaceChange >= 0) { + assignFromView(child, position); + return; + } + mPosition = position; + if (mLayoutFromEnd) { + final int prevLayoutEnd = mOrientationHelper.getEndAfterPadding() - spaceChange; + final int childEnd = mOrientationHelper.getDecoratedEnd(child); + final int previousEndMargin = prevLayoutEnd - childEnd; + mCoordinate = mOrientationHelper.getEndAfterPadding() - previousEndMargin; + // ensure we did not push child's top out of bounds because of this + if (previousEndMargin > 0) { // we have room to shift bottom if necessary + final int childSize = mOrientationHelper.getDecoratedMeasurement(child); + final int estimatedChildStart = mCoordinate - childSize; + final int layoutStart = mOrientationHelper.getStartAfterPadding(); + final int previousStartMargin = mOrientationHelper.getDecoratedStart(child) + - layoutStart; + final int startReference = layoutStart + Math.min(previousStartMargin, 0); + final int startMargin = estimatedChildStart - startReference; + if (startMargin < 0) { + // offset to make top visible but not too much + mCoordinate += Math.min(previousEndMargin, -startMargin); + } + } + } else { + final int childStart = mOrientationHelper.getDecoratedStart(child); + final int startMargin = childStart - mOrientationHelper.getStartAfterPadding(); + mCoordinate = childStart; + if (startMargin > 0) { // we have room to fix end as well + final int estimatedEnd = childStart + + mOrientationHelper.getDecoratedMeasurement(child); + final int previousLayoutEnd = mOrientationHelper.getEndAfterPadding() + - spaceChange; + final int previousEndMargin = previousLayoutEnd + - mOrientationHelper.getDecoratedEnd(child); + final int endReference = mOrientationHelper.getEndAfterPadding() + - Math.min(0, previousEndMargin); + final int endMargin = endReference - estimatedEnd; + if (endMargin < 0) { + mCoordinate -= Math.min(startMargin, -endMargin); + } + } + } + } + + public void assignFromView(View child, int position) { + if (mLayoutFromEnd) { + mCoordinate = mOrientationHelper.getDecoratedEnd(child) + + mOrientationHelper.getTotalSpaceChange(); + } else { + mCoordinate = mOrientationHelper.getDecoratedStart(child); + } + + mPosition = position; + } + } + + protected static class LayoutChunkResult { + public int mConsumed; + public boolean mFinished; + public boolean mIgnoreConsumed; + public boolean mFocusable; + + void resetInternal() { + mConsumed = 0; + mFinished = false; + mIgnoreConsumed = false; + mFocusable = false; + } + } +} diff --git a/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ScrollbarHelper.java b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ScrollbarHelper.java new file mode 100644 index 0000000000..e785e960d4 --- /dev/null +++ b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ScrollbarHelper.java @@ -0,0 +1,105 @@ +/* + * Copyright 2018 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 app.k9mail.ui.utils.linearlayoutmanager; + +import android.view.View; + +import androidx.recyclerview.widget.OrientationHelper; +import androidx.recyclerview.widget.RecyclerView; + + +/** + * A helper class to do scroll offset calculations. + */ +class ScrollbarHelper { + + /** + * @param startChild View closest to start of the list. (top or left) + * @param endChild View closest to end of the list (bottom or right) + */ + static int computeScrollOffset(RecyclerView.State state, OrientationHelper orientation, + View startChild, View endChild, RecyclerView.LayoutManager lm, + boolean smoothScrollbarEnabled, boolean reverseLayout) { + if (lm.getChildCount() == 0 || state.getItemCount() == 0 || startChild == null + || endChild == null) { + return 0; + } + final int minPosition = Math.min(lm.getPosition(startChild), + lm.getPosition(endChild)); + final int maxPosition = Math.max(lm.getPosition(startChild), + lm.getPosition(endChild)); + final int itemsBefore = reverseLayout + ? Math.max(0, state.getItemCount() - maxPosition - 1) + : Math.max(0, minPosition); + if (!smoothScrollbarEnabled) { + return itemsBefore; + } + final int laidOutArea = Math.abs(orientation.getDecoratedEnd(endChild) + - orientation.getDecoratedStart(startChild)); + final int itemRange = Math.abs(lm.getPosition(startChild) + - lm.getPosition(endChild)) + 1; + final float avgSizePerRow = (float) laidOutArea / itemRange; + + return Math.round(itemsBefore * avgSizePerRow + (orientation.getStartAfterPadding() + - orientation.getDecoratedStart(startChild))); + } + + /** + * @param startChild View closest to start of the list. (top or left) + * @param endChild View closest to end of the list (bottom or right) + */ + static int computeScrollExtent(RecyclerView.State state, OrientationHelper orientation, + View startChild, View endChild, RecyclerView.LayoutManager lm, + boolean smoothScrollbarEnabled) { + if (lm.getChildCount() == 0 || state.getItemCount() == 0 || startChild == null + || endChild == null) { + return 0; + } + if (!smoothScrollbarEnabled) { + return Math.abs(lm.getPosition(startChild) - lm.getPosition(endChild)) + 1; + } + final int extend = orientation.getDecoratedEnd(endChild) + - orientation.getDecoratedStart(startChild); + return Math.min(orientation.getTotalSpace(), extend); + } + + /** + * @param startChild View closest to start of the list. (top or left) + * @param endChild View closest to end of the list (bottom or right) + */ + static int computeScrollRange(RecyclerView.State state, OrientationHelper orientation, + View startChild, View endChild, RecyclerView.LayoutManager lm, + boolean smoothScrollbarEnabled) { + if (lm.getChildCount() == 0 || state.getItemCount() == 0 || startChild == null + || endChild == null) { + return 0; + } + if (!smoothScrollbarEnabled) { + return state.getItemCount(); + } + // smooth scrollbar enabled. try to estimate better. + final int laidOutArea = orientation.getDecoratedEnd(endChild) + - orientation.getDecoratedStart(startChild); + final int laidOutRange = Math.abs(lm.getPosition(startChild) + - lm.getPosition(endChild)) + + 1; + // estimate a size for full list. + return (int) ((float) laidOutArea / laidOutRange * state.getItemCount()); + } + + private ScrollbarHelper() { + } +} diff --git a/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ViewBoundsCheck.java b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ViewBoundsCheck.java new file mode 100644 index 0000000000..63bdcf06a9 --- /dev/null +++ b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/ViewBoundsCheck.java @@ -0,0 +1,269 @@ +/* + * Copyright 2018 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 app.k9mail.ui.utils.linearlayoutmanager; + +import android.view.View; + +import androidx.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A utility class used to check the boundaries of a given view within its parent view based on + * a set of boundary flags. + */ +class ViewBoundsCheck { + + static final int GT = 1 << 0; + static final int EQ = 1 << 1; + static final int LT = 1 << 2; + + + static final int CVS_PVS_POS = 0; + /** + * The child view's start should be strictly greater than parent view's start. + */ + static final int FLAG_CVS_GT_PVS = GT << CVS_PVS_POS; + + /** + * The child view's start can be equal to its parent view's start. This flag follows with GT + * or LT indicating greater (less) than or equal relation. + */ + static final int FLAG_CVS_EQ_PVS = EQ << CVS_PVS_POS; + + /** + * The child view's start should be strictly less than parent view's start. + */ + static final int FLAG_CVS_LT_PVS = LT << CVS_PVS_POS; + + + static final int CVS_PVE_POS = 4; + /** + * The child view's start should be strictly greater than parent view's end. + */ + static final int FLAG_CVS_GT_PVE = GT << CVS_PVE_POS; + + /** + * The child view's start can be equal to its parent view's end. This flag follows with GT + * or LT indicating greater (less) than or equal relation. + */ + static final int FLAG_CVS_EQ_PVE = EQ << CVS_PVE_POS; + + /** + * The child view's start should be strictly less than parent view's end. + */ + static final int FLAG_CVS_LT_PVE = LT << CVS_PVE_POS; + + + static final int CVE_PVS_POS = 8; + /** + * The child view's end should be strictly greater than parent view's start. + */ + static final int FLAG_CVE_GT_PVS = GT << CVE_PVS_POS; + + /** + * The child view's end can be equal to its parent view's start. This flag follows with GT + * or LT indicating greater (less) than or equal relation. + */ + static final int FLAG_CVE_EQ_PVS = EQ << CVE_PVS_POS; + + /** + * The child view's end should be strictly less than parent view's start. + */ + static final int FLAG_CVE_LT_PVS = LT << CVE_PVS_POS; + + + static final int CVE_PVE_POS = 12; + /** + * The child view's end should be strictly greater than parent view's end. + */ + static final int FLAG_CVE_GT_PVE = GT << CVE_PVE_POS; + + /** + * The child view's end can be equal to its parent view's end. This flag follows with GT + * or LT indicating greater (less) than or equal relation. + */ + static final int FLAG_CVE_EQ_PVE = EQ << CVE_PVE_POS; + + /** + * The child view's end should be strictly less than parent view's end. + */ + static final int FLAG_CVE_LT_PVE = LT << CVE_PVE_POS; + + static final int MASK = GT | EQ | LT; + + final Callback mCallback; + BoundFlags mBoundFlags; + /** + * The set of flags that can be passed for checking the view boundary conditions. + * CVS in the flag name indicates the child view, and PV indicates the parent view.\ + * The following S, E indicate a view's start and end points, respectively. + * GT and LT indicate a strictly greater and less than relationship. + * Greater than or equal (or less than or equal) can be specified by setting both GT and EQ (or + * LT and EQ) flags. + * For instance, setting both {@link #FLAG_CVS_GT_PVS} and {@link #FLAG_CVS_EQ_PVS} indicate the + * child view's start should be greater than or equal to its parent start. + */ + @IntDef(flag = true, value = { + FLAG_CVS_GT_PVS, FLAG_CVS_EQ_PVS, FLAG_CVS_LT_PVS, + FLAG_CVS_GT_PVE, FLAG_CVS_EQ_PVE, FLAG_CVS_LT_PVE, + FLAG_CVE_GT_PVS, FLAG_CVE_EQ_PVS, FLAG_CVE_LT_PVS, + FLAG_CVE_GT_PVE, FLAG_CVE_EQ_PVE, FLAG_CVE_LT_PVE + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ViewBounds {} + + ViewBoundsCheck(Callback callback) { + mCallback = callback; + mBoundFlags = new BoundFlags(); + } + + static class BoundFlags { + int mBoundFlags = 0; + int mRvStart, mRvEnd, mChildStart, mChildEnd; + + void setBounds(int rvStart, int rvEnd, int childStart, int childEnd) { + mRvStart = rvStart; + mRvEnd = rvEnd; + mChildStart = childStart; + mChildEnd = childEnd; + } + + void addFlags(@ViewBounds int flags) { + mBoundFlags |= flags; + } + + void resetFlags() { + mBoundFlags = 0; + } + + int compare(int x, int y) { + if (x > y) { + return GT; + } + if (x == y) { + return EQ; + } + return LT; + } + + boolean boundsMatch() { + if ((mBoundFlags & (MASK << CVS_PVS_POS)) != 0) { + if ((mBoundFlags & (compare(mChildStart, mRvStart) << CVS_PVS_POS)) == 0) { + return false; + } + } + + if ((mBoundFlags & (MASK << CVS_PVE_POS)) != 0) { + if ((mBoundFlags & (compare(mChildStart, mRvEnd) << CVS_PVE_POS)) == 0) { + return false; + } + } + + if ((mBoundFlags & (MASK << CVE_PVS_POS)) != 0) { + if ((mBoundFlags & (compare(mChildEnd, mRvStart) << CVE_PVS_POS)) == 0) { + return false; + } + } + + if ((mBoundFlags & (MASK << CVE_PVE_POS)) != 0) { + if ((mBoundFlags & (compare(mChildEnd, mRvEnd) << CVE_PVE_POS)) == 0) { + return false; + } + } + return true; + } + }; + + /** + * Returns the first view starting from fromIndex to toIndex in views whose bounds lie within + * its parent bounds based on the provided preferredBoundFlags. If no match is found based on + * the preferred flags, and a nonzero acceptableBoundFlags is specified, the last view whose + * bounds lie within its parent view based on the acceptableBoundFlags is returned. If no such + * view is found based on either of these two flags, null is returned. + * @param fromIndex The view position index to start the search from. + * @param toIndex The view position index to end the search at. + * @param preferredBoundFlags The flags indicating the preferred match. Once a match is found + * based on this flag, that view is returned instantly. + * @param acceptableBoundFlags The flags indicating the acceptable match if no preferred match + * is found. If so, and if acceptableBoundFlags is non-zero, the + * last matching acceptable view is returned. Otherwise, null is + * returned. + * @return The first view that satisfies acceptableBoundFlags or the last view satisfying + * acceptableBoundFlags boundary conditions. + */ + View findOneViewWithinBoundFlags(int fromIndex, int toIndex, + @ViewBounds int preferredBoundFlags, + @ViewBounds int acceptableBoundFlags) { + final int start = mCallback.getParentStart(); + final int end = mCallback.getParentEnd(); + final int next = toIndex > fromIndex ? 1 : -1; + View acceptableMatch = null; + for (int i = fromIndex; i != toIndex; i += next) { + final View child = mCallback.getChildAt(i); + final int childStart = mCallback.getChildStart(child); + final int childEnd = mCallback.getChildEnd(child); + mBoundFlags.setBounds(start, end, childStart, childEnd); + if (preferredBoundFlags != 0) { + mBoundFlags.resetFlags(); + mBoundFlags.addFlags(preferredBoundFlags); + if (mBoundFlags.boundsMatch()) { + // found a perfect match + return child; + } + } + if (acceptableBoundFlags != 0) { + mBoundFlags.resetFlags(); + mBoundFlags.addFlags(acceptableBoundFlags); + if (mBoundFlags.boundsMatch()) { + acceptableMatch = child; + } + } + } + return acceptableMatch; + } + + /** + * Returns whether the specified view lies within the boundary condition of its parent view. + * @param child The child view to be checked. + * @param boundsFlags The flag against which the child view and parent view are matched. + * @return True if the view meets the boundsFlag, false otherwise. + */ + boolean isViewWithinBoundFlags(View child, @ViewBounds int boundsFlags) { + mBoundFlags.setBounds(mCallback.getParentStart(), mCallback.getParentEnd(), + mCallback.getChildStart(child), mCallback.getChildEnd(child)); + if (boundsFlags != 0) { + mBoundFlags.resetFlags(); + mBoundFlags.addFlags(boundsFlags); + return mBoundFlags.boundsMatch(); + } + return false; + } + + /** + * Callback provided by the user of this class in order to retrieve information about child and + * parent boundaries. + */ + interface Callback { + View getChildAt(int index); + int getParentStart(); + int getParentEnd(); + int getChildStart(View view); + int getChildEnd(View view); + } +} -- GitLab From e9bd127e45dfc7fae5b4d43a77ee1226b1aa1593 Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 11 Oct 2022 14:32:39 +0200 Subject: [PATCH 040/121] Change how `LinearLayoutManager` decides how to anchor the list If the list is scrolled to the top and items are inserted before the previously first list item, `RecyclerView` is "scrolled" to to show the new first item at the top. --- .../k9/ui/messagelist/MessageListAdapter.kt | 14 ++--- .../LinearLayoutManager.java | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt index afd7661ef7..49988afb8d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt @@ -70,16 +70,10 @@ class MessageListAdapter internal constructor( selected = selected.intersect(uniqueIds) } - if (oldMessageList.isEmpty()) { - // While loading, only the footer view is showing. If we used DiffUtil, the footer view would be used as - // anchor element and the updated list would be scrolled all the way down. - notifyDataSetChanged() - } else { - val diffResult = DiffUtil.calculateDiff( - MessageListDiffCallback(oldMessageList = oldMessageList, newMessageList = value) - ) - diffResult.dispatchUpdatesTo(this) - } + val diffResult = DiffUtil.calculateDiff( + MessageListDiffCallback(oldMessageList = oldMessageList, newMessageList = value) + ) + diffResult.dispatchUpdatesTo(this) } private var messagesMap = emptyMap() diff --git a/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LinearLayoutManager.java b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LinearLayoutManager.java index c85e809f5e..4e812428e1 100644 --- a/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LinearLayoutManager.java +++ b/ui-utils/LinearLayoutManager/src/main/java/app/k9mail/ui/utils/linearlayoutmanager/LinearLayoutManager.java @@ -152,6 +152,8 @@ public class LinearLayoutManager extends LayoutManager implements // time. private int[] mReusableIntPair = new int[2]; + private boolean mScrolledToTop = false; + /** * Creates a vertical LinearLayoutManager * @@ -732,6 +734,39 @@ public class LinearLayoutManager extends LayoutManager implements mPendingScrollPosition = RecyclerView.NO_POSITION; mPendingScrollPositionOffset = INVALID_OFFSET; mAnchorInfo.reset(); + + updateScrolledToTop(); + } + + private void updateScrolledToTop() { + boolean oldScrolledToTop = mScrolledToTop; + mScrolledToTop = isScrolledToTop(); + + if (DEBUG && mScrolledToTop != oldScrolledToTop) { + Log.d(TAG, "Scrolled to top: " + mScrolledToTop); + } + } + + private boolean isScrolledToTop() { + if (getChildCount() == 0) { + return true; + } + + View firstChild = getChildAt(0); + if (firstChild == null) { + // This probably doesn't happen when getChildCount() != 0. But we really don't want to crash here. + return false; + } + + int position = getPosition(firstChild); + if (position != 0) { + return false; + } + + int recyclerViewStart = mOrientationHelper.getStartAfterPadding(); + int firstVisibleChildStart = mOrientationHelper.getDecoratedStart(firstChild); + + return recyclerViewStart == firstVisibleChildStart; } /** @@ -848,6 +883,11 @@ public class LinearLayoutManager extends LayoutManager implements if (mLastStackFromEnd != mStackFromEnd) { return false; } + + if (updateAnchorFromScrollPosition(state, anchorInfo)) { + return true; + } + View referenceChild = findReferenceChild( recycler, @@ -877,6 +917,18 @@ public class LinearLayoutManager extends LayoutManager implements return false; } + private boolean updateAnchorFromScrollPosition(RecyclerView.State state, AnchorInfo anchorInfo) { + if (state.isPreLayout() || !mScrolledToTop) { + return false; + } + + anchorInfo.mLayoutFromEnd = false; + anchorInfo.mPosition = 0; + anchorInfo.mValid = true; + anchorInfo.assignCoordinateFromPadding(); + return true; + } + /** * If there is a pending scroll position or saved states, updates the anchor info from that * data and returns true @@ -1399,6 +1451,7 @@ public class LinearLayoutManager extends LayoutManager implements if (DEBUG) { Log.d(TAG, "Don't have any more elements to scroll"); } + updateScrolledToTop(); return 0; } final int scrolled = absDelta > consumed ? layoutDirection * consumed : delta; @@ -1407,6 +1460,8 @@ public class LinearLayoutManager extends LayoutManager implements Log.d(TAG, "scroll req: " + delta + " scrolled: " + scrolled); } mLayoutState.mLastScrollDelta = scrolled; + + updateScrolledToTop(); return scrolled; } -- GitLab From 511af40dc38d093e4111ca6e2004e5ed07ea568b Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 11 Oct 2022 21:04:43 +0200 Subject: [PATCH 041/121] Use proper API to set SNI server name on API 24+ --- .../k9/helper/DefaultTrustedSocketFactory.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/core/src/main/java/com/fsck/k9/helper/DefaultTrustedSocketFactory.java b/app/core/src/main/java/com/fsck/k9/helper/DefaultTrustedSocketFactory.java index ce046daa9e..7ab0b258c9 100644 --- a/app/core/src/main/java/com/fsck/k9/helper/DefaultTrustedSocketFactory.java +++ b/app/core/src/main/java/com/fsck/k9/helper/DefaultTrustedSocketFactory.java @@ -11,26 +11,23 @@ import java.util.List; import android.content.Context; import android.net.SSLCertificateSocketFactory; +import android.os.Build; import android.text.TextUtils; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.ssl.TrustManagerFactory; import com.fsck.k9.mail.ssl.TrustedSocketFactory; import javax.net.ssl.KeyManager; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import timber.log.Timber; -/** - * Prior to API 21 (and notably from API 10 - 2.3.4) Android weakened it's cipher list - * by ordering them badly such that RC4-MD5 was preferred. To work around this we - * remove the insecure ciphers and reorder them so the latest more secure ciphers are at the top. - * - * On more modern versions of Android we keep the system configuration. - */ public class DefaultTrustedSocketFactory implements TrustedSocketFactory { private static final String[] ENABLED_CIPHERS; private static final String[] ENABLED_PROTOCOLS; @@ -150,6 +147,11 @@ public class DefaultTrustedSocketFactory implements TrustedSocketFactory { if (factory instanceof android.net.SSLCertificateSocketFactory) { SSLCertificateSocketFactory sslCertificateSocketFactory = (SSLCertificateSocketFactory) factory; sslCertificateSocketFactory.setHostname(socket, hostname); + } else if (Build.VERSION.SDK_INT >= 24) { + SSLParameters sslParameters = socket.getSSLParameters(); + List sniServerNames = Collections.singletonList(new SNIHostName(hostname)); + sslParameters.setServerNames(sniServerNames); + socket.setSSLParameters(sslParameters); } else { setHostnameViaReflection(socket, hostname); } -- GitLab From a833cd5b08d4f38a02d5e5a785f4e9d51b5475b1 Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 12 Oct 2022 14:14:37 +0200 Subject: [PATCH 042/121] Stop using `white-space: pre-wrap` for the HTML signature Line breaks display inconsistently across browser implementations when using both `
` and the CSS rule. --- .../src/main/java/com/fsck/k9/message/TextBodyBuilder.java | 3 +-- .../src/test/java/com/fsck/k9/message/TextBodyBuilderTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/core/src/main/java/com/fsck/k9/message/TextBodyBuilder.java b/app/core/src/main/java/com/fsck/k9/message/TextBodyBuilder.java index eb2235cb1b..5a03104c48 100644 --- a/app/core/src/main/java/com/fsck/k9/message/TextBodyBuilder.java +++ b/app/core/src/main/java/com/fsck/k9/message/TextBodyBuilder.java @@ -188,8 +188,7 @@ class TextBodyBuilder { private String getSignatureHtml() { String signature = ""; if (!isEmpty(mSignature)) { - signature = "

" + - HtmlConverter.textToHtmlFragmentWithOriginalWhitespace(mSignature) + "
"; + signature = HtmlConverter.textToHtmlFragment(mSignature); } return signature; } diff --git a/app/core/src/test/java/com/fsck/k9/message/TextBodyBuilderTest.kt b/app/core/src/test/java/com/fsck/k9/message/TextBodyBuilderTest.kt index e29ac44ce0..67eafbbef7 100644 --- a/app/core/src/test/java/com/fsck/k9/message/TextBodyBuilderTest.kt +++ b/app/core/src/test/java/com/fsck/k9/message/TextBodyBuilderTest.kt @@ -18,8 +18,8 @@ class TextBodyBuilderTest(val testData: TestData) { private const val QUOTED_HTML_TAGS_END = "\n" private const val QUOTED_HTML_TAGS_START = "" private const val SIGNATURE_TEXT = "-- \r\n\r\nsignature\r\n indented second line" - private const val SIGNATURE_TEXT_HTML = "
" + - "
--

signature
indented second line
" + private const val SIGNATURE_TEXT_HTML = + "
--

signature
\u00A0 indented second line
" @JvmStatic @Parameterized.Parameters(name = "{index}: {0}") -- GitLab From de27506761f284815a759de0b4e63104c8d57a94 Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 12 Oct 2022 14:57:13 +0200 Subject: [PATCH 043/121] Update translations --- .../legacy/src/main/res/values-ar/strings.xml | 16 +++++ .../legacy/src/main/res/values-be/strings.xml | 16 +++++ .../legacy/src/main/res/values-bg/strings.xml | 16 +++++ .../legacy/src/main/res/values-br/strings.xml | 16 +++++ .../legacy/src/main/res/values-ca/strings.xml | 16 +++++ .../legacy/src/main/res/values-cs/strings.xml | 16 +++++ .../legacy/src/main/res/values-cy/strings.xml | 16 +++++ .../legacy/src/main/res/values-da/strings.xml | 16 +++++ .../legacy/src/main/res/values-de/strings.xml | 28 +++++++- .../legacy/src/main/res/values-el/strings.xml | 16 +++++ .../src/main/res/values-en-rGB/strings.xml | 11 +++ .../legacy/src/main/res/values-eo/strings.xml | 16 +++++ .../legacy/src/main/res/values-es/strings.xml | 22 ++++++ .../legacy/src/main/res/values-et/strings.xml | 16 +++++ .../legacy/src/main/res/values-eu/strings.xml | 16 +++++ .../legacy/src/main/res/values-fa/strings.xml | 16 +++++ .../legacy/src/main/res/values-fi/strings.xml | 22 ++++++ .../legacy/src/main/res/values-fr/strings.xml | 17 +++++ .../legacy/src/main/res/values-fy/strings.xml | 22 ++++++ .../legacy/src/main/res/values-gd/strings.xml | 16 +++++ .../src/main/res/values-gl-rES/strings.xml | 16 +++++ .../legacy/src/main/res/values-gl/strings.xml | 16 +++++ .../legacy/src/main/res/values-hr/strings.xml | 16 +++++ .../legacy/src/main/res/values-hu/strings.xml | 16 +++++ .../legacy/src/main/res/values-in/strings.xml | 16 +++++ .../legacy/src/main/res/values-is/strings.xml | 16 +++++ .../legacy/src/main/res/values-it/strings.xml | 16 +++++ .../legacy/src/main/res/values-iw/strings.xml | 16 +++++ .../legacy/src/main/res/values-ja/strings.xml | 22 ++++++ .../legacy/src/main/res/values-ko/strings.xml | 16 +++++ .../legacy/src/main/res/values-lt/strings.xml | 16 +++++ .../legacy/src/main/res/values-lv/strings.xml | 16 +++++ .../legacy/src/main/res/values-ml/strings.xml | 16 +++++ .../legacy/src/main/res/values-nb/strings.xml | 16 +++++ .../legacy/src/main/res/values-nl/strings.xml | 22 ++++++ .../legacy/src/main/res/values-pl/strings.xml | 30 ++++++-- .../src/main/res/values-pt-rBR/strings.xml | 16 +++++ .../src/main/res/values-pt-rPT/strings.xml | 16 +++++ .../legacy/src/main/res/values-ro/strings.xml | 16 +++++ .../legacy/src/main/res/values-ru/strings.xml | 71 +++++++++++++------ .../legacy/src/main/res/values-sk/strings.xml | 16 +++++ .../legacy/src/main/res/values-sl/strings.xml | 16 +++++ .../legacy/src/main/res/values-sq/strings.xml | 21 ++++++ .../legacy/src/main/res/values-sr/strings.xml | 16 +++++ .../legacy/src/main/res/values-sv/strings.xml | 22 ++++++ .../legacy/src/main/res/values-tr/strings.xml | 16 +++++ .../legacy/src/main/res/values-uk/strings.xml | 16 +++++ .../src/main/res/values-zh-rCN/strings.xml | 22 ++++++ .../src/main/res/values-zh-rTW/strings.xml | 16 +++++ 49 files changed, 879 insertions(+), 29 deletions(-) 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 a417f3f7ea..bb389a9d9f 100644 --- a/app/ui/legacy/src/main/res/values-ar/strings.xml +++ b/app/ui/legacy/src/main/res/values-ar/strings.xml @@ -238,6 +238,22 @@ إلغاء الرسالة جعل كل الرسائل مقروءة احذف (من الإشعارات) + + + + + بدون + + + + + أرشيف + + احذف + + بريد مُزعج + + حَرِّكْ إخفاء عميل البريد احذف مُعرف بريد كية-9 من ترويسات الرسالة احذف التوقيت 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 5c0a66ea54..77a3822f12 100644 --- a/app/ui/legacy/src/main/res/values-be/strings.xml +++ b/app/ui/legacy/src/main/res/values-be/strings.xml @@ -243,6 +243,22 @@ K-9 Mail - шматфункцыянальны свабодны паштовы к Скасаваць ліст Пазначыць усе лісты прачытанымі Выдаліць (у апавяшчэнні) + + + + + Няма + + + + + Архіў + + Выдаліць + + Спам + + Перамясціць Схаваць паштовы кліент Прыбраць \"K-9 User-Agent\" з загалоўкаў лістоў Схаваць часавы пояс 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 dd1d1a9704..219b22998d 100644 --- a/app/ui/legacy/src/main/res/values-bg/strings.xml +++ b/app/ui/legacy/src/main/res/values-bg/strings.xml @@ -249,6 +249,22 @@ K-9 Mail е мощен, безплатен имейл клиент за Андр Изтрийте съобщението Отбелязване на всички съобщения като прочетени Изтрийте (от лентата с известия) + + + + + Никой + + + + + Архивирайте + + Изтрий + + Спам + + Премести Скриване на имейл програмата Премахване на K-9 хедъра за потребителски агент Скриване на времевата зона 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 f773015ab4..8022c5a13e 100644 --- a/app/ui/legacy/src/main/res/values-br/strings.xml +++ b/app/ui/legacy/src/main/res/values-br/strings.xml @@ -230,6 +230,22 @@ Danevellit beugoù, kenlabourit war keweriusterioù nevez ha savit goulennoù wa Dilezel ar gemennadenn Merkañ an holl gemennadennoù evel lennet Dilemel (er rebuziñ) + + + + + Netra + + + + + Dielloù + + Dilemel + + Lastez + + Dilec’hiañ Kuzhat an arval postel Dilemel User-agent K-9 eus talbennoù ar postel Kuzhat ar gwerzhid-eur 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 57e42b683b..574f6af14d 100644 --- a/app/ui/legacy/src/main/res/values-ca/strings.xml +++ b/app/ui/legacy/src/main/res/values-ca/strings.xml @@ -260,6 +260,22 @@ Si us plau, envieu informes d\'errors, contribuïu-hi amb noves millores i feu p Descarta el missatge Marca tots els missatges com a llegits Elimina (des de notificacions) + + + + + Cap + + + + + Arxiva + + Elimina + + Correu brossa + + Mou Amaga el client de correu Suprimeix el nom K-9 User-Agent de les capçaleres del missatge Amaga el fus horari 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 eef6898e58..25a9e41ee2 100644 --- a/app/ui/legacy/src/main/res/values-cs/strings.xml +++ b/app/ui/legacy/src/main/res/values-cs/strings.xml @@ -264,6 +264,22 @@ Hlášení o chyb, úpravy pro nové funkce a dotazy zadávejte prostřednictví Zahodit zprávu Označit všechny zprávy jako přečtené Smazat (z oznámení) + + + + + Žádné + + + + + Archivovat + + Smazat + + Nevyžádaná + + Přesunout Skrýt e-mailového klienta Odstraňovat z hlaviček e-mailů informaci o K-9 (user agent) Skrýt časovou zónu 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 72ed72e5d4..99d35634c8 100644 --- a/app/ui/legacy/src/main/res/values-cy/strings.xml +++ b/app/ui/legacy/src/main/res/values-cy/strings.xml @@ -265,6 +265,22 @@ Plîs rho wybod am unrhyw wallau, syniadau am nodweddion newydd, neu ofyn cwesti Dileu neges Nodi pob neges fel ei wedi ei darllen Dileu (o hysbysiad) + + + + + Dim + + + + + Archif + + Dileu + + Sbam + + Symud Cuddio cleient e-bost Cuddio\'r Cleient K-9 o benynnau negeseuon Cuddio cylchfa amser 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 07d5139e75..c84e1c67b3 100644 --- a/app/ui/legacy/src/main/res/values-da/strings.xml +++ b/app/ui/legacy/src/main/res/values-da/strings.xml @@ -253,6 +253,22 @@ Rapporter venligst fejl, forslag til nye funktioner eller stil spørgsmål på: Kassér meddelelse Marker alle meddelelser som læst Slet (fra beskedbjælke) + + + + + Ingen + + + + + Arkivere + + Slet + + Spam + + Flyt Skjul mail klient Fjern K-9 Bruger-Agent fra mail headers Skjul tidszone diff --git a/app/ui/legacy/src/main/res/values-de/strings.xml b/app/ui/legacy/src/main/res/values-de/strings.xml index 57145b99c2..a07702e919 100644 --- a/app/ui/legacy/src/main/res/values-de/strings.xml +++ b/app/ui/legacy/src/main/res/values-de/strings.xml @@ -115,7 +115,7 @@ Bitte senden Sie Fehlerberichte, Ideen für neue Funktionen und stellen Sie Frag Teilen Absender auswählen Als wichtig markieren - Wichtig-Markierung entfernen + Als unwichtig markieren Kopieren Abmelden Kopfzeilen anzeigen @@ -254,11 +254,33 @@ Bitte senden Sie Fehlerberichte, Ideen für neue Funktionen und stellen Sie Frag Bestätigungsdialog Bei Ausführung der ausgewählten Aktionen einen Bestätigungsdialog anzeigen Löschen - Sternmarkierte Löschen (nur in Nachrichtenansicht) + Als wichtig markiert löschen (nur in Nachrichtenansicht) Spam Nachricht verwerfen Alle Nachrichten als gelesen markieren Löschen (aus Benachrichtigung) + + Wischaktionen + + Nach rechts wischen + + Nach links wischen + + Keine + + Auswahl wechseln + + Als gelesen/ungelesen markieren + + Wichtig/unwichtig markieren + + Archiv + + Löschen + + Spam + + Verschieben E-Mail-Client ausblenden K-9 Benutzer-Agent aus E-Mail-Kopfzeilen entfernen Zeitzone ausblenden @@ -682,7 +704,7 @@ Bitte senden Sie Fehlerberichte, Ideen für neue Funktionen und stellen Sie Frag Aufwendige visuelle Effekte benutzen Navigation per Lautstärketasten in der Nachrichtenansicht Gemeinsamen Posteingang anzeigen - Anzahl der Sterne anzeigen + Anzahl der wichtigen Nachrichten anzeigen Gemeinsamer Posteingang Alle Nachrichten aus integrierten Ordnern In gem. Posteingang integrieren 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 62f45fc37b..76d766e02b 100644 --- a/app/ui/legacy/src/main/res/values-el/strings.xml +++ b/app/ui/legacy/src/main/res/values-el/strings.xml @@ -261,6 +261,22 @@ Απόρριψη μηνύματος Μαρκάρισμα όλων των μηνυμάτων ως αναγνωσμένων Διαγραφή (από ειδοποίηση) + + + + + Κανένας + + + + + Αρχειοθέτηση + + Διαγραφή + + Ανεπιθύμητα + + Μετακίνηση Απόκρυψη προγράμματος-πελάτη αλληλογραφίας Αφαίρεση του Πράκτορα-χρήστη K-9 από την επικεφαλίδα του μηνύματος Απόκρυψη ζώνης ώρας diff --git a/app/ui/legacy/src/main/res/values-en-rGB/strings.xml b/app/ui/legacy/src/main/res/values-en-rGB/strings.xml index 359089c3f8..845db6a311 100644 --- a/app/ui/legacy/src/main/res/values-en-rGB/strings.xml +++ b/app/ui/legacy/src/main/res/values-en-rGB/strings.xml @@ -17,6 +17,17 @@ Colourise contacts Colourise names in your contact list + + + + + + + + + + + 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 df5f7c0417..9d0d8f41b8 100644 --- a/app/ui/legacy/src/main/res/values-eo/strings.xml +++ b/app/ui/legacy/src/main/res/values-eo/strings.xml @@ -247,6 +247,22 @@ Bonvolu raporti erarojn, kontribui novajn eblojn kaj peti pri novaj funkcioj per Ignori mesaĝon Marki ĉiujn kiel legitajn Forigi (el sciigo) + + + + + Nenia + + + + + Arĥivo + + Forigi + + Trudmesaĝo + + Movi Kaŝi retpoŝtilan nomon Forviŝi nomon de klienta aplikaĵo K-9 el mesaĝokapo Kaŝi horzonon 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 423d4dae0f..ad04e3e239 100644 --- a/app/ui/legacy/src/main/res/values-es/strings.xml +++ b/app/ui/legacy/src/main/res/values-es/strings.xml @@ -260,6 +260,28 @@ Puedes informar de fallos, contribuir con su desarrollo y hacer preguntas en Descartar mensaje
Marcar todos los mensajes como leídos Borrar (de notificaciones) + + Acciones al deslizar el dedo + + Al deslizar a la derecha + + Al deslizar a la izquierda + + Ninguno + + De/seleccionar + + Marcar como leído/sin leer + + Marcar como destacado o no + + Archivo + + Borrar + + Spam + + Mover Ocultar cliente de correo Eliminar la marca con el «agente de usuario» de K-9 de las cabeceras del mensaje, el destinatario no verá qué programa de correo usas Ocultar zona horaria 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 0e4d0e9739..ceb971d769 100644 --- a/app/ui/legacy/src/main/res/values-et/strings.xml +++ b/app/ui/legacy/src/main/res/values-et/strings.xml @@ -261,6 +261,22 @@ Veateated saad saata, kaastööd teha ning küsida teavet järgmisel lehel: Loobu kirjast Märgi kõik kirjad loetuks Kustuta (teadetest) + + + + + Mitte ükski + + + + + Arhiveeri + + Kustuta + + Rämps + + Teisalda Peida meiliklient Eemalda K-9 User-Agent meili päistest Peida ajavöönd 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 4d88f2edf1..13c7d3c366 100644 --- a/app/ui/legacy/src/main/res/values-eu/strings.xml +++ b/app/ui/legacy/src/main/res/values-eu/strings.xml @@ -259,6 +259,22 @@ Mesedez akatsen berri emateko, ezaugarri berriak gehitzeko eta galderak egiteko Baztertu mezua Markatu mezu guztiak irakurritako gisa Ezabatu (jakinarazpenetatik) + + + + + Bat ere ez + + + + + Artxibatu + + Ezabatu + + Zabor-posta + + Mugitu Ezkutatu posta bezeroa Kendu K-9 erabiltzaile-agentea posta goiburuetatik Ezkutatu ordu-zona 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 dd9fd4db3c..8f26a563ef 100644 --- a/app/ui/legacy/src/main/res/values-fa/strings.xml +++ b/app/ui/legacy/src/main/res/values-fa/strings.xml @@ -260,6 +260,22 @@ دورانداختن پیام همهٔ پیام‌ها خوانده شد حذف (از اعلان) + + + + + هیچ‌کدام + + + + + بایگانی + + حذف + + هرزنامه + + انتقال کارخواه رایانامه را مخفی کن شناسهٔ K-9 User-Agent را از سرایند رایانامه‌ها بردار منطقهٔ زمانی را مخفی کن 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 a92ca9b4a1..ce9c3d94c6 100644 --- a/app/ui/legacy/src/main/res/values-fi/strings.xml +++ b/app/ui/legacy/src/main/res/values-fi/strings.xml @@ -259,6 +259,28 @@ Ilmoita virheistä, ota osaa sovelluskehitykseen ja esitä kysymyksiä osoittees Hylkää viesti Merkitse kaikki viestit luetuiksi Poista (ilmoituksessa) + + Vetotoiminnot + + Veto oikealle + + Veto vasemmalle + + Ei mitään + + Valitse viesti tai poista valinta viestin kohdalta + + Merkitse luetuksi/lukemattomaksi + + Lisää tai poista tähti + + Arkistoi + + Poista + + Roskaposti + + Siirrä Piilota sähköpostisovellus Poista K-9-tunnistetiedot viestin otsaketiedoista Piilota aikavyöhyke 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 098d3fd998..afd30f4d18 100644 --- a/app/ui/legacy/src/main/res/values-fr/strings.xml +++ b/app/ui/legacy/src/main/res/values-fr/strings.xml @@ -263,6 +263,23 @@ Rapportez les bogues, recommandez de nouvelles fonctions et posez vos questions Abandonner le courriel Marquer tous les courriels comme lus Supprimer (d’une notification) + + + + + + + Marquer comme lu/non lu + + Ajouter/supprimer l\'étoile + + Archiver + + Supprimer + + Pourriel + + Déplacer Cacher le client de courriel Supprimer l’agent utilisateur K-9 des en-têtes de courriels Cacher le fuseau horaire 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 2d289e093c..22f429d9fd 100644 --- a/app/ui/legacy/src/main/res/values-fy/strings.xml +++ b/app/ui/legacy/src/main/res/values-fy/strings.xml @@ -255,6 +255,28 @@ Graach flaterrapporten stjoere, bydragen foar nije funksjes en fragen stelle op Berjocht ôfbrekke Alles as lêzen markearje Fuortsmite (fan meldingen) + + Fei-aksjes + + Nei rjochts feie + + Nei links feie + + Gjin + + Seleksje wikselje + + As lêzen/net-lêzen markearje + + Stjer tafoegje/fuortsmite + + Argivearje + + Fuortsmite + + Net-winske + + Ferpleatse E-mailclient ferstopje K-9-brûkersagent fan e-mailkopteksten fuortsmite Tiidsône ferstopje 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 b0987f1a77..3eeabe1e42 100644 --- a/app/ui/legacy/src/main/res/values-gd/strings.xml +++ b/app/ui/legacy/src/main/res/values-gd/strings.xml @@ -215,6 +215,22 @@ Tilg an teachdaireachd air falbh Comharraich gun deach gach teachdaireachd a leughadh Sguab às (on bhrath) + + + + + Chan eil gin + + + + + Tasg-lann + + Sguab às + + Spama + + Gluais Falaich cliant a’ phuist Thoir air falbh an raon Mail User-Agent o bhannan-cinn phost Falaich an roinn-tìde 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 8ea246da6d..e700ecc478 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 @@ -161,6 +161,22 @@ Lixo Desbotar mensaxe Eliminar (das notificacións) + + + + + Ningún + + + + + Arquivar + + Eliminar + + Lixo + + Mover Quitar o User-Agent K-9 das cabeceiras do correo Mostrar botón \'Eliminar\' Nunca 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 208f5e6312..8a5d22d098 100644 --- a/app/ui/legacy/src/main/res/values-gl/strings.xml +++ b/app/ui/legacy/src/main/res/values-gl/strings.xml @@ -253,6 +253,22 @@ Por favor envíen informes de fallos, contribúa con novas características e co Descartar mensaxe Marcar todas as mensaxes como lidas Borrar (da notificación) + + + + + ningunha + + + + + Archivo + + Eliminar + + Spam + + Mover Ocultar cliente de correo Quitar K-9 User-Agent dos cabezallos do correo Ocultar zona horaria 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 62abc968b5..8ca5662ace 100644 --- a/app/ui/legacy/src/main/res/values-hr/strings.xml +++ b/app/ui/legacy/src/main/res/values-hr/strings.xml @@ -203,6 +203,22 @@ Odbaci poruku Označi sve poruke kao pročitane Obriši (iz obavijesti) + + + + + Nijedna + + + + + Arhiva + + Obriši + + Neželjena pošta + + Premjesti Sakrij klijent e-pošte Makni K-9 Korisničkog agenta iz zaglavlja pošte Sakrij vremensku zonu 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 6fbee38037..c36fe75842 100644 --- a/app/ui/legacy/src/main/res/values-hu/strings.xml +++ b/app/ui/legacy/src/main/res/values-hu/strings.xml @@ -258,6 +258,22 @@ Hibajelentések beküldésével közreműködhet az új funkciókban, és kérd Üzenet elvetése Összes üzenet megjelölése olvasottként Törlés (az értesítésekből) + + + + + Nincs + + + + + Archívum + + Törlés + + Levélszemét + + Áthelyezés Levelező alkalmazás elrejtése A K-9 azonosító eltávolítása a levél fejlécéből Időzóna elrejtése 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 9d78730a1b..95e8a7d34f 100644 --- a/app/ui/legacy/src/main/res/values-in/strings.xml +++ b/app/ui/legacy/src/main/res/values-in/strings.xml @@ -238,6 +238,22 @@ Kirimkan laporan bug, kontribusikan fitur baru dan ajukan pertanyaan di Buang pesan Tandai semua pesan sebagai telah dibaca Hapus (dari notifikasi) + + + + + Nihil + + + + + Arsipkan + + Hapus + + Spam + + Pindah Sembunyikan mail client Buang User-Agent K-9 dari tajuk surel Sembunyikan zona waktu 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 0f82d64d1e..7bb957726c 100644 --- a/app/ui/legacy/src/main/res/values-is/strings.xml +++ b/app/ui/legacy/src/main/res/values-is/strings.xml @@ -260,6 +260,22 @@ Sendu inn villuskýrslur, leggðu fram nýja eiginleika og spurðu spurninga á Henda skilaboðum Merkja öll skilaboð sem lesin Eyða (úr tilkynningu) + + + + + Engin + + + + + Geymsla + + Fjarlægja + + Ruslpóstur + + Færa Fela póstforrit Fjarlægja kennistreng K-9 úr pósthausum Fela tímabelti 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 4b3f4d3341..17b79c0ffb 100644 --- a/app/ui/legacy/src/main/res/values-it/strings.xml +++ b/app/ui/legacy/src/main/res/values-it/strings.xml @@ -263,6 +263,22 @@ Invia segnalazioni di bug, contribuisci con nuove funzionalità e poni domande s Scarta messaggio Marca tutti i messaggi come letti Elimina (da notifica) + + + + + Nessuna + + + + + Archivia + + Elimina + + Spam + + Sposta Nascondi User-Agent Rimuove lo User-Agent di K-9 dalle intestazioni dei messaggi Nascondi fuso orario 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 cdd01608f8..3545b28a19 100644 --- a/app/ui/legacy/src/main/res/values-iw/strings.xml +++ b/app/ui/legacy/src/main/res/values-iw/strings.xml @@ -212,6 +212,22 @@ דואר זבל מחק הודעה סמן את כל ההודעות כ\"נקראו\" + + + + + כלום + + + + + העבר לארכיון + + מחק + + דואר זבל + + העבר הסתר איזור זמן הצג את הכפתור \'מחק\' אף פעם 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 d2d8e02b17..49a2432254 100644 --- a/app/ui/legacy/src/main/res/values-ja/strings.xml +++ b/app/ui/legacy/src/main/res/values-ja/strings.xml @@ -258,6 +258,28 @@ K-9 は大多数のメールクライアントと同様に、ほとんどのフ メッセージ破棄 すべてのメールを既読にする 削除(通知領域) + + スワイプ操作 + + 右へスワイプ + + 左へスワイプ + + なし + + 選択の切り替え + + 既読/未読にする + + スターを付ける/外す + + アーカイブ + + 削除 + + 迷惑メール + + 移動 メールクライアントを隠す メールヘッダからK-9のUser-Agentヘッダを削除する タイムゾーンを隠す 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 f6a31f3d05..70ea6e96ad 100644 --- a/app/ui/legacy/src/main/res/values-ko/strings.xml +++ b/app/ui/legacy/src/main/res/values-ko/strings.xml @@ -194,6 +194,22 @@ 메시지 버림 모든 메시지를 읽은 상태로 표시 삭제 (알림에서) + + + + + 없음 + + + + + 보관 + + 삭제 + + 스팸 + + 이동 메일 클라이언트를 숨김 메일 헤더에서 K-9 User Agent 제거 시간 구역을 숨기기 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 2a987a45b0..c9c96a8b46 100644 --- a/app/ui/legacy/src/main/res/values-lt/strings.xml +++ b/app/ui/legacy/src/main/res/values-lt/strings.xml @@ -262,6 +262,22 @@ Pateikite pranešimus apie klaidas, prisidėkite prie naujų funkcijų kūrimo i Išmesti laišką Pažymėti visus laiškus kaip perskaitytus Pašalinti + + + + + Jokios + + + + + Archyvuoti + + Šalinti + + Brukalas + + Perkelti Slėpti pašto klientą Pašalinti K-9 User-Agent iš pašto antraščių Slėpti laiko juostą 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 93f3c09908..6173ce1748 100644 --- a/app/ui/legacy/src/main/res/values-lv/strings.xml +++ b/app/ui/legacy/src/main/res/values-lv/strings.xml @@ -254,6 +254,22 @@ pat %d vairāk Izmest vēstuli Atzīmēt visas vēstules kā izlasītas Dzēst (no paziņojuma) + + + + + Neviens + + + + + Arhīvs + + Dzēst + + Surogātpasts + + Pārvietot Paslēpt pasta programmu Noņemt K-9 lietotāju-aģentu no vēstules papildinformācijas Paslēpt laika zonu 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 d515059b7e..e151be90a8 100644 --- a/app/ui/legacy/src/main/res/values-ml/strings.xml +++ b/app/ui/legacy/src/main/res/values-ml/strings.xml @@ -254,6 +254,22 @@ സന്ദേശം നിരസിക്കുക എല്ലാ സന്ദേശങ്ങളും വായിച്ചതായി അടയാളപ്പെടുത്തുക ഇല്ലാതാക്കുക (അറിയിപ്പിൽ നിന്ന്) + + + + + ഒന്നുമില്ല + + + + + ശേഖരം + + ഇല്ലാതാക്കുക + + സ്പാം + + നീക്കുക മെയിൽ ക്ലയന്റ് മറയ്‌ക്കുക മെയിൽ തലക്കെട്ടുകളിൽ നിന്ന് കെ-9 യൂസർ-ഏജന്റിനെ നീക്കം ചെയ്യുക സമയ മേഖല മറയ്ക്കൂ 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 1c62463788..c6e353124e 100644 --- a/app/ui/legacy/src/main/res/values-nb/strings.xml +++ b/app/ui/legacy/src/main/res/values-nb/strings.xml @@ -239,6 +239,22 @@ til %d flere Forkast melding Merk alle meldinger som lest Slett (fra varsel) + + + + + Ingen + + + + + Arkiver + + Slett + + Søppelpost + + Flytt Skjul e-postklient Fjern K-9 -brukeragent fra meldingshoder Skjul tidssone 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 c384515ecd..07e27f187c 100644 --- a/app/ui/legacy/src/main/res/values-nl/strings.xml +++ b/app/ui/legacy/src/main/res/values-nl/strings.xml @@ -255,6 +255,28 @@ Graag foutrapporten sturen, bijdragen voor nieuwe functies en vragen stellen op Bericht afbreken Alles als gelezen markeren Verwijderen (van notificaties) + + Veegacties + + Naar rechts vegen + + Naar links vegen + + Geen + + Selectie wisselen + + Als gelezen/ongelezen markeren + + Ster toevoegen/verwijderen + + Archiveren + + Verwijderen + + Spam + + Verplaats Mailclient verbergen K-9-gebruikersagent van e-mailkoppen verwijderen Tijdzone verbergen 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 2b50bc25a5..4ceb729b93 100644 --- a/app/ui/legacy/src/main/res/values-pl/strings.xml +++ b/app/ui/legacy/src/main/res/values-pl/strings.xml @@ -256,14 +256,36 @@ Wysłane z urządzenia Android za pomocą K-9 Mail. Proszę wybaczyć moją zwi Wraca do listy wiadomości po usunięciu danej wiadomości Pokaż następną wiadomość po usunięciu Pokaż domyślnie następną wiadomość po usunięciu - Potwierdź akcje - Pokaż potwierdzające okno dialogowe, gdy wykonujesz wybrane akcje + Potwierdź czynności + Pokaż potwierdzające okno dialogowe, gdy wykonujesz wybrane czynności Usuń Usuń oznacz. gwiazkdą (tylko widok wiadomości) Spam Odrzuć wiadomość Oznacz wszystkie wiadomości jako przeczytane Usuń (powiadomienie) + + Czynności przesunięcia + + Przesuń w prawo + + Przesuń w lewo + + Żadne + + Przełącz wybór + + Oznacz jako nie/przeczytane + + Dodaj/usuń gwiazdkę + + Archiwizuj + + Usuń + + Spam + + Przenieś Ukryj klienta poczty Usuń User-Agent K-9 z nagłówka wiadomości Ukryj strefę czasową @@ -883,8 +905,8 @@ Wysłane z urządzenia Android za pomocą K-9 Mail. Proszę wybaczyć moją zwi Oznacz wszystkie jako przeczytane Pokoloruj zdjęcia kontaktów Używa kolorów gdy brakuje zdjęć kontaktów - Widoczne operacje wiadomości - Pokaż wybrane operacje w menu widoku wiadomości + Widoczne czynności wiadomości + Pokaż wybrane czynności w menu widoku wiadomości Ładowanie załącznika… Wysyłanie wiadomości Zapisywanie wersji roboczej 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 9cb91179f6..2fca8fca62 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 @@ -261,6 +261,22 @@ Por favor encaminhe relatórios de bugs, contribua com novos recursos e tire dú Descartar mensagem Marcar todas as mensagens como lidas Excluir (na notificação) + + + + + Nenhuma + + + + + Arquivadas + + Excluir + + Spam + + Mover Ocultar o cliente de e-mail Remove o User-Agent do K-9 dos cabeçalhos das mensagens Ocultar o fuso horário 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 39f1110b13..f14f823e51 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 @@ -258,6 +258,22 @@ Por favor envie relatórios de falhas, contribua com novas funcionalidades e col Rejeitar mensagem Marcar todas as mensagens como lidas Eliminar (a partir da notificação) + + + + + Nenhuma + + + + + Arquivo + + Eliminar + + Spam + + Mover Ocultar cliente de email Remover o \"User-Agent\" do K-9 dos cabeçalhos dos emails Ocultar fuso horário 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 4710883d5f..610a85ca6e 100644 --- a/app/ui/legacy/src/main/res/values-ro/strings.xml +++ b/app/ui/legacy/src/main/res/values-ro/strings.xml @@ -262,6 +262,22 @@ cel mult încă %d Renunță la mesaj Marchează toate mesajele ca citite Șterge (din notificare) + + + + + Fără + + + + + Arhivă + + Şterge + + Spam + + Mută Ascunde clientul de mail Elimină K-9 User-Agent din anteturile mesajelor Ascunde fusul orar 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 fb1f3b8343..34a5375ff2 100644 --- a/app/ui/legacy/src/main/res/values-ru/strings.xml +++ b/app/ui/legacy/src/main/res/values-ru/strings.xml @@ -12,6 +12,8 @@ Лицензия Apache версии 2.0 Проект с открытым исходным кодом Сайт + Руководство пользователя + Получить помощь Форум Федеративная сеть Твиттер @@ -170,7 +172,9 @@ K-9 Mail — почтовый клиент для Android. Сбой аутентификации Сбой аутентификации для %s. Измените настройки сервера + Ошибка уведомления + При попытке создать системное уведомление для нового сообщения произошла ошибка. Причина, скорее всего, заключается в отсутствующем звука уведомления.\n\nНажмите , чтобы открыть настройки уведомлений. Проверка %1$s:%2$s Проверка почты Отправка %s @@ -221,7 +225,7 @@ K-9 Mail — почтовый клиент для Android. От: %1$s <%2$s> Кому: Копия: - Скрытая: + Скрытая копия: Не получается сохранить вложение. Изображения Отсутствует просмотрщик %s. @@ -259,6 +263,28 @@ K-9 Mail — почтовый клиент для Android. Отменить сообщение Прочитаны все Удалить (в уведомлении) + + Действия смахиванием + + Смахивание вправо + + Смахивание влево + + Нет + + Отметить письмо + + Отметить как прочитанное/непрочитанное + + Добавить/удалить звездочку + + Архив + + Удалить + + Переместить в спам + + Перенести Скрыть почтовый клиент Убрать K-9 User-Agent из заголовков сообщений Скрыть временную зону @@ -378,17 +404,17 @@ K-9 Mail — почтовый клиент для Android. 1 час Уведомлять о новой почте Загружать сообщений - 10 - 25 - 50 - 100 - 250 - 500 - 1000 + 10 сообщений + 25 сообщений + 50 сообщений + 100 сообщений + 250 сообщений + 500 сообщений + 1000 сообщений 2500 сообщений 5000 сообщений 10000 сообщений - Все + Все сообщения Нельзя скопировать или переместить сообщение, не синхронизированное с сервером Настройка не завершена Неверные логин или пароль.\n(%s) @@ -405,12 +431,12 @@ K-9 Mail — почтовый клиент для Android. Уведомить о почте Уведомления папок Все - 1 класс - 1 и 2 классы - Кроме 2 класса + Папки только 1-го класса + Папки 1-го и 2-го классов + Все, кроме папок 2-го класса Нет Уведомить о проверке - Ваш адрес email + Ваш адрес эл. почты Показать уведомление о новой почте Показать уведомление о проверке почты Также об исходящей @@ -439,7 +465,7 @@ K-9 Mail — почтовый клиент для Android. Удалять подписи из цитируемого текста Формат сообщений Только текст - HTML + HTML (сохранять изображения и форматирование) Автоматически Включать Копия/Скрытая Уведомление о прочтении @@ -461,8 +487,8 @@ K-9 Mail — почтовый клиент для Android. OpenPGP не настроен Соединено с %s Конфигурируется… - Хранить черновики зашифрованными - Все черновики будут зашифрованы + Хранить все черновики в зашифрованном виде + Все черновики будут храниться в зашифрованном виде Зашифровать черновики, если включено шифрование Интервал проверки Цвет @@ -563,7 +589,7 @@ K-9 Mail — почтовый клиент для Android. Шаблон 5 Повтор Выключено - Мелодия + Мелодия для оповещения Свет уведомлений Выключено Цвет @@ -594,13 +620,13 @@ K-9 Mail — почтовый клиент для Android. Название роли опция Ваше имя - опция + (Необязательно) Адрес email - обязательно + (Обязательно) Адрес для ответа - опция + (Необязательно) Подпись - опция + (Необязательно) Использовать подпись Подпись Основная роль @@ -679,6 +705,7 @@ K-9 Mail — почтовый клиент для Android. 1000 папок Анимация Анимация интерфейса + Управление клавишами громкости для перехода между письмами Показывать общий ящик для входящих Cчетчик важных Входящие @@ -895,7 +922,7 @@ K-9 Mail — почтовый клиент для Android. *Зашифровано* Добавить из Контактов Копия - Скрытая + Скрытая копия Кому От Ответить на 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 679e348da7..ef7a02bd91 100644 --- a/app/ui/legacy/src/main/res/values-sk/strings.xml +++ b/app/ui/legacy/src/main/res/values-sk/strings.xml @@ -244,6 +244,22 @@ Prosím, nahlasujte prípadné chyby, prispievajte novými funkciami a pýtajte Zahodiť správu Označiť všetky správy ako prečítané Vymazať (z oznámení) + + + + + Žiadna + + + + + Archivovať + + Vymazať + + Nevyžiadaná pošta + + Presunúť Skryť e-mail klienta Odstrániť používateľského agenta K-9 z hlavičiek správ Skryť časové pásmo 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 92d5b75e69..c11bf74af3 100644 --- a/app/ui/legacy/src/main/res/values-sl/strings.xml +++ b/app/ui/legacy/src/main/res/values-sl/strings.xml @@ -264,6 +264,22 @@ dodatnih %d sporočil Zavrženje neshranjenega sporočila Označi vsa sporočila kot prebrana Brisanje neposredno iz obvestilne vrstice + + + + + Brez usklajevanja + + + + + Arhivirano + + Brisanje vseh vrst sporočil + + Neželena pošta + + Premakni Skrij program Odstrani podatke programa K-9 iz glave pošte Skrij časovni pas 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 dd3e0b3f7e..7f39c0ad9c 100644 --- a/app/ui/legacy/src/main/res/values-sq/strings.xml +++ b/app/ui/legacy/src/main/res/values-sq/strings.xml @@ -261,6 +261,27 @@ Ju lutemi, parashtrim njoftimesh për të meta, kontribute për veçori të reaj Hidhe tej mesazhin Shënoji krejt mesazhet si të lexuar Fshije (që prej njoftimit) + + Veprime fërkimi + + Fërkim djathtas + + Fërkim majtas + + Asnjë + + + Vëri/hiqi shenjë si i lexuar + + Shtoni/hiqni yll + + Arkivoji + + Fshije + + Shënoje si të padëshiruar + + Lëvizeni Fshihe klientin e postës Hiqeni K-9 User-Agent nga kryet e email-it Fshihe zonën kohore 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 b5826fafdb..a303c516ad 100644 --- a/app/ui/legacy/src/main/res/values-sr/strings.xml +++ b/app/ui/legacy/src/main/res/values-sr/strings.xml @@ -242,6 +242,22 @@ одбацивања поруке означавање свих као прочитане брисања (из обавештења) + + + + + ниједна + + + + + Архивирај + + брисања + + померања у нежељене + + Премести Сакриј клијента Уклања К-9 идентификацију из заглавља поруке Сакриј временску зону 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 e696c2c9b2..8e315a0f45 100644 --- a/app/ui/legacy/src/main/res/values-sv/strings.xml +++ b/app/ui/legacy/src/main/res/values-sv/strings.xml @@ -260,6 +260,28 @@ Skicka in felrapporter, bidra med nya funktioner och ställ frågor på Kassera meddelandet Markera alla meddelanden som lästa Ta bort (från avisering) + + Svepåtgärder + + Svep åt höger + + Svep åt vänster + + Inga + + Växla markering + + Markera som läst/oläst + + Lägg till/ta bort stjärna + + Arkivera + + Ta bort + + Skräppost + + Flytta Dölj e-postklient Ta bort K-9-användaragent från meddelanderubriker Dölj tidszon 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 c5951ee1da..32eabc056f 100644 --- a/app/ui/legacy/src/main/res/values-tr/strings.xml +++ b/app/ui/legacy/src/main/res/values-tr/strings.xml @@ -252,6 +252,22 @@ Microsoft Exchange ile konuşurken bazı tuhaflıklar yaşadığını not ediniz Mesajı yoksay Tüm iletileri okundu olarak işaretle Sil (bildirimden) + + + + + Hiçbiri + + + + + Arşiv + + Sil + + Gereksiz posta + + Taşı Posta istemcisini gizle Posta başlığından K-9 Kullanıcı Aracısını kaldır Zaman dilimini gizle 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 2823354c54..e660a10f3a 100644 --- a/app/ui/legacy/src/main/res/values-uk/strings.xml +++ b/app/ui/legacy/src/main/res/values-uk/strings.xml @@ -265,6 +265,22 @@ K-9 Mail — це вільний клієнт електронної пошти Скасувати повідомлення Позначити всі повідомлення як прочитані Видалити (зі сповіщення) + + + + + Жодна + + + + + Архівувати + + Видалити + + Спам + + Перемістити Приховати поштовий клієнт Прибрати K-9 User-Agent з поштових заголовків Приховати часову зону 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 da8c70b354..eea1b52a99 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 @@ -258,6 +258,28 @@ K-9 Mail 是 Android 上一款功能强大的免费邮件客户端。 丢弃邮件 全部标为已读 删除(来自通知) + + 滑动操作 + + 右滑 + + 左滑 + + + + 切换选择 + + 标为已读/未读 + + 添加/删除星标 + + 归档 + + 删除 + + 标记为垃圾邮件 + + 移动 隐藏邮件客户端 从邮件头中移除 K-9 的 User-Agent 隐藏时区 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 54036031ea..ddf2ace0f2 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 @@ -258,6 +258,22 @@ K-9 Mail 是 Android 上一款功能強大,免費的電子郵件用戶端。 捨棄郵件 全部標記為已讀 刪除 (從通知欄) + + + + + + + + + + 封存 + + 刪除 + + 標記為垃圾郵件 + + 移動 隱藏郵件客戶端 從郵件標頭中移除 K-9 的 User-Agent 隱藏時區 -- GitLab From 1f2e6fb171743ddafd6d116fb000498adb913b6d Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 12 Oct 2022 17:29:06 +0200 Subject: [PATCH 044/121] Remove inappropriate use of `lateinit var` from `MessageListFragment` - Restructure the code so `MessageListAdapter` is only created once and initialized early. - Remove view references in `onDestroyView()` --- .../k9/ui/messagelist/MessageListFragment.kt | 93 ++++++++++--------- 1 file changed, 51 insertions(+), 42 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index af96fef14f..4489078ef8 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -76,8 +76,9 @@ class MessageListFragment : private lateinit var fragmentListener: MessageListFragmentListener - private lateinit var recyclerView: RecyclerView - private lateinit var swipeRefreshLayout: SwipeRefreshLayout + private var recyclerView: RecyclerView? = null + private var swipeRefreshLayout: SwipeRefreshLayout? = null + private lateinit var adapter: MessageListAdapter private lateinit var accountUuids: Array @@ -157,6 +158,8 @@ class MessageListFragment : setMessageList(messageListInfo) } + adapter = createMessageListAdapter() + isInitialized = true } @@ -175,7 +178,7 @@ class MessageListFragment : } fun restoreListState(savedListState: Parcelable) { - recyclerView.layoutManager?.onRestoreInstanceState(savedListState) + recyclerView?.layoutManager?.onRestoreInstanceState(savedListState) } private fun decodeArguments(): MessageListFragment? { @@ -212,15 +215,36 @@ class MessageListFragment : return this } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.message_list_fragment, container, false).apply { - initializeSwipeRefreshLayout(this) - initializeRecyclerView(this) + private fun createMessageListAdapter(): MessageListAdapter { + return MessageListAdapter( + theme = requireActivity().theme, + res = resources, + layoutInflater = layoutInflater, + contactsPictureLoader = ContactPicture.getContactPictureLoader(), + listItemListener = this, + appearance = messageListAppearance, + relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), clock) + ).apply { + activeMessage = this@MessageListFragment.activeMessage } } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.message_list_fragment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + initializeSwipeRefreshLayout(view) + initializeRecyclerView(view) + + // This needs to be done before loading the message list below + initializeSortSettings() + + loadMessageList() + } + private fun initializeSwipeRefreshLayout(view: View) { - swipeRefreshLayout = view.findViewById(R.id.swiperefresh) + val swipeRefreshLayout = view.findViewById(R.id.swiperefresh) if (isRemoteSearchAllowed) { swipeRefreshLayout.setOnRefreshListener { onRemoteSearchRequested() } @@ -230,49 +254,23 @@ class MessageListFragment : // Disable pull-to-refresh until the message list has been loaded swipeRefreshLayout.isEnabled = false + + this.swipeRefreshLayout = swipeRefreshLayout } private fun initializeRecyclerView(view: View) { - recyclerView = view.findViewById(R.id.message_list) + val recyclerView = view.findViewById(R.id.message_list) val itemDecoration = MessageListItemDecoration(requireContext()) recyclerView.addItemDecoration(itemDecoration) recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.itemAnimator = MessageListItemAnimator() - } - - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - - initializeMessageList() - - // This needs to be done before loading the message list below - initializeSortSettings() - loadMessageList() - } - - private fun initializeMessageList() { - val theme = requireActivity().theme - - adapter = MessageListAdapter( - theme = theme, - res = resources, - layoutInflater = layoutInflater, - contactsPictureLoader = ContactPicture.getContactPictureLoader(), - listItemListener = this, - appearance = messageListAppearance, - relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), clock) - ) - - adapter.activeMessage = activeMessage - - recyclerView.adapter = adapter val itemTouchHelper = ItemTouchHelper( MessageListSwipeCallback( resources, - resourceProvider = SwipeResourceProvider(theme), + resourceProvider = SwipeResourceProvider(requireActivity().theme), swipeActionSupportProvider, swipeRightAction = K9.swipeRightAction, swipeLeftAction = K9.swipeLeftAction, @@ -281,6 +279,10 @@ class MessageListFragment : ) ) itemTouchHelper.attachToRecyclerView(recyclerView) + + recyclerView.adapter = adapter + + this.recyclerView = recyclerView } private fun initializeSortSettings() { @@ -363,7 +365,7 @@ class MessageListFragment : fun progress(progress: Boolean) { if (!progress) { - swipeRefreshLayout.isRefreshing = false + swipeRefreshLayout?.isRefreshing = false } fragmentListener.setMessageListProgressEnabled(progress) @@ -423,6 +425,9 @@ class MessageListFragment : } override fun onDestroyView() { + recyclerView = null + swipeRefreshLayout = null + if (isNewMessagesView && !requireActivity().isChangingConfigurations) { messagingController.clearNewMessages(account) } @@ -502,7 +507,7 @@ class MessageListFragment : val queryString = localSearch.remoteSearchArguments isRemoteSearch = true - swipeRefreshLayout.isEnabled = false + swipeRefreshLayout?.isEnabled = false remoteSearchFuture = messagingController.searchRemoteMessages( searchAccount, @@ -1159,6 +1164,7 @@ class MessageListFragment : private val selectedMessageListItem: MessageListItem? get() { + val recyclerView = recyclerView ?: return null val focusedView = recyclerView.focusedChild ?: return null val viewHolder = recyclerView.findContainingViewHolder(focusedView) as? MessageViewHolder ?: return null return adapter.getItemById(viewHolder.uniqueId) @@ -1271,8 +1277,10 @@ class MessageListFragment : return } - swipeRefreshLayout.isRefreshing = false - swipeRefreshLayout.isEnabled = isPullToRefreshAllowed + swipeRefreshLayout?.let { swipeRefreshLayout -> + swipeRefreshLayout.isRefreshing = false + swipeRefreshLayout.isEnabled = isPullToRefreshAllowed + } if (isThreadDisplay) { if (messageListItems.isNotEmpty()) { @@ -1389,6 +1397,7 @@ class MessageListFragment : } private fun scrollToMessage(messageReference: MessageReference) { + val recyclerView = recyclerView ?: return val messageListItem = adapter.getItem(messageReference) ?: return val position = adapter.getPosition(messageListItem) ?: return -- GitLab From c8ff69ba9a72f277bd6169f7dfe8afb0b4a9de8f Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 12 Oct 2022 17:36:16 +0200 Subject: [PATCH 045/121] Remove unused code --- .../fsck/k9/ui/messagelist/MessageListFragment.kt | 5 ----- .../k9/ui/messagelist/MessageListHandler.java | 15 --------------- 2 files changed, 20 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index 4489078ef8..c947c03ec5 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -5,7 +5,6 @@ import android.app.SearchManager import android.content.Context import android.content.Intent import android.os.Bundle -import android.os.Parcelable import android.os.SystemClock import android.view.LayoutInflater import android.view.Menu @@ -177,10 +176,6 @@ class MessageListFragment : rememberedSelected = savedInstanceState.getLongArray(STATE_SELECTED_MESSAGES)?.toSet() } - fun restoreListState(savedListState: Parcelable) { - recyclerView?.layoutManager?.onRestoreInstanceState(savedListState) - } - private fun decodeArguments(): MessageListFragment? { val arguments = requireArguments() showingThreadedList = arguments.getBoolean(ARG_THREADED_LIST, false) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java index 22a7a29826..8b200c5777 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java @@ -5,7 +5,6 @@ import java.lang.ref.WeakReference; import android.app.Activity; import android.os.Handler; -import android.os.Parcelable; /** * This class is used to run operations that modify UI elements in the UI thread. @@ -22,7 +21,6 @@ public class MessageListHandler extends Handler { private static final int ACTION_PROGRESS = 3; 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 WeakReference mFragment; @@ -68,14 +66,6 @@ public class MessageListHandler extends Handler { sendMessage(msg); } - public void restoreListPosition(Parcelable savedListState) { - MessageListFragment fragment = mFragment.get(); - if (fragment != null) { - android.os.Message msg = android.os.Message.obtain(this, ACTION_RESTORE_LIST_POSITION, savedListState); - sendMessage(msg); - } - } - @Override public void handleMessage(android.os.Message msg) { MessageListFragment fragment = mFragment.get(); @@ -117,11 +107,6 @@ public class MessageListHandler extends Handler { fragment.goBack(); break; } - case ACTION_RESTORE_LIST_POSITION: { - Parcelable savedListState = (Parcelable) msg.obj; - fragment.restoreListState(savedListState); - break; - } } } } -- GitLab From 2641a854431e2657d2848ff734195fa3b8cef1e5 Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 12 Oct 2022 19:49:37 +0200 Subject: [PATCH 046/121] Version 6.309 --- app/k9mail/build.gradle | 4 ++-- app/ui/legacy/src/main/res/raw/changelog_master.xml | 8 ++++++++ fastlane/metadata/android/en-US/changelogs/33009.txt | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/33009.txt diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 172b5ae079..00305aa7c0 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.fsck.k9" testApplicationId "com.fsck.k9.tests" - versionCode 33008 - versionName '6.309-SNAPSHOT' + versionCode 33009 + versionName '6.309' // 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/ui/legacy/src/main/res/raw/changelog_master.xml b/app/ui/legacy/src/main/res/raw/changelog_master.xml index e23f7d0061..4659d09eb9 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,14 @@ Locale-specific versions are kept in res/raw-/changelog.xml. --> + + Automatically scroll to the top after using pull to refresh, so new messages are visible + Fixed a bug that prevented the app from establishing a connection to the outgoing server under certain circumstances + Fixed a bug that could lead to messages being sent twice when the send button was double clicked + Changed the structure of HTML signatures in outgoing messages to increase compatibility + More bug fixes and internal changes + Updated translations + Added swipe actions to the message list screen Changed the minimum size of the message list widget to 2x2 cells diff --git a/fastlane/metadata/android/en-US/changelogs/33009.txt b/fastlane/metadata/android/en-US/changelogs/33009.txt new file mode 100644 index 0000000000..c72bb703ed --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33009.txt @@ -0,0 +1,6 @@ +- Automatically scroll to the top after using pull to refresh, so new messages are visible +- Fixed a bug that prevented the app from establishing a connection to the outgoing server under certain circumstances +- Fixed a bug that could lead to messages being sent twice when the send button was double clicked +- Changed the structure of HTML signatures in outgoing messages to increase compatibility +- More bug fixes and internal changes +- Updated translations -- GitLab From 243fbcfd5ada7bb6b39684745f1efb04718731e3 Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 12 Oct 2022 20:03:48 +0200 Subject: [PATCH 047/121] Prepare for version 6.310 --- app/k9mail/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 00305aa7c0..ee9a92a2be 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -51,7 +51,7 @@ android { testApplicationId "com.fsck.k9.tests" versionCode 33009 - versionName '6.309' + versionName '6.310-SNAPSHOT' // 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", -- GitLab From 755885bb2b1ce5daab47f6815115e58b1b0ee66c Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 14 Oct 2022 13:37:02 +0200 Subject: [PATCH 048/121] Rename .java to .kt --- ...{MessageListWidgetService.java => MessageListWidgetService.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/k9mail/src/main/java/com/fsck/k9/widget/list/{MessageListWidgetService.java => MessageListWidgetService.kt} (100%) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.java b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.kt similarity index 100% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.java rename to app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.kt -- GitLab From f464b3be6dff279c0efc529858b6e2dfe2e37d08 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 14 Oct 2022 13:37:02 +0200 Subject: [PATCH 049/121] Convert `MessageListWidgetService` to Kotlin --- .../k9/widget/list/MessageListWidgetService.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.kt index 980a585c62..137d8646cc 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.kt @@ -1,13 +1,10 @@ -package com.fsck.k9.widget.list; +package com.fsck.k9.widget.list +import android.content.Intent +import android.widget.RemoteViewsService -import android.content.Intent; -import android.widget.RemoteViewsService; - - -public class MessageListWidgetService extends RemoteViewsService { - @Override - public RemoteViewsFactory onGetViewFactory(Intent intent) { - return new MessageListRemoteViewFactory(getApplicationContext()); +class MessageListWidgetService : RemoteViewsService() { + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { + return MessageListRemoteViewFactory(applicationContext) } } -- GitLab From ee98a96cbaece7aff6ed909b97906fd0f203426a Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 14 Oct 2022 13:58:50 +0200 Subject: [PATCH 050/121] Rename .java to .kt --- ...essageListWidgetProvider.java => MessageListWidgetProvider.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/k9mail/src/main/java/com/fsck/k9/widget/list/{MessageListWidgetProvider.java => MessageListWidgetProvider.kt} (100%) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.java b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt similarity index 100% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.java rename to app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt -- GitLab From 6ad7d022fe4720fb79a2f4ec8085c8b41a3cd9d1 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 14 Oct 2022 13:58:50 +0200 Subject: [PATCH 051/121] Convert `MessageListWidgetProvider` to Kotlin --- .../widget/list/MessageListWidgetProvider.kt | 157 +++++++++--------- 1 file changed, 80 insertions(+), 77 deletions(-) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt index c7a9ac0073..9f5e730bc1 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt @@ -1,101 +1,104 @@ -package com.fsck.k9.widget.list; - - -import android.app.PendingIntent; -import android.appwidget.AppWidgetManager; -import android.appwidget.AppWidgetProvider; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.widget.RemoteViews; - -import com.fsck.k9.R; -import com.fsck.k9.activity.MessageCompose; -import com.fsck.k9.activity.MessageList; -import com.fsck.k9.search.SearchAccount; - -import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; -import static com.fsck.k9.helper.PendingIntentCompat.FLAG_IMMUTABLE; -import static com.fsck.k9.helper.PendingIntentCompat.FLAG_MUTABLE; - - -public class MessageListWidgetProvider extends AppWidgetProvider { - private static final String ACTION_UPDATE_MESSAGE_LIST = "UPDATE_MESSAGE_LIST"; - +package com.fsck.k9.widget.list + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.RemoteViews +import com.fsck.k9.R +import com.fsck.k9.activity.MessageCompose +import com.fsck.k9.activity.MessageList +import com.fsck.k9.activity.MessageList.Companion.intentDisplaySearch +import com.fsck.k9.helper.PendingIntentCompat.FLAG_IMMUTABLE +import com.fsck.k9.helper.PendingIntentCompat.FLAG_MUTABLE +import com.fsck.k9.search.SearchAccount.Companion.createUnifiedInboxAccount + +class MessageListWidgetProvider : AppWidgetProvider() { + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId) + } + } - public static void triggerMessageListWidgetUpdate(Context context) { - Context appContext = context.getApplicationContext(); - AppWidgetManager widgetManager = AppWidgetManager.getInstance(appContext); - ComponentName widget = new ComponentName(appContext, MessageListWidgetProvider.class); - int[] widgetIds = widgetManager.getAppWidgetIds(widget); + private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { + val views = RemoteViews(context.packageName, R.layout.message_list_widget_layout) - Intent intent = new Intent(context, MessageListWidgetProvider.class); - intent.setAction(ACTION_UPDATE_MESSAGE_LIST); - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetIds); - context.sendBroadcast(intent); - } + views.setTextViewText(R.id.folder, context.getString(com.fsck.k9.ui.R.string.integrated_inbox_title)) - @Override - public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { - for (int appWidgetId : appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId); + val intent = Intent(context, MessageListWidgetService::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) } - } + views.setRemoteAdapter(R.id.listView, intent) - private void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) { - RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.message_list_widget_layout); + val viewAction = viewActionTemplatePendingIntent(context) + views.setPendingIntentTemplate(R.id.listView, viewAction) - views.setTextViewText(R.id.folder, context.getString(com.fsck.k9.ui.R.string.integrated_inbox_title)); + val composeAction = composeActionPendingIntent(context) + views.setOnClickPendingIntent(R.id.new_message, composeAction) - Intent intent = new Intent(context, MessageListWidgetService.class); - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); - intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); - views.setRemoteAdapter(R.id.listView, intent); + val headerClickAction = viewUnifiedInboxPendingIntent(context) + views.setOnClickPendingIntent(R.id.top_controls, headerClickAction) - PendingIntent viewAction = viewActionTemplatePendingIntent(context); - views.setPendingIntentTemplate(R.id.listView, viewAction); + appWidgetManager.updateAppWidget(appWidgetId, views) + } - PendingIntent composeAction = composeActionPendingIntent(context); - views.setOnClickPendingIntent(R.id.new_message, composeAction); + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) - PendingIntent headerClickAction = viewUnifiedInboxPendingIntent(context); - views.setOnClickPendingIntent(R.id.top_controls, headerClickAction); + if (intent.action == ACTION_UPDATE_MESSAGE_LIST) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetIds = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS) - appWidgetManager.updateAppWidget(appWidgetId, views); + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.listView) + } } - @Override - public void onReceive(Context context, Intent intent) { - super.onReceive(context, intent); - - String action = intent.getAction(); - if (action.equals(ACTION_UPDATE_MESSAGE_LIST)) { - AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); - int[] appWidgetIds = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS); - appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.listView); + private fun viewActionTemplatePendingIntent(context: Context): PendingIntent { + val intent = Intent(context, MessageList::class.java).apply { + action = Intent.ACTION_VIEW } + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or FLAG_MUTABLE) } - private PendingIntent viewActionTemplatePendingIntent(Context context) { - Intent intent = new Intent(context, MessageList.class); - intent.setAction(Intent.ACTION_VIEW); + private fun viewUnifiedInboxPendingIntent(context: Context): PendingIntent { + val unifiedInboxAccount = createUnifiedInboxAccount() + val intent = intentDisplaySearch( + context = context, + search = unifiedInboxAccount.relatedSearch, + noThreading = true, + newTask = true, + clearTop = true + ) + + return PendingIntent.getActivity(context, -1, intent, PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) + } - return PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT | FLAG_MUTABLE); + private fun composeActionPendingIntent(context: Context): PendingIntent { + val intent = Intent(context, MessageCompose::class.java).apply { + action = MessageCompose.ACTION_COMPOSE + } + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) } - private PendingIntent viewUnifiedInboxPendingIntent(Context context) { - SearchAccount unifiedInboxAccount = SearchAccount.createUnifiedInboxAccount(); - Intent intent = MessageList.intentDisplaySearch( - context, unifiedInboxAccount.getRelatedSearch(), true, true, true); + companion object { + private const val ACTION_UPDATE_MESSAGE_LIST = "UPDATE_MESSAGE_LIST" - return PendingIntent.getActivity(context, -1, intent, FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE); - } + fun triggerMessageListWidgetUpdate(context: Context) { + val appContext = context.applicationContext + val widgetManager = AppWidgetManager.getInstance(appContext) - private PendingIntent composeActionPendingIntent(Context context) { - Intent intent = new Intent(context, MessageCompose.class); - intent.setAction(MessageCompose.ACTION_COMPOSE); + val widget = ComponentName(appContext, MessageListWidgetProvider::class.java) + val widgetIds = widgetManager.getAppWidgetIds(widget) + val intent = Intent(context, MessageListWidgetProvider::class.java).apply { + action = ACTION_UPDATE_MESSAGE_LIST + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetIds) + } - return PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE); + context.sendBroadcast(intent) + } } } -- GitLab From 2015fe790554e7278a4060b98acb0c3a1b1da44d Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 14 Oct 2022 14:33:54 +0200 Subject: [PATCH 052/121] Rename .java to .kt --- ...ListRemoteViewFactory.java => MessageListRemoteViewFactory.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/k9mail/src/main/java/com/fsck/k9/widget/list/{MessageListRemoteViewFactory.java => MessageListRemoteViewFactory.kt} (100%) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.java b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt similarity index 100% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.java rename to app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt -- GitLab From dfef2d9ece98209682ca738d8d855a6914a558d9 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 14 Oct 2022 14:33:54 +0200 Subject: [PATCH 053/121] Convert `MessageListRemoteViewFactory` to Kotlin --- .../com/fsck/k9/helper/CursorExtensions.kt | 4 + .../list/MessageListRemoteViewFactory.kt | 304 ++++++++---------- 2 files changed, 131 insertions(+), 177 deletions(-) diff --git a/app/core/src/main/java/com/fsck/k9/helper/CursorExtensions.kt b/app/core/src/main/java/com/fsck/k9/helper/CursorExtensions.kt index 61c60c7ba8..53544cdf9e 100644 --- a/app/core/src/main/java/com/fsck/k9/helper/CursorExtensions.kt +++ b/app/core/src/main/java/com/fsck/k9/helper/CursorExtensions.kt @@ -35,3 +35,7 @@ fun Cursor.getIntOrThrow(columnName: String): Int { fun Cursor.getLongOrThrow(columnName: String): Long { return getLongOrNull(columnName) ?: error("Column $columnName must not be null") } + +fun Cursor.getBoolean(columnIndex: Int): Boolean { + return getString(columnIndex).toBoolean() +} diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt index 5ebc411574..f292d2db05 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt @@ -1,217 +1,167 @@ -package com.fsck.k9.widget.list; - - -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Locale; - -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.graphics.Typeface; -import android.net.Uri; -import android.os.Binder; -import androidx.core.content.ContextCompat; -import android.text.SpannableString; -import android.text.style.StyleSpan; -import android.view.View; -import android.widget.RemoteViews; -import android.widget.RemoteViewsService; - -import com.fsck.k9.K9; -import com.fsck.k9.R; -import com.fsck.k9.external.MessageProvider; - - -public class MessageListRemoteViewFactory implements RemoteViewsService.RemoteViewsFactory { - private static final String[] MAIL_LIST_PROJECTIONS = { - MessageProvider.MessageColumns.SENDER, - MessageProvider.MessageColumns.SEND_DATE, - MessageProvider.MessageColumns.SUBJECT, - MessageProvider.MessageColumns.PREVIEW, - MessageProvider.MessageColumns.UNREAD, - MessageProvider.MessageColumns.HAS_ATTACHMENTS, - MessageProvider.MessageColumns.URI, - MessageProvider.MessageColumns.ACCOUNT_COLOR, - }; - - - private final Context context; - private final Calendar calendar; - private final ArrayList mailItems = new ArrayList<>(25); - private boolean senderAboveSubject; - private int readTextColor; - private int unreadTextColor; - - - public MessageListRemoteViewFactory(Context context) { - this.context = context; - calendar = Calendar.getInstance(); +package com.fsck.k9.widget.list + +import android.content.Context +import android.content.Intent +import android.graphics.Typeface +import android.net.Uri +import android.os.Binder +import android.text.SpannableString +import android.text.style.StyleSpan +import android.view.View +import android.widget.RemoteViews +import android.widget.RemoteViewsService.RemoteViewsFactory +import androidx.core.content.ContextCompat +import androidx.core.database.getLongOrNull +import com.fsck.k9.K9 +import com.fsck.k9.R +import com.fsck.k9.external.MessageProvider +import com.fsck.k9.helper.getBoolean +import com.fsck.k9.helper.map +import java.util.Calendar +import java.util.Locale +import com.fsck.k9.ui.R as UiR + +class MessageListRemoteViewFactory(private val context: Context) : RemoteViewsFactory { + private val calendar: Calendar = Calendar.getInstance() + + private var mailItems = emptyList() + private var senderAboveSubject = false + private var readTextColor = 0 + private var unreadTextColor = 0 + + override fun onCreate() { + senderAboveSubject = K9.isMessageListSenderAboveSubject + readTextColor = ContextCompat.getColor(context, R.color.message_list_widget_text_read) + unreadTextColor = ContextCompat.getColor(context, R.color.message_list_widget_text_unread) } - @Override - public void onCreate() { - senderAboveSubject = K9.isMessageListSenderAboveSubject(); - readTextColor = ContextCompat.getColor(context, R.color.message_list_widget_text_read); - unreadTextColor = ContextCompat.getColor(context, R.color.message_list_widget_text_unread); - } - - @Override - public void onDataSetChanged() { - long identityToken = Binder.clearCallingIdentity(); + override fun onDataSetChanged() { + val identityToken = Binder.clearCallingIdentity() try { - loadMessageList(); + loadMessageList() } finally { - Binder.restoreCallingIdentity(identityToken); + Binder.restoreCallingIdentity(identityToken) } } - private void loadMessageList() { - mailItems.clear(); - - Uri unifiedInboxUri = MessageProvider.CONTENT_URI.buildUpon().appendPath("inbox_messages").build(); - Cursor cursor = context.getContentResolver().query(unifiedInboxUri, MAIL_LIST_PROJECTIONS, null, null, null); - - if (cursor == null) { - return; - } - - try { - while (cursor.moveToNext()) { - String sender = cursor.getString(0); - long date = cursor.isNull(1) ? 0L : cursor.getLong(1); - String subject = cursor.getString(2); - String preview = cursor.getString(3); - boolean unread = toBoolean(cursor.getString(4)); - boolean hasAttachment = toBoolean(cursor.getString(5)); - Uri viewUri = Uri.parse(cursor.getString(6)); - int color = cursor.getInt(7); - - mailItems.add(new MailItem(sender, date, subject, preview, unread, hasAttachment, viewUri, color)); + private fun loadMessageList() { + val contentResolver = context.contentResolver + val unifiedInboxUri = MessageProvider.CONTENT_URI.buildUpon().appendPath("inbox_messages").build() + + mailItems = contentResolver.query(unifiedInboxUri, MAIL_LIST_PROJECTIONS, null, null, null)?.use { cursor -> + cursor.map { + MailItem( + sender = cursor.getString(0), + date = cursor.getLongOrNull(1) ?: 0L, + subject = cursor.getString(2), + preview = cursor.getString(3), + unread = cursor.getBoolean(4), + hasAttachment = cursor.getBoolean(5), + uri = Uri.parse(cursor.getString(6)), + color = cursor.getInt(7) + ) } - } finally { - cursor.close(); - } + } ?: emptyList() } - @Override - public void onDestroy() { - // Implement interface - } + override fun onDestroy() = Unit - @Override - public int getCount() { - return mailItems.size(); - } + override fun getCount(): Int = mailItems.size - @Override - public RemoteViews getViewAt(int position) { - RemoteViews remoteView = new RemoteViews(context.getPackageName(), R.layout.message_list_widget_list_item); - MailItem item = mailItems.get(position); + override fun getViewAt(position: Int): RemoteViews { + val remoteView = RemoteViews(context.packageName, R.layout.message_list_widget_list_item) - CharSequence sender = item.unread ? bold(item.sender) : item.sender; - CharSequence subject = item.unread ? bold(item.subject) : item.subject; + val item = mailItems[position] + + val sender = if (item.unread) bold(item.sender) else item.sender + val subject = if (item.unread) bold(item.subject) else item.subject if (senderAboveSubject) { - remoteView.setTextViewText(R.id.sender, sender); - remoteView.setTextViewText(R.id.mail_subject, subject); + remoteView.setTextViewText(R.id.sender, sender) + remoteView.setTextViewText(R.id.mail_subject, subject) } else { - remoteView.setTextViewText(R.id.sender, subject); - remoteView.setTextViewText(R.id.mail_subject, sender); + remoteView.setTextViewText(R.id.sender, subject) + remoteView.setTextViewText(R.id.mail_subject, sender) } - remoteView.setTextViewText(R.id.mail_date, item.getDateFormatted("%d %s")); - remoteView.setTextViewText(R.id.mail_preview, item.preview); - int textColor = item.getTextColor(); - remoteView.setTextColor(R.id.sender, textColor); - remoteView.setTextColor(R.id.mail_subject, textColor); - remoteView.setTextColor(R.id.mail_date, textColor); - remoteView.setTextColor(R.id.mail_preview, textColor); + remoteView.setTextViewText(R.id.mail_date, formatDate(item.date)) + remoteView.setTextViewText(R.id.mail_preview, item.preview) + + val textColor = getTextColor(item) + remoteView.setTextColor(R.id.sender, textColor) + remoteView.setTextColor(R.id.mail_subject, textColor) + remoteView.setTextColor(R.id.mail_date, textColor) + remoteView.setTextColor(R.id.mail_preview, textColor) if (item.hasAttachment) { - remoteView.setInt(R.id.attachment, "setVisibility", View.VISIBLE); + remoteView.setInt(R.id.attachment, "setVisibility", View.VISIBLE) } else { - remoteView.setInt(R.id.attachment, "setVisibility", View.GONE); + remoteView.setInt(R.id.attachment, "setVisibility", View.GONE) } - Intent intent = new Intent(); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - intent.setData(item.uri); - remoteView.setOnClickFillInIntent(R.id.mail_list_item, intent); - - remoteView.setInt(R.id.chip, "setBackgroundColor", item.color); + val intent = Intent().apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + data = item.uri + } + remoteView.setOnClickFillInIntent(R.id.mail_list_item, intent) - return remoteView; - } + remoteView.setInt(R.id.chip, "setBackgroundColor", item.color) - @Override - public RemoteViews getLoadingView() { - RemoteViews loadingView = new RemoteViews(context.getPackageName(), R.layout.message_list_widget_loading); - loadingView.setTextViewText(R.id.loadingText, context.getString(com.fsck.k9.ui.R.string.mail_list_widget_loading)); - loadingView.setViewVisibility(R.id.loadingText, View.VISIBLE); - return loadingView; + return remoteView } - @Override - public int getViewTypeCount() { - return 2; + override fun getLoadingView(): RemoteViews { + return RemoteViews(context.packageName, R.layout.message_list_widget_loading).apply { + setTextViewText(R.id.loadingText, context.getString(UiR.string.mail_list_widget_loading)) + setViewVisibility(R.id.loadingText, View.VISIBLE) + } } - @Override - public long getItemId(int position) { - return position; - } + override fun getViewTypeCount(): Int = 2 - @Override - public boolean hasStableIds() { - return true; - } + override fun getItemId(position: Int): Long = position.toLong() - private CharSequence bold(String text) { - SpannableString spannableString = new SpannableString(text); - spannableString.setSpan(new StyleSpan(Typeface.BOLD), 0, text.length(), 0); - return spannableString; - } + override fun hasStableIds(): Boolean = true - private boolean toBoolean(String value) { - return Boolean.valueOf(value); + private fun bold(text: String): CharSequence { + return SpannableString(text).apply { + setSpan(StyleSpan(Typeface.BOLD), 0, text.length, 0) + } } + private fun formatDate(date: Long): String { + calendar.timeInMillis = date + val dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH) + val month = calendar.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.getDefault()) - private class MailItem { - final long date; - final String sender; - final String preview; - final String subject; - final boolean unread; - final boolean hasAttachment; - final Uri uri; - final int color; - - - MailItem(String sender, long date, String subject, String preview, boolean unread, boolean hasAttachment, - Uri viewUri, int color) { - this.sender = sender; - this.date = date; - this.preview = preview; - this.subject = subject; - this.unread = unread; - this.uri = viewUri; - this.hasAttachment = hasAttachment; - this.color = color; - } - - int getTextColor() { - return unread ? unreadTextColor : readTextColor; - } + return String.format("%d %s", dayOfMonth, month) + } - String getDateFormatted(String format) { - calendar.setTimeInMillis(date); + private fun getTextColor(mailItem: MailItem): Int { + return if (mailItem.unread) unreadTextColor else readTextColor + } - return String.format(format, - calendar.get(Calendar.DAY_OF_MONTH), - calendar.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.getDefault())); - } + companion object { + private val MAIL_LIST_PROJECTIONS = arrayOf( + MessageProvider.MessageColumns.SENDER, + MessageProvider.MessageColumns.SEND_DATE, + MessageProvider.MessageColumns.SUBJECT, + MessageProvider.MessageColumns.PREVIEW, + MessageProvider.MessageColumns.UNREAD, + MessageProvider.MessageColumns.HAS_ATTACHMENTS, + MessageProvider.MessageColumns.URI, + MessageProvider.MessageColumns.ACCOUNT_COLOR + ) } } +private class MailItem( + val sender: String, + val date: Long, + val subject: String, + val preview: String, + val unread: Boolean, + val hasAttachment: Boolean, + val uri: Uri, + val color: Int +) -- GitLab From e75d15d7e640883ad55ace85b6b120000c575ede Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 14 Oct 2022 20:15:04 +0200 Subject: [PATCH 054/121] Don't use `MessageProvider` for message list widget --- .../com/fsck/k9/widget/list/KoinModule.kt | 3 +- .../fsck/k9/widget/list/MessageListConfig.kt | 12 ++ .../fsck/k9/widget/list/MessageListItem.kt | 21 +++ .../k9/widget/list/MessageListItemMapper.kt | 62 ++++++++ .../fsck/k9/widget/list/MessageListLoader.kt | 149 ++++++++++++++++++ .../list/MessageListRemoteViewFactory.kt | 113 ++++--------- 6 files changed, 281 insertions(+), 79 deletions(-) create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListConfig.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItem.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItemMapper.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListLoader.kt diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt index 05ddb774ac..05046bdb78 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt @@ -3,5 +3,6 @@ package com.fsck.k9.widget.list import org.koin.dsl.module val messageListWidgetModule = module { - single { MessageListWidgetUpdateListener(get()) } + single { MessageListWidgetUpdateListener(context = get()) } + factory { MessageListLoader(preferences = get(), messageListRepository = get(), messageHelper = get()) } } diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListConfig.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListConfig.kt new file mode 100644 index 0000000000..efe435bea4 --- /dev/null +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListConfig.kt @@ -0,0 +1,12 @@ +package com.fsck.k9.widget.list + +import com.fsck.k9.Account.SortType +import com.fsck.k9.search.LocalSearch + +internal data class MessageListConfig( + val search: LocalSearch, + val showingThreadedList: Boolean, + val sortType: SortType, + val sortAscending: Boolean, + val sortDateAscending: Boolean +) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItem.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItem.kt new file mode 100644 index 0000000000..fe79f1f4db --- /dev/null +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItem.kt @@ -0,0 +1,21 @@ +package com.fsck.k9.widget.list + +import android.net.Uri + +internal data class MessageListItem( + val displayName: String, + val displayDate: String, + val subject: String, + val preview: String, + val isRead: Boolean, + val hasAttachments: Boolean, + val uri: Uri, + val accountColor: Int, + val uniqueId: Long, + + val sortSubject: String?, + val sortMessageDate: Long, + val sortInternalDate: Long, + val sortIsStarred: Boolean, + val sortDatabaseId: Long, +) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItemMapper.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItemMapper.kt new file mode 100644 index 0000000000..b7ad94a338 --- /dev/null +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItemMapper.kt @@ -0,0 +1,62 @@ +package com.fsck.k9.widget.list + +import android.net.Uri +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.ui.helper.DisplayAddressHelper +import java.util.Calendar +import java.util.Locale + +internal class MessageListItemMapper( + private val messageHelper: MessageHelper, + private val account: Account +) : MessageMapper { + private val calendar: Calendar = Calendar.getInstance() + + override fun map(message: MessageDetailsAccessor): MessageListItem { + val fromAddresses = message.fromAddresses + val toAddresses = message.toAddresses + val previewResult = message.preview + 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()).toString() + } else { + messageHelper.getSenderDisplayName(displayAddress).toString() + } + val uri = Uri.parse("k9mail://messages/${account.accountNumber}/${message.folderId}/${message.messageServerId}") + + return MessageListItem( + displayName = displayName, + displayDate = formatDate(message.messageDate), + subject = message.subject.orEmpty(), + preview = previewText, + isRead = message.isRead, + hasAttachments = message.hasAttachments, + uri = uri, + accountColor = account.chipColor, + uniqueId = uniqueId, + sortSubject = message.subject, + sortMessageDate = message.messageDate, + sortInternalDate = message.internalDate, + sortIsStarred = message.isStarred, + sortDatabaseId = message.id, + ) + } + + private fun formatDate(date: Long): String { + calendar.timeInMillis = date + val dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH) + val month = calendar.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.getDefault()) + + return String.format("%d %s", dayOfMonth, month) + } + + private fun createUniqueId(account: Account, messageId: Long): Long { + return ((account.accountNumber + 1).toLong() shl 52) + messageId + } +} diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListLoader.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListLoader.kt new file mode 100644 index 0000000000..dd32d8306c --- /dev/null +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListLoader.kt @@ -0,0 +1,149 @@ +package com.fsck.k9.widget.list + +import com.fsck.k9.Account +import com.fsck.k9.Account.SortType +import com.fsck.k9.Preferences +import com.fsck.k9.helper.MessageHelper +import com.fsck.k9.mailstore.MessageColumns +import com.fsck.k9.mailstore.MessageListRepository +import com.fsck.k9.search.SqlQueryBuilder +import com.fsck.k9.search.getAccounts +import timber.log.Timber + +internal class MessageListLoader( + private val preferences: Preferences, + private val messageListRepository: MessageListRepository, + private val messageHelper: MessageHelper +) { + + fun getMessageList(config: MessageListConfig): List { + return try { + getMessageListInfo(config) + } catch (e: Exception) { + Timber.e(e, "Error while fetching message list") + + // TODO: Return an error object instead of an empty list + emptyList() + } + } + + private fun getMessageListInfo(config: MessageListConfig): List { + val accounts = config.search.getAccounts(preferences) + val messageListItems = accounts + .flatMap { account -> + loadMessageListForAccount(account, config) + } + .sortedWith(config) + + return messageListItems + } + + private fun loadMessageListForAccount(account: Account, config: MessageListConfig): List { + val accountUuid = account.uuid + val sortOrder = buildSortOrder(config) + val mapper = MessageListItemMapper(messageHelper, account) + + return if (config.showingThreadedList) { + val (selection, selectionArgs) = buildSelection(account, config) + messageListRepository.getThreadedMessages(accountUuid, selection, selectionArgs, sortOrder, mapper) + } else { + 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 = mutableListOf() + + SqlQueryBuilder.buildWhereClause(account, config.search.conditions, query, queryArgs) + + val selection = query.toString() + val selectionArgs = queryArgs.toTypedArray() + + return selection to selectionArgs + } + + private fun buildSortOrder(config: MessageListConfig): String { + val sortColumn = when (config.sortType) { + 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" + val secondarySort = if (config.sortType == SortType.SORT_DATE || config.sortType == SortType.SORT_ARRIVAL) { + "" + } else { + if (config.sortDateAscending) { + "${MessageColumns.DATE} ASC, " + } else { + "${MessageColumns.DATE} DESC, " + } + } + + return "$sortColumn$sortDirection, $secondarySort${MessageColumns.ID} DESC" + } + + private fun List.sortedWith(config: MessageListConfig): List { + val comparator = when (config.sortType) { + SortType.SORT_DATE -> { + compareBy(config.sortAscending) { it.sortMessageDate } + } + SortType.SORT_ARRIVAL -> { + compareBy(config.sortAscending) { it.sortInternalDate } + } + SortType.SORT_SUBJECT -> { + compareStringBy(config.sortAscending) { it.sortSubject.orEmpty() } + .thenByDate(config) + } + SortType.SORT_SENDER -> { + compareStringBy(config.sortAscending) { it.displayName } + .thenByDate(config) + } + SortType.SORT_UNREAD -> { + compareBy(config.sortAscending) { it.isRead } + .thenByDate(config) + } + SortType.SORT_FLAGGED -> { + compareBy(!config.sortAscending) { it.sortIsStarred } + .thenByDate(config) + } + SortType.SORT_ATTACHMENT -> { + compareBy(!config.sortAscending) { it.hasAttachments } + .thenByDate(config) + } + }.thenByDescending { it.sortDatabaseId } + + return this.sortedWith(comparator) + } +} + +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) + } +} + +private fun Comparator.thenByDate(config: MessageListConfig): Comparator { + return if (config.sortDateAscending) { + thenBy { it.sortMessageDate } + } else { + thenByDescending { it.sortMessageDate } + } +} diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt index f292d2db05..084733655f 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt @@ -3,88 +3,77 @@ package com.fsck.k9.widget.list import android.content.Context import android.content.Intent import android.graphics.Typeface -import android.net.Uri -import android.os.Binder import android.text.SpannableString import android.text.style.StyleSpan import android.view.View import android.widget.RemoteViews import android.widget.RemoteViewsService.RemoteViewsFactory import androidx.core.content.ContextCompat -import androidx.core.database.getLongOrNull +import com.fsck.k9.Account.SortType import com.fsck.k9.K9 import com.fsck.k9.R -import com.fsck.k9.external.MessageProvider -import com.fsck.k9.helper.getBoolean -import com.fsck.k9.helper.map -import java.util.Calendar -import java.util.Locale +import com.fsck.k9.search.LocalSearch +import com.fsck.k9.search.SearchAccount +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import com.fsck.k9.ui.R as UiR -class MessageListRemoteViewFactory(private val context: Context) : RemoteViewsFactory { - private val calendar: Calendar = Calendar.getInstance() +internal class MessageListRemoteViewFactory(private val context: Context) : RemoteViewsFactory, KoinComponent { + private val messageListLoader: MessageListLoader by inject() - private var mailItems = emptyList() + private lateinit var unifiedInboxSearch: LocalSearch + + private var messageListItems = emptyList() private var senderAboveSubject = false private var readTextColor = 0 private var unreadTextColor = 0 override fun onCreate() { + unifiedInboxSearch = SearchAccount.createUnifiedInboxAccount().relatedSearch + senderAboveSubject = K9.isMessageListSenderAboveSubject readTextColor = ContextCompat.getColor(context, R.color.message_list_widget_text_read) unreadTextColor = ContextCompat.getColor(context, R.color.message_list_widget_text_unread) } override fun onDataSetChanged() { - val identityToken = Binder.clearCallingIdentity() - try { - loadMessageList() - } finally { - Binder.restoreCallingIdentity(identityToken) - } + loadMessageList() } private fun loadMessageList() { - val contentResolver = context.contentResolver - val unifiedInboxUri = MessageProvider.CONTENT_URI.buildUpon().appendPath("inbox_messages").build() - - mailItems = contentResolver.query(unifiedInboxUri, MAIL_LIST_PROJECTIONS, null, null, null)?.use { cursor -> - cursor.map { - MailItem( - sender = cursor.getString(0), - date = cursor.getLongOrNull(1) ?: 0L, - subject = cursor.getString(2), - preview = cursor.getString(3), - unread = cursor.getBoolean(4), - hasAttachment = cursor.getBoolean(5), - uri = Uri.parse(cursor.getString(6)), - color = cursor.getInt(7) - ) - } - } ?: emptyList() + // TODO: Use same sort order that is used for the Unified Inbox inside the app + val messageListConfig = MessageListConfig( + search = unifiedInboxSearch, + showingThreadedList = K9.isThreadedViewEnabled, + sortType = SortType.SORT_DATE, + sortAscending = false, + sortDateAscending = false + ) + + messageListItems = messageListLoader.getMessageList(messageListConfig) } override fun onDestroy() = Unit - override fun getCount(): Int = mailItems.size + override fun getCount(): Int = messageListItems.size override fun getViewAt(position: Int): RemoteViews { val remoteView = RemoteViews(context.packageName, R.layout.message_list_widget_list_item) - val item = mailItems[position] + val item = messageListItems[position] - val sender = if (item.unread) bold(item.sender) else item.sender - val subject = if (item.unread) bold(item.subject) else item.subject + val displayName = if (item.isRead) item.displayName else bold(item.displayName) + val subject = if (item.isRead) item.subject else bold(item.subject) if (senderAboveSubject) { - remoteView.setTextViewText(R.id.sender, sender) + remoteView.setTextViewText(R.id.sender, displayName) remoteView.setTextViewText(R.id.mail_subject, subject) } else { remoteView.setTextViewText(R.id.sender, subject) - remoteView.setTextViewText(R.id.mail_subject, sender) + remoteView.setTextViewText(R.id.mail_subject, displayName) } - remoteView.setTextViewText(R.id.mail_date, formatDate(item.date)) + remoteView.setTextViewText(R.id.mail_date, item.displayDate) remoteView.setTextViewText(R.id.mail_preview, item.preview) val textColor = getTextColor(item) @@ -93,7 +82,7 @@ class MessageListRemoteViewFactory(private val context: Context) : RemoteViewsFa remoteView.setTextColor(R.id.mail_date, textColor) remoteView.setTextColor(R.id.mail_preview, textColor) - if (item.hasAttachment) { + if (item.hasAttachments) { remoteView.setInt(R.id.attachment, "setVisibility", View.VISIBLE) } else { remoteView.setInt(R.id.attachment, "setVisibility", View.GONE) @@ -105,7 +94,7 @@ class MessageListRemoteViewFactory(private val context: Context) : RemoteViewsFa } remoteView.setOnClickFillInIntent(R.id.mail_list_item, intent) - remoteView.setInt(R.id.chip, "setBackgroundColor", item.color) + remoteView.setInt(R.id.chip, "setBackgroundColor", item.accountColor) return remoteView } @@ -119,7 +108,7 @@ class MessageListRemoteViewFactory(private val context: Context) : RemoteViewsFa override fun getViewTypeCount(): Int = 2 - override fun getItemId(position: Int): Long = position.toLong() + override fun getItemId(position: Int): Long = messageListItems[position].uniqueId override fun hasStableIds(): Boolean = true @@ -129,39 +118,7 @@ class MessageListRemoteViewFactory(private val context: Context) : RemoteViewsFa } } - private fun formatDate(date: Long): String { - calendar.timeInMillis = date - val dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH) - val month = calendar.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.getDefault()) - - return String.format("%d %s", dayOfMonth, month) - } - - private fun getTextColor(mailItem: MailItem): Int { - return if (mailItem.unread) unreadTextColor else readTextColor - } - - companion object { - private val MAIL_LIST_PROJECTIONS = arrayOf( - MessageProvider.MessageColumns.SENDER, - MessageProvider.MessageColumns.SEND_DATE, - MessageProvider.MessageColumns.SUBJECT, - MessageProvider.MessageColumns.PREVIEW, - MessageProvider.MessageColumns.UNREAD, - MessageProvider.MessageColumns.HAS_ATTACHMENTS, - MessageProvider.MessageColumns.URI, - MessageProvider.MessageColumns.ACCOUNT_COLOR - ) + private fun getTextColor(messageListItem: MessageListItem): Int { + return if (messageListItem.isRead) readTextColor else unreadTextColor } } - -private class MailItem( - val sender: String, - val date: Long, - val subject: String, - val preview: String, - val unread: Boolean, - val hasAttachment: Boolean, - val uri: Uri, - val color: Int -) -- GitLab From af11ca5e4e05d3007e200e4d8b0e87d57ab77866 Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 15 Oct 2022 00:10:29 +0200 Subject: [PATCH 055/121] Remove now unused `MessageProvider` --- .../k9/controller/MessagingController.java | 22 - .../com/fsck/k9/mailstore/LocalMessage.java | 4 - .../controller/MessagingControllerTest.java | 23 - app/k9mail/src/main/AndroidManifest.xml | 5 - app/k9mail/src/main/java/com/fsck/k9/App.kt | 2 - .../fsck/k9/external/MessageInfoHolder.java | 81 -- .../com/fsck/k9/external/MessageProvider.java | 1046 ----------------- 7 files changed, 1183 deletions(-) delete mode 100644 app/k9mail/src/main/java/com/fsck/k9/external/MessageInfoHolder.java delete mode 100644 app/k9mail/src/main/java/com/fsck/k9/external/MessageProvider.java 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 b31e2800a8..0d9ed85cf1 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 @@ -91,7 +91,6 @@ import static com.fsck.k9.K9.MAX_SEND_ATTEMPTS; import static com.fsck.k9.helper.ExceptionHelper.getRootCauseMessage; import static com.fsck.k9.helper.Preconditions.checkNotNull; import static com.fsck.k9.mail.Flag.X_REMOTE_COPY_STARTED; -import static com.fsck.k9.search.LocalSearchExtensions.getAccountsFromLocalSearch; /** @@ -393,27 +392,6 @@ public class MessagingController { } } - /** - * Find all messages in any local account which match the query 'query' - */ - public List searchLocalMessages(final LocalSearch search) { - List searchAccounts = getAccountsFromLocalSearch(search, preferences); - - List messages = new ArrayList<>(); - for (final Account account : searchAccounts) { - try { - LocalStore localStore = localStoreProvider.getInstance(account); - List localMessages = localStore.searchForMessages(search); - - messages.addAll(localMessages); - } catch (Exception e) { - Timber.e(e); - } - } - - return messages; - } - public Future searchRemoteMessages(String acctUuid, long folderId, String query, Set requiredFlags, Set forbiddenFlags, MessagingListener listener) { Timber.i("searchRemoteMessages (acct = %s, folderId = %d, query = %s)", acctUuid, folderId, query); diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java b/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java index c3d0de6613..31aabb5e6e 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java @@ -194,10 +194,6 @@ public class LocalMessage extends MimeMessage { return (attachmentCount > 0); } - public int getAttachmentCount() { - return attachmentCount; - } - @Override public void setFrom(Address from) { this.mFrom = new Address[] { from }; 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 641dbc62e0..1239d581f6 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 @@ -19,7 +19,6 @@ import com.fsck.k9.mail.AuthenticationFailedException; import com.fsck.k9.mail.CertificateValidationException; import com.fsck.k9.mail.ConnectionSecurity; import com.fsck.k9.mail.Flag; -import com.fsck.k9.mail.MessageRetrievalListener; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.ServerSettings; import com.fsck.k9.mailstore.LocalFolder; @@ -35,15 +34,12 @@ import com.fsck.k9.mailstore.SpecialLocalFoldersCreator; import com.fsck.k9.notification.NotificationController; import com.fsck.k9.notification.NotificationStrategy; import com.fsck.k9.preferences.Protocols; -import com.fsck.k9.search.LocalSearch; import com.fsck.k9.search.SearchAccount; import org.jetbrains.annotations.NotNull; import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; -import org.mockito.Captor; import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -52,7 +48,6 @@ import org.mockito.stubbing.Answer; import org.robolectric.RuntimeEnvironment; import org.robolectric.shadows.ShadowLog; -import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.ArgumentMatchers.eq; @@ -90,8 +85,6 @@ public class MessagingControllerTest extends K9RobolectricTest { @Mock private SimpleMessagingListener listener; @Mock - private LocalSearch search; - @Mock private LocalFolder localFolder; @Mock private LocalFolder sentFolder; @@ -101,8 +94,6 @@ public class MessagingControllerTest extends K9RobolectricTest { private NotificationController notificationController; @Mock private NotificationStrategy notificationStrategy; - @Captor - private ArgumentCaptor> messageRetrievalListenerCaptor; private Context appContext; private Set reqFlags; @@ -178,20 +169,6 @@ public class MessagingControllerTest extends K9RobolectricTest { verify(backend).refreshFolderList(); } - @Test - public void searchLocalMessages_shouldIgnoreExceptions() - throws Exception { - LocalMessage localMessage = mock(LocalMessage.class); - when(localMessage.getFolder()).thenReturn(localFolder); - when(search.searchAllAccounts()).thenReturn(true); - when(search.getAccountUuids()).thenReturn(new String[0]); - when(localStore.searchForMessages(search)).thenThrow(new MessagingException("Test")); - - List messages = controller.searchLocalMessages(search); - - assertThat(messages).isEmpty(); - } - private void setupRemoteSearch() throws Exception { remoteMessages = new ArrayList<>(); Collections.addAll(remoteMessages, "oldMessageUid", "newMessageUid1", "newMessageUid2"); diff --git a/app/k9mail/src/main/AndroidManifest.xml b/app/k9mail/src/main/AndroidManifest.xml index aa8b7bd317..a15231f834 100644 --- a/app/k9mail/src/main/AndroidManifest.xml +++ b/app/k9mail/src/main/AndroidManifest.xml @@ -381,11 +381,6 @@ - - 0 && account.isAnIdentity(addrs[0])) { - CharSequence to = MessageHelper.toFriendly(message.getRecipients(RecipientType.TO), contactHelper); - counterParty = to.toString(); - target.sender = new SpannableStringBuilder(context.getString(R.string.message_to_label)).append(to); - } else { - target.sender = MessageHelper.toFriendly(addrs, contactHelper); - counterParty = target.sender.toString(); - } - - if (addrs.length > 0) { - target.senderAddress = addrs[0].getAddress(); - } else { - // a reasonable fallback "whomever we were corresponding with - target.senderAddress = counterParty; - } - - target.uid = message.getUid(); - target.uri = message.getUri(); - - return target; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof MessageInfoHolder)) { - return false; - } - MessageInfoHolder other = (MessageInfoHolder)o; - return message.equals(other.message); - } - - @Override - public int hashCode() { - return uid.hashCode(); - } -} diff --git a/app/k9mail/src/main/java/com/fsck/k9/external/MessageProvider.java b/app/k9mail/src/main/java/com/fsck/k9/external/MessageProvider.java deleted file mode 100644 index 759744d9d1..0000000000 --- a/app/k9mail/src/main/java/com/fsck/k9/external/MessageProvider.java +++ /dev/null @@ -1,1046 +0,0 @@ -package com.fsck.k9.external; - - -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.Semaphore; -import java.util.concurrent.SynchronousQueue; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import android.annotation.TargetApi; -import android.content.ContentProvider; -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.content.UriMatcher; -import android.database.CharArrayBuffer; -import android.database.ContentObserver; -import android.database.CrossProcessCursor; -import android.database.Cursor; -import android.database.CursorWindow; -import android.database.DataSetObserver; -import android.database.MatrixCursor; -import android.net.Uri; -import android.os.Binder; -import android.os.Build; -import android.os.Bundle; -import android.provider.BaseColumns; - -import com.fsck.k9.Account; -import com.fsck.k9.BuildConfig; -import com.fsck.k9.DI; -import com.fsck.k9.Preferences; -import com.fsck.k9.controller.MessageReference; -import com.fsck.k9.controller.MessagingController; -import com.fsck.k9.controller.SimpleMessagingListener; -import com.fsck.k9.mail.Flag; -import com.fsck.k9.mail.Message; -import com.fsck.k9.mailstore.LocalMessage; -import com.fsck.k9.search.SearchAccount; -import timber.log.Timber; - - -public class MessageProvider extends ContentProvider { - public static String AUTHORITY = BuildConfig.APPLICATION_ID + ".messageprovider"; - public static Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY); - - private static final String[] DEFAULT_MESSAGE_PROJECTION = new String[] { - MessageColumns._ID, - MessageColumns.SEND_DATE, - MessageColumns.SENDER, - MessageColumns.SUBJECT, - MessageColumns.PREVIEW, - MessageColumns.ACCOUNT, - MessageColumns.URI, - MessageColumns.DELETE_URI, - MessageColumns.SENDER_ADDRESS - }; - private static final String[] DEFAULT_ACCOUNT_PROJECTION = new String[] { - AccountColumns.ACCOUNT_NUMBER, - AccountColumns.ACCOUNT_NAME, - }; - private static final String[] UNREAD_PROJECTION = new String[] { - UnreadColumns.ACCOUNT_NAME, - UnreadColumns.UNREAD - }; - - - private UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); - private List queryHandlers = new ArrayList<>(); - - /** - * How many simultaneous cursors we can afford to expose at once - */ - Semaphore semaphore = new Semaphore(1); - - ScheduledExecutorService scheduledPool = Executors.newScheduledThreadPool(1); - - - @Override - public boolean onCreate() { - registerQueryHandler(new ThrottlingQueryHandler(new AccountsQueryHandler())); - registerQueryHandler(new ThrottlingQueryHandler(new MessagesQueryHandler())); - registerQueryHandler(new ThrottlingQueryHandler(new UnreadQueryHandler())); - - return true; - } - - public static void init() { - Timber.v("Registering content resolver notifier"); - - final Context context = DI.get(Context.class); - MessagingController messagingController = DI.get(MessagingController.class); - messagingController.addListener(new SimpleMessagingListener() { - @Override - public void folderStatusChanged(Account account, long folderId) { - context.getContentResolver().notifyChange(CONTENT_URI, null); - } - }); - } - - @Override - public String getType(Uri uri) { - Timber.v("MessageProvider/getType: %s", uri); - - return null; - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { - Timber.v("MessageProvider/query: %s", uri); - - int code = uriMatcher.match(uri); - if (code == -1) { - throw new IllegalStateException("Unrecognized URI: " + uri); - } - - Cursor cursor; - try { - QueryHandler handler = queryHandlers.get(code); - cursor = handler.query(uri, projection, selection, selectionArgs, sortOrder); - } catch (Exception e) { - Timber.e(e, "Unable to execute query for URI: %s", uri); - return null; - } - - return cursor; - } - - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - - Timber.v("MessageProvider/delete: %s", uri); - - // Note: can only delete a message - - List segments = uri.getPathSegments(); - int accountId = Integer.parseInt(segments.get(1)); - long folderId = Long.parseLong(segments.get(2)); - String msgUid = segments.get(3); - - // get account - Account myAccount = null; - for (Account account : Preferences.getPreferences().getAccounts()) { - if (account.getAccountNumber() == accountId) { - myAccount = account; - } - } - - if (myAccount == null) { - Timber.e("Could not find account with id %d", accountId); - } - - if (myAccount != null) { - MessageReference messageReference = new MessageReference(myAccount.getUuid(), folderId, msgUid); - MessagingController controller = MessagingController.getInstance(getContext()); - controller.deleteMessage(messageReference); - } - - // FIXME return the actual number of deleted messages - return 0; - } - - @Override - public Uri insert(Uri uri, ContentValues values) { - Timber.v("MessageProvider/insert: %s", uri); - - return null; - } - - @Override - public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { - Timber.v("MessageProvider/update: %s", uri); - - // TBD - - return 0; - } - - /** - * Register a {@link QueryHandler} to handle a certain {@link Uri} for - * {@link #query(Uri, String[], String, String[], String)} - */ - protected void registerQueryHandler(QueryHandler handler) { - if (queryHandlers.contains(handler)) { - return; - } - queryHandlers.add(handler); - - int code = queryHandlers.indexOf(handler); - uriMatcher.addURI(AUTHORITY, handler.getPath(), code); - } - - - public static class ReverseDateComparator implements Comparator { - @Override - public int compare(MessageInfoHolder object2, MessageInfoHolder object1) { - if (object1.compareDate == null) { - return (object2.compareDate == null ? 0 : 1); - } else if (object2.compareDate == null) { - return -1; - } else { - return object1.compareDate.compareTo(object2.compareDate); - } - } - } - - public interface MessageColumns extends BaseColumns { - /** - * The number of milliseconds since Jan. 1, 1970, midnight GMT. - * - *

Type: INTEGER (long)

- */ - String SEND_DATE = "date"; - - /** - *

Type: TEXT

- */ - String SENDER = "sender"; - - /** - *

Type: TEXT

- */ - String SENDER_ADDRESS = "senderAddress"; - - /** - *

Type: TEXT

- */ - String SUBJECT = "subject"; - - /** - *

Type: TEXT

- */ - String PREVIEW = "preview"; - - /** - *

Type: BOOLEAN

- */ - String UNREAD = "unread"; - - /** - *

Type: TEXT

- */ - String ACCOUNT = "account"; - - /** - *

Type: INTEGER

- */ - String ACCOUNT_NUMBER = "accountNumber"; - - /** - *

Type: BOOLEAN

- */ - String HAS_ATTACHMENTS = "hasAttachments"; - - /** - *

Type: BOOLEAN

- */ - String HAS_STAR = "hasStar"; - - /** - *

Type: INTEGER

- */ - String ACCOUNT_COLOR = "accountColor"; - - String URI = "uri"; - String DELETE_URI = "delUri"; - - /** - * @deprecated the field value is misnamed/misleading - present for compatibility purpose only. To be removed. - */ - @Deprecated - String INCREMENT = "id"; - } - - public interface AccountColumns { - /** - *

Type: INTEGER

- */ - String ACCOUNT_NUMBER = "accountNumber"; - /** - *

Type: String

- */ - String ACCOUNT_NAME = "accountName"; - - - String ACCOUNT_UUID = "accountUuid"; - String ACCOUNT_COLOR = "accountColor"; - } - - public interface UnreadColumns { - /** - *

Type: String

- */ - String ACCOUNT_NAME = "accountName"; - /** - *

Type: INTEGER

- */ - String UNREAD = "unread"; - } - - protected interface QueryHandler { - /** - * The path this instance is able to respond to. - */ - String getPath(); - - Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) - throws Exception; - } - - /** - * Extracts a value from an object. - */ - public interface FieldExtractor { - K getField(T source); - } - - /** - * Extracts the {@link LocalMessage#getDatabaseId() ID} from the given {@link MessageInfoHolder}. The underlying - * {@link Message} is expected to be a {@link LocalMessage}. - */ - public static class IdExtractor implements FieldExtractor { - @Override - public Long getField(MessageInfoHolder source) { - return source.message.getDatabaseId(); - } - } - - public static class CountExtractor implements FieldExtractor { - private Integer count; - - public CountExtractor(int count) { - this.count = count; - } - - @Override - public Integer getField(T source) { - return count; - } - } - - public static class SubjectExtractor implements FieldExtractor { - @Override - public String getField(MessageInfoHolder source) { - return source.message.getSubject(); - } - } - - public static class SendDateExtractor implements FieldExtractor { - @Override - public Long getField(MessageInfoHolder source) { - return source.message.getSentDate().getTime(); - } - } - - public static class PreviewExtractor implements FieldExtractor { - @Override - public String getField(MessageInfoHolder source) { - return source.message.getPreview(); - } - } - - public static class UriExtractor implements FieldExtractor { - @Override - public String getField(MessageInfoHolder source) { - return source.uri; - } - } - - public static class DeleteUriExtractor implements FieldExtractor { - @Override - public String getField(MessageInfoHolder source) { - LocalMessage message = source.message; - int accountNumber = message.getAccount().getAccountNumber(); - return CONTENT_URI.buildUpon() - .appendPath("delete_message") - .appendPath(Integer.toString(accountNumber)) - .appendPath(Long.toString(message.getFolder().getDatabaseId())) - .appendPath(message.getUid()) - .build() - .toString(); - } - } - - public static class SenderExtractor implements FieldExtractor { - @Override - public CharSequence getField(MessageInfoHolder source) { - return source.sender; - } - } - - public static class SenderAddressExtractor implements FieldExtractor { - @Override - public String getField(MessageInfoHolder source) { - return source.senderAddress; - } - } - - public static class AccountExtractor implements FieldExtractor { - @Override - public String getField(MessageInfoHolder source) { - return source.message.getAccount().getDisplayName(); - } - } - - public static class AccountColorExtractor implements FieldExtractor { - @Override - public Integer getField(MessageInfoHolder source) { - return source.message.getAccount().getChipColor(); - } - } - - public static class AccountNumberExtractor implements FieldExtractor { - @Override - public Integer getField(MessageInfoHolder source) { - return source.message.getAccount().getAccountNumber(); - } - } - - public static class HasAttachmentsExtractor implements FieldExtractor { - @Override - public Boolean getField(MessageInfoHolder source) { - return source.message.hasAttachments(); - } - } - - public static class HasStarExtractor implements FieldExtractor { - @Override - public Boolean getField(MessageInfoHolder source) { - return source.message.isSet(Flag.FLAGGED); - } - } - - public static class UnreadExtractor implements FieldExtractor { - @Override - public Boolean getField(MessageInfoHolder source) { - return !source.read; - } - } - - /** - * @deprecated having an incremental value has no real interest, implemented for compatibility only - */ - @Deprecated - public static class IncrementExtractor implements FieldExtractor { - private int count = 0; - - - @Override - public Integer getField(MessageInfoHolder source) { - return count++; - } - } - - /** - * Retrieve messages from the integrated inbox. - */ - protected class MessagesQueryHandler implements QueryHandler { - - @Override - public String getPath() { - return "inbox_messages/"; - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) - throws Exception { - return getMessages(projection); - } - - protected MatrixCursor getMessages(String[] projection) throws InterruptedException { - // new code for integrated inbox, only execute this once as it will be processed afterwards via the listener - SearchAccount integratedInboxAccount = SearchAccount.createUnifiedInboxAccount(); - MessagingController msgController = MessagingController.getInstance(getContext()); - - List messages = msgController.searchLocalMessages(integratedInboxAccount.getRelatedSearch()); - List holders = convertToMessageInfoHolder(messages); - - // TODO add sort order parameter - Collections.sort(holders, new ReverseDateComparator()); - - String[] projectionToUse; - if (projection == null) { - projectionToUse = DEFAULT_MESSAGE_PROJECTION; - } else { - projectionToUse = projection; - } - - LinkedHashMap> extractors = - resolveMessageExtractors(projectionToUse, holders.size()); - int fieldCount = extractors.size(); - - String[] actualProjection = extractors.keySet().toArray(new String[fieldCount]); - MatrixCursor cursor = new MatrixCursor(actualProjection); - - for (MessageInfoHolder holder : holders) { - Object[] o = new Object[fieldCount]; - - int i = 0; - for (FieldExtractor extractor : extractors.values()) { - o[i] = extractor.getField(holder); - i += 1; - } - - cursor.addRow(o); - } - - return cursor; - } - - private List convertToMessageInfoHolder(List messages) { - List holders = new ArrayList<>(); - - Context context = getContext(); - for (LocalMessage message : messages) { - Account messageAccount = message.getAccount(); - MessageInfoHolder messageInfoHolder = MessageInfoHolder.create(context, message, messageAccount); - - holders.add(messageInfoHolder); - } - - return holders; - } - - protected LinkedHashMap> resolveMessageExtractors( - String[] projection, int count) { - LinkedHashMap> extractors = new LinkedHashMap<>(); - - for (String field : projection) { - if (extractors.containsKey(field)) { - continue; - } - if (MessageColumns._ID.equals(field)) { - extractors.put(field, new IdExtractor()); - } else if (MessageColumns._COUNT.equals(field)) { - extractors.put(field, new CountExtractor<>(count)); - } else if (MessageColumns.SUBJECT.equals(field)) { - extractors.put(field, new SubjectExtractor()); - } else if (MessageColumns.SENDER.equals(field)) { - extractors.put(field, new SenderExtractor()); - } else if (MessageColumns.SENDER_ADDRESS.equals(field)) { - extractors.put(field, new SenderAddressExtractor()); - } else if (MessageColumns.SEND_DATE.equals(field)) { - extractors.put(field, new SendDateExtractor()); - } else if (MessageColumns.PREVIEW.equals(field)) { - extractors.put(field, new PreviewExtractor()); - } else if (MessageColumns.URI.equals(field)) { - extractors.put(field, new UriExtractor()); - } else if (MessageColumns.DELETE_URI.equals(field)) { - extractors.put(field, new DeleteUriExtractor()); - } else if (MessageColumns.UNREAD.equals(field)) { - extractors.put(field, new UnreadExtractor()); - } else if (MessageColumns.ACCOUNT.equals(field)) { - extractors.put(field, new AccountExtractor()); - } else if (MessageColumns.ACCOUNT_COLOR.equals(field)) { - extractors.put(field, new AccountColorExtractor()); - } else if (MessageColumns.ACCOUNT_NUMBER.equals(field)) { - extractors.put(field, new AccountNumberExtractor()); - } else if (MessageColumns.HAS_ATTACHMENTS.equals(field)) { - extractors.put(field, new HasAttachmentsExtractor()); - } else if (MessageColumns.HAS_STAR.equals(field)) { - extractors.put(field, new HasStarExtractor()); - } else if (MessageColumns.INCREMENT.equals(field)) { - extractors.put(field, new IncrementExtractor()); - } - } - return extractors; - } - } - - /** - * Retrieve the account list. - */ - protected class AccountsQueryHandler implements QueryHandler { - - - @Override - public String getPath() { - return "accounts"; - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) - throws Exception { - return getAllAccounts(projection); - } - - public Cursor getAllAccounts(String[] projection) { - if (projection == null) { - projection = DEFAULT_ACCOUNT_PROJECTION; - } - - MatrixCursor cursor = new MatrixCursor(projection); - - for (Account account : Preferences.getPreferences().getAccounts()) { - Object[] values = new Object[projection.length]; - - int fieldIndex = 0; - for (String field : projection) { - if (AccountColumns.ACCOUNT_NUMBER.equals(field)) { - values[fieldIndex] = account.getAccountNumber(); - } else if (AccountColumns.ACCOUNT_NAME.equals(field)) { - values[fieldIndex] = account.getDisplayName(); - } else if (AccountColumns.ACCOUNT_UUID.equals(field)) { - values[fieldIndex] = account.getUuid(); - } else if (AccountColumns.ACCOUNT_COLOR.equals(field)) { - values[fieldIndex] = account.getChipColor(); - } else { - values[fieldIndex] = null; - } - ++fieldIndex; - } - - cursor.addRow(values); - } - - return cursor; - } - } - - /** - * Retrieve the unread message count for a given account specified by its {@link Account#getAccountNumber() number}. - */ - protected class UnreadQueryHandler implements QueryHandler { - - @Override - public String getPath() { - return "account_unread/#"; - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) - throws Exception { - List segments = uri.getPathSegments(); - int accountId = Integer.parseInt(segments.get(1)); - - /* - * This method below calls Account.getStats() which uses EmailProvider to do its work. - * For this to work we need to clear the calling identity. Otherwise accessing - * EmailProvider will fail because it's not exported so third-party apps can't access it - * directly. - */ - long identityToken = Binder.clearCallingIdentity(); - try { - return getAccountUnread(accountId); - } finally { - Binder.restoreCallingIdentity(identityToken); - } - } - - private Cursor getAccountUnread(int accountNumber) { - - MatrixCursor cursor = new MatrixCursor(UNREAD_PROJECTION); - - Account myAccount; - - Object[] values = new Object[2]; - - Context context = getContext(); - MessagingController controller = MessagingController.getInstance(context); - Collection accounts = Preferences.getPreferences().getAccounts(); - - for (Account account : accounts) { - if (account.getAccountNumber() == accountNumber) { - myAccount = account; - values[0] = myAccount.getDisplayName(); - values[1] = controller.getUnreadMessageCount(account); - cursor.addRow(values); - } - } - - return cursor; - } - } - - /** - * Cursor wrapper that release a semaphore on close. Close is also triggered on {@link #finalize()}. - */ - protected static class MonitoredCursor implements CrossProcessCursor { - /** - * The underlying cursor implementation that handles regular requests - */ - private CrossProcessCursor cursor; - - /** - * Whether {@link #close()} was invoked - */ - private AtomicBoolean closed = new AtomicBoolean(false); - - private Semaphore semaphore; - - - protected MonitoredCursor(CrossProcessCursor cursor, Semaphore semaphore) { - this.cursor = cursor; - this.semaphore = semaphore; - } - - @Override - public void close() { - if (closed.compareAndSet(false, true)) { - cursor.close(); - Timber.d("Cursor closed, null'ing & releasing semaphore"); - cursor = null; - semaphore.release(); - } - } - - @Override - public boolean isClosed() { - return closed.get() || cursor.isClosed(); - } - - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - protected void checkClosed() throws IllegalStateException { - if (closed.get()) { - throw new IllegalStateException("Cursor was closed"); - } - } - - @Override - public void fillWindow(int pos, CursorWindow winow) { - checkClosed(); - cursor.fillWindow(pos, winow); - } - - @Override - public CursorWindow getWindow() { - checkClosed(); - return cursor.getWindow(); - } - - @Override - public boolean onMove(int oldPosition, int newPosition) { - checkClosed(); - return cursor.onMove(oldPosition, newPosition); - } - - @Override - public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) { - checkClosed(); - cursor.copyStringToBuffer(columnIndex, buffer); - } - - @Override - public void deactivate() { - checkClosed(); - cursor.deactivate(); - } - - @Override - public byte[] getBlob(int columnIndex) { - checkClosed(); - return cursor.getBlob(columnIndex); - } - - @Override - public int getColumnCount() { - checkClosed(); - return cursor.getColumnCount(); - } - - @Override - public int getColumnIndex(String columnName) { - checkClosed(); - return cursor.getColumnIndex(columnName); - } - - @Override - public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException { - checkClosed(); - return cursor.getColumnIndexOrThrow(columnName); - } - - @Override - public String getColumnName(int columnIndex) { - checkClosed(); - return cursor.getColumnName(columnIndex); - } - - @Override - public String[] getColumnNames() { - checkClosed(); - return cursor.getColumnNames(); - } - - @Override - public int getCount() { - checkClosed(); - return cursor.getCount(); - } - - @Override - public double getDouble(int columnIndex) { - checkClosed(); - return cursor.getDouble(columnIndex); - } - - @Override - public Bundle getExtras() { - checkClosed(); - return cursor.getExtras(); - } - - @Override - public float getFloat(int columnIndex) { - checkClosed(); - return cursor.getFloat(columnIndex); - } - - @Override - public int getInt(int columnIndex) { - checkClosed(); - return cursor.getInt(columnIndex); - } - - @Override - public long getLong(int columnIndex) { - checkClosed(); - return cursor.getLong(columnIndex); - } - - @Override - public int getPosition() { - checkClosed(); - return cursor.getPosition(); - } - - @Override - public short getShort(int columnIndex) { - checkClosed(); - return cursor.getShort(columnIndex); - } - - @Override - public String getString(int columnIndex) { - checkClosed(); - return cursor.getString(columnIndex); - } - - @Override - public boolean getWantsAllOnMoveCalls() { - checkClosed(); - return cursor.getWantsAllOnMoveCalls(); - } - - @TargetApi(Build.VERSION_CODES.M) - @Override - public void setExtras(Bundle extras) { - cursor.setExtras(extras); - } - - @Override - public boolean isAfterLast() { - checkClosed(); - return cursor.isAfterLast(); - } - - @Override - public boolean isBeforeFirst() { - checkClosed(); - return cursor.isBeforeFirst(); - } - - @Override - public boolean isFirst() { - checkClosed(); - return cursor.isFirst(); - } - - @Override - public boolean isLast() { - checkClosed(); - return cursor.isLast(); - } - - @Override - public boolean isNull(int columnIndex) { - checkClosed(); - return cursor.isNull(columnIndex); - } - - @Override - public boolean move(int offset) { - checkClosed(); - return cursor.move(offset); - } - - @Override - public boolean moveToFirst() { - checkClosed(); - return cursor.moveToFirst(); - } - - @Override - public boolean moveToLast() { - checkClosed(); - return cursor.moveToLast(); - } - - @Override - public boolean moveToNext() { - checkClosed(); - return cursor.moveToNext(); - } - - @Override - public boolean moveToPosition(int position) { - checkClosed(); - return cursor.moveToPosition(position); - } - - @Override - public boolean moveToPrevious() { - checkClosed(); - return cursor.moveToPrevious(); - } - - @Override - public void registerContentObserver(ContentObserver observer) { - checkClosed(); - cursor.registerContentObserver(observer); - } - - @Override - public void registerDataSetObserver(DataSetObserver observer) { - checkClosed(); - cursor.registerDataSetObserver(observer); - } - - @SuppressWarnings("deprecation") - @Override - public boolean requery() { - checkClosed(); - return cursor.requery(); - } - - @Override - public Bundle respond(Bundle extras) { - checkClosed(); - return cursor.respond(extras); - } - - @Override - public void setNotificationUri(ContentResolver cr, Uri uri) { - checkClosed(); - cursor.setNotificationUri(cr, uri); - } - - @Override - public void unregisterContentObserver(ContentObserver observer) { - checkClosed(); - cursor.unregisterContentObserver(observer); - } - - @Override - public void unregisterDataSetObserver(DataSetObserver observer) { - checkClosed(); - cursor.unregisterDataSetObserver(observer); - } - - @Override - public int getType(int columnIndex) { - checkClosed(); - return cursor.getType(columnIndex); - } - - @Override - public Uri getNotificationUri() { - return null; - } - } - - protected class ThrottlingQueryHandler implements QueryHandler { - private QueryHandler delegate; - - - public ThrottlingQueryHandler(QueryHandler delegate) { - this.delegate = delegate; - } - - @Override - public String getPath() { - return delegate.getPath(); - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) - throws Exception { - semaphore.acquire(); - - Cursor cursor = null; - try { - cursor = delegate.query(uri, projection, selection, selectionArgs, sortOrder); - } finally { - if (cursor == null) { - semaphore.release(); - } - } - - // Android content resolvers can only process CrossProcessCursor instances - if (!(cursor instanceof CrossProcessCursor)) { - Timber.w("Unsupported cursor, returning null: %s", cursor); - semaphore.release(); - return null; - } - - MonitoredCursor wrapped = new MonitoredCursor((CrossProcessCursor) cursor, semaphore); - - // Use a weak reference not to actively prevent garbage collection - final WeakReference weakReference = new WeakReference<>(wrapped); - - // Make sure the cursor is closed after 30 seconds - scheduledPool.schedule(new Runnable() { - - @Override - public void run() { - MonitoredCursor monitored = weakReference.get(); - if (monitored != null && !monitored.isClosed()) { - Timber.w("Forcibly closing remotely exposed cursor"); - try { - monitored.close(); - } catch (Exception e) { - Timber.w(e, "Exception while forcibly closing cursor"); - } - } - } - }, 30, TimeUnit.SECONDS); - - return wrapped; - } - } -} -- GitLab From c9d89657b04dae56f0cc99e2a0270757759f23f4 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 17 Oct 2022 17:52:11 +0200 Subject: [PATCH 056/121] Create a separate Gradle module for the message list widget --- app/k9mail/build.gradle | 1 + app/k9mail/src/main/AndroidManifest.xml | 7 +-- .../src/main/java/com/fsck/k9/Dependencies.kt | 4 +- app/ui/message-list-widget/build.gradle | 44 ++++++++++++++++++ .../src/main/AndroidManifest.xml | 11 +++++ .../app/k9mail/ui}/widget/list/KoinModule.kt | 2 +- .../ui}/widget/list/MessageListConfig.kt | 2 +- .../k9mail/ui}/widget/list/MessageListItem.kt | 2 +- .../ui}/widget/list/MessageListItemMapper.kt | 2 +- .../ui}/widget/list/MessageListLoader.kt | 2 +- .../list/MessageListRemoteViewFactory.kt | 3 +- .../widget/list/MessageListWidgetProvider.kt | 8 ++-- .../widget/list/MessageListWidgetService.kt | 2 +- .../list/MessageListWidgetUpdateListener.kt | 2 +- .../message_list_widget_preview.png | Bin .../res/layout/message_list_widget_layout.xml | 0 .../layout/message_list_widget_list_item.xml | 0 .../layout/message_list_widget_loading.xml | 0 .../src/main/res/values/colors.xml} | 0 .../main/res/xml/message_list_widget_info.xml | 0 settings.gradle | 1 + 21 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 app/ui/message-list-widget/build.gradle create mode 100644 app/ui/message-list-widget/src/main/AndroidManifest.xml rename app/{k9mail/src/main/java/com/fsck/k9 => ui/message-list-widget/src/main/java/app/k9mail/ui}/widget/list/KoinModule.kt (87%) rename app/{k9mail/src/main/java/com/fsck/k9 => ui/message-list-widget/src/main/java/app/k9mail/ui}/widget/list/MessageListConfig.kt (89%) rename app/{k9mail/src/main/java/com/fsck/k9 => ui/message-list-widget/src/main/java/app/k9mail/ui}/widget/list/MessageListItem.kt (93%) rename app/{k9mail/src/main/java/com/fsck/k9 => ui/message-list-widget/src/main/java/app/k9mail/ui}/widget/list/MessageListItemMapper.kt (98%) rename app/{k9mail/src/main/java/com/fsck/k9 => ui/message-list-widget/src/main/java/app/k9mail/ui}/widget/list/MessageListLoader.kt (99%) rename app/{k9mail/src/main/java/com/fsck/k9 => ui/message-list-widget/src/main/java/app/k9mail/ui}/widget/list/MessageListRemoteViewFactory.kt (98%) rename app/{k9mail/src/main/java/com/fsck/k9 => ui/message-list-widget/src/main/java/app/k9mail/ui}/widget/list/MessageListWidgetProvider.kt (94%) rename app/{k9mail/src/main/java/com/fsck/k9 => ui/message-list-widget/src/main/java/app/k9mail/ui}/widget/list/MessageListWidgetService.kt (88%) rename app/{k9mail/src/main/java/com/fsck/k9 => ui/message-list-widget/src/main/java/app/k9mail/ui}/widget/list/MessageListWidgetUpdateListener.kt (96%) rename app/{k9mail => ui/message-list-widget}/src/main/res/drawable-xxhdpi/message_list_widget_preview.png (100%) rename app/{k9mail => ui/message-list-widget}/src/main/res/layout/message_list_widget_layout.xml (100%) rename app/{k9mail => ui/message-list-widget}/src/main/res/layout/message_list_widget_list_item.xml (100%) rename app/{k9mail => ui/message-list-widget}/src/main/res/layout/message_list_widget_loading.xml (100%) rename app/{k9mail/src/main/res/values/message_list_widget_colors.xml => ui/message-list-widget/src/main/res/values/colors.xml} (100%) rename app/{k9mail => ui/message-list-widget}/src/main/res/xml/message_list_widget_info.xml (100%) diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index ee9a92a2be..e42f384922 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -7,6 +7,7 @@ if (rootProject.testCoverage) { dependencies { implementation project(":app:ui:legacy") + implementation project(":app:ui:message-list-widget") implementation project(":app:core") implementation project(":app:storage") implementation project(":app:crypto-openpgp") diff --git a/app/k9mail/src/main/AndroidManifest.xml b/app/k9mail/src/main/AndroidManifest.xml index a15231f834..9ce40d9114 100644 --- a/app/k9mail/src/main/AndroidManifest.xml +++ b/app/k9mail/src/main/AndroidManifest.xml @@ -315,7 +315,7 @@ @@ -336,11 +336,6 @@ - - diff --git a/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt b/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt index 4f3aeb00ab..94953b6fbf 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt @@ -1,5 +1,7 @@ package com.fsck.k9 +import app.k9mail.ui.widget.list.MessageListWidgetUpdateListener +import app.k9mail.ui.widget.list.messageListWidgetModule import com.fsck.k9.auth.createOAuthConfigurationProvider import com.fsck.k9.backends.backendsModule import com.fsck.k9.controller.ControllerExtension @@ -10,8 +12,6 @@ import com.fsck.k9.preferences.K9StoragePersister import com.fsck.k9.preferences.StoragePersister import com.fsck.k9.resources.resourcesModule import com.fsck.k9.storage.storageModule -import com.fsck.k9.widget.list.MessageListWidgetUpdateListener -import com.fsck.k9.widget.list.messageListWidgetModule import com.fsck.k9.widget.unread.UnreadWidgetUpdateListener import com.fsck.k9.widget.unread.unreadWidgetModule import org.koin.core.qualifier.named diff --git a/app/ui/message-list-widget/build.gradle b/app/ui/message-list-widget/build.gradle new file mode 100644 index 0000000000..b22a598965 --- /dev/null +++ b/app/ui/message-list-widget/build.gradle @@ -0,0 +1,44 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +dependencies { + implementation project(":app:ui:legacy") + implementation project(":app:core") + + implementation "com.jakewharton.timber:timber:${versions.timber}" +} + +android { + namespace 'app.k9mail.ui.widget.list' + + compileSdkVersion buildConfig.compileSdk + buildToolsVersion buildConfig.buildTools + + defaultConfig { + minSdkVersion buildConfig.minSdk + targetSdkVersion buildConfig.robolectricSdk + } + + buildTypes { + debug { + manifestPlaceholders = ['appAuthRedirectScheme': 'FIXME: override this in your app project'] + } + release { + manifestPlaceholders = ['appAuthRedirectScheme': 'FIXME: override this in your app project'] + } + } + + lintOptions { + abortOnError false + lintConfig file("$rootProject.projectDir/config/lint/lint.xml") + } + + compileOptions { + sourceCompatibility javaVersion + targetCompatibility javaVersion + } + + kotlinOptions { + jvmTarget = kotlinJvmVersion + } +} diff --git a/app/ui/message-list-widget/src/main/AndroidManifest.xml b/app/ui/message-list-widget/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..26b1f44446 --- /dev/null +++ b/app/ui/message-list-widget/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/KoinModule.kt similarity index 87% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt rename to app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/KoinModule.kt index 05046bdb78..ff571e44eb 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/KoinModule.kt @@ -1,4 +1,4 @@ -package com.fsck.k9.widget.list +package app.k9mail.ui.widget.list import org.koin.dsl.module diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListConfig.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListConfig.kt similarity index 89% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListConfig.kt rename to app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListConfig.kt index efe435bea4..ba9d726ef0 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListConfig.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListConfig.kt @@ -1,4 +1,4 @@ -package com.fsck.k9.widget.list +package app.k9mail.ui.widget.list import com.fsck.k9.Account.SortType import com.fsck.k9.search.LocalSearch diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItem.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt similarity index 93% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItem.kt rename to app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt index fe79f1f4db..ecedb63349 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItem.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt @@ -1,4 +1,4 @@ -package com.fsck.k9.widget.list +package app.k9mail.ui.widget.list import android.net.Uri diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItemMapper.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt similarity index 98% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItemMapper.kt rename to app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt index b7ad94a338..35efd356eb 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListItemMapper.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt @@ -1,4 +1,4 @@ -package com.fsck.k9.widget.list +package app.k9mail.ui.widget.list import android.net.Uri import com.fsck.k9.Account diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListLoader.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListLoader.kt similarity index 99% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListLoader.kt rename to app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListLoader.kt index dd32d8306c..b80f3a38b6 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListLoader.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListLoader.kt @@ -1,4 +1,4 @@ -package com.fsck.k9.widget.list +package app.k9mail.ui.widget.list import com.fsck.k9.Account import com.fsck.k9.Account.SortType diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt similarity index 98% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt rename to app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt index 084733655f..0e5566d929 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt @@ -1,4 +1,4 @@ -package com.fsck.k9.widget.list +package app.k9mail.ui.widget.list import android.content.Context import android.content.Intent @@ -11,7 +11,6 @@ import android.widget.RemoteViewsService.RemoteViewsFactory import androidx.core.content.ContextCompat import com.fsck.k9.Account.SortType import com.fsck.k9.K9 -import com.fsck.k9.R import com.fsck.k9.search.LocalSearch import com.fsck.k9.search.SearchAccount import org.koin.core.component.KoinComponent diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt similarity index 94% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt rename to app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt index 9f5e730bc1..7860d88393 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt @@ -1,4 +1,4 @@ -package com.fsck.k9.widget.list +package app.k9mail.ui.widget.list import android.app.PendingIntent import android.appwidget.AppWidgetManager @@ -8,15 +8,15 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.widget.RemoteViews -import com.fsck.k9.R import com.fsck.k9.activity.MessageCompose import com.fsck.k9.activity.MessageList import com.fsck.k9.activity.MessageList.Companion.intentDisplaySearch import com.fsck.k9.helper.PendingIntentCompat.FLAG_IMMUTABLE import com.fsck.k9.helper.PendingIntentCompat.FLAG_MUTABLE import com.fsck.k9.search.SearchAccount.Companion.createUnifiedInboxAccount +import com.fsck.k9.ui.R as UiR -class MessageListWidgetProvider : AppWidgetProvider() { +open class MessageListWidgetProvider : AppWidgetProvider() { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { for (appWidgetId in appWidgetIds) { updateAppWidget(context, appWidgetManager, appWidgetId) @@ -26,7 +26,7 @@ class MessageListWidgetProvider : AppWidgetProvider() { private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { val views = RemoteViews(context.packageName, R.layout.message_list_widget_layout) - views.setTextViewText(R.id.folder, context.getString(com.fsck.k9.ui.R.string.integrated_inbox_title)) + views.setTextViewText(R.id.folder, context.getString(UiR.string.integrated_inbox_title)) val intent = Intent(context, MessageListWidgetService::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetService.kt similarity index 88% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.kt rename to app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetService.kt index 137d8646cc..09a82e2524 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetService.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetService.kt @@ -1,4 +1,4 @@ -package com.fsck.k9.widget.list +package app.k9mail.ui.widget.list import android.content.Intent import android.widget.RemoteViewsService diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetUpdateListener.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetUpdateListener.kt similarity index 96% rename from app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetUpdateListener.kt rename to app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetUpdateListener.kt index 4a84ff2750..cccb26c19d 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetUpdateListener.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetUpdateListener.kt @@ -1,4 +1,4 @@ -package com.fsck.k9.widget.list +package app.k9mail.ui.widget.list import android.content.Context import com.fsck.k9.Account diff --git a/app/k9mail/src/main/res/drawable-xxhdpi/message_list_widget_preview.png b/app/ui/message-list-widget/src/main/res/drawable-xxhdpi/message_list_widget_preview.png similarity index 100% rename from app/k9mail/src/main/res/drawable-xxhdpi/message_list_widget_preview.png rename to app/ui/message-list-widget/src/main/res/drawable-xxhdpi/message_list_widget_preview.png diff --git a/app/k9mail/src/main/res/layout/message_list_widget_layout.xml b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_layout.xml similarity index 100% rename from app/k9mail/src/main/res/layout/message_list_widget_layout.xml rename to app/ui/message-list-widget/src/main/res/layout/message_list_widget_layout.xml diff --git a/app/k9mail/src/main/res/layout/message_list_widget_list_item.xml b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item.xml similarity index 100% rename from app/k9mail/src/main/res/layout/message_list_widget_list_item.xml rename to app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item.xml diff --git a/app/k9mail/src/main/res/layout/message_list_widget_loading.xml b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_loading.xml similarity index 100% rename from app/k9mail/src/main/res/layout/message_list_widget_loading.xml rename to app/ui/message-list-widget/src/main/res/layout/message_list_widget_loading.xml diff --git a/app/k9mail/src/main/res/values/message_list_widget_colors.xml b/app/ui/message-list-widget/src/main/res/values/colors.xml similarity index 100% rename from app/k9mail/src/main/res/values/message_list_widget_colors.xml rename to app/ui/message-list-widget/src/main/res/values/colors.xml diff --git a/app/k9mail/src/main/res/xml/message_list_widget_info.xml b/app/ui/message-list-widget/src/main/res/xml/message_list_widget_info.xml similarity index 100% rename from app/k9mail/src/main/res/xml/message_list_widget_info.xml rename to app/ui/message-list-widget/src/main/res/xml/message_list_widget_info.xml diff --git a/settings.gradle b/settings.gradle index 37458009bf..c6d5c75343 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,6 +2,7 @@ include ':app:k9mail' include ':app:ui:base' include ':app:ui:setup' include ':app:ui:legacy' +include ':app:ui:message-list-widget' include ':app:core' include ':app:storage' include ':app:crypto-openpgp' -- GitLab From 2640c0e0a7fd9d62142e126b0d6874f7d6be6a44 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 17 Oct 2022 19:17:51 +0200 Subject: [PATCH 057/121] Retain fully-qualified name of `MessageListWidgetProvider` in the manifest --- app/k9mail/src/main/AndroidManifest.xml | 2 +- app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt | 2 ++ .../main/java/com/fsck/k9/widget/list/KoinModule.kt | 8 ++++++++ .../fsck/k9/widget/list/MessageListWidgetProvider.kt | 9 +++++++++ .../k9mail/ui/widget/list/MessageListWidgetConfig.kt | 5 +++++ .../ui/widget/list/MessageListWidgetProvider.kt | 11 ++++++++--- 6 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt create mode 100644 app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetConfig.kt diff --git a/app/k9mail/src/main/AndroidManifest.xml b/app/k9mail/src/main/AndroidManifest.xml index 9ce40d9114..9f9169d075 100644 --- a/app/k9mail/src/main/AndroidManifest.xml +++ b/app/k9mail/src/main/AndroidManifest.xml @@ -315,7 +315,7 @@ diff --git a/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt b/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt index 94953b6fbf..d75a41aa7b 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt @@ -12,6 +12,7 @@ import com.fsck.k9.preferences.K9StoragePersister import com.fsck.k9.preferences.StoragePersister import com.fsck.k9.resources.resourcesModule import com.fsck.k9.storage.storageModule +import com.fsck.k9.widget.list.messageListWidgetConfigModule import com.fsck.k9.widget.unread.UnreadWidgetUpdateListener import com.fsck.k9.widget.unread.unreadWidgetModule import org.koin.core.qualifier.named @@ -35,6 +36,7 @@ private val mainAppModule = module { val appModules = listOf( mainAppModule, + messageListWidgetConfigModule, messageListWidgetModule, unreadWidgetModule, notificationModule, diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt new file mode 100644 index 0000000000..c7cebde8fe --- /dev/null +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.widget.list + +import app.k9mail.ui.widget.list.MessageListWidgetConfig +import org.koin.dsl.module + +val messageListWidgetConfigModule = module { + single { K9MessageListWidgetConfig() } +} diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt new file mode 100644 index 0000000000..805be2eb13 --- /dev/null +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt @@ -0,0 +1,9 @@ +package com.fsck.k9.widget.list + +import app.k9mail.ui.widget.list.MessageListWidgetConfig + +class MessageListWidgetProvider : app.k9mail.ui.widget.list.MessageListWidgetProvider() + +internal class K9MessageListWidgetConfig : MessageListWidgetConfig { + override val providerClass = MessageListWidgetProvider::class.java +} diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetConfig.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetConfig.kt new file mode 100644 index 0000000000..5fc1442f54 --- /dev/null +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetConfig.kt @@ -0,0 +1,5 @@ +package app.k9mail.ui.widget.list + +interface MessageListWidgetConfig { + val providerClass: Class +} diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt index 7860d88393..21b87856c3 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt @@ -14,6 +14,8 @@ import com.fsck.k9.activity.MessageList.Companion.intentDisplaySearch import com.fsck.k9.helper.PendingIntentCompat.FLAG_IMMUTABLE import com.fsck.k9.helper.PendingIntentCompat.FLAG_MUTABLE import com.fsck.k9.search.SearchAccount.Companion.createUnifiedInboxAccount +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import com.fsck.k9.ui.R as UiR open class MessageListWidgetProvider : AppWidgetProvider() { @@ -84,16 +86,19 @@ open class MessageListWidgetProvider : AppWidgetProvider() { return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) } - companion object { + companion object : KoinComponent { private const val ACTION_UPDATE_MESSAGE_LIST = "UPDATE_MESSAGE_LIST" + private val messageListWidgetConfig: MessageListWidgetConfig by inject() + fun triggerMessageListWidgetUpdate(context: Context) { val appContext = context.applicationContext val widgetManager = AppWidgetManager.getInstance(appContext) - val widget = ComponentName(appContext, MessageListWidgetProvider::class.java) + val providerClass = messageListWidgetConfig.providerClass + val widget = ComponentName(appContext, providerClass) val widgetIds = widgetManager.getAppWidgetIds(widget) - val intent = Intent(context, MessageListWidgetProvider::class.java).apply { + val intent = Intent(context, providerClass).apply { action = ACTION_UPDATE_MESSAGE_LIST putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetIds) } -- GitLab From 343ed16ae06d2ef6012307f6cdae0dec95a0d11b Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 18 Oct 2022 15:16:36 +0200 Subject: [PATCH 058/121] Update the message list widget on app start Now that we've changed the component name of `MessageListWidgetService` this is necessary so widget hosts learn of the new name. It's also a good idea in general to update all RemoteViews on app startup (since the app might have been updated). --- app/k9mail/src/main/java/com/fsck/k9/App.kt | 2 ++ .../widget/list/MessageListWidgetProvider.kt | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/k9mail/src/main/java/com/fsck/k9/App.kt b/app/k9mail/src/main/java/com/fsck/k9/App.kt index caee3d4373..9453de2d75 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/App.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/App.kt @@ -3,6 +3,7 @@ package com.fsck.k9 import android.app.Application import android.content.res.Configuration import android.content.res.Resources +import app.k9mail.ui.widget.list.MessageListWidgetProvider import com.fsck.k9.activity.MessageCompose import com.fsck.k9.controller.MessagingController import com.fsck.k9.notification.NotificationChannelManager @@ -42,6 +43,7 @@ class App : Application() { initializeAppLanguage() updateNotificationChannelsOnAppLanguageChanges() themeManager.init() + MessageListWidgetProvider.init(this) messagingListenerProvider.listeners.forEach { listener -> messagingController.addListener(listener) diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt index 21b87856c3..b2323c8d7d 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt @@ -91,6 +91,24 @@ open class MessageListWidgetProvider : AppWidgetProvider() { private val messageListWidgetConfig: MessageListWidgetConfig by inject() + fun init(context: Context) { + resetMessageListWidget(context) + } + + private fun resetMessageListWidget(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + + val providerClass = messageListWidgetConfig.providerClass + val componentName = ComponentName(context, providerClass) + val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) + + val intent = Intent(context, providerClass).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds) + } + context.sendBroadcast(intent) + } + fun triggerMessageListWidgetUpdate(context: Context) { val appContext = context.applicationContext val widgetManager = AppWidgetManager.getInstance(appContext) -- GitLab From 0482b93071f29ad18289a949736bfe2563bd579e Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 18 Oct 2022 15:18:35 +0200 Subject: [PATCH 059/121] Simplify the code to notify the message list widget of changes --- .../widget/list/MessageListWidgetProvider.kt | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt index b2323c8d7d..dd65557704 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt @@ -48,17 +48,6 @@ open class MessageListWidgetProvider : AppWidgetProvider() { appWidgetManager.updateAppWidget(appWidgetId, views) } - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - - if (intent.action == ACTION_UPDATE_MESSAGE_LIST) { - val appWidgetManager = AppWidgetManager.getInstance(context) - val appWidgetIds = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS) - - appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.listView) - } - } - private fun viewActionTemplatePendingIntent(context: Context): PendingIntent { val intent = Intent(context, MessageList::class.java).apply { action = Intent.ACTION_VIEW @@ -87,8 +76,6 @@ open class MessageListWidgetProvider : AppWidgetProvider() { } companion object : KoinComponent { - private const val ACTION_UPDATE_MESSAGE_LIST = "UPDATE_MESSAGE_LIST" - private val messageListWidgetConfig: MessageListWidgetConfig by inject() fun init(context: Context) { @@ -110,18 +97,13 @@ open class MessageListWidgetProvider : AppWidgetProvider() { } fun triggerMessageListWidgetUpdate(context: Context) { - val appContext = context.applicationContext - val widgetManager = AppWidgetManager.getInstance(appContext) + val appWidgetManager = AppWidgetManager.getInstance(context) val providerClass = messageListWidgetConfig.providerClass - val widget = ComponentName(appContext, providerClass) - val widgetIds = widgetManager.getAppWidgetIds(widget) - val intent = Intent(context, providerClass).apply { - action = ACTION_UPDATE_MESSAGE_LIST - putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetIds) - } + val componentName = ComponentName(context, providerClass) + val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) - context.sendBroadcast(intent) + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.listView) } } } -- GitLab From 98b75c01b2e063b28dbe72f7f91d676e987e6ec1 Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 18 Oct 2022 15:20:12 +0200 Subject: [PATCH 060/121] Simplify intent to start `MailListWidgetService` --- .../app/k9mail/ui/widget/list/MessageListWidgetProvider.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt index dd65557704..a1ea3ff25d 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt @@ -6,7 +6,6 @@ import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context import android.content.Intent -import android.net.Uri import android.widget.RemoteViews import com.fsck.k9.activity.MessageCompose import com.fsck.k9.activity.MessageList @@ -30,10 +29,7 @@ open class MessageListWidgetProvider : AppWidgetProvider() { views.setTextViewText(R.id.folder, context.getString(UiR.string.integrated_inbox_title)) - val intent = Intent(context, MessageListWidgetService::class.java).apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) - data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) - } + val intent = Intent(context, MessageListWidgetService::class.java) views.setRemoteAdapter(R.id.listView, intent) val viewAction = viewActionTemplatePendingIntent(context) -- GitLab From b028c863977931160e2ba2b1550840b997bd6389 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 17 Oct 2022 21:16:29 +0200 Subject: [PATCH 061/121] Add thread count to message list widget layout --- .../k9mail/ui/widget/list/MessageListItem.kt | 1 + .../ui/widget/list/MessageListItemMapper.kt | 1 + .../widget/list/MessageListRemoteViewFactory.kt | 7 +++++++ .../layout/message_list_widget_list_item.xml | 17 ++++++++++++++++- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt index ecedb63349..9ebfab398e 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt @@ -9,6 +9,7 @@ internal data class MessageListItem( val preview: String, val isRead: Boolean, val hasAttachments: Boolean, + val threadCount: Int, val uri: Uri, val accountColor: Int, val uniqueId: Long, diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt index 35efd356eb..5d18bbc58b 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt @@ -37,6 +37,7 @@ internal class MessageListItemMapper( preview = previewText, isRead = message.isRead, hasAttachments = message.hasAttachments, + threadCount = message.threadCount, uri = uri, accountColor = account.chipColor, uniqueId = uniqueId, diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt index 0e5566d929..ef14511b62 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt @@ -75,6 +75,13 @@ internal class MessageListRemoteViewFactory(private val context: Context) : Remo remoteView.setTextViewText(R.id.mail_date, item.displayDate) remoteView.setTextViewText(R.id.mail_preview, item.preview) + if (item.threadCount > 1) { + remoteView.setTextViewText(R.id.thread_count, item.threadCount.toString()) + remoteView.setInt(R.id.thread_count, "setVisibility", View.VISIBLE) + } else { + remoteView.setInt(R.id.thread_count, "setVisibility", View.GONE) + } + val textColor = getTextColor(item) remoteView.setTextColor(R.id.sender, textColor) remoteView.setTextColor(R.id.mail_subject, textColor) diff --git a/app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item.xml b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item.xml index 368ee21e97..661b362001 100644 --- a/app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item.xml +++ b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item.xml @@ -39,6 +39,21 @@ android:visibility="gone" tools:visibility="visible" /> + + Date: Tue, 18 Oct 2022 16:03:24 +0200 Subject: [PATCH 062/121] Load Unified Inbox when opening a message from the message list widget --- .../com/fsck/k9/mailstore/LocalMessage.java | 4 -- .../java/com/fsck/k9/activity/MessageList.kt | 44 ++++++++++--------- .../k9mail/ui/widget/list/MessageListItem.kt | 4 +- .../ui/widget/list/MessageListItemMapper.kt | 5 +-- .../list/MessageListRemoteViewFactory.kt | 7 +-- .../widget/list/MessageListWidgetProvider.kt | 11 +++-- 6 files changed, 36 insertions(+), 39 deletions(-) diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java b/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java index 31aabb5e6e..a638139672 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java @@ -384,10 +384,6 @@ public class LocalMessage extends MimeMessage { return mFolder; } - public String getUri() { - return "k9mail://messages/" + getAccount().getAccountNumber() + "/" + getFolder().getDatabaseId() + "/" + getUid(); - } - @Override public void writeTo(OutputStream out) throws IOException, MessagingException { if (headerNeedsUpdating) { 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 dfdcd8fad6..e8eba73048 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 @@ -421,26 +421,9 @@ open class MessageList : private fun decodeExtrasToLaunchData(intent: Intent): LaunchData { val action = intent.action - val data = intent.data val queryString = intent.getStringExtra(SearchManager.QUERY) - if (action == Intent.ACTION_VIEW && data != null && data.pathSegments.size >= 3) { - val segmentList = data.pathSegments - val accountId = segmentList[0] - for (account in preferences.accounts) { - if (account.accountNumber.toString() == accountId) { - val folderId = segmentList[1].toLong() - val messageUid = segmentList[2] - val messageReference = MessageReference(account.uuid, folderId, messageUid) - - return LaunchData( - search = messageReference.toLocalSearch(), - messageReference = messageReference, - messageViewOnly = true - ) - } - } - } else if (action == ACTION_SHORTCUT) { + if (action == ACTION_SHORTCUT) { // Handle shortcut intents val specialFolder = intent.getStringExtra(EXTRA_SPECIAL_FOLDER) if (SearchAccount.UNIFIED_INBOX == specialFolder) { @@ -489,7 +472,8 @@ open class MessageList : return LaunchData( search = search, - messageReference = messageReference + messageReference = messageReference, + messageViewOnly = intent.getBooleanExtra(EXTRA_MESSAGE_VIEW_ONLY, false) ) } } else if (intent.hasExtra(EXTRA_SEARCH)) { @@ -1419,6 +1403,7 @@ open class MessageList : private const val EXTRA_ACCOUNT = "account_uuid" private const val EXTRA_MESSAGE_REFERENCE = "message_reference" + private const val EXTRA_MESSAGE_VIEW_ONLY = "message_view_only" // used for remote search const val EXTRA_SEARCH_ACCOUNT = "com.fsck.k9.search_account" @@ -1521,21 +1506,38 @@ open class MessageList : fun actionDisplayMessageIntent( context: Context, messageReference: MessageReference, - openInUnifiedInbox: Boolean = false + openInUnifiedInbox: Boolean = false, + messageViewOnly: Boolean = false ): Intent { - return Intent(context, MessageList::class.java).apply { + return actionDisplayMessageTemplateIntent(context, openInUnifiedInbox, messageViewOnly).apply { putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) + } + } + fun actionDisplayMessageTemplateIntent( + context: Context, + openInUnifiedInbox: Boolean, + messageViewOnly: Boolean + ): Intent { + return Intent(context, MessageList::class.java).apply { if (openInUnifiedInbox) { val search = SearchAccount.createUnifiedInboxAccount().relatedSearch putExtra(EXTRA_SEARCH, ParcelableUtil.marshall(search)) } + putExtra(EXTRA_MESSAGE_VIEW_ONLY, messageViewOnly) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } } + fun actionDisplayMessageTemplateFillIntent(messageReference: MessageReference): Intent { + return Intent().apply { + putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) + } + } + @JvmStatic fun launch(context: Context) { val intent = Intent(context, MessageList::class.java).apply { diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt index 9ebfab398e..19c266df10 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItem.kt @@ -1,6 +1,6 @@ package app.k9mail.ui.widget.list -import android.net.Uri +import com.fsck.k9.controller.MessageReference internal data class MessageListItem( val displayName: String, @@ -10,8 +10,8 @@ internal data class MessageListItem( val isRead: Boolean, val hasAttachments: Boolean, val threadCount: Int, - val uri: Uri, val accountColor: Int, + val messageReference: MessageReference, val uniqueId: Long, val sortSubject: String?, diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt index 5d18bbc58b..e0f7653590 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListItemMapper.kt @@ -1,7 +1,7 @@ package app.k9mail.ui.widget.list -import android.net.Uri import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference import com.fsck.k9.helper.MessageHelper import com.fsck.k9.mailstore.MessageDetailsAccessor import com.fsck.k9.mailstore.MessageMapper @@ -28,7 +28,6 @@ internal class MessageListItemMapper( } else { messageHelper.getSenderDisplayName(displayAddress).toString() } - val uri = Uri.parse("k9mail://messages/${account.accountNumber}/${message.folderId}/${message.messageServerId}") return MessageListItem( displayName = displayName, @@ -38,8 +37,8 @@ internal class MessageListItemMapper( isRead = message.isRead, hasAttachments = message.hasAttachments, threadCount = message.threadCount, - uri = uri, accountColor = account.chipColor, + messageReference = MessageReference(account.uuid, message.folderId, message.messageServerId), uniqueId = uniqueId, sortSubject = message.subject, sortMessageDate = message.messageDate, diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt index ef14511b62..fe0c5d7b2e 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt @@ -1,7 +1,6 @@ package app.k9mail.ui.widget.list import android.content.Context -import android.content.Intent import android.graphics.Typeface import android.text.SpannableString import android.text.style.StyleSpan @@ -11,6 +10,7 @@ import android.widget.RemoteViewsService.RemoteViewsFactory import androidx.core.content.ContextCompat import com.fsck.k9.Account.SortType import com.fsck.k9.K9 +import com.fsck.k9.activity.MessageList import com.fsck.k9.search.LocalSearch import com.fsck.k9.search.SearchAccount import org.koin.core.component.KoinComponent @@ -94,10 +94,7 @@ internal class MessageListRemoteViewFactory(private val context: Context) : Remo remoteView.setInt(R.id.attachment, "setVisibility", View.GONE) } - val intent = Intent().apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - data = item.uri - } + val intent = MessageList.actionDisplayMessageTemplateFillIntent(item.messageReference) remoteView.setOnClickFillInIntent(R.id.mail_list_item, intent) remoteView.setInt(R.id.chip, "setBackgroundColor", item.accountColor) diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt index a1ea3ff25d..11fb3da925 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt @@ -45,10 +45,13 @@ open class MessageListWidgetProvider : AppWidgetProvider() { } private fun viewActionTemplatePendingIntent(context: Context): PendingIntent { - val intent = Intent(context, MessageList::class.java).apply { - action = Intent.ACTION_VIEW - } - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or FLAG_MUTABLE) + val intent = MessageList.actionDisplayMessageTemplateIntent( + context, + openInUnifiedInbox = true, + messageViewOnly = true + ) + + return PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or FLAG_MUTABLE) } private fun viewUnifiedInboxPendingIntent(context: Context): PendingIntent { -- GitLab From da283a7c32428c39e57f82648a3acfdcfaea85b5 Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 19 Oct 2022 14:36:40 +0200 Subject: [PATCH 063/121] Change the way the message list widget listens to changes Also change the code to only listen for message list changes when the message list widget has been added to the home screen. --- .../k9/mailstore/MessageListRepository.kt | 23 +++- app/k9mail/src/main/java/com/fsck/k9/App.kt | 5 +- .../src/main/java/com/fsck/k9/Dependencies.kt | 2 - .../app/k9mail/ui/widget/list/KoinModule.kt | 2 +- .../widget/list/MessageListWidgetManager.kt | 106 ++++++++++++++++++ .../widget/list/MessageListWidgetProvider.kt | 45 ++------ .../list/MessageListWidgetUpdateListener.kt | 35 ------ 7 files changed, 139 insertions(+), 79 deletions(-) create mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetManager.kt delete mode 100644 app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetUpdateListener.kt 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 index 5a748ee272..2bea1c5340 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/MessageListRepository.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageListRepository.kt @@ -5,19 +5,32 @@ import java.util.concurrent.CopyOnWriteArraySet class MessageListRepository( private val messageStoreManager: MessageStoreManager ) { - private val listeners = CopyOnWriteArraySet>() + private val globalListeners = CopyOnWriteArraySet() + private val accountListeners = CopyOnWriteArraySet>() + + fun addListener(listener: MessageListChangedListener) { + globalListeners.add(listener) + } fun addListener(accountUuid: String, listener: MessageListChangedListener) { - listeners.add(accountUuid to listener) + accountListeners.add(accountUuid to listener) } fun removeListener(listener: MessageListChangedListener) { - val entries = listeners.filter { it.second == listener }.toSet() - listeners.removeAll(entries) + globalListeners.remove(listener) + + val accountEntries = accountListeners.filter { it.second == listener }.toSet() + if (accountEntries.isNotEmpty()) { + accountListeners.removeAll(accountEntries) + } } fun notifyMessageListChanged(accountUuid: String) { - for (listener in listeners) { + for (listener in globalListeners) { + listener.onMessageListChanged() + } + + for (listener in accountListeners) { if (listener.first == accountUuid) { listener.second.onMessageListChanged() } diff --git a/app/k9mail/src/main/java/com/fsck/k9/App.kt b/app/k9mail/src/main/java/com/fsck/k9/App.kt index 9453de2d75..907bd8a63b 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/App.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/App.kt @@ -3,7 +3,7 @@ package com.fsck.k9 import android.app.Application import android.content.res.Configuration import android.content.res.Resources -import app.k9mail.ui.widget.list.MessageListWidgetProvider +import app.k9mail.ui.widget.list.MessageListWidgetManager import com.fsck.k9.activity.MessageCompose import com.fsck.k9.controller.MessagingController import com.fsck.k9.notification.NotificationChannelManager @@ -28,6 +28,7 @@ class App : Application() { private val themeManager: ThemeManager by inject() private val appLanguageManager: AppLanguageManager by inject() private val notificationChannelManager: NotificationChannelManager by inject() + private val messageListWidgetManager: MessageListWidgetManager by inject() private val appCoroutineScope: CoroutineScope = GlobalScope + Dispatchers.Main private var appLanguageManagerInitialized = false @@ -43,7 +44,7 @@ class App : Application() { initializeAppLanguage() updateNotificationChannelsOnAppLanguageChanges() themeManager.init() - MessageListWidgetProvider.init(this) + messageListWidgetManager.init() messagingListenerProvider.listeners.forEach { listener -> messagingController.addListener(listener) diff --git a/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt b/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt index d75a41aa7b..8967c972a6 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt @@ -1,6 +1,5 @@ package com.fsck.k9 -import app.k9mail.ui.widget.list.MessageListWidgetUpdateListener import app.k9mail.ui.widget.list.messageListWidgetModule import com.fsck.k9.auth.createOAuthConfigurationProvider import com.fsck.k9.backends.backendsModule @@ -24,7 +23,6 @@ private val mainAppModule = module { MessagingListenerProvider( listOf( get(), - get() ) ) } diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/KoinModule.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/KoinModule.kt index ff571e44eb..dbee532f8c 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/KoinModule.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/KoinModule.kt @@ -3,6 +3,6 @@ package app.k9mail.ui.widget.list import org.koin.dsl.module val messageListWidgetModule = module { - single { MessageListWidgetUpdateListener(context = get()) } + single { MessageListWidgetManager(context = get(), messageListRepository = get(), config = get()) } factory { MessageListLoader(preferences = get(), messageListRepository = get(), messageHelper = get()) } } diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetManager.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetManager.kt new file mode 100644 index 0000000000..6eab9f1d38 --- /dev/null +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetManager.kt @@ -0,0 +1,106 @@ +package app.k9mail.ui.widget.list + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import com.fsck.k9.core.BuildConfig +import com.fsck.k9.mailstore.MessageListChangedListener +import com.fsck.k9.mailstore.MessageListRepository +import timber.log.Timber + +class MessageListWidgetManager( + private val context: Context, + private val messageListRepository: MessageListRepository, + private val config: MessageListWidgetConfig, +) { + private lateinit var appWidgetManager: AppWidgetManager + + private var listenerAdded = false + private val listener = MessageListChangedListener { + onMessageListChanged() + } + + fun init() { + appWidgetManager = AppWidgetManager.getInstance(context) + + if (isAtLeastOneMessageListWidgetAdded()) { + resetMessageListWidget() + registerMessageListChangedListener() + } + } + + private fun onMessageListChanged() { + try { + triggerMessageListWidgetUpdate() + } catch (e: RuntimeException) { + if (BuildConfig.DEBUG) { + throw e + } else { + Timber.e(e, "Error while updating message list widget") + } + } + } + + internal fun onWidgetAdded() { + Timber.v("Message list widget added") + + registerMessageListChangedListener() + } + + internal fun onWidgetRemoved() { + Timber.v("Message list widget removed") + + if (!isAtLeastOneMessageListWidgetAdded()) { + unregisterMessageListChangedListener() + } + } + + @Synchronized + private fun registerMessageListChangedListener() { + if (!listenerAdded) { + listenerAdded = true + messageListRepository.addListener(listener) + + Timber.v("Message list widget is now listening for message list changes…") + } + } + + @Synchronized + private fun unregisterMessageListChangedListener() { + if (listenerAdded) { + listenerAdded = false + messageListRepository.removeListener(listener) + + Timber.v("Message list widget stopped listening for message list changes.") + } + } + + private fun isAtLeastOneMessageListWidgetAdded(): Boolean { + return getAppWidgetIds().isNotEmpty() + } + + private fun triggerMessageListWidgetUpdate() { + val appWidgetIds = getAppWidgetIds() + if (appWidgetIds.isNotEmpty()) { + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.listView) + } + } + + private fun resetMessageListWidget() { + val appWidgetIds = getAppWidgetIds() + if (appWidgetIds.isNotEmpty()) { + val intent = Intent(context, config.providerClass).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds) + } + + context.sendBroadcast(intent) + } + } + + private fun getAppWidgetIds(): IntArray { + val componentName = ComponentName(context, config.providerClass) + return appWidgetManager.getAppWidgetIds(componentName) + } +} diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt index 11fb3da925..fffb2d470f 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetProvider.kt @@ -3,7 +3,6 @@ package app.k9mail.ui.widget.list import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider -import android.content.ComponentName import android.content.Context import android.content.Intent import android.widget.RemoteViews @@ -17,7 +16,17 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import com.fsck.k9.ui.R as UiR -open class MessageListWidgetProvider : AppWidgetProvider() { +open class MessageListWidgetProvider : AppWidgetProvider(), KoinComponent { + private val messageListWidgetManager: MessageListWidgetManager by inject() + + override fun onEnabled(context: Context) { + messageListWidgetManager.onWidgetAdded() + } + + override fun onDisabled(context: Context) { + messageListWidgetManager.onWidgetRemoved() + } + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { for (appWidgetId in appWidgetIds) { updateAppWidget(context, appWidgetManager, appWidgetId) @@ -73,36 +82,4 @@ open class MessageListWidgetProvider : AppWidgetProvider() { } return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) } - - companion object : KoinComponent { - private val messageListWidgetConfig: MessageListWidgetConfig by inject() - - fun init(context: Context) { - resetMessageListWidget(context) - } - - private fun resetMessageListWidget(context: Context) { - val appWidgetManager = AppWidgetManager.getInstance(context) - - val providerClass = messageListWidgetConfig.providerClass - val componentName = ComponentName(context, providerClass) - val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) - - val intent = Intent(context, providerClass).apply { - action = AppWidgetManager.ACTION_APPWIDGET_UPDATE - putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds) - } - context.sendBroadcast(intent) - } - - fun triggerMessageListWidgetUpdate(context: Context) { - val appWidgetManager = AppWidgetManager.getInstance(context) - - val providerClass = messageListWidgetConfig.providerClass - val componentName = ComponentName(context, providerClass) - val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) - - appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.listView) - } - } } diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetUpdateListener.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetUpdateListener.kt deleted file mode 100644 index cccb26c19d..0000000000 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListWidgetUpdateListener.kt +++ /dev/null @@ -1,35 +0,0 @@ -package app.k9mail.ui.widget.list - -import android.content.Context -import com.fsck.k9.Account -import com.fsck.k9.controller.SimpleMessagingListener -import com.fsck.k9.core.BuildConfig -import com.fsck.k9.mail.Message -import timber.log.Timber - -class MessageListWidgetUpdateListener(private val context: Context) : SimpleMessagingListener() { - - private fun updateMailListWidget() { - try { - MessageListWidgetProvider.triggerMessageListWidgetUpdate(context) - } catch (e: RuntimeException) { - if (BuildConfig.DEBUG) { - throw e - } else { - Timber.e(e, "Error while updating message list widget") - } - } - } - - override fun synchronizeMailboxRemovedMessage(account: Account, folderServerId: String, messageServerId: String) { - updateMailListWidget() - } - - override fun synchronizeMailboxNewMessage(account: Account, folderServerId: String, message: Message) { - updateMailListWidget() - } - - override fun folderStatusChanged(account: Account, folderId: Long) { - updateMailListWidget() - } -} -- GitLab From 86ae99dd6e878c6803c1b0a2b1044e206420be2d Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 20 Oct 2022 12:08:36 +0200 Subject: [PATCH 064/121] Fix size of `ListView` in message list widget --- .../src/main/res/layout/message_list_widget_layout.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/message-list-widget/src/main/res/layout/message_list_widget_layout.xml b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_layout.xml index af5b9a546e..a3bc8c63f0 100644 --- a/app/ui/message-list-widget/src/main/res/layout/message_list_widget_layout.xml +++ b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_layout.xml @@ -38,7 +38,7 @@ -- GitLab From 5b448e5c6953805cc0f9c630984169c504b6a0d1 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 20 Oct 2022 13:20:25 +0200 Subject: [PATCH 065/121] Improve loading layouts of the message list widget --- app/ui/legacy/src/main/res/values/strings.xml | 5 ++++- .../widget/list/MessageListRemoteViewFactory.kt | 6 +++--- .../message_list_widget_list_item_loading.xml | 12 ++++++++++++ .../res/layout/message_list_widget_loading.xml | 17 ++++++----------- .../main/res/xml/message_list_widget_info.xml | 4 ++-- 5 files changed, 27 insertions(+), 17 deletions(-) create mode 100644 app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item_loading.xml diff --git a/app/ui/legacy/src/main/res/values/strings.xml b/app/ui/legacy/src/main/res/values/strings.xml index 3b52fa01b1..ecc655b2f8 100644 --- a/app/ui/legacy/src/main/res/values/strings.xml +++ b/app/ui/legacy/src/main/res/values/strings.xml @@ -1200,7 +1200,10 @@ Please submit bug reports, contribute new features and ask questions at Go to Settings K-9 Message List - Loading messages… + + Loading… + + Loading… Encryption not possible Some of the selected recipients don\'t support this feature! diff --git a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt index fe0c5d7b2e..b446a8b785 100644 --- a/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt +++ b/app/ui/message-list-widget/src/main/java/app/k9mail/ui/widget/list/MessageListRemoteViewFactory.kt @@ -103,9 +103,9 @@ internal class MessageListRemoteViewFactory(private val context: Context) : Remo } override fun getLoadingView(): RemoteViews { - return RemoteViews(context.packageName, R.layout.message_list_widget_loading).apply { - setTextViewText(R.id.loadingText, context.getString(UiR.string.mail_list_widget_loading)) - setViewVisibility(R.id.loadingText, View.VISIBLE) + return RemoteViews(context.packageName, R.layout.message_list_widget_list_item_loading).apply { + // Set the text here instead of in the layout so the app language override is used + setTextViewText(R.id.loadingText, context.getString(UiR.string.message_list_widget_list_item_loading)) } } diff --git a/app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item_loading.xml b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item_loading.xml new file mode 100644 index 0000000000..bb92fcc430 --- /dev/null +++ b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_list_item_loading.xml @@ -0,0 +1,12 @@ + + diff --git a/app/ui/message-list-widget/src/main/res/layout/message_list_widget_loading.xml b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_loading.xml index 2df87f5340..49f087d9dc 100644 --- a/app/ui/message-list-widget/src/main/res/layout/message_list_widget_loading.xml +++ b/app/ui/message-list-widget/src/main/res/layout/message_list_widget_loading.xml @@ -1,14 +1,9 @@ - - - - - + android:background="@android:color/white" + android:gravity="center" + android:padding="16dp" + android:text="@string/message_list_widget_initializing" + android:textSize="18sp" /> diff --git a/app/ui/message-list-widget/src/main/res/xml/message_list_widget_info.xml b/app/ui/message-list-widget/src/main/res/xml/message_list_widget_info.xml index e683f4097f..cbcd3d4f56 100644 --- a/app/ui/message-list-widget/src/main/res/xml/message_list_widget_info.xml +++ b/app/ui/message-list-widget/src/main/res/xml/message_list_widget_info.xml @@ -1,7 +1,7 @@ Date: Thu, 20 Oct 2022 17:07:20 +0200 Subject: [PATCH 066/121] Don't put serialized `LocalSearch` instance in account shortcut Intent --- .../java/com/fsck/k9/activity/MessageList.kt | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) 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 e8eba73048..ab3fe015ed 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 @@ -426,9 +426,26 @@ open class MessageList : if (action == ACTION_SHORTCUT) { // Handle shortcut intents val specialFolder = intent.getStringExtra(EXTRA_SPECIAL_FOLDER) - if (SearchAccount.UNIFIED_INBOX == specialFolder) { + if (specialFolder == SearchAccount.UNIFIED_INBOX) { return LaunchData(search = SearchAccount.createUnifiedInboxAccount().relatedSearch) } + + val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT) + if (accountUuid != null) { + val account = preferences.getAccount(accountUuid) + if (account == null) { + Timber.d("Account %s not found.", accountUuid) + return LaunchData(createDefaultLocalSearch()) + } + + val folderId = defaultFolderProvider.getDefaultFolder(account) + val search = LocalSearch().apply { + addAccountUuid(accountUuid) + addAllowedFolder(folderId) + } + + return LaunchData(search = search) + } } else if (action == Intent.ACTION_SEARCH && queryString != null) { // Query was received from Search Dialog val query = queryString.trim() @@ -1493,14 +1510,14 @@ open class MessageList : @JvmStatic fun shortcutIntentForAccount(context: Context?, account: Account): Intent { - val folderId = defaultFolderProvider.getDefaultFolder(account) + return Intent(context, MessageList::class.java).apply { + action = ACTION_SHORTCUT + putExtra(EXTRA_ACCOUNT, account.uuid) - val search = LocalSearch().apply { - addAccountUuid(account.uuid) - addAllowedFolder(folderId) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - - return intentDisplaySearch(context, search, noThreading = false, newTask = true, clearTop = true) } fun actionDisplayMessageIntent( -- GitLab From f0b369711b60456d6ef9346152e90b561fc81bc1 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 20 Oct 2022 17:59:25 +0200 Subject: [PATCH 067/121] Remove support for old launcher shortcuts --- app/k9mail/src/main/AndroidManifest.xml | 14 -------------- .../java/com/fsck/k9/activity/MessageList.kt | 18 ------------------ 2 files changed, 32 deletions(-) diff --git a/app/k9mail/src/main/AndroidManifest.xml b/app/k9mail/src/main/AndroidManifest.xml index 9f9169d075..4a246df163 100644 --- a/app/k9mail/src/main/AndroidManifest.xml +++ b/app/k9mail/src/main/AndroidManifest.xml @@ -59,13 +59,6 @@ - - - - @@ -158,13 +151,6 @@ android:label="@string/ac_transfer_title" /> - - - - Date: Fri, 21 Oct 2022 15:41:11 +0200 Subject: [PATCH 068/121] Don't launch a crypto provider activity until `MessageViewFragment` becomes active --- .../com/fsck/k9/activity/MessageCompose.java | 4 +++- .../fsck/k9/activity/MessageLoaderHelper.java | 14 ++++++++++---- .../k9/ui/crypto/MessageCryptoCallback.java | 2 +- .../fsck/k9/ui/crypto/MessageCryptoHelper.java | 12 ++++++++++-- .../k9/ui/messageview/MessageViewFragment.kt | 18 ++++++++++++++---- 5 files changed, 38 insertions(+), 12 deletions(-) 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 925edd1ed6..7364f05cff 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 @@ -1701,7 +1701,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, } @Override - public void startIntentSenderForMessageLoaderHelper(IntentSender si, int requestCode, Intent fillIntent, + public boolean startIntentSenderForMessageLoaderHelper(IntentSender si, int requestCode, Intent fillIntent, int flagsMask, int flagValues, int extraFlags) { try { requestCode |= REQUEST_MASK_LOADER_HELPER; @@ -1709,6 +1709,8 @@ public class MessageCompose extends K9Activity implements OnClickListener, } catch (SendIntentException e) { Timber.e(e, "Irrecoverable error calling PendingIntent!"); } + + return true; } @Override 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 a591aeed5c..2c905f0ccf 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 @@ -142,6 +142,12 @@ public class MessageLoaderHelper { startOrResumeLocalMessageLoader(); } + public void resumeCryptoOperationIfNecessary() { + if (messageCryptoHelper != null) { + messageCryptoHelper.resumeCryptoOperationIfNecessary(); + } + } + @UiThread public void asyncRestartMessageCryptoProcessing() { cancelAndClearCryptoOperation(); @@ -351,13 +357,13 @@ public class MessageLoaderHelper { } @Override - public void startPendingIntentForCryptoHelper(IntentSender si, int requestCode, Intent fillIntent, + public boolean startPendingIntentForCryptoHelper(IntentSender si, int requestCode, Intent fillIntent, int flagsMask, int flagValues, int extraFlags) { if (callback == null) { throw new IllegalStateException("unexpected call when callback is already detached"); } - callback.startIntentSenderForMessageLoaderHelper(si, requestCode, fillIntent, + return callback.startIntentSenderForMessageLoaderHelper(si, requestCode, fillIntent, flagsMask, flagValues, extraFlags); } }; @@ -518,8 +524,8 @@ public class MessageLoaderHelper { void setLoadingProgress(int current, int max); - void startIntentSenderForMessageLoaderHelper(IntentSender si, int requestCode, Intent fillIntent, int flagsMask, - int flagValues, int extraFlags); + boolean startIntentSenderForMessageLoaderHelper(IntentSender si, int requestCode, Intent fillIntent, + int flagsMask, int flagValues, int extraFlags); void onDownloadErrorMessageNotFound(); void onDownloadErrorNetworkError(); diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoCallback.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoCallback.java index ce54ed38e8..3df5d05088 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoCallback.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoCallback.java @@ -10,6 +10,6 @@ import com.fsck.k9.mailstore.MessageCryptoAnnotations; public interface MessageCryptoCallback { void onCryptoHelperProgress(int current, int max); void onCryptoOperationsFinished(MessageCryptoAnnotations annotations); - void startPendingIntentForCryptoHelper(IntentSender si, int requestCode, Intent fillIntent, + boolean startPendingIntentForCryptoHelper(IntentSender si, int requestCode, Intent fillIntent, int flagsMask, int flagValues, int extraFlags); } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoHelper.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoHelper.java index b0b9fb039d..bcd1e56889 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoHelper.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/MessageCryptoHelper.java @@ -120,6 +120,12 @@ public class MessageCryptoHelper { nextStep(); } + public void resumeCryptoOperationIfNecessary() { + if (queuedPendingIntent != null) { + deliverResult(); + } + } + private void findPartsForMultipartEncryptionPass() { List encryptedParts = MessageCryptoStructureDetector.findMultipartEncryptedParts(currentMessage); for (Part part : encryptedParts) { @@ -765,9 +771,11 @@ public class MessageCryptoHelper { if (queuedResult != null) { callback.onCryptoOperationsFinished(queuedResult); } else if (queuedPendingIntent != null) { - callback.startPendingIntentForCryptoHelper( + boolean pendingIntentHandled = callback.startPendingIntentForCryptoHelper( queuedPendingIntent.getIntentSender(), REQUEST_CODE_USER_INTERACTION, null, 0, 0, 0); - queuedPendingIntent = null; + if (pendingIntentHandled) { + queuedPendingIntent = null; + } } else { throw new IllegalStateException("deliverResult() called with no result!"); } 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 a7de139a0c..944e4e6cbe 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 @@ -91,6 +91,9 @@ class MessageViewFragment : private var isDeleteMenuItemDisabled: Boolean = false private var wasMessageMarkedAsOpened: Boolean = false + private var isActive: Boolean = false + private set + override fun onAttach(context: Context) { super.onAttach(context) @@ -176,10 +179,13 @@ class MessageViewFragment : override fun setMenuVisibility(menuVisible: Boolean) { super.setMenuVisibility(menuVisible) + isActive = menuVisible - // When the menu is hidden, the message associated with this fragment is no longer active. If the user returns - // to it, we want to mark the message as opened again. - if (!menuVisible) { + if (menuVisible) { + messageLoaderHelper.resumeCryptoOperationIfNecessary() + } else { + // When the menu is hidden, the message associated with this fragment is no longer active. If the user returns + // to it, we want to mark the message as opened again. wasMessageMarkedAsOpened = false } } @@ -887,7 +893,9 @@ class MessageViewFragment : flagsMask: Int, flagValues: Int, extraFlags: Int - ) { + ): Boolean { + if (!isActive) return false + showProgressThreshold = null try { val maskedRequestCode = requestCode or REQUEST_MASK_LOADER_HELPER @@ -897,6 +905,8 @@ class MessageViewFragment : } catch (e: SendIntentException) { Timber.e(e, "Irrecoverable error calling PendingIntent!") } + + return true } } -- GitLab From c5361e9329f6de8bec7ece93da72b5efb3a72965 Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 22 Oct 2022 16:23:08 +0200 Subject: [PATCH 069/121] Only disable clicks on send button if `MessageBuilder` was successfully created --- .../src/main/java/com/fsck/k9/activity/MessageCompose.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 7364f05cff..244c8f7075 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 @@ -803,10 +803,9 @@ public class MessageCompose extends K9Activity implements OnClickListener, return; } - sendMessageHasBeenTriggered = true; - currentMessageBuilder = createMessageBuilder(false); if (currentMessageBuilder != null) { + sendMessageHasBeenTriggered = true; changesMadeSinceLastSave = false; setProgressBarIndeterminateVisibility(true); currentMessageBuilder.buildAsync(this); -- GitLab From 7274d7790c42e65beca6d3b67d57082caddf76ce Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 22 Oct 2022 16:27:37 +0200 Subject: [PATCH 070/121] Add log entry when creating `MessageBuilder` has failed --- .../src/main/java/com/fsck/k9/activity/MessageCompose.java | 1 + 1 file changed, 1 insertion(+) 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 244c8f7075..bfd2d4fb58 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 @@ -696,6 +696,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, ComposeCryptoStatus cryptoStatus = recipientPresenter.getCurrentCachedCryptoStatus(); if (cryptoStatus == null) { + Timber.w("Couldn't retrieve crypto status; not creating MessageBuilder!"); return null; } -- GitLab From 863222f658094745a5423aa2006d91ab9556b55f Mon Sep 17 00:00:00 2001 From: cketti Date: Sun, 23 Oct 2022 23:09:48 +0200 Subject: [PATCH 071/121] Keep `` tags when sanitizing HTML --- .../app/k9mail/html/cleaner/BodyCleaner.kt | 2 +- .../k9mail/html/cleaner/HtmlSanitizerTest.kt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 3bc44effdc..36b9ec0d5b 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 @@ -13,7 +13,7 @@ internal class BodyCleaner { init { val allowList = Safelist.relaxed() - .addTags("font", "hr", "ins", "del", "center", "map", "area", "title") + .addTags("font", "hr", "ins", "del", "center", "map", "area", "title", "tt") .addAttributes("font", "color", "face", "size") .addAttributes("a", "name") .addAttributes("div", "align") 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 8720a3273d..1c80b1f540 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 @@ -445,6 +445,24 @@ class HtmlSanitizerTest { ) } + @Test + fun `should keep 'tt' element`() { + val html = """some text""" + + val result = htmlSanitizer.sanitize(html) + + assertThat(result.toCompactString()).isEqualTo( + """ + + + + some text + + + """.trimIndent().trimLineBreaks() + ) + } + private fun Document.toCompactString(): String { outputSettings() .prettyPrint(false) -- GitLab From be8bb258744ce68bcbdfb99d0dd054dd25f71913 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 24 Oct 2022 12:12:15 +0200 Subject: [PATCH 072/121] Keep ``, ``, and `` tags when sanitizing HTML --- .../app/k9mail/html/cleaner/BodyCleaner.kt | 2 +- .../k9mail/html/cleaner/HtmlSanitizerTest.kt | 23 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) 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 36b9ec0d5b..43a6d82738 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 @@ -13,7 +13,7 @@ internal class BodyCleaner { init { val allowList = Safelist.relaxed() - .addTags("font", "hr", "ins", "del", "center", "map", "area", "title", "tt") + .addTags("font", "hr", "ins", "del", "center", "map", "area", "title", "tt", "kbd", "samp", "var") .addAttributes("font", "color", "face", "size") .addAttributes("a", "name") .addAttributes("div", "align") 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 1c80b1f540..d60ae8b381 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 @@ -447,7 +447,26 @@ class HtmlSanitizerTest { @Test fun `should keep 'tt' element`() { - val html = """some text""" + assertTagsNotStripped("tt") + } + + @Test + fun `should keep 'kbd' element`() { + assertTagsNotStripped("kbd") + } + + @Test + fun `should keep 'samp' element`() { + assertTagsNotStripped("samp") + } + + @Test + fun `should keep 'var' element`() { + assertTagsNotStripped("var") + } + + private fun assertTagsNotStripped(element: String) { + val html = """<$element>some text""" val result = htmlSanitizer.sanitize(html) @@ -456,7 +475,7 @@ class HtmlSanitizerTest { - some text + <$element>some text """.trimIndent().trimLineBreaks() -- GitLab From 29652d9cbe90b2f144945118f3b4d3a3a920a94e Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 24 Oct 2022 13:33:28 +0200 Subject: [PATCH 073/121] Update translations --- .../legacy/src/main/res/values-ar/strings.xml | 5 +- .../legacy/src/main/res/values-be/strings.xml | 5 +- .../legacy/src/main/res/values-bg/strings.xml | 5 +- .../legacy/src/main/res/values-br/strings.xml | 5 +- .../legacy/src/main/res/values-ca/strings.xml | 21 +++++-- .../legacy/src/main/res/values-cs/strings.xml | 5 +- .../legacy/src/main/res/values-cy/strings.xml | 5 +- .../legacy/src/main/res/values-da/strings.xml | 5 +- .../legacy/src/main/res/values-de/strings.xml | 9 ++- .../legacy/src/main/res/values-el/strings.xml | 5 +- .../src/main/res/values-en-rGB/strings.xml | 2 + .../legacy/src/main/res/values-eo/strings.xml | 5 +- .../legacy/src/main/res/values-es/strings.xml | 5 +- .../legacy/src/main/res/values-et/strings.xml | 5 +- .../legacy/src/main/res/values-eu/strings.xml | 12 +++- .../legacy/src/main/res/values-fa/strings.xml | 5 +- .../legacy/src/main/res/values-fi/strings.xml | 5 +- .../legacy/src/main/res/values-fr/strings.xml | 15 ++++- .../legacy/src/main/res/values-fy/strings.xml | 5 +- .../legacy/src/main/res/values-gd/strings.xml | 5 +- .../src/main/res/values-gl-rES/strings.xml | 4 ++ .../legacy/src/main/res/values-gl/strings.xml | 5 +- .../legacy/src/main/res/values-hr/strings.xml | 5 +- .../legacy/src/main/res/values-hu/strings.xml | 5 +- .../legacy/src/main/res/values-in/strings.xml | 5 +- .../legacy/src/main/res/values-is/strings.xml | 5 +- .../legacy/src/main/res/values-it/strings.xml | 5 +- .../legacy/src/main/res/values-iw/strings.xml | 2 + .../legacy/src/main/res/values-ja/strings.xml | 5 +- .../legacy/src/main/res/values-ko/strings.xml | 4 ++ .../legacy/src/main/res/values-lt/strings.xml | 5 +- .../legacy/src/main/res/values-lv/strings.xml | 5 +- .../legacy/src/main/res/values-ml/strings.xml | 5 +- .../legacy/src/main/res/values-nb/strings.xml | 5 +- .../legacy/src/main/res/values-nl/strings.xml | 5 +- .../legacy/src/main/res/values-pl/strings.xml | 5 +- .../src/main/res/values-pt-rBR/strings.xml | 13 ++++- .../src/main/res/values-pt-rPT/strings.xml | 5 +- .../legacy/src/main/res/values-ro/strings.xml | 5 +- .../legacy/src/main/res/values-ru/strings.xml | 55 ++++++++++--------- .../legacy/src/main/res/values-sk/strings.xml | 5 +- .../legacy/src/main/res/values-sl/strings.xml | 5 +- .../legacy/src/main/res/values-sq/strings.xml | 5 +- .../legacy/src/main/res/values-sr/strings.xml | 5 +- .../legacy/src/main/res/values-sv/strings.xml | 5 +- .../legacy/src/main/res/values-tr/strings.xml | 5 +- .../legacy/src/main/res/values-uk/strings.xml | 5 +- .../src/main/res/values-zh-rCN/strings.xml | 5 +- .../src/main/res/values-zh-rTW/strings.xml | 5 +- 49 files changed, 252 insertions(+), 80 deletions(-) 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 bb389a9d9f..ea33024ac6 100644 --- a/app/ui/legacy/src/main/res/values-ar/strings.xml +++ b/app/ui/legacy/src/main/res/values-ar/strings.xml @@ -755,7 +755,10 @@ نص غير مُوَقَّع هذه الرسالة مُعماة قائمة رسائل K-9 - يُحمل الرسائل… + + يُحمل… + + يُحمل… التعمية غير ممكنة تنشيط التعمية تعطيل التعمية 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 77a3822f12..eba907b606 100644 --- a/app/ui/legacy/src/main/res/values-be/strings.xml +++ b/app/ui/legacy/src/main/res/values-be/strings.xml @@ -944,7 +944,10 @@ K-9 Mail - шматфункцыянальны свабодны паштовы к Ліст зашыфраваны OpenPGP\nКаб прачытаць, неабходна ўсталяваць і наладзіць праграму OpenPGP. Перайсці ў налады Лісты K-9 - Загрузка лістоў… + + Загрузка… + + Загрузка… Зашыфраваць немагчыма Некаторыя з гэтых адрасатаў не падтрымліваюць такую функцыю! Уключыць шыфраванне 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 219b22998d..17e2572fe3 100644 --- a/app/ui/legacy/src/main/res/values-bg/strings.xml +++ b/app/ui/legacy/src/main/res/values-bg/strings.xml @@ -931,7 +931,10 @@ K-9 Mail е мощен, безплатен имейл клиент за Андр Този имейл е криптиран използвайки OpenPGP.\nЗа да го прочетете, трябва да инсталирате и настроите OpenPGP приложението. Към настройки K-9 списък със собщения - Зареждане на съобщения… + + Зареждане… + + Зареждане… Криптирането е невъзможно Някои от избраните получатели не поддържат тази функция! Включване на криптирането 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 8022c5a13e..6b8e2340a9 100644 --- a/app/ui/legacy/src/main/res/values-br/strings.xml +++ b/app/ui/legacy/src/main/res/values-br/strings.xml @@ -885,7 +885,10 @@ Danevellit beugoù, kenlabourit war keweriusterioù nevez ha savit goulennoù wa Enrineget eo bet ar postel-mañ gant OpenPGP.\nEvit gallout e lenn e rankit staliañ ha kefluniañ un arload OpenPGP keverlec’h. Mont d\'an arventennoù Roll kemennadennoù K-9 - O kargañ kemennadennoù… + + O kargañ… + + O kargañ… N’eo ket posupl enrinegañ Ul lodenn eus an degemererien ne skoront ket ar c’heweriuster-mañ! Gweredekaat an enrinegañ 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 574f6af14d..9ba2ceae10 100644 --- a/app/ui/legacy/src/main/res/values-ca/strings.xml +++ b/app/ui/legacy/src/main/res/values-ca/strings.xml @@ -218,7 +218,7 @@ Si us plau, envieu informes d\'errors, contribuïu-hi amb noves millores i feu p Inclou el missatge citat Elimina el text citat Edita el text citat - Suprimeix l\'adjunt + Elimina l\'adjunt De: %1$s <%2$s> A: A/c: @@ -261,13 +261,19 @@ Si us plau, envieu informes d\'errors, contribuïu-hi amb noves millores i feu p Marca tots els missatges com a llegits Elimina (des de notificacions) + Accions lliscants + Llisca a la dreta + Llisca a l\'esquerra Cap + Commuta la selecció + Marca-ho com a llegit / no llegit + Elimina / afegeix un estel Arxiva @@ -277,7 +283,7 @@ Si us plau, envieu informes d\'errors, contribuïu-hi amb noves millores i feu p Mou Amaga el client de correu - Suprimeix el nom K-9 User-Agent de les capçaleres del missatge + Elimina el nom K-9 User-Agent de les capçaleres del missatge Amaga el fus horari Fes servir UTC en comptes de l\'hora local a les capçaleres dels missatges i a la capçalera de resposta Mostra el botó \"Elimina\" @@ -438,8 +444,8 @@ Si us plau, envieu informes d\'errors, contribuïu-hi amb noves millores i feu p No mostris notificacions de missatges pertanyents a una conversa de correu electrònic. Marca el missatge obert com a llegit Marca el missatge com a llegit després d\'haver-lo obert. - Marca com a llegit quan se suprimeixi - Marca un missatge com a llegit quan se suprimeix. + Marca com a llegit quan s\'elimini. + Marca un missatge com a llegit quan s\'elimini. Categories de notificacions Configureu les notificacions per als missatges nous Configureu les notificacions per als errors i l\'estat @@ -749,7 +755,7 @@ Si us plau, envieu informes d\'errors, contribuïu-hi amb noves millores i feu p Descarto el missatge? Segur que voleu descartar aquest missatge? Voleu netejar els missatges locals? - Això suprimirà tots els missatges locals de la carpeta. No se suprimirà cap missatge del servidor. + Això eliminarà tots els missatges locals de la carpeta. No s\'eliminarà cap missatge del servidor. Neteja els missatges Confirmeu l\'eliminació Voleu eliminar aquest missatge? @@ -994,7 +1000,10 @@ Si us plau, envieu informes d\'errors, contribuïu-hi amb noves millores i feu p Aquest missatge ha estat encriptat amb OpenPGP.\nPer llegir-lo, us caldrà instal·lar i configurar una aplicació d\'OpenPGP compatible. Ves a la configuració Llista de missatges del K-9 - S\'està carregant missatges… + + S\'està carregant… + + S\'està carregant… Encriptació no possible Alguns dels destinataris seleccionats no admeten aquesta característica! Habilita l\'encriptació 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 25a9e41ee2..f480a72be5 100644 --- a/app/ui/legacy/src/main/res/values-cs/strings.xml +++ b/app/ui/legacy/src/main/res/values-cs/strings.xml @@ -1007,7 +1007,10 @@ Hlášení o chyb, úpravy pro nové funkce a dotazy zadávejte prostřednictví Tento email byl zašifrovaný pomocí OpenPGP.\nAbyste si ho mohl/a přečíst, potřebujete si nainstalovat a nakonfigurovat kompatibilní OpenPGP aplikaci. Přejít do nastavení Seznam zpráv K-9 - Načítání zpráv… + + Načítání… + + Načítání… Šifrování není možné Někteří ze zvolených příjemců nepodporují tuto funkci! Zapnout šifrování 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 99d35634c8..e16f5757bb 100644 --- a/app/ui/legacy/src/main/res/values-cy/strings.xml +++ b/app/ui/legacy/src/main/res/values-cy/strings.xml @@ -1009,7 +1009,10 @@ Plîs rho wybod am unrhyw wallau, syniadau am nodweddion newydd, neu ofyn cwesti Mae\'r neges e-bost hon wedi ei amgryptio gydag OpenPGP.\nI\'w darllen, rhaid gosod a ffurfweddu ap OpenPGP sy\'n cydweddu. Mynd i Osodiadau Rhestr Negeseuon K-9 - Yn llwytho negeseuon… + + Yn llwytho… + + Yn llwytho… Dyw amgryptiad ddim ar gael. Dyw rhai o\'r derbynnwyr ddim yn cefnogi\'r nodwedd hon. Galluogi amgryptio 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 c84e1c67b3..15c4258686 100644 --- a/app/ui/legacy/src/main/res/values-da/strings.xml +++ b/app/ui/legacy/src/main/res/values-da/strings.xml @@ -960,7 +960,10 @@ Rapporter venligst fejl, forslag til nye funktioner eller stil spørgsmål på: Denne email er krypteret med OpenPGP.\nFor at læse den skal du installere en kompatibel OpenPGP applikation. Gå til opsætning K-9 Meddelelsesliste - Indlæser meddelelser… + + Indlæser… + + Indlæser… Kryptering var ikke mulig Nogle af de valgte modtagere understøtter ikke denne funktion! Aktivér kryptering diff --git a/app/ui/legacy/src/main/res/values-de/strings.xml b/app/ui/legacy/src/main/res/values-de/strings.xml index a07702e919..1dbb9674a4 100644 --- a/app/ui/legacy/src/main/res/values-de/strings.xml +++ b/app/ui/legacy/src/main/res/values-de/strings.xml @@ -254,7 +254,7 @@ Bitte senden Sie Fehlerberichte, Ideen für neue Funktionen und stellen Sie Frag Bestätigungsdialog Bei Ausführung der ausgewählten Aktionen einen Bestätigungsdialog anzeigen Löschen - Als wichtig markiert löschen (nur in Nachrichtenansicht) + Als wichtig markierte (aus Nachrichtenansicht) löschen Spam Nachricht verwerfen Alle Nachrichten als gelesen markieren @@ -702,7 +702,7 @@ Bitte senden Sie Fehlerberichte, Ideen für neue Funktionen und stellen Sie Frag 1000 Ordner Animationen Aufwendige visuelle Effekte benutzen - Navigation per Lautstärketasten in der Nachrichtenansicht + Per Lautstärketasten durch Nachrichtenansicht navigieren Gemeinsamen Posteingang anzeigen Anzahl der wichtigen Nachrichten anzeigen Gemeinsamer Posteingang @@ -1001,7 +1001,10 @@ Bitte senden Sie Fehlerberichte, Ideen für neue Funktionen und stellen Sie Frag Um sie zu lesen, muss eine kompatible OpenPGP-App installiert und konfiguriert werden. Zu den Einstellungen K-9 Nachrichtenliste - Nachrichten werden geladen… + + Ladevorgang… + + Ladevorgang… Verschlüsselung nicht möglich Einige der ausgewählten Empfänger unterstützen diese Funktion nicht! Verschlüsselung aktivieren 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 76d766e02b..8337b1efac 100644 --- a/app/ui/legacy/src/main/res/values-el/strings.xml +++ b/app/ui/legacy/src/main/res/values-el/strings.xml @@ -995,7 +995,10 @@ Αυτό το μήνυμα έχει κρυπτογραφηθεί με το OpenPGP.\nΓια να το διαβάσετε, πρέπει να εγκαταστήσετε και να ρυθμίσετε τις παραμέτρους μιας συμβατής εφαρμογής OpenPGP. Μετάβαση στις Ρυθμίσεις Λίστα Μηνυμάτων του K-9 - Φόρτωση μηνυμάτων… + + Φόρτωση… + + Φόρτωση… Δεν είναι δυνατή η κρυπτογράφηση Ορισμένοι από τους επιλεγμένους παραλήπτες δεν υποστηρίζουν αυτήν τη δυνατότητα! Ενεργοποίηση Κρυπτογράφησης diff --git a/app/ui/legacy/src/main/res/values-en-rGB/strings.xml b/app/ui/legacy/src/main/res/values-en-rGB/strings.xml index 845db6a311..c2d7a6e595 100644 --- a/app/ui/legacy/src/main/res/values-en-rGB/strings.xml +++ b/app/ui/legacy/src/main/res/values-en-rGB/strings.xml @@ -46,5 +46,7 @@ Failed to initialise end-to-end encryption, please check your settings + + 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 9d0d8f41b8..42355790e3 100644 --- a/app/ui/legacy/src/main/res/values-eo/strings.xml +++ b/app/ui/legacy/src/main/res/values-eo/strings.xml @@ -950,7 +950,10 @@ Bonvolu raporti erarojn, kontribui novajn eblojn kaj peti pri novaj funkcioj per Tiu ĉi retletero estas ĉifrita per OpenPGP.\nPor legi ĝin, vi devas instali kaj agordi kongruan OpenPGP-aplikaĵon. Iri al agordoj K-9 mesaĝlisto - Ŝargado de mesaĝoj… + + Ŝargado… + + Ŝargado… Ĉifrado ne eblas Kelkaj da elektitaj ricevontoj ne subtenas tiun ĉi agordon! Aktivigi ĉifradon 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 ad04e3e239..2b785a6f0e 100644 --- a/app/ui/legacy/src/main/res/values-es/strings.xml +++ b/app/ui/legacy/src/main/res/values-es/strings.xml @@ -1005,7 +1005,10 @@ Puedes informar de fallos, contribuir con su desarrollo y hacer preguntas en
Este correo ha sido cifrado con OpenPGP.\nPara leerlo es necesario instalar y configurar una aplicación compatible con OpenPGP. Ir a los ajustes Lista de mensajes de K-9 - Cargando mensajes… + + Cargando… + + Cargando… No es posible cifrar Algunos de los remitentes marcados no entienden esta característica Activar cifrado 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 ceb971d769..36f0b1cca3 100644 --- a/app/ui/legacy/src/main/res/values-et/strings.xml +++ b/app/ui/legacy/src/main/res/values-et/strings.xml @@ -996,7 +996,10 @@ Veateated saad saata, kaastööd teha ning küsida teavet järgmisel lehel: Selle kirja krüptimisel on kasutusel OpenPGP.\nKirjalugemsieks palun paigalda ja seadista sobilik OpenPGP rakendus. Ava sätted K-9 kirjade loend - Kirjade laadimine… + + Laadib… + + Laadib… Krüpteerimine poe võimalik Mõned kirja saajad ei saa seda funktsionaalsust kasutada! Võimalda krüpteerimine 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 13c7d3c366..78bafc4988 100644 --- a/app/ui/legacy/src/main/res/values-eu/strings.xml +++ b/app/ui/legacy/src/main/res/values-eu/strings.xml @@ -260,13 +260,19 @@ Mesedez akatsen berri emateko, ezaugarri berriak gehitzeko eta galderak egiteko Markatu mezu guztiak irakurritako gisa Ezabatu (jakinarazpenetatik) + Hatza pasatzearen ekintzak + Pasatu hatza eskubirantz + Pasatu hatza ezkerrerantz Bat ere ez + Alderantzizkatu hautaketa + Markatu irakurrita/irakurri gabe + Jarri/kendu izarra Artxibatu @@ -695,6 +701,7 @@ Mesedez akatsen berri emateko, ezaugarri berriak gehitzeko eta galderak egiteko 1000 karpeta Animazioak Erabili bistaratze efektu nabarmenak + Nabigatu bolumenaren botoiekin mezuak ikusterakoan Erakutsi Sarrera Ontzi Bateratua Erakutsi kontu izardunak Sarrerako ontzi bateratua @@ -992,7 +999,10 @@ Mesedez akatsen berri emateko, ezaugarri berriak gehitzeko eta galderak egiteko Posta hau OpenPGPrekin zifratua izan da.\nIrakurtzeko, OpenPGPrekin bateragarria den aplikazio bat instalatu eta konfiguratu behar duzu. Joan ezarpenetara K-9 Mezuen Zerrenda - Mezuak kargatzen… + + Kargatzen… + + Kargatzen… Zifratzea ez da posible Hautatutako hartzaile batzuek ez dute eginbide hau onartzen! Gaitu zifratzea 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 8f26a563ef..08bfd2eb45 100644 --- a/app/ui/legacy/src/main/res/values-fa/strings.xml +++ b/app/ui/legacy/src/main/res/values-fa/strings.xml @@ -993,7 +993,10 @@ این رایانامه با OpenPGP رمزنگاری شده است.\nبرای خواندن آن، باید برنامه‌ای سازگار با OpenPGP را نصب و پیکربندی کنید. برو به تنظیمات لیست پیام K-9 - بارگیری پیام‌ها… + + بارگیری… + + بارگیری… رمزنگاری ممکن نیست بعضی از گیرندگان انتخابی از این ویژگی پشتیبانی نمی‌کنند! فعال‌سازی رمزنگاری 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 ce9c3d94c6..c5ba53382e 100644 --- a/app/ui/legacy/src/main/res/values-fi/strings.xml +++ b/app/ui/legacy/src/main/res/values-fi/strings.xml @@ -999,7 +999,10 @@ Ilmoita virheistä, ota osaa sovelluskehitykseen ja esitä kysymyksiä osoittees Tämä viesti on salattu OpenPGP:llä.\nLukeaksesi viestin sinun tulee asentaa ja määrittää yhteensopiva OpenPGP-sovellus. Siirry asetuksiin K-9-viestiluettelo - Ladataan viestejä… + + Ladataan… + + Ladataan… Salaus ei ole mahdollista Jotkut valitut vastaanottajat eivät tue tätä ominaisuutta! Käytä salausta 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 afd30f4d18..b758d68eef 100644 --- a/app/ui/legacy/src/main/res/values-fr/strings.xml +++ b/app/ui/legacy/src/main/res/values-fr/strings.xml @@ -264,14 +264,19 @@ Rapportez les bogues, recommandez de nouvelles fonctions et posez vos questions Marquer tous les courriels comme lus Supprimer (d’une notification) + Actions de balayage + Balayer vers la droite + Balayer vers la gauche + Aucune + Sélectionner ou dessélectionner - Marquer comme lu/non lu + Marquer comme lu ou non lu - Ajouter/supprimer l\'étoile + Ajouter ou supprimer l’étoile Archiver @@ -700,6 +705,7 @@ Rapportez les bogues, recommandez de nouvelles fonctions et posez vos questions 1 000 dossiers Animation Utiliser des effets visuels voyants + Navigation avec la touche de volume dans la vue des courriels Afficher la boîte de réception unifiée Afficher le compte d’étoilés Boîte de réception unifiée @@ -1002,7 +1008,10 @@ Rapportez les bogues, recommandez de nouvelles fonctions et posez vos questions Ce courriel a été chiffré avec OpenPGP.\nPour le lire, vous devez installer et configurer une appli compatible avec OpenPGP. Aller dans Paramètres Liste des courriels de K-9 - Chargement des courriels… + + Chargement… + + Chargement… Le chiffrement est impossible Certains des destinataires sélectionnés ne prennent pas cette fonction en charge ! Activer le chiffrement 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 22f429d9fd..f8b9189fe6 100644 --- a/app/ui/legacy/src/main/res/values-fy/strings.xml +++ b/app/ui/legacy/src/main/res/values-fy/strings.xml @@ -995,7 +995,10 @@ Graach flaterrapporten stjoere, bydragen foar nije funksjes en fragen stelle op Dit e-mailberjocht is OpenPGP-fersifere.\nYnstallearje en stel in OpenPGP-app yn om it e-mailberjocht te lêzen. Gean nei Ynstellingen K-9-berjochtelist - Berjochten oan it laden… + + Lade… + + Lade… Fersifering net mooglik Guon fan de ûntfangers stypje dizze funksje net! Fersifering ynskeakelje 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 3eeabe1e42..2874a726d8 100644 --- a/app/ui/legacy/src/main/res/values-gd/strings.xml +++ b/app/ui/legacy/src/main/res/values-gd/strings.xml @@ -874,7 +874,10 @@ \nFeumaidh tu aplacaid a tha comasach air OpenPGP a stàladh is a rèiteachadh mus urrainn dhut a leughadh. Tagh aplacaid OpenPGP Liosta theachdaireachdan puist - A’ luchdadh nan teachdaireachdan… + + ’Ga luchdadh… + + ’Ga luchdadh… Cha ghabh a chrioptachadh Cha chuir gach faightear a thagh thu taic ris a’ ghleus seo! Cuir crioptachadh an comas 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 e700ecc478..a8e9c40719 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 @@ -650,6 +650,10 @@ Móbil Aceptar A cargar… + + A cargar… + + A cargar… Volver Axustes xerais 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 8a5d22d098..44e9ac4c48 100644 --- a/app/ui/legacy/src/main/res/values-gl/strings.xml +++ b/app/ui/legacy/src/main/res/values-gl/strings.xml @@ -956,7 +956,10 @@ Por favor envíen informes de fallos, contribúa con novas características e co Este correo foi cifrado con OpenPGP.\nPara lelo, precisa instalar e configurar un app compatible con OpenPGP Ir a Configuración Lista de mensaxes K-9 - Cargando mensaxes… + + A cargar… + + A cargar… Cifrado non posible Algún dos destinatarios seleccionados non soportan esta característica! Habilitar cifrado 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 8ca5662ace..64a0ad4529 100644 --- a/app/ui/legacy/src/main/res/values-hr/strings.xml +++ b/app/ui/legacy/src/main/res/values-hr/strings.xml @@ -881,7 +881,10 @@ Ova e-poruka je šifrirana korištenjem OpenPGP.\nDa bi ste je pročitali, potrebno je da instalirate i postavite odgovarajuću OpenPGP aplikaciju. Idi u Postavke K-9 Popis Poruka - Učitavam poruke… + + Učitavam… + + Učitavam… Šifriranje nije moguće Uključi šifriranje Onemogući šifriranje 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 c36fe75842..7066a303f9 100644 --- a/app/ui/legacy/src/main/res/values-hu/strings.xml +++ b/app/ui/legacy/src/main/res/values-hu/strings.xml @@ -991,7 +991,10 @@ Hibajelentések beküldésével közreműködhet az új funkciókban, és kérd Az e-mail titkosítva lett OpenPGP használatával.\nAz elolvasásához egy megfelelő OpenPGP alkalmazást kell telepítenie és beállítania. Ugrás a beállításokhoz K-9 üzenetlista - Üzenetek betöltése… + + Betöltés… + + Betöltés… A titkosítás nem lehetséges Néhány kiválasztott címzett nem támogatja ezt a funkciót! Titkosítás engedélyezése 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 95e8a7d34f..255b93aade 100644 --- a/app/ui/legacy/src/main/res/values-in/strings.xml +++ b/app/ui/legacy/src/main/res/values-in/strings.xml @@ -923,7 +923,10 @@ Kirimkan laporan bug, kontribusikan fitur baru dan ajukan pertanyaan di Email ini telah dienkripsi dengan OpenPGP. \nUntuk membacanya, Anda perlu menginstal dan mengkonfigurasi Aplikasi OpenPGP yang kompatibel. Pergi ke pengaturan Daftar pesan K-9 - Memuat pesan… + + Memuat… + + Memuat… Enkripsi tidak mungkin dilakukan Beberapa penerima yang dipilih tidak mendukung fitur ini! Aktifkan Enkripsi 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 7bb957726c..8b1c35ce71 100644 --- a/app/ui/legacy/src/main/res/values-is/strings.xml +++ b/app/ui/legacy/src/main/res/values-is/strings.xml @@ -994,7 +994,10 @@ Sendu inn villuskýrslur, leggðu fram nýja eiginleika og spurðu spurninga á Þessi tölvupóstur var dulritaður með OpenPGP.\nTil að lesa hann verður þú að setja upp og stilla samhæft OpenPGP-forrit. Fara í stillingar Skilaboðalisti K-9 - Hleður skilaboð… + + Hleður… + + Hleður… Dulritun er ekki möguleg Sumir valinna viðtakenda styðja ekki við þennan eiginleika! Virkja dulritun 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 17b79c0ffb..0de163288d 100644 --- a/app/ui/legacy/src/main/res/values-it/strings.xml +++ b/app/ui/legacy/src/main/res/values-it/strings.xml @@ -1001,7 +1001,10 @@ Invia segnalazioni di bug, contribuisci con nuove funzionalità e poni domande s Questo messaggio è stato cifrato con OpenPGP.\nPer leggerlo, devi installare e configurare un\'applicazione compatibile con OpenPGP. Vai alle impostazioni Elenco messaggi di K-9 - Caricamento messaggi… + + Caricamento in corso… + + Caricamento in corso… Cifratura non possibile Alcuni dei destinatari selezionati non supportano questa funzionalità! Abilita cifratura 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 3545b28a19..db44e38f69 100644 --- a/app/ui/legacy/src/main/res/values-iw/strings.xml +++ b/app/ui/legacy/src/main/res/values-iw/strings.xml @@ -614,6 +614,8 @@ אחר סלולרי אישור + + הגדרות כלליות 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 49a2432254..6d814e65e9 100644 --- a/app/ui/legacy/src/main/res/values-ja/strings.xml +++ b/app/ui/legacy/src/main/res/values-ja/strings.xml @@ -994,7 +994,10 @@ K-9 は大多数のメールクライアントと同様に、ほとんどのフ このメールは OpenPGP で暗号化されました。\n読むためには、OpenPGP と互換のアプリをインストールして設定する必要があります。 設定に移動 K-9 メッセージ一覧 - メッセージ読み込み中… + + 読み込み中… + + 読み込み中… 暗号化不可 選択した受信者の一部が、この機能をサポートしていません! 暗号化を有効にする 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 70ea6e96ad..e2e0f709e9 100644 --- a/app/ui/legacy/src/main/res/values-ko/strings.xml +++ b/app/ui/legacy/src/main/res/values-ko/strings.xml @@ -766,6 +766,10 @@ 모든 서명이 보일 것입니다 서명되지 않은 글 설정으로 움직이기 + + 로딩 중… + + 로딩 중… 암호화가 불가능합니다 선택된 몇몇 받는 사람들은 이 기능을 지원하지 않습니다! 암호화 사용 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 c9c96a8b46..ca9b1384d2 100644 --- a/app/ui/legacy/src/main/res/values-lt/strings.xml +++ b/app/ui/legacy/src/main/res/values-lt/strings.xml @@ -999,7 +999,10 @@ Pateikite pranešimus apie klaidas, prisidėkite prie naujų funkcijų kūrimo i Šis el. laiškas užšifruotas naudojant OpenPGP.\nKad jį perskaitytumėte, turite įdiegti ir sukonfigūruoti suderinamą OpenPGP programėlę. Eiti į Nustatymus K-9 laiškų sąrašas - Įkeliami laiškai... + + Įkeliama… + + Įkeliama… Šifravimas neįmanomas Kai kurie iš pasirinktų gavėjų nepalaiko šios funkcijos! Įjungti šifravimą 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 6173ce1748..ccd3112474 100644 --- a/app/ui/legacy/src/main/res/values-lv/strings.xml +++ b/app/ui/legacy/src/main/res/values-lv/strings.xml @@ -962,7 +962,10 @@ pat %d vairāk Šī vēstule ir šifrēta ar OpenPGP. Lai to apskatītos, jāinstalē un jāiestata saderīga OpenPGP lietotne Doties uz iestatījumiem K-9 Vēstuļu saraksts - Ielādē vēstules… + + Ielādē… + + Ielādē… Nevar iešifrēt Daži no izvēlētajiem saņēmējiem šo iespēju neatbalsta! Ieslēgt šifrēšanu 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 e151be90a8..bfd3982118 100644 --- a/app/ui/legacy/src/main/res/values-ml/strings.xml +++ b/app/ui/legacy/src/main/res/values-ml/strings.xml @@ -956,7 +956,10 @@ ഈ ഇമെയിൽ ഓപ്പൺപിജിപി ഉപയോഗിച്ച് എൻക്രിപ്റ്റുചെയ്‌തതാണ്.\nഇത് വായിക്കാൻ, നിങ്ങൾ ഒരു അനുയോജ്യമായ ഓപ്പൺപിജിപി അപ്ലിക്കേഷൻ ഇൻസ്റ്റാളുചെയ്‌ത് ക്രമീകരിക്കേണ്ടതുണ്ട്. ക്രമീകരണങ്ങളിലേക്ക് പോകുക കെ-9 സന്ദേശ പട്ടിക - സന്ദേശങ്ങൾ ലോഡുചെയ്യുന്നു… + + ലോഡുചെയ്യുന്നു… + + ലോഡുചെയ്യുന്നു… എൻക്രിപ്ഷൻ സാധ്യമല്ല തിരഞ്ഞെടുത്ത ചില സ്വീകർത്താക്കൾ ഈ സവിശേഷതയെ പിന്തുണയ്ക്കുന്നില്ല! എൻക്രിപ്ഷൻ പ്രാപ്തമാക്കുക 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 c6e353124e..d2a64cd6b8 100644 --- a/app/ui/legacy/src/main/res/values-nb/strings.xml +++ b/app/ui/legacy/src/main/res/values-nb/strings.xml @@ -913,7 +913,10 @@ til %d flere Denne e-posten har blitt kryptert med OpenPGP.\nFor å lese den, må du installere og sette opp et kompatibelt OpenPGP-program. Gå til \"Innstillinger\" K-9 meldingsliste - Laster inn meldinger… + + Laster… + + Laster… Kryptering er ikke mulig Noen av de valgte mottakerne støtter ikke denne funksjonen! Skru på kryptering 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 07e27f187c..0d49caafb6 100644 --- a/app/ui/legacy/src/main/res/values-nl/strings.xml +++ b/app/ui/legacy/src/main/res/values-nl/strings.xml @@ -995,7 +995,10 @@ Graag foutrapporten sturen, bijdragen voor nieuwe functies en vragen stellen op Dit e-mailbericht is OpenPGP versleuteld.\nInstalleer en stel een OpenPGP-app in om het e-mailbericht te lezen. Ga naar Instellingen K-9-berichtenlijst - Berichten aan het laden… + + Laden… + + Laden… Versleuteling niet mogelijk Sommige van de ontvangers ondersteunen deze functie niet! Versleuteling inschakelen 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 4ceb729b93..6dfcd3bf70 100644 --- a/app/ui/legacy/src/main/res/values-pl/strings.xml +++ b/app/ui/legacy/src/main/res/values-pl/strings.xml @@ -1014,7 +1014,10 @@ Wysłane z urządzenia Android za pomocą K-9 Mail. Proszę wybaczyć moją zwi Ta wiadomość e-mail została zaszyfrowana z użyciem OpenPGP.\n Aby ją odczytać, należy zainstalować i skonfigurować zgodną aplikację OpenPGP. Przejdź do ustawień Lista wiadomości K-9 - Ładowanie wiadomości… + + Ładowanie… + + Ładowanie… Szyfrowanie nie możliwe Część z wybranych odbiorców nie obsługuje tej funkcjonalności. Włącz szyfrowanie 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 2fca8fca62..bb1ab6a5c0 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 @@ -262,15 +262,21 @@ Por favor encaminhe relatórios de bugs, contribua com novos recursos e tire dú Marcar todas as mensagens como lidas Excluir (na notificação) + Ações de deslizamento + Deslizar para a direita + Deslizar para a esquerda Nenhuma + Alternar a seleção + Marcar como lida/não lida + Adicionar/remover estrela - Arquivadas + Arquivar Excluir @@ -1000,7 +1006,10 @@ Por favor encaminhe relatórios de bugs, contribua com novos recursos e tire dú Esta mensagem foi criptografada com OpenPGP.\nPara ler, precisa instalar e configurar um aplicativo OpenPGP compatível. Ir para as configurações Lista de mensagens do K-9 - Carregando mensagens… + + Carregando… + + Carregando… Não foi possível criptografar Alguns dos destinatários selecionados não têm suporte a este recurso! Habilitar a criptografia 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 f14f823e51..0ea4b36c05 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 @@ -993,7 +993,10 @@ Por favor envie relatórios de falhas, contribua com novas funcionalidades e col Este email foi encriptado com OpenPGP.\nPara o ler, necessita de instalar e configurar uma aplicação OpenPGP compatível. Ir para as configurações K-9 Lista de mensagens - A carregar mensagens… + + A carregar… + + A carregar… Não é possível encriptar Alguns dos destinatários selecionados não suportam esta funcionalidade! Ativar encriptação 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 610a85ca6e..1ead48d202 100644 --- a/app/ui/legacy/src/main/res/values-ro/strings.xml +++ b/app/ui/legacy/src/main/res/values-ro/strings.xml @@ -1003,7 +1003,10 @@ Uneori datorită faptului că cineva încearcă să te atace pe tine sau serveru Acest mesaj a fost criptat cu OpenPGP.\nPentru a-l citi trebuie să instalați și să configurați o aplicație OpenPGP compatibilă. Mergi la Setări Listă mesaje K-9 - Se încarcă mesajele… + + Se încarcă… + + Se încarcă… Criptarea nu este posibilă Unii dintre destinatarii selectați nu suportă această facilitate! Activează criptarea 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 34a5375ff2..bcade6a6c7 100644 --- a/app/ui/legacy/src/main/res/values-ru/strings.xml +++ b/app/ui/legacy/src/main/res/values-ru/strings.xml @@ -63,7 +63,7 @@ K-9 Mail — почтовый клиент для Android. Авторы О почте K-9 - Ящики + Учётные записи Дополнительно Новое Ответ @@ -85,8 +85,8 @@ K-9 Mail — почтовый клиент для Android. Ответить Ответить всем Удалить - В архив - В спам + Архивировать + Отправить в спам Переслать Переслать вложением Редактировать как новое сообщение @@ -164,7 +164,7 @@ K-9 Mail — почтовый клиент для Android. Удалить Удалить все Архив - В архив все + Архивировать все Спам Ошибка сертификата Сбой сертификата %s @@ -362,11 +362,11 @@ K-9 Mail — почтовый клиент для Android. Вручную Автообласть имён IMAP Префикс пути IMAP - Папка черновиков - Папка отправленных - Папка удалённых - Папка архивных - Папка спама + Папка черновики + Папка отправленные + Папка корзина/удалённые + Папка архив + Папка спам Только подписанные папки Автопереход в папку Путь OWA @@ -525,26 +525,26 @@ K-9 Mail — почтовый клиент для Android. 1 год Видимость папок Все - 1-й класс - 1-й и 2-й классы - Кроме 2-го класса + Папки только 1-го класса + Папки 1-го и 2-го классов + Все, кроме папок 2-го класса Проверка папок Все - 1-й класс - 1-й и 2-й классы - Кроме 2-го класса + Папки только 1-го класса + Папки 1-го и 2-го классов + Все, кроме папок 2-го класса Нет Push Все - 1-й класс - 1-й и 2-й классы - Кроме 2-го класса + Папки только 1-го класса + Папки 1-го и 2-го классов + Все, кроме папок 2-го класса Нет Приёмник копирования Все - 1-й класс - 1-й и 2-й классы - Кроме 2-го класса + Папки только 1-го класса + Папки 1-го и 2-го классов + Все, кроме папок 2-го класса Синхронное удаление Удалять сообщения при удалении на сервере Отсутствует OpenPGP-приложение – было удалено? @@ -661,8 +661,8 @@ K-9 Mail — почтовый клиент для Android. Поиск папки Видимость папок Все папки - 1-й класс - 1-й и 2-й класс + Папки 1-го класса + Папки 1-го и 2-го классов Кроме 2-го класса Размещение подписи Перед цитатой @@ -796,8 +796,8 @@ K-9 Mail — почтовый клиент для Android. Экспорт настроек… Настройки успешно экспортированы Сбой экспорта настроек - Импорт - Выберите файл + Импортировать настройки + Выбрать файл Импорт Настройки успешно импортированы @@ -1013,7 +1013,10 @@ K-9 Mail — почтовый клиент для Android. Сообщение зашифровано OpenPGP.\nЧтобы прочесть его, необходимо установить и настроить подходящее OpenPGP-приложение. В Настройки Сообщения K-9 - Загрузка сообщений… + + Загрузка… + + Загрузка… Шифрование невозможно Не все выбранные адресаты поддерживают эту возможность! Включить шифрование 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 ef7a02bd91..8eb5d1d2e9 100644 --- a/app/ui/legacy/src/main/res/values-sk/strings.xml +++ b/app/ui/legacy/src/main/res/values-sk/strings.xml @@ -887,7 +887,10 @@ Prosím, nahlasujte prípadné chyby, prispievajte novými funkciami a pýtajte Táto správa je zašifrovaná Táto správa bola zašifrovaná pomocou OpenPGP\nJe potrebné nakonfigurovať kompatibilnú OpenPGP aplikáciu. Zoznam správ K-9 - Načítavajú sa správy… + + Načítavam… + + Načítavam… Späť Všeobecné nastavenia 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 c11bf74af3..782ec54963 100644 --- a/app/ui/legacy/src/main/res/values-sl/strings.xml +++ b/app/ui/legacy/src/main/res/values-sl/strings.xml @@ -1007,7 +1007,10 @@ dodatnih %d sporočil Sporočilo je šifrirano s programom OpenPGP.\nZa ogled vsebine je treba namestiti ustrezen podprt program OpenPGP. Pojdi v Nastavitve Seznami sporočil - Poteka nalaganje sporočil … + + Poteka nalaganje … + + Poteka nalaganje … Šifriranje ni mogoče Nekateri izmed izbranih prejemnikov ne podpirajo te možnosti! Omogoči šifriranje 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 7f39c0ad9c..71843b095e 100644 --- a/app/ui/legacy/src/main/res/values-sq/strings.xml +++ b/app/ui/legacy/src/main/res/values-sq/strings.xml @@ -999,7 +999,10 @@ Ju lutemi, parashtrim njoftimesh për të meta, kontribute për veçori të reaj Ky email është fshehtëzuar me OpenPGP.\nQë ta lexoni, lypset të instaloni dhe formësoni një aplikacion të përputhshëm me OpenPGP-në. Kaloni te Rregullimet Listë K-9 Mesazhesh - Po ngarkohen mesazhe… + + Po ngarkohet… + + Po ngarkohet… Fshehtëzim jo i mundshëm Disa nga marrësit e përzgjedhur nuk e mbulojnë këtë veçori! Aktivizo Fshehtëzimin 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 a303c516ad..01a1f7faae 100644 --- a/app/ui/legacy/src/main/res/values-sr/strings.xml +++ b/app/ui/legacy/src/main/res/values-sr/strings.xml @@ -937,7 +937,10 @@ Ова е-порука је шифрована помоћу у ОпенПГП-а.\nДа бисте је прочитали, треба да инсталирате и подесите компатибилну ОпенПГП апликацију. Иди на Поставке К-9 списак порука - Учитавам поруке… + + Учитавам… + + Учитавам… Шифровање није могуће Неки од изабраних прималаца не подржавају ово! Укључи шифровање 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 8e315a0f45..03eb42d3d5 100644 --- a/app/ui/legacy/src/main/res/values-sv/strings.xml +++ b/app/ui/legacy/src/main/res/values-sv/strings.xml @@ -1000,7 +1000,10 @@ Skicka in felrapporter, bidra med nya funktioner och ställ frågor på Detta e-postmeddelande har krypterats med OpenPGP.\nFör att läsa det, måste du installera och konfigurera en kompatibel OpenPGP-app. Gå till inställningar K-9 Meddelandelista - Läser in meddelanden… + + Läser in… + + Läser in… Kryptering inte möjligt Några av de valda mottagarna stöder inte denna funktion! Aktivera kryptering 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 32eabc056f..ffe13fecd8 100644 --- a/app/ui/legacy/src/main/res/values-tr/strings.xml +++ b/app/ui/legacy/src/main/res/values-tr/strings.xml @@ -974,7 +974,10 @@ Microsoft Exchange ile konuşurken bazı tuhaflıklar yaşadığını not ediniz Bu eposta OpenPGP ile şifrelendi. On okumak için uyumlu bir OpenPGP uygulaması kurup yapılandırmanız gerekir. Ayarlara Git K-9 İleti Listesi - İletiler Yükleniyor… + + Yükleniyor… + + Yükleniyor… Şifreleme yapılamaz Seçilen bazı alıcılar bu özelliği desteklemiyor! Şifreleme etkin 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 e660a10f3a..58f3149f89 100644 --- a/app/ui/legacy/src/main/res/values-uk/strings.xml +++ b/app/ui/legacy/src/main/res/values-uk/strings.xml @@ -1008,7 +1008,10 @@ K-9 Mail — це вільний клієнт електронної пошти Цей електронний лист було зашифровано за допомогою OpenPGP.\nЩоб його прочитати, необхідно встановити та налаштувати сумісний OpenPGP-додаток. Перейти до Налаштувань K-9 Список повідомлень - Завантаження листів… + + Завантаження… + + Завантаження… Шифрування неможливе Деякі з вибраних адресатів не підтримують цю функцію. Увімкнути шифрування 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 eea1b52a99..42b68edb10 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 @@ -993,7 +993,10 @@ K-9 Mail 是 Android 上一款功能强大的免费邮件客户端。 这份邮件使用 OpenPGP 进行了加密。\n请安装并配置一个兼容 OpenPGP 的应用来查看邮件。 前往设置 K-9消息列表 - 正在加载邮件…… + + 加载中… + + 加载中… 无法加密 某些选定的收件人不支持此功能! 启用加密 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 ddf2ace0f2..26f5db4e7b 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 @@ -986,7 +986,10 @@ K-9 Mail 是 Android 上一款功能強大,免費的電子郵件用戶端。 這封郵件使用 OpenPGP 進行了加密。\n請安裝並設定一個相容 OpenPGP 的應用程式來查看郵件。 前往設定 K-9 訊息列表 - 正在載入郵件… + + 載入中… + + 載入中… 無法加密 有些已選定的收件人不支援此功能! 啟用加密 -- GitLab From 2d9be7a0b69180bd0518ba3935d44721e475e227 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 24 Oct 2022 14:14:41 +0200 Subject: [PATCH 074/121] Version 6.310 --- app/k9mail/build.gradle | 4 ++-- app/ui/legacy/src/main/res/raw/changelog_master.xml | 7 +++++++ fastlane/metadata/android/en-US/changelogs/33010.txt | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/33010.txt diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index e42f384922..7c78066955 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -51,8 +51,8 @@ android { applicationId "com.fsck.k9" testApplicationId "com.fsck.k9.tests" - versionCode 33009 - versionName '6.310-SNAPSHOT' + versionCode 33010 + versionName '6.310' // 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/ui/legacy/src/main/res/raw/changelog_master.xml b/app/ui/legacy/src/main/res/raw/changelog_master.xml index 4659d09eb9..f582ff2b64 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,13 @@ Locale-specific versions are kept in res/raw-/changelog.xml. --> + + Fixed "K-9 Accounts" shortcuts (you probably have to remove existing shortcuts and add them again) + Fixed a couple of bugs and display issues in the message list widget + Fixed bug that could lead to a crypto provider popup showing before swiping to the next/previous message was completed + Some other minor bug fixes + Updated translations + Automatically scroll to the top after using pull to refresh, so new messages are visible Fixed a bug that prevented the app from establishing a connection to the outgoing server under certain circumstances diff --git a/fastlane/metadata/android/en-US/changelogs/33010.txt b/fastlane/metadata/android/en-US/changelogs/33010.txt new file mode 100644 index 0000000000..fec633ff60 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33010.txt @@ -0,0 +1,5 @@ +- Fixed "K-9 Accounts" shortcuts (you probably have to remove existing shortcuts and add them again) +- Fixed a couple of bugs and display issues in the message list widget +- Fixed bug that could lead to a crypto provider popup showing before swiping to the next/previous message was completed +- Some other minor bug fixes +- Updated translations -- GitLab From a51795c5e8167eb052e14862ffdfe3e7f945b5cb Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 24 Oct 2022 14:35:30 +0200 Subject: [PATCH 075/121] Prepare for version 6.311 --- app/k9mail/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 7c78066955..89193d0473 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -52,7 +52,7 @@ android { testApplicationId "com.fsck.k9.tests" versionCode 33010 - versionName '6.310' + versionName '6.311-SNAPSHOT' // 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", -- GitLab From aeb20e20c7cf92bf0bb7589ff33bbb62b23f0e0d Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 24 Oct 2022 19:09:13 +0200 Subject: [PATCH 076/121] Ignore clicks on views for messages that have been removed from the list --- .../fsck/k9/ui/messagelist/MessageListAdapter.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt index 49988afb8d..4072c6bd2c 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt @@ -162,13 +162,14 @@ class MessageListAdapter internal constructor( } private val messageClickedListener = OnClickListener { view: View -> - val messageListItem = getItemFromView(view) + val messageListItem = getItemFromView(view) ?: return@OnClickListener listItemListener.onMessageClicked(messageListItem) } private val messageLongClickedListener = OnLongClickListener { view: View -> - val messageListItem = getItemFromView(view) - listItemListener.onToggleMessageSelection(messageListItem) + getItemFromView(view)?.let { messageListItem -> + listItemListener.onToggleMessageSelection(messageListItem) + } true } @@ -177,13 +178,13 @@ class MessageListAdapter internal constructor( } private val flagClickListener = OnClickListener { view: View -> - val messageListItem = getItemFromView(view) + val messageListItem = getItemFromView(view) ?: return@OnClickListener listItemListener.onToggleMessageFlag(messageListItem) } private val contactPictureClickListener = OnClickListener { view: View -> val parentView = view.parent.parent as View - val messageListItem = getItemFromView(parentView) + val messageListItem = getItemFromView(parentView) ?: return@OnClickListener listItemListener.onToggleMessageSelection(messageListItem) } @@ -516,9 +517,9 @@ class MessageListAdapter internal constructor( .sumOf { it.threadCount.coerceAtLeast(1) } } - private fun getItemFromView(view: View): MessageListItem { + private fun getItemFromView(view: View): MessageListItem? { val messageViewHolder = view.tag as MessageViewHolder - return getItemById(messageViewHolder.uniqueId) ?: error("Couldn't find MessageListItem by View") + return getItemById(messageViewHolder.uniqueId) } } -- GitLab From 893a6900ddc1574c56205b720679bfa5446b05f7 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 24 Oct 2022 20:50:35 +0200 Subject: [PATCH 077/121] Don't throw when calling `MessageStore.getMessageServerId()` Return `null` when the message can no longer be found in the message store. --- .../com/fsck/k9/controller/DraftOperations.kt | 6 ++++-- .../fsck/k9/controller/MessagingController.java | 15 +++++++++------ .../java/com/fsck/k9/mailstore/MessageStore.kt | 2 +- .../fsck/k9/storage/messages/K9MessageStore.kt | 2 +- .../storage/messages/RetrieveMessageOperations.kt | 5 ++--- .../messages/RetrieveMessageOperationsTest.kt | 7 ++++--- 6 files changed, 21 insertions(+), 16 deletions(-) diff --git a/app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt b/app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt index b613b650e6..83963f052d 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt +++ b/app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt @@ -75,8 +75,10 @@ internal class DraftOperations( messagingController.queuePendingCommand(account, command) } else { val fakeMessageServerId = messageStore.getMessageServerId(messageId) - val command = PendingAppend.create(folderId, fakeMessageServerId) - messagingController.queuePendingCommand(account, command) + if (fakeMessageServerId != null) { + val command = PendingAppend.create(folderId, fakeMessageServerId) + messagingController.queuePendingCommand(account, command) + } } messagingController.processPendingCommands(account) 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 0d9ed85cf1..8d503e35d2 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 @@ -1615,9 +1615,11 @@ public class MessagingController { if (!sentFolder.isLocalOnly()) { String destinationUid = messageStore.getMessageServerId(destinationMessageId); - PendingCommand command = PendingAppend.create(sentFolderId, destinationUid); - queuePendingCommand(account, command); - processPendingCommands(account); + if (destinationUid != null) { + PendingCommand command = PendingAppend.create(sentFolderId, destinationUid); + queuePendingCommand(account, command); + processPendingCommands(account); + } } } @@ -1901,9 +1903,10 @@ public class MessagingController { MessageStore messageStore = messageStoreManager.getMessageStore(account); String messageServerId = messageStore.getMessageServerId(messageId); - MessageReference messageReference = new MessageReference(account.getUuid(), folderId, messageServerId); - - deleteMessage(messageReference); + if (messageServerId != null) { + 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/mailstore/MessageStore.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt index 724f78083a..75c292e237 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 @@ -91,7 +91,7 @@ interface MessageStore { /** * Retrieve the server ID for a given message. */ - fun getMessageServerId(messageId: Long): String + fun getMessageServerId(messageId: Long): String? /** * Retrieve the server IDs for the given messages. 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 ee69f27d90..ca1e7dec6a 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 @@ -78,7 +78,7 @@ class K9MessageStore( updateMessageOperations.clearNewMessageState() } - override fun getMessageServerId(messageId: Long): String { + override fun getMessageServerId(messageId: Long): String? { return retrieveMessageOperations.getMessageServerId(messageId) } diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt index a57bfee541..c094ae3b3c 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt @@ -4,7 +4,6 @@ import androidx.core.database.getLongOrNull import com.fsck.k9.K9 import com.fsck.k9.mail.Flag import com.fsck.k9.mail.Header -import com.fsck.k9.mail.MessagingException import com.fsck.k9.mail.internet.MimeHeader import com.fsck.k9.mail.message.MessageHeaderParser import com.fsck.k9.mailstore.LockableDatabase @@ -13,7 +12,7 @@ import java.util.Date internal class RetrieveMessageOperations(private val lockableDatabase: LockableDatabase) { - fun getMessageServerId(messageId: Long): String { + fun getMessageServerId(messageId: Long): String? { return lockableDatabase.execute(false) { database -> database.query( "messages", @@ -25,7 +24,7 @@ internal class RetrieveMessageOperations(private val lockableDatabase: LockableD if (cursor.moveToFirst()) { cursor.getString(0) } else { - throw MessagingException("Message [ID: $messageId] not found in database") + null } } } diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt index 5825dccec9..09dccb31d7 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt @@ -2,7 +2,6 @@ package com.fsck.k9.storage.messages import com.fsck.k9.mail.Flag import com.fsck.k9.mail.Header -import com.fsck.k9.mail.MessagingException import com.fsck.k9.mail.crlf import com.fsck.k9.storage.RobolectricTest import com.google.common.truth.Truth.assertThat @@ -14,9 +13,11 @@ class RetrieveMessageOperationsTest : RobolectricTest() { private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) private val retrieveMessageOperations = RetrieveMessageOperations(lockableDatabase) - @Test(expected = MessagingException::class) + @Test fun `get message server id of non-existent message`() { - retrieveMessageOperations.getMessageServerId(42) + val messageServerId = retrieveMessageOperations.getMessageServerId(42) + + assertThat(messageServerId).isNull() } @Test -- GitLab From e251ec7f579d0e6c7900a689e6c684051d644ead Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 24 Oct 2022 23:10:45 +0200 Subject: [PATCH 078/121] Add more logging for creating/removing notifications --- .../fsck/k9/controller/MessagingController.java | 1 - .../fsck/k9/notification/NotificationController.kt | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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 8d503e35d2..81f86dcc0c 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 @@ -2654,7 +2654,6 @@ public class MessagingController { LocalFolder localFolder = message.getFolder(); if (!suppressNotifications && notificationStrategy.shouldNotifyForMessage(account, localFolder, message, isOldMessage)) { - Timber.v("Creating notification for message %s:%s", localFolder.getName(), message.getUid()); // Notify with the localMessage so that we don't have to recalculate the content preview. boolean silent = notificationState.wasNotified(); notificationController.addNewMailNotification(account, message, silent); diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt index fe0144108e..6d506852ee 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt @@ -4,6 +4,7 @@ import com.fsck.k9.Account import com.fsck.k9.controller.MessageReference import com.fsck.k9.mailstore.LocalFolder import com.fsck.k9.mailstore.LocalMessage +import timber.log.Timber class NotificationController internal constructor( private val certificateErrorNotificationController: CertificateErrorNotificationController, @@ -61,20 +62,33 @@ class NotificationController internal constructor( } fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean) { + Timber.v( + "Creating notification for message %s:%s:%s", + message.account.uuid, + message.folder.databaseId, + message.uid + ) + newMailNotificationController.addNewMailNotification(account, message, silent) } fun removeNewMailNotification(account: Account, messageReference: MessageReference) { + Timber.v("Removing notification for message %s", messageReference) + newMailNotificationController.removeNewMailNotifications(account, clearNewMessageState = true) { listOf(messageReference) } } fun clearNewMailNotifications(account: Account, selector: (List) -> List) { + Timber.v("Removing some notifications for account %s", account.uuid) + newMailNotificationController.removeNewMailNotifications(account, clearNewMessageState = false, selector) } fun clearNewMailNotifications(account: Account, clearNewMessageState: Boolean) { + Timber.v("Removing all notifications for account %s", account.uuid) + newMailNotificationController.clearNewMailNotifications(account, clearNewMessageState) } } -- GitLab From a7fcf9f607258f311f6c7b405b31cfaf1869421a Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 25 Oct 2022 11:49:45 +0200 Subject: [PATCH 079/121] Only let the active message view update the toolbar menu --- .../com/fsck/k9/ui/messageview/MessageViewFragment.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 944e4e6cbe..e2d39f5659 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 @@ -107,6 +107,12 @@ class MessageViewFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Hide the toolbar menu when first creating this fragment. The menu will be set to visible once this fragment + // becomes the active page of the view pager in MessageViewContainerFragment. + if (savedInstanceState == null) { + setMenuVisibility(false) + } + setHasOptionsMenu(true) messageReference = MessageReference.parse(arguments?.getString(ARG_REFERENCE)) @@ -178,9 +184,10 @@ class MessageViewFragment : } override fun setMenuVisibility(menuVisible: Boolean) { - super.setMenuVisibility(menuVisible) isActive = menuVisible + super.setMenuVisibility(menuVisible) + if (menuVisible) { messageLoaderHelper.resumeCryptoOperationIfNecessary() } else { @@ -207,6 +214,8 @@ class MessageViewFragment : } override fun onPrepareOptionsMenu(menu: Menu) { + if (!isActive) return + menu.findItem(R.id.delete).apply { isVisible = K9.isMessageViewDeleteActionVisible isEnabled = !isDeleteMenuItemDisabled -- GitLab From 4b528fc8b4724140a55f86e2bf62d280b249f091 Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 25 Oct 2022 17:36:04 +0200 Subject: [PATCH 080/121] Don't use smooth scrolling when moving to previous/next message This seems to work around a bug where sometimes the scroll operation isn't completed and the `MessageViewFragment` being scrolled to is never marked as active. See #6346. --- .../k9/ui/messageview/MessageViewContainerFragment.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 4501c25075..f5ffabb4da 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 @@ -172,9 +172,7 @@ class MessageViewContainerFragment : Fragment() { val newPosition = viewPager.currentItem - 1 return if (newPosition >= 0) { setActiveMessage(newPosition) - - val smoothScroll = true - viewPager.setCurrentItem(newPosition, smoothScroll) + viewPager.setCurrentItem(newPosition, /* smoothScroll = */ false) true } else { false @@ -185,9 +183,7 @@ class MessageViewContainerFragment : Fragment() { val newPosition = viewPager.currentItem + 1 return if (newPosition < adapter.itemCount) { setActiveMessage(newPosition) - - val smoothScroll = true - viewPager.setCurrentItem(newPosition, smoothScroll) + viewPager.setCurrentItem(newPosition, /* smoothScroll = */ false) true } else { false -- GitLab From e91e1e49bf8d633ab69f55651c7f976d36fc5f6a Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 25 Oct 2022 18:10:18 +0200 Subject: [PATCH 081/121] Ignore page change events to an item that is no longer in the adapter --- .../MessageViewContainerFragment.kt | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) 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 f5ffabb4da..d5dd3509f8 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 @@ -145,20 +145,19 @@ class MessageViewContainerFragment : Fragment() { } private fun setActiveMessage(position: Int) { - // 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) + val newMessageReference = adapter.getMessageReference(position) ?: return if (newMessageReference == activeMessageReference) { + // 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. currentPosition = position return } rememberNavigationDirection(position) - messageReference = adapter.getMessageReference(position) - - activeMessageReference = messageReference - fragmentListener.setActiveMessage(messageReference) + messageReference = newMessageReference + activeMessageReference = newMessageReference + fragmentListener.setActiveMessage(newMessageReference) } private fun rememberNavigationDirection(newPosition: Int) { @@ -261,10 +260,8 @@ class MessageViewContainerFragment : Fragment() { return MessageViewFragment.newInstance(messageReference) } - fun getMessageReference(position: Int): MessageReference { - check(position in messageList.indices) - - return messageList[position].messageReference + fun getMessageReference(position: Int): MessageReference? { + return messageList.getOrNull(position)?.messageReference } fun getPosition(messageReference: MessageReference): Int { -- GitLab From 880b4d88b1d7211c2d2b2507b6781b8aa9bfee28 Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 26 Oct 2022 18:48:52 +0200 Subject: [PATCH 082/121] Remove `DiscoveryTarget` --- .../api/ConnectionSettingsDiscovery.kt | 8 +-- .../providersxml/ProvidersXmlDiscovery.kt | 3 +- .../providersxml/ProvidersXmlDiscoveryTest.kt | 5 +- .../srvrecords/SrvServiceDiscovery.kt | 19 +++--- .../srvrecords/SrvServiceDiscoveryTest.kt | 60 ++----------------- .../thunderbird/ThunderbirdDiscovery.kt | 3 +- .../k9/activity/setup/AccountSetupBasics.kt | 3 +- 7 files changed, 19 insertions(+), 82 deletions(-) diff --git a/app/autodiscovery/api/src/main/java/com/fsck/k9/autodiscovery/api/ConnectionSettingsDiscovery.kt b/app/autodiscovery/api/src/main/java/com/fsck/k9/autodiscovery/api/ConnectionSettingsDiscovery.kt index 90eb4b6f91..4ef3af5800 100644 --- a/app/autodiscovery/api/src/main/java/com/fsck/k9/autodiscovery/api/ConnectionSettingsDiscovery.kt +++ b/app/autodiscovery/api/src/main/java/com/fsck/k9/autodiscovery/api/ConnectionSettingsDiscovery.kt @@ -4,13 +4,7 @@ import com.fsck.k9.mail.AuthType import com.fsck.k9.mail.ConnectionSecurity interface ConnectionSettingsDiscovery { - fun discover(email: String, target: DiscoveryTarget): DiscoveryResults? -} - -enum class DiscoveryTarget(val outgoing: Boolean, val incoming: Boolean) { - OUTGOING(true, false), - INCOMING(false, true), - INCOMING_AND_OUTGOING(true, true) + fun discover(email: String): DiscoveryResults? } data class DiscoveryResults(val incoming: List, val outgoing: List) diff --git a/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscovery.kt b/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscovery.kt index b2dd7bd219..fa8aff880a 100644 --- a/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscovery.kt +++ b/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscovery.kt @@ -5,7 +5,6 @@ import android.net.Uri import com.fsck.k9.autodiscovery.api.ConnectionSettingsDiscovery import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings import com.fsck.k9.autodiscovery.api.DiscoveryResults -import com.fsck.k9.autodiscovery.api.DiscoveryTarget import com.fsck.k9.helper.EmailHelper import com.fsck.k9.mail.AuthType import com.fsck.k9.mail.ConnectionSecurity @@ -19,7 +18,7 @@ class ProvidersXmlDiscovery( private val oAuthConfigurationProvider: OAuthConfigurationProvider ) : ConnectionSettingsDiscovery { - override fun discover(email: String, target: DiscoveryTarget): DiscoveryResults? { + override fun discover(email: String): DiscoveryResults? { val domain = EmailHelper.getDomainFromEmailAddress(email) ?: return null val provider = findProviderForDomain(domain) ?: return null diff --git a/app/autodiscovery/providersxml/src/test/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscoveryTest.kt b/app/autodiscovery/providersxml/src/test/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscoveryTest.kt index 4e7d3f329e..f91ce3b38d 100644 --- a/app/autodiscovery/providersxml/src/test/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscoveryTest.kt +++ b/app/autodiscovery/providersxml/src/test/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscoveryTest.kt @@ -2,7 +2,6 @@ package com.fsck.k9.autodiscovery.providersxml import androidx.test.core.app.ApplicationProvider import com.fsck.k9.RobolectricTest -import com.fsck.k9.autodiscovery.api.DiscoveryTarget import com.fsck.k9.mail.AuthType import com.fsck.k9.mail.ConnectionSecurity import com.fsck.k9.oauth.OAuthConfiguration @@ -17,7 +16,7 @@ class ProvidersXmlDiscoveryTest : RobolectricTest() { @Test fun discover_withGmailDomain_shouldReturnCorrectSettings() { - val connectionSettings = providersXmlDiscovery.discover("user@gmail.com", DiscoveryTarget.INCOMING_AND_OUTGOING) + val connectionSettings = providersXmlDiscovery.discover("user@gmail.com") assertThat(connectionSettings).isNotNull() with(connectionSettings!!.incoming.first()) { @@ -37,7 +36,7 @@ class ProvidersXmlDiscoveryTest : RobolectricTest() { @Test fun discover_withUnknownDomain_shouldReturnNull() { val connectionSettings = providersXmlDiscovery.discover( - "user@not.present.in.providers.xml.example", DiscoveryTarget.INCOMING_AND_OUTGOING + "user@not.present.in.providers.xml.example" ) assertThat(connectionSettings).isNull() diff --git a/app/autodiscovery/srvrecords/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscovery.kt b/app/autodiscovery/srvrecords/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscovery.kt index af8e2eed45..9dbbfdbd76 100644 --- a/app/autodiscovery/srvrecords/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscovery.kt +++ b/app/autodiscovery/srvrecords/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscovery.kt @@ -3,7 +3,6 @@ package com.fsck.k9.autodiscovery.srvrecords import com.fsck.k9.autodiscovery.api.ConnectionSettingsDiscovery import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings import com.fsck.k9.autodiscovery.api.DiscoveryResults -import com.fsck.k9.autodiscovery.api.DiscoveryTarget import com.fsck.k9.helper.EmailHelper import com.fsck.k9.mail.AuthType import com.fsck.k9.mail.ConnectionSecurity @@ -12,19 +11,19 @@ class SrvServiceDiscovery( private val srvResolver: MiniDnsSrvResolver ) : ConnectionSettingsDiscovery { - override fun discover(email: String, target: DiscoveryTarget): DiscoveryResults? { + override fun discover(email: String): DiscoveryResults? { val domain = EmailHelper.getDomainFromEmailAddress(email) ?: return null val mailServicePriority = compareBy { it.priority }.thenByDescending { it.security } - val outgoingSettings = if (target.outgoing) - listOf(SrvType.SUBMISSIONS, SrvType.SUBMISSION).flatMap { srvResolver.lookup(domain, it) } - .sortedWith(mailServicePriority).map { newServerSettings(it, email) } - else listOf() + val outgoingSettings = listOf(SrvType.SUBMISSIONS, SrvType.SUBMISSION) + .flatMap { srvResolver.lookup(domain, it) } + .sortedWith(mailServicePriority) + .map { newServerSettings(it, email) } - val incomingSettings = if (target.incoming) - listOf(SrvType.IMAPS, SrvType.IMAP).flatMap { srvResolver.lookup(domain, it) } - .sortedWith(mailServicePriority).map { newServerSettings(it, email) } - else listOf() + val incomingSettings = listOf(SrvType.IMAPS, SrvType.IMAP) + .flatMap { srvResolver.lookup(domain, it) } + .sortedWith(mailServicePriority) + .map { newServerSettings(it, email) } return DiscoveryResults(incoming = incomingSettings, outgoing = outgoingSettings) } diff --git a/app/autodiscovery/srvrecords/src/test/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscoveryTest.kt b/app/autodiscovery/srvrecords/src/test/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscoveryTest.kt index 5123822d74..73425a50ff 100644 --- a/app/autodiscovery/srvrecords/src/test/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscoveryTest.kt +++ b/app/autodiscovery/srvrecords/src/test/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscoveryTest.kt @@ -1,15 +1,11 @@ package com.fsck.k9.autodiscovery.srvrecords -import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings import com.fsck.k9.autodiscovery.api.DiscoveryResults -import com.fsck.k9.autodiscovery.api.DiscoveryTarget import com.fsck.k9.mail.ConnectionSecurity import org.junit.Assert.assertEquals import org.junit.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoMoreInteractions class SrvServiceDiscoveryTest { @@ -18,7 +14,7 @@ class SrvServiceDiscoveryTest { val srvResolver = newMockSrvResolver() val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) - val result = srvServiceDiscovery.discover("test@example.com", DiscoveryTarget.INCOMING_AND_OUTGOING) + val result = srvServiceDiscovery.discover("test@example.com") assertEquals(DiscoveryResults(listOf(), listOf()), result) } @@ -33,7 +29,7 @@ class SrvServiceDiscoveryTest { ) val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) - val result = srvServiceDiscovery.discover("test@example.com", DiscoveryTarget.INCOMING_AND_OUTGOING) + val result = srvServiceDiscovery.discover("test@example.com") assertEquals(2, result!!.incoming.size) assertEquals(0, result.outgoing.size) @@ -57,7 +53,7 @@ class SrvServiceDiscoveryTest { ) val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) - val result = srvServiceDiscovery.discover("test@example.com", DiscoveryTarget.INCOMING_AND_OUTGOING) + val result = srvServiceDiscovery.discover("test@example.com") assertEquals(0, result!!.incoming.size) assertEquals(2, result.outgoing.size) @@ -133,7 +129,7 @@ class SrvServiceDiscoveryTest { ) val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) - val result = srvServiceDiscovery.discover("test@example.com", DiscoveryTarget.INCOMING_AND_OUTGOING) + val result = srvServiceDiscovery.discover("test@example.com") assertEquals( listOf( @@ -155,54 +151,6 @@ class SrvServiceDiscoveryTest { ) } - @Test - fun discover_whenOnlyOutgoingTrue_shouldOnlyFetchOutgoing() { - val srvResolver = newMockSrvResolver( - submissionServices = listOf( - newMailService( - host = "smtp.example.com", - port = 465, - srvType = SrvType.SUBMISSIONS, - security = ConnectionSecurity.SSL_TLS_REQUIRED, - priority = 0 - ) - ) - ) - - val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) - val result = srvServiceDiscovery.discover("test@example.com", DiscoveryTarget.OUTGOING) - - verify(srvResolver).lookup("example.com", SrvType.SUBMISSIONS) - verify(srvResolver).lookup("example.com", SrvType.SUBMISSION) - verifyNoMoreInteractions(srvResolver) - assertEquals(1, result!!.outgoing.size) - assertEquals(listOf(), result.incoming) - } - - @Test - fun discover_whenOnlyIncomingTrue_shouldOnlyFetchIncoming() { - val srvResolver = newMockSrvResolver( - imapsServices = listOf( - newMailService( - host = "imaps.example.com", - port = 993, - srvType = SrvType.IMAPS, - security = ConnectionSecurity.SSL_TLS_REQUIRED, - priority = 0 - ) - ) - ) - - val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) - val result = srvServiceDiscovery.discover("test@example.com", DiscoveryTarget.INCOMING) - - verify(srvResolver).lookup("example.com", SrvType.IMAPS) - verify(srvResolver).lookup("example.com", SrvType.IMAP) - verifyNoMoreInteractions(srvResolver) - assertEquals(1, result!!.incoming.size) - assertEquals(listOf(), result.outgoing) - } - private fun newMailService( host: String = "example.com", priority: Int = 0, diff --git a/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdDiscovery.kt b/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdDiscovery.kt index ff02028f9c..3b5c3a5f85 100644 --- a/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdDiscovery.kt +++ b/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdDiscovery.kt @@ -2,14 +2,13 @@ package com.fsck.k9.autodiscovery.thunderbird import com.fsck.k9.autodiscovery.api.ConnectionSettingsDiscovery import com.fsck.k9.autodiscovery.api.DiscoveryResults -import com.fsck.k9.autodiscovery.api.DiscoveryTarget class ThunderbirdDiscovery( private val fetcher: ThunderbirdAutoconfigFetcher, private val parser: ThunderbirdAutoconfigParser ) : ConnectionSettingsDiscovery { - override fun discover(email: String, target: DiscoveryTarget): DiscoveryResults? { + override fun discover(email: String): DiscoveryResults? { val autoconfigInputStream = fetcher.fetchAutoconfigFile(email) ?: return null return autoconfigInputStream.use { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.kt index c9c58432da..1ee50879d9 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.kt @@ -17,7 +17,6 @@ import com.fsck.k9.Preferences import com.fsck.k9.account.AccountCreator import com.fsck.k9.activity.setup.AccountSetupCheckSettings.CheckDirection import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings -import com.fsck.k9.autodiscovery.api.DiscoveryTarget import com.fsck.k9.autodiscovery.providersxml.ProvidersXmlDiscovery import com.fsck.k9.helper.SimpleTextWatcher import com.fsck.k9.helper.Utility.requiredFieldValid @@ -311,7 +310,7 @@ class AccountSetupBasics : K9Activity() { } private fun providersXmlDiscoveryDiscover(email: String): ConnectionSettings? { - val discoveryResults = providersXmlDiscovery.discover(email, DiscoveryTarget.INCOMING_AND_OUTGOING) + val discoveryResults = providersXmlDiscovery.discover(email) if (discoveryResults == null || discoveryResults.incoming.isEmpty() || discoveryResults.outgoing.isEmpty()) { return null } -- GitLab From ee608a6201d1283dbc9e641d9a6f3080903d2a75 Mon Sep 17 00:00:00 2001 From: cketti Date: Wed, 26 Oct 2022 19:51:39 +0200 Subject: [PATCH 083/121] Change `ThunderbirdDiscovery` to support all specified autoconfig URLs --- .../ThunderbirdAutoconfigFetcher.kt | 20 +------- .../ThunderbirdAutoconfigUrlProvider.kt | 47 +++++++++++++++++++ .../thunderbird/ThunderbirdDiscovery.kt | 16 +++++-- .../thunderbird/ThunderbirdAutoconfigTest.kt | 9 ---- .../ThunderbirdAutoconfigUrlProviderTest.kt | 20 ++++++++ 5 files changed, 80 insertions(+), 32 deletions(-) create mode 100644 app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProvider.kt create mode 100644 app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProviderTest.kt diff --git a/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigFetcher.kt b/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigFetcher.kt index 94c513d550..c4da63191c 100644 --- a/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigFetcher.kt +++ b/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigFetcher.kt @@ -1,6 +1,5 @@ package com.fsck.k9.autodiscovery.thunderbird -import com.fsck.k9.helper.EmailHelper import java.io.InputStream import okhttp3.HttpUrl import okhttp3.OkHttpClient @@ -8,8 +7,7 @@ import okhttp3.Request class ThunderbirdAutoconfigFetcher(private val okHttpClient: OkHttpClient) { - fun fetchAutoconfigFile(email: String): InputStream? { - val url = getAutodiscoveryAddress(email) + fun fetchAutoconfigFile(url: HttpUrl): InputStream? { val request = Request.Builder().url(url).build() val response = okHttpClient.newCall(request).execute() @@ -20,20 +18,4 @@ class ThunderbirdAutoconfigFetcher(private val okHttpClient: OkHttpClient) { null } } - - companion object { - // address described at: - // https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration#Configuration_server_at_ISP - internal fun getAutodiscoveryAddress(email: String): HttpUrl { - val domain = EmailHelper.getDomainFromEmailAddress(email) - requireNotNull(domain) { "Couldn't extract domain from email address: $email" } - - return HttpUrl.Builder() - .scheme("https") - .host(domain) - .addEncodedPathSegments(".well-known/autoconfig/mail/config-v1.1.xml") - .addQueryParameter("emailaddress", email) - .build() - } - } } diff --git a/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProvider.kt b/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProvider.kt new file mode 100644 index 0000000000..bffb1e20f3 --- /dev/null +++ b/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProvider.kt @@ -0,0 +1,47 @@ +package com.fsck.k9.autodiscovery.thunderbird + +import com.fsck.k9.helper.EmailHelper +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +class ThunderbirdAutoconfigUrlProvider { + fun getAutoconfigUrls(email: String): List { + val domain = EmailHelper.getDomainFromEmailAddress(email) + requireNotNull(domain) { "Couldn't extract domain from email address: $email" } + + return listOf( + createProviderUrl(domain, email), + createDomainUrl(scheme = "https", domain), + createDomainUrl(scheme = "http", domain), + createIspDbUrl(domain) + ) + } + + private fun createProviderUrl(domain: String?, email: String): HttpUrl { + // https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email} + return HttpUrl.Builder() + .scheme("https") + .host("autoconfig.$domain") + .addEncodedPathSegments("mail/config-v1.1.xml") + .addQueryParameter("emailaddress", email) + .build() + } + + private fun createDomainUrl(scheme: String, domain: String): HttpUrl { + // https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml + // http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml + return HttpUrl.Builder() + .scheme(scheme) + .host(domain) + .addEncodedPathSegments(".well-known/autoconfig/mail/config-v1.1.xml") + .build() + } + + private fun createIspDbUrl(domain: String): HttpUrl { + // https://autoconfig.thunderbird.net/v1.1/{domain} + return "https://autoconfig.thunderbird.net/v1.1/".toHttpUrl() + .newBuilder() + .addPathSegment(domain) + .build() + } +} diff --git a/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdDiscovery.kt b/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdDiscovery.kt index 3b5c3a5f85..23d16819ad 100644 --- a/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdDiscovery.kt +++ b/app/autodiscovery/thunderbird/src/main/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdDiscovery.kt @@ -4,16 +4,24 @@ import com.fsck.k9.autodiscovery.api.ConnectionSettingsDiscovery import com.fsck.k9.autodiscovery.api.DiscoveryResults class ThunderbirdDiscovery( + private val urlProvider: ThunderbirdAutoconfigUrlProvider, private val fetcher: ThunderbirdAutoconfigFetcher, private val parser: ThunderbirdAutoconfigParser ) : ConnectionSettingsDiscovery { override fun discover(email: String): DiscoveryResults? { - val autoconfigInputStream = fetcher.fetchAutoconfigFile(email) ?: return null + val autoconfigUrls = urlProvider.getAutoconfigUrls(email) - return autoconfigInputStream.use { - parser.parseSettings(it, email) - } + return autoconfigUrls + .asSequence() + .mapNotNull { autoconfigUrl -> + fetcher.fetchAutoconfigFile(autoconfigUrl)?.use { inputStream -> + parser.parseSettings(inputStream, email) + } + } + .firstOrNull { result -> + result.incoming.isNotEmpty() || result.outgoing.isNotEmpty() + } } override fun toString(): String = "Thunderbird autoconfig" diff --git a/app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigTest.kt b/app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigTest.kt index 4648f67560..ca7dc0f1c2 100644 --- a/app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigTest.kt +++ b/app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigTest.kt @@ -169,13 +169,4 @@ class ThunderbirdAutoconfigTest { ) ) } - - @Test - fun generatedUrls() { - val autoDiscoveryAddress = ThunderbirdAutoconfigFetcher.getAutodiscoveryAddress("test@metacode.biz") - - assertThat(autoDiscoveryAddress.toString()).isEqualTo( - "https://metacode.biz/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=test%40metacode.biz" - ) - } } diff --git a/app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProviderTest.kt b/app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProviderTest.kt new file mode 100644 index 0000000000..d285a3ac02 --- /dev/null +++ b/app/autodiscovery/thunderbird/src/test/java/com/fsck/k9/autodiscovery/thunderbird/ThunderbirdAutoconfigUrlProviderTest.kt @@ -0,0 +1,20 @@ +package com.fsck.k9.autodiscovery.thunderbird + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ThunderbirdAutoconfigUrlProviderTest { + private val urlProvider = ThunderbirdAutoconfigUrlProvider() + + @Test + fun `getAutoconfigUrls with ASCII email address`() { + val autoconfigUrls = urlProvider.getAutoconfigUrls("test@domain.example") + + assertThat(autoconfigUrls.map { it.toString() }).containsExactly( + "https://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example", + "https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml", + "http://domain.example/.well-known/autoconfig/mail/config-v1.1.xml", + "https://autoconfig.thunderbird.net/v1.1/domain.example" + ) + } +} -- GitLab From d7f4ab88ea1e1ff33ae5527c42ece4b4f57ae7da Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 27 Oct 2022 12:12:32 +0200 Subject: [PATCH 084/121] Use container view when determining visibility of "reply to" input --- .../src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt index 76297080e5..37151c0fc6 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt @@ -29,7 +29,7 @@ class ReplyToView(activity: MessageCompose) : View.OnClickListener { } var isVisible: Boolean - get() = replyToView.isVisible + get() = replyToWrapper.isVisible set(visible) { replyToDivider.isVisible = visible replyToView.isVisible = visible -- GitLab From d21fb83289272b9d48877f1c4b7e1fd18cc134f5 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 27 Oct 2022 12:26:07 +0200 Subject: [PATCH 085/121] Change `ReplyToPresenterTest` to not extend `K9RobolectricTest` --- .../java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt b/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt index ca33c808f5..9d1a9c27ce 100644 --- a/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt +++ b/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt @@ -2,7 +2,7 @@ package com.fsck.k9.activity.compose import android.os.Bundle import com.fsck.k9.Identity -import com.fsck.k9.K9RobolectricTest +import com.fsck.k9.RobolectricTest import com.fsck.k9.mail.Address import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -16,7 +16,7 @@ private const val REPLY_TO_ADDRESS = "reply-to@example.com" private const val REPLY_TO_ADDRESS_2 = "reply-to2@example.com" private const val REPLY_TO_ADDRESS_3 = "reply-to3@example.com" -class ReplyToPresenterTest : K9RobolectricTest() { +class ReplyToPresenterTest : RobolectricTest() { private val view = mock() private val replyToPresenter = ReplyToPresenter(view) -- GitLab From c25972cccb50316b68b01a51cff44e283d27e021 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 27 Oct 2022 12:38:01 +0200 Subject: [PATCH 086/121] Don't tie requesting focus to changing visibility of the "reply to" input The "reply to" input field should not be focused when restoring the instance state, i.e. the visibility of the view. --- .../fsck/k9/activity/compose/ReplyToView.kt | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt index 37151c0fc6..f80fbc9e0a 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt @@ -14,7 +14,7 @@ import com.fsck.k9.view.RecipientSelectView.Recipient private const val VIEW_INDEX_REPLY_TO_EXPANDER_VISIBLE = 0 private const val VIEW_INDEX_REPLY_TO_EXPANDER_HIDDEN = 1 -class ReplyToView(activity: MessageCompose) : View.OnClickListener { +class ReplyToView(activity: MessageCompose) { private val replyToView: RecipientSelectView = activity.findViewById(R.id.reply_to) private val replyToWrapper: View = activity.findViewById(R.id.reply_to_wrapper) private val replyToDivider: View = activity.findViewById(R.id.reply_to_divider) @@ -24,8 +24,14 @@ class ReplyToView(activity: MessageCompose) : View.OnClickListener { private val textWatchers = mutableSetOf() init { - replyToExpander.setOnClickListener(this) - activity.findViewById(R.id.reply_to_label).setOnClickListener(this) + replyToExpander.setOnClickListener { + isVisible = true + replyToView.requestFocus() + } + + activity.findViewById(R.id.reply_to_label).setOnClickListener { + replyToView.requestFocus() + } } var isVisible: Boolean @@ -36,20 +42,12 @@ class ReplyToView(activity: MessageCompose) : View.OnClickListener { replyToWrapper.isVisible = visible if (visible && replyToExpanderContainer.displayedChild == VIEW_INDEX_REPLY_TO_EXPANDER_VISIBLE) { - replyToView.requestFocus() replyToExpanderContainer.displayedChild = VIEW_INDEX_REPLY_TO_EXPANDER_HIDDEN } else if (replyToExpanderContainer.displayedChild == VIEW_INDEX_REPLY_TO_EXPANDER_HIDDEN) { replyToExpanderContainer.displayedChild = VIEW_INDEX_REPLY_TO_EXPANDER_VISIBLE } } - override fun onClick(view: View) { - when (view.id) { - R.id.reply_to_expander -> isVisible = true - R.id.reply_to_label -> replyToView.requestFocus() - } - } - fun hasUncompletedText(): Boolean { replyToView.tryPerformCompletion() return replyToView.hasUncompletedText() -- GitLab From 367948ac7a99016584403fa7d18e93df3c75dd5b Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 27 Oct 2022 12:44:30 +0200 Subject: [PATCH 087/121] Only hide the "reply to" field if it doesn't contain content --- .../k9/activity/compose/ReplyToPresenter.kt | 4 +-- .../fsck/k9/activity/compose/ReplyToView.kt | 6 ++++ .../activity/compose/ReplyToPresenterTest.kt | 36 ------------------- 3 files changed, 7 insertions(+), 39 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt index c8f33e0075..5014bc5f21 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt @@ -53,9 +53,7 @@ class ReplyToPresenter(private val view: ReplyToView) { } fun onNonRecipientFieldFocused() { - if (view.isVisible && view.getAddresses().isEmpty()) { - view.isVisible = false - } + view.hideIfBlank() } fun onSaveInstanceState(outState: Bundle) { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt index f80fbc9e0a..e2f23bf879 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt @@ -48,6 +48,12 @@ class ReplyToView(activity: MessageCompose) { } } + fun hideIfBlank() { + if (isVisible && replyToView.text.isBlank()) { + isVisible = false + } + } + fun hasUncompletedText(): Boolean { replyToView.tryPerformCompletion() return replyToView.hasUncompletedText() diff --git a/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt b/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt index 9d1a9c27ce..2f606d206a 100644 --- a/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt +++ b/app/ui/legacy/src/test/java/com/fsck/k9/activity/compose/ReplyToPresenterTest.kt @@ -8,7 +8,6 @@ import com.google.common.truth.Truth.assertThat import org.junit.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.stubbing import org.mockito.kotlin.verify @@ -115,39 +114,4 @@ class ReplyToPresenterTest : RobolectricTest() { verify(view).silentlyRemoveAddresses(Address.parse(replyToOne)) verify(view).silentlyAddAddresses(Address.parse(replyToTwo)) } - - @Test - fun testOnNonRecipientFieldFocused_notVisible_expectNoChange() { - stubbing(view) { - on { isVisible } doReturn false - } - - replyToPresenter.onNonRecipientFieldFocused() - - verify(view, never()).isVisible = false - } - - @Test - fun testOnNonRecipientFieldFocused_noContentFieldVisible_expectHide() { - stubbing(view) { - on { isVisible } doReturn true - on { getAddresses() } doReturn emptyArray() - } - - replyToPresenter.onNonRecipientFieldFocused() - - verify(view).isVisible = false - } - - @Test - fun testOnNonRecipientFieldFocused_withContentFieldVisible_expectNoChange() { - stubbing(view) { - on { isVisible } doReturn true - on { getAddresses() } doReturn Address.parse(REPLY_TO_ADDRESS) - } - - replyToPresenter.onNonRecipientFieldFocused() - - verify(view, never()).isVisible = false - } } -- GitLab From a0edf47b2b066b9eb2394b316766b70d3a37bd43 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 27 Oct 2022 22:49:23 +0200 Subject: [PATCH 088/121] Simplify `SwipeResourceProvider` code that returns the action background color --- .../ui/messagelist/MessageListSwipeCallback.kt | 8 ++++---- .../k9/ui/messagelist/SwipeResourceProvider.kt | 16 +++++++--------- app/ui/legacy/src/main/res/values/attrs.xml | 6 ++---- app/ui/legacy/src/main/res/values/themes.xml | 12 ++++-------- 4 files changed, 17 insertions(+), 25 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index 20e665fe69..df68f437ac 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -92,10 +92,10 @@ class MessageListSwipeCallback( val swipeThresholdReached = abs(dX) > swipeThreshold if (swipeThresholdReached) { val action = if (dX > 0) swipeRightAction else swipeLeftAction - val backgroundColor = resourceProvider.getBackgroundColor(item, action) + val backgroundColor = resourceProvider.getBackgroundColor(action) drawBackground(view, backgroundColor) } else { - val backgroundColor = resourceProvider.getBackgroundColor(item, SwipeAction.None) + val backgroundColor = resourceProvider.getBackgroundColor(SwipeAction.None) drawBackground(view, backgroundColor) } @@ -137,7 +137,7 @@ class MessageListSwipeCallback( val iconBottom = iconTop + icon.intrinsicHeight icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) - icon.setTint(resourceProvider.getIconTint(item, swipeRightAction, swipeThresholdReached)) + icon.setTint(resourceProvider.getIconTint(swipeRightAction, swipeThresholdReached)) icon.draw(this) } } @@ -150,7 +150,7 @@ class MessageListSwipeCallback( val iconBottom = iconTop + icon.intrinsicHeight icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) - icon.setTint(resourceProvider.getIconTint(item, swipeLeftAction, swipeThresholdReached)) + icon.setTint(resourceProvider.getIconTint(swipeLeftAction, swipeThresholdReached)) icon.draw(this) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt index d61064c9d2..9adf57c5f2 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt @@ -23,20 +23,18 @@ class SwipeResourceProvider(val theme: Theme) { private val noActionColor = theme.resolveColorAttribute(R.attr.messageListSwipeDisabledBackgroundColor) private val selectColor = theme.resolveColorAttribute(R.attr.messageListSwipeSelectBackgroundColor) - private val markAsReadColor = theme.resolveColorAttribute(R.attr.messageListSwipeMarkAsReadBackgroundColor) - private val markAsUnreadColor = theme.resolveColorAttribute(R.attr.messageListSwipeMarkAsUnreadBackgroundColor) - private val addStarColor = theme.resolveColorAttribute(R.attr.messageListSwipeAddStarBackgroundColor) - private val removeStarColor = theme.resolveColorAttribute(R.attr.messageListSwipeRemoveStarBackgroundColor) + private val toggleReadColor = theme.resolveColorAttribute(R.attr.messageListSwipeToggleReadBackgroundColor) + private val toggleStarColor = theme.resolveColorAttribute(R.attr.messageListSwipeToggleStarBackgroundColor) private val archiveColor = theme.resolveColorAttribute(R.attr.messageListSwipeArchiveBackgroundColor) private val deleteColor = theme.resolveColorAttribute(R.attr.messageListSwipeDeleteBackgroundColor) private val spamColor = theme.resolveColorAttribute(R.attr.messageListSwipeSpamBackgroundColor) private val moveColor = theme.resolveColorAttribute(R.attr.messageListSwipeMoveBackgroundColor) - fun getIconTint(item: MessageListItem, action: SwipeAction, swipeThresholdReached: Boolean): Int { + fun getIconTint(action: SwipeAction, swipeThresholdReached: Boolean): Int { return if (swipeThresholdReached) { iconTint } else { - getBackgroundColor(item, action) + getBackgroundColor(action) } } @@ -53,12 +51,12 @@ class SwipeResourceProvider(val theme: Theme) { } } - fun getBackgroundColor(item: MessageListItem, action: SwipeAction): Int { + fun getBackgroundColor(action: SwipeAction): Int { return when (action) { SwipeAction.None -> noActionColor SwipeAction.ToggleSelection -> selectColor - SwipeAction.ToggleRead -> if (item.isRead) markAsUnreadColor else markAsReadColor - SwipeAction.ToggleStar -> if (item.isStarred) removeStarColor else addStarColor + SwipeAction.ToggleRead -> toggleReadColor + SwipeAction.ToggleStar -> toggleStarColor SwipeAction.Archive -> archiveColor SwipeAction.Delete -> deleteColor SwipeAction.Spam -> spamColor diff --git a/app/ui/legacy/src/main/res/values/attrs.xml b/app/ui/legacy/src/main/res/values/attrs.xml index 7d93616e92..e950d305d5 100644 --- a/app/ui/legacy/src/main/res/values/attrs.xml +++ b/app/ui/legacy/src/main/res/values/attrs.xml @@ -83,13 +83,11 @@ - - + - - + diff --git a/app/ui/legacy/src/main/res/values/themes.xml b/app/ui/legacy/src/main/res/values/themes.xml index 9488b7f8f2..494687675c 100644 --- a/app/ui/legacy/src/main/res/values/themes.xml +++ b/app/ui/legacy/src/main/res/values/themes.xml @@ -97,13 +97,11 @@ @drawable/ic_import_status @color/material_blue_600 ?attr/iconActionMarkAsRead - @color/material_blue_600 ?attr/iconActionMarkAsUnread - @color/material_blue_600 + @color/material_blue_600 ?attr/iconActionFlag - @color/material_orange_600 ?attr/iconActionUnflag - @color/material_orange_600 + @color/material_orange_600 ?attr/iconActionArchive @color/material_green_600 ?attr/iconActionDelete @@ -243,13 +241,11 @@ @drawable/ic_import_status @color/material_blue_700 ?attr/iconActionMarkAsRead - @color/material_blue_700 ?attr/iconActionMarkAsUnread - @color/material_blue_700 + @color/material_blue_700 ?attr/iconActionFlag - @color/material_orange_700 ?attr/iconActionUnflag - @color/material_orange_700 + @color/material_orange_700 ?attr/iconActionArchive @color/material_green_700 ?attr/iconActionDelete -- GitLab From d88be8dab7cc867007c3c3699641e3d5ac2941dc Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 27 Oct 2022 23:02:32 +0200 Subject: [PATCH 089/121] Move display logic from `SwipeResourceProvider` to `MessageListSwipeCallback` --- .../ui/messagelist/MessageListSwipeCallback.kt | 16 ++++++++++++++-- .../k9/ui/messagelist/SwipeResourceProvider.kt | 10 +--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index df68f437ac..992947657d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -137,7 +137,13 @@ class MessageListSwipeCallback( val iconBottom = iconTop + icon.intrinsicHeight icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) - icon.setTint(resourceProvider.getIconTint(swipeRightAction, swipeThresholdReached)) + val iconTint = if (swipeThresholdReached) { + resourceProvider.iconTint + } else { + resourceProvider.getBackgroundColor(swipeRightAction) + } + icon.setTint(iconTint) + icon.draw(this) } } @@ -150,7 +156,13 @@ class MessageListSwipeCallback( val iconBottom = iconTop + icon.intrinsicHeight icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) - icon.setTint(resourceProvider.getIconTint(swipeLeftAction, swipeThresholdReached)) + val iconTint = if (swipeThresholdReached) { + resourceProvider.iconTint + } else { + resourceProvider.getBackgroundColor(swipeLeftAction) + } + icon.setTint(iconTint) + icon.draw(this) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt index 9adf57c5f2..7285c63e08 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt @@ -9,7 +9,7 @@ import com.fsck.k9.ui.resolveColorAttribute import com.fsck.k9.ui.resolveDrawableAttribute class SwipeResourceProvider(val theme: Theme) { - private val iconTint = theme.resolveColorAttribute(R.attr.messageListSwipeIconTint) + val iconTint = theme.resolveColorAttribute(R.attr.messageListSwipeIconTint) private val selectIcon = theme.loadDrawable(R.attr.messageListSwipeSelectIcon) private val markAsReadIcon = theme.loadDrawable(R.attr.messageListSwipeMarkAsReadIcon) @@ -30,14 +30,6 @@ class SwipeResourceProvider(val theme: Theme) { private val spamColor = theme.resolveColorAttribute(R.attr.messageListSwipeSpamBackgroundColor) private val moveColor = theme.resolveColorAttribute(R.attr.messageListSwipeMoveBackgroundColor) - fun getIconTint(action: SwipeAction, swipeThresholdReached: Boolean): Int { - return if (swipeThresholdReached) { - iconTint - } else { - getBackgroundColor(action) - } - } - fun getIcon(item: MessageListItem, action: SwipeAction): Drawable? { return when (action) { SwipeAction.None -> null -- GitLab From 84548339767b069d35634c438fac27f05f52c571 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 28 Oct 2022 13:50:38 +0200 Subject: [PATCH 090/121] Rename .java to .kt --- .../store/pop3/{Pop3ConnectionTest.java => Pop3ConnectionTest.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/{Pop3ConnectionTest.java => Pop3ConnectionTest.kt} (100%) diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.java b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt similarity index 100% rename from mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.java rename to mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt -- GitLab From d6c0dcda0149ec2ed58f1c566a1b0caf072d57b8 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 28 Oct 2022 13:50:38 +0200 Subject: [PATCH 091/121] Convert `Pop3ConnectionTest` to Kotlin --- mail/protocols/pop3/build.gradle | 2 + .../k9/mail/store/pop3/Pop3ConnectionTest.kt | 905 +++++++++--------- 2 files changed, 449 insertions(+), 458 deletions(-) diff --git a/mail/protocols/pop3/build.gradle b/mail/protocols/pop3/build.gradle index f798dc6667..feec06c0f0 100644 --- a/mail/protocols/pop3/build.gradle +++ b/mail/protocols/pop3/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'java-library' +apply plugin: 'kotlin' apply plugin: 'com.android.lint' if (rootProject.testCoverage) { @@ -17,6 +18,7 @@ dependencies { testImplementation "junit:junit:${versions.junit}" testImplementation "com.google.truth:truth:${versions.truth}" testImplementation "org.mockito:mockito-core:${versions.mockito}" + testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" testImplementation "com.squareup.okio:okio:${versions.okio}" testImplementation "com.jcraft:jzlib:1.0.7" testImplementation "commons-io:commons-io:${versions.commonsIo}" diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt index 9f8d0b6e18..9c41fb2cea 100644 --- a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt @@ -1,584 +1,573 @@ -package com.fsck.k9.mail.store.pop3; - - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.net.Socket; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; - -import com.fsck.k9.mail.AuthType; -import com.fsck.k9.mail.AuthenticationFailedException; -import com.fsck.k9.mail.CertificateValidationException; -import com.fsck.k9.mail.CertificateValidationException.Reason; -import com.fsck.k9.mail.ConnectionSecurity; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.filter.Base64; -import com.fsck.k9.mail.helpers.TestTrustedSocketFactory; -import com.fsck.k9.mail.ssl.TrustedSocketFactory; -import javax.net.ssl.SSLException; -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - - -public class Pop3ConnectionTest { - - private static final String host = "server"; - private static final int port = 12345; - private static String username = "user"; - private static String password = "password"; - private static final String INITIAL_RESPONSE = "+OK POP3 server greeting\r\n"; - private static final String CAPA = - "CAPA\r\n"; - private static final String CAPA_RESPONSE = - "+OK Listing of supported mechanisms follows\r\n" + - "SASL PLAIN CRAM-MD5 EXTERNAL\r\n" + - ".\r\n"; - private static final String AUTH_PLAIN_WITH_LOGIN = "AUTH PLAIN\r\n" + - new String(Base64.encodeBase64(("\000"+username+"\000"+password).getBytes())) + "\r\n"; - private static final String AUTH_PLAIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n"; - - private static final String SUCCESSFUL_PLAIN_AUTH = CAPA + AUTH_PLAIN_WITH_LOGIN; - private static final String SUCCESSFUL_PLAIN_AUTH_RESPONSE = - INITIAL_RESPONSE + - CAPA_RESPONSE + - AUTH_PLAIN_AUTHENTICATED_RESPONSE; -/** - private static final String AUTH_PLAIN_FAILED_RESPONSE = "+OK\r\n" + "Plain authentication failure"; - private static final String STAT = "STAT\r\n"; - private static final String STAT_RESPONSE = "+OK 20 0\r\n"; - private static final String UIDL_UNSUPPORTED_RESPONSE = "-ERR UIDL unsupported\r\n"; - private static final String UIDL_SUPPORTED_RESPONSE = "+OK UIDL supported\r\n"; - **/ - - private TrustedSocketFactory mockTrustedSocketFactory; - private Socket mockSocket; - private ByteArrayOutputStream outputStreamForMockSocket; - private SimplePop3Settings settings; - private TrustedSocketFactory socketFactory; +package com.fsck.k9.mail.store.pop3 + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.CertificateValidationException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.helpers.TestTrustedSocketFactory +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import com.google.common.truth.Truth.assertThat +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.Socket +import java.security.NoSuchAlgorithmException +import java.security.cert.CertificateException +import javax.net.ssl.SSLException +import okio.ByteString.Companion.encodeUtf8 +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.anyString +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.stubbing +import org.mockito.kotlin.verify + +class Pop3ConnectionTest { + private var mockTrustedSocketFactory: TrustedSocketFactory? = null + private var mockSocket: Socket? = null + private val outputStreamForMockSocket = ByteArrayOutputStream() + private val settings = SimplePop3Settings() + private val socketFactory = TestTrustedSocketFactory.newInstance() @Before - public void before() throws Exception { - createCommonSettings(); - createMocks(); - socketFactory = TestTrustedSocketFactory.newInstance(); - } - - private void createCommonSettings() { - settings = new SimplePop3Settings(); - settings.setUsername(username); - settings.setPassword(password); - } - - private void createMocks() - throws MessagingException, IOException, NoSuchAlgorithmException, KeyManagementException { - mockTrustedSocketFactory = mock(TrustedSocketFactory.class); - mockSocket = mock(Socket.class); - outputStreamForMockSocket = new ByteArrayOutputStream(); - when(mockTrustedSocketFactory.createSocket(null, host, port, null)) - .thenReturn(mockSocket); - when(mockSocket.getOutputStream()).thenReturn(outputStreamForMockSocket); - when(mockSocket.isConnected()).thenReturn(true); - } - - private void addSettingsForValidMockSocket() { - settings.setHost(host); - settings.setPort(port); - settings.setConnectionSecurity(ConnectionSecurity.SSL_TLS_REQUIRED); + fun before() { + createCommonSettings() + createMocks() } @Test - public void constructor_doesntCreateSocket() throws Exception { - addSettingsForValidMockSocket(); - settings.setAuthType(AuthType.PLAIN); + fun `constructor should not create socket`() { + addSettingsForValidMockSocket() + settings.authType = AuthType.PLAIN - new Pop3Connection(settings, mockTrustedSocketFactory); + Pop3Connection(settings, mockTrustedSocketFactory) - verifyNoMoreInteractions(mockTrustedSocketFactory); + verifyNoMoreInteractions(mockTrustedSocketFactory) } - //Using MockSocketFactory - - @Test(expected = CertificateValidationException.class) - public void open_whenTrustedSocketFactoryThrowsSSLCertificateException_throwCertificateValidationException() - throws Exception { - when(mockTrustedSocketFactory.createSocket(null, host, port, null)).thenThrow( - new SSLException(new CertificateException())); - addSettingsForValidMockSocket(); - settings.setAuthType(AuthType.PLAIN); + @Test(expected = CertificateValidationException::class) + fun `when TrustedSocketFactory throws SSLCertificateException, open() should throw CertificateValidationException`() { + stubbing(mockTrustedSocketFactory!!) { + on { createSocket(null, HOST, PORT, null) } doThrow SSLException(CertificateException()) + } + addSettingsForValidMockSocket() + settings.authType = AuthType.PLAIN + val connection = Pop3Connection(settings, mockTrustedSocketFactory) - Pop3Connection connection = new Pop3Connection(settings, mockTrustedSocketFactory); - connection.open(); + connection.open() } - @Test(expected = MessagingException.class) - public void open_whenTrustedSocketFactoryThrowsCertificateException_throwMessagingException() throws Exception { - when(mockTrustedSocketFactory.createSocket(null, host, port, null)).thenThrow( - new SSLException("")); - addSettingsForValidMockSocket(); - settings.setAuthType(AuthType.PLAIN); + @Test(expected = MessagingException::class) + fun `when TrustedSocketFactory throws CertificateException, open() should throw MessagingException`() { + stubbing(mockTrustedSocketFactory!!) { + on { createSocket(null, HOST, PORT, null) } doThrow SSLException("") + } + addSettingsForValidMockSocket() + settings.authType = AuthType.PLAIN + val connection = Pop3Connection(settings, mockTrustedSocketFactory) - Pop3Connection connection = new Pop3Connection(settings, mockTrustedSocketFactory); - connection.open(); + connection.open() } - @Test(expected = MessagingException.class) - public void open_whenTrustedSocketFactoryThrowsGeneralSecurityException_throwMessagingException() throws Exception { - when(mockTrustedSocketFactory.createSocket(null, host, port, null)).thenThrow( - new NoSuchAlgorithmException("")); - addSettingsForValidMockSocket(); - settings.setAuthType(AuthType.PLAIN); + @Test(expected = MessagingException::class) + fun `when TrustedSocketFactory throws NoSuchAlgorithmException, open() should throw MessagingException`() { + stubbing(mockTrustedSocketFactory!!) { + on { createSocket(null, HOST, PORT, null) } doThrow NoSuchAlgorithmException("") + } + addSettingsForValidMockSocket() + settings.authType = AuthType.PLAIN + val connection = Pop3Connection(settings, mockTrustedSocketFactory) - Pop3Connection connection = new Pop3Connection(settings, mockTrustedSocketFactory); - connection.open(); + connection.open() } - @Test(expected = MessagingException.class) - public void open_whenTrustedSocketFactoryThrowsIOException_throwMessagingException() throws Exception { - when(mockTrustedSocketFactory.createSocket(null, host, port, null)).thenThrow( - new IOException("")); - addSettingsForValidMockSocket(); - settings.setAuthType(AuthType.PLAIN); + @Test(expected = MessagingException::class) + fun `when TrustedSocketFactory throws IOException, open() should throw MessagingException`() { + stubbing(mockTrustedSocketFactory!!) { + on { createSocket(null, HOST, PORT, null) } doThrow IOException("") + } + addSettingsForValidMockSocket() + settings.authType = AuthType.PLAIN + val connection = Pop3Connection(settings, mockTrustedSocketFactory) - Pop3Connection connection = new Pop3Connection(settings, mockTrustedSocketFactory); - connection.open(); + connection.open() } - @Test(expected = MessagingException.class) - public void open_whenSocketNotConnected_throwsMessagingException() throws Exception { - when(mockSocket.isConnected()).thenReturn(false); - addSettingsForValidMockSocket(); - settings.setAuthType(AuthType.PLAIN); + @Test(expected = MessagingException::class) + fun `open() with socket not connected should throw MessagingException`() { + stubbing(mockSocket!!) { + on { isConnected } doReturn false + } + addSettingsForValidMockSocket() + settings.authType = AuthType.PLAIN + val connection = Pop3Connection(settings, mockTrustedSocketFactory) - Pop3Connection connection = new Pop3Connection(settings, mockTrustedSocketFactory); - connection.open(); + connection.open() } @Test - public void open_withTLS_authenticatesOverSocket() throws Exception { - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(SUCCESSFUL_PLAIN_AUTH_RESPONSE.getBytes())); - addSettingsForValidMockSocket(); - settings.setAuthType(AuthType.PLAIN); + fun `open() should send AUTH PLAIN command`() { + stubbing(mockSocket!!) { + on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() + } + addSettingsForValidMockSocket() + settings.authType = AuthType.PLAIN + val connection = Pop3Connection(settings, mockTrustedSocketFactory) - Pop3Connection connection = new Pop3Connection(settings, mockTrustedSocketFactory); - connection.open(); + connection.open() - assertEquals(SUCCESSFUL_PLAIN_AUTH, new String(outputStreamForMockSocket.toByteArray())); + assertThat(outputStreamForMockSocket.toByteArray().decodeToString()).isEqualTo(SUCCESSFUL_PLAIN_AUTH) } + @Test(expected = CertificateValidationException::class) + fun `open() with STLS capability unavailable should throw CertificateValidationException`() { + setupUnavailableStartTlsConnection() + settings.authType = AuthType.PLAIN + settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED - - //Using both - - @Test(expected = CertificateValidationException.class) - public void open_withSTLSunavailable_throwsCertificateValidationException() throws Exception { - MockPop3Server server = setupUnavailableStartTLSConnection(); - settings.setAuthType(AuthType.PLAIN); - settings.setConnectionSecurity(ConnectionSecurity.STARTTLS_REQUIRED); - - createAndOpenPop3Connection(settings, mockTrustedSocketFactory); + createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) } @Test - public void open_withSTLSunavailable_doesntCreateSocket() throws Exception { - MockPop3Server server = setupUnavailableStartTLSConnection(); - settings.setAuthType(AuthType.PLAIN); - settings.setConnectionSecurity(ConnectionSecurity.STARTTLS_REQUIRED); + fun `open() with STLS capability unavailable should not call createSocket() to upgrade to TLS`() { + setupUnavailableStartTlsConnection() + settings.authType = AuthType.PLAIN + settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED try { - Pop3Connection connection = new Pop3Connection(settings, mockTrustedSocketFactory); - connection.open(); - } catch (Exception ignored) { + val connection = Pop3Connection(settings, mockTrustedSocketFactory) + connection.open() + } catch (ignored: Exception) { } - verify(mockTrustedSocketFactory, never()).createSocket(any(Socket.class), anyString(), - anyInt(), anyString()); + verify(mockTrustedSocketFactory!!, never()).createSocket(any(), anyString(), anyInt(), anyString()) } - @Test(expected = Pop3ErrorResponse.class) - public void open_withStartTLS_withSTLSerr_throwsException() throws Exception { - MockPop3Server server = setupFailedStartTLSConnection(); - - when(mockTrustedSocketFactory.createSocket( - any(Socket.class), eq(server.getHost()), eq(server.getPort()), eq((String) null))) - .thenReturn(mockSocket); - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(SUCCESSFUL_PLAIN_AUTH_RESPONSE.getBytes())); + @Test(expected = Pop3ErrorResponse::class) + fun `open() with error response to STLS command should throw`() { + val server = setupFailedStartTlsConnection() + stubbing(mockTrustedSocketFactory!!) { + on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doReturn mockSocket + } + stubbing(mockSocket!!) { + on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() + } - createAndOpenPop3Connection(settings, mockTrustedSocketFactory); + createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) } @Test - public void open_withStartTLS_withSTLSerr_doesntCreateSocket() throws Exception { - MockPop3Server server = setupFailedStartTLSConnection(); - - when(mockTrustedSocketFactory.createSocket( - any(Socket.class), eq(server.getHost()), eq(server.getPort()), eq((String) null))) - .thenReturn(mockSocket); - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(SUCCESSFUL_PLAIN_AUTH_RESPONSE.getBytes())); + fun `open() with STLS error response should not call createSocket() to upgrade to TLS`() { + val server = setupFailedStartTlsConnection() + stubbing(mockTrustedSocketFactory!!) { + on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doReturn mockSocket + } + stubbing(mockSocket!!) { + on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() + } try { - createAndOpenPop3Connection(settings, mockTrustedSocketFactory); - } catch (Exception ignored) { + createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) + } catch (ignored: Exception) { } - verify(mockTrustedSocketFactory, never()).createSocket(any(Socket.class), anyString(), - anyInt(), anyString()); + verify(mockTrustedSocketFactory!!, never()).createSocket(any(), anyString(), anyInt(), anyString()) } @Test - public void open_withStartTLS_usesSocketFactoryToCreateTLSSocket() throws Exception { - MockPop3Server server = setupStartTLSConnection(); - settings.setAuthType(AuthType.PLAIN); - - when(mockTrustedSocketFactory.createSocket( - any(Socket.class), eq(server.getHost()), eq(server.getPort()), eq((String) null))) - .thenReturn(mockSocket); - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(SUCCESSFUL_PLAIN_AUTH_RESPONSE.getBytes())); + fun `open() with StartTLS should use TrustedSocketFactory to create TLS socket`() { + val server = setupStartTLSConnection() + settings.authType = AuthType.PLAIN + stubbing(mockTrustedSocketFactory!!) { + on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doReturn mockSocket + } + stubbing(mockSocket!!) { + on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() + } - createAndOpenPop3Connection(settings, mockTrustedSocketFactory); + createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) - verify(mockTrustedSocketFactory).createSocket(any(Socket.class), eq(server.getHost()), - eq(server.getPort()), eq((String) null)); + verify(mockTrustedSocketFactory!!).createSocket(any(), eq(server.host), eq(server.port), eq(null)) } - @Test(expected = MessagingException.class) - public void open_withStartTLS_whenSocketFactoryThrowsException_ThrowsException() throws Exception { - MockPop3Server server = setupStartTLSConnection(); - settings.setAuthType(AuthType.PLAIN); - - when(mockTrustedSocketFactory.createSocket( - any(Socket.class), eq(server.getHost()), eq(server.getPort()), eq((String) null))) - .thenThrow(new IOException()); - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(SUCCESSFUL_PLAIN_AUTH_RESPONSE.getBytes())); + @Test(expected = MessagingException::class) + fun `open() with StartTLS and TrustedSocketFactory throwing should throw`() { + val server = setupStartTLSConnection() + settings.authType = AuthType.PLAIN + stubbing(mockTrustedSocketFactory!!) { + on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doThrow IOException() + } + stubbing(mockSocket!!) { + on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() + } - createAndOpenPop3Connection(settings, mockTrustedSocketFactory); + createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) - verify(mockTrustedSocketFactory).createSocket(any(Socket.class), eq(server.getHost()), - eq(server.getPort()), eq((String) null)); + verify(mockTrustedSocketFactory!!).createSocket(any(), eq(server.host), eq(server.port), eq(null)) } @Test - public void open_withStartTLS_authenticatesOverSecureSocket() throws Exception { - MockPop3Server server = setupStartTLSConnection(); - settings.setAuthType(AuthType.PLAIN); - - when(mockTrustedSocketFactory.createSocket( - any(Socket.class), eq(server.getHost()), eq(server.getPort()), eq((String) null))) - .thenReturn(mockSocket); - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(SUCCESSFUL_PLAIN_AUTH_RESPONSE.getBytes())); - - createAndOpenPop3Connection(settings, mockTrustedSocketFactory); - - assertEquals(SUCCESSFUL_PLAIN_AUTH, new String(outputStreamForMockSocket.toByteArray())); - } - - private MockPop3Server setupStartTLSConnection() throws IOException {new MockPop3Server(); - MockPop3Server server = new MockPop3Server(); - setupServerWithStartTLSAvailable(server); - server.expect("STLS"); - server.output("+OK Begin TLS negotiation"); - server.start(); - settings.setHost(server.getHost()); - settings.setPort(server.getPort()); - settings.setConnectionSecurity(ConnectionSecurity.STARTTLS_REQUIRED); - return server; - } - - private MockPop3Server setupFailedStartTLSConnection() throws IOException {new MockPop3Server(); - MockPop3Server server = new MockPop3Server(); - setupServerWithStartTLSAvailable(server); - server.expect("STLS"); - server.output("-ERR Unavailable"); - server.start(); - settings.setHost(server.getHost()); - settings.setPort(server.getPort()); - settings.setConnectionSecurity(ConnectionSecurity.STARTTLS_REQUIRED); - return server; - } - - private MockPop3Server setupUnavailableStartTLSConnection() throws IOException {new MockPop3Server(); - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL PLAIN"); - server.output("."); - server.start(); - settings.setHost(server.getHost()); - settings.setPort(server.getPort()); - settings.setConnectionSecurity(ConnectionSecurity.STARTTLS_REQUIRED); - return server; - } - - private void setupServerWithStartTLSAvailable(MockPop3Server server) { - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("STLS"); - server.output("SASL PLAIN"); - server.output("."); - } - - //Using RealSocketFactory with MockPop3Server - - @Test - public void open_withAuthTypePlainAndPlainAuthCapability_performsPlainAuth() throws Exception { - settings.setAuthType(AuthType.PLAIN); + fun `open() with StartTLS should authenticate over secure socket`() { + val server = setupStartTLSConnection() + settings.authType = AuthType.PLAIN + stubbing(mockTrustedSocketFactory!!) { + on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doReturn mockSocket + } + stubbing(mockSocket!!) { + on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() + } - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL PLAIN CRAM-MD5 EXTERNAL"); - server.output("."); - server.expect("AUTH PLAIN"); - server.output("+OK"); - server.expect(new String(Base64.encodeBase64(("\000"+username+"\000"+password).getBytes()))); - server.output("+OK"); - startServerAndCreateOpenConnection(server); + createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) - server.verifyConnectionStillOpen(); - server.verifyInteractionCompleted(); + assertThat(outputStreamForMockSocket.toByteArray().decodeToString()).isEqualTo(SUCCESSFUL_PLAIN_AUTH) } @Test - public void open_withAuthTypePlainAndPlainAuthCapabilityAndInvalidPasswordResponse_throwsException() throws Exception { - settings.setAuthType(AuthType.PLAIN); - - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL PLAIN CRAM-MD5 EXTERNAL"); - server.output("."); - server.expect("AUTH PLAIN"); - server.output("+OK"); - server.expect(new String(Base64.encodeBase64(("\000"+username+"\000"+password).getBytes()))); - server.output("-ERR"); + fun `open() with AUTH PLAIN`() { + settings.authType = AuthType.PLAIN + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN CRAM-MD5 EXTERNAL") + server.output(".") + server.expect("AUTH PLAIN") + server.output("+OK") + server.expect(AUTH_PLAIN_ARGUMENT) + server.output("+OK") - try { - startServerAndCreateOpenConnection(server); - fail("Expected auth failure"); - } catch (AuthenticationFailedException ignored) {} + startServerAndCreateOpenConnection(server) - server.verifyInteractionCompleted(); + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() } @Test - public void open_withAuthTypePlainAndNoPlainAuthCapability_performsLogin() throws Exception { - settings.setAuthType(AuthType.PLAIN); - - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL CRAM-MD5 EXTERNAL"); - server.output("."); - server.expect("USER user"); - server.output("+OK"); - server.expect("PASS password"); - server.output("-ERR"); + fun `open() with authentication error should throw`() { + settings.authType = AuthType.PLAIN + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN CRAM-MD5 EXTERNAL") + server.output(".") + server.expect("AUTH PLAIN") + server.output("+OK") + server.expect(AUTH_PLAIN_ARGUMENT) + server.output("-ERR") try { - startServerAndCreateOpenConnection(server); - fail("Expected auth failure"); - } catch (AuthenticationFailedException ignored) {} + startServerAndCreateOpenConnection(server) + fail("Expected auth failure") + } catch (ignored: AuthenticationFailedException) { + } - server.verifyInteractionCompleted(); + server.verifyInteractionCompleted() } @Test - public void open_withAuthTypePlainAndNoPlainAuthCapabilityAndLoginFailure_throwsException() throws Exception { - settings.setAuthType(AuthType.PLAIN); + fun `open() with AuthType_PLAIN and no SASL PLAIN capability should use USER and PASS commands`() { + settings.authType = AuthType.PLAIN + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL CRAM-MD5 EXTERNAL") + server.output(".") + server.expect("USER user") + server.output("+OK") + server.expect("PASS password") + server.output("+OK") - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL CRAM-MD5 EXTERNAL"); - server.output("."); - server.expect("USER user"); - server.output("+OK"); - server.expect("PASS password"); - server.output("+OK"); + startServerAndCreateOpenConnection(server) - startServerAndCreateOpenConnection(server); - - server.verifyInteractionCompleted(); + server.verifyInteractionCompleted() } @Test - public void open_withAuthTypeCramMd5AndCapability_performsCramMd5Auth() throws IOException, MessagingException { - settings.setAuthType(AuthType.CRAM_MD5); + fun `open() with authentication failure during fallback to USER and PASS commands should throw`() { + settings.authType = AuthType.PLAIN + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL CRAM-MD5 EXTERNAL") + server.output(".") + server.expect("USER user") + server.output("+OK") + server.expect("PASS password") + server.output("-ERR") - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL PLAIN CRAM-MD5 EXTERNAL"); - server.output("."); - server.expect("AUTH CRAM-MD5"); - server.output("+ abcd"); - server.expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg=="); - server.output("+OK"); - startServerAndCreateOpenConnection(server); + try { + startServerAndCreateOpenConnection(server) + fail("Expected auth failure") + } catch (ignored: AuthenticationFailedException) { + } - server.verifyConnectionStillOpen(); - server.verifyInteractionCompleted(); + server.verifyInteractionCompleted() } @Test - public void open_withAuthTypeCramMd5AndCapabilityAndCramFailure_throwsException() throws IOException, MessagingException { - settings.setAuthType(AuthType.CRAM_MD5); + fun `open() with CRAM-MD5 authentication`() { + settings.authType = AuthType.CRAM_MD5 + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN CRAM-MD5 EXTERNAL") + server.output(".") + server.expect("AUTH CRAM-MD5") + server.output("+ abcd") + server.expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==") + server.output("+OK") + startServerAndCreateOpenConnection(server) - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL PLAIN CRAM-MD5 EXTERNAL"); - server.output("."); - server.expect("AUTH CRAM-MD5"); - server.output("+ abcd"); - server.expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg=="); - server.output("-ERR"); + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with authentication failure when using CRAM-MD5 should throw`() { + settings.authType = AuthType.CRAM_MD5 + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN CRAM-MD5 EXTERNAL") + server.output(".") + server.expect("AUTH CRAM-MD5") + server.output("+ abcd") + server.expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==") + server.output("-ERR") try { - startServerAndCreateOpenConnection(server); - fail("Expected auth failure"); - } catch (AuthenticationFailedException ignored) {} + startServerAndCreateOpenConnection(server) + fail("Expected auth failure") + } catch (ignored: AuthenticationFailedException) { + } - server.verifyInteractionCompleted(); + server.verifyInteractionCompleted() } @Test - public void open_withAuthTypeCramMd5AndNoCapability_performsApopAuth() throws IOException, MessagingException { - settings.setAuthType(AuthType.CRAM_MD5); + fun `open() with CRAM-MD5 configured but missing capability should use APOP`() { + settings.authType = AuthType.CRAM_MD5 + val server = MockPop3Server() + server.output("+OK abcabcd") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN EXTERNAL") + server.output(".") + server.expect("APOP user c8e8c560e385faaa6367d4145572b8ea") + server.output("+OK") - MockPop3Server server = new MockPop3Server(); - server.output("+OK abcabcd"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL PLAIN EXTERNAL"); - server.output("."); - server.expect("APOP user c8e8c560e385faaa6367d4145572b8ea"); - server.output("+OK"); - startServerAndCreateOpenConnection(server); + startServerAndCreateOpenConnection(server) - server.verifyConnectionStillOpen(); - server.verifyInteractionCompleted(); + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() } @Test - public void open_withAuthTypeCramMd5AndNoCapabilityAndApopFailure_throwsException() throws IOException, MessagingException { - settings.setAuthType(AuthType.CRAM_MD5); - - - MockPop3Server server = new MockPop3Server(); - server.output("+OK abcabcd"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL PLAIN EXTERNAL"); - server.output("."); - server.expect("APOP user c8e8c560e385faaa6367d4145572b8ea"); - server.output("-ERR"); + fun `open() with authentication failure when using APOP should throw`() { + settings.authType = AuthType.CRAM_MD5 + val server = MockPop3Server() + server.output("+OK abcabcd") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN EXTERNAL") + server.output(".") + server.expect("APOP user c8e8c560e385faaa6367d4145572b8ea") + server.output("-ERR") try { - startServerAndCreateOpenConnection(server); - fail("Expected auth failure"); - } catch (AuthenticationFailedException ignored) {} + startServerAndCreateOpenConnection(server) + fail("Expected auth failure") + } catch (ignored: AuthenticationFailedException) { + } - server.verifyInteractionCompleted(); + server.verifyInteractionCompleted() } @Test - public void open_withAuthTypeExternalAndCapability_performsExternalAuth() throws IOException, MessagingException { - settings.setAuthType(AuthType.EXTERNAL); + fun `open() with AUTH EXTERNAL`() { + settings.authType = AuthType.EXTERNAL + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL CRAM-MD5 EXTERNAL") + server.output(".") + server.expect("AUTH EXTERNAL dXNlcg==") + server.output("+OK") - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL CRAM-MD5 EXTERNAL"); - server.output("."); - server.expect("AUTH EXTERNAL dXNlcg=="); - server.output("+OK"); - startServerAndCreateOpenConnection(server); + startServerAndCreateOpenConnection(server) - server.verifyConnectionStillOpen(); - server.verifyInteractionCompleted(); + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() } @Test - public void open_withAuthTypeExternalAndNoCapability_throwsCVE() throws IOException, MessagingException { - settings.setAuthType(AuthType.EXTERNAL); - - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL PLAIN CRAM-MD5"); - server.output("."); + fun `open() with AuthType_EXTERNAL configured but missing capability should throw`() { + settings.authType = AuthType.EXTERNAL + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN CRAM-MD5") + server.output(".") try { - startServerAndCreateOpenConnection(server); - fail("CVE expected"); - } catch (CertificateValidationException e) { - assertEquals(Reason.MissingCapability, e.getReason()); + startServerAndCreateOpenConnection(server) + fail("CVE expected") + } catch (e: CertificateValidationException) { + assertThat(e.reason).isEqualTo(CertificateValidationException.Reason.MissingCapability) } - server.verifyConnectionStillOpen(); - server.verifyInteractionCompleted(); + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() } @Test - public void open_withAuthTypeExternalAndCapability_withRejection_throwsCVE() throws IOException, MessagingException { - settings.setAuthType(AuthType.EXTERNAL); - - MockPop3Server server = new MockPop3Server(); - server.output("+OK POP3 server greeting"); - server.expect("CAPA"); - server.output("+OK Listing of supported mechanisms follows"); - server.output("SASL PLAIN CRAM-MD5 EXTERNAL"); - server.output("."); - server.expect("AUTH EXTERNAL dXNlcg=="); - server.output("-ERR Invalid certificate"); + fun `open() with authentication failure when using AUTH EXTERNAL should throw`() { + settings.authType = AuthType.EXTERNAL + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN CRAM-MD5 EXTERNAL") + server.output(".") + server.expect("AUTH EXTERNAL dXNlcg==") + server.output("-ERR Invalid certificate") try { - startServerAndCreateOpenConnection(server); - fail("CVE expected"); - } catch (CertificateValidationException e) { - assertEquals("POP3 client certificate authentication failed: -ERR Invalid certificate", e.getMessage()); + startServerAndCreateOpenConnection(server) + fail("CVE expected") + } catch (e: CertificateValidationException) { + assertThat(e).hasMessageThat() + .isEqualTo("POP3 client certificate authentication failed: -ERR Invalid certificate") } - server.verifyInteractionCompleted(); + server.verifyInteractionCompleted() + } + + private fun createCommonSettings() { + settings.username = USERNAME + settings.password = PASSWORD + } + + private fun createMocks() { + mockSocket = mock { + on { getOutputStream() } doReturn outputStreamForMockSocket + on { isConnected } doReturn true + } + + mockTrustedSocketFactory = mock { + on { createSocket(null, HOST, PORT, null) } doReturn mockSocket + } + } + + private fun addSettingsForValidMockSocket() { + settings.host = HOST + settings.port = PORT + settings.connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED + } + + private fun startServerAndCreateOpenConnection(server: MockPop3Server) { + server.start() + settings.host = server.host + settings.port = server.port + createAndOpenPop3Connection(settings, socketFactory!!) + } + + private fun createAndOpenPop3Connection(settings: Pop3Settings, socketFactory: TrustedSocketFactory) { + val connection = Pop3Connection(settings, socketFactory) + connection.open() + } + + private fun setupStartTLSConnection(): MockPop3Server { + val server = MockPop3Server() + setupServerWithStartTlsAvailable(server) + server.expect("STLS") + server.output("+OK Begin TLS negotiation") + server.start() + + settings.host = server.host + settings.port = server.port + settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED + + return server } - private void startServerAndCreateOpenConnection(MockPop3Server server) throws IOException, - MessagingException { - server.start(); - settings.setHost(server.getHost()); - settings.setPort(server.getPort()); - createAndOpenPop3Connection(settings, socketFactory); + private fun setupFailedStartTlsConnection(): MockPop3Server { + val server = MockPop3Server() + setupServerWithStartTlsAvailable(server) + server.expect("STLS") + server.output("-ERR Unavailable") + server.start() + + settings.host = server.host + settings.port = server.port + settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED + + return server } - private void createAndOpenPop3Connection(Pop3Settings settings, TrustedSocketFactory socketFactory) - throws MessagingException { - Pop3Connection connection = new Pop3Connection(settings, socketFactory); - connection.open(); + private fun setupUnavailableStartTlsConnection(): MockPop3Server { + val server = MockPop3Server() + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN") + server.output(".") + server.start() + + settings.host = server.host + settings.port = server.port + settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED + + return server + } + + private fun setupServerWithStartTlsAvailable(server: MockPop3Server) { + server.output("+OK POP3 server greeting") + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("STLS") + server.output("SASL PLAIN") + server.output(".") + } + + companion object { + private const val HOST = "server" + private const val PORT = 12345 + private const val USERNAME = "user" + private const val PASSWORD = "password" + + private const val INITIAL_RESPONSE = "+OK POP3 server greeting\r\n" + private const val CAPA_COMMAND = "CAPA\r\n" + private const val CAPA_RESPONSE = + "+OK Listing of supported mechanisms follows\r\n" + + "SASL PLAIN CRAM-MD5 EXTERNAL\r\n" + + ".\r\n" + + private val AUTH_PLAIN_ARGUMENT = "\u0000$USERNAME\u0000$PASSWORD".encodeUtf8().base64() + private val AUTH_PLAIN_COMMAND = "AUTH PLAIN\r\n$AUTH_PLAIN_ARGUMENT\r\n" + + private const val AUTH_PLAIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n" + + private val SUCCESSFUL_PLAIN_AUTH = + CAPA_COMMAND + AUTH_PLAIN_COMMAND + + private const val SUCCESSFUL_PLAIN_AUTH_RESPONSE = + INITIAL_RESPONSE + CAPA_RESPONSE + AUTH_PLAIN_AUTHENTICATED_RESPONSE } } -- GitLab From b0b4d4693af9db52fe5c36f17cb3ffb69181e3ab Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 28 Oct 2022 15:16:03 +0200 Subject: [PATCH 092/121] Reduce the amount of mocking in `Pop3ConnectionTest` --- .../k9/mail/store/pop3/Pop3ConnectionTest.kt | 242 +++++------------- 1 file changed, 62 insertions(+), 180 deletions(-) diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt index 9c41fb2cea..c0145fe097 100644 --- a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt @@ -8,9 +8,7 @@ import com.fsck.k9.mail.MessagingException import com.fsck.k9.mail.helpers.TestTrustedSocketFactory import com.fsck.k9.mail.ssl.TrustedSocketFactory import com.google.common.truth.Truth.assertThat -import java.io.ByteArrayOutputStream import java.io.IOException -import java.net.Socket import java.security.NoSuchAlgorithmException import java.security.cert.CertificateException import javax.net.ssl.SSLException @@ -18,216 +16,100 @@ import okio.ByteString.Companion.encodeUtf8 import org.junit.Assert.fail import org.junit.Before import org.junit.Test -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.anyString -import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.kotlin.any -import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.stubbing -import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions class Pop3ConnectionTest { - private var mockTrustedSocketFactory: TrustedSocketFactory? = null - private var mockSocket: Socket? = null - private val outputStreamForMockSocket = ByteArrayOutputStream() private val settings = SimplePop3Settings() private val socketFactory = TestTrustedSocketFactory.newInstance() @Before fun before() { createCommonSettings() - createMocks() - } - - @Test - fun `constructor should not create socket`() { - addSettingsForValidMockSocket() - settings.authType = AuthType.PLAIN - - Pop3Connection(settings, mockTrustedSocketFactory) - - verifyNoMoreInteractions(mockTrustedSocketFactory) } @Test(expected = CertificateValidationException::class) fun `when TrustedSocketFactory throws SSLCertificateException, open() should throw CertificateValidationException`() { - stubbing(mockTrustedSocketFactory!!) { - on { createSocket(null, HOST, PORT, null) } doThrow SSLException(CertificateException()) + createTlsServer() + val mockSocketFactory = mock { + on { createSocket(null, settings.host, settings.port, null) } doThrow SSLException(CertificateException()) } - addSettingsForValidMockSocket() - settings.authType = AuthType.PLAIN - val connection = Pop3Connection(settings, mockTrustedSocketFactory) + val connection = Pop3Connection(settings, mockSocketFactory) connection.open() } @Test(expected = MessagingException::class) fun `when TrustedSocketFactory throws CertificateException, open() should throw MessagingException`() { - stubbing(mockTrustedSocketFactory!!) { - on { createSocket(null, HOST, PORT, null) } doThrow SSLException("") + createTlsServer() + val mockSocketFactory = mock { + on { createSocket(null, settings.host, settings.port, null) } doThrow SSLException("") } - addSettingsForValidMockSocket() - settings.authType = AuthType.PLAIN - val connection = Pop3Connection(settings, mockTrustedSocketFactory) + val connection = Pop3Connection(settings, mockSocketFactory) connection.open() } @Test(expected = MessagingException::class) fun `when TrustedSocketFactory throws NoSuchAlgorithmException, open() should throw MessagingException`() { - stubbing(mockTrustedSocketFactory!!) { - on { createSocket(null, HOST, PORT, null) } doThrow NoSuchAlgorithmException("") + createTlsServer() + val mockSocketFactory = mock { + on { createSocket(null, settings.host, settings.port, null) } doThrow NoSuchAlgorithmException() } - addSettingsForValidMockSocket() - settings.authType = AuthType.PLAIN - val connection = Pop3Connection(settings, mockTrustedSocketFactory) + val connection = Pop3Connection(settings, mockSocketFactory) connection.open() } @Test(expected = MessagingException::class) fun `when TrustedSocketFactory throws IOException, open() should throw MessagingException`() { - stubbing(mockTrustedSocketFactory!!) { - on { createSocket(null, HOST, PORT, null) } doThrow IOException("") - } - addSettingsForValidMockSocket() - settings.authType = AuthType.PLAIN - val connection = Pop3Connection(settings, mockTrustedSocketFactory) - - connection.open() - } - - @Test(expected = MessagingException::class) - fun `open() with socket not connected should throw MessagingException`() { - stubbing(mockSocket!!) { - on { isConnected } doReturn false + createTlsServer() + val mockSocketFactory = mock { + on { createSocket(null, settings.host, settings.port, null) } doThrow IOException() } - addSettingsForValidMockSocket() - settings.authType = AuthType.PLAIN - val connection = Pop3Connection(settings, mockTrustedSocketFactory) + val connection = Pop3Connection(settings, mockSocketFactory) connection.open() } - @Test - fun `open() should send AUTH PLAIN command`() { - stubbing(mockSocket!!) { - on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() - } - addSettingsForValidMockSocket() - settings.authType = AuthType.PLAIN - val connection = Pop3Connection(settings, mockTrustedSocketFactory) - - connection.open() - - assertThat(outputStreamForMockSocket.toByteArray().decodeToString()).isEqualTo(SUCCESSFUL_PLAIN_AUTH) - } - @Test(expected = CertificateValidationException::class) fun `open() with STLS capability unavailable should throw CertificateValidationException`() { setupUnavailableStartTlsConnection() - settings.authType = AuthType.PLAIN - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED - createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) - } - - @Test - fun `open() with STLS capability unavailable should not call createSocket() to upgrade to TLS`() { - setupUnavailableStartTlsConnection() - settings.authType = AuthType.PLAIN - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED - - try { - val connection = Pop3Connection(settings, mockTrustedSocketFactory) - connection.open() - } catch (ignored: Exception) { - } - - verify(mockTrustedSocketFactory!!, never()).createSocket(any(), anyString(), anyInt(), anyString()) + createAndOpenPop3Connection(settings, socketFactory) } @Test(expected = Pop3ErrorResponse::class) fun `open() with error response to STLS command should throw`() { - val server = setupFailedStartTlsConnection() - stubbing(mockTrustedSocketFactory!!) { - on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doReturn mockSocket - } - stubbing(mockSocket!!) { - on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() - } + setupFailedStartTlsConnection() - createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) + createAndOpenPop3Connection(settings, socketFactory) } @Test fun `open() with STLS error response should not call createSocket() to upgrade to TLS`() { - val server = setupFailedStartTlsConnection() - stubbing(mockTrustedSocketFactory!!) { - on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doReturn mockSocket - } - stubbing(mockSocket!!) { - on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() - } + setupFailedStartTlsConnection() + val mockSocketFactory = mock() try { - createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) + createAndOpenPop3Connection(settings, mockSocketFactory) } catch (ignored: Exception) { } - verify(mockTrustedSocketFactory!!, never()).createSocket(any(), anyString(), anyInt(), anyString()) - } - - @Test - fun `open() with StartTLS should use TrustedSocketFactory to create TLS socket`() { - val server = setupStartTLSConnection() - settings.authType = AuthType.PLAIN - stubbing(mockTrustedSocketFactory!!) { - on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doReturn mockSocket - } - stubbing(mockSocket!!) { - on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() - } - - createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) - - verify(mockTrustedSocketFactory!!).createSocket(any(), eq(server.host), eq(server.port), eq(null)) + verifyNoInteractions(mockSocketFactory) } @Test(expected = MessagingException::class) fun `open() with StartTLS and TrustedSocketFactory throwing should throw`() { - val server = setupStartTLSConnection() - settings.authType = AuthType.PLAIN - stubbing(mockTrustedSocketFactory!!) { + val server = setupStartTlsConnection() + val mockSocketFactory = mock { on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doThrow IOException() } - stubbing(mockSocket!!) { - on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() - } - - createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) - verify(mockTrustedSocketFactory!!).createSocket(any(), eq(server.host), eq(server.port), eq(null)) - } - - @Test - fun `open() with StartTLS should authenticate over secure socket`() { - val server = setupStartTLSConnection() - settings.authType = AuthType.PLAIN - stubbing(mockTrustedSocketFactory!!) { - on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doReturn mockSocket - } - stubbing(mockSocket!!) { - on { getInputStream() } doReturn SUCCESSFUL_PLAIN_AUTH_RESPONSE.toByteArray().inputStream() - } - - createAndOpenPop3Connection(settings, mockTrustedSocketFactory!!) - - assertThat(outputStreamForMockSocket.toByteArray().decodeToString()).isEqualTo(SUCCESSFUL_PLAIN_AUTH) + createAndOpenPop3Connection(settings, mockSocketFactory) } @Test @@ -459,26 +341,33 @@ class Pop3ConnectionTest { server.verifyInteractionCompleted() } - private fun createCommonSettings() { - settings.username = USERNAME - settings.password = PASSWORD - } + @Test + fun `open() with StartTLS and AUTH PLAIN`() { + settings.authType = AuthType.PLAIN + settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED + val server = MockPop3Server() + setupServerWithStartTlsAvailable(server) + server.expect("STLS") + server.output("+OK Begin TLS negotiation") + server.startTls() + server.expect("CAPA") + server.output("+OK Listing of supported mechanisms follows") + server.output("SASL PLAIN") + server.output(".") + server.expect("AUTH PLAIN") + server.output("+OK") + server.expect(AUTH_PLAIN_ARGUMENT) + server.output("+OK") - private fun createMocks() { - mockSocket = mock { - on { getOutputStream() } doReturn outputStreamForMockSocket - on { isConnected } doReturn true - } + startServerAndCreateOpenConnection(server) - mockTrustedSocketFactory = mock { - on { createSocket(null, HOST, PORT, null) } doReturn mockSocket - } + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() } - private fun addSettingsForValidMockSocket() { - settings.host = HOST - settings.port = PORT - settings.connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED + private fun createCommonSettings() { + settings.username = USERNAME + settings.password = PASSWORD } private fun startServerAndCreateOpenConnection(server: MockPop3Server) { @@ -493,7 +382,7 @@ class Pop3ConnectionTest { connection.open() } - private fun setupStartTLSConnection(): MockPop3Server { + private fun setupStartTlsConnection(): MockPop3Server { val server = MockPop3Server() setupServerWithStartTlsAvailable(server) server.expect("STLS") @@ -546,28 +435,21 @@ class Pop3ConnectionTest { server.output(".") } + private fun createTlsServer() { + // MockPop3Server doesn't actually support implicit TLS. However, all tests using this method will encounter + // an exception before sending the first command to the server. + val server = MockPop3Server() + server.start() + + settings.host = server.host + settings.port = server.port + settings.connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED + } + companion object { - private const val HOST = "server" - private const val PORT = 12345 private const val USERNAME = "user" private const val PASSWORD = "password" - private const val INITIAL_RESPONSE = "+OK POP3 server greeting\r\n" - private const val CAPA_COMMAND = "CAPA\r\n" - private const val CAPA_RESPONSE = - "+OK Listing of supported mechanisms follows\r\n" + - "SASL PLAIN CRAM-MD5 EXTERNAL\r\n" + - ".\r\n" - private val AUTH_PLAIN_ARGUMENT = "\u0000$USERNAME\u0000$PASSWORD".encodeUtf8().base64() - private val AUTH_PLAIN_COMMAND = "AUTH PLAIN\r\n$AUTH_PLAIN_ARGUMENT\r\n" - - private const val AUTH_PLAIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n" - - private val SUCCESSFUL_PLAIN_AUTH = - CAPA_COMMAND + AUTH_PLAIN_COMMAND - - private const val SUCCESSFUL_PLAIN_AUTH_RESPONSE = - INITIAL_RESPONSE + CAPA_RESPONSE + AUTH_PLAIN_AUTHENTICATED_RESPONSE } } -- GitLab From 5d2b6a4578a73a4a904812da14d99402c54df643 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 28 Oct 2022 16:10:17 +0200 Subject: [PATCH 093/121] Clean up `Pop3ConnectionTest` --- .../k9/mail/store/pop3/Pop3ConnectionTest.kt | 433 ++++++++---------- 1 file changed, 197 insertions(+), 236 deletions(-) diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt index c0145fe097..5d627b1358 100644 --- a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.kt @@ -1,9 +1,16 @@ package com.fsck.k9.mail.store.pop3 import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthType.CRAM_MD5 +import com.fsck.k9.mail.AuthType.EXTERNAL +import com.fsck.k9.mail.AuthType.LOGIN +import com.fsck.k9.mail.AuthType.PLAIN import com.fsck.k9.mail.AuthenticationFailedException import com.fsck.k9.mail.CertificateValidationException import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ConnectionSecurity.NONE +import com.fsck.k9.mail.ConnectionSecurity.SSL_TLS_REQUIRED +import com.fsck.k9.mail.ConnectionSecurity.STARTTLS_REQUIRED import com.fsck.k9.mail.MessagingException import com.fsck.k9.mail.helpers.TestTrustedSocketFactory import com.fsck.k9.mail.ssl.TrustedSocketFactory @@ -14,7 +21,6 @@ import java.security.cert.CertificateException import javax.net.ssl.SSLException import okio.ByteString.Companion.encodeUtf8 import org.junit.Assert.fail -import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doThrow @@ -23,75 +29,82 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verifyNoInteractions class Pop3ConnectionTest { - private val settings = SimplePop3Settings() private val socketFactory = TestTrustedSocketFactory.newInstance() - @Before - fun before() { - createCommonSettings() - } - @Test(expected = CertificateValidationException::class) fun `when TrustedSocketFactory throws SSLCertificateException, open() should throw CertificateValidationException`() { - createTlsServer() + val server = startTlsServer() + val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED) val mockSocketFactory = mock { on { createSocket(null, settings.host, settings.port, null) } doThrow SSLException(CertificateException()) } - val connection = Pop3Connection(settings, mockSocketFactory) - connection.open() + createAndOpenPop3Connection(settings, mockSocketFactory) } @Test(expected = MessagingException::class) fun `when TrustedSocketFactory throws CertificateException, open() should throw MessagingException`() { - createTlsServer() + val server = startTlsServer() + val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED) val mockSocketFactory = mock { on { createSocket(null, settings.host, settings.port, null) } doThrow SSLException("") } - val connection = Pop3Connection(settings, mockSocketFactory) - connection.open() + createAndOpenPop3Connection(settings, mockSocketFactory) } @Test(expected = MessagingException::class) fun `when TrustedSocketFactory throws NoSuchAlgorithmException, open() should throw MessagingException`() { - createTlsServer() + val server = startTlsServer() + val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED) val mockSocketFactory = mock { on { createSocket(null, settings.host, settings.port, null) } doThrow NoSuchAlgorithmException() } - val connection = Pop3Connection(settings, mockSocketFactory) - connection.open() + createAndOpenPop3Connection(settings, mockSocketFactory) } @Test(expected = MessagingException::class) fun `when TrustedSocketFactory throws IOException, open() should throw MessagingException`() { - createTlsServer() + val server = startTlsServer() + val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED) val mockSocketFactory = mock { on { createSocket(null, settings.host, settings.port, null) } doThrow IOException() } - val connection = Pop3Connection(settings, mockSocketFactory) - connection.open() + createAndOpenPop3Connection(settings, mockSocketFactory) } @Test(expected = CertificateValidationException::class) fun `open() with STLS capability unavailable should throw CertificateValidationException`() { - setupUnavailableStartTlsConnection() + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN") + } + val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED) - createAndOpenPop3Connection(settings, socketFactory) + createAndOpenPop3Connection(settings) } @Test(expected = Pop3ErrorResponse::class) fun `open() with error response to STLS command should throw`() { - setupFailedStartTlsConnection() + val server = startServer { + setupServerWithStartTlsAvailable() + expect("STLS") + output("-ERR Unavailable") + } + val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED) - createAndOpenPop3Connection(settings, socketFactory) + createAndOpenPop3Connection(settings) } @Test fun `open() with STLS error response should not call createSocket() to upgrade to TLS`() { - setupFailedStartTlsConnection() + val server = startServer { + setupServerWithStartTlsAvailable() + expect("STLS") + output("-ERR Unavailable") + } + val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED) val mockSocketFactory = mock() try { @@ -104,9 +117,14 @@ class Pop3ConnectionTest { @Test(expected = MessagingException::class) fun `open() with StartTLS and TrustedSocketFactory throwing should throw`() { - val server = setupStartTlsConnection() + val server = startServer { + setupServerWithStartTlsAvailable() + expect("STLS") + output("+OK Begin TLS negotiation") + } + val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED) val mockSocketFactory = mock { - on { createSocket(any(), eq(server.host), eq(server.port), eq(null)) } doThrow IOException() + on { createSocket(any(), eq(settings.host), eq(settings.port), eq(null)) } doThrow IOException() } createAndOpenPop3Connection(settings, mockSocketFactory) @@ -114,19 +132,16 @@ class Pop3ConnectionTest { @Test fun `open() with AUTH PLAIN`() { - settings.authType = AuthType.PLAIN - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN CRAM-MD5 EXTERNAL") - server.output(".") - server.expect("AUTH PLAIN") - server.output("+OK") - server.expect(AUTH_PLAIN_ARGUMENT) - server.output("+OK") - - startServerAndCreateOpenConnection(server) + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH PLAIN") + output("+OK") + expect(AUTH_PLAIN_ARGUMENT) + output("+OK") + } + val settings = server.createSettings(authType = PLAIN) + + createAndOpenPop3Connection(settings) server.verifyConnectionStillOpen() server.verifyInteractionCompleted() @@ -134,20 +149,17 @@ class Pop3ConnectionTest { @Test fun `open() with authentication error should throw`() { - settings.authType = AuthType.PLAIN - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN CRAM-MD5 EXTERNAL") - server.output(".") - server.expect("AUTH PLAIN") - server.output("+OK") - server.expect(AUTH_PLAIN_ARGUMENT) - server.output("-ERR") + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH PLAIN") + output("+OK") + expect(AUTH_PLAIN_ARGUMENT) + output("-ERR") + } + val settings = server.createSettings(authType = PLAIN) try { - startServerAndCreateOpenConnection(server) + createAndOpenPop3Connection(settings) fail("Expected auth failure") } catch (ignored: AuthenticationFailedException) { } @@ -157,39 +169,33 @@ class Pop3ConnectionTest { @Test fun `open() with AuthType_PLAIN and no SASL PLAIN capability should use USER and PASS commands`() { - settings.authType = AuthType.PLAIN - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL CRAM-MD5 EXTERNAL") - server.output(".") - server.expect("USER user") - server.output("+OK") - server.expect("PASS password") - server.output("+OK") - - startServerAndCreateOpenConnection(server) + val server = startServer { + setupServerWithAuthenticationMethods("CRAM-MD5 EXTERNAL") + expect("USER user") + output("+OK") + expect("PASS password") + output("+OK") + } + val settings = server.createSettings(authType = PLAIN) + + createAndOpenPop3Connection(settings) server.verifyInteractionCompleted() } @Test fun `open() with authentication failure during fallback to USER and PASS commands should throw`() { - settings.authType = AuthType.PLAIN - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL CRAM-MD5 EXTERNAL") - server.output(".") - server.expect("USER user") - server.output("+OK") - server.expect("PASS password") - server.output("-ERR") + val server = startServer { + setupServerWithAuthenticationMethods("CRAM-MD5 EXTERNAL") + expect("USER user") + output("+OK") + expect("PASS password") + output("-ERR") + } + val settings = server.createSettings(authType = PLAIN) try { - startServerAndCreateOpenConnection(server) + createAndOpenPop3Connection(settings) fail("Expected auth failure") } catch (ignored: AuthenticationFailedException) { } @@ -199,19 +205,16 @@ class Pop3ConnectionTest { @Test fun `open() with CRAM-MD5 authentication`() { - settings.authType = AuthType.CRAM_MD5 - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN CRAM-MD5 EXTERNAL") - server.output(".") - server.expect("AUTH CRAM-MD5") - server.output("+ abcd") - server.expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==") - server.output("+OK") - - startServerAndCreateOpenConnection(server) + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH CRAM-MD5") + output("+ abcd") + expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==") + output("+OK") + } + val settings = server.createSettings(authType = CRAM_MD5) + + createAndOpenPop3Connection(settings) server.verifyConnectionStillOpen() server.verifyInteractionCompleted() @@ -219,20 +222,17 @@ class Pop3ConnectionTest { @Test fun `open() with authentication failure when using CRAM-MD5 should throw`() { - settings.authType = AuthType.CRAM_MD5 - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN CRAM-MD5 EXTERNAL") - server.output(".") - server.expect("AUTH CRAM-MD5") - server.output("+ abcd") - server.expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==") - server.output("-ERR") + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH CRAM-MD5") + output("+ abcd") + expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==") + output("-ERR") + } + val settings = server.createSettings(authType = CRAM_MD5) try { - startServerAndCreateOpenConnection(server) + createAndOpenPop3Connection(settings) fail("Expected auth failure") } catch (ignored: AuthenticationFailedException) { } @@ -242,17 +242,18 @@ class Pop3ConnectionTest { @Test fun `open() with CRAM-MD5 configured but missing capability should use APOP`() { - settings.authType = AuthType.CRAM_MD5 - val server = MockPop3Server() - server.output("+OK abcabcd") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN EXTERNAL") - server.output(".") - server.expect("APOP user c8e8c560e385faaa6367d4145572b8ea") - server.output("+OK") - - startServerAndCreateOpenConnection(server) + val server = startServer { + output("+OK abcabcd") + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("SASL PLAIN EXTERNAL") + output(".") + expect("APOP user c8e8c560e385faaa6367d4145572b8ea") + output("+OK") + } + val settings = server.createSettings(authType = CRAM_MD5) + + createAndOpenPop3Connection(settings) server.verifyConnectionStillOpen() server.verifyInteractionCompleted() @@ -260,18 +261,19 @@ class Pop3ConnectionTest { @Test fun `open() with authentication failure when using APOP should throw`() { - settings.authType = AuthType.CRAM_MD5 - val server = MockPop3Server() - server.output("+OK abcabcd") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN EXTERNAL") - server.output(".") - server.expect("APOP user c8e8c560e385faaa6367d4145572b8ea") - server.output("-ERR") + val server = startServer { + output("+OK abcabcd") + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("SASL PLAIN EXTERNAL") + output(".") + expect("APOP user c8e8c560e385faaa6367d4145572b8ea") + output("-ERR") + } + val settings = server.createSettings(authType = CRAM_MD5) try { - startServerAndCreateOpenConnection(server) + createAndOpenPop3Connection(settings) fail("Expected auth failure") } catch (ignored: AuthenticationFailedException) { } @@ -281,17 +283,14 @@ class Pop3ConnectionTest { @Test fun `open() with AUTH EXTERNAL`() { - settings.authType = AuthType.EXTERNAL - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL CRAM-MD5 EXTERNAL") - server.output(".") - server.expect("AUTH EXTERNAL dXNlcg==") - server.output("+OK") - - startServerAndCreateOpenConnection(server) + val server = startServer { + setupServerWithAuthenticationMethods("CRAM-MD5 EXTERNAL") + expect("AUTH EXTERNAL dXNlcg==") + output("+OK") + } + val settings = server.createSettings(authType = EXTERNAL) + + createAndOpenPop3Connection(settings) server.verifyConnectionStillOpen() server.verifyInteractionCompleted() @@ -299,16 +298,13 @@ class Pop3ConnectionTest { @Test fun `open() with AuthType_EXTERNAL configured but missing capability should throw`() { - settings.authType = AuthType.EXTERNAL - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN CRAM-MD5") - server.output(".") + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5") + } + val settings = server.createSettings(authType = EXTERNAL) try { - startServerAndCreateOpenConnection(server) + createAndOpenPop3Connection(settings) fail("CVE expected") } catch (e: CertificateValidationException) { assertThat(e.reason).isEqualTo(CertificateValidationException.Reason.MissingCapability) @@ -320,18 +316,15 @@ class Pop3ConnectionTest { @Test fun `open() with authentication failure when using AUTH EXTERNAL should throw`() { - settings.authType = AuthType.EXTERNAL - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN CRAM-MD5 EXTERNAL") - server.output(".") - server.expect("AUTH EXTERNAL dXNlcg==") - server.output("-ERR Invalid certificate") + val server = startServer { + setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL") + expect("AUTH EXTERNAL dXNlcg==") + output("-ERR Invalid certificate") + } + val settings = server.createSettings(authType = EXTERNAL) try { - startServerAndCreateOpenConnection(server) + createAndOpenPop3Connection(settings) fail("CVE expected") } catch (e: CertificateValidationException) { assertThat(e).hasMessageThat() @@ -343,107 +336,75 @@ class Pop3ConnectionTest { @Test fun `open() with StartTLS and AUTH PLAIN`() { - settings.authType = AuthType.PLAIN - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED - val server = MockPop3Server() - setupServerWithStartTlsAvailable(server) - server.expect("STLS") - server.output("+OK Begin TLS negotiation") - server.startTls() - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN") - server.output(".") - server.expect("AUTH PLAIN") - server.output("+OK") - server.expect(AUTH_PLAIN_ARGUMENT) - server.output("+OK") - - startServerAndCreateOpenConnection(server) + val server = startServer { + setupServerWithStartTlsAvailable() + expect("STLS") + output("+OK Begin TLS negotiation") + startTls() + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("SASL PLAIN") + output(".") + expect("AUTH PLAIN") + output("+OK") + expect(AUTH_PLAIN_ARGUMENT) + output("+OK") + } + val settings = server.createSettings(authType = PLAIN, connectionSecurity = STARTTLS_REQUIRED) + + createAndOpenPop3Connection(settings) server.verifyConnectionStillOpen() server.verifyInteractionCompleted() } - private fun createCommonSettings() { - settings.username = USERNAME - settings.password = PASSWORD - } - - private fun startServerAndCreateOpenConnection(server: MockPop3Server) { - server.start() - settings.host = server.host - settings.port = server.port - createAndOpenPop3Connection(settings, socketFactory!!) - } - - private fun createAndOpenPop3Connection(settings: Pop3Settings, socketFactory: TrustedSocketFactory) { - val connection = Pop3Connection(settings, socketFactory) + private fun createAndOpenPop3Connection( + settings: Pop3Settings, + trustedSocketFactory: TrustedSocketFactory = socketFactory + ) { + val connection = Pop3Connection(settings, trustedSocketFactory) connection.open() } - private fun setupStartTlsConnection(): MockPop3Server { - val server = MockPop3Server() - setupServerWithStartTlsAvailable(server) - server.expect("STLS") - server.output("+OK Begin TLS negotiation") - server.start() - - settings.host = server.host - settings.port = server.port - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED - - return server + private fun MockPop3Server.setupServerWithAuthenticationMethods(authenticationMethods: String) { + output("+OK POP3 server greeting") + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("SASL $authenticationMethods") + output(".") } - private fun setupFailedStartTlsConnection(): MockPop3Server { - val server = MockPop3Server() - setupServerWithStartTlsAvailable(server) - server.expect("STLS") - server.output("-ERR Unavailable") - server.start() - - settings.host = server.host - settings.port = server.port - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED - - return server + private fun MockPop3Server.setupServerWithStartTlsAvailable() { + output("+OK POP3 server greeting") + expect("CAPA") + output("+OK Listing of supported mechanisms follows") + output("STLS") + output("SASL PLAIN") + output(".") } - private fun setupUnavailableStartTlsConnection(): MockPop3Server { - val server = MockPop3Server() - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("SASL PLAIN") - server.output(".") - server.start() - - settings.host = server.host - settings.port = server.port - settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED - - return server + private fun startTlsServer(): MockPop3Server { + // MockPop3Server doesn't actually support implicit TLS. However, all tests using this method will encounter + // an exception before sending the first command to the server. + return startServer { } } - private fun setupServerWithStartTlsAvailable(server: MockPop3Server) { - server.output("+OK POP3 server greeting") - server.expect("CAPA") - server.output("+OK Listing of supported mechanisms follows") - server.output("STLS") - server.output("SASL PLAIN") - server.output(".") + private fun MockPop3Server.createSettings( + authType: AuthType = LOGIN, + connectionSecurity: ConnectionSecurity = NONE + ): Pop3Settings { + return SimplePop3Settings().apply { + username = USERNAME + password = PASSWORD + this.authType = authType + host = this@createSettings.host + port = this@createSettings.port + this.connectionSecurity = connectionSecurity + } } - private fun createTlsServer() { - // MockPop3Server doesn't actually support implicit TLS. However, all tests using this method will encounter - // an exception before sending the first command to the server. - val server = MockPop3Server() - server.start() - - settings.host = server.host - settings.port = server.port - settings.connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED + private fun startServer(block: MockPop3Server.() -> Unit): MockPop3Server { + return MockPop3Server().apply(block).also { it.start() } } companion object { -- GitLab From fcd8d770c401ed4f3119eef8857ed1098e8289ee Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 29 Oct 2022 00:19:37 +0200 Subject: [PATCH 094/121] Rename .java to .kt --- .../k9/mail/store/pop3/{Pop3StoreTest.java => Pop3StoreTest.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/{Pop3StoreTest.java => Pop3StoreTest.kt} (100%) diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.java b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt similarity index 100% rename from mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.java rename to mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt -- GitLab From 0553ad957ba7bbe412e3189be1fa137d339f5020 Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 29 Oct 2022 00:19:37 +0200 Subject: [PATCH 095/121] Convert `Pop3StoreTest` to Kotlin --- .../fsck/k9/mail/store/pop3/Pop3StoreTest.kt | 239 +++++++++--------- 1 file changed, 119 insertions(+), 120 deletions(-) diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt index d7021637ca..fccc4f4bb2 100644 --- a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt @@ -1,154 +1,153 @@ -package com.fsck.k9.mail.store.pop3; - - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.net.Socket; - -import com.fsck.k9.mail.AuthType; -import com.fsck.k9.mail.AuthenticationFailedException; -import com.fsck.k9.mail.ConnectionSecurity; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.ServerSettings; -import com.fsck.k9.mail.filter.Base64; -import com.fsck.k9.mail.ssl.TrustedSocketFactory; -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - - -public class Pop3StoreTest { - private static final String INITIAL_RESPONSE = "+OK POP3 server greeting\r\n"; - private static final String CAPA = "CAPA\r\n"; - private static final String CAPA_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" + - "SASL PLAIN CRAM-MD5 EXTERNAL\r\n" + - ".\r\n"; - private static final String AUTH_PLAIN_WITH_LOGIN = "AUTH PLAIN\r\n" + - new String(Base64.encodeBase64(("\000user\000password").getBytes())) + "\r\n"; - private static final String AUTH_PLAIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n"; - private static final String AUTH_PLAIN_FAILED_RESPONSE = "+OK\r\n" + "Plain authentication failure"; - private static final String STAT = "STAT\r\n"; - private static final String STAT_RESPONSE = "+OK 20 0\r\n"; - private static final String UIDL_UNSUPPORTED_RESPONSE = "-ERR UIDL unsupported\r\n"; - private static final String UIDL_SUPPORTED_RESPONSE = "+OK UIDL supported\r\n"; - - - private Pop3Store store; - private TrustedSocketFactory mockTrustedSocketFactory = mock(TrustedSocketFactory.class); - private Socket mockSocket = mock(Socket.class); - private OutputStream mockOutputStream = mock(OutputStream.class); - - - @Before - public void setUp() throws Exception { - ServerSettings serverSettings = createServerSettings(); - when(mockTrustedSocketFactory.createSocket(null, "server", 12345, null)).thenReturn(mockSocket); - when(mockSocket.isConnected()).thenReturn(true); - when(mockSocket.isClosed()).thenReturn(false); - - when(mockSocket.getOutputStream()).thenReturn(mockOutputStream); - store = new Pop3Store(serverSettings, mockTrustedSocketFactory); - } +package com.fsck.k9.mail.store.pop3 + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import com.google.common.truth.Truth.assertThat +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.Socket +import okio.ByteString.Companion.encodeUtf8 +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing + +class Pop3StoreTest { + private val trustedSocketFactory = mock() + private val store: Pop3Store = Pop3Store(createServerSettings(), trustedSocketFactory) @Test - public void getFolder_shouldReturnSameFolderEachTime() { - Pop3Folder folderOne = store.getFolder("TestFolder"); - Pop3Folder folderTwo = store.getFolder("TestFolder"); + fun `getFolder() should return same instance every time`() { + val folderOne = store.getFolder("TestFolder") + val folderTwo = store.getFolder("TestFolder") - assertSame(folderOne, folderTwo); + assertThat(folderTwo).isSameInstanceAs(folderOne) } @Test - public void getFolder_shouldReturnFolderWithCorrectName() throws Exception { - Pop3Folder folder = store.getFolder("TestFolder"); + fun `getFolder() should return folder with correct server ID`() { + val folder = store.getFolder("TestFolder") - assertEquals("TestFolder", folder.getServerId()); + assertThat(folder.serverId).isEqualTo("TestFolder") } - @Test(expected = MessagingException.class) - public void checkSetting_whenConnectionThrowsException_shouldThrowMessagingException() - throws Exception { - when(mockTrustedSocketFactory.createSocket(any(Socket.class), - anyString(), anyInt(), anyString())).thenThrow(new IOException("Test")); - store.checkSettings(); + @Test(expected = MessagingException::class) + fun `checkSettings() with TrustedSocketFactory throwing should throw MessagingException`() { + stubbing(trustedSocketFactory) { + on { createSocket(null, "server", 12345, null) } doThrow IOException() + } + + store.checkSettings() } - @Test(expected = MessagingException.class) - public void checkSetting_whenUidlUnsupported_shouldThrowMessagingException() - throws Exception { - String response = INITIAL_RESPONSE + + @Test(expected = MessagingException::class) + fun `checkSettings() with UIDL command not supported should throw MessagingException`() { + setupSocketWithResponse( + INITIAL_RESPONSE + CAPA_RESPONSE + AUTH_PLAIN_AUTHENTICATED_RESPONSE + STAT_RESPONSE + - UIDL_UNSUPPORTED_RESPONSE; - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(response.getBytes("UTF-8"))); - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - when(mockSocket.getOutputStream()).thenReturn(byteArrayOutputStream); - store.checkSettings(); + UIDL_UNSUPPORTED_RESPONSE + ) + + store.checkSettings() } @Test - public void checkSetting_whenUidlSupported_shouldReturn() - throws Exception { - String response = INITIAL_RESPONSE + + fun `checkSettings() with UIDL supported`() { + setupSocketWithResponse( + INITIAL_RESPONSE + CAPA_RESPONSE + AUTH_PLAIN_AUTHENTICATED_RESPONSE + STAT_RESPONSE + - UIDL_SUPPORTED_RESPONSE; - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(response.getBytes("UTF-8"))); - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - when(mockSocket.getOutputStream()).thenReturn(byteArrayOutputStream); - store.checkSettings(); - } + UIDL_SUPPORTED_RESPONSE + ) - // Component Level Tests + store.checkSettings() + } @Test - public void open_withAuthResponseUsingAuthPlain_shouldRetrieveMessageCountOnAuthenticatedSocket() throws Exception { - String response = INITIAL_RESPONSE + + // TODO: Move to Pop3FolderTest + fun `Pop3Folder_open() with auth response using AUTH PLAIN should retrieve message count`() { + val outputStream = setupSocketWithResponse( + INITIAL_RESPONSE + CAPA_RESPONSE + AUTH_PLAIN_AUTHENTICATED_RESPONSE + - STAT_RESPONSE; - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(response.getBytes("UTF-8"))); - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - when(mockSocket.getOutputStream()).thenReturn(byteArrayOutputStream); - Pop3Folder folder = store.getFolder(Pop3Folder.INBOX); + STAT_RESPONSE + ) + val folder = store.getFolder(Pop3Folder.INBOX) - folder.open(); + folder.open() + store.createConnection() - assertEquals(20, folder.getMessageCount()); - assertEquals(CAPA + AUTH_PLAIN_WITH_LOGIN + STAT, byteArrayOutputStream.toString("UTF-8")); + assertThat(folder.messageCount).isEqualTo(20) + assertThat(outputStream.toByteArray().decodeToString()).isEqualTo(CAPA + AUTH_PLAIN_WITH_LOGIN + STAT) } - @Test(expected = AuthenticationFailedException.class) - public void open_withFailedAuth_shouldThrow() throws Exception { - String response = INITIAL_RESPONSE + - CAPA_RESPONSE + - AUTH_PLAIN_FAILED_RESPONSE; - when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(response.getBytes("UTF-8"))); - Pop3Folder folder = store.getFolder(Pop3Folder.INBOX); + @Test(expected = AuthenticationFailedException::class) + // TODO: Move to Pop3FolderTest + fun `Pop3Folder_open() with failed authentication should throw`() { + val response = INITIAL_RESPONSE + + CAPA_RESPONSE + + AUTH_PLAIN_FAILED_RESPONSE + setupSocketWithResponse(response) + val folder = store.getFolder(Pop3Folder.INBOX) - folder.open(); + folder.open() } - private ServerSettings createServerSettings() { - return new ServerSettings( - "pop3", - "server", - 12345, - ConnectionSecurity.SSL_TLS_REQUIRED, - AuthType.PLAIN, - "user", - "password", - null); + private fun createServerSettings(): ServerSettings { + return ServerSettings( + type = "pop3", + host = "server", + port = 12345, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null + ) + } + + private fun setupSocketWithResponse(response: String): ByteArrayOutputStream { + val outputStream = ByteArrayOutputStream() + + val socket = mock { + on { isConnected } doReturn true + on { isClosed } doReturn false + on { getOutputStream() } doReturn outputStream + on { getInputStream() } doReturn response.byteInputStream() + } + + stubbing(trustedSocketFactory) { + on { createSocket(null, "server", 12345, null) } doReturn socket + } + + return outputStream + } + + companion object { + private const val INITIAL_RESPONSE = "+OK POP3 server greeting\r\n" + + private const val CAPA = "CAPA\r\n" + private const val CAPA_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" + + "SASL PLAIN CRAM-MD5 EXTERNAL\r\n" + + ".\r\n" + + private val AUTH_PLAIN_ARGUMENT = "\u0000user\u0000password".encodeUtf8().base64() + private val AUTH_PLAIN_WITH_LOGIN = "AUTH PLAIN\r\n$AUTH_PLAIN_ARGUMENT\r\n" + private const val AUTH_PLAIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n" + private const val AUTH_PLAIN_FAILED_RESPONSE = "+OK\r\n" + "Plain authentication failure" + + private const val STAT = "STAT\r\n" + private const val STAT_RESPONSE = "+OK 20 0\r\n" + + private const val UIDL_UNSUPPORTED_RESPONSE = "-ERR UIDL unsupported\r\n" + private const val UIDL_SUPPORTED_RESPONSE = "+OK UIDL supported\r\n" } } -- GitLab From 7ffb3495c095ee80843e649bda90be230f661c20 Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 29 Oct 2022 01:04:30 +0200 Subject: [PATCH 096/121] Rename .java to .kt --- .../k9/mail/store/pop3/{Pop3FolderTest.java => Pop3FolderTest.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/{Pop3FolderTest.java => Pop3FolderTest.kt} (100%) diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.java b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt similarity index 100% rename from mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.java rename to mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt -- GitLab From 1e13d9c3242d73314fe66ffca5b50fd6d324d810 Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 29 Oct 2022 01:04:30 +0200 Subject: [PATCH 097/121] Convert `Pop3FolderTest` to Kotlin --- .../fsck/k9/mail/store/pop3/Pop3FolderTest.kt | 350 +++++++++--------- 1 file changed, 170 insertions(+), 180 deletions(-) diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt index 9051f48a09..2228b6a75c 100644 --- a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt @@ -1,260 +1,250 @@ -package com.fsck.k9.mail.store.pop3; - - -import com.fsck.k9.mail.FetchProfile; -import com.fsck.k9.mail.FetchProfile.Item; -import com.fsck.k9.mail.MessageRetrievalListener; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.internet.BinaryTempFileBody; - -import org.junit.Before; -import org.junit.Test; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertSame; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class Pop3FolderTest { - private static final int MAX_DOWNLOAD_SIZE = -1; - - private Pop3Store mockStore; - private Pop3Connection mockConnection; - private MessageRetrievalListener mockListener; - private Pop3Folder folder; +package com.fsck.k9.mail.store.pop3 + +import com.fsck.k9.mail.Body +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.MessageRetrievalListener +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.crlf +import com.fsck.k9.mail.internet.BinaryTempFileBody +import com.fsck.k9.mail.store.pop3.Pop3Commands.STAT_COMMAND +import com.google.common.truth.Truth.assertThat +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing + +class Pop3FolderTest { + private val connection = mock { + on { executeSimpleCommand(STAT_COMMAND) } doReturn "+OK $MESSAGE_COUNT 0" + on { isOpen } doReturn true + } + private val store = mock { + on { createConnection() } doReturn connection + } + private val messageRetrievalListener = mock>() + private val folder = Pop3Folder(store, Pop3Folder.INBOX) @Before - public void before() throws MessagingException { - mockStore = mock(Pop3Store.class); - mockConnection = mock(Pop3Connection.class); - mockListener = mock(MessageRetrievalListener.class); - when(mockStore.createConnection()).thenReturn(mockConnection); - when(mockConnection.executeSimpleCommand(Pop3Commands.STAT_COMMAND)).thenReturn("+OK 10 0"); - folder = new Pop3Folder(mockStore, Pop3Folder.INBOX); - BinaryTempFileBody.setTempDirectory(new File(System.getProperty("java.io.tmpdir"))); + fun setUp() { + BinaryTempFileBody.setTempDirectory(File(System.getProperty("java.io.tmpdir"))) } - @Test(expected = MessagingException.class) - public void open_withoutInboxFolder_shouldThrow() throws Exception { - Pop3Folder folder = new Pop3Folder(mockStore, "TestFolder"); + @Test(expected = MessagingException::class) + fun `open() without Inbox folder should throw`() { + val folder = Pop3Folder(store, "TestFolder") - folder.open(); + folder.open() } @Test - public void open_withoutInboxFolder_shouldNotTryAndCreateConnection() throws Exception { - Pop3Folder folder = new Pop3Folder(mockStore, "TestFolder"); + fun `open() without Inbox folder should not call Pop3Store_createConnection()`() { + val folder = Pop3Folder(store, "TestFolder") + try { - folder.open(); - } catch (Exception ignored) {} - verify(mockStore, never()).createConnection(); + folder.open() + } catch (ignored: Exception) { + } + + verify(store, never()).createConnection() } - @Test(expected = MessagingException.class) - public void open_withInboxFolderWithExceptionCreatingConnection_shouldThrow() - throws MessagingException { + @Test(expected = MessagingException::class) + fun `open() with exception when creating a connection should throw`() { + stubbing(store) { + on { createConnection() } doThrow MessagingException("Test") + } - when(mockStore.createConnection()).thenThrow(new MessagingException("Test")); - folder.open(); + folder.open() } @Test - public void open_withInboxFolder_shouldSetMessageCountFromStatResponse() - throws MessagingException { - folder.open(); - - int messageCount = folder.getMessageCount(); + fun `open() should set message count from STAT response`() { + folder.open() - assertEquals(10, messageCount); + assertThat(folder.messageCount).isEqualTo(MESSAGE_COUNT) } - @Test(expected = MessagingException.class) - public void open_withInboxFolder_whenStatCommandFails_shouldThrow() - throws MessagingException { - when(mockConnection.executeSimpleCommand(Pop3Commands.STAT_COMMAND)) - .thenThrow(new MessagingException("Test")); + @Test(expected = MessagingException::class) + fun `open() with STAT command failing should throw`() { + stubbing(connection) { + on { executeSimpleCommand(STAT_COMMAND) } doThrow MessagingException("Test") + } - folder.open(); + folder.open() } @Test - public void open_createsAndOpensConnection() - throws MessagingException { - folder.open(); + fun `open() should open connection`() { + folder.open() - verify(mockStore, times(1)).createConnection(); - verify(mockConnection).open(); + verify(store, times(1)).createConnection() + verify(connection).open() } @Test - public void open_whenAlreadyOpenWithValidConnection_doesNotCreateAnotherConnection() - throws MessagingException { - folder.open(); - when(mockConnection.isOpen()).thenReturn(true); + fun `open() with connection already open should not create another connection`() { + folder.open() - folder.open(); + folder.open() - verify(mockStore, times(1)).createConnection(); + verify(store, times(1)).createConnection() } @Test - public void close_onNonOpenedFolder_succeeds() - throws MessagingException { - - - folder.close(); + fun `close() with closed folder should not throw`() { + folder.close() } @Test - public void close_onOpenedFolder_succeeds() - throws MessagingException { - - folder.open(); + fun `close() with open folder should not throw`() { + folder.open() - folder.close(); + folder.close() } @Test - public void close_onOpenedFolder_sendsQUIT() - throws MessagingException { - - folder.open(); - when(mockConnection.isOpen()).thenReturn(true); + fun `close() with open folder should send QUIT command`() { + folder.open() - folder.close(); + folder.close() - verify(mockConnection).executeSimpleCommand(Pop3Commands.QUIT_COMMAND); + verify(connection).executeSimpleCommand(Pop3Commands.QUIT_COMMAND) } @Test - public void close_withExceptionQuiting_ignoresException() - throws MessagingException { + fun `close() with exception when sending QUIT command should not throw`() { + stubbing(connection) { + on { executeSimpleCommand(Pop3Commands.QUIT_COMMAND) } doThrow MessagingException("Test") + } + folder.open() - folder.open(); - when(mockConnection.isOpen()).thenReturn(true); - doThrow(new MessagingException("Test")) - .when(mockConnection) - .executeSimpleCommand(Pop3Commands.QUIT_COMMAND); - - folder.close(); + folder.close() } @Test - public void close_onOpenedFolder_closesConnection() - throws MessagingException { - - folder.open(); - when(mockConnection.isOpen()).thenReturn(true); + fun `close() with open folder should close connection`() { + folder.open() - folder.close(); + folder.close() - verify(mockConnection).close(); + verify(connection).close() } @Test - public void getMessages_returnsListOfMessagesOnServer() throws IOException, MessagingException { - folder.open(); - - when(mockConnection.readLine()).thenReturn("1 abcd").thenReturn("."); + fun `getMessages() should return list of messages on server`() { + stubbing(connection) { + on { readLine() } doReturn "1 $MESSAGE_SERVER_ID" doReturn "." + } + folder.open() - List result = folder.getMessages(1, 1, mockListener); + val result = folder.getMessages(1, 1, messageRetrievalListener) - assertEquals(1, result.size()); + assertThat(result).hasSize(1) } - @Test(expected = MessagingException.class) - public void getMessages_withInvalidSet_throwsException() throws IOException, MessagingException { - folder.open(); + @Test(expected = MessagingException::class) + fun `getMessages() with invalid set should throw`() { + folder.open() - folder.getMessages(2, 1, mockListener); + folder.getMessages(2, 1, messageRetrievalListener) } - @Test(expected = MessagingException.class) - public void getMessages_withIOExceptionReadingLine_throwsException() throws IOException, MessagingException { - folder.open(); + @Test(expected = MessagingException::class) + fun `getMessages() with IOException when reading line should throw`() { + stubbing(connection) { + on { readLine() } doThrow IOException("Test") + } + folder.open() - when(mockConnection.readLine()).thenThrow(new IOException("Test")); - - folder.getMessages(1, 1, mockListener); + folder.getMessages(1, 1, messageRetrievalListener) } @Test - public void getMessage_withPreviouslyFetchedMessage_returnsMessage() - throws IOException, MessagingException { - folder.open(); - - List messageList = setupMessageFromServer(); + fun `getMessage() with previously fetched message should return message`() { + folder.open() + val messageList = setupMessageFromServer() - Pop3Message message = folder.getMessage("abcd"); + val message = folder.getMessage(MESSAGE_SERVER_ID) - assertSame(messageList.get(0), message); + assertThat(message).isSameInstanceAs(messageList.first()) } @Test - public void getMessage_withNoPreviouslyFetchedMessage_returnsNewMessage() - throws IOException, MessagingException { - folder.open(); + fun `getMessage() without previously fetched message should return new message`() { + folder.open() - Pop3Message message = folder.getMessage("abcd"); + val message = folder.getMessage(MESSAGE_SERVER_ID) - assertNotNull(message); + assertThat(message).isNotNull() } - @Test - public void fetch_withEnvelopeProfile_setsSizeOfMessage() throws MessagingException, IOException { - folder.open(); - List messageList = setupMessageFromServer(); - FetchProfile fetchProfile = new FetchProfile(); - fetchProfile.add(Item.ENVELOPE); - when(mockConnection.readLine()).thenReturn("1 100").thenReturn("."); + fun `fetch() with ENVELOPE profile should set size of message`() { + folder.open() + val messageList = setupMessageFromServer() + val fetchProfile = FetchProfile() + fetchProfile.add(FetchProfile.Item.ENVELOPE) + stubbing(connection) { + on { readLine() } doReturn "1 100" doReturn "." + } - folder.fetch(messageList, fetchProfile, mockListener, MAX_DOWNLOAD_SIZE); + folder.fetch(messageList, fetchProfile, messageRetrievalListener, MAX_DOWNLOAD_SIZE) - assertEquals(100, messageList.get(0).getSize()); + assertThat(messageList.first().size).isEqualTo(100) } @Test - public void fetch_withBodyProfile_setsContentOfMessage() throws MessagingException, IOException { - InputStream messageInputStream = new ByteArrayInputStream(( - "From: \r\n" + - "To: \r\n" + - "Subject: Testmail\r\n" + - "MIME-Version: 1.0\r\n" + - "Content-type: text/plain\r\n" + - "Content-Transfer-Encoding: 7bit\r\n" + - "\r\n" + - "this is some test text.").getBytes()); - folder.open(); - List messageList = setupMessageFromServer(); - FetchProfile fetchProfile = new FetchProfile(); - fetchProfile.add(Item.BODY); - when(mockConnection.readLine()).thenReturn("1 100").thenReturn("."); - when(mockConnection.getInputStream()).thenReturn(messageInputStream); - - folder.fetch(messageList, fetchProfile, mockListener, MAX_DOWNLOAD_SIZE); - - ByteArrayOutputStream bodyData = new ByteArrayOutputStream(); - messageList.get(0).getBody().writeTo(bodyData); - - assertEquals("this is some test text.", new String(bodyData.toByteArray(), "UTF-8")); - } - - private List setupMessageFromServer() throws IOException, MessagingException { - when(mockConnection.readLine()).thenReturn("1 abcd").thenReturn("."); - return folder.getMessages(1, 1, mockListener); + fun `fetch() with BODY profile should set content of message`() { + val messageInputStream = + """ + From: + To: + Subject: Testmail + MIME-Version: 1.0 + Content-type: text/plain + Content-Transfer-Encoding: 7bit + + this is some test text. + """.trimIndent().crlf().byteInputStream() + folder.open() + val messageList = setupMessageFromServer() + val fetchProfile = FetchProfile() + fetchProfile.add(FetchProfile.Item.BODY) + stubbing(connection) { + on { readLine() } doReturn("1 100") doReturn(".") + on { inputStream } doReturn messageInputStream + } + + folder.fetch(messageList, fetchProfile, messageRetrievalListener, MAX_DOWNLOAD_SIZE) + + assertThat(messageList.first().body.writeToString()).isEqualTo("this is some test text.") + } + + private fun setupMessageFromServer(): List { + stubbing(connection) { + on { readLine() } doReturn "1 $MESSAGE_SERVER_ID" doReturn "." + } + + return folder.getMessages(1, 1, messageRetrievalListener) + } + + private fun Body.writeToString(): String { + return ByteArrayOutputStream().also { outputStream -> + writeTo(outputStream) + }.toByteArray().decodeToString() + } + + companion object { + private const val MESSAGE_COUNT = 10 + private const val MESSAGE_SERVER_ID = "abcd" + private const val MAX_DOWNLOAD_SIZE = -1 } } -- GitLab From 15b7d8ee063b46a31983e4c226a3ff62057cce6e Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 29 Oct 2022 01:10:28 +0200 Subject: [PATCH 098/121] Move test from `Pop3StoreTest` to `Pop3FolderTest` --- .../fsck/k9/mail/store/pop3/Pop3FolderTest.kt | 10 +++++ .../fsck/k9/mail/store/pop3/Pop3StoreTest.kt | 37 ------------------- 2 files changed, 10 insertions(+), 37 deletions(-) diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt index 2228b6a75c..52d316cf71 100644 --- a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.kt @@ -1,5 +1,6 @@ package com.fsck.k9.mail.store.pop3 +import com.fsck.k9.mail.AuthenticationFailedException import com.fsck.k9.mail.Body import com.fsck.k9.mail.FetchProfile import com.fsck.k9.mail.MessageRetrievalListener @@ -65,6 +66,15 @@ class Pop3FolderTest { folder.open() } + @Test(expected = AuthenticationFailedException::class) + fun `open() with failed authentication should throw`() { + stubbing(connection) { + on { open() } doThrow AuthenticationFailedException("Test") + } + + folder.open() + } + @Test fun `open() should set message count from STAT response`() { folder.open() diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt index fccc4f4bb2..82ab36616b 100644 --- a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt @@ -1,7 +1,6 @@ package com.fsck.k9.mail.store.pop3 import com.fsck.k9.mail.AuthType -import com.fsck.k9.mail.AuthenticationFailedException import com.fsck.k9.mail.ConnectionSecurity import com.fsck.k9.mail.MessagingException import com.fsck.k9.mail.ServerSettings @@ -10,7 +9,6 @@ import com.google.common.truth.Truth.assertThat import java.io.ByteArrayOutputStream import java.io.IOException import java.net.Socket -import okio.ByteString.Companion.encodeUtf8 import org.junit.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow @@ -71,36 +69,6 @@ class Pop3StoreTest { store.checkSettings() } - @Test - // TODO: Move to Pop3FolderTest - fun `Pop3Folder_open() with auth response using AUTH PLAIN should retrieve message count`() { - val outputStream = setupSocketWithResponse( - INITIAL_RESPONSE + - CAPA_RESPONSE + - AUTH_PLAIN_AUTHENTICATED_RESPONSE + - STAT_RESPONSE - ) - val folder = store.getFolder(Pop3Folder.INBOX) - - folder.open() - store.createConnection() - - assertThat(folder.messageCount).isEqualTo(20) - assertThat(outputStream.toByteArray().decodeToString()).isEqualTo(CAPA + AUTH_PLAIN_WITH_LOGIN + STAT) - } - - @Test(expected = AuthenticationFailedException::class) - // TODO: Move to Pop3FolderTest - fun `Pop3Folder_open() with failed authentication should throw`() { - val response = INITIAL_RESPONSE + - CAPA_RESPONSE + - AUTH_PLAIN_FAILED_RESPONSE - setupSocketWithResponse(response) - val folder = store.getFolder(Pop3Folder.INBOX) - - folder.open() - } - private fun createServerSettings(): ServerSettings { return ServerSettings( type = "pop3", @@ -134,17 +102,12 @@ class Pop3StoreTest { companion object { private const val INITIAL_RESPONSE = "+OK POP3 server greeting\r\n" - private const val CAPA = "CAPA\r\n" private const val CAPA_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" + "SASL PLAIN CRAM-MD5 EXTERNAL\r\n" + ".\r\n" - private val AUTH_PLAIN_ARGUMENT = "\u0000user\u0000password".encodeUtf8().base64() - private val AUTH_PLAIN_WITH_LOGIN = "AUTH PLAIN\r\n$AUTH_PLAIN_ARGUMENT\r\n" private const val AUTH_PLAIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n" - private const val AUTH_PLAIN_FAILED_RESPONSE = "+OK\r\n" + "Plain authentication failure" - private const val STAT = "STAT\r\n" private const val STAT_RESPONSE = "+OK 20 0\r\n" private const val UIDL_UNSUPPORTED_RESPONSE = "-ERR UIDL unsupported\r\n" -- GitLab From 4908bcad47fbe6992b16ae21c96d279f466d3cb4 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 28 Oct 2022 12:24:22 +0200 Subject: [PATCH 099/121] Try all IP addresses when connecting to a POP3 server --- .../k9/mail/store/pop3/Pop3Connection.java | 51 +++++++++++++++---- .../fsck/k9/mail/store/pop3/Pop3StoreTest.kt | 7 +-- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Connection.java b/mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Connection.java index aaff29d73d..4badc73e5f 100644 --- a/mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Connection.java +++ b/mail/protocols/pop3/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Connection.java @@ -5,9 +5,10 @@ import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.IOException; import java.io.InputStream; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; -import java.net.SocketAddress; +import java.net.UnknownHostException; import java.security.GeneralSecurityException; import java.security.KeyManagementException; import java.security.MessageDigest; @@ -61,15 +62,7 @@ class Pop3Connection { void open() throws MessagingException { try { - SocketAddress socketAddress = new InetSocketAddress(settings.getHost(), settings.getPort()); - if (settings.getConnectionSecurity() == ConnectionSecurity.SSL_TLS_REQUIRED) { - socket = trustedSocketFactory.createSocket(null, settings.getHost(), - settings.getPort(), settings.getClientCertificateAlias()); - } else { - socket = new Socket(); - } - - socket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); + socket = connect(); in = new BufferedInputStream(socket.getInputStream(), 1024); out = new BufferedOutputStream(socket.getOutputStream(), 512); @@ -102,6 +95,44 @@ class Pop3Connection { } } + private Socket connect() + throws IOException, MessagingException, NoSuchAlgorithmException, KeyManagementException { + InetAddress[] inetAddresses = InetAddress.getAllByName(settings.getHost()); + + IOException connectException = null; + for (InetAddress address : inetAddresses) { + try { + return connectToAddress(address); + } catch (IOException e) { + Timber.w(e, "Could not connect to %s", address); + connectException = e; + } + } + + throw connectException != null ? connectException : new UnknownHostException(); + } + + private Socket connectToAddress(InetAddress address) + throws IOException, MessagingException, NoSuchAlgorithmException, KeyManagementException { + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_POP3) { + Timber.d("Connecting to %s as %s", settings.getHost(), address); + } + + InetSocketAddress socketAddress = new InetSocketAddress(address, settings.getPort()); + + final Socket socket; + if (settings.getConnectionSecurity() == ConnectionSecurity.SSL_TLS_REQUIRED) { + socket = trustedSocketFactory.createSocket(null, settings.getHost(), settings.getPort(), + settings.getClientCertificateAlias()); + } else { + socket = new Socket(); + } + + socket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); + + return socket; + } + /* * If STARTTLS is not available throws a CertificateValidationException which in K-9 * triggers a "Certificate error" notification that takes the user to the incoming diff --git a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt index 82ab36616b..554f766833 100644 --- a/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt +++ b/mail/protocols/pop3/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.kt @@ -37,7 +37,7 @@ class Pop3StoreTest { @Test(expected = MessagingException::class) fun `checkSettings() with TrustedSocketFactory throwing should throw MessagingException`() { stubbing(trustedSocketFactory) { - on { createSocket(null, "server", 12345, null) } doThrow IOException() + on { createSocket(null, HOST, 12345, null) } doThrow IOException() } store.checkSettings() @@ -72,7 +72,7 @@ class Pop3StoreTest { private fun createServerSettings(): ServerSettings { return ServerSettings( type = "pop3", - host = "server", + host = HOST, port = 12345, connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, authenticationType = AuthType.PLAIN, @@ -93,13 +93,14 @@ class Pop3StoreTest { } stubbing(trustedSocketFactory) { - on { createSocket(null, "server", 12345, null) } doReturn socket + on { createSocket(null, HOST, 12345, null) } doReturn socket } return outputStream } companion object { + private const val HOST = "127.0.0.1" private const val INITIAL_RESPONSE = "+OK POP3 server greeting\r\n" private const val CAPA_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" + -- GitLab From 05d0038a673a341af967bb256e86828a94e206fe Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 28 Oct 2022 16:34:57 +0200 Subject: [PATCH 100/121] Don't wrap exceptions when trying to connect to IMAP/SMTP servers --- .../com/fsck/k9/mail/store/imap/RealImapConnection.kt | 3 ++- .../fsck/k9/mail/store/imap/RealImapConnectionTest.kt | 10 ++-------- .../com/fsck/k9/mail/transport/smtp/SmtpTransport.kt | 3 ++- 3 files changed, 6 insertions(+), 10 deletions(-) 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 index 363640466d..b27bc038a9 100644 --- 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 @@ -27,6 +27,7 @@ import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.net.SocketAddress +import java.net.UnknownHostException import java.security.GeneralSecurityException import java.security.Security import java.security.cert.CertificateException @@ -154,7 +155,7 @@ internal class RealImapConnection( } } - throw MessagingException("Cannot connect to host", connectException) + throw connectException ?: UnknownHostException() } private fun connectToAddress(address: InetAddress): Socket { 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 d6a004623d..570e3f2990 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 @@ -640,18 +640,12 @@ class RealImapConnectionTest { server.verifyInteractionCompleted() } - @Test + @Test(expected = IOException::class) fun `open() with connection error should throw`() { val settings = createImapSettings(host = "127.1.2.3") val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider) - try { - imapConnection.open() - fail("Expected exception") - } catch (e: MessagingException) { - assertThat(e).hasMessageThat().isEqualTo("Cannot connect to host") - assertThat(e).hasCauseThat().isInstanceOf(IOException::class.java) - } + imapConnection.open() } @Test diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt index ecebbf811e..3c8816d650 100644 --- a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt +++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt @@ -32,6 +32,7 @@ import java.net.Inet6Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket +import java.net.UnknownHostException import java.security.GeneralSecurityException import java.util.Locale import javax.net.ssl.SSLException @@ -252,7 +253,7 @@ class SmtpTransport( } } - throw MessagingException("Cannot connect to host", connectException) + throw connectException ?: UnknownHostException() } private fun connectToAddress(address: InetAddress): Socket { -- GitLab From 1570c2389f4cfb883c483428649ed1910365ba4c Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 28 Oct 2022 16:36:59 +0200 Subject: [PATCH 101/121] SMTP: Don't treat all TLS errors as certificate error --- .../java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt index 3c8816d650..0591d664ef 100644 --- a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt +++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt @@ -34,6 +34,7 @@ import java.net.InetSocketAddress import java.net.Socket import java.net.UnknownHostException import java.security.GeneralSecurityException +import java.security.cert.CertificateException import java.util.Locale import javax.net.ssl.SSLException import org.apache.commons.io.IOUtils @@ -230,7 +231,11 @@ class SmtpTransport( throw e } catch (e: SSLException) { close() - throw CertificateValidationException(e.message, e) + if (e.cause is CertificateException) { + throw CertificateValidationException(e.message, e) + } else { + throw e + } } catch (e: GeneralSecurityException) { close() throw MessagingException("Unable to open connection to SMTP server due to security error.", e) -- GitLab From bcf89ed0c342d9ff1091143fa4a796189161a822 Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 1 Nov 2022 12:19:35 +0100 Subject: [PATCH 102/121] Version 6.311 --- app/k9mail/build.gradle | 4 ++-- app/ui/legacy/src/main/res/raw/changelog_master.xml | 9 +++++++++ fastlane/metadata/android/en-US/changelogs/33011.txt | 7 +++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/33011.txt diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 89193d0473..5dabc3d618 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -51,8 +51,8 @@ android { applicationId "com.fsck.k9" testApplicationId "com.fsck.k9.tests" - versionCode 33010 - versionName '6.311-SNAPSHOT' + versionCode 33011 + versionName '6.311' // 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/ui/legacy/src/main/res/raw/changelog_master.xml b/app/ui/legacy/src/main/res/raw/changelog_master.xml index f582ff2b64..a3de481aa5 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,15 @@ Locale-specific versions are kept in res/raw-/changelog.xml. --> + + Don't unexpectedly show and focus the "reply to" field when composing a message + Fixed a bug where sometimes toolbar buttons in the message view would affect another message than the one currently being displayed + Changed the way the app switches to the next/previous message to avoid a bug that could lead to the toolbar disappearing + Fall back to using IPv4 if connecting to a POP3 server using IPv6 fails + SMTP: Stop treating all TLS errors as certificate error + Added more (local) logging when creating and removing notifications + Fixed a couple of rare crashes + Fixed "K-9 Accounts" shortcuts (you probably have to remove existing shortcuts and add them again) Fixed a couple of bugs and display issues in the message list widget diff --git a/fastlane/metadata/android/en-US/changelogs/33011.txt b/fastlane/metadata/android/en-US/changelogs/33011.txt new file mode 100644 index 0000000000..60e4ec7928 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33011.txt @@ -0,0 +1,7 @@ +- Don't unexpectedly show and focus the "reply to" field when composing a message +- Fixed a bug where sometimes toolbar buttons in the message view would affect another message than the one currently being displayed +- Changed the way the app switches to the next/previous message to avoid a bug that could lead to the toolbar disappearing +- Fall back to using IPv4 if connecting to a POP3 server using IPv6 fails +- SMTP: Stop treating all TLS errors as certificate error +- Added more (local) logging when creating and removing notifications +- Fixed a couple of rare crashes -- GitLab From 154b526fbdb9357a758fd282226124ad3f46b76c Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 1 Nov 2022 12:34:54 +0100 Subject: [PATCH 103/121] Prepare for version 6.312 --- app/k9mail/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 5dabc3d618..2e2eef03f0 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -52,7 +52,7 @@ android { testApplicationId "com.fsck.k9.tests" versionCode 33011 - versionName '6.311' + versionName '6.312-SNAPSHOT' // 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", -- GitLab From 647340e1e8b1df647cace18b1b13b2420871afdd Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 27 Oct 2022 20:47:01 +0200 Subject: [PATCH 104/121] Swipe actions: Display action name next to the icon --- .../k9/ui/messagelist/MessageListAdapter.kt | 4 + .../k9/ui/messagelist/MessageListFragment.kt | 2 +- .../messagelist/MessageListSwipeCallback.kt | 140 +++++++++--------- .../ui/messagelist/SwipeResourceProvider.kt | 28 +++- .../src/main/res/layout/swipe_left_action.xml | 47 ++++++ .../main/res/layout/swipe_right_action.xml | 47 ++++++ .../legacy/src/main/res/values/dimensions.xml | 1 + app/ui/legacy/src/main/res/values/strings.xml | 21 +++ app/ui/legacy/src/main/res/values/themes.xml | 4 +- 9 files changed, 221 insertions(+), 73 deletions(-) create mode 100644 app/ui/legacy/src/main/res/layout/swipe_left_action.xml create mode 100644 app/ui/legacy/src/main/res/layout/swipe_right_action.xml diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt index 4072c6bd2c..3a892bc586 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt @@ -463,6 +463,10 @@ class MessageListAdapter internal constructor( item.messageUid == activeMessage.uid } + fun isSelected(item: MessageListItem): Boolean { + return item.uniqueId in selected + } + fun toggleSelection(item: MessageListItem) { if (messagesMap[item.uniqueId] == null) { // MessageListItem is no longer in the list diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index c947c03ec5..145dce4ab0 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -264,7 +264,7 @@ class MessageListFragment : val itemTouchHelper = ItemTouchHelper( MessageListSwipeCallback( - resources, + requireContext(), resourceProvider = SwipeResourceProvider(requireActivity().theme), swipeActionSupportProvider, swipeRightAction = K9.swipeRightAction, diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index 992947657d..21d0e40a89 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -1,10 +1,15 @@ package com.fsck.k9.ui.messagelist -import android.content.res.Resources +import android.annotation.SuppressLint +import android.content.Context import android.graphics.Canvas import android.graphics.Paint +import android.view.LayoutInflater import android.view.View -import androidx.core.graphics.withSave +import android.view.View.MeasureSpec +import android.widget.ImageView +import android.widget.TextView +import androidx.core.graphics.withTranslation import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder @@ -12,8 +17,9 @@ import com.fsck.k9.SwipeAction import com.fsck.k9.ui.R import kotlin.math.abs +@SuppressLint("InflateParams") class MessageListSwipeCallback( - resources: Resources, + context: Context, private val resourceProvider: SwipeResourceProvider, private val swipeActionSupportProvider: SwipeActionSupportProvider, private val swipeRightAction: SwipeAction, @@ -21,10 +27,19 @@ class MessageListSwipeCallback( private val adapter: MessageListAdapter, private val listener: MessageListSwipeListener ) : ItemTouchHelper.Callback() { - private val iconPadding = resources.getDimension(R.dimen.messageListSwipeIconPadding).toInt() - private val swipeThreshold = resources.getDimension(R.dimen.messageListSwipeThreshold) + private val swipeThreshold = context.resources.getDimension(R.dimen.messageListSwipeThreshold) private val backgroundColorPaint = Paint() + private val swipeRightLayout: View + private val swipeLeftLayout: View + + init { + val layoutInflater = LayoutInflater.from(context) + + swipeRightLayout = layoutInflater.inflate(R.layout.swipe_right_action, null, false) + swipeLeftLayout = layoutInflater.inflate(R.layout.swipe_left_action, null, false) + } + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: ViewHolder): Int { if (viewHolder !is MessageViewHolder) return 0 @@ -82,89 +97,78 @@ class MessageListSwipeCallback( actionState: Int, isCurrentlyActive: Boolean ) { - canvas.withSave { - val view = viewHolder.itemView - - val holder = viewHolder as MessageViewHolder - val item = adapter.getItemById(holder.uniqueId) ?: return@withSave - - val swipeThreshold = recyclerView.width * getSwipeThreshold(holder) - val swipeThresholdReached = abs(dX) > swipeThreshold - if (swipeThresholdReached) { - val action = if (dX > 0) swipeRightAction else swipeLeftAction - val backgroundColor = resourceProvider.getBackgroundColor(action) - drawBackground(view, backgroundColor) - } else { - val backgroundColor = resourceProvider.getBackgroundColor(SwipeAction.None) - drawBackground(view, backgroundColor) - } + val view = viewHolder.itemView + val viewWidth = view.width + val viewHeight = view.height - // Stop drawing the icon when the view has been animated all the way off the screen by ItemTouchHelper. - // We do this so the icon doesn't switch state when RecyclerView's ItemAnimator animates the view back after - // a toggle action (mark as read/unread, add/remove star) was used. - if (isCurrentlyActive || abs(dX).toInt() < view.width) { - drawIcon(dX, view, item, swipeThresholdReached) + val isViewAnimatingBack = !isCurrentlyActive && abs(dX).toInt() >= viewWidth + + canvas.withTranslation(x = view.left.toFloat(), y = view.top.toFloat()) { + if (isViewAnimatingBack) { + drawBackground(dX, viewWidth, viewHeight) + } else { + val holder = viewHolder as MessageViewHolder + drawLayout(dX, viewWidth, viewHeight, holder) } } super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) } - private fun Canvas.drawBackground(view: View, color: Int) { - backgroundColorPaint.color = color + private fun Canvas.drawBackground(dX: Float, width: Int, height: Int) { + val swipeAction = if (dX > 0) swipeRightAction else swipeLeftAction + val backgroundColor = resourceProvider.getBackgroundColor(swipeAction) + + backgroundColorPaint.color = backgroundColor drawRect( - view.left.toFloat(), - view.top.toFloat(), - view.right.toFloat(), - view.bottom.toFloat(), + 0F, + 0F, + width.toFloat(), + height.toFloat(), backgroundColorPaint ) } - private fun Canvas.drawIcon(dX: Float, view: View, item: MessageListItem, swipeThresholdReached: Boolean) { - if (dX > 0) { - drawSwipeRightIcon(view, item, swipeThresholdReached) - } else { - drawSwipeLeftIcon(view, item, swipeThresholdReached) - } - } + private fun Canvas.drawLayout(dX: Float, width: Int, height: Int, viewHolder: MessageViewHolder) { + val item = adapter.getItemById(viewHolder.uniqueId) ?: return + val isSelected = adapter.isSelected(item) - private fun Canvas.drawSwipeRightIcon(view: View, item: MessageListItem, swipeThresholdReached: Boolean) { - resourceProvider.getIcon(item, swipeRightAction)?.let { icon -> - val iconLeft = iconPadding - val iconTop = view.top + ((view.height - icon.intrinsicHeight) / 2) - val iconRight = iconLeft + icon.intrinsicWidth - val iconBottom = iconTop + icon.intrinsicHeight - icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) + val swipeRight = dX > 0 + val swipeThresholdReached = abs(dX) > swipeThreshold - val iconTint = if (swipeThresholdReached) { - resourceProvider.iconTint - } else { - resourceProvider.getBackgroundColor(swipeRightAction) - } - icon.setTint(iconTint) + val swipeLayout = if (swipeRight) swipeRightLayout else swipeLeftLayout + val swipeAction = if (swipeRight) swipeRightAction else swipeLeftAction - icon.draw(this) + val foregroundColor: Int + val backgroundColor: Int + if (swipeThresholdReached) { + foregroundColor = resourceProvider.iconTint + backgroundColor = resourceProvider.getBackgroundColor(swipeAction) + } else { + foregroundColor = resourceProvider.getBackgroundColor(swipeAction) + backgroundColor = resourceProvider.getBackgroundColor(SwipeAction.None) } - } - private fun Canvas.drawSwipeLeftIcon(view: View, item: MessageListItem, swipeThresholdReached: Boolean) { - resourceProvider.getIcon(item, swipeLeftAction)?.let { icon -> - val iconRight = view.right - iconPadding - val iconLeft = iconRight - icon.intrinsicWidth - val iconTop = view.top + ((view.height - icon.intrinsicHeight) / 2) - val iconBottom = iconTop + icon.intrinsicHeight - icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) + swipeLayout.setBackgroundColor(backgroundColor) - val iconTint = if (swipeThresholdReached) { - resourceProvider.iconTint - } else { - resourceProvider.getBackgroundColor(swipeLeftAction) - } - icon.setTint(iconTint) + val icon = resourceProvider.getIcon(item, swipeAction) + icon.setTint(foregroundColor) + + val iconView = swipeLayout.findViewById(R.id.swipe_action_icon) + iconView.setImageDrawable(icon) - icon.draw(this) + val textView = swipeLayout.findViewById(R.id.swipe_action_text) + textView.setTextColor(foregroundColor) + textView.text = resourceProvider.getActionName(item, swipeAction, isSelected) + + if (swipeLayout.isDirty) { + val widthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) + val heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + swipeLayout.measure(widthMeasureSpec, heightMeasureSpec) + swipeLayout.layout(0, 0, width, height) } + + swipeLayout.draw(this) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt index 7285c63e08..55b09c8168 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/SwipeResourceProvider.kt @@ -30,9 +30,20 @@ class SwipeResourceProvider(val theme: Theme) { private val spamColor = theme.resolveColorAttribute(R.attr.messageListSwipeSpamBackgroundColor) private val moveColor = theme.resolveColorAttribute(R.attr.messageListSwipeMoveBackgroundColor) - fun getIcon(item: MessageListItem, action: SwipeAction): Drawable? { + private val selectText = theme.resources.getString(R.string.swipe_action_select) + private val deselectText = theme.resources.getString(R.string.swipe_action_deselect) + private val markAsReadText = theme.resources.getString(R.string.swipe_action_mark_as_read) + private val markAsUnreadText = theme.resources.getString(R.string.swipe_action_mark_as_unread) + private val addStarText = theme.resources.getString(R.string.swipe_action_add_star) + private val removeStarText = theme.resources.getString(R.string.swipe_action_remove_star) + private val archiveText = theme.resources.getString(R.string.swipe_action_archive) + private val deleteText = theme.resources.getString(R.string.swipe_action_delete) + private val spamText = theme.resources.getString(R.string.swipe_action_spam) + private val moveText = theme.resources.getString(R.string.swipe_action_move) + + fun getIcon(item: MessageListItem, action: SwipeAction): Drawable { return when (action) { - SwipeAction.None -> null + SwipeAction.None -> error("action == SwipeAction.None") SwipeAction.ToggleSelection -> selectIcon SwipeAction.ToggleRead -> if (item.isRead) markAsUnreadIcon else markAsReadIcon SwipeAction.ToggleStar -> if (item.isStarred) removeStarIcon else addStarIcon @@ -55,6 +66,19 @@ class SwipeResourceProvider(val theme: Theme) { SwipeAction.Move -> moveColor } } + + fun getActionName(item: MessageListItem, action: SwipeAction, isSelected: Boolean): String { + return when (action) { + SwipeAction.None -> error("action == SwipeAction.None") + SwipeAction.ToggleSelection -> if (isSelected) deselectText else selectText + SwipeAction.ToggleRead -> if (item.isRead) markAsUnreadText else markAsReadText + SwipeAction.ToggleStar -> if (item.isStarred) removeStarText else addStarText + SwipeAction.Archive -> archiveText + SwipeAction.Delete -> deleteText + SwipeAction.Spam -> spamText + SwipeAction.Move -> moveText + } + } } private fun Theme.loadDrawable(@AttrRes attributeId: Int): Drawable { diff --git a/app/ui/legacy/src/main/res/layout/swipe_left_action.xml b/app/ui/legacy/src/main/res/layout/swipe_left_action.xml new file mode 100644 index 0000000000..26c786abf1 --- /dev/null +++ b/app/ui/legacy/src/main/res/layout/swipe_left_action.xml @@ -0,0 +1,47 @@ + + + + + + + + diff --git a/app/ui/legacy/src/main/res/layout/swipe_right_action.xml b/app/ui/legacy/src/main/res/layout/swipe_right_action.xml new file mode 100644 index 0000000000..d03da7aee0 --- /dev/null +++ b/app/ui/legacy/src/main/res/layout/swipe_right_action.xml @@ -0,0 +1,47 @@ + + + + + + + + diff --git a/app/ui/legacy/src/main/res/values/dimensions.xml b/app/ui/legacy/src/main/res/values/dimensions.xml index c46128602b..e47b20f376 100644 --- a/app/ui/legacy/src/main/res/values/dimensions.xml +++ b/app/ui/legacy/src/main/res/values/dimensions.xml @@ -11,4 +11,5 @@ 16dp 72dp 24dp + 12dp diff --git a/app/ui/legacy/src/main/res/values/strings.xml b/app/ui/legacy/src/main/res/values/strings.xml index ecc655b2f8..fbb6ce4f96 100644 --- a/app/ui/legacy/src/main/res/values/strings.xml +++ b/app/ui/legacy/src/main/res/values/strings.xml @@ -1279,4 +1279,25 @@ You can keep this message and use it as a backup for your secret key. If you wan Configure notification If you don\'t need instant notifications about new messages, you should disable Push and use Polling. Polling checks for new mail at regular intervals and does not need the notification. Disable Push + + + Select + + Deselect + + Mark read + + Mark unread + + Add star + + Remove star + + Archive + + Delete + + Spam + + Move… diff --git a/app/ui/legacy/src/main/res/values/themes.xml b/app/ui/legacy/src/main/res/values/themes.xml index 494687675c..e5356dbb32 100644 --- a/app/ui/legacy/src/main/res/values/themes.xml +++ b/app/ui/legacy/src/main/res/values/themes.xml @@ -94,7 +94,7 @@ #ffffff @color/material_gray_200 - @drawable/ic_import_status + @drawable/ic_check_circle @color/material_blue_600 ?attr/iconActionMarkAsRead ?attr/iconActionMarkAsUnread @@ -238,7 +238,7 @@ #ffffff @color/material_gray_900 - @drawable/ic_import_status + @drawable/ic_check_circle @color/material_blue_700 ?attr/iconActionMarkAsRead ?attr/iconActionMarkAsUnread -- GitLab From 73d9100087d31e6b8fdffad696df284290eb8e1b Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 7 Nov 2022 16:36:47 +0100 Subject: [PATCH 105/121] Make `AccountManager.getAccountsFlow()` only return fully set up accounts --- app/core/src/main/java/com/fsck/k9/Preferences.kt | 7 +++++-- .../java/com/fsck/k9/ui/settings/SettingsListFragment.kt | 5 ++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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 7e66d18f85..84517c4cf0 100644 --- a/app/core/src/main/java/com/fsck/k9/Preferences.kt +++ b/app/core/src/main/java/com/fsck/k9/Preferences.kt @@ -110,6 +110,9 @@ class Preferences internal constructor( } } + private val completeAccounts: List + get() = accounts.filter { it.isFinishedSetup } + override fun getAccount(accountUuid: String): Account? { synchronized(accountLock) { if (accountsMap == null) { @@ -151,10 +154,10 @@ class Preferences internal constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun getAccountsFlow(): Flow> { return callbackFlow { - send(accounts) + send(completeAccounts) val listener = AccountsChangeListener { - trySendBlocking(accounts) + trySendBlocking(completeAccounts) } addOnAccountsChangeListener(listener) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsListFragment.kt index b1aca73160..121883d90f 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsListFragment.kt @@ -77,11 +77,10 @@ class SettingsListFragment : Fragment(), ItemTouchCallback { private fun populateSettingsList() { viewModel.accounts.observeNotNull(this) { accounts -> - val accountsFinishedSetup = accounts.filter { it.isFinishedSetup } - if (accountsFinishedSetup.isEmpty()) { + if (accounts.isEmpty()) { launchOnboarding() } else { - populateSettingsList(accountsFinishedSetup) + populateSettingsList(accounts) } } } -- GitLab From 04d97b4e2e9c47f8f928a36d985bd85d035349a5 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 7 Nov 2022 17:03:49 +0100 Subject: [PATCH 106/121] Don't crash when trying to remove certificates for incomplete accounts --- .../main/java/com/fsck/k9/account/AccountRemover.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/account/AccountRemover.kt b/app/ui/legacy/src/main/java/com/fsck/k9/account/AccountRemover.kt index 483cba3dfc..7d8ab45131 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/account/AccountRemover.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/account/AccountRemover.kt @@ -36,7 +36,7 @@ class AccountRemover( preferences.deleteAccount(account) - localKeyStoreManager.deleteCertificates(account) + removeCertificates(account) Core.setServicesEnabled() Timber.v("Finished removing account '%s'.", accountName) @@ -62,4 +62,12 @@ class AccountRemover( Timber.e(e, "Failed to reset remote store for account %s", account) } } + + private fun removeCertificates(account: Account) { + try { + localKeyStoreManager.deleteCertificates(account) + } catch (e: Exception) { + Timber.e(e, "Failed to remove certificates for account %s", account) + } + } } -- GitLab From d63eda9b071ae72976347f980569beae5531b968 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 3 Nov 2022 16:40:43 +0100 Subject: [PATCH 107/121] Import a copy of `ItemTouchHelper` Based on RecyclerView 1.2.1 --- settings.gradle | 1 + ui-utils/ItemTouchHelper/build.gradle | 27 + .../itemtouchhelper/ItemTouchHelper.java | 2487 +++++++++++++++++ .../itemtouchhelper/ItemTouchUIUtilImpl.java | 93 + 4 files changed, 2608 insertions(+) create mode 100644 ui-utils/ItemTouchHelper/build.gradle create mode 100644 ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java create mode 100644 ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchUIUtilImpl.java diff --git a/settings.gradle b/settings.gradle index c6d5c75343..2971d3a866 100644 --- a/settings.gradle +++ b/settings.gradle @@ -13,6 +13,7 @@ include ':app:autodiscovery:srvrecords' include ':app:autodiscovery:thunderbird' include ':app:html-cleaner' include ':ui-utils:LinearLayoutManager' +include ':ui-utils:ItemTouchHelper' include ':mail:common' include ':mail:testing' include ':mail:protocols:imap' diff --git a/ui-utils/ItemTouchHelper/build.gradle b/ui-utils/ItemTouchHelper/build.gradle new file mode 100644 index 0000000000..922deebf98 --- /dev/null +++ b/ui-utils/ItemTouchHelper/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'com.android.library' + +dependencies { + api "androidx.recyclerview:recyclerview:${versions.androidxRecyclerView}" +} + +android { + namespace 'app.k9mail.ui.utils.itemtouchhelper' + + compileSdkVersion buildConfig.compileSdk + buildToolsVersion buildConfig.buildTools + + defaultConfig { + minSdkVersion buildConfig.minSdk + targetSdkVersion buildConfig.robolectricSdk + } + + lintOptions { + abortOnError false + lintConfig file("$rootProject.projectDir/config/lint/lint.xml") + } + + compileOptions { + sourceCompatibility javaVersion + targetCompatibility javaVersion + } +} diff --git a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java new file mode 100644 index 0000000000..6c4f005c89 --- /dev/null +++ b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java @@ -0,0 +1,2487 @@ +/* + * Copyright 2018 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 app.k9mail.ui.utils.itemtouchhelper; + +import android.animation.Animator; +import android.animation.ValueAnimator; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.os.Build; +import android.util.Log; +import android.view.GestureDetector; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewParent; +import android.view.animation.Interpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.view.GestureDetectorCompat; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.R; +import androidx.recyclerview.widget.ItemTouchUIUtil; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +import java.util.ArrayList; +import java.util.List; + +/** + * This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView. + *

+ * It works with a RecyclerView and a Callback class, which configures what type of interactions + * are enabled and also receives events when user performs these actions. + *

+ * Depending on which functionality you support, you should override + * {@link Callback#onMove(RecyclerView, ViewHolder, ViewHolder)} and / or + * {@link Callback#onSwiped(ViewHolder, int)}. + *

+ * This class is designed to work with any LayoutManager but for certain situations, it can be + * optimized for your custom LayoutManager by extending methods in the + * {@link ItemTouchHelper.Callback} class or implementing {@link ItemTouchHelper.ViewDropHandler} + * interface in your LayoutManager. + *

+ * By default, ItemTouchHelper moves the items' translateX/Y properties to reposition them. You can + * customize these behaviors by overriding {@link Callback#onChildDraw(Canvas, RecyclerView, + * ViewHolder, float, float, int, boolean)} + * or {@link Callback#onChildDrawOver(Canvas, RecyclerView, ViewHolder, float, float, int, + * boolean)}. + *

+ * Most of the time you only need to override onChildDraw. + */ +public class ItemTouchHelper extends RecyclerView.ItemDecoration + implements RecyclerView.OnChildAttachStateChangeListener { + + /** + * Up direction, used for swipe & drag control. + */ + public static final int UP = 1; + + /** + * Down direction, used for swipe & drag control. + */ + public static final int DOWN = 1 << 1; + + /** + * Left direction, used for swipe & drag control. + */ + public static final int LEFT = 1 << 2; + + /** + * Right direction, used for swipe & drag control. + */ + public static final int RIGHT = 1 << 3; + + // If you change these relative direction values, update Callback#convertToAbsoluteDirection, + // Callback#convertToRelativeDirection. + /** + * Horizontal start direction. Resolved to LEFT or RIGHT depending on RecyclerView's layout + * direction. Used for swipe & drag control. + */ + public static final int START = LEFT << 2; + + /** + * Horizontal end direction. Resolved to LEFT or RIGHT depending on RecyclerView's layout + * direction. Used for swipe & drag control. + */ + public static final int END = RIGHT << 2; + + /** + * ItemTouchHelper is in idle state. At this state, either there is no related motion event by + * the user or latest motion events have not yet triggered a swipe or drag. + */ + public static final int ACTION_STATE_IDLE = 0; + + /** + * A View is currently being swiped. + */ + @SuppressWarnings("WeakerAccess") + public static final int ACTION_STATE_SWIPE = 1; + + /** + * A View is currently being dragged. + */ + @SuppressWarnings("WeakerAccess") + public static final int ACTION_STATE_DRAG = 2; + + /** + * Animation type for views which are swiped successfully. + */ + @SuppressWarnings("WeakerAccess") + public static final int ANIMATION_TYPE_SWIPE_SUCCESS = 1 << 1; + + /** + * Animation type for views which are not completely swiped thus will animate back to their + * original position. + */ + @SuppressWarnings("WeakerAccess") + public static final int ANIMATION_TYPE_SWIPE_CANCEL = 1 << 2; + + /** + * Animation type for views that were dragged and now will animate to their final position. + */ + @SuppressWarnings("WeakerAccess") + public static final int ANIMATION_TYPE_DRAG = 1 << 3; + + private static final String TAG = "ItemTouchHelper"; + + private static final boolean DEBUG = false; + + private static final int ACTIVE_POINTER_ID_NONE = -1; + + static final int DIRECTION_FLAG_COUNT = 8; + + private static final int ACTION_MODE_IDLE_MASK = (1 << DIRECTION_FLAG_COUNT) - 1; + + static final int ACTION_MODE_SWIPE_MASK = ACTION_MODE_IDLE_MASK << DIRECTION_FLAG_COUNT; + + static final int ACTION_MODE_DRAG_MASK = ACTION_MODE_SWIPE_MASK << DIRECTION_FLAG_COUNT; + + /** + * The unit we are using to track velocity + */ + private static final int PIXELS_PER_SECOND = 1000; + + /** + * Views, whose state should be cleared after they are detached from RecyclerView. + * This is necessary after swipe dismissing an item. We wait until animator finishes its job + * to clean these views. + */ + final List mPendingCleanup = new ArrayList<>(); + + /** + * Re-use array to calculate dx dy for a ViewHolder + */ + private final float[] mTmpPosition = new float[2]; + + /** + * Currently selected view holder + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + ViewHolder mSelected = null; + + /** + * The reference coordinates for the action start. For drag & drop, this is the time long + * press is completed vs for swipe, this is the initial touch point. + */ + float mInitialTouchX; + + float mInitialTouchY; + + /** + * Set when ItemTouchHelper is assigned to a RecyclerView. + */ + private float mSwipeEscapeVelocity; + + /** + * Set when ItemTouchHelper is assigned to a RecyclerView. + */ + private float mMaxSwipeVelocity; + + /** + * The diff between the last event and initial touch. + */ + float mDx; + + float mDy; + + /** + * The coordinates of the selected view at the time it is selected. We record these values + * when action starts so that we can consistently position it even if LayoutManager moves the + * View. + */ + private float mSelectedStartX; + + private float mSelectedStartY; + + /** + * The pointer we are tracking. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + int mActivePointerId = ACTIVE_POINTER_ID_NONE; + + /** + * Developer callback which controls the behavior of ItemTouchHelper. + */ + @NonNull + Callback mCallback; + + /** + * Current mode. + */ + private int mActionState = ACTION_STATE_IDLE; + + /** + * The direction flags obtained from unmasking + * {@link Callback#getAbsoluteMovementFlags(RecyclerView, ViewHolder)} for the current + * action state. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + int mSelectedFlags; + + /** + * When a View is dragged or swiped and needs to go back to where it was, we create a Recover + * Animation and animate it to its location using this custom Animator, instead of using + * framework Animators. + * Using framework animators has the side effect of clashing with ItemAnimator, creating + * jumpy UIs. + */ + @VisibleForTesting + List mRecoverAnimations = new ArrayList<>(); + + private int mSlop; + + RecyclerView mRecyclerView; + + /** + * When user drags a view to the edge, we start scrolling the LayoutManager as long as View + * is partially out of bounds. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final Runnable mScrollRunnable = new Runnable() { + @Override + public void run() { + if (mSelected != null && scrollIfNecessary()) { + if (mSelected != null) { //it might be lost during scrolling + moveIfNecessary(mSelected); + } + mRecyclerView.removeCallbacks(mScrollRunnable); + ViewCompat.postOnAnimation(mRecyclerView, this); + } + } + }; + + /** + * Used for detecting fling swipe + */ + VelocityTracker mVelocityTracker; + + //re-used list for selecting a swap target + private List mSwapTargets; + + //re used for for sorting swap targets + private List mDistances; + + /** + * If drag & drop is supported, we use child drawing order to bring them to front. + */ + private RecyclerView.ChildDrawingOrderCallback mChildDrawingOrderCallback = null; + + /** + * This keeps a reference to the child dragged by the user. Even after user stops dragging, + * until view reaches its final position (end of recover animation), we keep a reference so + * that it can be drawn above other children. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + View mOverdrawChild = null; + + /** + * We cache the position of the overdraw child to avoid recalculating it each time child + * position callback is called. This value is invalidated whenever a child is attached or + * detached. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + int mOverdrawChildPosition = -1; + + /** + * Used to detect long press. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + GestureDetectorCompat mGestureDetector; + + /** + * Callback for when long press occurs. + */ + private ItemTouchHelperGestureListener mItemTouchHelperGestureListener; + + private final OnItemTouchListener mOnItemTouchListener = new OnItemTouchListener() { + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, + @NonNull MotionEvent event) { + mGestureDetector.onTouchEvent(event); + if (DEBUG) { + Log.d(TAG, "intercept: x:" + event.getX() + ",y:" + event.getY() + ", " + event); + } + final int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + mActivePointerId = event.getPointerId(0); + mInitialTouchX = event.getX(); + mInitialTouchY = event.getY(); + obtainVelocityTracker(); + if (mSelected == null) { + final RecoverAnimation animation = findAnimation(event); + if (animation != null) { + mInitialTouchX -= animation.mX; + mInitialTouchY -= animation.mY; + endRecoverAnimation(animation.mViewHolder, true); + if (mPendingCleanup.remove(animation.mViewHolder.itemView)) { + mCallback.clearView(mRecyclerView, animation.mViewHolder); + } + select(animation.mViewHolder, animation.mActionState); + updateDxDy(event, mSelectedFlags, 0); + } + } + } else if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { + mActivePointerId = ACTIVE_POINTER_ID_NONE; + select(null, ACTION_STATE_IDLE); + } else if (mActivePointerId != ACTIVE_POINTER_ID_NONE) { + // in a non scroll orientation, if distance change is above threshold, we + // can select the item + final int index = event.findPointerIndex(mActivePointerId); + if (DEBUG) { + Log.d(TAG, "pointer index " + index); + } + if (index >= 0) { + checkSelectForSwipe(action, event, index); + } + } + if (mVelocityTracker != null) { + mVelocityTracker.addMovement(event); + } + return mSelected != null; + } + + @Override + public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent event) { + mGestureDetector.onTouchEvent(event); + if (DEBUG) { + Log.d(TAG, + "on touch: x:" + mInitialTouchX + ",y:" + mInitialTouchY + ", :" + event); + } + if (mVelocityTracker != null) { + mVelocityTracker.addMovement(event); + } + if (mActivePointerId == ACTIVE_POINTER_ID_NONE) { + return; + } + final int action = event.getActionMasked(); + final int activePointerIndex = event.findPointerIndex(mActivePointerId); + if (activePointerIndex >= 0) { + checkSelectForSwipe(action, event, activePointerIndex); + } + ViewHolder viewHolder = mSelected; + if (viewHolder == null) { + return; + } + switch (action) { + case MotionEvent.ACTION_MOVE: { + // Find the index of the active pointer and fetch its position + if (activePointerIndex >= 0) { + updateDxDy(event, mSelectedFlags, activePointerIndex); + moveIfNecessary(viewHolder); + mRecyclerView.removeCallbacks(mScrollRunnable); + mScrollRunnable.run(); + mRecyclerView.invalidate(); + } + break; + } + case MotionEvent.ACTION_CANCEL: + if (mVelocityTracker != null) { + mVelocityTracker.clear(); + } + // fall through + case MotionEvent.ACTION_UP: + select(null, ACTION_STATE_IDLE); + mActivePointerId = ACTIVE_POINTER_ID_NONE; + break; + case MotionEvent.ACTION_POINTER_UP: { + final int pointerIndex = event.getActionIndex(); + final int pointerId = event.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mActivePointerId = event.getPointerId(newPointerIndex); + updateDxDy(event, mSelectedFlags, pointerIndex); + } + break; + } + } + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + if (!disallowIntercept) { + return; + } + select(null, ACTION_STATE_IDLE); + } + }; + + /** + * Temporary rect instance that is used when we need to lookup Item decorations. + */ + private Rect mTmpRect; + + /** + * When user started to drag scroll. Reset when we don't scroll + */ + private long mDragScrollStartTimeInMs; + + /** + * Creates an ItemTouchHelper that will work with the given Callback. + *

+ * You can attach ItemTouchHelper to a RecyclerView via + * {@link #attachToRecyclerView(RecyclerView)}. Upon attaching, it will add an item decoration, + * an onItemTouchListener and a Child attach / detach listener to the RecyclerView. + * + * @param callback The Callback which controls the behavior of this touch helper. + */ + public ItemTouchHelper(@NonNull Callback callback) { + mCallback = callback; + } + + private static boolean hitTest(View child, float x, float y, float left, float top) { + return x >= left + && x <= left + child.getWidth() + && y >= top + && y <= top + child.getHeight(); + } + + /** + * Attaches the ItemTouchHelper to the provided RecyclerView. If TouchHelper is already + * attached to a RecyclerView, it will first detach from the previous one. You can call this + * method with {@code null} to detach it from the current RecyclerView. + * + * @param recyclerView The RecyclerView instance to which you want to add this helper or + * {@code null} if you want to remove ItemTouchHelper from the current + * RecyclerView. + */ + public void attachToRecyclerView(@Nullable RecyclerView recyclerView) { + if (mRecyclerView == recyclerView) { + return; // nothing to do + } + if (mRecyclerView != null) { + destroyCallbacks(); + } + mRecyclerView = recyclerView; + if (recyclerView != null) { + final Resources resources = recyclerView.getResources(); + mSwipeEscapeVelocity = resources + .getDimension(R.dimen.item_touch_helper_swipe_escape_velocity); + mMaxSwipeVelocity = resources + .getDimension(R.dimen.item_touch_helper_swipe_escape_max_velocity); + setupCallbacks(); + } + } + + private void setupCallbacks() { + ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext()); + mSlop = vc.getScaledTouchSlop(); + mRecyclerView.addItemDecoration(this); + mRecyclerView.addOnItemTouchListener(mOnItemTouchListener); + mRecyclerView.addOnChildAttachStateChangeListener(this); + startGestureDetection(); + } + + private void destroyCallbacks() { + mRecyclerView.removeItemDecoration(this); + mRecyclerView.removeOnItemTouchListener(mOnItemTouchListener); + mRecyclerView.removeOnChildAttachStateChangeListener(this); + // clean all attached + final int recoverAnimSize = mRecoverAnimations.size(); + for (int i = recoverAnimSize - 1; i >= 0; i--) { + final RecoverAnimation recoverAnimation = mRecoverAnimations.get(0); + recoverAnimation.cancel(); + mCallback.clearView(mRecyclerView, recoverAnimation.mViewHolder); + } + mRecoverAnimations.clear(); + mOverdrawChild = null; + mOverdrawChildPosition = -1; + releaseVelocityTracker(); + stopGestureDetection(); + } + + private void startGestureDetection() { + mItemTouchHelperGestureListener = new ItemTouchHelperGestureListener(); + mGestureDetector = new GestureDetectorCompat(mRecyclerView.getContext(), + mItemTouchHelperGestureListener); + } + + private void stopGestureDetection() { + if (mItemTouchHelperGestureListener != null) { + mItemTouchHelperGestureListener.doNotReactToLongPress(); + mItemTouchHelperGestureListener = null; + } + if (mGestureDetector != null) { + mGestureDetector = null; + } + } + + private void getSelectedDxDy(float[] outPosition) { + if ((mSelectedFlags & (LEFT | RIGHT)) != 0) { + outPosition[0] = mSelectedStartX + mDx - mSelected.itemView.getLeft(); + } else { + outPosition[0] = mSelected.itemView.getTranslationX(); + } + if ((mSelectedFlags & (UP | DOWN)) != 0) { + outPosition[1] = mSelectedStartY + mDy - mSelected.itemView.getTop(); + } else { + outPosition[1] = mSelected.itemView.getTranslationY(); + } + } + + @Override + public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { + float dx = 0, dy = 0; + if (mSelected != null) { + getSelectedDxDy(mTmpPosition); + dx = mTmpPosition[0]; + dy = mTmpPosition[1]; + } + mCallback.onDrawOver(c, parent, mSelected, + mRecoverAnimations, mActionState, dx, dy); + } + + @Override + public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { + // we don't know if RV changed something so we should invalidate this index. + mOverdrawChildPosition = -1; + float dx = 0, dy = 0; + if (mSelected != null) { + getSelectedDxDy(mTmpPosition); + dx = mTmpPosition[0]; + dy = mTmpPosition[1]; + } + mCallback.onDraw(c, parent, mSelected, + mRecoverAnimations, mActionState, dx, dy); + } + + /** + * Starts dragging or swiping the given View. Call with null if you want to clear it. + * + * @param selected The ViewHolder to drag or swipe. Can be null if you want to cancel the + * current action, but may not be null if actionState is ACTION_STATE_DRAG. + * @param actionState The type of action + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void select(@Nullable ViewHolder selected, int actionState) { + if (selected == mSelected && actionState == mActionState) { + return; + } + mDragScrollStartTimeInMs = Long.MIN_VALUE; + final int prevActionState = mActionState; + // prevent duplicate animations + endRecoverAnimation(selected, true); + mActionState = actionState; + if (actionState == ACTION_STATE_DRAG) { + if (selected == null) { + throw new IllegalArgumentException("Must pass a ViewHolder when dragging"); + } + + // we remove after animation is complete. this means we only elevate the last drag + // child but that should perform good enough as it is very hard to start dragging a + // new child before the previous one settles. + mOverdrawChild = selected.itemView; + addChildDrawingOrderCallback(); + } + int actionStateMask = (1 << (DIRECTION_FLAG_COUNT + DIRECTION_FLAG_COUNT * actionState)) + - 1; + boolean preventLayout = false; + + if (mSelected != null) { + final ViewHolder prevSelected = mSelected; + if (prevSelected.itemView.getParent() != null) { + final int swipeDir = prevActionState == ACTION_STATE_DRAG ? 0 + : swipeIfNecessary(prevSelected); + releaseVelocityTracker(); + // find where we should animate to + final float targetTranslateX, targetTranslateY; + int animationType; + switch (swipeDir) { + case LEFT: + case RIGHT: + case START: + case END: + targetTranslateY = 0; + targetTranslateX = Math.signum(mDx) * mRecyclerView.getWidth(); + break; + case UP: + case DOWN: + targetTranslateX = 0; + targetTranslateY = Math.signum(mDy) * mRecyclerView.getHeight(); + break; + default: + targetTranslateX = 0; + targetTranslateY = 0; + } + if (prevActionState == ACTION_STATE_DRAG) { + animationType = ANIMATION_TYPE_DRAG; + } else if (swipeDir > 0) { + animationType = ANIMATION_TYPE_SWIPE_SUCCESS; + } else { + animationType = ANIMATION_TYPE_SWIPE_CANCEL; + } + getSelectedDxDy(mTmpPosition); + final float currentTranslateX = mTmpPosition[0]; + final float currentTranslateY = mTmpPosition[1]; + final RecoverAnimation rv = new RecoverAnimation(prevSelected, animationType, + prevActionState, currentTranslateX, currentTranslateY, + targetTranslateX, targetTranslateY) { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + if (this.mOverridden) { + return; + } + if (swipeDir <= 0) { + // this is a drag or failed swipe. recover immediately + mCallback.clearView(mRecyclerView, prevSelected); + // full cleanup will happen on onDrawOver + } else { + // wait until remove animation is complete. + mPendingCleanup.add(prevSelected.itemView); + mIsPendingCleanup = true; + if (swipeDir > 0) { + // Animation might be ended by other animators during a layout. + // We defer callback to avoid editing adapter during a layout. + postDispatchSwipe(this, swipeDir); + } + } + // removed from the list after it is drawn for the last time + if (mOverdrawChild == prevSelected.itemView) { + removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView); + } + } + }; + final long duration = mCallback.getAnimationDuration(mRecyclerView, animationType, + targetTranslateX - currentTranslateX, targetTranslateY - currentTranslateY); + rv.setDuration(duration); + mRecoverAnimations.add(rv); + rv.start(); + preventLayout = true; + } else { + removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView); + mCallback.clearView(mRecyclerView, prevSelected); + } + mSelected = null; + } + if (selected != null) { + mSelectedFlags = + (mCallback.getAbsoluteMovementFlags(mRecyclerView, selected) & actionStateMask) + >> (mActionState * DIRECTION_FLAG_COUNT); + mSelectedStartX = selected.itemView.getLeft(); + mSelectedStartY = selected.itemView.getTop(); + mSelected = selected; + + if (actionState == ACTION_STATE_DRAG) { + mSelected.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + } + } + final ViewParent rvParent = mRecyclerView.getParent(); + if (rvParent != null) { + rvParent.requestDisallowInterceptTouchEvent(mSelected != null); + } + if (!preventLayout) { + mRecyclerView.getLayoutManager().requestSimpleAnimationsInNextLayout(); + } + mCallback.onSelectedChanged(mSelected, mActionState); + mRecyclerView.invalidate(); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void postDispatchSwipe(final RecoverAnimation anim, final int swipeDir) { + // wait until animations are complete. + mRecyclerView.post(new Runnable() { + @Override + public void run() { + if (mRecyclerView != null && mRecyclerView.isAttachedToWindow() + && !anim.mOverridden + && anim.mViewHolder.getAbsoluteAdapterPosition() + != RecyclerView.NO_POSITION) { + final RecyclerView.ItemAnimator animator = mRecyclerView.getItemAnimator(); + // if animator is running or we have other active recover animations, we try + // not to call onSwiped because DefaultItemAnimator is not good at merging + // animations. Instead, we wait and batch. + if ((animator == null || !animator.isRunning(null)) + && !hasRunningRecoverAnim()) { + mCallback.onSwiped(anim.mViewHolder, swipeDir); + } else { + mRecyclerView.post(this); + } + } + } + }); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + boolean hasRunningRecoverAnim() { + final int size = mRecoverAnimations.size(); + for (int i = 0; i < size; i++) { + if (!mRecoverAnimations.get(i).mEnded) { + return true; + } + } + return false; + } + + /** + * If user drags the view to the edge, trigger a scroll if necessary. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + boolean scrollIfNecessary() { + if (mSelected == null) { + mDragScrollStartTimeInMs = Long.MIN_VALUE; + return false; + } + final long now = System.currentTimeMillis(); + final long scrollDuration = mDragScrollStartTimeInMs + == Long.MIN_VALUE ? 0 : now - mDragScrollStartTimeInMs; + RecyclerView.LayoutManager lm = mRecyclerView.getLayoutManager(); + if (mTmpRect == null) { + mTmpRect = new Rect(); + } + int scrollX = 0; + int scrollY = 0; + lm.calculateItemDecorationsForChild(mSelected.itemView, mTmpRect); + if (lm.canScrollHorizontally()) { + int curX = (int) (mSelectedStartX + mDx); + final int leftDiff = curX - mTmpRect.left - mRecyclerView.getPaddingLeft(); + if (mDx < 0 && leftDiff < 0) { + scrollX = leftDiff; + } else if (mDx > 0) { + final int rightDiff = + curX + mSelected.itemView.getWidth() + mTmpRect.right + - (mRecyclerView.getWidth() - mRecyclerView.getPaddingRight()); + if (rightDiff > 0) { + scrollX = rightDiff; + } + } + } + if (lm.canScrollVertically()) { + int curY = (int) (mSelectedStartY + mDy); + final int topDiff = curY - mTmpRect.top - mRecyclerView.getPaddingTop(); + if (mDy < 0 && topDiff < 0) { + scrollY = topDiff; + } else if (mDy > 0) { + final int bottomDiff = curY + mSelected.itemView.getHeight() + mTmpRect.bottom + - (mRecyclerView.getHeight() - mRecyclerView.getPaddingBottom()); + if (bottomDiff > 0) { + scrollY = bottomDiff; + } + } + } + if (scrollX != 0) { + scrollX = mCallback.interpolateOutOfBoundsScroll(mRecyclerView, + mSelected.itemView.getWidth(), scrollX, + mRecyclerView.getWidth(), scrollDuration); + } + if (scrollY != 0) { + scrollY = mCallback.interpolateOutOfBoundsScroll(mRecyclerView, + mSelected.itemView.getHeight(), scrollY, + mRecyclerView.getHeight(), scrollDuration); + } + if (scrollX != 0 || scrollY != 0) { + if (mDragScrollStartTimeInMs == Long.MIN_VALUE) { + mDragScrollStartTimeInMs = now; + } + mRecyclerView.scrollBy(scrollX, scrollY); + return true; + } + mDragScrollStartTimeInMs = Long.MIN_VALUE; + return false; + } + + private List findSwapTargets(ViewHolder viewHolder) { + if (mSwapTargets == null) { + mSwapTargets = new ArrayList<>(); + mDistances = new ArrayList<>(); + } else { + mSwapTargets.clear(); + mDistances.clear(); + } + final int margin = mCallback.getBoundingBoxMargin(); + final int left = Math.round(mSelectedStartX + mDx) - margin; + final int top = Math.round(mSelectedStartY + mDy) - margin; + final int right = left + viewHolder.itemView.getWidth() + 2 * margin; + final int bottom = top + viewHolder.itemView.getHeight() + 2 * margin; + final int centerX = (left + right) / 2; + final int centerY = (top + bottom) / 2; + final RecyclerView.LayoutManager lm = mRecyclerView.getLayoutManager(); + final int childCount = lm.getChildCount(); + for (int i = 0; i < childCount; i++) { + View other = lm.getChildAt(i); + if (other == viewHolder.itemView) { + continue; //myself! + } + if (other.getBottom() < top || other.getTop() > bottom + || other.getRight() < left || other.getLeft() > right) { + continue; + } + final ViewHolder otherVh = mRecyclerView.getChildViewHolder(other); + if (mCallback.canDropOver(mRecyclerView, mSelected, otherVh)) { + // find the index to add + final int dx = Math.abs(centerX - (other.getLeft() + other.getRight()) / 2); + final int dy = Math.abs(centerY - (other.getTop() + other.getBottom()) / 2); + final int dist = dx * dx + dy * dy; + + int pos = 0; + final int cnt = mSwapTargets.size(); + for (int j = 0; j < cnt; j++) { + if (dist > mDistances.get(j)) { + pos++; + } else { + break; + } + } + mSwapTargets.add(pos, otherVh); + mDistances.add(pos, dist); + } + } + return mSwapTargets; + } + + /** + * Checks if we should swap w/ another view holder. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void moveIfNecessary(ViewHolder viewHolder) { + if (mRecyclerView.isLayoutRequested()) { + return; + } + if (mActionState != ACTION_STATE_DRAG) { + return; + } + + final float threshold = mCallback.getMoveThreshold(viewHolder); + final int x = (int) (mSelectedStartX + mDx); + final int y = (int) (mSelectedStartY + mDy); + if (Math.abs(y - viewHolder.itemView.getTop()) < viewHolder.itemView.getHeight() * threshold + && Math.abs(x - viewHolder.itemView.getLeft()) + < viewHolder.itemView.getWidth() * threshold) { + return; + } + List swapTargets = findSwapTargets(viewHolder); + if (swapTargets.size() == 0) { + return; + } + // may swap. + ViewHolder target = mCallback.chooseDropTarget(viewHolder, swapTargets, x, y); + if (target == null) { + mSwapTargets.clear(); + mDistances.clear(); + return; + } + final int toPosition = target.getAbsoluteAdapterPosition(); + final int fromPosition = viewHolder.getAbsoluteAdapterPosition(); + if (mCallback.onMove(mRecyclerView, viewHolder, target)) { + // keep target visible + mCallback.onMoved(mRecyclerView, viewHolder, fromPosition, + target, toPosition, x, y); + } + } + + @Override + public void onChildViewAttachedToWindow(@NonNull View view) { + } + + @Override + public void onChildViewDetachedFromWindow(@NonNull View view) { + removeChildDrawingOrderCallbackIfNecessary(view); + final ViewHolder holder = mRecyclerView.getChildViewHolder(view); + if (holder == null) { + return; + } + if (mSelected != null && holder == mSelected) { + select(null, ACTION_STATE_IDLE); + } else { + endRecoverAnimation(holder, false); // this may push it into pending cleanup list. + if (mPendingCleanup.remove(holder.itemView)) { + mCallback.clearView(mRecyclerView, holder); + } + } + } + + /** + * Returns the animation type or 0 if cannot be found. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void endRecoverAnimation(ViewHolder viewHolder, boolean override) { + final int recoverAnimSize = mRecoverAnimations.size(); + for (int i = recoverAnimSize - 1; i >= 0; i--) { + final RecoverAnimation anim = mRecoverAnimations.get(i); + if (anim.mViewHolder == viewHolder) { + anim.mOverridden |= override; + if (!anim.mEnded) { + anim.cancel(); + } + mRecoverAnimations.remove(i); + return; + } + } + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, + RecyclerView.State state) { + outRect.setEmpty(); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void obtainVelocityTracker() { + if (mVelocityTracker != null) { + mVelocityTracker.recycle(); + } + mVelocityTracker = VelocityTracker.obtain(); + } + + private void releaseVelocityTracker() { + if (mVelocityTracker != null) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + } + + private ViewHolder findSwipedView(MotionEvent motionEvent) { + final RecyclerView.LayoutManager lm = mRecyclerView.getLayoutManager(); + if (mActivePointerId == ACTIVE_POINTER_ID_NONE) { + return null; + } + final int pointerIndex = motionEvent.findPointerIndex(mActivePointerId); + final float dx = motionEvent.getX(pointerIndex) - mInitialTouchX; + final float dy = motionEvent.getY(pointerIndex) - mInitialTouchY; + final float absDx = Math.abs(dx); + final float absDy = Math.abs(dy); + + if (absDx < mSlop && absDy < mSlop) { + return null; + } + if (absDx > absDy && lm.canScrollHorizontally()) { + return null; + } else if (absDy > absDx && lm.canScrollVertically()) { + return null; + } + View child = findChildView(motionEvent); + if (child == null) { + return null; + } + return mRecyclerView.getChildViewHolder(child); + } + + /** + * Checks whether we should select a View for swiping. + */ + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void checkSelectForSwipe(int action, MotionEvent motionEvent, int pointerIndex) { + if (mSelected != null || action != MotionEvent.ACTION_MOVE + || mActionState == ACTION_STATE_DRAG || !mCallback.isItemViewSwipeEnabled()) { + return; + } + if (mRecyclerView.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING) { + return; + } + final ViewHolder vh = findSwipedView(motionEvent); + if (vh == null) { + return; + } + final int movementFlags = mCallback.getAbsoluteMovementFlags(mRecyclerView, vh); + + final int swipeFlags = (movementFlags & ACTION_MODE_SWIPE_MASK) + >> (DIRECTION_FLAG_COUNT * ACTION_STATE_SWIPE); + + if (swipeFlags == 0) { + return; + } + + // mDx and mDy are only set in allowed directions. We use custom x/y here instead of + // updateDxDy to avoid swiping if user moves more in the other direction + final float x = motionEvent.getX(pointerIndex); + final float y = motionEvent.getY(pointerIndex); + + // Calculate the distance moved + final float dx = x - mInitialTouchX; + final float dy = y - mInitialTouchY; + // swipe target is chose w/o applying flags so it does not really check if swiping in that + // direction is allowed. This why here, we use mDx mDy to check slope value again. + final float absDx = Math.abs(dx); + final float absDy = Math.abs(dy); + + if (absDx < mSlop && absDy < mSlop) { + return; + } + if (absDx > absDy) { + if (dx < 0 && (swipeFlags & LEFT) == 0) { + return; + } + if (dx > 0 && (swipeFlags & RIGHT) == 0) { + return; + } + } else { + if (dy < 0 && (swipeFlags & UP) == 0) { + return; + } + if (dy > 0 && (swipeFlags & DOWN) == 0) { + return; + } + } + mDx = mDy = 0f; + mActivePointerId = motionEvent.getPointerId(0); + select(vh, ACTION_STATE_SWIPE); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + View findChildView(MotionEvent event) { + // first check elevated views, if none, then call RV + final float x = event.getX(); + final float y = event.getY(); + if (mSelected != null) { + final View selectedView = mSelected.itemView; + if (hitTest(selectedView, x, y, mSelectedStartX + mDx, mSelectedStartY + mDy)) { + return selectedView; + } + } + for (int i = mRecoverAnimations.size() - 1; i >= 0; i--) { + final RecoverAnimation anim = mRecoverAnimations.get(i); + final View view = anim.mViewHolder.itemView; + if (hitTest(view, x, y, anim.mX, anim.mY)) { + return view; + } + } + return mRecyclerView.findChildViewUnder(x, y); + } + + /** + * Starts dragging the provided ViewHolder. By default, ItemTouchHelper starts a drag when a + * View is long pressed. You can disable that behavior by overriding + * {@link ItemTouchHelper.Callback#isLongPressDragEnabled()}. + *

+ * For this method to work: + *

    + *
  • The provided ViewHolder must be a child of the RecyclerView to which this + * ItemTouchHelper + * is attached.
  • + *
  • {@link ItemTouchHelper.Callback} must have dragging enabled.
  • + *
  • There must be a previous touch event that was reported to the ItemTouchHelper + * through RecyclerView's ItemTouchListener mechanism. As long as no other ItemTouchListener + * grabs previous events, this should work as expected.
  • + *
+ * + * For example, if you would like to let your user to be able to drag an Item by touching one + * of its descendants, you may implement it as follows: + *
+     *     viewHolder.dragButton.setOnTouchListener(new View.OnTouchListener() {
+     *         public boolean onTouch(View v, MotionEvent event) {
+     *             if (MotionEvent.getActionMasked(event) == MotionEvent.ACTION_DOWN) {
+     *                 mItemTouchHelper.startDrag(viewHolder);
+     *             }
+     *             return false;
+     *         }
+     *     });
+     * 
+ *

+ * + * @param viewHolder The ViewHolder to start dragging. It must be a direct child of + * RecyclerView. + * @see ItemTouchHelper.Callback#isItemViewSwipeEnabled() + */ + public void startDrag(@NonNull ViewHolder viewHolder) { + if (!mCallback.hasDragFlag(mRecyclerView, viewHolder)) { + Log.e(TAG, "Start drag has been called but dragging is not enabled"); + return; + } + if (viewHolder.itemView.getParent() != mRecyclerView) { + Log.e(TAG, "Start drag has been called with a view holder which is not a child of " + + "the RecyclerView which is controlled by this ItemTouchHelper."); + return; + } + obtainVelocityTracker(); + mDx = mDy = 0f; + select(viewHolder, ACTION_STATE_DRAG); + } + + /** + * Starts swiping the provided ViewHolder. By default, ItemTouchHelper starts swiping a View + * when user swipes their finger (or mouse pointer) over the View. You can disable this + * behavior + * by overriding {@link ItemTouchHelper.Callback} + *

+ * For this method to work: + *

    + *
  • The provided ViewHolder must be a child of the RecyclerView to which this + * ItemTouchHelper is attached.
  • + *
  • {@link ItemTouchHelper.Callback} must have swiping enabled.
  • + *
  • There must be a previous touch event that was reported to the ItemTouchHelper + * through RecyclerView's ItemTouchListener mechanism. As long as no other ItemTouchListener + * grabs previous events, this should work as expected.
  • + *
+ * + * For example, if you would like to let your user to be able to swipe an Item by touching one + * of its descendants, you may implement it as follows: + *
+     *     viewHolder.dragButton.setOnTouchListener(new View.OnTouchListener() {
+     *         public boolean onTouch(View v, MotionEvent event) {
+     *             if (MotionEvent.getActionMasked(event) == MotionEvent.ACTION_DOWN) {
+     *                 mItemTouchHelper.startSwipe(viewHolder);
+     *             }
+     *             return false;
+     *         }
+     *     });
+     * 
+ * + * @param viewHolder The ViewHolder to start swiping. It must be a direct child of + * RecyclerView. + */ + public void startSwipe(@NonNull ViewHolder viewHolder) { + if (!mCallback.hasSwipeFlag(mRecyclerView, viewHolder)) { + Log.e(TAG, "Start swipe has been called but swiping is not enabled"); + return; + } + if (viewHolder.itemView.getParent() != mRecyclerView) { + Log.e(TAG, "Start swipe has been called with a view holder which is not a child of " + + "the RecyclerView controlled by this ItemTouchHelper."); + return; + } + obtainVelocityTracker(); + mDx = mDy = 0f; + select(viewHolder, ACTION_STATE_SWIPE); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + RecoverAnimation findAnimation(MotionEvent event) { + if (mRecoverAnimations.isEmpty()) { + return null; + } + View target = findChildView(event); + for (int i = mRecoverAnimations.size() - 1; i >= 0; i--) { + final RecoverAnimation anim = mRecoverAnimations.get(i); + if (anim.mViewHolder.itemView == target) { + return anim; + } + } + return null; + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void updateDxDy(MotionEvent ev, int directionFlags, int pointerIndex) { + final float x = ev.getX(pointerIndex); + final float y = ev.getY(pointerIndex); + + // Calculate the distance moved + mDx = x - mInitialTouchX; + mDy = y - mInitialTouchY; + if ((directionFlags & LEFT) == 0) { + mDx = Math.max(0, mDx); + } + if ((directionFlags & RIGHT) == 0) { + mDx = Math.min(0, mDx); + } + if ((directionFlags & UP) == 0) { + mDy = Math.max(0, mDy); + } + if ((directionFlags & DOWN) == 0) { + mDy = Math.min(0, mDy); + } + } + + private int swipeIfNecessary(ViewHolder viewHolder) { + if (mActionState == ACTION_STATE_DRAG) { + return 0; + } + final int originalMovementFlags = mCallback.getMovementFlags(mRecyclerView, viewHolder); + final int absoluteMovementFlags = mCallback.convertToAbsoluteDirection( + originalMovementFlags, + ViewCompat.getLayoutDirection(mRecyclerView)); + final int flags = (absoluteMovementFlags + & ACTION_MODE_SWIPE_MASK) >> (ACTION_STATE_SWIPE * DIRECTION_FLAG_COUNT); + if (flags == 0) { + return 0; + } + final int originalFlags = (originalMovementFlags + & ACTION_MODE_SWIPE_MASK) >> (ACTION_STATE_SWIPE * DIRECTION_FLAG_COUNT); + int swipeDir; + if (Math.abs(mDx) > Math.abs(mDy)) { + if ((swipeDir = checkHorizontalSwipe(viewHolder, flags)) > 0) { + // if swipe dir is not in original flags, it should be the relative direction + if ((originalFlags & swipeDir) == 0) { + // convert to relative + return Callback.convertToRelativeDirection(swipeDir, + ViewCompat.getLayoutDirection(mRecyclerView)); + } + return swipeDir; + } + if ((swipeDir = checkVerticalSwipe(viewHolder, flags)) > 0) { + return swipeDir; + } + } else { + if ((swipeDir = checkVerticalSwipe(viewHolder, flags)) > 0) { + return swipeDir; + } + if ((swipeDir = checkHorizontalSwipe(viewHolder, flags)) > 0) { + // if swipe dir is not in original flags, it should be the relative direction + if ((originalFlags & swipeDir) == 0) { + // convert to relative + return Callback.convertToRelativeDirection(swipeDir, + ViewCompat.getLayoutDirection(mRecyclerView)); + } + return swipeDir; + } + } + return 0; + } + + private int checkHorizontalSwipe(ViewHolder viewHolder, int flags) { + if ((flags & (LEFT | RIGHT)) != 0) { + final int dirFlag = mDx > 0 ? RIGHT : LEFT; + if (mVelocityTracker != null && mActivePointerId > -1) { + mVelocityTracker.computeCurrentVelocity(PIXELS_PER_SECOND, + mCallback.getSwipeVelocityThreshold(mMaxSwipeVelocity)); + final float xVelocity = mVelocityTracker.getXVelocity(mActivePointerId); + final float yVelocity = mVelocityTracker.getYVelocity(mActivePointerId); + final int velDirFlag = xVelocity > 0f ? RIGHT : LEFT; + final float absXVelocity = Math.abs(xVelocity); + if ((velDirFlag & flags) != 0 && dirFlag == velDirFlag + && absXVelocity >= mCallback.getSwipeEscapeVelocity(mSwipeEscapeVelocity) + && absXVelocity > Math.abs(yVelocity)) { + return velDirFlag; + } + } + + final float threshold = mRecyclerView.getWidth() * mCallback + .getSwipeThreshold(viewHolder); + + if ((flags & dirFlag) != 0 && Math.abs(mDx) > threshold) { + return dirFlag; + } + } + return 0; + } + + private int checkVerticalSwipe(ViewHolder viewHolder, int flags) { + if ((flags & (UP | DOWN)) != 0) { + final int dirFlag = mDy > 0 ? DOWN : UP; + if (mVelocityTracker != null && mActivePointerId > -1) { + mVelocityTracker.computeCurrentVelocity(PIXELS_PER_SECOND, + mCallback.getSwipeVelocityThreshold(mMaxSwipeVelocity)); + final float xVelocity = mVelocityTracker.getXVelocity(mActivePointerId); + final float yVelocity = mVelocityTracker.getYVelocity(mActivePointerId); + final int velDirFlag = yVelocity > 0f ? DOWN : UP; + final float absYVelocity = Math.abs(yVelocity); + if ((velDirFlag & flags) != 0 && velDirFlag == dirFlag + && absYVelocity >= mCallback.getSwipeEscapeVelocity(mSwipeEscapeVelocity) + && absYVelocity > Math.abs(xVelocity)) { + return velDirFlag; + } + } + + final float threshold = mRecyclerView.getHeight() * mCallback + .getSwipeThreshold(viewHolder); + if ((flags & dirFlag) != 0 && Math.abs(mDy) > threshold) { + return dirFlag; + } + } + return 0; + } + + private void addChildDrawingOrderCallback() { + if (Build.VERSION.SDK_INT >= 21) { + return; // we use elevation on Lollipop + } + if (mChildDrawingOrderCallback == null) { + mChildDrawingOrderCallback = new RecyclerView.ChildDrawingOrderCallback() { + @Override + public int onGetChildDrawingOrder(int childCount, int i) { + if (mOverdrawChild == null) { + return i; + } + int childPosition = mOverdrawChildPosition; + if (childPosition == -1) { + childPosition = mRecyclerView.indexOfChild(mOverdrawChild); + mOverdrawChildPosition = childPosition; + } + if (i == childCount - 1) { + return childPosition; + } + return i < childPosition ? i : i + 1; + } + }; + } + mRecyclerView.setChildDrawingOrderCallback(mChildDrawingOrderCallback); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void removeChildDrawingOrderCallbackIfNecessary(View view) { + if (view == mOverdrawChild) { + mOverdrawChild = null; + // only remove if we've added + if (mChildDrawingOrderCallback != null) { + mRecyclerView.setChildDrawingOrderCallback(null); + } + } + } + + /** + * An interface which can be implemented by LayoutManager for better integration with + * {@link ItemTouchHelper}. + */ + public interface ViewDropHandler { + + /** + * Called by the {@link ItemTouchHelper} after a View is dropped over another View. + *

+ * A LayoutManager should implement this interface to get ready for the upcoming move + * operation. + *

+ * For example, LinearLayoutManager sets up a "scrollToPositionWithOffset" calls so that + * the View under drag will be used as an anchor View while calculating the next layout, + * making layout stay consistent. + * + * @param view The View which is being dragged. It is very likely that user is still + * dragging this View so there might be other calls to + * {@code prepareForDrop()} after this one. + * @param target The target view which is being dropped on. + * @param x The left offset of the View that is being dragged. This value + * includes the movement caused by the user. + * @param y The top offset of the View that is being dragged. This value + * includes the movement caused by the user. + */ + void prepareForDrop(@NonNull View view, @NonNull View target, int x, int y); + } + + /** + * This class is the contract between ItemTouchHelper and your application. It lets you control + * which touch behaviors are enabled per each ViewHolder and also receive callbacks when user + * performs these actions. + *

+ * To control which actions user can take on each view, you should override + * {@link #getMovementFlags(RecyclerView, ViewHolder)} and return appropriate set + * of direction flags. ({@link #LEFT}, {@link #RIGHT}, {@link #START}, {@link #END}, + * {@link #UP}, {@link #DOWN}). You can use + * {@link #makeMovementFlags(int, int)} to easily construct it. Alternatively, you can use + * {@link SimpleCallback}. + *

+ * If user drags an item, ItemTouchHelper will call + * {@link Callback#onMove(RecyclerView, ViewHolder, ViewHolder) + * onMove(recyclerView, dragged, target)}. + * Upon receiving this callback, you should move the item from the old position + * ({@code dragged.getAdapterPosition()}) to new position ({@code target.getAdapterPosition()}) + * in your adapter and also call {@link RecyclerView.Adapter#notifyItemMoved(int, int)}. + * To control where a View can be dropped, you can override + * {@link #canDropOver(RecyclerView, ViewHolder, ViewHolder)}. When a + * dragging View overlaps multiple other views, Callback chooses the closest View with which + * dragged View might have changed positions. Although this approach works for many use cases, + * if you have a custom LayoutManager, you can override + * {@link #chooseDropTarget(ViewHolder, java.util.List, int, int)} to select a + * custom drop target. + *

+ * When a View is swiped, ItemTouchHelper animates it until it goes out of bounds, then calls + * {@link #onSwiped(ViewHolder, int)}. At this point, you should update your + * adapter (e.g. remove the item) and call related Adapter#notify event. + */ + @SuppressWarnings("UnusedParameters") + public abstract static class Callback { + + @SuppressWarnings("WeakerAccess") + public static final int DEFAULT_DRAG_ANIMATION_DURATION = 200; + + @SuppressWarnings("WeakerAccess") + public static final int DEFAULT_SWIPE_ANIMATION_DURATION = 250; + + static final int RELATIVE_DIR_FLAGS = START | END + | ((START | END) << DIRECTION_FLAG_COUNT) + | ((START | END) << (2 * DIRECTION_FLAG_COUNT)); + + private static final int ABS_HORIZONTAL_DIR_FLAGS = LEFT | RIGHT + | ((LEFT | RIGHT) << DIRECTION_FLAG_COUNT) + | ((LEFT | RIGHT) << (2 * DIRECTION_FLAG_COUNT)); + + private static final Interpolator sDragScrollInterpolator = new Interpolator() { + @Override + public float getInterpolation(float t) { + return t * t * t * t * t; + } + }; + + private static final Interpolator sDragViewScrollCapInterpolator = new Interpolator() { + @Override + public float getInterpolation(float t) { + t -= 1.0f; + return t * t * t * t * t + 1.0f; + } + }; + + /** + * Drag scroll speed keeps accelerating until this many milliseconds before being capped. + */ + private static final long DRAG_SCROLL_ACCELERATION_LIMIT_TIME_MS = 2000; + + private int mCachedMaxScrollSpeed = -1; + + /** + * Returns the {@link ItemTouchUIUtil} that is used by the {@link Callback} class for + * visual + * changes on Views in response to user interactions. {@link ItemTouchUIUtil} has different + * implementations for different platform versions. + *

+ * By default, {@link Callback} applies these changes on + * {@link RecyclerView.ViewHolder#itemView}. + *

+ * For example, if you have a use case where you only want the text to move when user + * swipes over the view, you can do the following: + *

+         *     public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder){
+         *         getDefaultUIUtil().clearView(((ItemTouchViewHolder) viewHolder).textView);
+         *     }
+         *     public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
+         *         if (viewHolder != null){
+         *             getDefaultUIUtil().onSelected(((ItemTouchViewHolder) viewHolder).textView);
+         *         }
+         *     }
+         *     public void onChildDraw(Canvas c, RecyclerView recyclerView,
+         *             RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState,
+         *             boolean isCurrentlyActive) {
+         *         getDefaultUIUtil().onDraw(c, recyclerView,
+         *                 ((ItemTouchViewHolder) viewHolder).textView, dX, dY,
+         *                 actionState, isCurrentlyActive);
+         *         return true;
+         *     }
+         *     public void onChildDrawOver(Canvas c, RecyclerView recyclerView,
+         *             RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState,
+         *             boolean isCurrentlyActive) {
+         *         getDefaultUIUtil().onDrawOver(c, recyclerView,
+         *                 ((ItemTouchViewHolder) viewHolder).textView, dX, dY,
+         *                 actionState, isCurrentlyActive);
+         *         return true;
+         *     }
+         * 
+ * + * @return The {@link ItemTouchUIUtil} instance that is used by the {@link Callback} + */ + @SuppressWarnings("WeakerAccess") + @NonNull + public static ItemTouchUIUtil getDefaultUIUtil() { + return ItemTouchUIUtilImpl.INSTANCE; + } + + /** + * Replaces a movement direction with its relative version by taking layout direction into + * account. + * + * @param flags The flag value that include any number of movement flags. + * @param layoutDirection The layout direction of the View. Can be obtained from + * {@link ViewCompat#getLayoutDirection(android.view.View)}. + * @return Updated flags which uses relative flags ({@link #START}, {@link #END}) instead + * of {@link #LEFT}, {@link #RIGHT}. + * @see #convertToAbsoluteDirection(int, int) + */ + @SuppressWarnings("WeakerAccess") + public static int convertToRelativeDirection(int flags, int layoutDirection) { + int masked = flags & ABS_HORIZONTAL_DIR_FLAGS; + if (masked == 0) { + return flags; // does not have any abs flags, good. + } + flags &= ~masked; //remove left / right. + if (layoutDirection == ViewCompat.LAYOUT_DIRECTION_LTR) { + // no change. just OR with 2 bits shifted mask and return + flags |= masked << 2; // START is 2 bits after LEFT, END is 2 bits after RIGHT. + return flags; + } else { + // add RIGHT flag as START + flags |= ((masked << 1) & ~ABS_HORIZONTAL_DIR_FLAGS); + // first clean RIGHT bit then add LEFT flag as END + flags |= ((masked << 1) & ABS_HORIZONTAL_DIR_FLAGS) << 2; + } + return flags; + } + + /** + * Convenience method to create movement flags. + *

+ * For instance, if you want to let your items be drag & dropped vertically and swiped + * left to be dismissed, you can call this method with: + * makeMovementFlags(UP | DOWN, LEFT); + * + * @param dragFlags The directions in which the item can be dragged. + * @param swipeFlags The directions in which the item can be swiped. + * @return Returns an integer composed of the given drag and swipe flags. + */ + public static int makeMovementFlags(int dragFlags, int swipeFlags) { + return makeFlag(ACTION_STATE_IDLE, swipeFlags | dragFlags) + | makeFlag(ACTION_STATE_SWIPE, swipeFlags) + | makeFlag(ACTION_STATE_DRAG, dragFlags); + } + + /** + * Shifts the given direction flags to the offset of the given action state. + * + * @param actionState The action state you want to get flags in. Should be one of + * {@link #ACTION_STATE_IDLE}, {@link #ACTION_STATE_SWIPE} or + * {@link #ACTION_STATE_DRAG}. + * @param directions The direction flags. Can be composed from {@link #UP}, {@link #DOWN}, + * {@link #RIGHT}, {@link #LEFT} {@link #START} and {@link #END}. + * @return And integer that represents the given directions in the provided actionState. + */ + @SuppressWarnings("WeakerAccess") + public static int makeFlag(int actionState, int directions) { + return directions << (actionState * DIRECTION_FLAG_COUNT); + } + + /** + * Should return a composite flag which defines the enabled move directions in each state + * (idle, swiping, dragging). + *

+ * Instead of composing this flag manually, you can use {@link #makeMovementFlags(int, + * int)} + * or {@link #makeFlag(int, int)}. + *

+ * This flag is composed of 3 sets of 8 bits, where first 8 bits are for IDLE state, next + * 8 bits are for SWIPE state and third 8 bits are for DRAG state. + * Each 8 bit sections can be constructed by simply OR'ing direction flags defined in + * {@link ItemTouchHelper}. + *

+ * For example, if you want it to allow swiping LEFT and RIGHT but only allow starting to + * swipe by swiping RIGHT, you can return: + *

+         *      makeFlag(ACTION_STATE_IDLE, RIGHT) | makeFlag(ACTION_STATE_SWIPE, LEFT | RIGHT);
+         * 
+ * This means, allow right movement while IDLE and allow right and left movement while + * swiping. + * + * @param recyclerView The RecyclerView to which ItemTouchHelper is attached. + * @param viewHolder The ViewHolder for which the movement information is necessary. + * @return flags specifying which movements are allowed on this ViewHolder. + * @see #makeMovementFlags(int, int) + * @see #makeFlag(int, int) + */ + public abstract int getMovementFlags(@NonNull RecyclerView recyclerView, + @NonNull ViewHolder viewHolder); + + /** + * Converts a given set of flags to absolution direction which means {@link #START} and + * {@link #END} are replaced with {@link #LEFT} and {@link #RIGHT} depending on the layout + * direction. + * + * @param flags The flag value that include any number of movement flags. + * @param layoutDirection The layout direction of the RecyclerView. + * @return Updated flags which includes only absolute direction values. + */ + @SuppressWarnings("WeakerAccess") + public int convertToAbsoluteDirection(int flags, int layoutDirection) { + int masked = flags & RELATIVE_DIR_FLAGS; + if (masked == 0) { + return flags; // does not have any relative flags, good. + } + flags &= ~masked; //remove start / end + if (layoutDirection == ViewCompat.LAYOUT_DIRECTION_LTR) { + // no change. just OR with 2 bits shifted mask and return + flags |= masked >> 2; // START is 2 bits after LEFT, END is 2 bits after RIGHT. + return flags; + } else { + // add START flag as RIGHT + flags |= ((masked >> 1) & ~RELATIVE_DIR_FLAGS); + // first clean start bit then add END flag as LEFT + flags |= ((masked >> 1) & RELATIVE_DIR_FLAGS) >> 2; + } + return flags; + } + + final int getAbsoluteMovementFlags(RecyclerView recyclerView, + ViewHolder viewHolder) { + final int flags = getMovementFlags(recyclerView, viewHolder); + return convertToAbsoluteDirection(flags, ViewCompat.getLayoutDirection(recyclerView)); + } + + boolean hasDragFlag(RecyclerView recyclerView, ViewHolder viewHolder) { + final int flags = getAbsoluteMovementFlags(recyclerView, viewHolder); + return (flags & ACTION_MODE_DRAG_MASK) != 0; + } + + boolean hasSwipeFlag(RecyclerView recyclerView, + ViewHolder viewHolder) { + final int flags = getAbsoluteMovementFlags(recyclerView, viewHolder); + return (flags & ACTION_MODE_SWIPE_MASK) != 0; + } + + /** + * Return true if the current ViewHolder can be dropped over the the target ViewHolder. + *

+ * This method is used when selecting drop target for the dragged View. After Views are + * eliminated either via bounds check or via this method, resulting set of views will be + * passed to {@link #chooseDropTarget(ViewHolder, java.util.List, int, int)}. + *

+ * Default implementation returns true. + * + * @param recyclerView The RecyclerView to which ItemTouchHelper is attached to. + * @param current The ViewHolder that user is dragging. + * @param target The ViewHolder which is below the dragged ViewHolder. + * @return True if the dragged ViewHolder can be replaced with the target ViewHolder, false + * otherwise. + */ + @SuppressWarnings("WeakerAccess") + public boolean canDropOver(@NonNull RecyclerView recyclerView, @NonNull ViewHolder current, + @NonNull ViewHolder target) { + return true; + } + + /** + * Called when ItemTouchHelper wants to move the dragged item from its old position to + * the new position. + *

+ * If this method returns true, ItemTouchHelper assumes {@code viewHolder} has been moved + * to the adapter position of {@code target} ViewHolder + * ({@link ViewHolder#getAbsoluteAdapterPosition() + * ViewHolder#getAdapterPositionInRecyclerView()}). + *

+ * If you don't support drag & drop, this method will never be called. + * + * @param recyclerView The RecyclerView to which ItemTouchHelper is attached to. + * @param viewHolder The ViewHolder which is being dragged by the user. + * @param target The ViewHolder over which the currently active item is being + * dragged. + * @return True if the {@code viewHolder} has been moved to the adapter position of + * {@code target}. + * @see #onMoved(RecyclerView, ViewHolder, int, ViewHolder, int, int, int) + */ + public abstract boolean onMove(@NonNull RecyclerView recyclerView, + @NonNull ViewHolder viewHolder, @NonNull ViewHolder target); + + /** + * Returns whether ItemTouchHelper should start a drag and drop operation if an item is + * long pressed. + *

+ * Default value returns true but you may want to disable this if you want to start + * dragging on a custom view touch using {@link #startDrag(ViewHolder)}. + * + * @return True if ItemTouchHelper should start dragging an item when it is long pressed, + * false otherwise. Default value is true. + * @see #startDrag(ViewHolder) + */ + public boolean isLongPressDragEnabled() { + return true; + } + + /** + * Returns whether ItemTouchHelper should start a swipe operation if a pointer is swiped + * over the View. + *

+ * Default value returns true but you may want to disable this if you want to start + * swiping on a custom view touch using {@link #startSwipe(ViewHolder)}. + * + * @return True if ItemTouchHelper should start swiping an item when user swipes a pointer + * over the View, false otherwise. Default value is true. + * @see #startSwipe(ViewHolder) + */ + public boolean isItemViewSwipeEnabled() { + return true; + } + + /** + * When finding views under a dragged view, by default, ItemTouchHelper searches for views + * that overlap with the dragged View. By overriding this method, you can extend or shrink + * the search box. + * + * @return The extra margin to be added to the hit box of the dragged View. + */ + @SuppressWarnings("WeakerAccess") + public int getBoundingBoxMargin() { + return 0; + } + + /** + * Returns the fraction that the user should move the View to be considered as swiped. + * The fraction is calculated with respect to RecyclerView's bounds. + *

+ * Default value is .5f, which means, to swipe a View, user must move the View at least + * half of RecyclerView's width or height, depending on the swipe direction. + * + * @param viewHolder The ViewHolder that is being dragged. + * @return A float value that denotes the fraction of the View size. Default value + * is .5f . + */ + @SuppressWarnings("WeakerAccess") + public float getSwipeThreshold(@NonNull ViewHolder viewHolder) { + return .5f; + } + + /** + * Returns the fraction that the user should move the View to be considered as it is + * dragged. After a view is moved this amount, ItemTouchHelper starts checking for Views + * below it for a possible drop. + * + * @param viewHolder The ViewHolder that is being dragged. + * @return A float value that denotes the fraction of the View size. Default value is + * .5f . + */ + @SuppressWarnings("WeakerAccess") + public float getMoveThreshold(@NonNull ViewHolder viewHolder) { + return .5f; + } + + /** + * Defines the minimum velocity which will be considered as a swipe action by the user. + *

+ * You can increase this value to make it harder to swipe or decrease it to make it easier. + * Keep in mind that ItemTouchHelper also checks the perpendicular velocity and makes sure + * current direction velocity is larger then the perpendicular one. Otherwise, user's + * movement is ambiguous. You can change the threshold by overriding + * {@link #getSwipeVelocityThreshold(float)}. + *

+ * The velocity is calculated in pixels per second. + *

+ * The default framework value is passed as a parameter so that you can modify it with a + * multiplier. + * + * @param defaultValue The default value (in pixels per second) used by the + * ItemTouchHelper. + * @return The minimum swipe velocity. The default implementation returns the + * defaultValue parameter. + * @see #getSwipeVelocityThreshold(float) + * @see #getSwipeThreshold(ViewHolder) + */ + @SuppressWarnings("WeakerAccess") + public float getSwipeEscapeVelocity(float defaultValue) { + return defaultValue; + } + + /** + * Defines the maximum velocity ItemTouchHelper will ever calculate for pointer movements. + *

+ * To consider a movement as swipe, ItemTouchHelper requires it to be larger than the + * perpendicular movement. If both directions reach to the max threshold, none of them will + * be considered as a swipe because it is usually an indication that user rather tried to + * scroll then swipe. + *

+ * The velocity is calculated in pixels per second. + *

+ * You can customize this behavior by changing this method. If you increase the value, it + * will be easier for the user to swipe diagonally and if you decrease the value, user will + * need to make a rather straight finger movement to trigger a swipe. + * + * @param defaultValue The default value(in pixels per second) used by the ItemTouchHelper. + * @return The velocity cap for pointer movements. The default implementation returns the + * defaultValue parameter. + * @see #getSwipeEscapeVelocity(float) + */ + @SuppressWarnings("WeakerAccess") + public float getSwipeVelocityThreshold(float defaultValue) { + return defaultValue; + } + + /** + * Called by ItemTouchHelper to select a drop target from the list of ViewHolders that + * are under the dragged View. + *

+ * Default implementation filters the View with which dragged item have changed position + * in the drag direction. For instance, if the view is dragged UP, it compares the + * view.getTop() of the two views before and after drag started. If that value + * is different, the target view passes the filter. + *

+ * Among these Views which pass the test, the one closest to the dragged view is chosen. + *

+ * This method is called on the main thread every time user moves the View. If you want to + * override it, make sure it does not do any expensive operations. + * + * @param selected The ViewHolder being dragged by the user. + * @param dropTargets The list of ViewHolder that are under the dragged View and + * candidate as a drop. + * @param curX The updated left value of the dragged View after drag translations + * are applied. This value does not include margins added by + * {@link RecyclerView.ItemDecoration}s. + * @param curY The updated top value of the dragged View after drag translations + * are applied. This value does not include margins added by + * {@link RecyclerView.ItemDecoration}s. + * @return A ViewHolder to whose position the dragged ViewHolder should be + * moved to. + */ + @SuppressWarnings("WeakerAccess") + public ViewHolder chooseDropTarget(@NonNull ViewHolder selected, + @NonNull List dropTargets, int curX, int curY) { + int right = curX + selected.itemView.getWidth(); + int bottom = curY + selected.itemView.getHeight(); + ViewHolder winner = null; + int winnerScore = -1; + final int dx = curX - selected.itemView.getLeft(); + final int dy = curY - selected.itemView.getTop(); + final int targetsSize = dropTargets.size(); + for (int i = 0; i < targetsSize; i++) { + final ViewHolder target = dropTargets.get(i); + if (dx > 0) { + int diff = target.itemView.getRight() - right; + if (diff < 0 && target.itemView.getRight() > selected.itemView.getRight()) { + final int score = Math.abs(diff); + if (score > winnerScore) { + winnerScore = score; + winner = target; + } + } + } + if (dx < 0) { + int diff = target.itemView.getLeft() - curX; + if (diff > 0 && target.itemView.getLeft() < selected.itemView.getLeft()) { + final int score = Math.abs(diff); + if (score > winnerScore) { + winnerScore = score; + winner = target; + } + } + } + if (dy < 0) { + int diff = target.itemView.getTop() - curY; + if (diff > 0 && target.itemView.getTop() < selected.itemView.getTop()) { + final int score = Math.abs(diff); + if (score > winnerScore) { + winnerScore = score; + winner = target; + } + } + } + + if (dy > 0) { + int diff = target.itemView.getBottom() - bottom; + if (diff < 0 && target.itemView.getBottom() > selected.itemView.getBottom()) { + final int score = Math.abs(diff); + if (score > winnerScore) { + winnerScore = score; + winner = target; + } + } + } + } + return winner; + } + + /** + * Called when a ViewHolder is swiped by the user. + *

+ * If you are returning relative directions ({@link #START} , {@link #END}) from the + * {@link #getMovementFlags(RecyclerView, ViewHolder)} method, this method + * will also use relative directions. Otherwise, it will use absolute directions. + *

+ * If you don't support swiping, this method will never be called. + *

+ * ItemTouchHelper will keep a reference to the View until it is detached from + * RecyclerView. + * As soon as it is detached, ItemTouchHelper will call + * {@link #clearView(RecyclerView, ViewHolder)}. + * + * @param viewHolder The ViewHolder which has been swiped by the user. + * @param direction The direction to which the ViewHolder is swiped. It is one of + * {@link #UP}, {@link #DOWN}, + * {@link #LEFT} or {@link #RIGHT}. If your + * {@link #getMovementFlags(RecyclerView, ViewHolder)} + * method + * returned relative flags instead of {@link #LEFT} / {@link #RIGHT}; + * `direction` will be relative as well. ({@link #START} or {@link + * #END}). + */ + public abstract void onSwiped(@NonNull ViewHolder viewHolder, int direction); + + /** + * Called when the ViewHolder swiped or dragged by the ItemTouchHelper is changed. + *

+ * If you override this method, you should call super. + * + * @param viewHolder The new ViewHolder that is being swiped or dragged. Might be null if + * it is cleared. + * @param actionState One of {@link ItemTouchHelper#ACTION_STATE_IDLE}, + * {@link ItemTouchHelper#ACTION_STATE_SWIPE} or + * {@link ItemTouchHelper#ACTION_STATE_DRAG}. + * @see #clearView(RecyclerView, RecyclerView.ViewHolder) + */ + public void onSelectedChanged(@Nullable ViewHolder viewHolder, int actionState) { + if (viewHolder != null) { + ItemTouchUIUtilImpl.INSTANCE.onSelected(viewHolder.itemView); + } + } + + private int getMaxDragScroll(RecyclerView recyclerView) { + if (mCachedMaxScrollSpeed == -1) { + mCachedMaxScrollSpeed = recyclerView.getResources().getDimensionPixelSize( + R.dimen.item_touch_helper_max_drag_scroll_per_frame); + } + return mCachedMaxScrollSpeed; + } + + /** + * Called when {@link #onMove(RecyclerView, ViewHolder, ViewHolder)} returns true. + *

+ * ItemTouchHelper does not create an extra Bitmap or View while dragging, instead, it + * modifies the existing View. Because of this reason, it is important that the View is + * still part of the layout after it is moved. This may not work as intended when swapped + * Views are close to RecyclerView bounds or there are gaps between them (e.g. other Views + * which were not eligible for dropping over). + *

+ * This method is responsible to give necessary hint to the LayoutManager so that it will + * keep the View in visible area. For example, for LinearLayoutManager, this is as simple + * as calling {@link LinearLayoutManager#scrollToPositionWithOffset(int, int)}. + * + * Default implementation calls {@link RecyclerView#scrollToPosition(int)} if the View's + * new position is likely to be out of bounds. + *

+ * It is important to ensure the ViewHolder will stay visible as otherwise, it might be + * removed by the LayoutManager if the move causes the View to go out of bounds. In that + * case, drag will end prematurely. + * + * @param recyclerView The RecyclerView controlled by the ItemTouchHelper. + * @param viewHolder The ViewHolder under user's control. + * @param fromPos The previous adapter position of the dragged item (before it was + * moved). + * @param target The ViewHolder on which the currently active item has been dropped. + * @param toPos The new adapter position of the dragged item. + * @param x The updated left value of the dragged View after drag translations + * are applied. This value does not include margins added by + * {@link RecyclerView.ItemDecoration}s. + * @param y The updated top value of the dragged View after drag translations + * are applied. This value does not include margins added by + * {@link RecyclerView.ItemDecoration}s. + */ + public void onMoved(@NonNull final RecyclerView recyclerView, + @NonNull final ViewHolder viewHolder, int fromPos, @NonNull final ViewHolder target, + int toPos, int x, int y) { + final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager(); + if (layoutManager instanceof ViewDropHandler) { + ((ViewDropHandler) layoutManager).prepareForDrop(viewHolder.itemView, + target.itemView, x, y); + return; + } + + // if layout manager cannot handle it, do some guesswork + if (layoutManager.canScrollHorizontally()) { + final int minLeft = layoutManager.getDecoratedLeft(target.itemView); + if (minLeft <= recyclerView.getPaddingLeft()) { + recyclerView.scrollToPosition(toPos); + } + final int maxRight = layoutManager.getDecoratedRight(target.itemView); + if (maxRight >= recyclerView.getWidth() - recyclerView.getPaddingRight()) { + recyclerView.scrollToPosition(toPos); + } + } + + if (layoutManager.canScrollVertically()) { + final int minTop = layoutManager.getDecoratedTop(target.itemView); + if (minTop <= recyclerView.getPaddingTop()) { + recyclerView.scrollToPosition(toPos); + } + final int maxBottom = layoutManager.getDecoratedBottom(target.itemView); + if (maxBottom >= recyclerView.getHeight() - recyclerView.getPaddingBottom()) { + recyclerView.scrollToPosition(toPos); + } + } + } + + void onDraw(Canvas c, RecyclerView parent, ViewHolder selected, + List recoverAnimationList, + int actionState, float dX, float dY) { + final int recoverAnimSize = recoverAnimationList.size(); + for (int i = 0; i < recoverAnimSize; i++) { + final ItemTouchHelper.RecoverAnimation anim = recoverAnimationList.get(i); + anim.update(); + final int count = c.save(); + onChildDraw(c, parent, anim.mViewHolder, anim.mX, anim.mY, anim.mActionState, + false); + c.restoreToCount(count); + } + if (selected != null) { + final int count = c.save(); + onChildDraw(c, parent, selected, dX, dY, actionState, true); + c.restoreToCount(count); + } + } + + void onDrawOver(Canvas c, RecyclerView parent, ViewHolder selected, + List recoverAnimationList, + int actionState, float dX, float dY) { + final int recoverAnimSize = recoverAnimationList.size(); + for (int i = 0; i < recoverAnimSize; i++) { + final ItemTouchHelper.RecoverAnimation anim = recoverAnimationList.get(i); + final int count = c.save(); + onChildDrawOver(c, parent, anim.mViewHolder, anim.mX, anim.mY, anim.mActionState, + false); + c.restoreToCount(count); + } + if (selected != null) { + final int count = c.save(); + onChildDrawOver(c, parent, selected, dX, dY, actionState, true); + c.restoreToCount(count); + } + boolean hasRunningAnimation = false; + for (int i = recoverAnimSize - 1; i >= 0; i--) { + final RecoverAnimation anim = recoverAnimationList.get(i); + if (anim.mEnded && !anim.mIsPendingCleanup) { + recoverAnimationList.remove(i); + } else if (!anim.mEnded) { + hasRunningAnimation = true; + } + } + if (hasRunningAnimation) { + parent.invalidate(); + } + } + + /** + * Called by the ItemTouchHelper when the user interaction with an element is over and it + * also completed its animation. + *

+ * This is a good place to clear all changes on the View that was done in + * {@link #onSelectedChanged(RecyclerView.ViewHolder, int)}, + * {@link #onChildDraw(Canvas, RecyclerView, ViewHolder, float, float, int, + * boolean)} or + * {@link #onChildDrawOver(Canvas, RecyclerView, ViewHolder, float, float, int, boolean)}. + * + * @param recyclerView The RecyclerView which is controlled by the ItemTouchHelper. + * @param viewHolder The View that was interacted by the user. + */ + public void clearView(@NonNull RecyclerView recyclerView, @NonNull ViewHolder viewHolder) { + ItemTouchUIUtilImpl.INSTANCE.clearView(viewHolder.itemView); + } + + /** + * Called by ItemTouchHelper on RecyclerView's onDraw callback. + *

+ * If you would like to customize how your View's respond to user interactions, this is + * a good place to override. + *

+ * Default implementation translates the child by the given dX, + * dY. + * ItemTouchHelper also takes care of drawing the child after other children if it is being + * dragged. This is done using child re-ordering mechanism. On platforms prior to L, this + * is + * achieved via {@link android.view.ViewGroup#getChildDrawingOrder(int, int)} and on L + * and after, it changes View's elevation value to be greater than all other children.) + * + * @param c The canvas which RecyclerView is drawing its children + * @param recyclerView The RecyclerView to which ItemTouchHelper is attached to + * @param viewHolder The ViewHolder which is being interacted by the User or it was + * interacted and simply animating to its original position + * @param dX The amount of horizontal displacement caused by user's action + * @param dY The amount of vertical displacement caused by user's action + * @param actionState The type of interaction on the View. Is either {@link + * #ACTION_STATE_DRAG} or {@link #ACTION_STATE_SWIPE}. + * @param isCurrentlyActive True if this view is currently being controlled by the user or + * false it is simply animating back to its original state. + * @see #onChildDrawOver(Canvas, RecyclerView, ViewHolder, float, float, int, + * boolean) + */ + public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView, + @NonNull ViewHolder viewHolder, + float dX, float dY, int actionState, boolean isCurrentlyActive) { + ItemTouchUIUtilImpl.INSTANCE.onDraw(c, recyclerView, viewHolder.itemView, dX, dY, + actionState, isCurrentlyActive); + } + + /** + * Called by ItemTouchHelper on RecyclerView's onDraw callback. + *

+ * If you would like to customize how your View's respond to user interactions, this is + * a good place to override. + *

+ * Default implementation translates the child by the given dX, + * dY. + * ItemTouchHelper also takes care of drawing the child after other children if it is being + * dragged. This is done using child re-ordering mechanism. On platforms prior to L, this + * is + * achieved via {@link android.view.ViewGroup#getChildDrawingOrder(int, int)} and on L + * and after, it changes View's elevation value to be greater than all other children.) + * + * @param c The canvas which RecyclerView is drawing its children + * @param recyclerView The RecyclerView to which ItemTouchHelper is attached to + * @param viewHolder The ViewHolder which is being interacted by the User or it was + * interacted and simply animating to its original position + * @param dX The amount of horizontal displacement caused by user's action + * @param dY The amount of vertical displacement caused by user's action + * @param actionState The type of interaction on the View. Is either {@link + * #ACTION_STATE_DRAG} or {@link #ACTION_STATE_SWIPE}. + * @param isCurrentlyActive True if this view is currently being controlled by the user or + * false it is simply animating back to its original state. + * @see #onChildDrawOver(Canvas, RecyclerView, ViewHolder, float, float, int, + * boolean) + */ + public void onChildDrawOver(@NonNull Canvas c, @NonNull RecyclerView recyclerView, + ViewHolder viewHolder, + float dX, float dY, int actionState, boolean isCurrentlyActive) { + ItemTouchUIUtilImpl.INSTANCE.onDrawOver(c, recyclerView, viewHolder.itemView, dX, dY, + actionState, isCurrentlyActive); + } + + /** + * Called by the ItemTouchHelper when user action finished on a ViewHolder and now the View + * will be animated to its final position. + *

+ * Default implementation uses ItemAnimator's duration values. If + * animationType is {@link #ANIMATION_TYPE_DRAG}, it returns + * {@link RecyclerView.ItemAnimator#getMoveDuration()}, otherwise, it returns + * {@link RecyclerView.ItemAnimator#getRemoveDuration()}. If RecyclerView does not have + * any {@link RecyclerView.ItemAnimator} attached, this method returns + * {@code DEFAULT_DRAG_ANIMATION_DURATION} or {@code DEFAULT_SWIPE_ANIMATION_DURATION} + * depending on the animation type. + * + * @param recyclerView The RecyclerView to which the ItemTouchHelper is attached to. + * @param animationType The type of animation. Is one of {@link #ANIMATION_TYPE_DRAG}, + * {@link #ANIMATION_TYPE_SWIPE_CANCEL} or + * {@link #ANIMATION_TYPE_SWIPE_SUCCESS}. + * @param animateDx The horizontal distance that the animation will offset + * @param animateDy The vertical distance that the animation will offset + * @return The duration for the animation + */ + @SuppressWarnings("WeakerAccess") + public long getAnimationDuration(@NonNull RecyclerView recyclerView, int animationType, + float animateDx, float animateDy) { + final RecyclerView.ItemAnimator itemAnimator = recyclerView.getItemAnimator(); + if (itemAnimator == null) { + return animationType == ANIMATION_TYPE_DRAG ? DEFAULT_DRAG_ANIMATION_DURATION + : DEFAULT_SWIPE_ANIMATION_DURATION; + } else { + return animationType == ANIMATION_TYPE_DRAG ? itemAnimator.getMoveDuration() + : itemAnimator.getRemoveDuration(); + } + } + + /** + * Called by the ItemTouchHelper when user is dragging a view out of bounds. + *

+ * You can override this method to decide how much RecyclerView should scroll in response + * to this action. Default implementation calculates a value based on the amount of View + * out of bounds and the time it spent there. The longer user keeps the View out of bounds, + * the faster the list will scroll. Similarly, the larger portion of the View is out of + * bounds, the faster the RecyclerView will scroll. + * + * @param recyclerView The RecyclerView instance to which ItemTouchHelper is + * attached to. + * @param viewSize The total size of the View in scroll direction, excluding + * item decorations. + * @param viewSizeOutOfBounds The total size of the View that is out of bounds. This value + * is negative if the View is dragged towards left or top edge. + * @param totalSize The total size of RecyclerView in the scroll direction. + * @param msSinceStartScroll The time passed since View is kept out of bounds. + * @return The amount that RecyclerView should scroll. Keep in mind that this value will + * be passed to {@link RecyclerView#scrollBy(int, int)} method. + */ + @SuppressWarnings("WeakerAccess") + public int interpolateOutOfBoundsScroll(@NonNull RecyclerView recyclerView, + int viewSize, int viewSizeOutOfBounds, + int totalSize, long msSinceStartScroll) { + final int maxScroll = getMaxDragScroll(recyclerView); + final int absOutOfBounds = Math.abs(viewSizeOutOfBounds); + final int direction = (int) Math.signum(viewSizeOutOfBounds); + // might be negative if other direction + float outOfBoundsRatio = Math.min(1f, 1f * absOutOfBounds / viewSize); + final int cappedScroll = (int) (direction * maxScroll + * sDragViewScrollCapInterpolator.getInterpolation(outOfBoundsRatio)); + final float timeRatio; + if (msSinceStartScroll > DRAG_SCROLL_ACCELERATION_LIMIT_TIME_MS) { + timeRatio = 1f; + } else { + timeRatio = (float) msSinceStartScroll / DRAG_SCROLL_ACCELERATION_LIMIT_TIME_MS; + } + final int value = (int) (cappedScroll * sDragScrollInterpolator + .getInterpolation(timeRatio)); + if (value == 0) { + return viewSizeOutOfBounds > 0 ? 1 : -1; + } + return value; + } + } + + /** + * A simple wrapper to the default Callback which you can construct with drag and swipe + * directions and this class will handle the flag callbacks. You should still override onMove + * or + * onSwiped depending on your use case. + * + *

+     * ItemTouchHelper mIth = new ItemTouchHelper(
+     *     new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
+     *         ItemTouchHelper.LEFT) {
+     *         public boolean onMove(RecyclerView recyclerView,
+     *             ViewHolder viewHolder, ViewHolder target) {
+     *             final int fromPos = viewHolder.getAdapterPosition();
+     *             final int toPos = target.getAdapterPosition();
+     *             // move item in `fromPos` to `toPos` in adapter.
+     *             return true;// true if moved, false otherwise
+     *         }
+     *         public void onSwiped(ViewHolder viewHolder, int direction) {
+     *             // remove from adapter
+     *         }
+     * });
+     * 
+ */ + public abstract static class SimpleCallback extends Callback { + + private int mDefaultSwipeDirs; + + private int mDefaultDragDirs; + + /** + * Creates a Callback for the given drag and swipe allowance. These values serve as + * defaults + * and if you want to customize behavior per ViewHolder, you can override + * {@link #getSwipeDirs(RecyclerView, ViewHolder)} + * and / or {@link #getDragDirs(RecyclerView, ViewHolder)}. + * + * @param dragDirs Binary OR of direction flags in which the Views can be dragged. Must be + * composed of {@link #LEFT}, {@link #RIGHT}, {@link #START}, {@link + * #END}, + * {@link #UP} and {@link #DOWN}. + * @param swipeDirs Binary OR of direction flags in which the Views can be swiped. Must be + * composed of {@link #LEFT}, {@link #RIGHT}, {@link #START}, {@link + * #END}, + * {@link #UP} and {@link #DOWN}. + */ + public SimpleCallback(int dragDirs, int swipeDirs) { + mDefaultSwipeDirs = swipeDirs; + mDefaultDragDirs = dragDirs; + } + + /** + * Updates the default swipe directions. For example, you can use this method to toggle + * certain directions depending on your use case. + * + * @param defaultSwipeDirs Binary OR of directions in which the ViewHolders can be swiped. + */ + @SuppressWarnings({"WeakerAccess", "unused"}) + public void setDefaultSwipeDirs(@SuppressWarnings("unused") int defaultSwipeDirs) { + mDefaultSwipeDirs = defaultSwipeDirs; + } + + /** + * Updates the default drag directions. For example, you can use this method to toggle + * certain directions depending on your use case. + * + * @param defaultDragDirs Binary OR of directions in which the ViewHolders can be dragged. + */ + @SuppressWarnings({"WeakerAccess", "unused"}) + public void setDefaultDragDirs(@SuppressWarnings("unused") int defaultDragDirs) { + mDefaultDragDirs = defaultDragDirs; + } + + /** + * Returns the swipe directions for the provided ViewHolder. + * Default implementation returns the swipe directions that was set via constructor or + * {@link #setDefaultSwipeDirs(int)}. + * + * @param recyclerView The RecyclerView to which the ItemTouchHelper is attached to. + * @param viewHolder The ViewHolder for which the swipe direction is queried. + * @return A binary OR of direction flags. + */ + @SuppressWarnings("WeakerAccess") + public int getSwipeDirs(@SuppressWarnings("unused") @NonNull RecyclerView recyclerView, + @NonNull @SuppressWarnings("unused") ViewHolder viewHolder) { + return mDefaultSwipeDirs; + } + + /** + * Returns the drag directions for the provided ViewHolder. + * Default implementation returns the drag directions that was set via constructor or + * {@link #setDefaultDragDirs(int)}. + * + * @param recyclerView The RecyclerView to which the ItemTouchHelper is attached to. + * @param viewHolder The ViewHolder for which the swipe direction is queried. + * @return A binary OR of direction flags. + */ + @SuppressWarnings("WeakerAccess") + public int getDragDirs(@SuppressWarnings("unused") @NonNull RecyclerView recyclerView, + @SuppressWarnings("unused") @NonNull ViewHolder viewHolder) { + return mDefaultDragDirs; + } + + @Override + public int getMovementFlags(@NonNull RecyclerView recyclerView, + @NonNull ViewHolder viewHolder) { + return makeMovementFlags(getDragDirs(recyclerView, viewHolder), + getSwipeDirs(recyclerView, viewHolder)); + } + } + + private class ItemTouchHelperGestureListener extends GestureDetector.SimpleOnGestureListener { + + /** + * Whether to execute code in response to the the invoking of + * {@link ItemTouchHelperGestureListener#onLongPress(MotionEvent)}. + * + * It is necessary to control this here because + * {@link GestureDetector.SimpleOnGestureListener} can only be set on a + * {@link GestureDetector} in a GestureDetector's constructor, a GestureDetector will call + * onLongPress if an {@link MotionEvent#ACTION_DOWN} event is not followed by another event + * that would cancel it (like {@link MotionEvent#ACTION_UP} or + * {@link MotionEvent#ACTION_CANCEL}), the long press responding to the long press event + * needs to be cancellable to prevent unexpected behavior. + * + * @see #doNotReactToLongPress() + */ + private boolean mShouldReactToLongPress = true; + + ItemTouchHelperGestureListener() { + } + + /** + * Call to prevent executing code in response to + * {@link ItemTouchHelperGestureListener#onLongPress(MotionEvent)} being called. + */ + void doNotReactToLongPress() { + mShouldReactToLongPress = false; + } + + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public void onLongPress(MotionEvent e) { + if (!mShouldReactToLongPress) { + return; + } + View child = findChildView(e); + if (child != null) { + ViewHolder vh = mRecyclerView.getChildViewHolder(child); + if (vh != null) { + if (!mCallback.hasDragFlag(mRecyclerView, vh)) { + return; + } + int pointerId = e.getPointerId(0); + // Long press is deferred. + // Check w/ active pointer id to avoid selecting after motion + // event is canceled. + if (pointerId == mActivePointerId) { + final int index = e.findPointerIndex(mActivePointerId); + final float x = e.getX(index); + final float y = e.getY(index); + mInitialTouchX = x; + mInitialTouchY = y; + mDx = mDy = 0f; + if (DEBUG) { + Log.d(TAG, + "onlong press: x:" + mInitialTouchX + ",y:" + mInitialTouchY); + } + if (mCallback.isLongPressDragEnabled()) { + select(vh, ACTION_STATE_DRAG); + } + } + } + } + } + } + + @VisibleForTesting + static class RecoverAnimation implements Animator.AnimatorListener { + + final float mStartDx; + + final float mStartDy; + + final float mTargetX; + + final float mTargetY; + + final ViewHolder mViewHolder; + + final int mActionState; + + @VisibleForTesting + final ValueAnimator mValueAnimator; + + final int mAnimationType; + + boolean mIsPendingCleanup; + + float mX; + + float mY; + + // if user starts touching a recovering view, we put it into interaction mode again, + // instantly. + boolean mOverridden = false; + + boolean mEnded = false; + + private float mFraction; + + RecoverAnimation(ViewHolder viewHolder, int animationType, + int actionState, float startDx, float startDy, float targetX, float targetY) { + mActionState = actionState; + mAnimationType = animationType; + mViewHolder = viewHolder; + mStartDx = startDx; + mStartDy = startDy; + mTargetX = targetX; + mTargetY = targetY; + mValueAnimator = ValueAnimator.ofFloat(0f, 1f); + mValueAnimator.addUpdateListener( + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + setFraction(animation.getAnimatedFraction()); + } + }); + mValueAnimator.setTarget(viewHolder.itemView); + mValueAnimator.addListener(this); + setFraction(0f); + } + + public void setDuration(long duration) { + mValueAnimator.setDuration(duration); + } + + public void start() { + mViewHolder.setIsRecyclable(false); + mValueAnimator.start(); + } + + public void cancel() { + mValueAnimator.cancel(); + } + + public void setFraction(float fraction) { + mFraction = fraction; + } + + /** + * We run updates on onDraw method but use the fraction from animator callback. + * This way, we can sync translate x/y values w/ the animators to avoid one-off frames. + */ + public void update() { + if (mStartDx == mTargetX) { + mX = mViewHolder.itemView.getTranslationX(); + } else { + mX = mStartDx + mFraction * (mTargetX - mStartDx); + } + if (mStartDy == mTargetY) { + mY = mViewHolder.itemView.getTranslationY(); + } else { + mY = mStartDy + mFraction * (mTargetY - mStartDy); + } + } + + @Override + public void onAnimationStart(Animator animation) { + + } + + @Override + public void onAnimationEnd(Animator animation) { + if (!mEnded) { + mViewHolder.setIsRecyclable(true); + } + mEnded = true; + } + + @Override + public void onAnimationCancel(Animator animation) { + setFraction(1f); //make sure we recover the view's state. + } + + @Override + public void onAnimationRepeat(Animator animation) { + + } + } +} diff --git a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchUIUtilImpl.java b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchUIUtilImpl.java new file mode 100644 index 0000000000..82c854926c --- /dev/null +++ b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchUIUtilImpl.java @@ -0,0 +1,93 @@ +/* + * Copyright 2018 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 app.k9mail.ui.utils.itemtouchhelper; + +import android.graphics.Canvas; +import android.os.Build; +import android.view.View; + +import androidx.core.view.ViewCompat; +import androidx.recyclerview.R; +import androidx.recyclerview.widget.ItemTouchUIUtil; +import androidx.recyclerview.widget.RecyclerView; + + +/** + * Package private class to keep implementations. Putting them inside ItemTouchUIUtil makes them + * public API, which is not desired in this case. + */ +class ItemTouchUIUtilImpl implements ItemTouchUIUtil { + static final ItemTouchUIUtil INSTANCE = new ItemTouchUIUtilImpl(); + + @Override + public void onDraw(Canvas c, RecyclerView recyclerView, View view, float dX, float dY, + int actionState, boolean isCurrentlyActive) { + if (Build.VERSION.SDK_INT >= 21) { + if (isCurrentlyActive) { + Object originalElevation = view.getTag(R.id.item_touch_helper_previous_elevation); + if (originalElevation == null) { + originalElevation = ViewCompat.getElevation(view); + float newElevation = 1f + findMaxElevation(recyclerView, view); + ViewCompat.setElevation(view, newElevation); + view.setTag(R.id.item_touch_helper_previous_elevation, originalElevation); + } + } + } + + view.setTranslationX(dX); + view.setTranslationY(dY); + } + + private static float findMaxElevation(RecyclerView recyclerView, View itemView) { + final int childCount = recyclerView.getChildCount(); + float max = 0; + for (int i = 0; i < childCount; i++) { + final View child = recyclerView.getChildAt(i); + if (child == itemView) { + continue; + } + final float elevation = ViewCompat.getElevation(child); + if (elevation > max) { + max = elevation; + } + } + return max; + } + + @Override + public void onDrawOver(Canvas c, RecyclerView recyclerView, View view, float dX, float dY, + int actionState, boolean isCurrentlyActive) { + } + + @Override + public void clearView(View view) { + if (Build.VERSION.SDK_INT >= 21) { + final Object tag = view.getTag(R.id.item_touch_helper_previous_elevation); + if (tag instanceof Float) { + ViewCompat.setElevation(view, (Float) tag); + } + view.setTag(R.id.item_touch_helper_previous_elevation, null); + } + + view.setTranslationX(0f); + view.setTranslationY(0f); + } + + @Override + public void onSelected(View view) { + } +} -- GitLab From 944595f905b4814f573a05bbfad984dbb5403cf7 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 3 Nov 2022 16:44:02 +0100 Subject: [PATCH 108/121] Use our own copy of `ItemTouchHelper` --- app/ui/legacy/build.gradle | 1 + .../main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt | 2 +- .../java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/ui/legacy/build.gradle b/app/ui/legacy/build.gradle index e1e2207bca..67f7312be2 100644 --- a/app/ui/legacy/build.gradle +++ b/app/ui/legacy/build.gradle @@ -23,6 +23,7 @@ dependencies { implementation "com.takisoft.preferencex:preferencex-colorpicker:${versions.preferencesFix}" implementation "androidx.recyclerview:recyclerview:${versions.androidxRecyclerView}" implementation project(':ui-utils:LinearLayoutManager') + implementation project(':ui-utils:ItemTouchHelper') implementation "androidx.lifecycle:lifecycle-runtime-ktx:${versions.androidxLifecycle}" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.androidxLifecycle}" implementation "androidx.lifecycle:lifecycle-livedata-ktx:${versions.androidxLifecycle}" diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index 145dce4ab0..a47113eadf 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -15,9 +15,9 @@ import android.widget.Toast import androidx.appcompat.view.ActionMode import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import app.k9mail.ui.utils.itemtouchhelper.ItemTouchHelper import app.k9mail.ui.utils.linearlayoutmanager.LinearLayoutManager import com.fsck.k9.Account import com.fsck.k9.Account.Expunge diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index 21d0e40a89..ed24ec5ac9 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -10,9 +10,9 @@ import android.view.View.MeasureSpec import android.widget.ImageView import android.widget.TextView import androidx.core.graphics.withTranslation -import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder +import app.k9mail.ui.utils.itemtouchhelper.ItemTouchHelper import com.fsck.k9.SwipeAction import com.fsck.k9.ui.R import kotlin.math.abs -- GitLab From f75101dfc12963a57f55854e04b9dd30066edc4f Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 4 Nov 2022 14:09:03 +0100 Subject: [PATCH 109/121] Add support for swipe actions not animating the view all the way off the screen --- .../src/main/java/com/fsck/k9/SwipeAction.kt | 18 +-- .../messagelist/MessageListSwipeCallback.kt | 24 +++- .../itemtouchhelper/ItemTouchHelper.java | 120 ++++++++++++------ 3 files changed, 113 insertions(+), 49 deletions(-) diff --git a/app/core/src/main/java/com/fsck/k9/SwipeAction.kt b/app/core/src/main/java/com/fsck/k9/SwipeAction.kt index 5d4fc94341..8079a87661 100644 --- a/app/core/src/main/java/com/fsck/k9/SwipeAction.kt +++ b/app/core/src/main/java/com/fsck/k9/SwipeAction.kt @@ -1,12 +1,12 @@ package com.fsck.k9 -enum class SwipeAction { - None, - ToggleSelection, - ToggleRead, - ToggleStar, - Archive, - Delete, - Spam, - Move +enum class SwipeAction(val removesItem: Boolean) { + None(removesItem = false), + ToggleSelection(removesItem = false), + ToggleRead(removesItem = false), + ToggleStar(removesItem = false), + Archive(removesItem = true), + Delete(removesItem = true), + Spam(removesItem = true), + Move(removesItem = true) } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index ed24ec5ac9..c9a00b5f35 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -101,7 +101,7 @@ class MessageListSwipeCallback( val viewWidth = view.width val viewHeight = view.height - val isViewAnimatingBack = !isCurrentlyActive && abs(dX).toInt() >= viewWidth + val isViewAnimatingBack = !isCurrentlyActive canvas.withTranslation(x = view.left.toFloat(), y = view.top.toFloat()) { if (isViewAnimatingBack) { @@ -170,6 +170,28 @@ class MessageListSwipeCallback( swipeLayout.draw(this) } + + override fun getMaxSwipeDistance(recyclerView: RecyclerView, direction: Int): Int { + return recyclerView.width / 2 + } + + override fun shouldAnimateOut(direction: Int): Boolean { + return when (direction) { + ItemTouchHelper.RIGHT -> swipeRightAction.removesItem + ItemTouchHelper.LEFT -> swipeLeftAction.removesItem + else -> error("Unsupported direction") + } + } + + override fun getAnimationDuration( + recyclerView: RecyclerView, + animationType: Int, + animateDx: Float, + animateDy: Float + ): Long { + val percentage = abs(animateDx) / recyclerView.width + return (super.getAnimationDuration(recyclerView, animationType, animateDx, animateDy) * percentage).toLong() + } } fun interface SwipeActionSupportProvider { diff --git a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java index 6c4f005c89..9c6efeff29 100644 --- a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java +++ b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java @@ -47,27 +47,10 @@ import java.util.ArrayList; import java.util.List; /** - * This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView. - *

- * It works with a RecyclerView and a Callback class, which configures what type of interactions - * are enabled and also receives events when user performs these actions. - *

- * Depending on which functionality you support, you should override - * {@link Callback#onMove(RecyclerView, ViewHolder, ViewHolder)} and / or - * {@link Callback#onSwiped(ViewHolder, int)}. - *

- * This class is designed to work with any LayoutManager but for certain situations, it can be - * optimized for your custom LayoutManager by extending methods in the - * {@link ItemTouchHelper.Callback} class or implementing {@link ItemTouchHelper.ViewDropHandler} - * interface in your LayoutManager. - *

- * By default, ItemTouchHelper moves the items' translateX/Y properties to reposition them. You can - * customize these behaviors by overriding {@link Callback#onChildDraw(Canvas, RecyclerView, - * ViewHolder, float, float, int, boolean)} - * or {@link Callback#onChildDrawOver(Canvas, RecyclerView, ViewHolder, float, float, int, - * boolean)}. - *

- * Most of the time you only need to override onChildDraw. + * Fork of {@link androidx.recyclerview.widget.ItemTouchHelper} that supports horizontal swipe actions that don't + * remove the list item. In that case item views are not animated all the way off the screen. + *
+ * See {@link Callback#shouldAnimateOut(int)} and {@link Callback#getMaxSwipeDistance(RecyclerView, int)}. */ public class ItemTouchHelper extends RecyclerView.ItemDecoration implements RecyclerView.OnChildAttachStateChangeListener { @@ -106,6 +89,11 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration */ public static final int END = RIGHT << 2; + /** + * Flag that indicates a swipe was performed using a fling. + */ + private static final int FLING = 1 << 20; + /** * ItemTouchHelper is in idle state. At this state, either there is no related motion event by * the user or latest motion events have not yet triggered a swipe or drag. @@ -562,11 +550,26 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration getSelectedDxDy(mTmpPosition); dx = mTmpPosition[0]; dy = mTmpPosition[1]; + + if ((mSelectedFlags & (LEFT | RIGHT)) != 0 && dx != 0) { + dx = limitDeltaX(parent, dx); + } } + mCallback.onDraw(c, parent, mSelected, mRecoverAnimations, mActionState, dx, dy); } + private float limitDeltaX(RecyclerView recyclerView, float deltaX) { + int swipeDirection = deltaX > 0 ? RIGHT : LEFT; + if (!mCallback.shouldAnimateOut(swipeDirection)) { + int maxWidth = mCallback.getMaxSwipeDistance(recyclerView, swipeDirection); + deltaX = Math.abs(deltaX) > maxWidth ? Math.signum(deltaX) * maxWidth : deltaX; + } + + return deltaX; + } + /** * Starts dragging or swiping the given View. Call with null if you want to clear it. * @@ -602,9 +605,16 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration if (mSelected != null) { final ViewHolder prevSelected = mSelected; if (prevSelected.itemView.getParent() != null) { - final int swipeDir = prevActionState == ACTION_STATE_DRAG ? 0 + final int swipeFlags = prevActionState == ACTION_STATE_DRAG ? 0 : swipeIfNecessary(prevSelected); + final boolean wasFling = (swipeFlags & FLING) != 0; + final int swipeDir = swipeFlags & ~FLING; releaseVelocityTracker(); + + getSelectedDxDy(mTmpPosition); + final float currentTranslateX = limitDeltaX(mRecyclerView, mTmpPosition[0]); + final float currentTranslateY = mTmpPosition[1]; + // find where we should animate to final float targetTranslateX, targetTranslateY; int animationType; @@ -613,8 +623,15 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration case RIGHT: case START: case END: + if (mCallback.shouldAnimateOut(swipeDir)) { + targetTranslateX = Math.signum(mDx) * mRecyclerView.getWidth(); + } else if (wasFling) { + int maxSwipeDistance = mCallback.getMaxSwipeDistance(mRecyclerView, swipeDir); + targetTranslateX = Math.signum(mDx) * maxSwipeDistance; + } else { + targetTranslateX = currentTranslateX; + } targetTranslateY = 0; - targetTranslateX = Math.signum(mDx) * mRecyclerView.getWidth(); break; case UP: case DOWN: @@ -632,9 +649,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration } else { animationType = ANIMATION_TYPE_SWIPE_CANCEL; } - getSelectedDxDy(mTmpPosition); - final float currentTranslateX = mTmpPosition[0]; - final float currentTranslateY = mTmpPosition[1]; + final RecoverAnimation rv = new RecoverAnimation(prevSelected, animationType, prevActionState, currentTranslateX, currentTranslateY, targetTranslateX, targetTranslateY) { @@ -1208,32 +1223,36 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration } final int originalFlags = (originalMovementFlags & ACTION_MODE_SWIPE_MASK) >> (ACTION_STATE_SWIPE * DIRECTION_FLAG_COUNT); - int swipeDir; + int swipeFlags; if (Math.abs(mDx) > Math.abs(mDy)) { - if ((swipeDir = checkHorizontalSwipe(viewHolder, flags)) > 0) { + if ((swipeFlags = checkHorizontalSwipe(viewHolder, flags)) > 0) { + int fling = swipeFlags & FLING; + int swipeDir = swipeFlags & ~FLING; // if swipe dir is not in original flags, it should be the relative direction if ((originalFlags & swipeDir) == 0) { // convert to relative return Callback.convertToRelativeDirection(swipeDir, - ViewCompat.getLayoutDirection(mRecyclerView)); + ViewCompat.getLayoutDirection(mRecyclerView)) | fling; } - return swipeDir; + return swipeFlags; } - if ((swipeDir = checkVerticalSwipe(viewHolder, flags)) > 0) { - return swipeDir; + if ((swipeFlags = checkVerticalSwipe(viewHolder, flags)) > 0) { + return swipeFlags; } } else { - if ((swipeDir = checkVerticalSwipe(viewHolder, flags)) > 0) { - return swipeDir; + if ((swipeFlags = checkVerticalSwipe(viewHolder, flags)) > 0) { + return swipeFlags; } - if ((swipeDir = checkHorizontalSwipe(viewHolder, flags)) > 0) { + if ((swipeFlags = checkHorizontalSwipe(viewHolder, flags)) > 0) { + int fling = swipeFlags & FLING; + int swipeDir = swipeFlags & ~FLING; // if swipe dir is not in original flags, it should be the relative direction if ((originalFlags & swipeDir) == 0) { // convert to relative return Callback.convertToRelativeDirection(swipeDir, - ViewCompat.getLayoutDirection(mRecyclerView)); + ViewCompat.getLayoutDirection(mRecyclerView)) | fling; } - return swipeDir; + return swipeFlags; } } return 0; @@ -1252,7 +1271,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration if ((velDirFlag & flags) != 0 && dirFlag == velDirFlag && absXVelocity >= mCallback.getSwipeEscapeVelocity(mSwipeEscapeVelocity) && absXVelocity > Math.abs(yVelocity)) { - return velDirFlag; + return velDirFlag | FLING; } } @@ -1987,7 +2006,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration anim.update(); final int count = c.save(); onChildDraw(c, parent, anim.mViewHolder, anim.mX, anim.mY, anim.mActionState, - false); + anim.mAnimationType == ANIMATION_TYPE_SWIPE_SUCCESS && !anim.mIsPendingCleanup); c.restoreToCount(count); } if (selected != null) { @@ -2189,6 +2208,29 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration } return value; } + + /** + * Called to find out whether or not views should be animated out when the swipe action was successfully + * triggered. + * + * @param direction The swipe direction. + * @return {@code true} if the swipe action removes the item from the list. {@code false} otherwise. + */ + public boolean shouldAnimateOut(int direction) { + return true; + } + + /** + * Called to find out how far a view can be moved during a swipe when the swipe action doesn't remove the item + * from the list. See {@link #shouldAnimateOut(int)}. + * + * @param recyclerView The RecyclerView instance to which ItemTouchHelper is attached to. + * @param direction The swipe direction. + * @return The maximum distance in pixels that a view can be moved during a swipe. + */ + public int getMaxSwipeDistance(RecyclerView recyclerView, int direction) { + return recyclerView.getWidth(); + } } /** -- GitLab From ebb54c26cdde56ae3d543a850c1c5a6b6b8bdb4e Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 4 Nov 2022 15:11:06 +0100 Subject: [PATCH 110/121] Use width of swipe action text to calculate maximum swipe distance --- .../ui/messagelist/MessageListSwipeCallback.kt | 16 +++++++++++++++- .../src/main/res/layout/swipe_left_action.xml | 5 +++-- .../src/main/res/layout/swipe_right_action.xml | 3 ++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index c9a00b5f35..f9f00fd9b7 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -27,12 +27,16 @@ class MessageListSwipeCallback( private val adapter: MessageListAdapter, private val listener: MessageListSwipeListener ) : ItemTouchHelper.Callback() { + private val swipePadding = context.resources.getDimension(R.dimen.messageListSwipeIconPadding).toInt() private val swipeThreshold = context.resources.getDimension(R.dimen.messageListSwipeThreshold) private val backgroundColorPaint = Paint() private val swipeRightLayout: View private val swipeLeftLayout: View + private var maxSwipeRightDistance: Int = -1 + private var maxSwipeLeftDistance: Int = -1 + init { val layoutInflater = LayoutInflater.from(context) @@ -166,13 +170,23 @@ class MessageListSwipeCallback( val heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) swipeLayout.measure(widthMeasureSpec, heightMeasureSpec) swipeLayout.layout(0, 0, width, height) + + if (swipeRight) { + maxSwipeRightDistance = textView.right + swipePadding + } else { + maxSwipeLeftDistance = swipeLayout.width - textView.left + swipePadding + } } swipeLayout.draw(this) } override fun getMaxSwipeDistance(recyclerView: RecyclerView, direction: Int): Int { - return recyclerView.width / 2 + return when (direction) { + ItemTouchHelper.RIGHT -> if (maxSwipeRightDistance > 0) maxSwipeRightDistance else recyclerView.width + ItemTouchHelper.LEFT -> if (maxSwipeLeftDistance > 0) maxSwipeLeftDistance else recyclerView.width + else -> recyclerView.width + } } override fun shouldAnimateOut(direction: Int): Boolean { diff --git a/app/ui/legacy/src/main/res/layout/swipe_left_action.xml b/app/ui/legacy/src/main/res/layout/swipe_left_action.xml index 26c786abf1..93400abc45 100644 --- a/app/ui/legacy/src/main/res/layout/swipe_left_action.xml +++ b/app/ui/legacy/src/main/res/layout/swipe_left_action.xml @@ -26,7 +26,7 @@ Date: Mon, 7 Nov 2022 14:24:56 +0100 Subject: [PATCH 111/121] Avoid crash when one of the swipe actions is "None" --- .../k9/ui/messagelist/MessageListSwipeCallback.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index f9f00fd9b7..a94bb7ad1e 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -107,12 +107,14 @@ class MessageListSwipeCallback( val isViewAnimatingBack = !isCurrentlyActive - canvas.withTranslation(x = view.left.toFloat(), y = view.top.toFloat()) { - if (isViewAnimatingBack) { - drawBackground(dX, viewWidth, viewHeight) - } else { - val holder = viewHolder as MessageViewHolder - drawLayout(dX, viewWidth, viewHeight, holder) + if (dX != 0F) { + canvas.withTranslation(x = view.left.toFloat(), y = view.top.toFloat()) { + if (isViewAnimatingBack) { + drawBackground(dX, viewWidth, viewHeight) + } else { + val holder = viewHolder as MessageViewHolder + drawLayout(dX, viewWidth, viewHeight, holder) + } } } -- GitLab From 273d0b433d7814ae45792b9629d05255e84bd6d0 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 10 Nov 2022 20:29:26 +0100 Subject: [PATCH 112/121] Handle animating a swiped view back to its start position inside `ItemTouchHelper` --- .../ui/messagelist/MessageListItemAnimator.kt | 20 +- .../messagelist/MessageListSwipeCallback.kt | 16 +- .../itemtouchhelper/ItemTouchHelper.java | 184 ++++++++++++------ 3 files changed, 150 insertions(+), 70 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemAnimator.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemAnimator.kt index ad9ec900cd..af65ac4a79 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemAnimator.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListItemAnimator.kt @@ -9,9 +9,21 @@ class MessageListItemAnimator : DefaultItemAnimator() { changeDuration = 120 } - override fun canReuseUpdatedViewHolder(viewHolder: ViewHolder, payloads: MutableList): Boolean { - // ItemTouchHelper expects swiped views to be removed from the view hierarchy. So we don't reuse views that are - // marked as having been swiped. - return !viewHolder.wasSwiped && super.canReuseUpdatedViewHolder(viewHolder, payloads) + override fun animateChange( + oldHolder: ViewHolder, + newHolder: ViewHolder, + fromX: Int, + fromY: Int, + toX: Int, + toY: Int + ): Boolean { + if (oldHolder == newHolder && newHolder.wasSwiped) { + // Don't touch views currently being swiped + dispatchChangeFinished(oldHolder, true) + dispatchChangeFinished(newHolder, false) + return false + } + + return super.animateChange(oldHolder, newHolder, fromX, fromY, toX, toY) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index a94bb7ad1e..7ec398a4fd 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -72,8 +72,7 @@ class MessageListSwipeCallback( val holder = viewHolder as MessageViewHolder val item = adapter.getItemById(holder.uniqueId) ?: error("Couldn't find MessageListItem") - // ItemTouchHelper expects swiped views to be removed from the view hierarchy. We mark this ViewHolder so that - // MessageListItemAnimator knows not to reuse it during an animation. + // Mark view to prevent MessageListItemAnimator from interfering with swipe animations viewHolder.markAsSwiped(true) when (direction) { @@ -99,26 +98,25 @@ class MessageListSwipeCallback( dX: Float, dY: Float, actionState: Int, - isCurrentlyActive: Boolean + isCurrentlyActive: Boolean, + success: Boolean ) { val view = viewHolder.itemView val viewWidth = view.width val viewHeight = view.height - val isViewAnimatingBack = !isCurrentlyActive - if (dX != 0F) { canvas.withTranslation(x = view.left.toFloat(), y = view.top.toFloat()) { - if (isViewAnimatingBack) { - drawBackground(dX, viewWidth, viewHeight) - } else { + if (isCurrentlyActive || !success) { val holder = viewHolder as MessageViewHolder drawLayout(dX, viewWidth, viewHeight, holder) + } else { + drawBackground(dX, viewWidth, viewHeight) } } } - super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive, success) } private fun Canvas.drawBackground(dX: Float, width: Int, height: Int) { diff --git a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java index 9c6efeff29..bffd573c42 100644 --- a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java +++ b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java @@ -615,75 +615,68 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration final float currentTranslateX = limitDeltaX(mRecyclerView, mTmpPosition[0]); final float currentTranslateY = mTmpPosition[1]; - // find where we should animate to - final float targetTranslateX, targetTranslateY; - int animationType; + final int animationType; + if (prevActionState == ACTION_STATE_DRAG) { + animationType = ANIMATION_TYPE_DRAG; + } else if (swipeDir > 0) { + animationType = ANIMATION_TYPE_SWIPE_SUCCESS; + } else { + animationType = ANIMATION_TYPE_SWIPE_CANCEL; + } + + final RecoverAnimation animation; + final boolean useDefaultDuration; switch (swipeDir) { case LEFT: case RIGHT: case START: case END: if (mCallback.shouldAnimateOut(swipeDir)) { - targetTranslateX = Math.signum(mDx) * mRecyclerView.getWidth(); + float targetTranslateX = Math.signum(mDx) * mRecyclerView.getWidth(); + + animation = new MoveOutAnimation(prevSelected, animationType, prevActionState, + currentTranslateX, currentTranslateY, targetTranslateX, currentTranslateY, swipeDir, + /* moveBackAfterwards */ false); + + useDefaultDuration = true; } else if (wasFling) { int maxSwipeDistance = mCallback.getMaxSwipeDistance(mRecyclerView, swipeDir); - targetTranslateX = Math.signum(mDx) * maxSwipeDistance; + float targetTranslateX = Math.signum(mDx) * maxSwipeDistance; + + animation = new MoveOutAnimation(prevSelected, animationType, prevActionState, + currentTranslateX, currentTranslateY, targetTranslateX, currentTranslateY, swipeDir, + /* moveBackAfterwards */ true); + + useDefaultDuration = true; } else { - targetTranslateX = currentTranslateX; + // This is a dummy animation to ensure mCallback.onChildDraw() calls will be made even if + // the animating back part is delayed. + animation = new MoveOutAnimation(prevSelected, animationType, prevActionState, + currentTranslateX, currentTranslateY, currentTranslateX, currentTranslateY, + swipeDir, /* moveBackAfterwards */ true); + + animation.setDuration(0); + useDefaultDuration = false; } - targetTranslateY = 0; break; case UP: case DOWN: - targetTranslateX = 0; - targetTranslateY = Math.signum(mDy) * mRecyclerView.getHeight(); - break; + throw new UnsupportedOperationException(); default: - targetTranslateX = 0; - targetTranslateY = 0; + animation = new MoveBackAnimation(prevSelected, animationType, prevActionState, + currentTranslateX, currentTranslateY); + useDefaultDuration = true; } - if (prevActionState == ACTION_STATE_DRAG) { - animationType = ANIMATION_TYPE_DRAG; - } else if (swipeDir > 0) { - animationType = ANIMATION_TYPE_SWIPE_SUCCESS; - } else { - animationType = ANIMATION_TYPE_SWIPE_CANCEL; + + if (useDefaultDuration) { + long duration = mCallback.getAnimationDuration(mRecyclerView, animationType, + animation.mTargetX - animation.mStartDx, animation.mTargetY - animation.mStartDy); + animation.setDuration(duration); } - final RecoverAnimation rv = new RecoverAnimation(prevSelected, animationType, - prevActionState, currentTranslateX, currentTranslateY, - targetTranslateX, targetTranslateY) { - @Override - public void onAnimationEnd(Animator animation) { - super.onAnimationEnd(animation); - if (this.mOverridden) { - return; - } - if (swipeDir <= 0) { - // this is a drag or failed swipe. recover immediately - mCallback.clearView(mRecyclerView, prevSelected); - // full cleanup will happen on onDrawOver - } else { - // wait until remove animation is complete. - mPendingCleanup.add(prevSelected.itemView); - mIsPendingCleanup = true; - if (swipeDir > 0) { - // Animation might be ended by other animators during a layout. - // We defer callback to avoid editing adapter during a layout. - postDispatchSwipe(this, swipeDir); - } - } - // removed from the list after it is drawn for the last time - if (mOverdrawChild == prevSelected.itemView) { - removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView); - } - } - }; - final long duration = mCallback.getAnimationDuration(mRecyclerView, animationType, - targetTranslateX - currentTranslateX, targetTranslateY - currentTranslateY); - rv.setDuration(duration); - mRecoverAnimations.add(rv); - rv.start(); + mRecoverAnimations.add(animation); + animation.start(); + preventLayout = true; } else { removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView); @@ -715,7 +708,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration } @SuppressWarnings("WeakerAccess") /* synthetic access */ - void postDispatchSwipe(final RecoverAnimation anim, final int swipeDir) { + void postDispatchSwipe(final RecoverAnimation anim, final int swipeDir, final boolean moveBackAfterwards) { // wait until animations are complete. mRecyclerView.post(new Runnable() { @Override @@ -731,6 +724,9 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration if ((animator == null || !animator.isRunning(null)) && !hasRunningRecoverAnim()) { mCallback.onSwiped(anim.mViewHolder, swipeDir); + if (moveBackAfterwards) { + startMoveBackAnimation(anim); + } } else { mRecyclerView.post(this); } @@ -739,6 +735,20 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration }); } + private void startMoveBackAnimation(RecoverAnimation animation) { + MoveBackAnimation moveBackAnimation = new MoveBackAnimation(animation.mViewHolder, animation.mAnimationType, + animation.mActionState, animation.mTargetX, animation.mTargetY); + + long duration = mCallback.getAnimationDuration(mRecyclerView, animation.mAnimationType, + -animation.mTargetX, -animation.mTargetY); + moveBackAnimation.setDuration(duration); + + mRecoverAnimations.remove(animation); + mRecoverAnimations.add(moveBackAnimation); + + moveBackAnimation.start(); + } + @SuppressWarnings("WeakerAccess") /* synthetic access */ boolean hasRunningRecoverAnim() { final int size = mRecoverAnimations.size(); @@ -2005,13 +2015,15 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration final ItemTouchHelper.RecoverAnimation anim = recoverAnimationList.get(i); anim.update(); final int count = c.save(); + boolean isCurrentlyActive = anim instanceof MoveOutAnimation; + boolean success = anim.mAnimationType == ANIMATION_TYPE_SWIPE_SUCCESS; onChildDraw(c, parent, anim.mViewHolder, anim.mX, anim.mY, anim.mActionState, - anim.mAnimationType == ANIMATION_TYPE_SWIPE_SUCCESS && !anim.mIsPendingCleanup); + isCurrentlyActive, success); c.restoreToCount(count); } if (selected != null) { final int count = c.save(); - onChildDraw(c, parent, selected, dX, dY, actionState, true); + onChildDraw(c, parent, selected, dX, dY, actionState, true, false); c.restoreToCount(count); } } @@ -2053,7 +2065,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration * This is a good place to clear all changes on the View that was done in * {@link #onSelectedChanged(RecyclerView.ViewHolder, int)}, * {@link #onChildDraw(Canvas, RecyclerView, ViewHolder, float, float, int, - * boolean)} or + * boolean, boolean)} or * {@link #onChildDrawOver(Canvas, RecyclerView, ViewHolder, float, float, int, boolean)}. * * @param recyclerView The RecyclerView which is controlled by the ItemTouchHelper. @@ -2092,7 +2104,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration */ public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView, @NonNull ViewHolder viewHolder, - float dX, float dY, int actionState, boolean isCurrentlyActive) { + float dX, float dY, int actionState, boolean isCurrentlyActive, boolean success) { ItemTouchUIUtilImpl.INSTANCE.onDraw(c, recyclerView, viewHolder.itemView, dX, dY, actionState, isCurrentlyActive); } @@ -2526,4 +2538,62 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration } } + + private class MoveBackAnimation extends RecoverAnimation { + MoveBackAnimation(ViewHolder viewHolder, int animationType, int actionState, float startDx, float startDy) { + super(viewHolder, animationType, actionState, startDx, startDy, /* targetX */ 0, /* targetY */ 0); + } + + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + if (this.mOverridden) { + return; + } + + mCallback.clearView(mRecyclerView, mViewHolder); + // full cleanup will happen on onDrawOver + + // removed from the list after it is drawn for the last time + if (mOverdrawChild == mViewHolder.itemView) { + removeChildDrawingOrderCallbackIfNecessary(mViewHolder.itemView); + } + } + } + + private class MoveOutAnimation extends RecoverAnimation { + private final int mSwipeDirection; + private final boolean mMoveBackAfterwards; + + MoveOutAnimation(ViewHolder viewHolder, int animationType, int actionState, float startDx, float startDy, + float targetX, float targetY, int swipeDirection, boolean moveBackAfterwards) { + super(viewHolder, animationType, actionState, startDx, startDy, targetX, targetY); + this.mSwipeDirection = swipeDirection; + this.mMoveBackAfterwards = moveBackAfterwards; + } + + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + if (this.mOverridden) { + return; + } + + if (!mMoveBackAfterwards) { + mPendingCleanup.add(mViewHolder.itemView); + } + mIsPendingCleanup = true; + + // Animation might be ended by other animators during a layout. + // We defer callback to avoid editing adapter during a layout. + postDispatchSwipe(this, mSwipeDirection, mMoveBackAfterwards); + + if (!mMoveBackAfterwards) { + // removed from the list after it is drawn for the last time + if (mOverdrawChild == mViewHolder.itemView) { + removeChildDrawingOrderCallbackIfNecessary(mViewHolder.itemView); + } + } + } + } } -- GitLab From 789fbe4d4349661ce68b7f2c22e9ceac00b1afa6 Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 11 Nov 2022 14:00:11 +0100 Subject: [PATCH 113/121] Deselect message during swipe When swiping a selected message we remove the selection state at the start and restore it afterwards if the list item isn't removed. Except when the swipe action is "toggle selection". Then we keep the current selection state while the list item is dragged. --- .../k9/ui/messagelist/MessageListAdapter.kt | 4 +- .../k9/ui/messagelist/MessageListFragment.kt | 94 ++++++++++++++----- .../messagelist/MessageListSwipeCallback.kt | 36 ++++++- .../itemtouchhelper/ItemTouchHelper.java | 33 ++++++- 4 files changed, 138 insertions(+), 29 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt index 3a892bc586..b58281a031 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt @@ -480,11 +480,11 @@ class MessageListAdapter internal constructor( } } - private fun selectMessage(item: MessageListItem) { + fun selectMessage(item: MessageListItem) { selected = selected + item.uniqueId } - private fun deselectMessage(item: MessageListItem) { + fun deselectMessage(item: MessageListItem) { selected = selected - item.uniqueId } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index a47113eadf..f66cd1c51f 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -810,7 +810,24 @@ class MessageListFragment : private fun toggleMessageSelect(messageListItem: MessageListItem) { adapter.toggleSelection(messageListItem) + updateAfterSelectionChange() + } + + private fun selectMessage(messageListItem: MessageListItem) { + adapter.selectMessage(messageListItem) + updateAfterSelectionChange() + } + + private fun deselectMessage(messageListItem: MessageListItem) { + adapter.deselectMessage(messageListItem) + updateAfterSelectionChange() + } + + private fun isMessageSelected(messageListItem: MessageListItem): Boolean { + return adapter.isSelected(messageListItem) + } + private fun updateAfterSelectionChange() { if (adapter.selectedCount == 0) { actionMode?.finish() actionMode = null @@ -1434,36 +1451,67 @@ class MessageListFragment : private val isPullToRefreshAllowed: Boolean get() = isRemoteSearchAllowed || isCheckMailAllowed - private val swipeListener = MessageListSwipeListener { item, action -> - when (action) { - SwipeAction.None -> Unit - SwipeAction.ToggleSelection -> { - toggleMessageSelect(item) - } - SwipeAction.ToggleRead -> { - setFlag(item, Flag.SEEN, !item.isRead) + private var itemSelectedOnSwipeStart = false + + private val swipeListener = object : MessageListSwipeListener { + override fun onSwipeStarted(item: MessageListItem, action: SwipeAction) { + itemSelectedOnSwipeStart = isMessageSelected(item) + if (itemSelectedOnSwipeStart && action != SwipeAction.ToggleSelection) { + deselectMessage(item) } - SwipeAction.ToggleStar -> { - setFlag(item, Flag.FLAGGED, !item.isStarred) + } + + override fun onSwipeActionChanged(item: MessageListItem, action: SwipeAction) { + if (action == SwipeAction.ToggleSelection) { + if (itemSelectedOnSwipeStart && !isMessageSelected(item)) { + selectMessage(item) + } + } else if (isMessageSelected(item)) { + deselectMessage(item) } - SwipeAction.Archive -> { - onArchive(item.messageReference) + } + + override fun onSwipeAction(item: MessageListItem, action: SwipeAction) { + if (action.removesItem || action == SwipeAction.ToggleSelection) { + itemSelectedOnSwipeStart = false } - SwipeAction.Delete -> { - if (K9.isConfirmDelete) { - notifyItemChanged(item) + + when (action) { + SwipeAction.None -> Unit + SwipeAction.ToggleSelection -> { + toggleMessageSelect(item) } - onDelete(listOf(item.messageReference)) - } - SwipeAction.Spam -> { - if (K9.isConfirmSpam) { + SwipeAction.ToggleRead -> { + setFlag(item, Flag.SEEN, !item.isRead) + } + SwipeAction.ToggleStar -> { + setFlag(item, Flag.FLAGGED, !item.isStarred) + } + SwipeAction.Archive -> { + onArchive(item.messageReference) + } + SwipeAction.Delete -> { + if (K9.isConfirmDelete) { + notifyItemChanged(item) + } + onDelete(listOf(item.messageReference)) + } + SwipeAction.Spam -> { + if (K9.isConfirmSpam) { + notifyItemChanged(item) + } + onSpam(listOf(item.messageReference)) + } + SwipeAction.Move -> { notifyItemChanged(item) + onMove(item.messageReference) } - onSpam(listOf(item.messageReference)) } - SwipeAction.Move -> { - notifyItemChanged(item) - onMove(item.messageReference) + } + + override fun onSwipeEnded(item: MessageListItem) { + if (itemSelectedOnSwipeStart && !isMessageSelected(item)) { + selectMessage(item) } } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index 7ec398a4fd..fb3dbec149 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -68,9 +68,28 @@ class MessageListSwipeCallback( throw UnsupportedOperationException("not implemented") } + override fun onSwipeStarted(viewHolder: ViewHolder, direction: Int) { + val swipeAction = when (direction) { + ItemTouchHelper.RIGHT -> swipeRightAction + ItemTouchHelper.LEFT -> swipeLeftAction + else -> error("Unsupported direction: $direction") + } + + listener.onSwipeStarted(viewHolder.messageListItem, swipeAction) + } + + override fun onSwipeDirectionChanged(viewHolder: ViewHolder, direction: Int) { + val swipeAction = when (direction) { + ItemTouchHelper.RIGHT -> swipeRightAction + ItemTouchHelper.LEFT -> swipeLeftAction + else -> error("Unsupported direction: $direction") + } + + listener.onSwipeActionChanged(viewHolder.messageListItem, swipeAction) + } + override fun onSwiped(viewHolder: ViewHolder, direction: Int) { - val holder = viewHolder as MessageViewHolder - val item = adapter.getItemById(holder.uniqueId) ?: error("Couldn't find MessageListItem") + val item = viewHolder.messageListItem // Mark view to prevent MessageListItemAnimator from interfering with swipe animations viewHolder.markAsSwiped(true) @@ -82,6 +101,10 @@ class MessageListSwipeCallback( } } + override fun onSwipeEnded(viewHolder: ViewHolder) { + listener.onSwipeEnded(viewHolder.messageListItem) + } + override fun clearView(recyclerView: RecyclerView, viewHolder: ViewHolder) { super.clearView(recyclerView, viewHolder) viewHolder.markAsSwiped(false) @@ -206,14 +229,21 @@ class MessageListSwipeCallback( val percentage = abs(animateDx) / recyclerView.width return (super.getAnimationDuration(recyclerView, animationType, animateDx, animateDy) * percentage).toLong() } + + private val ViewHolder.messageListItem: MessageListItem + get() = (this as? MessageViewHolder)?.uniqueId?.let { adapter.getItemById(it) } + ?: error("Couldn't find MessageListItem") } fun interface SwipeActionSupportProvider { fun isActionSupported(item: MessageListItem, action: SwipeAction): Boolean } -fun interface MessageListSwipeListener { +interface MessageListSwipeListener { + fun onSwipeStarted(item: MessageListItem, action: SwipeAction) + fun onSwipeActionChanged(item: MessageListItem, action: SwipeAction) fun onSwipeAction(item: MessageListItem, action: SwipeAction) + fun onSwipeEnded(item: MessageListItem) } private fun ViewHolder.markAsSwiped(value: Boolean) { diff --git a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java index bffd573c42..7e3115d70a 100644 --- a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java +++ b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java @@ -193,6 +193,11 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration float mDy; + /** + * Current swipe direction. Used for {@link Callback#onSwipeDirectionChanged(ViewHolder, int)} + */ + private int mSwipeDirection = 0; + /** * The coordinates of the selected view at the time it is selected. We record these values * when action starts so that we can consistently position it even if LayoutManager moves the @@ -554,6 +559,14 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration if ((mSelectedFlags & (LEFT | RIGHT)) != 0 && dx != 0) { dx = limitDeltaX(parent, dx); } + + if (dx != 0) { + int direction = dx > 0 ? RIGHT : LEFT; + if (direction != mSwipeDirection) { + mSwipeDirection = direction; + mCallback.onSwipeDirectionChanged(mSelected, direction); + } + } } mCallback.onDraw(c, parent, mSelected, @@ -582,6 +595,7 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration if (selected == mSelected && actionState == mActionState) { return; } + mDragScrollStartTimeInMs = Long.MIN_VALUE; final int prevActionState = mActionState; // prevent duplicate animations @@ -726,6 +740,8 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration mCallback.onSwiped(anim.mViewHolder, swipeDir); if (moveBackAfterwards) { startMoveBackAnimation(anim); + } else { + mCallback.onSwipeEnded(anim.mViewHolder); } } else { mRecyclerView.post(this); @@ -1061,6 +1077,10 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration } mDx = mDy = 0f; mActivePointerId = motionEvent.getPointerId(0); + + mSwipeDirection = dx > 0 ? RIGHT : LEFT; + mCallback.onSwipeStarted(vh, mSwipeDirection); + select(vh, ACTION_STATE_SWIPE); } @@ -2240,9 +2260,18 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration * @param direction The swipe direction. * @return The maximum distance in pixels that a view can be moved during a swipe. */ - public int getMaxSwipeDistance(RecyclerView recyclerView, int direction) { + public int getMaxSwipeDistance(@NonNull RecyclerView recyclerView, int direction) { return recyclerView.getWidth(); } + + public void onSwipeStarted(@NonNull ViewHolder viewHolder, int direction) { + } + + public void onSwipeDirectionChanged(@NonNull ViewHolder viewHolder, int direction) { + } + + public void onSwipeEnded(@NonNull ViewHolder viewHolder) { + } } /** @@ -2551,6 +2580,8 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration return; } + mCallback.onSwipeEnded(mViewHolder); + mCallback.clearView(mRecyclerView, mViewHolder); // full cleanup will happen on onDrawOver -- GitLab From 30a2126fcba7d3d7bc0d524333d12e918d0e0d68 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 14 Nov 2022 13:28:31 +0100 Subject: [PATCH 114/121] Mark view when swipe starts so ItemAnimator doesn't interfere --- .../com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt index fb3dbec149..6077ccff1d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListSwipeCallback.kt @@ -69,6 +69,9 @@ class MessageListSwipeCallback( } override fun onSwipeStarted(viewHolder: ViewHolder, direction: Int) { + // Mark view to prevent MessageListItemAnimator from interfering with swipe animations + viewHolder.markAsSwiped(true) + val swipeAction = when (direction) { ItemTouchHelper.RIGHT -> swipeRightAction ItemTouchHelper.LEFT -> swipeLeftAction @@ -91,9 +94,6 @@ class MessageListSwipeCallback( override fun onSwiped(viewHolder: ViewHolder, direction: Int) { val item = viewHolder.messageListItem - // Mark view to prevent MessageListItemAnimator from interfering with swipe animations - viewHolder.markAsSwiped(true) - when (direction) { ItemTouchHelper.RIGHT -> listener.onSwipeAction(item, swipeRightAction) ItemTouchHelper.LEFT -> listener.onSwipeAction(item, swipeLeftAction) -- GitLab From e17459f2105a36779d94555c1811e9ddb7cabd1c Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 14 Nov 2022 14:05:35 +0100 Subject: [PATCH 115/121] Reset swiped views when canceling a swipe action in the confirmation dialog --- .../k9/ui/messagelist/MessageListFragment.kt | 30 ++++++++++++++----- .../itemtouchhelper/ItemTouchHelper.java | 8 +++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index f66cd1c51f..9f3c54ebf5 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -76,6 +76,7 @@ class MessageListFragment : private lateinit var fragmentListener: MessageListFragmentListener private var recyclerView: RecyclerView? = null + private var itemTouchHelper: ItemTouchHelper? = null private var swipeRefreshLayout: SwipeRefreshLayout? = null private lateinit var adapter: MessageListAdapter @@ -278,6 +279,7 @@ class MessageListFragment : recyclerView.adapter = adapter this.recyclerView = recyclerView + this.itemTouchHelper = itemTouchHelper } private fun initializeSortSettings() { @@ -421,6 +423,7 @@ class MessageListFragment : override fun onDestroyView() { recyclerView = null + itemTouchHelper = null swipeRefreshLayout = null if (isNewMessagesView && !requireActivity().isChangingConfigurations) { @@ -1108,8 +1111,25 @@ class MessageListFragment : override fun doNegativeClick(dialogId: Int) { if (dialogId == R.id.dialog_confirm_spam || dialogId == R.id.dialog_confirm_delete) { - // No further need for this reference - activeMessages = null + val activeMessages = this.activeMessages ?: return + if (activeMessages.size == 1) { + // List item might have been swiped and is still showing the "swipe action background" + resetSwipedView(activeMessages.first()) + } + + this.activeMessages = null + } + } + + private fun resetSwipedView(messageReference: MessageReference) { + val recyclerView = this.recyclerView ?: return + val itemTouchHelper = this.itemTouchHelper ?: return + + adapter.getItem(messageReference)?.let { messageListItem -> + recyclerView.findViewHolderForItemId(messageListItem.uniqueId)?.let { viewHolder -> + itemTouchHelper.stopSwipe(viewHolder) + notifyItemChanged(messageListItem) + } } } @@ -1491,15 +1511,9 @@ class MessageListFragment : onArchive(item.messageReference) } SwipeAction.Delete -> { - if (K9.isConfirmDelete) { - notifyItemChanged(item) - } onDelete(listOf(item.messageReference)) } SwipeAction.Spam -> { - if (K9.isConfirmSpam) { - notifyItemChanged(item) - } onSpam(listOf(item.messageReference)) } SwipeAction.Move -> { diff --git a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java index 7e3115d70a..7a205b1ff8 100644 --- a/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java +++ b/ui-utils/ItemTouchHelper/src/main/java/app/k9mail/ui/utils/itemtouchhelper/ItemTouchHelper.java @@ -938,6 +938,10 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration @Override public void onChildViewDetachedFromWindow(@NonNull View view) { + resetSwipeView(view); + } + + private void resetSwipeView(@NonNull View view) { removeChildDrawingOrderCallbackIfNecessary(view); final ViewHolder holder = mRecyclerView.getChildViewHolder(view); if (holder == null) { @@ -953,6 +957,10 @@ public class ItemTouchHelper extends RecyclerView.ItemDecoration } } + public void stopSwipe(@NonNull ViewHolder viewHolder) { + resetSwipeView(viewHolder.itemView); + } + /** * Returns the animation type or 0 if cannot be found. */ -- GitLab From 97ec70a631820e7fcc385d754af6029a84c07af7 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 14 Nov 2022 14:24:05 +0100 Subject: [PATCH 116/121] Reset swiped view when moving an item --- .../java/com/fsck/k9/ui/messagelist/MessageListFragment.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index 9f3c54ebf5..b9d9aeaca7 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -1517,8 +1517,9 @@ class MessageListFragment : onSpam(listOf(item.messageReference)) } SwipeAction.Move -> { - notifyItemChanged(item) - onMove(item.messageReference) + val messageReference = item.messageReference + resetSwipedView(messageReference) + onMove(messageReference) } } } -- GitLab From be17b94cf420897d3c71191c488e606db59efd08 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 14 Nov 2022 14:28:52 +0100 Subject: [PATCH 117/121] Don't enable archive swipe action in archive folder --- .../java/com/fsck/k9/ui/messagelist/MessageListFragment.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index b9d9aeaca7..373a0f861f 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -1542,7 +1542,9 @@ class MessageListFragment : SwipeAction.ToggleSelection -> true SwipeAction.ToggleRead -> !isOutbox SwipeAction.ToggleStar -> !isOutbox - SwipeAction.Archive -> !isOutbox && item.account.hasArchiveFolder() + SwipeAction.Archive -> { + !isOutbox && item.account.hasArchiveFolder() && item.folderId != item.account.archiveFolderId + } SwipeAction.Delete -> true SwipeAction.Move -> !isOutbox && messagingController.isMoveCapable(item.account) SwipeAction.Spam -> !isOutbox && item.account.hasSpamFolder() && item.folderId != item.account.spamFolderId -- GitLab From e2672cb0ddb870b6c09b30a5c983093940bac8c5 Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 15 Nov 2022 13:33:49 +0100 Subject: [PATCH 118/121] Update translations --- .../legacy/src/main/res/values-ar/strings.xml | 18 +++ .../legacy/src/main/res/values-be/strings.xml | 18 +++ .../legacy/src/main/res/values-bg/strings.xml | 18 +++ .../legacy/src/main/res/values-br/strings.xml | 18 +++ .../legacy/src/main/res/values-ca/strings.xml | 20 ++++ .../legacy/src/main/res/values-cs/strings.xml | 18 +++ .../legacy/src/main/res/values-cy/strings.xml | 18 +++ .../legacy/src/main/res/values-da/strings.xml | 18 +++ .../legacy/src/main/res/values-de/strings.xml | 24 +++- .../legacy/src/main/res/values-el/strings.xml | 18 +++ .../src/main/res/values-en-rGB/strings.xml | 10 ++ .../legacy/src/main/res/values-eo/strings.xml | 18 +++ .../legacy/src/main/res/values-es/strings.xml | 20 ++++ .../legacy/src/main/res/values-et/strings.xml | 20 ++++ .../legacy/src/main/res/values-eu/strings.xml | 18 +++ .../legacy/src/main/res/values-fa/strings.xml | 17 +++ .../legacy/src/main/res/values-fi/strings.xml | 20 ++++ .../legacy/src/main/res/values-fr/strings.xml | 20 ++++ .../legacy/src/main/res/values-fy/strings.xml | 20 ++++ .../legacy/src/main/res/values-gd/strings.xml | 18 +++ .../src/main/res/values-gl-rES/strings.xml | 18 +++ .../legacy/src/main/res/values-gl/strings.xml | 18 +++ .../legacy/src/main/res/values-hr/strings.xml | 18 +++ .../legacy/src/main/res/values-hu/strings.xml | 18 +++ .../legacy/src/main/res/values-in/strings.xml | 18 +++ .../legacy/src/main/res/values-is/strings.xml | 26 +++++ .../legacy/src/main/res/values-it/strings.xml | 18 +++ .../legacy/src/main/res/values-iw/strings.xml | 18 +++ .../legacy/src/main/res/values-ja/strings.xml | 20 ++++ .../legacy/src/main/res/values-ko/strings.xml | 21 ++++ .../legacy/src/main/res/values-lt/strings.xml | 18 +++ .../legacy/src/main/res/values-lv/strings.xml | 63 ++++++++++ .../legacy/src/main/res/values-ml/strings.xml | 18 +++ .../legacy/src/main/res/values-nb/strings.xml | 18 +++ .../legacy/src/main/res/values-nl/strings.xml | 20 ++++ .../legacy/src/main/res/values-pl/strings.xml | 20 ++++ .../src/main/res/values-pt-rBR/strings.xml | 20 ++++ .../src/main/res/values-pt-rPT/strings.xml | 43 +++++++ .../legacy/src/main/res/values-ro/strings.xml | 26 +++++ .../legacy/src/main/res/values-ru/strings.xml | 108 ++++++++++-------- .../legacy/src/main/res/values-sk/strings.xml | 18 +++ .../legacy/src/main/res/values-sl/strings.xml | 18 +++ .../legacy/src/main/res/values-sq/strings.xml | 37 ++++-- .../legacy/src/main/res/values-sr/strings.xml | 18 +++ .../legacy/src/main/res/values-sv/strings.xml | 20 ++++ .../legacy/src/main/res/values-tr/strings.xml | 26 ++++- .../legacy/src/main/res/values-uk/strings.xml | 18 +++ .../src/main/res/values-zh-rCN/strings.xml | 20 ++++ .../src/main/res/values-zh-rTW/strings.xml | 27 +++++ 49 files changed, 1059 insertions(+), 59 deletions(-) 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 ea33024ac6..8c51cc7d21 100644 --- a/app/ui/legacy/src/main/res/values-ar/strings.xml +++ b/app/ui/legacy/src/main/res/values-ar/strings.xml @@ -776,4 +776,22 @@ مُوقّف لمعرفة المزيد + + حدد + + إلغ التحديد + + + علامة غير مقروء + + أضف نجمة + + احذف النجمة + + أرشيف + + احذف + + بريد مُزعج + 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 eba907b606..e58e2fb064 100644 --- a/app/ui/legacy/src/main/res/values-be/strings.xml +++ b/app/ui/legacy/src/main/res/values-be/strings.xml @@ -998,4 +998,22 @@ K-9 Mail - шматфункцыянальны свабодны паштовы к Дазволіць доступ да кантактаў Для вываду прапаноў імёнаў і фота праграме неабходна мець доступ да кантактаў. Падчас загрузкі даных адбылася памылка + + Абраць + + Зняць выбар + + + Пазначыць як нечытанае + + Адзначыць + + Выдаліць адзнаку + + Архіў + + Выдаліць + + Спам + 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 17e2572fe3..1719d046a6 100644 --- a/app/ui/legacy/src/main/res/values-bg/strings.xml +++ b/app/ui/legacy/src/main/res/values-bg/strings.xml @@ -996,4 +996,22 @@ K-9 Mail е мощен, безплатен имейл клиент за Андр Конфигурирайте известия Ако нямате нужда от мигновени известия, трябва да деактивирате Push и да използвате Polling. Polling проверява за нови писма на регулярни интервали и не се нуждае от известия. Изключи Push + + Избери + + Отмени Избор + + + Маркирай като непрочетено + + Добави звезда + + Премахни звезда + + Архивирайте + + Изтрий + + Спам + 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 6b8e2340a9..854db1fb83 100644 --- a/app/ui/legacy/src/main/res/values-br/strings.xml +++ b/app/ui/legacy/src/main/res/values-br/strings.xml @@ -936,4 +936,22 @@ Gallout a rit mirout ar gemennadenn-mañ hag implij anezhi evel un enrolladenn e Aotren da haeziñ an darempredoù Evit bezañ gouest da ginnig darempredoù hag evit skrammañ anvioù ha skeudennoù an darempredoù en deus ezhomm an arload da haeziñ ho tarempredoù. + + Diuzañ + + Diziuzañ + + + Merkañ evel anlennet + + Ouzhpennañ ur steredenn + + Dilemel ar steredenn + + Dielloù + + Dilemel + + Lastez + 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 9ba2ceae10..a27049a0ae 100644 --- a/app/ui/legacy/src/main/res/values-ca/strings.xml +++ b/app/ui/legacy/src/main/res/values-ca/strings.xml @@ -1065,4 +1065,24 @@ Podeu desar aquest missatge i fer-lo servir com a còpia de seguretat per a la v Configureu la notificació Si no necessiteu notificacions instantànies sobre missatges nous, haureu de desactivar Tramesa i utilitzar Sondeig. El sondeig comprova si hi ha correus nous a intervals regulars i no necessita la notificació. Desactiva la Tramesa + + Selecciona + + Desselecciona + + Marca com a llegit + + Marca com a no llegit + + Afegeix un estel + + Elimina l\'estel + + Arxiva + + Elimina + + Correu brossa + + Mou… 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 f480a72be5..0f1f3cdafb 100644 --- a/app/ui/legacy/src/main/res/values-cs/strings.xml +++ b/app/ui/legacy/src/main/res/values-cs/strings.xml @@ -1071,4 +1071,22 @@ Tuto zprávu si můžete ponechat a použít jí jako zálohu svého tajného kl Nastavit upozorňování Pokud nepotřebujete okamžitá upozornění na nové zprávy, měli byste Push vypnout a použít pravidelné dotazování se. To kontroluje nové e-maily v pravidelném intervalu a nepotřebuje upozornění. Vypnout Push + + Vybrat + + Zrušit výběr + + + Označit jako nepřečtené + + Přidat \u2605 + + Odebrat \u2606 + + Archivovat + + Smazat + + Nevyžádaná + 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 e16f5757bb..1e8589be5c 100644 --- a/app/ui/legacy/src/main/res/values-cy/strings.xml +++ b/app/ui/legacy/src/main/res/values-cy/strings.xml @@ -1074,4 +1074,22 @@ Mae\'n bosib i ti gadw\'r neges hon a\'i ddefnyddio wrth gefn fel dy allwedd gyf Ffurfweddu hysbysiad Os nad wyt angen hysbysiadau ar unwaith am negeseuon newydd, analluoga Gwthio a defnyddio Gwirio. Mae Gwirio yn edrych am negeseuon newydd ar gyfnodau rheolaidd heb fod angen yr hysbysiad parhaus. Analluogi Gwthio + + Dewis + + Dad-ddewis + + + Nodi heb ei darllen + + Ychwanegu seren + + Tynnu seren + + Archif + + Dileu + + Sbam + 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 15c4258686..cce8155a7d 100644 --- a/app/ui/legacy/src/main/res/values-da/strings.xml +++ b/app/ui/legacy/src/main/res/values-da/strings.xml @@ -1025,4 +1025,22 @@ Beskeden kan beholdes og bruges som sikkerhedskopi til den hemmelige nøgle. Hvi Indstille påmindelse Hvis der ikke er brug for hurtig påmindelse om nye beskeder, skal skub handling deaktiveres og test bruges. Test kontrollerer regelmæssigt efter ny post og har ikke brug for påmindelsen. Deaktivere skub handling + + Vælg + + Fravælg + + + Marker som ulæst + + Tilføj stjernemarkering + + Fjern stjernemarkering + + Arkivere + + Slet + + Spam + diff --git a/app/ui/legacy/src/main/res/values-de/strings.xml b/app/ui/legacy/src/main/res/values-de/strings.xml index 1dbb9674a4..8e29977183 100644 --- a/app/ui/legacy/src/main/res/values-de/strings.xml +++ b/app/ui/legacy/src/main/res/values-de/strings.xml @@ -637,8 +637,8 @@ Bitte senden Sie Fehlerberichte, Ideen für neue Funktionen und stellen Sie Frag Alphabetisch nach Betreff (Z-A) Alphabetisch nach Absender (A-Z) Alphabetisch nach Absender (Z-A) - Markierte Nachrichten zuerst - Nicht markierte Nachrichten zuerst + Wichtige Nachrichten zuerst + Unwichtige Nachrichten zuerst Ungelesene Nachrichten zuerst Gelesene Nachrichten zuerst Nachrichten mit Anhängen zuerst @@ -1066,4 +1066,24 @@ Sie können diese Nachricht aufheben und sie als Backup für Ihren geheimen Schl Benachrichtigung einstellen Wenn Sie keine sofortigen Benachrichtigungen über neue Nachrichten benötigst, sollten Sie Push deaktivieren und die Abfrage verwenden. Die Abfrage prüft in regelmäßigen Abständen auf neue E-Mails und benötigt die Benachrichtigung nicht. Push deaktivieren + + Auswählen + + Abwählen + + Als gelesen markieren + + Als ungelesen markieren + + Als wichtig markieren + + Als unwichtig markieren + + Archiv + + Löschen + + Spam + + Verschieben… 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 8337b1efac..c76a44aaa2 100644 --- a/app/ui/legacy/src/main/res/values-el/strings.xml +++ b/app/ui/legacy/src/main/res/values-el/strings.xml @@ -1060,4 +1060,22 @@ Προσαρμογή ειδοποίησης Αν δεν χρειάζεστε άμεση ειδοποίηση για νέα μηνύματα, απενεργοποιήστε το σπρώξιμο και χρησιμοποιήστε ενημέρωση (Polling). Η ενημέρωση ελέγχει για νέα μηνύματα ανά τακτά χρονικά διαστήματα και δεν χρειάζεται την ειδοποίηση. Απενεργοποίηση σπρωξίματος + + Επιλογή + + Απο-επιλογή + + + Σήμανση ως μη αναγνωσμένο + + Προσθήκη αστεριού + + Αφαίρεση αστεριού + + Αρχειοθέτηση + + Διαγραφή + + Ανεπιθύμητα + diff --git a/app/ui/legacy/src/main/res/values-en-rGB/strings.xml b/app/ui/legacy/src/main/res/values-en-rGB/strings.xml index c2d7a6e595..ccb052fafe 100644 --- a/app/ui/legacy/src/main/res/values-en-rGB/strings.xml +++ b/app/ui/legacy/src/main/res/values-en-rGB/strings.xml @@ -49,4 +49,14 @@ + + + + + + + + + + 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 42355790e3..c57ace7149 100644 --- a/app/ui/legacy/src/main/res/values-eo/strings.xml +++ b/app/ui/legacy/src/main/res/values-eo/strings.xml @@ -1015,4 +1015,22 @@ Vi povas konservi tiun ĉi mesaĝon kaj uzi ĝin kiel sekurkopion de via privata Agordi sciigon Se vi ne bezonas tujajn sciigojn pri novaj mesaĝoj, malaktivigu puŝ-sciigojn kaj uzu periodajn petojn «pooling». Periodaj petoj «pooling» kontrolas pri novaj retleteroj en ripetiĝanta tempintervalo kaj ne postulas vidigi la puŝ-sciigon, Malaktivigi puŝ-sciigojn + + Elekti + + Malelekti + + + Marki kiel nelegitan + + Steletigi + + Malsteletigi + + Arĥivo + + Forigi + + Trudmesaĝo + 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 2b785a6f0e..3bf32e0156 100644 --- a/app/ui/legacy/src/main/res/values-es/strings.xml +++ b/app/ui/legacy/src/main/res/values-es/strings.xml @@ -1070,4 +1070,24 @@ Puedes guardar este mensaje y usarlo como copia de seguridad de tu clave secreta Configurar la apariencia de la notificación En caso de no necesitar notificaciones instantáneas de mensajes nuevos puedes desactivar las comprobaciones rápidas («push», o IDLE) y seguir utilizando comprobaciones normales («poll»); esta opción clásica pregunta al servidor de vez en cuando, conectándose de forma esporádica a intervalos regulares y no necesita mantener una conexión permanente ni mantener un servicio y notificación constante en segundo plano. Gastando menos batería. Desactivar sincronización rápida («push») + + Seleccionar + + Deseleccionar + + Marcar como leído + + Marcar como no leído + + Destacar el mensaje + + No marcar como destacado + + Archivo + + Borrar + + Spam + + Mover… 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 36f0b1cca3..08f75b0bc5 100644 --- a/app/ui/legacy/src/main/res/values-et/strings.xml +++ b/app/ui/legacy/src/main/res/values-et/strings.xml @@ -268,7 +268,9 @@ Veateated saad saata, kaastööd teha ning küsida teavet järgmisel lehel: Mitte ükski + Märgi loetuks/mitteloetuks + Lisa või eemalda tärn Arhiveeri @@ -1061,4 +1063,22 @@ Palun jäta see kiri alles ning kasuta seda muu hulgas oma krüptovõtme varunda Seadista teavitusi Kui sul pole vaja ülikiiret infot saabunud kirjade kohta, siis lülita tõuketeenused välja ning lase K-9 Mail\'il kasutada tavapärast uute kirjade kontrollimist. Sellisel juhul vaatab K-9 Mail seadistatud ajavahemiku järel serverist uusi kirju ja mainitud püsiteavitust pole vaja. Ära kasuta tõuketeenuseid + + Vali + + Tühista valik + + + Märgi mitteloetuks + + Lisa tärn + + Eemalda tärn + + Arhiveeri + + Kustuta + + Rämps + 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 78bafc4988..ba69660bda 100644 --- a/app/ui/legacy/src/main/res/values-eu/strings.xml +++ b/app/ui/legacy/src/main/res/values-eu/strings.xml @@ -1063,4 +1063,22 @@ Mezu hau gorde dezakezu eta zure gako sekretuaren babes-kopia gisa erabili. Hau 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 + + Hautatu + + Desautatu + + + Markatu irakurri gabeko gisa + + Gehitu izarra + + Kendu izarra + + Artxibatu + + Ezabatu + + Zabor-posta + 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 08bfd2eb45..aaac61e535 100644 --- a/app/ui/legacy/src/main/res/values-fa/strings.xml +++ b/app/ui/legacy/src/main/res/values-fa/strings.xml @@ -1058,4 +1058,21 @@ پیکربندی اعلان اگر برای پیام‌های جدید نیاز به اعلان آنی ندارید، بهتر است پیشرانی را غیرفعال کنید و سرکشی را به کار ببرید. روش سرکشی در فواصل زمانی منظم آمدن نامه‌های جدید را بررسی می‌کند و به آن اعلان نیاز ندارد. غیرفعال‌سازی پیشرانی + + + بی‌انتخاب + + + خوانده نشد + + افزودن ستاره + + حذف ستاره + + بایگانی + + حذف + + هرزنامه + 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 c5ba53382e..8fc265bc2a 100644 --- a/app/ui/legacy/src/main/res/values-fi/strings.xml +++ b/app/ui/legacy/src/main/res/values-fi/strings.xml @@ -1064,4 +1064,24 @@ Voit säilyttää tämän viestin ja käyttää sitä varmuuskopioina salausavai Ilmoitusten asetukset Jos et tarvitse välitöntä ilmoitusta uudesta viestistä, pushin sijaan on suositeltavaa käyttää pollausta. Pollaus tarkistaa uudet viestit määrätyin aikavälein, eikä tämä vaadi ilmoituksen näyttämistä. Poista push käytöstä + + Valitse + + Poista valinta + + Merkitse luetuksi + + Merkitse lukemattomaksi + + Lisää tähti + + Poista tähti + + Arkistoi + + Poista + + Roskaposti + + Siirrä… 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 b758d68eef..65d521bb6f 100644 --- a/app/ui/legacy/src/main/res/values-fr/strings.xml +++ b/app/ui/legacy/src/main/res/values-fr/strings.xml @@ -1068,4 +1068,24 @@ Rapportez les bogues, recommandez de nouvelles fonctions et posez vos questions Configurer la notification Si vous n’avez pas besoin de notifications instantanées pour les nouveaux courriels, vous devriez désactiver le pousser et utiliser la scrutation. La scrutation vérifie la présence de nouveaux courriels à intervalles réguliers et n’a pas besoin de notification. Désactiver le pousser + + Sélectionner + + Dessélectionner + + Marquer comme lu + + Marquer comme non lu + + Ajouter une étoile + + Supprimer l’étoile + + Archiver + + Supprimer + + Pourriel + + Déplacer… 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 f8b9189fe6..aebfec8f96 100644 --- a/app/ui/legacy/src/main/res/values-fy/strings.xml +++ b/app/ui/legacy/src/main/res/values-fy/strings.xml @@ -1060,4 +1060,24 @@ Jo kinne dit berjocht bewarje as reservekopy foar jo geheime kaai. As jo dit dwa Melding ynstelle As jo it net nedich fine om daliks meldingen te ûntfangen fan nije berjochten, dan kinne jo better push útskeakelje en yn stee dêrfan kieze foar peilen. Mei peilen wurdt der om de sa folle tiid kontrolearre oft der nije e-mailberjochten ynkommen binne. Dêrfoar is it werjaan fan in melding net kontinu nedich. Push útskeakelje + + Selektearje + + Deselektearje + + As lêzen markearje + + As net-lêzen markearje + + Stjer tafoegje + + Stjer fuortsmite + + Argivearje + + Fuortsmite + + Net-winske + + Ferpleatse… 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 2874a726d8..c3bcfe07ac 100644 --- a/app/ui/legacy/src/main/res/values-gd/strings.xml +++ b/app/ui/legacy/src/main/res/values-gd/strings.xml @@ -898,4 +898,22 @@ Teachdaireachd suidheachaidh Autocrypt Teachdaireachd suidheachaidh Autocrypt + + Tagh + + Dì-thagh + + + Comharraich nach deach a leughadh + + Cuir rionnag ris + + Thoir an rionnag air falbh + + Tasg-lann + + Sguab às + + Spama + 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 a8e9c40719..b77fe9db98 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 @@ -657,4 +657,22 @@ Volver Axustes xerais + + Seleccionar + + Deseleccionar + + + Marcar como non lida + + Engadir estrela + + Eliminar estrela + + Arquivar + + Eliminar + + Lixo + 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 44e9ac4c48..974ab4895f 100644 --- a/app/ui/legacy/src/main/res/values-gl/strings.xml +++ b/app/ui/legacy/src/main/res/values-gl/strings.xml @@ -1021,4 +1021,22 @@ Podes conservar esta mensaxe e usala como copia de seguridade da túa chave secr Configurar notificación Se non precisas notificacións instantáneas sobre mensaxes novas, debes desactivar Push e usar a Enquisa. As enquisas comproba se hai correos novos a intervalos regulares e non precisa a notificación. Desactivar Push + + Seleccionar + + Deseleccionar + + + Marcar Non Lido + + Engadir Estrela + + Eliminar Estrela + + Archivo + + Eliminar + + Spam + 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 64a0ad4529..e52c1524c5 100644 --- a/app/ui/legacy/src/main/res/values-hr/strings.xml +++ b/app/ui/legacy/src/main/res/values-hr/strings.xml @@ -917,4 +917,22 @@ Isključeno Dopusti pristup kontaktima + + Označi + + Odznači + + + Označi nepročitanim + + Dodaj zvjezdicu + + Ukloni zvjezdicu + + Arhiva + + Obriši + + Neželjena pošta + 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 7066a303f9..8d7a82bcd2 100644 --- a/app/ui/legacy/src/main/res/values-hu/strings.xml +++ b/app/ui/legacy/src/main/res/values-hu/strings.xml @@ -1056,4 +1056,22 @@ Megtarthatja ezt az üzenetet, és felhasználhatja a titkos kulcs biztonsági m Értesítés beállítása Ha nincs szüksége azonnali értesítésekre az új levelekről, akkor letilthatja a leküldést, és használhat lekérést. A lekérés rendszeres időközönként ellenőrzi, hogy vannak-e új levelek, és nincs szüksége értesítés megjelenítésére. Leküldés letiltása + + Kijelölés + + Kijelölés törlése + + + Megjelölés olvasatlanként + + Csillag hozzáadása + + Csillag eltávolítása + + Archívum + + Törlés + + Levélszemét + 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 255b93aade..2922664089 100644 --- a/app/ui/legacy/src/main/res/values-in/strings.xml +++ b/app/ui/legacy/src/main/res/values-in/strings.xml @@ -976,4 +976,22 @@ Anda dapat menyimpan pesan ini dan menggunakannya sebagai cadangan untuk kunci r Izinkan akses ke kontak Untuk dapat memberikan saran kontak dan untuk menampilkan nama kontak dan foto, aplikasi perlu akses ke kontak Anda. Terjadi kesalahan saat memuat data + + Pilih + + Hapus pilihan + + + Tandai belum dibaca + + Tambah bintang + + Buang bintang + + Arsipkan + + Hapus + + Spam + 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 8b1c35ce71..f82f6b2d87 100644 --- a/app/ui/legacy/src/main/res/values-is/strings.xml +++ b/app/ui/legacy/src/main/res/values-is/strings.xml @@ -261,13 +261,19 @@ Sendu inn villuskýrslur, leggðu fram nýja eiginleika og spurðu spurninga á Merkja öll skilaboð sem lesin Eyða (úr tilkynningu) + Aðgerðir við stroku + Strjúka til hægri + Strjúka til vinstri Engin + Víxla völdu + Merkja sem lesið/ólesið + Bæta við/fjarlægja stjörnu Geymsla @@ -1059,4 +1065,24 @@ Til að setja Autocrypt upp á nýju tæki, farðu þá eftir leiðbeiningunum s Stilla tilkynningu Ef þú þarft ekki tilkynningar jafnharðan um ný skilaboð, ættirðu að gera ýtitilkynningar óvirkar og nota frekar vöktun (polling). Vöktun þýðir að athugað er með nýjan póst með reglulegu millibili og þarfnast ekki tilkynningarinnar. Gera ýtitilkynningar óvirkar + + Velja + + Afvelja + + Merkja sem lesið + + Merkja sem ólesið + + Bæta við stjörnu + + Fjarlægja stjörnu + + Geymsla + + Fjarlægja + + Ruslpóstur + + Færa… 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 0de163288d..9e0eed1ad7 100644 --- a/app/ui/legacy/src/main/res/values-it/strings.xml +++ b/app/ui/legacy/src/main/res/values-it/strings.xml @@ -1066,4 +1066,22 @@ Puoi conservare questo messaggio e utilizza come una copia di sicurezza della tu Configura notifica Se non hai bisogno di notifiche istantanee sui nuovi messaggi, dovresti disabilitare Push e usare Polling. Il polling verifica la presenza di nuova posta a intervalli regolari e non necessita di notifica. Disabilita Push + + Seleziona + + Deseleziona + + + Marca come da leggere + + Aggiungi stella + + Rimuovi stella + + Archivia + + Elimina + + Spam + 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 db44e38f69..2bb91efaaf 100644 --- a/app/ui/legacy/src/main/res/values-iw/strings.xml +++ b/app/ui/legacy/src/main/res/values-iw/strings.xml @@ -618,4 +618,22 @@ הגדרות כלליות + + בחר + + בטל בחירה + + + סמן כלא נקרא + + הוסף כוכב + + מחק כוכב + + העבר לארכיון + + מחק + + דואר זבל + 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 6d814e65e9..83b0f11583 100644 --- a/app/ui/legacy/src/main/res/values-ja/strings.xml +++ b/app/ui/legacy/src/main/res/values-ja/strings.xml @@ -1059,4 +1059,24 @@ K-9 は大多数のメールクライアントと同様に、ほとんどのフ 通知設定 新しいメッセージの即時通知が不要な場合は、プッシュを無効にして同期を使用してください。同期は、一定の間隔で新着メールを確認するので、通知は必要ありません。 プッシュを無効にする + + 選択 + + 選択解除 + + 既読にする + + 未読にする + + スターを付ける + + スターをはずす + + アーカイブ + + 削除 + + 迷惑メール + + 移動… 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 e2e0f709e9..09db521f7b 100644 --- a/app/ui/legacy/src/main/res/values-ko/strings.xml +++ b/app/ui/legacy/src/main/res/values-ko/strings.xml @@ -7,10 +7,13 @@ K-9 계정 K-9 읽지 않은 메일 + K-9 개발자들 소소 코드 아파치 라이선스, 버전 2.0 오픈 소스 프로젝트 웹사이트 + 도움말 + 지원 사용자 포럼 라이선스 변경 내용 @@ -792,4 +795,22 @@ 커져 있음 꺼 있음 + + 선택 + + 선택 해제 + + + 읽지 않은 메일로 표시 + + 별표하기 + + 별표 해제하기 + + 보관 + + 삭제 + + 스팸 + 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 ca9b1384d2..8c4cebdcf4 100644 --- a/app/ui/legacy/src/main/res/values-lt/strings.xml +++ b/app/ui/legacy/src/main/res/values-lt/strings.xml @@ -1064,4 +1064,22 @@ Norėdami nustatyti automatinį šifravimą naujajame prietaise, vadovaukitės i Konfiguruoti pranešimą Jei jums nereikia momentinių pranešimų apie naujus laiškus, turėtumėte išjungti Push ir naudoti Polling. Polling reguliariai tikrina, ar yra naujų laiškų, ir pranešimo nereikia. Išjungti Push + + Pasirinkti + + Nebežymėti + + + Pažymėti kaip neskaitytą + + Pridėti žvaigždutę + + Pašalinti žvaigždutę + + Archyvuoti + + Šalinti + + Brukalas + 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 ccd3112474..d7213eb4af 100644 --- a/app/ui/legacy/src/main/res/values-lv/strings.xml +++ b/app/ui/legacy/src/main/res/values-lv/strings.xml @@ -12,6 +12,8 @@ Apache licence, Versija 2.0 Atvērtā koda projekts Tīmekļvietne + Lietotāja rokasgrāmata + Saņemt palīdzību Lietotāju forums Fediverse Twitter @@ -104,6 +106,7 @@ Lūdzu, iesniedziet kļūdu ziņojumus, ierosiniet jaunas iespējas un uzdodiet Meklēt Meklēt visur Meklēšanas rezultāti + Jaunas vēstules Iestatījumi Pārvaldīt mapes Konta iestatījumi @@ -114,6 +117,7 @@ Lūdzu, iesniedziet kļūdu ziņojumus, ierosiniet jaunas iespējas un uzdodiet Pievienot zvaigznīti Noņemt zvaigznīti Kopēt + Atrakstīties Rādīt vēstules papildinformāciju Adrese nokopēta starpliktuvē @@ -161,12 +165,15 @@ pat %d vairāk Arhīvs Arhivēt visu Surogātpasts + Sertifikāta kļūda Sertifikāta kļūda kontam %s Pārbaudiet sava servera iestatījumus Identitātes pārbaude neizdevās Identifikācijas pārbaude kontam %s neizdevās. Atjaunojiet servera iestatījumus! + Paziņojuma kļūda + Neizdevās izveidot sistēmas paziņojumu par jaunu vēstuli. Iespējams, ka trūkst paziņojuma skaņas. \n\nPieskarieties, lai atvērtu paziņojumu iestatījumus. Pārbauda pastu: %1$s:%2$s Pārbauda pastu Sūta pastu: %s @@ -238,6 +245,7 @@ pat %d vairāk Izmantot vārdus no kontaktu saraksta, kad vien iespējams Iekrāsot kontaktus Iekrāsot vārdus kontaktu sarakstā + Kontakta vārda krāsa Fiksēta platuma šrifti Izmantot fiksēta platuma šriftu, lai parādīt vienkārša teksta vēstules Automātiski ietilpināt vēstules @@ -255,13 +263,19 @@ pat %d vairāk Atzīmēt visas vēstules kā izlasītas Dzēst (no paziņojuma) + Pavilkšanas darbības + Pavilkt pa labi + Pavilkt pa kreisi Neviens + Pārslēgt izvēli + Atzīmēt kā izlasītu/nelasītu + Pielikt/noņemt zvaigznīti Arhīvs @@ -293,8 +307,11 @@ pat %d vairāk Uzlikt jaunu kontu E-pasta adrese Parole + Lai K-9 Pastā lietotu šo e-pasta kontu, jāpierakstās kontā un jādod atļauja piekļūt saviem e-pastiem. + Pierakstīties + Pierakstīties ar Google Lai apskatītos savu paroli, ieslēdziet ekrāna bloķēšanu savā ierīcē. Apstiprināt savu identitāti @@ -318,6 +335,7 @@ pat %d vairāk Parole bez drošās pārraides Šifrēta parole Klienta sertifikāts + OAuth 2.0 Ienākošā servera iestatījumi Lietotāja vārds Parole @@ -336,6 +354,7 @@ pat %d vairāk Nedzēst to no servera Izdzēst to no servera Atzīmēt kā izlasītu uz servera + Izmantot saspiešanu Izdzēst vēstules arī no servera Uzreiz Pārbaudes laikā @@ -400,6 +419,10 @@ pat %d vairāk Nepareizs lietotāja vārds vai parole.\n(%s) Serveris norādīja nederīgu SSL sertifikātu. Dažreiz tas ir servera nepareizas konfigurācijas dēļ. Citreiz to izraisījis mēģinājums uzlaust Jūsu e-pasta serveri. Ja nav pārliecības, kas par vainu, nospiediet "Noraidīt" un sazinieties ar sava e-pasta severa uzturētājiem.\n\n(%s) Nevar savienoties ar serveri.\n(%s) + Autorizācija atcelta + Autorizācija atcelta šādas kļūdas dēļ: %s + OAuth 2.0 šobrīd netiek nodrošināta. + Lietotnei neizdevās atrast pārlūku, lai saņemtu piekļuvi Jūsu kontam. Rediģēt informāciju Turpināt Papildus @@ -554,11 +577,29 @@ pat %d vairāk Konta nosaukums Jūsu vārds Paziņojumi + Vibrācija Vibrēt + Vibrācijas veids Noklusējums + 1. veids + 2. veids + 3. veids + 4. veids + 5. veids Atkārtot vibrēšanu + Atslēgts Jauna pasta skaņas signāls + Paziņojuma gaisma + Atslēgts Konta krāsa + Sistēmas noklusējuma krāsa + Balta + Sarkana + Zaļa + Zila + Dzeltena + Ciānkrāsa + Sarkana anilīnkrāsa Vēstules rakstīšanas iespējas Rakstīšanas noklusējuma iestatījumi Norādīt Jūsu noklusējuma \'No\', Bcc un parakstu @@ -663,6 +704,7 @@ pat %d vairāk 1000 mapes Animācija Izmantot spilgtus vizuālos efektus + Navigācija ar skaļuma taustiņu vēstules skatā Rādīt apvienoto pastkasti Parādīt atzīmēto vēstuļu skaitu Apvienotā pastkaste @@ -758,13 +800,16 @@ pat %d vairāk Lūdzu, ievadiet paroles + Lūdzu, pierakstieties! + Lūdzu, pierakstieties un ievadiet paroles! Neizdevās importēt iestatījumus Neizdevās nolasīt iestatījumu datni Neizdevās importēt dažus iestatījumus Importēts veiksmīgi Nepieciešama parole + Nepieciešama pierakstīšanās Nav importēts Importēšana neizdevās Vēlāk @@ -1027,4 +1072,22 @@ Lai jaunajā ierīcē iestatītu automātisko šifrēšanu, lūdzu sekojiet nor Iestatīt paziņojumu Ja nevēlaties saņemt paziņojumus par jaunām vēstulēm, atslēdziet lejupielādēšanu un izmantojiet pārbaudīšanu. Pārbaudē ik pēc noteikta laikā tiek noskaidrots, vai nav saņemts jauns pasts, un tai nav vajadzīgs paziņojums. Atslēgt lejupielādēšanu + + Atzīmēt + + Noņemt atzīmi + + + Atzīmēt kā nelasītu + + Pievienot zvaigznīti + + Noņemt zvaigznīti + + Arhīvs + + Dzēst + + Surogātpasts + 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 bfd3982118..3cfd8a509f 100644 --- a/app/ui/legacy/src/main/res/values-ml/strings.xml +++ b/app/ui/legacy/src/main/res/values-ml/strings.xml @@ -1008,4 +1008,22 @@ കോൺ‌ടാക്റ്റുകളിലേക്ക് പ്രവേശനം അനുവദിക്കുക കോൺ‌ടാക്റ്റ് നിർദ്ദേശങ്ങൾ‌ നൽ‌കുന്നതിനും കോൺ‌ടാക്റ്റ് നാമങ്ങളും ഫോട്ടോകളും പ്രദർശിപ്പിക്കുന്നതിനും, അപ്ലിക്കേഷന് നിങ്ങളുടെ കോൺ‌ടാക്റ്റുകളിലേക്ക് പ്രവേശനം ആവശ്യമാണ്. + + തിരഞ്ഞെടുക്കുക + + തിരഞ്ഞെടുത്തത് മാറ്റുക + + + വായിക്കാത്തതായി അടയാളപ്പെടുത്തുക + + നക്ഷത്രം ചേർക്കുക + + നക്ഷത്രം കളയുക + + ശേഖരം + + ഇല്ലാതാക്കുക + + സ്പാം + 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 d2a64cd6b8..d4e86f094e 100644 --- a/app/ui/legacy/src/main/res/values-nb/strings.xml +++ b/app/ui/legacy/src/main/res/values-nb/strings.xml @@ -950,4 +950,22 @@ til %d flere Av + + Velg + + Fjern valg + + + Merk uleste + + Legg til stjerne + + Fjern stjerne + + Arkiver + + Slett + + Søppelpost + 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 0d49caafb6..a15f64399a 100644 --- a/app/ui/legacy/src/main/res/values-nl/strings.xml +++ b/app/ui/legacy/src/main/res/values-nl/strings.xml @@ -1060,4 +1060,24 @@ Je kunt dit bericht bewaren als back-up voor de geheime sleutel. Als je dit wilt Melding instellen Als je het niet nodig vindt om per direct meldingen van nieuwe berichten te ontvangen, dan kun je beter push uitschakelen en in plaats daarvan kiezen voor peilen. Met peilen wordt er om de zoveel tijd gekeken of er nieuwe e-mailberichten zijn binnengekomen. Daarvoor is het weergeven van een melding niet continu nodig. Push uitschakelen + + Selecteren + + Deselecteren + + Als gelezen markeren + + Als ongelezen markeren + + Ster toevoegen + + Ster verwijderen + + Archiveren + + Verwijderen + + Spam + + Verplaatsen… 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 6dfcd3bf70..7abee9c832 100644 --- a/app/ui/legacy/src/main/res/values-pl/strings.xml +++ b/app/ui/legacy/src/main/res/values-pl/strings.xml @@ -1079,4 +1079,24 @@ Tą wiadomość można zachować i użyć w formie kopii zapasowej twojego klucz Skonfiguruj powiadomienie Jeśli nie potrzebujesz natychmiastowych powiadomień o nowych wiadomościach, wyłącz notyfikacje push i użyj regularnego odpytywania. Odpytywanie w regularnych odstępach czasu sprawdza nową pocztę i nie wymaga powiadomienia. Wyłącz powiadomienia push + + Zaznacz + + Odznacz + + Oznacz jako przeczytane + + Oznacz jako nieprzeczytane + + Dodaj gwiazdkę + + Usuń gwiazdkę + + Archiwizuj + + Usuń + + Spam + + Przenieś… 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 bb1ab6a5c0..22afc237e6 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 @@ -1071,4 +1071,24 @@ Você pode guardar esta mensagem e usá-la como um backup da sua chave secreta. Configurar a notificação Caso você não precise de notificações instantâneas sobre novas mensagens, você deve desabilitar o push e usar somente a consulta (\"polling\"). A consulta procura por novas mensagens em intervalos regulares e não necessita da notificação. Desabilitar o Push + + Marcar + + Desmarcar + + Marcar como lida + + Marcar como não lida + + Adicionar estrela + + Remover estrela + + Arquivar + + Excluir + + Spam + + Mover… 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 0ea4b36c05..578884433b 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 @@ -105,6 +105,7 @@ Por favor envie relatórios de falhas, contribua com novas funcionalidades e col Adicionar conta Compor Pesquisar + Procurar em todo o lado Resultados da pesquisa Novas mensagens Configurações @@ -172,11 +173,15 @@ Por favor envie relatórios de falhas, contribua com novas funcionalidades e col Erro de notificação + Occoreu um erro ao tentar criar uma notificação do sistema para uma nova mensagem. A causa mais provavel é a falta de um som de notificação.\n\nToque para abrir as definições de notificação +. A verificar correio: %1$s:%2$s A verificar correio A enviar correio: %s A enviar correio : + Sincronizar (Push) + Mostrado enquanto à espera de novas mensagens Mensagens Notificações relacionadas com mensagens Diversos @@ -260,11 +265,14 @@ Por favor envie relatórios de falhas, contribua com novas funcionalidades e col Eliminar (a partir da notificação) + Deslizo para a direita + Deslizo para a esquerda Nenhuma + Marcar como lida/não-lida Arquivo @@ -285,6 +293,7 @@ Por favor envie relatórios de falhas, contribua com novas funcionalidades e col Notificações no ecrã de bloqueio Sem notificações no ecrã de bloqueio Nome da aplicação + Número de novas mensagens Número de mensagens e remetentes O mesmo quando o ecrã está desbloqueado Período de sossego @@ -692,6 +701,7 @@ Por favor envie relatórios de falhas, contribua com novas funcionalidades e col 1000 pastas Animação Usar efeitos visuais exuberantes + Navegação com os botões de volume na visualização de mensagens Mostrar caixa de entrada unificada Caixa de entrada unificada Todas as mensagens em pastas unificadas @@ -1045,4 +1055,37 @@ Pode manter esta mensagem e usá-la como uma cópia de segurança para a sua cha Permitir acesso aos contactos Por forma a fornecer sugestões de contactos e para mostrar nomes de contactos e fotografias, a aplicação necessita do acesso aos seus contactos. + Occoreu um erro ao carregar os dados + Inicializando… + À espera de novos e-mails + Suspenso até que a sincronização em segundo plano seja permitida. + Suspenso até que a rede esteja disponível + Toque para saber mais + Informação sobre o Push + Ao usar o Push, o K-9 Mail mantêm uma conneção com o servidor de mail. O Android requer que seja mostrada uma notificação constante enquanto a aplicação está ativa em segundo plano. %s + Ainda assim, o Android também permite esconder a notificação. + Saber mais + Configurar a notificação + Se não necessita de notificações instantáneas, deve desligar o Push e utilizar o Polling. O Polling procura novos e-mails num intervalo regular e não requer a notificação + Desativar o Push + + Selecionar + + Desselecionar + + Marcar como lido + + Marcar como não lido + + Adicionar estrela + + Remover estrela + + Arquivo + + Eliminar + + Spam + + Mover… 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 1ead48d202..0c3b972e0b 100644 --- a/app/ui/legacy/src/main/res/values-ro/strings.xml +++ b/app/ui/legacy/src/main/res/values-ro/strings.xml @@ -263,13 +263,19 @@ cel mult încă %d Marchează toate mesajele ca citite Șterge (din notificare) + Acțiuni de glisare + Glisarea spre dreapta + Glisare spre stânga Fără + Comutarea selecției + Marcați ca citit/necitit + Adăugare/eliminare stea Arhivă @@ -1068,4 +1074,24 @@ Poți păstra acest mesaj și să îl folosești drept copie de siguranță a ch Configurați notificarea Dacă nu aveți nevoie de notificări instantanee cu privire la mesajele noi, dezactivați funcția Push și utilizați funcția Polling. Polling verifică dacă există mesaje noi la intervale regulate și nu are nevoie de notificare. Dezactivați Push + + Selectează + + Deselectează + + Marcați citit + + Marchează ca necitit + + Adaugă steluță + + Șterge steluță + + Arhivă + + Şterge + + Spam + + Mutați… 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 bcade6a6c7..8997c7c81e 100644 --- a/app/ui/legacy/src/main/res/values-ru/strings.xml +++ b/app/ui/legacy/src/main/res/values-ru/strings.xml @@ -4,7 +4,7 @@ Почта K-9 - Аккаунты K-9 + Учётные записи K-9 Не прочитано The K-9 Dog Walkers @@ -50,7 +50,7 @@ K-9 Mail — почтовый клиент для Android.

  • …и многое другое!
  • -Отметим, что K-9 Mail не полностью совместим с MS Exchange и не поддерживает бесплатные ящики Hotmail. +Пожалуйста, обратите внимание, что K-9 Mail не поддерживает большинство бесплатных учетных записей Hotmail также, как и большинство почтовых клиентов, и имеет некоторые ограничения при работе с MS Exchange .

    Вопросы, сообщения об ошибках и участие в разработке: https://github.com/k9mail/k-9/. @@ -59,10 +59,10 @@ K-9 Mail — почтовый клиент для Android. -- \nПростите за краткость, создано в K-9 Mail. - Ящик \"%s\" будет удалён из приложения K-9 Mail. + Учётная запись \"%s\" будет удалена из приложения K-9 Mail. Авторы - О почте K-9 + О приложении K-9 Mail Учётные записи Дополнительно Новое @@ -70,7 +70,7 @@ K-9 Mail — почтовый клиент для Android. Ответить всем Пересылка Переслать вложением - Выберите ящик + Выберите учётную запись Выбор папки Переместить в… Копировать в… @@ -101,7 +101,7 @@ K-9 Mail — почтовый клиент для Android. Отправить почту Обновить список папок Поиск папки - Добавить + Добавить учётную запись Создать Поиск сообщения Искать везде @@ -109,8 +109,8 @@ K-9 Mail — почтовый клиент для Android. Новые сообщения Настройки Выбрать папки - Настройки ящика - Удалить ящик + Настройки учётной записи + Удалить учётную запись Прочитано Передать Выбрать отправителя @@ -135,7 +135,7 @@ K-9 Mail — почтовый клиент для Android. Добавить вложение Очистить корзину Стереть - О программе + О приложении Настройки (Без темы) @@ -264,11 +264,11 @@ K-9 Mail — почтовый клиент для Android. Прочитаны все Удалить (в уведомлении) - Действия смахиванием + Управление жестами - Смахивание вправо + Жест вправо - Смахивание влево + Жест влево Нет @@ -305,8 +305,8 @@ K-9 Mail — почтовый клиент для Android. Полностью, в период тишины Начало Конец - Новый ящик - Адрес email + Создание учётной записи + Адрес электронной почты Пароль Чтобы использовать эту учётную запись электронной почты с K-9 Mail, вам необходимо войти в систему и предоставить приложению доступ к вашим электронным письмам. @@ -326,9 +326,9 @@ K-9 Mail — почтовый клиент для Android. Получение настроек\u2026 Отмена\u2026 Всё почти готово! - Имя ящика (необязательно): + Название учётной записи (необязательно): Ваше имя (видно адресату в сообщениях): - Тип ящика + Тип учётной записи Доступные протоколы POP3 IMAP @@ -338,7 +338,7 @@ K-9 Mail — почтовый клиент для Android. Сертификат клиента Тип аутентификации OAuth 2.0 Сервер входящей почты - Логин + Имя пользователя Пароль Сертификат клиента Сервер POP3 @@ -377,12 +377,12 @@ K-9 Mail — почтовый клиент для Android. Порт Безопасность Авторизация - Логин + Имя пользователя Пароль Аутентификация \"%1$s = %2$s\" недействителен для \"%3$s = %4$s\" Неверная настройка: %s - Настройки ящика + Настройки учётной записи Интервал проверки Вручную 15 минут @@ -417,7 +417,7 @@ K-9 Mail — почтовый клиент для Android. Все сообщения Нельзя скопировать или переместить сообщение, не синхронизированное с сервером Настройка не завершена - Неверные логин или пароль.\n(%s) + Неверные имя пользователя или пароль.\n(%s) Сервер предоставляет неверный сертификат SSL. Иногда это обусловлено неправильной настройкой. Или кто-то пытается атаковать Ваш почтовый сервер или Ваш компьютер. Если Вы не уверены в причинах, нажмите Отклонить и свяжитесь с персоналом, обслуживающим почтовый сервер.\n\n(%s) Не удаётся подключиться к серверу.\n(%s) Авторизация отменена @@ -427,7 +427,7 @@ K-9 Mail — почтовый клиент для Android. Правка Продолжить Дополнительно - Настройки ящика + Настройки учётной записи Уведомить о почте Уведомления папок Все @@ -491,8 +491,8 @@ K-9 Mail — почтовый клиент для Android. Все черновики будут храниться в зашифрованном виде Зашифровать черновики, если включено шифрование Интервал проверки - Цвет - Метка в списке ящиков и папок + Цвет для учётной записи + Акцентный цвет текущей учётной записи, используемый среди учётных записей и папок Отображать сообщений Загружать фрагмент 1 КиБ @@ -575,7 +575,7 @@ K-9 Mail — почтовый клиент для Android. Настройки сервера входящей почты Сервер исходящих Настройки сервера исходящей почты - Имя ящика + Название учётной записи Ваше имя Уведомления Вибрация @@ -592,7 +592,7 @@ K-9 Mail — почтовый клиент для Android. Мелодия для оповещения Свет уведомлений Выключено - Цвет + Цвет учётной записи Системный цвет Белый Красный @@ -621,7 +621,7 @@ K-9 Mail — почтовый клиент для Android. опция Ваше имя (Необязательно) - Адрес email + Адрес электронной почты (Обязательно) Адрес для ответа (Необязательно) @@ -633,7 +633,7 @@ K-9 Mail — почтовый клиент для Android. Выберите роль Отправитель Нельзя удалить основную роль - Нельзя создать роль без адреса email + Нельзя создать роль без адреса электронной почты Старые – новые Новые – старые Тема А – Я @@ -654,7 +654,7 @@ K-9 Mail — почтовый клиент для Android. Важность Прочитано Вложение - Удаление ящика + Удаление учётной записи Неверный сертификат сервера Принять Отклонить @@ -677,7 +677,7 @@ K-9 Mail — почтовый клиент для Android. Личное Сеть Интерфейс - Список ящиков + Список учётных записей Список сообщений Сообщения Тема приложения @@ -685,9 +685,9 @@ K-9 Mail — почтовый клиент для Android. Тема редактора Язык Настройки не найдены - Фиксированный просмотр + Задать тему для писем Разрешить выбор темы при чтении сообщения - Отключить выбор темы при чтении сообщения + Возможность выбрать тему для писем отдельно от темы приложения По умолчанию Фоновая синхронизация Никогда @@ -720,9 +720,9 @@ K-9 Mail — почтовый клиент для Android. Автоматически (%s) Шрифт Настройка размера шрифтов - Список ящиков - Имя ящика - Описание ящика + Список учётных записей + Название учётной записи + Описание учётной записи Список папок Имя папки Состояние папки @@ -822,15 +822,15 @@ K-9 Mail — почтовый клиент для Android. Чтобы использовать ящик \"%s\" необходимо ввести пароль. Чтобы использовать ящик \"%s\" необходимо ввести пароли. Чтобы использовать ящик \"%s\" необходимо ввести пароли. - Чтобы использовать ящик \"%s\" необходимо ввести пароли. + Чтобы использовать учётную запись \"%s\" необходимо ввести пароли. Входящий пароль сервера Исходящий пароль сервера Использовать тот же пароль для исходящего сервера Названия серверов: %s Счётчик непрочитанных - Ящик - Ящик для отображения счётчика непрочитанных + Учётная запись + Учётная запись для отображения счётчика непрочитанных Общие \"Входящие\" Счётчик папок Показывать счётчик непрочитанных только для одной папки @@ -838,7 +838,7 @@ K-9 Mail — почтовый клиент для Android. Папка для отображения счётчика непрочитанных Готово %1$s%2$s - Ящик не выбран + Учётная запись не выбрана Папка не выбрана Текст отсутствует Открыть @@ -860,7 +860,7 @@ K-9 Mail — почтовый клиент для Android. Написать В контакты В буфер - Адрес email + Адрес электронной почты Все 10 25 @@ -893,11 +893,11 @@ K-9 Mail — почтовый клиент для Android. Объединить сообщения, показав счётчик Обновление данных Обновление данных… - Обновление ящика \"%s\" + Обновление учётной записи \"%s\" Разделить экран Всегда - Нет - Ландшафт + Никогда + При горизонтальном положении Выберите сообщение Фото контактов Показать фото контактов в списке сообщений @@ -932,8 +932,8 @@ K-9 Mail — почтовый клиент для Android. Работа Прочее Мобильный - Для данного ящика не настроена папка Черновики! - Для данного ящика не настроены ключи! Проверьте настройки + Для данной учётной записи не настроена папка Черновики! + Для данной учётной записи не настроены ключи! Проверьте настройки Криптопровайдер использует несовместимую версию. Проверьте настройки! Нет доступа к криптопровайдеру. Проверьте настройки или нажмите значок шифрования для повтора! Ошибка инициализации сквозного шифрования. Проверьте настройки @@ -1040,7 +1040,7 @@ K-9 Mail — почтовый клиент для Android. Зашифрованное сообщение Шифровать темы сообщений Может поддерживаться не всеми адресатами - Внутренний сбой: неверный ящик! + Внутренний сбой: неверная учётная запись! Сбой подключения к %s! Отправка настроек автошифрования Передать настройки шифрования на другие устройства @@ -1078,4 +1078,22 @@ K-9 Mail — почтовый клиент для Android. Настроить уведомление Если вам не нужны мгновенные уведомления о новых сообщениях, вам следует отключить Push и использовать Опрос. Опрос проверяет наличие новой почты через регулярные промежутки времени и не нуждается в уведомлениях. Отключить Push + + Выбрать + + Снять выбор + + + Не прочитано + + Важное + + Обычное + + Архивировать + + Удалить + + Отправить в спам + 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 8eb5d1d2e9..8724a958a6 100644 --- a/app/ui/legacy/src/main/res/values-sk/strings.xml +++ b/app/ui/legacy/src/main/res/values-sk/strings.xml @@ -894,4 +894,22 @@ Prosím, nahlasujte prípadné chyby, prispievajte novými funkciami a pýtajte Späť Všeobecné nastavenia + + Vybrať + + Zrušiť výber + + + Označiť ako neprečítané + + Pridať hviezdičku + + Odstrániť hviezdičku + + Archivovať + + Vymazať + + Nevyžiadaná pošta + 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 782ec54963..6374a570ff 100644 --- a/app/ui/legacy/src/main/res/values-sl/strings.xml +++ b/app/ui/legacy/src/main/res/values-sl/strings.xml @@ -1072,4 +1072,22 @@ Sporočilo lahko shranite in ga uporabite kot varno kopijo šifrirnega ključa. Nastavitev obvestila Če ne potrebuješ takojšnjih obvestil o novih sporočilih, moraš onemogočiti potiskanje in uporabiti navadno izpraševanje. Izpraševanje išče novo pošto v rednih časovnih presledkih in ne potrebuje obvestila. Onemogoči potiskanje + + Izberi + + Odstrani izbor + + + Označi kot neprebrano + + Označi z zvezdico + + Odstrani zvezdico + + Arhivirano + + Brisanje vseh vrst sporočil + + Neželena pošta + 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 71843b095e..7783f1fe8f 100644 --- a/app/ui/legacy/src/main/res/values-sq/strings.xml +++ b/app/ui/legacy/src/main/res/values-sq/strings.xml @@ -112,7 +112,7 @@ Ju lutemi, parashtrim njoftimesh për të meta, kontribute për veçori të reaj Administroni dosje Rregullime llogarie Hiqe llogarinë - Shënoje si të lexuar + Vëri shenjë si të lexuar Ndajeni me të tjerë Zgjidhni dërgues Shtoji yll @@ -259,7 +259,7 @@ Ju lutemi, parashtrim njoftimesh për të meta, kontribute për veçori të reaj Fshi Ata Me Shenjë (nën pamjen mesazhe) Shënoje si të padëshiruar Hidhe tej mesazhin - Shënoji krejt mesazhet si të lexuar + Vëru shenjë krejt mesazheve si të lexuar Fshije (që prej njoftimit) Veprime fërkimi @@ -270,6 +270,7 @@ Ju lutemi, parashtrim njoftimesh për të meta, kontribute për veçori të reaj Asnjë + Përzgjidheni/shpërzgjidheni Vëri/hiqi shenjë si i lexuar @@ -351,7 +352,7 @@ Ju lutemi, parashtrim njoftimesh për të meta, kontribute për veçori të reaj Kur fshij një mesazh Mos e fshi te shërbyesi Fshije te shërbyesi - Shënoje si të lexuar te shërbyesi + Vëri shenjë si të lexuar te shërbyesi Përdor ngjeshje Hiqi dhe në shërbyes mesazhet e fshira Menjëherë @@ -442,10 +443,10 @@ Ju lutemi, parashtrim njoftimesh për të meta, kontribute për veçori të reaj Shfaq njoftime vetëm për mesazhe nga kontakte të njohur Shpërfill mesazhe fjalosjesh Mos shfaq njoftime për mesazhe që u përkasin një fjalosjeje me email - Shënoje si të lexuar kur hapet - Shënoje një mesazh të lexuar, kur hapet për parje + Vëri shenjë si të lexuar, kur hapet + Vëri shenjë një mesazhi të lexuar, kur hapet për parje Vëri shenjë si i lexuar, kur fshihet - Vërini shenjë si i lexuar një mesazhi, kur fshihet + Vëri shenjë si i lexuar një mesazhi, kur fshihet Kategori njoftimesh Formësoni njoftime për mesazhe të rinj Formësoni njoftime gabimesh dhe gjendjesh @@ -765,7 +766,7 @@ Ju lutemi, parashtrim njoftimesh për të meta, kontribute për veçori të reaj Po Jo Ripohoni vënien shenjë krejt mesazheve si të lexuar - Doni të shënohen krejt mesazhet si të lexuar? + Doni t’u vihet shenjë krejt mesazheve si të lexuar? Ripohoni zbrazje hedhurinash Doni të zbrazet dosja e hedhurinave? Po @@ -887,7 +888,7 @@ Ju lutemi, parashtrim njoftimesh për të meta, kontribute për veçori të reaj Ju lutemi, përzgjidhni një mesazh majtas Shfaq foto kontakti Shfaq foto kontakti te lista e mesazheve - Shënoji të tërë si të lexuar + Vëru shenjë të tërëve si të lexuar Ngjyros foto kontakti Ngjyros foto për kontakt që mungon Veprime të dukshme mbi mesazhet @@ -1064,4 +1065,24 @@ Mund ta mbani këtë mesazh dhe ta përdorni si një kopjeruatje të kyçit tuaj Formësoni njoftim Nëse nuk ju duhen njoftime të atypëratyshme rreth mesazhesh të reja, duhet ta çaktivizoni Push-in dhe të përdorni Vjelje. Vjelja kontrollon për postë të re sipas intervalesh të rregullta dhe nuk ka nevojë për njoftimin. Çaktivizoje Push-in + + Përzgjidhni + + Shpërzgjidhe + + Vëri shenjë si të lexuar + + Shënoje si të palexuar + + Shtoji yll + + Hiqi yll + + Arkivoji + + Fshije + + Shënoje si të padëshiruar + + Lëvizeni… 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 01a1f7faae..f37775cc6c 100644 --- a/app/ui/legacy/src/main/res/values-sr/strings.xml +++ b/app/ui/legacy/src/main/res/values-sr/strings.xml @@ -989,4 +989,22 @@ Дозволи приступ контактима Да би давала предлоге, приказивала имена и слике контаката, апликацији је потребна дозвола за приступ контактима. + + Изабери + + Поништи избор + + + Означи непрочитаним + + Додај звездицу + + Уклони звездицу + + Архивирај + + брисања + + померања у нежељене + 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 03eb42d3d5..9512599299 100644 --- a/app/ui/legacy/src/main/res/values-sv/strings.xml +++ b/app/ui/legacy/src/main/res/values-sv/strings.xml @@ -1065,4 +1065,24 @@ Du kan behålla detta meddelande och använda det som en säkerhetskopia för di Anpassa avisering Om du inte behöver omedelbara meddelanden om nya meddelanden bör du inaktivera Push och använda Polling. Polling kontrollerar regelbundet efter ny post och behöver inte meddelandet. Inaktivera Push + + Välj + + Välj bort + + Markera som läst + + Markera som oläst + + Lägg till stjärna + + Ta bort stjärna + + Arkivera + + Ta bort + + Skräppost + + Flytta… 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 ffe13fecd8..4bc13441a7 100644 --- a/app/ui/legacy/src/main/res/values-tr/strings.xml +++ b/app/ui/legacy/src/main/res/values-tr/strings.xml @@ -27,7 +27,7 @@ K-9 Posta\'ya Hoşgeldiniz -K-9 Mail Android için güçlü ve özgür bir eposta istemcisidir. +K-9 Posta, Android için güçlü ve özgür bir e-posta istemcisidir.

    Geliştirilmiş özellikler içerir:

    @@ -35,7 +35,7 @@ Geliştirilmiş özellikler içerir:
  • IMAP IDLE kullanılarak gelen posta anında gösteriliyor
  • Daha iyi başarım
  • İletiye tekrar işlem
  • -
  • Eposta imzaları
  • +
  • E-posta imzaları
  • Bcc-to-self
  • Klasör kayıtları
  • Tüm klasörü eşzamanlama
  • @@ -48,8 +48,8 @@ Geliştirilmiş özellikler içerir:
  • …ve daha fazlası
  • -Lütfen K-9\'un çoğu ücretsiz Hotmail hesabını desteklemediğini ve pekçok eposta istemcisi gibi -Microsoft Exchange ile konuşurken bazı tuhaflıklar yaşadığını not ediniz. +K-9\'un çoğu ücretsiz Hotmail hesabını desteklemediğini ve pek çok e-posta istemcisi gibi +Microsoft Exchange ile konuşurken kimi tuhaflıklar yaşadığını lütfen aklınızın bir köşesine yazın.

    Lütfen yeni özelliklere katkıda bulunmak ve soru sormak için hata raporu bildiriniz https://github.com/k9mail/k-9/.

    @@ -1035,4 +1035,22 @@ Bu iletiyi saklayabilir ve gizli anahtarınız için bir yedekleme olarak kullan Aynı zamanda Android de bildirimleri gizlemenize olanak sağlamaktadır. Daha fazlasını öğren Bildirimleri ayarla + + Seç + + Seçimi Kaldır + + + Okunmadı Olarak İşaretle + + Yıldız Ekle + + Yıldızı Kaldır + + Arşiv + + Sil + + Gereksiz posta + 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 58f3149f89..ee7a223abf 100644 --- a/app/ui/legacy/src/main/res/values-uk/strings.xml +++ b/app/ui/legacy/src/main/res/values-uk/strings.xml @@ -1073,4 +1073,22 @@ K-9 Mail — це вільний клієнт електронної пошти Налаштувати сповіщення Якщо вам не потрібне миттєве сповіщення про нові повідомлення, вам варто вимкнути Push і використовувати Polling. Polling перевіряє пошту з регулярними інтервалами і не потребує сповіщення. Вимкнути пуш-сповіщення + + Вибрати + + Скасувати вибір + + + Позначити непрочитаним + + Додати зірочку + + Прибрати зірочку + + Архівувати + + Видалити + + Спам + 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 42b68edb10..b28f7284f2 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 @@ -1058,4 +1058,24 @@ K-9 Mail 是 Android 上一款功能强大的免费邮件客户端。 配置通知 如果您不需要新消息的即时通知,则应禁用推送并使用轮询。 轮询会定期检查新邮件,不需要通知。 禁用推送 + + 选择 + + 取消选择 + + 标记为已读 + + 标记为未读 + + 添加星标 + + 移除星标 + + 归档 + + 删除 + + 标记为垃圾邮件 + + 移动… 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 26f5db4e7b..d032a49ca5 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 @@ -259,13 +259,19 @@ K-9 Mail 是 Android 上一款功能強大,免費的電子郵件用戶端。 全部標記為已讀 刪除 (從通知欄) + 滑動操作 + 右滑 + 左滑 + 切換選擇 + 標記為已讀/未讀 + 新增/移除星號 封存 @@ -694,6 +700,7 @@ K-9 Mail 是 Android 上一款功能強大,免費的電子郵件用戶端。 1000個信件匣 動畫 使用絢麗的視覺特效 + 訊息檢視中音量鍵導航 顯示全域收件匣 顯示有標記星號的數量 全域收件匣 @@ -1051,4 +1058,24 @@ K-9 Mail 是 Android 上一款功能強大,免費的電子郵件用戶端。 設定通知 如果你不需要新郵件的即時通知,則應停用推送並使用輪詢。輪詢會定期檢查新郵件,不需要通知權限。 停用推送 + + 選擇 + + 取消選擇 + + 標記為已讀 + + 標記為未讀 + + 加上星號 + + 移除星號 + + 封存 + + 刪除 + + 標記為垃圾郵件 + + 移動… -- GitLab From 4a7ac2965794a6d1c73e7b8e008196367b0edd9e Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 15 Nov 2022 15:35:05 +0100 Subject: [PATCH 119/121] Version 6.312 --- app/k9mail/build.gradle | 4 ++-- app/ui/legacy/src/main/res/raw/changelog_master.xml | 5 +++++ fastlane/metadata/android/en-US/changelogs/33012.txt | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/33012.txt diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 2e2eef03f0..693a1a0d63 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -51,8 +51,8 @@ android { applicationId "com.fsck.k9" testApplicationId "com.fsck.k9.tests" - versionCode 33011 - versionName '6.312-SNAPSHOT' + versionCode 33012 + versionName '6.312' // 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/ui/legacy/src/main/res/raw/changelog_master.xml b/app/ui/legacy/src/main/res/raw/changelog_master.xml index a3de481aa5..eab64ad04a 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,11 @@ Locale-specific versions are kept in res/raw-/changelog.xml. --> + + Tweaked swipe actions in the message list screen + Fixed a bug where canceling account setup was showing a new account in the side drawer + Updated translations + Don't unexpectedly show and focus the "reply to" field when composing a message Fixed a bug where sometimes toolbar buttons in the message view would affect another message than the one currently being displayed diff --git a/fastlane/metadata/android/en-US/changelogs/33012.txt b/fastlane/metadata/android/en-US/changelogs/33012.txt new file mode 100644 index 0000000000..0cf6ce190c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33012.txt @@ -0,0 +1,3 @@ +- Tweaked swipe actions in the message list screen +- Fixed a bug where canceling account setup was showing a new account in the side drawer +- Updated translations -- GitLab From 7bcb1bc9fe71ccd8e2b75edba3a11d67fcbc98ef Mon Sep 17 00:00:00 2001 From: cketti Date: Tue, 15 Nov 2022 15:49:02 +0100 Subject: [PATCH 120/121] Prepare for version 6.313 --- app/k9mail/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 693a1a0d63..68d751ef68 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -52,7 +52,7 @@ android { testApplicationId "com.fsck.k9.tests" versionCode 33012 - versionName '6.312' + versionName '6.313-SNAPSHOT' // 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", -- GitLab From bf5a03089f7fac355e796ea905b4e9a2fd18d178 Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 24 Nov 2022 18:32:31 +0100 Subject: [PATCH 121/121] Version 6.400 --- app/k9mail/build.gradle | 4 ++-- app/ui/legacy/src/main/res/raw/changelog_master.xml | 9 +++++++++ fastlane/metadata/android/en-US/changelogs/34000.txt | 7 +++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/34000.txt diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 68d751ef68..adb0ca636e 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -51,8 +51,8 @@ android { applicationId "com.fsck.k9" testApplicationId "com.fsck.k9.tests" - versionCode 33012 - versionName '6.313-SNAPSHOT' + versionCode 34000 + versionName '6.400' // 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/ui/legacy/src/main/res/raw/changelog_master.xml b/app/ui/legacy/src/main/res/raw/changelog_master.xml index eab64ad04a..c989d4196f 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,15 @@ Locale-specific versions are kept in res/raw-/changelog.xml. --> + + Added swipe actions to the message list screen + Added support for swiping between messages + Added a monochromatic app icon for Android 13 + Fixed "K-9 Accounts" shortcuts (please remove existing shortcuts and add them again) + Fixed error reporting for (old) send failures + A lot of other bug fixes; see changes for versions 6.3xx + Added Western Frisian translation and updated others + Tweaked swipe actions in the message list screen Fixed a bug where canceling account setup was showing a new account in the side drawer diff --git a/fastlane/metadata/android/en-US/changelogs/34000.txt b/fastlane/metadata/android/en-US/changelogs/34000.txt new file mode 100644 index 0000000000..26c6ab719e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/34000.txt @@ -0,0 +1,7 @@ +- Added swipe actions to the message list screen +- Added support for swiping between messages +- Added a monochromatic app icon for Android 13 +- Fixed "K-9 Accounts" shortcuts (please remove existing shortcuts and add them again) +- Fixed error reporting for (old) send failures +- A lot of other bug fixes; see changes for versions 6.3xx +- Added Western Frisian translation and updated others -- GitLab