Skip to content

Commit d708b7c

Browse files
authored
feat: configure notification actions (#10301)
2 parents 0f3d097 + 7b12989 commit d708b7c

33 files changed

Lines changed: 1497 additions & 223 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package net.thunderbird.core.common.notification
2+
3+
/**
4+
* Token strings used to persist notification action ordering.
5+
*/
6+
object NotificationActionTokens {
7+
const val REPLY = "reply"
8+
const val MARK_AS_READ = "mark_as_read"
9+
const val DELETE = "delete"
10+
const val STAR = "star"
11+
const val ARCHIVE = "archive"
12+
const val SPAM = "spam"
13+
14+
val DEFAULT_ORDER: List<String> = listOf(REPLY, MARK_AS_READ, DELETE, STAR, ARCHIVE, SPAM)
15+
16+
fun parseOrder(raw: String): List<String> {
17+
return raw
18+
.split(',')
19+
.map { it.trim() }
20+
.filter { it.isNotEmpty() }
21+
}
22+
23+
fun <T> normalizeOrder(
24+
persistedTokens: List<String>,
25+
supportedActions: List<Pair<String, T>>,
26+
): List<T> {
27+
val supportedByToken = supportedActions.toMap()
28+
val normalized = LinkedHashSet<T>()
29+
30+
for (token in persistedTokens) {
31+
supportedByToken[token]?.let { normalized.add(it) }
32+
}
33+
34+
for ((_, action) in supportedActions) {
35+
normalized.add(action)
36+
}
37+
38+
return normalized.toList()
39+
}
40+
41+
fun serializeOrder(tokens: List<String>): String = tokens.joinToString(separator = ",")
42+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package net.thunderbird.core.common.notification
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
6+
class NotificationActionTokensTest {
7+
@Test
8+
fun `normalizeOrder should ignore unknown tokens`() {
9+
val result = NotificationActionTokens.normalizeOrder(
10+
persistedTokens = listOf("unknown", NotificationActionTokens.DELETE),
11+
supportedActions = supportedActions(),
12+
)
13+
14+
assertEquals(
15+
listOf("delete", "reply", "mark_as_read"),
16+
result,
17+
)
18+
}
19+
20+
@Test
21+
fun `normalizeOrder should keep first occurrence only`() {
22+
val result = NotificationActionTokens.normalizeOrder(
23+
persistedTokens = listOf(
24+
NotificationActionTokens.DELETE,
25+
NotificationActionTokens.REPLY,
26+
NotificationActionTokens.DELETE,
27+
),
28+
supportedActions = supportedActions(),
29+
)
30+
31+
assertEquals(
32+
listOf("delete", "reply", "mark_as_read"),
33+
result,
34+
)
35+
}
36+
37+
@Test
38+
fun `normalizeOrder should append missing supported actions in canonical order`() {
39+
val result = NotificationActionTokens.normalizeOrder(
40+
persistedTokens = listOf(NotificationActionTokens.MARK_AS_READ),
41+
supportedActions = supportedActions(),
42+
)
43+
44+
assertEquals(
45+
listOf("mark_as_read", "reply", "delete"),
46+
result,
47+
)
48+
}
49+
50+
private fun supportedActions(): List<Pair<String, String>> {
51+
return listOf(
52+
NotificationActionTokens.REPLY to NotificationActionTokens.REPLY,
53+
NotificationActionTokens.MARK_AS_READ to NotificationActionTokens.MARK_AS_READ,
54+
NotificationActionTokens.DELETE to NotificationActionTokens.DELETE,
55+
)
56+
}
57+
}

core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreference.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package net.thunderbird.core.preference.notification
22

3+
import net.thunderbird.core.common.notification.NotificationActionTokens
34
import net.thunderbird.core.preference.LockScreenNotificationVisibility
45
import net.thunderbird.core.preference.NotificationQuickDelete
56

67
const val NOTIFICATION_PREFERENCE_DEFAULT_IS_QUIET_TIME_ENABLED = false
78
const val NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_STARTS = "21:00"
89
const val NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_END = "7:00"
910
const val NOTIFICATION_PREFERENCE_DEFAULT_IS_NOTIFICATION_DURING_QUIET_TIME_ENABLED = true
11+
val NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_ORDER = NotificationActionTokens.DEFAULT_ORDER
12+
const val NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_CUTOFF = 3
13+
const val NOTIFICATION_PREFERENCE_MAX_MESSAGE_ACTIONS_SHOWN = 3
14+
const val NOTIFICATION_PREFERENCE_DEFAULT_IS_SUMMARY_DELETE_ACTION_ENABLED = true
1015
val NOTIFICATION_PREFERENCE_DEFAULT_QUICK_DELETE_BEHAVIOUR = NotificationQuickDelete.ALWAYS
1116
val NOTIFICATION_PREFERENCE_DEFAULT_LOCK_SCREEN_NOTIFICATION_VISIBILITY = LockScreenNotificationVisibility.MESSAGE_COUNT
1217

@@ -16,6 +21,9 @@ data class NotificationPreference(
1621
val quietTimeEnds: String = NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_END,
1722
val isNotificationDuringQuietTimeEnabled: Boolean =
1823
NOTIFICATION_PREFERENCE_DEFAULT_IS_NOTIFICATION_DURING_QUIET_TIME_ENABLED,
24+
val messageActionsOrder: List<String> = NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_ORDER,
25+
val messageActionsCutoff: Int = NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_CUTOFF,
26+
val isSummaryDeleteActionEnabled: Boolean = NOTIFICATION_PREFERENCE_DEFAULT_IS_SUMMARY_DELETE_ACTION_ENABLED,
1927
val notificationQuickDeleteBehaviour: NotificationQuickDelete =
2028
NOTIFICATION_PREFERENCE_DEFAULT_QUICK_DELETE_BEHAVIOUR,
2129
val lockScreenNotificationVisibility: LockScreenNotificationVisibility =

core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreferenceManager.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ const val KEY_QUIET_TIME_ENDS = "quietTimeEnds"
66
const val KEY_QUIET_TIME_STARTS = "quietTimeStarts"
77
const val KEY_QUIET_TIME_ENABLED = "quietTimeEnabled"
88
const val KEY_NOTIFICATION_DURING_QUIET_TIME_ENABLED = "notificationDuringQuietTimeEnabled"
9+
const val KEY_MESSAGE_ACTIONS_ORDER = "messageActionsOrder"
10+
const val KEY_MESSAGE_ACTIONS_CUTOFF = "messageActionsCutoff"
11+
const val KEY_IS_SUMMARY_DELETE_ACTION_ENABLED = "isSummaryDeleteActionEnabled"
912

1013
const val KEY_NOTIFICATION_QUICK_DELETE_BEHAVIOUR = "notificationQuickDelete"
1114
const val KEY_LOCK_SCREEN_NOTIFICATION_VISIBILITY = "lockScreenNotificationVisibility"

core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/notification/DefaultNotificationPreferenceManager.kt

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import kotlinx.coroutines.flow.update
1010
import kotlinx.coroutines.launch
1111
import kotlinx.coroutines.sync.Mutex
1212
import kotlinx.coroutines.sync.withLock
13+
import net.thunderbird.core.common.notification.NotificationActionTokens
1314
import net.thunderbird.core.logging.Logger
15+
import net.thunderbird.core.preference.NotificationQuickDelete
1416
import net.thunderbird.core.preference.storage.Storage
1517
import net.thunderbird.core.preference.storage.StorageEditor
1618
import net.thunderbird.core.preference.storage.StoragePersister
@@ -29,8 +31,23 @@ class DefaultNotificationPreferenceManager(
2931
private val mutex = Mutex()
3032
private val storage: Storage
3133
get() = storagePersister.loadValues()
34+
private val initialConfig: NotificationPreference
35+
get() = getConfigFromStorage(storage)
3236
private val configState = MutableStateFlow(
33-
value = NotificationPreference(
37+
value = initialConfig,
38+
)
39+
40+
private fun getConfigFromStorage(storage: Storage): NotificationPreference {
41+
val notificationQuickDeleteBehaviour = storage.getEnumOrDefault(
42+
key = KEY_NOTIFICATION_QUICK_DELETE_BEHAVIOUR,
43+
default = NOTIFICATION_PREFERENCE_DEFAULT_QUICK_DELETE_BEHAVIOUR,
44+
)
45+
val isSummaryDeleteActionEnabled = resolveSummaryDeleteActionEnabled(
46+
storage = storage,
47+
quickDeleteBehaviour = notificationQuickDeleteBehaviour,
48+
)
49+
50+
return NotificationPreference(
3451
isQuietTimeEnabled = storage.getBoolean(
3552
key = KEY_QUIET_TIME_ENABLED,
3653
defValue = NOTIFICATION_PREFERENCE_DEFAULT_IS_QUIET_TIME_ENABLED,
@@ -47,16 +64,43 @@ class DefaultNotificationPreferenceManager(
4764
key = KEY_NOTIFICATION_DURING_QUIET_TIME_ENABLED,
4865
defValue = NOTIFICATION_PREFERENCE_DEFAULT_IS_NOTIFICATION_DURING_QUIET_TIME_ENABLED,
4966
),
50-
notificationQuickDeleteBehaviour = storage.getEnumOrDefault(
51-
key = KEY_NOTIFICATION_QUICK_DELETE_BEHAVIOUR,
52-
default = NOTIFICATION_PREFERENCE_DEFAULT_QUICK_DELETE_BEHAVIOUR,
67+
messageActionsOrder = NotificationActionTokens.parseOrder(
68+
storage.getStringOrDefault(
69+
key = KEY_MESSAGE_ACTIONS_ORDER,
70+
defValue = NotificationActionTokens.serializeOrder(
71+
NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_ORDER,
72+
),
73+
),
5374
),
75+
messageActionsCutoff = storage.getInt(
76+
key = KEY_MESSAGE_ACTIONS_CUTOFF,
77+
defValue = NOTIFICATION_PREFERENCE_DEFAULT_MESSAGE_ACTIONS_CUTOFF,
78+
),
79+
isSummaryDeleteActionEnabled = isSummaryDeleteActionEnabled,
80+
notificationQuickDeleteBehaviour = notificationQuickDeleteBehaviour,
5481
lockScreenNotificationVisibility = storage.getEnumOrDefault(
5582
key = KEY_LOCK_SCREEN_NOTIFICATION_VISIBILITY,
5683
default = NOTIFICATION_PREFERENCE_DEFAULT_LOCK_SCREEN_NOTIFICATION_VISIBILITY,
5784
),
58-
),
59-
)
85+
)
86+
}
87+
88+
private fun resolveSummaryDeleteActionEnabled(
89+
storage: Storage,
90+
quickDeleteBehaviour: NotificationQuickDelete,
91+
): Boolean {
92+
return if (storage.contains(KEY_IS_SUMMARY_DELETE_ACTION_ENABLED)) {
93+
storage.getBoolean(
94+
key = KEY_IS_SUMMARY_DELETE_ACTION_ENABLED,
95+
defValue = NOTIFICATION_PREFERENCE_DEFAULT_IS_SUMMARY_DELETE_ACTION_ENABLED,
96+
)
97+
} else {
98+
val derivedValue = quickDeleteBehaviour == NotificationQuickDelete.ALWAYS
99+
storageEditor.putBoolean(KEY_IS_SUMMARY_DELETE_ACTION_ENABLED, derivedValue)
100+
storageEditor.commit()
101+
derivedValue
102+
}
103+
}
60104

61105
override fun getConfig(): NotificationPreference = configState.value
62106
override fun getConfigFlow(): Flow<NotificationPreference> = configState
@@ -75,6 +119,12 @@ class DefaultNotificationPreferenceManager(
75119
KEY_NOTIFICATION_DURING_QUIET_TIME_ENABLED,
76120
config.isNotificationDuringQuietTimeEnabled,
77121
)
122+
storageEditor.putString(
123+
KEY_MESSAGE_ACTIONS_ORDER,
124+
NotificationActionTokens.serializeOrder(config.messageActionsOrder),
125+
)
126+
storageEditor.putInt(KEY_MESSAGE_ACTIONS_CUTOFF, config.messageActionsCutoff)
127+
storageEditor.putBoolean(KEY_IS_SUMMARY_DELETE_ACTION_ENABLED, config.isSummaryDeleteActionEnabled)
78128
storageEditor.putEnum(
79129
KEY_NOTIFICATION_QUICK_DELETE_BEHAVIOUR,
80130
config.notificationQuickDeleteBehaviour,

legacy/common/src/main/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@
182182
android:label="@string/general_settings_title"
183183
/>
184184

185+
<activity
186+
android:name="com.fsck.k9.ui.settings.notificationactions.NotificationActionsSettingsActivity"
187+
android:label="@string/notification_actions_settings_title"
188+
/>
189+
185190
<activity
186191
android:name="com.fsck.k9.ui.settings.account.AccountSettingsActivity"
187192
android:label="@string/account_settings_title_fmt"

legacy/common/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,14 @@ internal class K9NotificationActionCreator(
8080
}
8181

8282
override fun createDismissAllMessagesPendingIntent(account: LegacyAccountDto): PendingIntent {
83-
val intent = NotificationActionService.createDismissAllMessagesIntent(context, account).apply {
83+
val intent = NotificationActionIntents.createDismissAllMessagesIntent(context, account).apply {
8484
data = Uri.parse("data:,dismissAll/${account.uuid}/${System.currentTimeMillis()}")
8585
}
8686
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
8787
}
8888

8989
override fun createDismissMessagePendingIntent(messageReference: MessageReference): PendingIntent {
90-
val intent = NotificationActionService.createDismissMessageIntent(context, messageReference).apply {
90+
val intent = NotificationActionIntents.createDismissMessageIntent(context, messageReference).apply {
9191
data = Uri.parse("data:,dismiss/${messageReference.toIdentityString()}")
9292
}
9393
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
@@ -101,7 +101,7 @@ internal class K9NotificationActionCreator(
101101
}
102102

103103
override fun createMarkMessageAsReadPendingIntent(messageReference: MessageReference): PendingIntent {
104-
val intent = NotificationActionService.createMarkMessageAsReadIntent(context, messageReference).apply {
104+
val intent = NotificationActionIntents.createMarkMessageAsReadIntent(context, messageReference).apply {
105105
data = Uri.parse("data:,markAsRead/${messageReference.toIdentityString()}")
106106
}
107107
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
@@ -113,7 +113,7 @@ internal class K9NotificationActionCreator(
113113
): PendingIntent {
114114
val accountUuid = account.uuid
115115
val intent =
116-
NotificationActionService.createMarkAllAsReadIntent(context, accountUuid, messageReferences).apply {
116+
NotificationActionIntents.createMarkAllAsReadIntent(context, accountUuid, messageReferences).apply {
117117
data = Uri.parse("data:,markAllAsRead/$accountUuid/${System.currentTimeMillis()}")
118118
}
119119
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
@@ -144,7 +144,7 @@ internal class K9NotificationActionCreator(
144144
}
145145

146146
private fun createDeleteServicePendingIntent(messageReference: MessageReference): PendingIntent {
147-
val intent = NotificationActionService.createDeleteMessageIntent(context, messageReference).apply {
147+
val intent = NotificationActionIntents.createDeleteMessageIntent(context, messageReference).apply {
148148
data = Uri.parse("data:,delete/${messageReference.toIdentityString()}")
149149
}
150150
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
@@ -181,14 +181,14 @@ internal class K9NotificationActionCreator(
181181
): PendingIntent {
182182
val accountUuid = account.uuid
183183
val intent =
184-
NotificationActionService.createDeleteAllMessagesIntent(context, accountUuid, messageReferences).apply {
184+
NotificationActionIntents.createDeleteAllMessagesIntent(context, accountUuid, messageReferences).apply {
185185
data = Uri.parse("data:,deleteAll/$accountUuid/${System.currentTimeMillis()}")
186186
}
187187
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
188188
}
189189

190190
override fun createArchiveMessagePendingIntent(messageReference: MessageReference): PendingIntent {
191-
val intent = NotificationActionService.createArchiveMessageIntent(context, messageReference).apply {
191+
val intent = NotificationActionIntents.createArchiveMessageIntent(context, messageReference).apply {
192192
data = Uri.parse("data:,archive/${messageReference.toIdentityString()}")
193193
}
194194
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
@@ -198,19 +198,26 @@ internal class K9NotificationActionCreator(
198198
account: LegacyAccountDto,
199199
messageReferences: List<MessageReference>,
200200
): PendingIntent {
201-
val intent = NotificationActionService.createArchiveAllIntent(context, account, messageReferences).apply {
201+
val intent = NotificationActionIntents.createArchiveAllIntent(context, account, messageReferences).apply {
202202
data = Uri.parse("data:,archiveAll/${account.uuid}/${System.currentTimeMillis()}")
203203
}
204204
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
205205
}
206206

207207
override fun createMarkMessageAsSpamPendingIntent(messageReference: MessageReference): PendingIntent {
208-
val intent = NotificationActionService.createMarkMessageAsSpamIntent(context, messageReference).apply {
208+
val intent = NotificationActionIntents.createMarkMessageAsSpamIntent(context, messageReference).apply {
209209
data = Uri.parse("data:,spam/${messageReference.toIdentityString()}")
210210
}
211211
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
212212
}
213213

214+
override fun createMarkMessageAsStarPendingIntent(messageReference: MessageReference): PendingIntent {
215+
val intent = NotificationActionIntents.createMarkMessageAsStarIntent(context, messageReference).apply {
216+
data = Uri.parse("data:,star/${messageReference.toIdentityString()}")
217+
}
218+
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
219+
}
220+
214221
private fun createMessageListIntent(account: LegacyAccountDto): Intent {
215222
val folderId = defaultFolderProvider.getDefaultFolder(account)
216223
val search = LocalMessageSearch().apply {

legacy/common/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ class K9NotificationResourceProvider(private val context: Context) : Notificatio
1515
override val iconMarkAsRead: Int = Icons.Outlined.MarkEmailRead
1616
override val iconDelete: Int = Icons.Outlined.Delete
1717
override val iconReply: Int = Icons.Outlined.Reply
18+
override val iconArchive: Int = Icons.Outlined.Archive
19+
override val iconMarkAsSpam: Int = Icons.Outlined.Report
20+
override val iconStar: Int = Icons.Outlined.Star
1821
override val iconNewMail: Int = Icons.Outlined.MarkEmailUnread
1922
override val iconSendingMail: Int = Icons.Outlined.Sync
2023
override val iconCheckingMail: Int = Icons.Outlined.Sync
@@ -103,6 +106,8 @@ class K9NotificationResourceProvider(private val context: Context) : Notificatio
103106

104107
override fun actionReply(): String = context.getString(R.string.notification_action_reply)
105108

109+
override fun actionStar(): String = context.getString(R.string.notification_action_star)
110+
106111
override fun actionArchive(): String = context.getString(R.string.notification_action_archive)
107112

108113
override fun actionArchiveAll(): String = context.getString(R.string.notification_action_archive_all)

legacy/core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies {
1010
api(projects.core.mail.mailserver)
1111
api(projects.core.android.common)
1212
api(projects.core.android.account)
13+
api(projects.core.common)
1314
api(projects.core.preference.impl)
1415
api(projects.core.android.logging)
1516
api(projects.core.logging.implFile)

legacy/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ internal enum class NotificationAction {
6565
Reply,
6666
MarkAsRead,
6767
Delete,
68+
Archive,
69+
Spam,
70+
Star,
6871
}
6972

7073
internal enum class WearNotificationAction {

0 commit comments

Comments
 (0)