diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/notification/NotificationActionTokens.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/notification/NotificationActionTokens.kt new file mode 100644 index 00000000000..c9784f49edc --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/notification/NotificationActionTokens.kt @@ -0,0 +1,24 @@ +package net.thunderbird.core.common.notification + +/** + * Token strings used to persist notification action ordering. + */ +object NotificationActionTokens { + const val REPLY = "reply" + const val MARK_AS_READ = "mark_as_read" + const val DELETE = "delete" + const val STAR = "star" + const val ARCHIVE = "archive" + const val SPAM = "spam" + + val DEFAULT_ORDER: List = listOf(REPLY, MARK_AS_READ, DELETE, STAR, ARCHIVE, SPAM) + + fun parseOrder(raw: String): List { + return raw + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + } + + fun serializeOrder(tokens: List): String = tokens.joinToString(separator = ",") +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreference.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreference.kt index 26e838f8f03..fb583ed29ce 100644 --- a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreference.kt +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreference.kt @@ -1,11 +1,16 @@ package net.thunderbird.core.preference.notification +import net.thunderbird.core.common.notification.NotificationActionTokens import net.thunderbird.core.preference.NotificationQuickDelete const val NOTIFICATION_PREFERENCE_DEFAULT_IS_QUIET_TIME_ENABLED = false const val NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_STARTS = "21:00" const val NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_END = "7:00" const val NOTIFICATION_PREFERENCE_DEFAULT_IS_NOTIFICATION_DURING_QUIET_TIME_ENABLED = true +val NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_ORDER = NotificationActionTokens.DEFAULT_ORDER +const val NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_CUTOFF = 3 +const val NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN = 3 +const val NOTIFICATION_PREFERENCE_DEFAULT_IS_SUMMARY_DELETE_ACTION_ENABLED = true val NOTIFICATION_PREFERENCE_DEFAULT_QUICK_DELETE_BEHAVIOUR = NotificationQuickDelete.ALWAYS data class NotificationPreference( @@ -14,6 +19,9 @@ data class NotificationPreference( val quietTimeEnds: String = NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_END, val isNotificationDuringQuietTimeEnabled: Boolean = NOTIFICATION_PREFERENCE_DEFAULT_IS_NOTIFICATION_DURING_QUIET_TIME_ENABLED, + val messageActionsOrder: List = NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_ORDER, + val messageActionsCutoff: Int = NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_CUTOFF, + val isSummaryDeleteActionEnabled: Boolean = NOTIFICATION_PREFERENCE_DEFAULT_IS_SUMMARY_DELETE_ACTION_ENABLED, val notificationQuickDeleteBehaviour: NotificationQuickDelete = NOTIFICATION_PREFERENCE_DEFAULT_QUICK_DELETE_BEHAVIOUR, ) diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreferenceManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreferenceManager.kt index 550be548018..b40c828d499 100644 --- a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreferenceManager.kt +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreferenceManager.kt @@ -6,6 +6,9 @@ const val KEY_QUIET_TIME_ENDS = "quietTimeEnds" const val KEY_QUIET_TIME_STARTS = "quietTimeStarts" const val KEY_QUIET_TIME_ENABLED = "quietTimeEnabled" const val KEY_NOTIFICATION_DURING_QUIET_TIME_ENABLED = "notificationDuringQuietTimeEnabled" +const val KEY_MESSAGE_ACTIONS_ORDER = "messageActionsOrder" +const val KEY_MESSAGE_ACTIONS_CUTOFF = "messageActionsCutoff" +const val KEY_IS_SUMMARY_DELETE_ACTION_ENABLED = "isSummaryDeleteActionEnabled" const val KEY_NOTIFICATION_QUICK_DELETE_BEHAVIOUR = "notificationQuickDelete" diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/notification/DefaultNotificationPreferenceManager.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/notification/DefaultNotificationPreferenceManager.kt index 8925c88a192..fa87aee186d 100644 --- a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/notification/DefaultNotificationPreferenceManager.kt +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/notification/DefaultNotificationPreferenceManager.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import net.thunderbird.core.common.notification.NotificationActionTokens import net.thunderbird.core.logging.Logger import net.thunderbird.core.preference.storage.Storage import net.thunderbird.core.preference.storage.StorageEditor @@ -47,6 +48,22 @@ class DefaultNotificationPreferenceManager( key = KEY_NOTIFICATION_DURING_QUIET_TIME_ENABLED, defValue = NOTIFICATION_PREFERENCE_DEFAULT_IS_NOTIFICATION_DURING_QUIET_TIME_ENABLED, ), + messageActionsOrder = NotificationActionTokens.parseOrder( + storage.getStringOrDefault( + key = KEY_MESSAGE_ACTIONS_ORDER, + defValue = NotificationActionTokens.serializeOrder( + NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_ORDER, + ), + ), + ), + messageActionsCutoff = storage.getInt( + key = KEY_MESSAGE_ACTIONS_CUTOFF, + defValue = NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_CUTOFF, + ), + isSummaryDeleteActionEnabled = storage.getBoolean( + key = KEY_IS_SUMMARY_DELETE_ACTION_ENABLED, + defValue = NOTIFICATION_PREFERENCE_DEFAULT_IS_SUMMARY_DELETE_ACTION_ENABLED, + ), notificationQuickDeleteBehaviour = storage.getEnumOrDefault( key = KEY_NOTIFICATION_QUICK_DELETE_BEHAVIOUR, default = NOTIFICATION_PREFERENCE_DEFAULT_QUICK_DELETE_BEHAVIOUR, @@ -71,6 +88,12 @@ class DefaultNotificationPreferenceManager( KEY_NOTIFICATION_DURING_QUIET_TIME_ENABLED, config.isNotificationDuringQuietTimeEnabled, ) + storageEditor.putString( + KEY_MESSAGE_ACTIONS_ORDER, + NotificationActionTokens.serializeOrder(config.messageActionsOrder), + ) + storageEditor.putInt(KEY_MESSAGE_ACTIONS_CUTOFF, config.messageActionsCutoff) + storageEditor.putBoolean(KEY_IS_SUMMARY_DELETE_ACTION_ENABLED, config.isSummaryDeleteActionEnabled) storageEditor.putEnum( KEY_NOTIFICATION_QUICK_DELETE_BEHAVIOUR, config.notificationQuickDeleteBehaviour, diff --git a/legacy/common/src/main/AndroidManifest.xml b/legacy/common/src/main/AndroidManifest.xml index d9e2306e681..6e51b381d65 100644 --- a/legacy/common/src/main/AndroidManifest.xml +++ b/legacy/common/src/main/AndroidManifest.xml @@ -182,6 +182,11 @@ android:label="@string/general_settings_title" /> + + , ): PendingIntent { - val intent = NotificationActionService.createArchiveAllIntent(context, account, messageReferences).apply { + val intent = NotificationActionIntents.createArchiveAllIntent(context, account, messageReferences).apply { data = Uri.parse("data:,archiveAll/${account.uuid}/${System.currentTimeMillis()}") } return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!! } override fun createMarkMessageAsSpamPendingIntent(messageReference: MessageReference): PendingIntent { - val intent = NotificationActionService.createMarkMessageAsSpamIntent(context, messageReference).apply { + val intent = NotificationActionIntents.createMarkMessageAsSpamIntent(context, messageReference).apply { data = Uri.parse("data:,spam/${messageReference.toIdentityString()}") } return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!! } + override fun createMarkMessageAsStarPendingIntent(messageReference: MessageReference): PendingIntent { + val intent = NotificationActionIntents.createMarkMessageAsStarIntent(context, messageReference).apply { + data = Uri.parse("data:,star/${messageReference.toIdentityString()}") + } + return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!! + } + private fun createMessageListIntent(account: LegacyAccountDto): Intent { val folderId = defaultFolderProvider.getDefaultFolder(account) val search = LocalMessageSearch().apply { diff --git a/legacy/common/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt b/legacy/common/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt index 415a8fcfc15..19040acf0ee 100644 --- a/legacy/common/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt +++ b/legacy/common/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt @@ -15,6 +15,9 @@ class K9NotificationResourceProvider(private val context: Context) : Notificatio override val iconMarkAsRead: Int = Icons.Outlined.MarkEmailRead override val iconDelete: Int = Icons.Outlined.Delete override val iconReply: Int = Icons.Outlined.Reply + override val iconArchive: Int = Icons.Outlined.Archive + override val iconMarkAsSpam: Int = Icons.Outlined.Report + override val iconStar: Int = Icons.Outlined.Star override val iconNewMail: Int = Icons.Outlined.MarkEmailUnread override val iconSendingMail: Int = Icons.Outlined.Sync override val iconCheckingMail: Int = Icons.Outlined.Sync @@ -103,6 +106,8 @@ class K9NotificationResourceProvider(private val context: Context) : Notificatio override fun actionReply(): String = context.getString(R.string.notification_action_reply) + override fun actionStar(): String = context.getString(R.string.notification_action_star) + override fun actionArchive(): String = context.getString(R.string.notification_action_archive) override fun actionArchiveAll(): String = context.getString(R.string.notification_action_archive_all) diff --git a/legacy/core/build.gradle.kts b/legacy/core/build.gradle.kts index 4f676cf282f..00192187d71 100644 --- a/legacy/core/build.gradle.kts +++ b/legacy/core/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { api(projects.core.mail.mailserver) api(projects.core.android.common) api(projects.core.android.account) + api(projects.core.common) api(projects.core.preference.impl) api(projects.core.android.logging) api(projects.core.logging.implFile) diff --git a/legacy/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt b/legacy/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt index 715080d38f6..2b73af3c3d4 100644 --- a/legacy/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt +++ b/legacy/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt @@ -65,6 +65,9 @@ internal enum class NotificationAction { Reply, MarkAsRead, Delete, + Archive, + Spam, + Star, } internal enum class WearNotificationAction { diff --git a/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt b/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt index 74a1ee28a48..f496e92126e 100644 --- a/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt +++ b/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt @@ -48,4 +48,6 @@ interface NotificationActionCreator { ): PendingIntent fun createMarkMessageAsSpamPendingIntent(messageReference: MessageReference): PendingIntent + + fun createMarkMessageAsStarPendingIntent(messageReference: MessageReference): PendingIntent } diff --git a/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionIntents.kt b/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionIntents.kt new file mode 100644 index 00000000000..8fdee9c9f38 --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionIntents.kt @@ -0,0 +1,119 @@ +package com.fsck.k9.notification + +import android.content.Context +import android.content.Intent +import app.k9mail.legacy.message.controller.MessageReference +import com.fsck.k9.controller.MessageReferenceHelper +import net.thunderbird.core.android.account.LegacyAccountDto + +internal const val ACTION_MARK_AS_READ = "ACTION_MARK_AS_READ" +internal const val ACTION_DELETE = "ACTION_DELETE" +internal const val ACTION_ARCHIVE = "ACTION_ARCHIVE" +internal const val ACTION_SPAM = "ACTION_SPAM" +internal const val ACTION_STAR = "ACTION_STAR" +internal const val ACTION_DISMISS = "ACTION_DISMISS" +internal const val EXTRA_ACCOUNT_UUID = "accountUuid" +internal const val EXTRA_MESSAGE_REFERENCE = "messageReference" +internal const val EXTRA_MESSAGE_REFERENCES = "messageReferences" + +object NotificationActionIntents { + fun createMarkMessageAsReadIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_MARK_AS_READ + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, arrayListOf(messageReference.toIdentityString())) + } + } + + fun createMarkAllAsReadIntent( + context: Context, + accountUuid: String, + messageReferences: List, + ): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_MARK_AS_READ + putExtra(EXTRA_ACCOUNT_UUID, accountUuid) + putExtra( + EXTRA_MESSAGE_REFERENCES, + MessageReferenceHelper.toMessageReferenceStringList(messageReferences), + ) + } + } + + fun createDismissMessageIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DISMISS + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) + } + } + + fun createDismissAllMessagesIntent(context: Context, account: LegacyAccountDto): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DISMISS + putExtra(EXTRA_ACCOUNT_UUID, account.uuid) + } + } + + fun createDeleteMessageIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DELETE + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, arrayListOf(messageReference.toIdentityString())) + } + } + + fun createDeleteAllMessagesIntent( + context: Context, + accountUuid: String, + messageReferences: List, + ): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DELETE + putExtra(EXTRA_ACCOUNT_UUID, accountUuid) + putExtra( + EXTRA_MESSAGE_REFERENCES, + MessageReferenceHelper.toMessageReferenceStringList(messageReferences), + ) + } + } + + fun createArchiveMessageIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_ARCHIVE + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, arrayListOf(messageReference.toIdentityString())) + } + } + + fun createArchiveAllIntent( + context: Context, + account: LegacyAccountDto, + messageReferences: List, + ): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_ARCHIVE + putExtra(EXTRA_ACCOUNT_UUID, account.uuid) + putExtra( + EXTRA_MESSAGE_REFERENCES, + MessageReferenceHelper.toMessageReferenceStringList(messageReferences), + ) + } + } + + fun createMarkMessageAsSpamIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_SPAM + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) + } + } + + fun createMarkMessageAsStarIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_STAR + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, arrayListOf(messageReference.toIdentityString())) + } + } +} diff --git a/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt b/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt index e339cf9a931..096c982dc79 100644 --- a/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt +++ b/legacy/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt @@ -1,7 +1,6 @@ package com.fsck.k9.notification import android.app.Service -import android.content.Context import android.content.Intent import android.os.IBinder import app.k9mail.legacy.message.controller.MessageReference @@ -58,6 +57,7 @@ class NotificationActionService : Service() { ACTION_DELETE -> deleteMessages(intent) ACTION_ARCHIVE -> archiveMessages(intent) ACTION_SPAM -> markMessageAsSpam(intent, account) + ACTION_STAR -> markMessagesAsStarred(intent, account) ACTION_DISMISS -> Log.i("Notification dismissed") } @@ -122,6 +122,19 @@ class NotificationActionService : Service() { } } + private fun markMessagesAsStarred(intent: Intent, account: LegacyAccountDto) { + Log.i("NotificationActionService starring messages") + + val messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES) + val messageReferences = MessageReferenceHelper.toMessageReferenceList(messageReferenceStrings) + + for (messageReference in messageReferences) { + val folderId = messageReference.folderId + val uid = messageReference.uid + messagingController.setFlag(account, folderId, uid, Flag.FLAGGED, true) + } + } + private fun cancelNotifications(intent: Intent, account: LegacyAccountDto) { if (intent.hasExtra(EXTRA_MESSAGE_REFERENCE)) { val messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE) @@ -143,113 +156,4 @@ class NotificationActionService : Service() { messagingController.cancelNotificationsForAccount(account) } } - - companion object { - private const val ACTION_MARK_AS_READ = "ACTION_MARK_AS_READ" - private const val ACTION_DELETE = "ACTION_DELETE" - private const val ACTION_ARCHIVE = "ACTION_ARCHIVE" - private const val ACTION_SPAM = "ACTION_SPAM" - private const val ACTION_DISMISS = "ACTION_DISMISS" - private const val EXTRA_ACCOUNT_UUID = "accountUuid" - private const val EXTRA_MESSAGE_REFERENCE = "messageReference" - private const val EXTRA_MESSAGE_REFERENCES = "messageReferences" - - fun createMarkMessageAsReadIntent(context: Context, messageReference: MessageReference): Intent { - return Intent(context, NotificationActionService::class.java).apply { - action = ACTION_MARK_AS_READ - putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) - putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)) - } - } - - fun createMarkAllAsReadIntent( - context: Context, - accountUuid: String, - messageReferences: List, - ): Intent { - return Intent(context, NotificationActionService::class.java).apply { - action = ACTION_MARK_AS_READ - putExtra(EXTRA_ACCOUNT_UUID, accountUuid) - putExtra( - EXTRA_MESSAGE_REFERENCES, - MessageReferenceHelper.toMessageReferenceStringList(messageReferences), - ) - } - } - - fun createDismissMessageIntent(context: Context, messageReference: MessageReference): Intent { - return Intent(context, NotificationActionService::class.java).apply { - action = ACTION_DISMISS - putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) - putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) - } - } - - fun createDismissAllMessagesIntent(context: Context, account: LegacyAccountDto): Intent { - return Intent(context, NotificationActionService::class.java).apply { - action = ACTION_DISMISS - putExtra(EXTRA_ACCOUNT_UUID, account.uuid) - } - } - - fun createDeleteMessageIntent(context: Context, messageReference: MessageReference): Intent { - return Intent(context, NotificationActionService::class.java).apply { - action = ACTION_DELETE - putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) - putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)) - } - } - - fun createDeleteAllMessagesIntent( - context: Context, - accountUuid: String, - messageReferences: List, - ): Intent { - return Intent(context, NotificationActionService::class.java).apply { - action = ACTION_DELETE - putExtra(EXTRA_ACCOUNT_UUID, accountUuid) - putExtra( - EXTRA_MESSAGE_REFERENCES, - MessageReferenceHelper.toMessageReferenceStringList(messageReferences), - ) - } - } - - fun createArchiveMessageIntent(context: Context, messageReference: MessageReference): Intent { - return Intent(context, NotificationActionService::class.java).apply { - action = ACTION_ARCHIVE - putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) - putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)) - } - } - - fun createArchiveAllIntent( - context: Context, - account: LegacyAccountDto, - messageReferences: List, - ): Intent { - return Intent(context, NotificationActionService::class.java).apply { - action = ACTION_ARCHIVE - putExtra(EXTRA_ACCOUNT_UUID, account.uuid) - putExtra( - EXTRA_MESSAGE_REFERENCES, - MessageReferenceHelper.toMessageReferenceStringList(messageReferences), - ) - } - } - - fun createMarkMessageAsSpamIntent(context: Context, messageReference: MessageReference): Intent { - return Intent(context, NotificationActionService::class.java).apply { - action = ACTION_SPAM - putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) - putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) - } - } - - private fun createSingleItemArrayList(messageReference: MessageReference): ArrayList { - return ArrayList(1).apply { - add(messageReference.toIdentityString()) - } - } - } } diff --git a/legacy/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt b/legacy/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt index 83c4d3dcccf..c0fda0299ad 100644 --- a/legacy/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt +++ b/legacy/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt @@ -8,6 +8,9 @@ interface NotificationResourceProvider { val iconMarkAsRead: Int val iconDelete: Int val iconReply: Int + val iconArchive: Int + val iconMarkAsSpam: Int + val iconStar: Int val iconNewMail: Int val iconSendingMail: Int val iconCheckingMail: Int @@ -57,6 +60,7 @@ interface NotificationResourceProvider { fun actionDelete(): String fun actionDeleteAll(): String fun actionReply(): String + fun actionStar(): String fun actionArchive(): String fun actionArchiveAll(): String fun actionMarkAsSpam(): String diff --git a/legacy/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt b/legacy/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt index bd26ab90473..76b2fef3ad7 100644 --- a/legacy/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt +++ b/legacy/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt @@ -79,6 +79,9 @@ internal class SingleMessageNotificationCreator( NotificationAction.Reply -> addReplyAction(notificationData) NotificationAction.MarkAsRead -> addMarkAsReadAction(notificationData) NotificationAction.Delete -> addDeleteAction(notificationData) + NotificationAction.Archive -> addArchiveAction(notificationData) + NotificationAction.Spam -> addMarkAsSpamAction(notificationData) + NotificationAction.Star -> addStarAction(notificationData) } } } @@ -113,6 +116,33 @@ internal class SingleMessageNotificationCreator( addAction(icon, title, action) } + private fun NotificationBuilder.addArchiveAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.iconArchive + val title = resourceProvider.actionArchive() + val messageReference = notificationData.content.messageReference + val action = actionCreator.createArchiveMessagePendingIntent(messageReference) + + addAction(icon, title, action) + } + + private fun NotificationBuilder.addMarkAsSpamAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.iconMarkAsSpam + val title = resourceProvider.actionMarkAsSpam() + val messageReference = notificationData.content.messageReference + val action = actionCreator.createMarkMessageAsSpamPendingIntent(messageReference) + + addAction(icon, title, action) + } + + private fun NotificationBuilder.addStarAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.iconStar + val title = resourceProvider.actionStar() + val messageReference = notificationData.content.messageReference + val action = actionCreator.createMarkMessageAsStarPendingIntent(messageReference) + + addAction(icon, title, action) + } + private fun NotificationBuilder.setWearActions(notificationData: SingleNotificationData) = apply { val wearableExtender = WearableExtender().apply { for (action in notificationData.wearActions) { diff --git a/legacy/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt b/legacy/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt index 7d0ec0c3130..58b1987f034 100644 --- a/legacy/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt +++ b/legacy/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt @@ -1,8 +1,10 @@ package com.fsck.k9.notification import net.thunderbird.core.android.account.LegacyAccountDto +import net.thunderbird.core.common.notification.NotificationActionTokens import net.thunderbird.core.preference.NotificationQuickDelete import net.thunderbird.core.preference.interaction.InteractionSettingsPreferenceManager +import net.thunderbird.core.preference.notification.NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN import net.thunderbird.core.preference.notification.NotificationPreferenceManager internal class SingleMessageNotificationDataCreator( @@ -25,7 +27,7 @@ internal class SingleMessageNotificationDataCreator( isSilent = true, timestamp = timestamp, content = content, - actions = createSingleNotificationActions(), + actions = createSingleNotificationActions(account), wearActions = createSingleNotificationWearActions(account), addLockScreenNotification = addLockScreenNotification, ) @@ -42,22 +44,28 @@ internal class SingleMessageNotificationDataCreator( isSilent = silent, timestamp = timestamp, content = data.activeNotifications.first().content, - actions = createSingleNotificationActions(), + actions = createSingleNotificationActions(data.account), wearActions = createSingleNotificationWearActions(data.account), addLockScreenNotification = false, ), ) } - private fun createSingleNotificationActions(): List { - return buildList { - add(NotificationAction.Reply) - add(NotificationAction.MarkAsRead) + private fun createSingleNotificationActions(account: LegacyAccountDto): List { + val order = parseActionsOrder(notificationSettings.messageActionsOrder) + val cutoff = notificationSettings.messageActionsCutoff.coerceIn( + 0, + NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN, + ) - if (isDeleteActionEnabled()) { - add(NotificationAction.Delete) - } - } + return resolveActions( + order = order, + cutoff = cutoff, + hasArchiveFolder = account.hasArchiveFolder(), + isDeleteEnabled = isDeleteActionEnabled(), + hasSpamFolder = account.hasSpamFolder(), + isSpamEnabled = !interactionSettings.isConfirmSpam, + ) } private fun createSingleNotificationWearActions(account: LegacyAccountDto): List { @@ -79,13 +87,84 @@ internal class SingleMessageNotificationDataCreator( } } + private fun resolveActions( + order: List, + cutoff: Int, + hasArchiveFolder: Boolean, + isDeleteEnabled: Boolean, + hasSpamFolder: Boolean, + isSpamEnabled: Boolean, + ): List { + val desired = order.take(cutoff).filter { action -> + action.isAvailable( + hasArchiveFolder = hasArchiveFolder, + isDeleteEnabled = isDeleteEnabled, + hasSpamFolder = hasSpamFolder, + isSpamEnabled = isSpamEnabled, + ) + } + if (desired.size == NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN) return desired + + val filled = buildList { + addAll(desired) + for (action in order.drop(cutoff)) { + if (size == NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN) break + if ( + action !in this && + action.isAvailable( + hasArchiveFolder = hasArchiveFolder, + isDeleteEnabled = isDeleteEnabled, + hasSpamFolder = hasSpamFolder, + isSpamEnabled = isSpamEnabled, + ) + ) { + add(action) + } + } + } + + return filled + } + + private fun parseActionsOrder(tokens: List): List { + val seen = LinkedHashSet() + for (token in tokens) { + tokenToAction(token)?.let { seen.add(it) } + } + + for (action in listOf( + NotificationAction.Reply, + NotificationAction.MarkAsRead, + NotificationAction.Delete, + NotificationAction.Star, + NotificationAction.Archive, + NotificationAction.Spam, + )) { + seen.add(action) + } + + return seen.toList() + } + + private fun tokenToAction(token: String): NotificationAction? { + return when (token) { + NotificationActionTokens.REPLY -> NotificationAction.Reply + NotificationActionTokens.MARK_AS_READ -> NotificationAction.MarkAsRead + NotificationActionTokens.DELETE -> NotificationAction.Delete + NotificationActionTokens.STAR -> NotificationAction.Star + NotificationActionTokens.ARCHIVE -> NotificationAction.Archive + NotificationActionTokens.SPAM -> NotificationAction.Spam + else -> null + } + } + private fun isDeleteActionEnabled(): Boolean { return notificationSettings.notificationQuickDeleteBehaviour != NotificationQuickDelete.NEVER } // We don't support confirming actions on Wear devices. So don't show the action when confirmation is enabled. private fun isDeleteActionAvailableForWear(): Boolean { - return isDeleteActionEnabled() && !interactionSettings.isConfirmDeleteFromNotification + return !interactionSettings.isConfirmDeleteFromNotification } // We don't support confirming actions on Wear devices. So don't show the action when confirmation is enabled. @@ -93,3 +172,19 @@ internal class SingleMessageNotificationDataCreator( return account.hasSpamFolder() && !interactionSettings.isConfirmSpam } } + +private fun NotificationAction.isAvailable( + hasArchiveFolder: Boolean, + isDeleteEnabled: Boolean, + hasSpamFolder: Boolean, + isSpamEnabled: Boolean, +): Boolean { + return when (this) { + NotificationAction.Reply -> true + NotificationAction.MarkAsRead -> true + NotificationAction.Delete -> isDeleteEnabled + NotificationAction.Archive -> hasArchiveFolder + NotificationAction.Spam -> hasSpamFolder && isSpamEnabled + NotificationAction.Star -> true + } +} diff --git a/legacy/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt b/legacy/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt index 6d48efe7aa3..4d1677ec1d4 100644 --- a/legacy/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt +++ b/legacy/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt @@ -2,7 +2,6 @@ package com.fsck.k9.notification import net.thunderbird.core.android.account.LegacyAccountDto import net.thunderbird.core.preference.GeneralSettingsManager -import net.thunderbird.core.preference.NotificationQuickDelete private const val MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION = 5 @@ -51,7 +50,7 @@ internal class SummaryNotificationDataCreator( return buildList { add(SummaryNotificationAction.MarkAsRead) - if (isDeleteActionEnabled()) { + if (isSummaryDeleteActionEnabled()) { add(SummaryNotificationAction.Delete) } } @@ -71,14 +70,13 @@ internal class SummaryNotificationDataCreator( } } - private fun isDeleteActionEnabled(): Boolean { - return generalSettingsManager.getConfig().notification.notificationQuickDeleteBehaviour == - NotificationQuickDelete.ALWAYS + private fun isSummaryDeleteActionEnabled(): Boolean { + return generalSettingsManager.getConfig().notification.isSummaryDeleteActionEnabled } // We don't support confirming actions on Wear devices. So don't show the action when confirmation is enabled. private fun isDeleteActionAvailableForWear(): Boolean { - return isDeleteActionEnabled() && !interactionSettings.isConfirmDeleteFromNotification + return isSummaryDeleteActionEnabled() && !interactionSettings.isConfirmDeleteFromNotification } private val NotificationData.latestTimestamp: Long diff --git a/legacy/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt b/legacy/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt index 69e1db0ef5a..928a5c9274c 100644 --- a/legacy/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt +++ b/legacy/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt @@ -54,6 +54,16 @@ class NewMailNotificationManagerTest { private val notificationContentCreator = mock() private val localStoreProvider = createLocalStoreProvider() private val clock = TestClock(Instant.fromEpochMilliseconds(TIMESTAMP)) + private val generalSettings = GeneralSettings( + display = DisplaySettings(), + network = NetworkSettings(), + notification = NotificationPreference( + quietTimeStarts = "23:00", + quietTimeEnds = "00:00", + ), + privacy = PrivacySettings(), + platformConfigProvider = FakePlatformConfigProvider(), + ) private val manager = NewMailNotificationManager( notificationContentCreator, createNotificationRepository(), @@ -62,26 +72,17 @@ class NewMailNotificationManagerTest { interactionPreferences = mock { on { getConfig() } doReturn InteractionSettings() }, - notificationPreference = mock { on { getConfig() } doReturn NotificationPreference() }, + notificationPreference = mock { on { getConfig() } doReturn generalSettings.notification }, ), SummaryNotificationDataCreator( singleMessageNotificationDataCreator = SingleMessageNotificationDataCreator( interactionPreferences = mock { on { getConfig() } doReturn InteractionSettings() }, - notificationPreference = mock { on { getConfig() } doReturn NotificationPreference() }, + notificationPreference = mock { on { getConfig() } doReturn generalSettings.notification }, ), generalSettingsManager = mock { - on { getConfig() } doReturn GeneralSettings( - display = DisplaySettings(), - network = NetworkSettings(), - notification = NotificationPreference( - quietTimeStarts = "23:00", - quietTimeEnds = "00:00", - ), - privacy = PrivacySettings(), - platformConfigProvider = FakePlatformConfigProvider(), - ) + on { getConfig() } doReturn generalSettings }, ), clock, diff --git a/legacy/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt b/legacy/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt index 07aef6f998c..fa9c95587db 100644 --- a/legacy/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt +++ b/legacy/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt @@ -87,7 +87,7 @@ class SingleMessageNotificationDataCreatorTest { @Test fun `always show delete action without confirmation`() { - setDeleteAction(NotificationQuickDelete.ALWAYS) + setMessageActions(cutoff = 3) fakeInteractionPreferences.setConfirmDeleteFromNotification(false) val content = createNotificationContent() @@ -105,7 +105,7 @@ class SingleMessageNotificationDataCreatorTest { @Test fun `always show delete action with confirmation`() { - setDeleteAction(NotificationQuickDelete.ALWAYS) + setMessageActions(cutoff = 3) fakeInteractionPreferences.setConfirmDeleteFromNotification(true) val content = createNotificationContent() @@ -122,27 +122,8 @@ class SingleMessageNotificationDataCreatorTest { } @Test - fun `show delete action for single notification without confirmation`() { - setDeleteAction(NotificationQuickDelete.FOR_SINGLE_MSG) - fakeInteractionPreferences.setConfirmDeleteFromNotification(false) - val content = createNotificationContent() - - val result = notificationDataCreator.createSingleNotificationData( - account = account, - notificationId = 0, - content = content, - timestamp = 0, - addLockScreenNotification = false, - ) - - assertThat(result.actions).contains(NotificationAction.Delete) - assertThat(result.wearActions).contains(WearNotificationAction.Delete) - } - - @Test - fun `show delete action for single notification with confirmation`() { - setDeleteAction(NotificationQuickDelete.FOR_SINGLE_MSG) - fakeInteractionPreferences.setConfirmDeleteFromNotification(true) + fun `fill actions below cutoff up to max shown`() { + setMessageActions(cutoff = 2) val content = createNotificationContent() val result = notificationDataCreator.createSingleNotificationData( @@ -154,24 +135,6 @@ class SingleMessageNotificationDataCreatorTest { ) assertThat(result.actions).contains(NotificationAction.Delete) - assertThat(result.wearActions).doesNotContain(WearNotificationAction.Delete) - } - - @Test - fun `never show delete action`() { - setDeleteAction(NotificationQuickDelete.NEVER) - val content = createNotificationContent() - - val result = notificationDataCreator.createSingleNotificationData( - account = account, - notificationId = 0, - content = content, - timestamp = 0, - addLockScreenNotification = false, - ) - - assertThat(result.actions).doesNotContain(NotificationAction.Delete) - assertThat(result.wearActions).doesNotContain(WearNotificationAction.Delete) } @Test @@ -257,8 +220,11 @@ class SingleMessageNotificationDataCreatorTest { assertThat(result.wearActions).doesNotContain(WearNotificationAction.Spam) } - private fun setDeleteAction(mode: NotificationQuickDelete) { - fakeNotificationPreferences.setNotificationQuickDeleteBehaviour(mode) + private fun setMessageActions(cutoff: Int) { + fakeNotificationPreferences.setMessageActions( + order = listOf("reply", "mark_as_read", "delete", "archive", "spam"), + cutoff = cutoff, + ) } private fun createAccount(): LegacyAccountDto { @@ -318,5 +284,9 @@ class SingleMessageNotificationDataCreatorTest { fun setNotificationQuickDeleteBehaviour(behaviour: NotificationQuickDelete) { prefs.update { it.copy(notificationQuickDeleteBehaviour = behaviour) } } + + fun setMessageActions(order: List, cutoff: Int) { + prefs.update { it.copy(messageActionsOrder = order, messageActionsCutoff = cutoff) } + } } } diff --git a/legacy/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt b/legacy/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt index e5f25f07743..1b6476faa11 100644 --- a/legacy/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt +++ b/legacy/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt @@ -304,8 +304,13 @@ class SummaryNotificationDataCreatorTest { } private fun setDeleteAction(mode: NotificationQuickDelete) { + val isSummaryDeleteActionEnabled = mode == NotificationQuickDelete.ALWAYS + generalSettings = generalSettings.copy( - notification = generalSettings.notification.copy(notificationQuickDeleteBehaviour = mode), + notification = generalSettings.notification.copy( + isSummaryDeleteActionEnabled = isSummaryDeleteActionEnabled, + notificationQuickDeleteBehaviour = mode, + ), ) } diff --git a/legacy/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt b/legacy/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt index 044384a4ed3..fd84d7bca4d 100644 --- a/legacy/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt +++ b/legacy/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt @@ -8,6 +8,9 @@ class TestNotificationResourceProvider : NotificationResourceProvider { override val iconMarkAsRead: Int = 2 override val iconDelete: Int = 3 override val iconReply: Int = 4 + override val iconArchive: Int = 14 + override val iconMarkAsSpam: Int = 15 + override val iconStar: Int = 16 override val iconNewMail: Int = 5 override val iconSendingMail: Int = 6 override val iconCheckingMail: Int = 7 @@ -86,6 +89,8 @@ class TestNotificationResourceProvider : NotificationResourceProvider { override fun actionReply(): String = "Reply" + override fun actionStar(): String = "Star" + override fun actionArchive(): String = "Archive" override fun actionArchiveAll(): String = "Archive All" diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/notification/DeleteConfirmationActivity.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/notification/DeleteConfirmationActivity.kt index b0f6725d2b7..98752fe81b3 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/notification/DeleteConfirmationActivity.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/notification/DeleteConfirmationActivity.kt @@ -10,7 +10,7 @@ import com.fsck.k9.controller.MessageReferenceHelper import com.fsck.k9.controller.MessagingController import com.fsck.k9.fragment.ConfirmationDialogFragment import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener -import com.fsck.k9.notification.NotificationActionService +import com.fsck.k9.notification.NotificationActionIntents import com.fsck.k9.ui.R import com.fsck.k9.ui.base.BaseActivity import com.fsck.k9.ui.base.ThemeType @@ -89,7 +89,7 @@ class DeleteConfirmationActivity : BaseActivity(ThemeType.DIALOG), ConfirmationD } private fun triggerDelete() { - val intent = NotificationActionService.createDeleteAllMessagesIntent(this, account.uuid, messagesToDelete) + val intent = NotificationActionIntents.createDeleteAllMessagesIntent(this, account.uuid, messagesToDelete) startService(intent) } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt index 25eb4645144..993169f021a 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt @@ -12,7 +12,6 @@ import net.thunderbird.core.preference.AppTheme import net.thunderbird.core.preference.BackgroundOps import net.thunderbird.core.preference.BodyContentType import net.thunderbird.core.preference.GeneralSettingsManager -import net.thunderbird.core.preference.NotificationQuickDelete import net.thunderbird.core.preference.SplitViewMode import net.thunderbird.core.preference.SubTheme import net.thunderbird.core.preference.display.visualSettings.message.list.MessageListDateTimeFormat @@ -60,6 +59,7 @@ class GeneralSettingsDataStore( "drawerExpandAllFolder" -> visualSettings.drawerExpandAllFolder "quiet_time_enabled" -> notificationSettings.isQuietTimeEnabled "disable_notifications_during_quiet_time" -> !notificationSettings.isNotificationDuringQuietTimeEnabled + "notification_summary_delete" -> notificationSettings.isSummaryDeleteActionEnabled "privacy_hide_useragent" -> privacySettings.isHideUserAgent "privacy_hide_timezone" -> privacySettings.isHideTimeZone "debug_logging" -> debuggingSettings.isDebugLoggingEnabled @@ -101,6 +101,7 @@ class GeneralSettingsDataStore( "messageview_autofit_width" -> setIsAutoFitWidth(isAutoFitWidth = value) "quiet_time_enabled" -> setIsQuietTimeEnabled(isQuietTimeEnabled = value) "disable_notifications_during_quiet_time" -> setIsNotificationDuringQuietTimeEnabled(!value) + "notification_summary_delete" -> setIsSummaryDeleteActionEnabled(isSummaryDeleteActionEnabled = value) "privacy_hide_useragent" -> setIsHideUserAgent(isHideUserAgent = value) "privacy_hide_timezone" -> setIsHideTimeZone(isHideTimeZone = value) "debug_logging" -> setIsDebugLoggingEnabled(isDebugLoggingEnabled = value) @@ -151,7 +152,6 @@ class GeneralSettingsDataStore( "messagelist_preview_lines" -> messageListSettings.previewLines.toString() "message_list_date_time_format" -> messageListSettings.dateTimeFormat.toString() "splitview_mode" -> coreSettings.splitViewMode.name - "notification_quick_delete" -> notificationSettings.notificationQuickDeleteBehaviour.name "lock_screen_notification_visibility" -> K9.lockScreenNotificationVisibility.name "background_ops" -> networkSettings.backgroundOps.name "quiet_time_starts" -> notificationSettings.quietTimeStarts @@ -191,12 +191,6 @@ class GeneralSettingsDataStore( "messagelist_preview_lines" -> setMessageListPreviewLines(value.toInt()) "message_list_date_time_format" -> updateMessageListDateTimeFormat(value) "splitview_mode" -> setSplitViewModel(SplitViewMode.valueOf(value.uppercase())) - "notification_quick_delete" -> { - setNotificationQuickDeleteBehaviour( - behaviour = NotificationQuickDelete.valueOf(value), - ) - } - "lock_screen_notification_visibility" -> { K9.lockScreenNotificationVisibility = K9.LockScreenNotificationVisibility.valueOf(value) } @@ -370,13 +364,6 @@ class GeneralSettingsDataStore( } } - private fun setNotificationQuickDeleteBehaviour(behaviour: NotificationQuickDelete) { - skipSaveSettings = true - generalSettingsManager.update { settings -> - settings.copy(notification = settings.notification.copy(notificationQuickDeleteBehaviour = behaviour)) - } - } - private fun setFixedMessageViewTheme(fixedMessageViewTheme: Boolean) { skipSaveSettings = true generalSettingsManager.update { settings -> @@ -660,6 +647,17 @@ class GeneralSettingsDataStore( } } + private fun setIsSummaryDeleteActionEnabled(isSummaryDeleteActionEnabled: Boolean) { + skipSaveSettings = true + generalSettingsManager.update { settings -> + settings.copy( + notification = settings.notification.copy( + isSummaryDeleteActionEnabled = isSummaryDeleteActionEnabled, + ), + ) + } + } + private fun setIsHideTimeZone(isHideTimeZone: Boolean) { skipSaveSettings = true generalSettingsManager.update { settings -> diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt index 241e1378dcc..20a3cfb36c9 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt @@ -19,6 +19,7 @@ import com.fsck.k9.ui.BuildConfig import com.fsck.k9.ui.R import com.fsck.k9.ui.base.extensions.withArguments import com.fsck.k9.ui.observe +import com.fsck.k9.ui.settings.notificationactions.NotificationActionsSettingsActivity import com.fsck.k9.ui.settings.remove import com.google.android.material.snackbar.Snackbar import com.takisoft.preferencex.PreferenceFragmentCompat @@ -78,6 +79,13 @@ class GeneralSettingsFragment : PreferenceFragmentCompat() { preferenceManager.preferenceDataStore = dataStore this.rootKey = rootKey setPreferencesFromResource(R.xml.general_settings, rootKey) + + findPreference("notification_actions_settings")?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + context?.let { NotificationActionsSettingsActivity.start(it) } + true + } + val listener = Preference.OnPreferenceChangeListener { _, newValue -> if (!(newValue as Boolean)) { jobManager.cancelDebugLogLimit() diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/MessageNotificationAction.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/MessageNotificationAction.kt new file mode 100644 index 00000000000..1bfddb6882a --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/MessageNotificationAction.kt @@ -0,0 +1,66 @@ +package com.fsck.k9.ui.settings.notificationactions + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import app.k9mail.core.ui.legacy.designsystem.atom.icon.Icons +import com.fsck.k9.ui.R +import net.thunderbird.core.common.notification.NotificationActionTokens + +/** + * Actions available for message notifications in the settings UI. + * Tokens map to persisted preference strings. + */ +internal enum class MessageNotificationAction( + val token: String, + @StringRes val labelRes: Int, + @DrawableRes val iconRes: Int, +) { + Reply( + token = NotificationActionTokens.REPLY, + labelRes = R.string.notification_action_reply, + iconRes = Icons.Outlined.Reply, + ), + MarkAsRead( + token = NotificationActionTokens.MARK_AS_READ, + labelRes = R.string.notification_action_mark_as_read, + iconRes = Icons.Outlined.MarkEmailRead, + ), + Delete( + token = NotificationActionTokens.DELETE, + labelRes = R.string.notification_action_delete, + iconRes = Icons.Outlined.Delete, + ), + Star( + token = NotificationActionTokens.STAR, + labelRes = R.string.notification_action_star, + iconRes = Icons.Outlined.Star, + ), + Archive( + token = NotificationActionTokens.ARCHIVE, + labelRes = R.string.notification_action_archive, + iconRes = Icons.Outlined.Archive, + ), + Spam( + token = NotificationActionTokens.SPAM, + labelRes = R.string.notification_action_spam, + iconRes = Icons.Outlined.Report, + ), + ; + + companion object { + fun fromToken(token: String): MessageNotificationAction? { + return entries.firstOrNull { it.token == token } + } + + fun defaultOrder(): List { + val seen = LinkedHashSet() + for (token in NotificationActionTokens.DEFAULT_ORDER) { + fromToken(token)?.let { seen.add(it) } + } + for (action in entries) { + seen.add(action) + } + return seen.toList() + } + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsReorderController.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsReorderController.kt new file mode 100644 index 00000000000..14cbff58973 --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsReorderController.kt @@ -0,0 +1,248 @@ +package com.fsck.k9.ui.settings.notificationactions + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import net.thunderbird.core.preference.notification.NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN + +private const val SWAP_HYSTERESIS_PX = 8f + +internal data class ReorderVisibleItem( + val key: String, + val offset: Int, + val size: Int, +) + +internal class NotificationActionsReorderController( + initialActions: List, + initialCutoff: Int, + private val onStateChanged: (List, Int) -> Unit, +) { + private val renderedItemsState = mutableStateListOf() + private var cutoffIndexState by mutableIntStateOf(0) + + var draggedKey by mutableStateOf(null) + private set + + var draggedOffsetY by mutableFloatStateOf(0f) + private set + + private var dragDidMove = false + + val items: List + get() = renderedItemsState + + val cutoffIndex: Int + get() = cutoffIndexState + + init { + setState(initialActions = initialActions, initialCutoff = initialCutoff) + } + + fun setState( + initialActions: List, + initialCutoff: Int, + ) { + renderedItemsState.clear() + renderedItemsState.addAll(buildRenderedItems(actions = initialActions, cutoff = initialCutoff)) + cutoffIndexState = renderedItemsState.indexOfFirst { it is NotificationListItem.Cutoff } + draggedKey = null + draggedOffsetY = 0f + dragDidMove = false + } + + fun startDrag(itemKey: String) { + draggedKey = itemKey + draggedOffsetY = 0f + dragDidMove = false + } + + fun dragBy(deltaY: Float, visibleItems: List) { + val key = draggedKey ?: return + draggedOffsetY += deltaY + + val fromIndex = renderedItemsState.indexOfFirst { it.key == key } + val draggedItem = visibleItems.firstOrNull { it.key == key } + if (fromIndex == -1 || draggedItem == null) return + + val swapDecision = when { + deltaY > 0f -> { + val nextItem = renderedItemsState + .getOrNull(fromIndex + 1) + ?.key + ?.let { nextKey -> visibleItems.firstOrNull { it.key == nextKey } } + + nextItem + ?.takeIf { + val draggedBottom = draggedItem.offset + draggedItem.size + draggedOffsetY + val nextThreshold = it.offset + (it.size / 2f) + draggedBottom >= nextThreshold + SWAP_HYSTERESIS_PX + } + ?.let { + SwapDecision( + toIndex = fromIndex + 1, + offsetCompensation = -it.size.toFloat(), + ) + } + } + + deltaY < 0f -> { + val upwardSwap = calculateUpwardSwap( + fromIndex = fromIndex, + visibleItems = visibleItems, + ) + upwardSwap + ?.takeIf { + val draggedTop = draggedItem.offset + draggedOffsetY + draggedTop <= it.threshold - SWAP_HYSTERESIS_PX + } + ?.let { + SwapDecision( + toIndex = fromIndex - 1, + offsetCompensation = it.offsetCompensation, + ) + } + } + + else -> null + } + + if (swapDecision != null && moveRenderedItem(from = fromIndex, to = swapDecision.toIndex)) { + dragDidMove = true + draggedOffsetY += swapDecision.offsetCompensation + } + } + + private data class SwapDecision( + val toIndex: Int, + val offsetCompensation: Float, + ) + + private data class UpwardSwapConfig( + val threshold: Float, + val offsetCompensation: Float, + ) + + private fun calculateUpwardSwap( + fromIndex: Int, + visibleItems: List, + ): UpwardSwapConfig? { + val previousItemKey = renderedItemsState.getOrNull(fromIndex - 1)?.key + val previousItem = previousItemKey?.let { previousKey -> visibleItems.firstOrNull { it.key == previousKey } } + val maxPos = NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN + .coerceAtMost(renderedItemsState.lastIndex) + + return if (previousItemKey == null || previousItem == null) { + null + } else if (previousItemKey == NotificationListItem.Cutoff.key && cutoffIndexState >= maxPos) { + val lastAboveKey = renderedItemsState.getOrNull(fromIndex - 2)?.key + val lastAboveItem = lastAboveKey?.let { aboveKey -> visibleItems.firstOrNull { it.key == aboveKey } } + lastAboveItem?.let { + UpwardSwapConfig( + threshold = it.offset + (it.size / 2f), + offsetCompensation = (previousItem.size + it.size).toFloat(), + ) + } + } else { + UpwardSwapConfig( + threshold = previousItem.offset + (previousItem.size / 2f), + offsetCompensation = previousItem.size.toFloat(), + ) + } + } + + fun endDrag() { + val shouldNotify = dragDidMove + + draggedOffsetY = 0f + draggedKey = null + dragDidMove = false + + if (!shouldNotify) return + + notifyStateChanged() + } + + fun moveByStep(itemKey: String, delta: Int): Boolean { + val from = renderedItemsState.indexOfFirst { it.key == itemKey } + val to = from + delta + val didMove = from != -1 && moveRenderedItem(from = from, to = to) + if (didMove) { + notifyStateChanged() + } + return didMove + } + + fun canMove(itemKey: String, delta: Int): Boolean { + val from = renderedItemsState.indexOfFirst { it.key == itemKey } + if (from == -1) return false + + val to = from + delta + val maxPos = NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN + .coerceAtMost(renderedItemsState.lastIndex) + + // Don't allow the divider to be dragged to more than the max position + return from in renderedItemsState.indices && + to in renderedItemsState.indices && + from != to && + !(from == cutoffIndexState && to > maxPos) + } + + private fun moveRenderedItem(from: Int, to: Int): Boolean { + val maxPos = NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN + .coerceAtMost(renderedItemsState.lastIndex) + val invalidIndices = from !in renderedItemsState.indices || to !in renderedItemsState.indices || from == to + val dividerPastMax = from == cutoffIndexState && to > maxPos + if (invalidIndices || dividerPastMax) return false + + val previousCutoff = cutoffIndexState + val crossedCutoffUpward = previousCutoff in to.. maxPos && previousLastAboveKey != null) { + val previousLastAboveIndex = renderedItemsState.indexOfFirst { it.key == previousLastAboveKey } + if (previousLastAboveIndex in 0 until cutoffIndexState) { + val kickedItem = renderedItemsState.removeAt(previousLastAboveIndex) + cutoffIndexState -= 1 + renderedItemsState.add(cutoffIndexState + 1, kickedItem) + } + } + if (cutoffIndexState > maxPos) { + renderedItemsState.add(maxPos, renderedItemsState.removeAt(cutoffIndexState)) + cutoffIndexState = maxPos + } + + return true + } + + private fun buildRenderedItems( + actions: List, + cutoff: Int, + ): List { + val clampedCutoff = cutoff.coerceIn(0, actions.size) + return buildList { + actions.forEachIndexed { index, action -> + if (index == clampedCutoff) add(NotificationListItem.Cutoff) + add(NotificationListItem.Action(action = action)) + } + if (clampedCutoff == actions.size) { + add(NotificationListItem.Cutoff) + } + } + } + + private fun notifyStateChanged() { + val actions = renderedItemsState + .filterIsInstance() + .map { it.action } + onStateChanged(actions, cutoffIndexState) + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsSettingsActivity.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsSettingsActivity.kt new file mode 100644 index 00000000000..3d067582265 --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsSettingsActivity.kt @@ -0,0 +1,41 @@ +package com.fsck.k9.ui.settings.notificationactions + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import com.fsck.k9.ui.R +import com.fsck.k9.ui.base.BaseActivity +import com.fsck.k9.ui.base.extensions.fragmentTransaction + +/** + * Hosts the notification action configuration screen. + */ +class NotificationActionsSettingsActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setLayout(R.layout.general_settings) + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + title = getString(R.string.notification_actions_settings_title) + + if (savedInstanceState == null) { + fragmentTransaction { + replace( + R.id.generalSettingsContainer, + NotificationActionsSettingsFragment(), + ) + } + } + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressedDispatcher.onBackPressed() + return true + } + + companion object { + fun start(context: Context) { + context.startActivity(Intent(context, NotificationActionsSettingsActivity::class.java)) + } + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsSettingsFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsSettingsFragment.kt new file mode 100644 index 00000000000..6a77814edf5 --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsSettingsFragment.kt @@ -0,0 +1,88 @@ +package com.fsck.k9.ui.settings.notificationactions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.Fragment +import com.fsck.k9.ui.R +import kotlinx.collections.immutable.toImmutableList +import net.thunderbird.core.preference.GeneralSettingsManager +import net.thunderbird.core.preference.notification.NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_CUTOFF +import net.thunderbird.core.preference.update +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import org.koin.android.ext.android.inject + +/** + * Lets users reorder notification actions and position the cutoff line. + */ +class NotificationActionsSettingsFragment : Fragment() { + private val generalSettingsManager: GeneralSettingsManager by inject() + private val themeProvider: FeatureThemeProvider by inject() + + private var actionOrder: MutableList = MessageNotificationAction + .defaultOrder() + .toMutableList() + private var cutoff: Int = NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_CUTOFF + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + initializeStateFromPreferences() + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + themeProvider.WithTheme { + NotificationActionsSettingsScreen( + description = stringResource(R.string.notification_actions_settings_description), + initialActions = actionOrder.toImmutableList(), + initialCutoff = cutoff, + onStateChanged = ::onStateChanged, + ) + } + } + } + } + + private fun initializeStateFromPreferences() { + val notificationPrefs = generalSettingsManager.getConfig().notification + + actionOrder = parseOrder(notificationPrefs.messageActionsOrder).toMutableList() + cutoff = notificationPrefs.messageActionsCutoff + } + + private fun persist() { + generalSettingsManager.update { settings -> + settings.copy( + notification = settings.notification.copy( + messageActionsOrder = actionOrder.map { it.token }, + messageActionsCutoff = cutoff, + ), + ) + } + } + + private fun onStateChanged( + actions: List, + cutoff: Int, + ) { + actionOrder = actions.toMutableList() + this.cutoff = cutoff + persist() + } + + private fun parseOrder(tokens: List): List { + val seen = LinkedHashSet() + for (token in tokens) { + MessageNotificationAction.fromToken(token)?.let { seen.add(it) } + } + + for (action in MessageNotificationAction.defaultOrder()) { + seen.add(action) + } + + return seen.toList() + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsSettingsScreen.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsSettingsScreen.kt new file mode 100644 index 00000000000..63d72dab042 --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationActionsSettingsScreen.kt @@ -0,0 +1,405 @@ +package com.fsck.k9.ui.settings.notificationactions + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.core.ui.legacy.designsystem.atom.icon.Icons +import com.fsck.k9.ui.R +import kotlinx.collections.immutable.ImmutableList + +private const val ARROW_DISABLED_ALPHA = 0.38f +private const val DIMMED_ROW_ALPHA = 0.6f +private const val DRAGGED_ROW_SCALE = 1.02f +private const val DRAGGED_ROW_ELEVATION_DP = 12 + +@Composable +internal fun NotificationActionsSettingsScreen( + description: String, + initialActions: ImmutableList, + initialCutoff: Int, + onStateChanged: (List, Int) -> Unit, + modifier: Modifier = Modifier, +) { + val reorderController = rememberReorderController( + initialActions = initialActions, + initialCutoff = initialCutoff, + onStateChanged = onStateChanged, + ) + NotificationActionsContent( + description = description, + reorderController = reorderController, + modifier = modifier, + ) +} + +@Composable +private fun rememberReorderController( + initialActions: ImmutableList, + initialCutoff: Int, + onStateChanged: (List, Int) -> Unit, +): NotificationActionsReorderController { + val reorderController = remember { + NotificationActionsReorderController( + initialActions = initialActions, + initialCutoff = initialCutoff, + onStateChanged = onStateChanged, + ) + } + + LaunchedEffect(initialActions, initialCutoff) { + reorderController.setState( + initialActions = initialActions, + initialCutoff = initialCutoff, + ) + } + + return reorderController +} + +@Composable +private fun NotificationActionsContent( + description: String, + reorderController: NotificationActionsReorderController, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + TextBodyMedium( + text = description, + modifier = Modifier + .fillMaxWidth() + .padding( + start = MainTheme.spacings.double, + end = MainTheme.spacings.double, + top = MainTheme.spacings.double, + bottom = MainTheme.spacings.default, + ), + ) + + NotificationActionsList(reorderController = reorderController) + } + } +} + +@Composable +private fun NotificationActionsList(reorderController: NotificationActionsReorderController) { + val listState = rememberLazyListState() + val onDrag: (Float) -> Unit = { deltaY -> + reorderController.dragBy( + deltaY = deltaY, + visibleItems = listState.layoutInfo.visibleItemsInfo.mapNotNull { info -> + val key = info.key as? String ?: return@mapNotNull null + ReorderVisibleItem( + key = key, + offset = info.offset, + size = info.size, + ) + }, + ) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + vertical = MainTheme.spacings.default, + ), + ) { + itemsIndexed( + items = reorderController.items, + key = { _, item -> item.key }, + ) { index, item -> + val isDragged = reorderController.draggedKey == item.key + val rowModifier = if (isDragged) Modifier else Modifier.animateItem() + NotificationActionsListItem( + item = item, + index = index, + reorderController = reorderController, + onDrag = onDrag, + rowModifier = rowModifier, + ) + } + } +} + +@Composable +private fun NotificationActionsListItem( + item: NotificationListItem, + index: Int, + reorderController: NotificationActionsReorderController, + onDrag: (Float) -> Unit, + rowModifier: Modifier = Modifier, +) { + val isDragged = reorderController.draggedKey == item.key + val dragState = RowDragState( + isDragged = isDragged, + draggedOffsetY = reorderController.draggedOffsetY, + ) + val moveActions = MoveActions( + moveUpLabel = stringResource(R.string.accessibility_move_up), + moveDownLabel = stringResource(R.string.accessibility_move_down), + onMoveUp = { reorderController.moveByStep(item.key, -1) }, + onMoveDown = { reorderController.moveByStep(item.key, 1) }, + ) + val dragCallbacks = DragCallbacks( + onDragStart = { reorderController.startDrag(item.key) }, + onDrag = onDrag, + onDragEnd = reorderController::endDrag, + ) + when (item) { + is NotificationListItem.Action -> NotificationActionRow( + action = item.action, + rowState = ActionRowState( + isDimmed = index > reorderController.cutoffIndex, + canMoveUp = reorderController.canMove(item.key, -1), + canMoveDown = reorderController.canMove(item.key, 1), + dragState = dragState, + ), + moveActions = moveActions, + dragCallbacks = dragCallbacks, + modifier = rowModifier, + ) + + is NotificationListItem.Cutoff -> NotificationCutoffRow( + dragState = dragState, + moveActions = moveActions, + dragCallbacks = dragCallbacks, + modifier = rowModifier, + ) + } +} + +private data class ActionRowState( + val isDimmed: Boolean, + val canMoveUp: Boolean, + val canMoveDown: Boolean, + val dragState: RowDragState, +) + +@Composable +private fun NotificationActionRow( + action: MessageNotificationAction, + rowState: ActionRowState, + moveActions: MoveActions, + dragCallbacks: DragCallbacks, + modifier: Modifier = Modifier, +) { + val contentLabel = stringResource(action.labelRes) + val dragState = rowState.dragState.copy( + alpha = if (rowState.dragState.isDragged || !rowState.isDimmed) 1.0f else DIMMED_ROW_ALPHA, + ) + + NotificationReorderRow( + contentDescription = contentLabel, + moveActions = moveActions, + dragState = dragState, + startPadding = MainTheme.spacings.default, + dragCallbacks = dragCallbacks, + modifier = modifier + .heightIn(min = MainTheme.sizes.iconAvatar), + ) { + Image( + painter = painterResource(action.iconRes), + contentDescription = null, + modifier = Modifier.padding(MainTheme.spacings.default), + ) + TextBodyLarge( + text = stringResource(action.labelRes), + modifier = Modifier + .weight(1f) + .padding(start = MainTheme.spacings.triple, end = MainTheme.spacings.double), + ) + Row( + modifier = Modifier.padding(end = MainTheme.spacings.double), + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + ) { + ArrowButton( + iconRes = Icons.Outlined.ExpandLess, + contentDescription = moveActions.moveUpLabel, + enabled = rowState.canMoveUp, + onClick = moveActions.onMoveUp, + ) + ArrowButton( + iconRes = Icons.Outlined.ExpandMore, + contentDescription = moveActions.moveDownLabel, + enabled = rowState.canMoveDown, + onClick = moveActions.onMoveDown, + ) + } + } +} + +@Composable +private fun NotificationCutoffRow( + dragState: RowDragState, + moveActions: MoveActions, + dragCallbacks: DragCallbacks, + modifier: Modifier = Modifier, +) { + val cutoffContentLabel = stringResource(R.string.notification_actions_cutoff_description) + + NotificationReorderRow( + contentDescription = cutoffContentLabel, + moveActions = moveActions, + dragState = dragState.copy(alpha = 1f), + startPadding = MainTheme.spacings.double, + dragCallbacks = dragCallbacks, + modifier = modifier + .heightIn(min = MainTheme.sizes.iconAvatar), + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(end = MainTheme.spacings.double), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(MainTheme.spacings.quarter) + .align(Alignment.Center) + .alpha(DIMMED_ROW_ALPHA) + .background(MainTheme.colors.primary), + ) + } + } +} + +private data class RowDragState( + val isDragged: Boolean, + val draggedOffsetY: Float, + val alpha: Float = 1f, +) + +private data class DragCallbacks( + val onDragStart: () -> Unit, + val onDrag: (Float) -> Unit, + val onDragEnd: () -> Unit, +) + +private data class MoveActions( + val moveUpLabel: String, + val moveDownLabel: String, + val onMoveUp: () -> Boolean, + val onMoveDown: () -> Boolean, +) + +@Composable +private fun NotificationReorderRow( + contentDescription: String, + moveActions: MoveActions, + dragState: RowDragState, + startPadding: androidx.compose.ui.unit.Dp, + dragCallbacks: DragCallbacks, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + val dragScale by animateFloatAsState( + targetValue = if (dragState.isDragged) DRAGGED_ROW_SCALE else 1.0f, + label = "notificationRowDragScale", + ) + val density = LocalDensity.current + val dragElevationPx = with(density) { if (dragState.isDragged) DRAGGED_ROW_ELEVATION_DP.dp.toPx() else 0f } + + Row( + modifier = modifier + .fillMaxWidth() + .alpha(dragState.alpha) + .graphicsLayer { + translationY = if (dragState.isDragged) dragState.draggedOffsetY else 0f + scaleX = dragScale + scaleY = dragScale + shadowElevation = dragElevationPx + } + .zIndex(if (dragState.isDragged) 1f else 0f) + .background(MainTheme.colors.surface) + .padding(start = startPadding, end = MainTheme.spacings.zero) + .immediateDragGesture( + onDragStart = dragCallbacks.onDragStart, + onDrag = dragCallbacks.onDrag, + onDragEnd = dragCallbacks.onDragEnd, + ) + .semantics { + this.contentDescription = contentDescription + customActions = listOf( + CustomAccessibilityAction(moveActions.moveUpLabel) { moveActions.onMoveUp() }, + CustomAccessibilityAction(moveActions.moveDownLabel) { moveActions.onMoveDown() }, + ) + }, + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} + +@Composable +private fun ArrowButton( + iconRes: Int, + contentDescription: String, + enabled: Boolean, + onClick: () -> Boolean, + modifier: Modifier = Modifier, +) { + Image( + painter = painterResource(iconRes), + contentDescription = contentDescription, + modifier = modifier + .padding(vertical = MainTheme.spacings.default) + .alpha(if (enabled) 1f else ARROW_DISABLED_ALPHA) + .clickable(enabled = enabled) { onClick() }, + ) +} + +private fun Modifier.immediateDragGesture( + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, +): Modifier { + return pointerInput(Unit) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragEnd() }, + onDrag = { _, dragAmount -> + if (dragAmount.y != 0f) { + onDrag(dragAmount.y) + } + }, + ) + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationListItem.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationListItem.kt new file mode 100644 index 00000000000..ce03797cd72 --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/notificationactions/NotificationListItem.kt @@ -0,0 +1,15 @@ +package com.fsck.k9.ui.settings.notificationactions + +internal sealed interface NotificationListItem { + data class Action( + val action: MessageNotificationAction, + ) : NotificationListItem + + data object Cutoff : NotificationListItem +} + +internal val NotificationListItem.key: String + get() = when (this) { + is NotificationListItem.Action -> "action:${action.token}" + NotificationListItem.Cutoff -> "cutoff" + } diff --git a/legacy/ui/legacy/src/main/res/values/strings.xml b/legacy/ui/legacy/src/main/res/values/strings.xml index cb4f756e2de..8010670021b 100644 --- a/legacy/ui/legacy/src/main/res/values/strings.xml +++ b/legacy/ui/legacy/src/main/res/values/strings.xml @@ -141,7 +141,16 @@ Reply Mark Read Mark All Read + Notification actions + Choose which actions appear in email notifications + Show Delete on multi-message notifications + Adds a Delete action to notifications that summarize multiple new messages + Drag to reorder actions. Actions above the bar will be shown (up to 3). + Shown actions cutoff + Move up + Move down Delete + Star Delete All Archive Archive All diff --git a/legacy/ui/legacy/src/main/res/xml/general_settings.xml b/legacy/ui/legacy/src/main/res/xml/general_settings.xml index a1eee65d9eb..9287270ec65 100644 --- a/legacy/ui/legacy/src/main/res/xml/general_settings.xml +++ b/legacy/ui/legacy/src/main/res/xml/general_settings.xml @@ -484,15 +484,6 @@ app:pref_summaryHasTime="%s" /> - - + + + + }, + ) + } + + private fun NotificationActionsReorderController.actionOrder(): List { + return items + .filterIsInstance() + .map { it.action } + } + + private fun NotificationActionsReorderController.keyForAction(action: MessageNotificationAction): String { + return items + .filterIsInstance() + .first { it.action == action } + .key + } + + private fun NotificationActionsReorderController.visibleItemsForTest(): List { + return items.mapIndexed { index, item -> + ReorderVisibleItem( + key = item.key, + offset = index * 100, + size = 100, + ) + } + } +}