Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,23 @@
plugins {
id(ThunderbirdPlugins.Library.android)
alias(libs.plugins.compose)
}
android {
buildFeatures {
compose = true
}
namespace = "net.thunderbird.feature.widget.message.list"
}
dependencies {
implementation(projects.legacy.ui.legacy)
implementation(projects.legacy.core)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.kotlinx.collections.immutable)
debugImplementation(libs.androidx.glance.appwidget.preview)
debugImplementation(libs.androidx.glance.preview)
}

View file

@ -0,0 +1,86 @@
package net.thunderbird.feature.widget.message.list.ui
import android.graphics.Color
import androidx.compose.runtime.Composable
import androidx.glance.preview.ExperimentalGlancePreviewApi
import androidx.glance.preview.Preview
import app.k9mail.legacy.message.controller.MessageReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import net.thunderbird.feature.widget.message.list.MessageListItem
@OptIn(ExperimentalGlancePreviewApi::class)
@Preview(widthDp = 250, heightDp = 180)
@Composable
internal fun MessageListWidgetContentPreview() {
MessageListWidgetContent(
mails = generateMessageListItems(),
onOpenApp = {},
)
}
@OptIn(ExperimentalGlancePreviewApi::class)
@Preview(widthDp = 250, heightDp = 180)
@Composable
internal fun MessageListWidgetContentEmptyPreview() {
MessageListWidgetContent(
mails = persistentListOf(),
onOpenApp = {},
)
}
private fun generateMessageListItems(): ImmutableList<MessageListItem> {
return persistentListOf(
generateMessageListItem(
displayName = "Alice",
displayDate = "1 Jan",
subject = "Subject 1",
preview = "Preview 1",
color = Color.BLUE,
isRead = false,
),
generateMessageListItem(
displayName = "Bob",
displayDate = "2 Jan",
subject = "Subject 2",
preview = "Preview 2",
color = Color.RED,
isRead = true,
),
generateMessageListItem(
displayName = "Charlie",
displayDate = "3 Jan",
subject = "Subject 3",
preview = "Preview 3",
color = Color.RED,
isRead = false,
),
)
}
private fun generateMessageListItem(
displayName: String,
displayDate: String,
subject: String,
preview: String,
color: Int,
isRead: Boolean,
): MessageListItem {
return MessageListItem(
displayName = displayName,
displayDate = displayDate,
subject = subject,
preview = preview,
isRead = isRead,
hasAttachments = false,
threadCount = 0,
accountColor = color,
uniqueId = 0,
messageReference = MessageReference("accountUuid", 123, "messageServerId"),
sortSubject = subject,
sortMessageDate = 0,
sortInternalDate = 0,
sortIsStarred = false,
sortDatabaseId = 0,
)
}

View file

@ -0,0 +1,15 @@
package net.thunderbird.feature.widget.message.list
import org.koin.dsl.module
val featureWidgetMessageListModule = module {
factory {
MessageListLoader(
preferences = get(),
messageListRepository = get(),
messageHelper = get(),
generalSettingsManager = get(),
outboxFolderManager = get(),
)
}
}

View file

@ -0,0 +1,12 @@
package net.thunderbird.feature.widget.message.list
import net.thunderbird.core.android.account.SortType
import net.thunderbird.feature.search.legacy.LocalMessageSearch
internal data class MessageListConfig(
val search: LocalMessageSearch,
val showingThreadedList: Boolean,
val sortType: SortType,
val sortAscending: Boolean,
val sortDateAscending: Boolean,
)

View file

@ -0,0 +1,22 @@
package net.thunderbird.feature.widget.message.list
import app.k9mail.legacy.message.controller.MessageReference
internal data class MessageListItem(
val displayName: String,
val displayDate: String,
val subject: String,
val preview: String,
val isRead: Boolean,
val hasAttachments: Boolean,
val threadCount: Int,
val accountColor: Int,
val messageReference: MessageReference,
val uniqueId: Long,
val sortSubject: String?,
val sortMessageDate: Long,
val sortInternalDate: Long,
val sortIsStarred: Boolean,
val sortDatabaseId: Long,
)

View file

@ -0,0 +1,75 @@
package net.thunderbird.feature.widget.message.list
import app.k9mail.legacy.mailstore.MessageDetailsAccessor
import app.k9mail.legacy.mailstore.MessageMapper
import app.k9mail.legacy.message.controller.MessageReference
import com.fsck.k9.helper.MessageHelper
import com.fsck.k9.ui.helper.DisplayAddressHelper
import java.util.Calendar
import java.util.Locale
import net.thunderbird.core.android.account.LegacyAccount
import net.thunderbird.core.preference.GeneralSettingsManager
import net.thunderbird.feature.mail.folder.api.OutboxFolderManager
internal class MessageListItemMapper(
private val messageHelper: MessageHelper,
private val account: LegacyAccount,
private val generalSettingsManager: GeneralSettingsManager,
private val outboxFolderManager: OutboxFolderManager,
) : MessageMapper<MessageListItem> {
private val calendar: Calendar = Calendar.getInstance()
override fun map(message: MessageDetailsAccessor): MessageListItem {
val fromAddresses = message.fromAddresses
val toAddresses = message.toAddresses
val previewResult = message.preview
val previewText = if (previewResult.isPreviewTextAvailable) previewResult.previewText else ""
val uniqueId = createUniqueId(account, message.id)
val showRecipients = DisplayAddressHelper.shouldShowRecipients(outboxFolderManager, account, message.folderId)
val displayAddress = if (showRecipients) toAddresses.firstOrNull() else fromAddresses.firstOrNull()
val displayName = if (showRecipients) {
messageHelper.getRecipientDisplayNames(
addresses = toAddresses.toTypedArray(),
isShowCorrespondentNames = generalSettingsManager.getConfig().display.isShowCorrespondentNames,
isChangeContactNameColor = generalSettingsManager.getConfig().display.isChangeContactNameColor,
).toString()
} else {
messageHelper.getSenderDisplayName(displayAddress).toString()
}
return MessageListItem(
displayName = displayName,
displayDate = formatDate(message.messageDate),
subject = message.subject.orEmpty(),
preview = previewText,
isRead = message.isRead,
hasAttachments = message.hasAttachments,
threadCount = message.threadCount,
accountColor = account.chipColor,
messageReference = MessageReference(account.uuid, message.folderId, message.messageServerId),
uniqueId = uniqueId,
sortSubject = message.subject,
sortMessageDate = message.messageDate,
sortInternalDate = message.internalDate,
sortIsStarred = message.isStarred,
sortDatabaseId = message.id,
)
}
@Suppress("ImplicitDefaultLocale")
private fun formatDate(date: Long): String {
calendar.timeInMillis = date
val dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH)
val month = calendar.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.getDefault())
return String.format("%d %s", dayOfMonth, month)
}
private fun createUniqueId(account: LegacyAccount, messageId: Long): Long {
return ((account.accountNumber + 1).toLong() shl ACCOUNT_NUMBER_BIT_SHIFT) + messageId
}
private companion object {
const val ACCOUNT_NUMBER_BIT_SHIFT = 52
}
}

View file

@ -0,0 +1,155 @@
package net.thunderbird.feature.widget.message.list
import app.k9mail.legacy.mailstore.MessageListRepository
import com.fsck.k9.Preferences
import com.fsck.k9.helper.MessageHelper
import com.fsck.k9.mailstore.MessageColumns
import com.fsck.k9.search.getAccounts
import net.thunderbird.core.android.account.LegacyAccount
import net.thunderbird.core.android.account.SortType
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.preference.GeneralSettingsManager
import net.thunderbird.feature.mail.folder.api.OutboxFolderManager
import net.thunderbird.feature.search.legacy.sql.SqlWhereClause
internal class MessageListLoader(
private val preferences: Preferences,
private val messageListRepository: MessageListRepository,
private val messageHelper: MessageHelper,
private val generalSettingsManager: GeneralSettingsManager,
private val outboxFolderManager: OutboxFolderManager,
) {
@Suppress("TooGenericExceptionCaught")
fun getMessageList(config: MessageListConfig): List<MessageListItem> {
return try {
getMessageListInfo(config)
} catch (e: Exception) {
Log.e(e, "Error while fetching message list")
// TODO: Return an error object instead of an empty list
emptyList()
}
}
private fun getMessageListInfo(config: MessageListConfig): List<MessageListItem> {
val accounts = config.search.getAccounts(preferences)
val messageListItems = accounts
.flatMap { account ->
loadMessageListForAccount(account, config)
}
.sortedWith(config)
return messageListItems
}
private fun loadMessageListForAccount(account: LegacyAccount, config: MessageListConfig): List<MessageListItem> {
val accountUuid = account.uuid
val sortOrder = buildSortOrder(config)
val mapper = MessageListItemMapper(messageHelper, account, generalSettingsManager, outboxFolderManager)
return if (config.showingThreadedList) {
val (selection, selectionArgs) = buildSelection(config)
messageListRepository.getThreadedMessages(accountUuid, selection, selectionArgs, sortOrder, mapper)
} else {
val (selection, selectionArgs) = buildSelection(config)
messageListRepository.getMessages(accountUuid, selection, selectionArgs, sortOrder, mapper)
}
}
private fun buildSelection(config: MessageListConfig): Pair<String, Array<String>> {
val whereClause = SqlWhereClause.Builder()
.withConditions(config.search.conditions)
.build()
return whereClause.selection to whereClause.selectionArgs.toTypedArray()
}
private fun buildSortOrder(config: MessageListConfig): String {
val sortColumn = when (config.sortType) {
SortType.SORT_ARRIVAL -> MessageColumns.INTERNAL_DATE
SortType.SORT_ATTACHMENT -> "(${MessageColumns.ATTACHMENT_COUNT} < 1)"
SortType.SORT_FLAGGED -> "(${MessageColumns.FLAGGED} != 1)"
SortType.SORT_SENDER -> MessageColumns.SENDER_LIST // FIXME
SortType.SORT_SUBJECT -> "${MessageColumns.SUBJECT} COLLATE NOCASE"
SortType.SORT_UNREAD -> MessageColumns.READ
SortType.SORT_DATE -> MessageColumns.DATE
}
val sortDirection = if (config.sortAscending) " ASC" else " DESC"
val secondarySort = if (config.sortType == SortType.SORT_DATE || config.sortType == SortType.SORT_ARRIVAL) {
""
} else {
if (config.sortDateAscending) {
"${MessageColumns.DATE} ASC, "
} else {
"${MessageColumns.DATE} DESC, "
}
}
return "$sortColumn$sortDirection, $secondarySort${MessageColumns.ID} DESC"
}
private fun List<MessageListItem>.sortedWith(config: MessageListConfig): List<MessageListItem> {
val comparator = when (config.sortType) {
SortType.SORT_DATE -> {
compareBy(config.sortAscending) { it.sortMessageDate }
}
SortType.SORT_ARRIVAL -> {
compareBy(config.sortAscending) { it.sortInternalDate }
}
SortType.SORT_SUBJECT -> {
compareStringBy<MessageListItem>(config.sortAscending) { it.sortSubject.orEmpty() }
.thenByDate(config)
}
SortType.SORT_SENDER -> {
compareStringBy<MessageListItem>(config.sortAscending) { it.displayName }
.thenByDate(config)
}
SortType.SORT_UNREAD -> {
compareBy<MessageListItem>(config.sortAscending) { it.isRead }
.thenByDate(config)
}
SortType.SORT_FLAGGED -> {
compareBy<MessageListItem>(!config.sortAscending) { it.sortIsStarred }
.thenByDate(config)
}
SortType.SORT_ATTACHMENT -> {
compareBy<MessageListItem>(!config.sortAscending) { it.hasAttachments }
.thenByDate(config)
}
}.thenByDescending { it.sortDatabaseId }
return this.sortedWith(comparator)
}
}
private inline fun <T> compareBy(sortAscending: Boolean, crossinline selector: (T) -> Comparable<*>?): Comparator<T> {
return if (sortAscending) {
compareBy(selector)
} else {
compareByDescending(selector)
}
}
private inline fun <T> compareStringBy(sortAscending: Boolean, crossinline selector: (T) -> String): Comparator<T> {
return if (sortAscending) {
compareBy(String.CASE_INSENSITIVE_ORDER, selector)
} else {
compareByDescending(String.CASE_INSENSITIVE_ORDER, selector)
}
}
private fun Comparator<MessageListItem>.thenByDate(config: MessageListConfig): Comparator<MessageListItem> {
return if (config.sortDateAscending) {
thenBy { it.sortMessageDate }
} else {
thenByDescending { it.sortMessageDate }
}
}

View file

@ -0,0 +1,92 @@
package net.thunderbird.feature.widget.message.list
import android.app.PendingIntent
import android.content.Context
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.app.PendingIntentCompat
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent
import com.fsck.k9.CoreResourceProvider
import com.fsck.k9.activity.MessageList.Companion.intentDisplaySearch
import kotlin.random.Random.Default.nextInt
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.thunderbird.core.android.account.SortType
import net.thunderbird.core.preference.GeneralSettingsManager
import net.thunderbird.feature.search.legacy.SearchAccount.Companion.createUnifiedInboxAccount
import net.thunderbird.feature.widget.message.list.ui.MessageListWidgetContent
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
internal class MessageListWidget : GlanceAppWidget(), KoinComponent {
private val messageListLoader: MessageListLoader by inject()
private val coreResourceProvider: CoreResourceProvider by inject()
private val generalSettingsManager: GeneralSettingsManager by inject()
companion object {
private var lastMailList = emptyList<MessageListItem>()
private const val MESSAGE_COUNT = 100
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
var mails by remember { mutableStateOf(lastMailList) }
LaunchedEffect(Unit) {
CoroutineScope(Dispatchers.IO).launch {
val unifiedInboxSearch = createUnifiedInboxAccount(
unifiedInboxTitle = coreResourceProvider.searchUnifiedInboxTitle(),
unifiedInboxDetail = coreResourceProvider.searchUnifiedInboxDetail(),
).relatedSearch
val messageListConfig = MessageListConfig(
search = unifiedInboxSearch,
showingThreadedList = generalSettingsManager.getConfig()
.display.inboxSettings.isThreadedViewEnabled,
sortType = SortType.SORT_DATE,
sortAscending = false,
sortDateAscending = false,
)
val list = messageListLoader.getMessageList(messageListConfig)
mails = list.subList(0, list.size.coerceAtMost(MESSAGE_COUNT))
lastMailList = mails
}
}
MessageListWidgetContent(
mails = mails.toImmutableList(),
onOpenApp = { openApp(context) },
)
}
}
private fun openApp(context: Context) {
val unifiedInboxAccount = createUnifiedInboxAccount(
unifiedInboxTitle = coreResourceProvider.searchUnifiedInboxTitle(),
unifiedInboxDetail = coreResourceProvider.searchUnifiedInboxDetail(),
)
val intent = intentDisplaySearch(
context = context,
search = unifiedInboxAccount.relatedSearch,
noThreading = true,
newTask = true,
clearTop = true,
).apply {
action = nextInt().toString()
}
PendingIntentCompat.getActivity(
context,
nextInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
)!!.send()
}
}

View file

@ -0,0 +1,8 @@
package net.thunderbird.feature.widget.message.list
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
class MessageListWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MessageListWidget()
}

View file

@ -0,0 +1,96 @@
package net.thunderbird.feature.widget.message.list.ui
import android.app.PendingIntent
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.PendingIntentCompat
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.layout.wrapContentHeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import com.fsck.k9.activity.MessageList
import kotlin.random.Random
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.thunderbird.feature.widget.message.list.MessageListItem
@Suppress("LongMethod")
@Composable
internal fun MessageListItemView(item: MessageListItem) {
val context = LocalContext.current
Row(
GlanceModifier.Companion.fillMaxWidth().wrapContentHeight().clickable {
CoroutineScope(Dispatchers.IO).launch {
val intent = MessageList.Companion.actionDisplayMessageIntent(context, item.messageReference)
PendingIntentCompat.getActivity(
context,
Random.Default.nextInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
)!!
.send()
}
},
) {
Spacer(GlanceModifier.Companion.width(8.dp).background(Color(item.accountColor)))
Column(GlanceModifier.Companion.fillMaxWidth().padding(vertical = 4.dp, horizontal = 4.dp)) {
Row(GlanceModifier.Companion.fillMaxWidth()) {
Row(GlanceModifier.Companion.defaultWeight(), horizontalAlignment = Alignment.Companion.Start) {
Text(
item.subject,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 16.sp),
maxLines = 1,
)
}
Spacer(GlanceModifier.Companion.width(4.dp))
Row(horizontalAlignment = Alignment.Companion.End) {
Box(
GlanceModifier.Companion.background(GlanceTheme.colors.primaryContainer).cornerRadius(8.dp)
.padding(2.dp),
) {
Text(
item.threadCount.toString(),
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 13.sp),
)
}
Spacer(GlanceModifier.Companion.width(4.dp))
Text(item.displayDate, style = TextStyle(color = GlanceTheme.colors.primary))
}
}
Spacer(GlanceModifier.Companion.height(2.dp))
Row {
Text(
item.displayName,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 15.sp),
maxLines = 1,
)
}
Spacer(GlanceModifier.Companion.height(2.dp))
Row {
Text(
item.preview,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 13.sp),
maxLines = 1,
)
}
}
}
}

View file

@ -0,0 +1,86 @@
package net.thunderbird.feature.widget.message.list.ui
import android.app.PendingIntent
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.PendingIntentCompat
import androidx.glance.ColorFilter
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.background
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import app.k9mail.core.ui.legacy.designsystem.atom.icon.Icons
import com.fsck.k9.activity.MessageCompose
import kotlin.random.Random.Default.nextInt
import kotlinx.collections.immutable.ImmutableList
import net.thunderbird.feature.widget.message.list.MessageListItem
import net.thunderbird.feature.widget.message.list.R
@Composable
internal fun MessageListWidgetContent(
mails: ImmutableList<MessageListItem>,
onOpenApp: () -> Unit,
) {
val context = LocalContext.current
GlanceTheme(GlanceTheme.colors) {
Column(GlanceModifier.fillMaxSize().background(GlanceTheme.colors.surface)) {
Row(
GlanceModifier.padding(horizontal = 8.dp, vertical = 12.dp).fillMaxWidth()
.background(GlanceTheme.colors.primaryContainer)
.clickable {
onOpenApp()
},
) {
Text(
context.getString(R.string.message_list_glance_widget_inbox_title),
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 20.sp),
)
Spacer(GlanceModifier.defaultWeight())
Image(
ImageProvider(Icons.Outlined.Edit),
context.getString(R.string.message_list_glance_widget_compose_action),
GlanceModifier.padding(2.dp).padding(end = 6.dp).clickable {
val intent = Intent(context, MessageCompose::class.java).apply {
action = MessageCompose.ACTION_COMPOSE
}
PendingIntentCompat.getActivity(
context,
nextInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
)!!.send()
},
colorFilter = ColorFilter.tint(GlanceTheme.colors.primary),
)
}
LazyColumn(GlanceModifier.fillMaxSize()) {
items(mails) {
Column {
MessageListItemView(it)
Spacer(
GlanceModifier.height(2.dp).fillMaxWidth()
.background(GlanceTheme.colors.surfaceVariant),
)
}
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="message_list_glance_widget_compose_action">Compose</string>
<string name="message_list_glance_widget_inbox_title">Unified Inbox</string>
<string name="message_list_glance_widget_label">Message List</string>
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider
xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minHeight="180dp"
android:minWidth="250dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:previewImage="@drawable/message_list_glance_widget_preview"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen|keyguard"
/>