diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt index ef97d63a055..a03105d31c1 100644 --- a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt @@ -1,7 +1,9 @@ package app.k9mail.core.android.common.contact +import android.net.Uri import net.thunderbird.core.common.cache.Cache import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.mail.toEmailAddressOrNull interface ContactRepository { @@ -10,6 +12,8 @@ interface ContactRepository { fun hasContactFor(emailAddress: EmailAddress): Boolean fun hasAnyContactFor(emailAddresses: List): Boolean + + fun getPhotoUri(emailAddress: String): Uri? } interface CachingRepository { @@ -42,6 +46,12 @@ internal class CachingContactRepository( override fun hasAnyContactFor(emailAddresses: List): Boolean = emailAddresses.any { emailAddress -> hasContactFor(emailAddress) } + override fun getPhotoUri(emailAddress: String): Uri? { + return emailAddress.toEmailAddressOrNull()?.let { emailAddress -> + getContactFor(emailAddress)?.photoUri + } + } + override fun clearCache() { cache.clear() } diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt index a4eed9a1e6f..3854450ef7c 100644 --- a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt @@ -1,5 +1,6 @@ package app.k9mail.core.android.common.contact +import android.net.Uri import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isFalse @@ -140,4 +141,53 @@ internal class CachingContactRepositoryTest { assertThat(cache[CONTACT_EMAIL_ADDRESS]).isNull() } + + @Test + fun `getPhotoUri() returns null when email is invalid`() { + val result = testSubject.getPhotoUri("invalid-email") + + assertThat(result).isNull() + } + + @Test + fun `getPhotoUri() returns null when no contact found for valid email`() { + dataSource.stub { on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturn null } + + val result = testSubject.getPhotoUri(CONTACT_EMAIL_ADDRESS.address) + + assertThat(result).isNull() + } + + @Test + fun `getPhotoUri() returns contact photo uri when contact exists`() { + dataSource.stub { on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturn CONTACT } + + val result = testSubject.getPhotoUri(CONTACT_EMAIL_ADDRESS.address) + + assertThat(result).isEqualTo(CONTACT.photoUri) + } + + @Test + fun `getPhotoUri() returns cached photo uri when contact already cached`() { + cache[CONTACT_EMAIL_ADDRESS] = CONTACT + + val result = testSubject.getPhotoUri(CONTACT_EMAIL_ADDRESS.address) + + assertThat(result).isEqualTo(CONTACT.photoUri) + } + + @Test + fun `getPhotoUri() caches result after first fetch`() { + dataSource.stub { + on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturnConsecutively listOf( + CONTACT, + CONTACT.copy(photoUri = Uri.parse("content://other/photo")), + ) + } + + val result1 = testSubject.getPhotoUri(CONTACT_EMAIL_ADDRESS.address) + val result2 = testSubject.getPhotoUri(CONTACT_EMAIL_ADDRESS.address) + + assertThat(result1).isEqualTo(result2) + } } diff --git a/legacy/ui/legacy/build.gradle.kts b/legacy/ui/legacy/build.gradle.kts index 6042f39e6e8..909775faade 100644 --- a/legacy/ui/legacy/build.gradle.kts +++ b/legacy/ui/legacy/build.gradle.kts @@ -27,6 +27,8 @@ dependencies { implementation(projects.feature.notification.api) // TODO: Remove AccountOauth dependency implementation(projects.feature.account.oauth) + implementation(projects.feature.account.avatar.api) + implementation(projects.feature.account.avatar.impl) implementation(projects.feature.funding.api) implementation(projects.feature.search.implLegacy) implementation(projects.feature.settings.import) diff --git a/legacy/ui/legacy/src/debug/kotlin/com/fsck/k9/ui/messagelist/item/MessageItemContentPreview.kt b/legacy/ui/legacy/src/debug/kotlin/com/fsck/k9/ui/messagelist/item/MessageItemContentPreview.kt index aae82769ac1..cdee97efbb5 100644 --- a/legacy/ui/legacy/src/debug/kotlin/com/fsck/k9/ui/messagelist/item/MessageItemContentPreview.kt +++ b/legacy/ui/legacy/src/debug/kotlin/com/fsck/k9/ui/messagelist/item/MessageItemContentPreview.kt @@ -2,6 +2,8 @@ package com.fsck.k9.ui.messagelist.item import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.PreviewLightDark +import app.k9mail.core.android.common.contact.Contact +import app.k9mail.core.android.common.contact.ContactRepository import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark import com.fsck.k9.FontSizes import com.fsck.k9.UiDensity @@ -12,7 +14,9 @@ import com.fsck.k9.ui.messagelist.MessageListAppearance import com.fsck.k9.ui.messagelist.MessageListItem import net.thunderbird.core.android.account.Identity import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.common.mail.EmailAddress import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.avatar.AvatarMonogramCreator import net.thunderbird.feature.account.storage.profile.AvatarDto import net.thunderbird.feature.account.storage.profile.AvatarTypeDto import net.thunderbird.feature.account.storage.profile.ProfileDto @@ -25,6 +29,8 @@ internal fun MessageItemContentPreview() { item = fakeMessageListItem, isActive = true, isSelected = false, + contactRepository = fakeContactRepository, + avatarMonogramCreator = fakeAvatarMonogramCreator, onClick = {}, onLongClick = {}, onAvatarClick = {}, @@ -97,3 +103,23 @@ private val fakeMessageListAppearance = MessageListAppearance( showAccountIndicator = true, density = UiDensity.Default, ) + +private val fakeContactRepository = object : ContactRepository { + override fun getContactFor(emailAddress: EmailAddress): Contact? { + error("Not implemented") + } + + override fun hasContactFor(emailAddress: EmailAddress): Boolean { + error("Not implemented") + } + + override fun hasAnyContactFor(emailAddresses: List): Boolean { + error("Not implemented") + } + + override fun getPhotoUri(emailAddress: String) = null +} + +private val fakeAvatarMonogramCreator = object : AvatarMonogramCreator { + override fun create(name: String?, email: String?) = "SE" +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt index 589f5319ddd..0c8a2be7b04 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt @@ -3,9 +3,7 @@ package com.fsck.k9.contacts import android.content.ContentResolver import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.net.Uri import app.k9mail.core.android.common.contact.ContactRepository -import net.thunderbird.core.common.mail.toEmailAddressOrNull import net.thunderbird.core.logging.legacy.Log internal class ContactPhotoLoader( @@ -13,7 +11,7 @@ internal class ContactPhotoLoader( private val contactRepository: ContactRepository, ) { fun loadContactPhoto(emailAddress: String): Bitmap? { - val photoUri = getPhotoUri(emailAddress) ?: return null + val photoUri = contactRepository.getPhotoUri(emailAddress = emailAddress) ?: return null return try { contentResolver.openInputStream(photoUri).use { inputStream -> BitmapFactory.decodeStream(inputStream) @@ -23,10 +21,4 @@ internal class ContactPhotoLoader( null } } - - private fun getPhotoUri(email: String): Uri? { - return email.toEmailAddressOrNull()?.let { emailAddress -> - contactRepository.getContactFor(emailAddress)?.photoUri - } - } } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt index 4490af34d06..054ad19ed85 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt @@ -11,6 +11,7 @@ import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView +import app.k9mail.core.android.common.contact.ContactRepository import app.k9mail.feature.launcher.FeatureLauncherActivity import app.k9mail.feature.launcher.FeatureLauncherTarget import app.k9mail.legacy.message.controller.MessageReference @@ -27,6 +28,7 @@ import net.thunderbird.core.featureflag.FeatureFlagKey import net.thunderbird.core.featureflag.FeatureFlagProvider import net.thunderbird.core.featureflag.FeatureFlagResult import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.feature.account.avatar.AvatarMonogramCreator import net.thunderbird.feature.notification.api.ui.action.NotificationAction private const val FOOTER_ID = 1L @@ -47,6 +49,8 @@ class MessageListAdapter internal constructor( private val relativeDateTimeFormatter: RelativeDateTimeFormatter, private val themeProvider: FeatureThemeProvider, private val featureFlagProvider: FeatureFlagProvider, + private val contactRepository: ContactRepository, + private val avatarMonogramCreator: AvatarMonogramCreator, ) : RecyclerView.Adapter() { val colors: MessageViewHolderColors = MessageViewHolderColors.resolveColors(theme) @@ -266,6 +270,8 @@ class MessageListAdapter internal constructor( ComposableMessageViewHolder.create( context = parent.context, themeProvider = themeProvider, + contactRepository = contactRepository, + avatarMonogramCreator = avatarMonogramCreator, onClick = { listItemListener.onMessageClicked(it) }, onLongClick = { listItemListener.onToggleMessageSelection(it) }, onFavouriteClick = { listItemListener.onToggleMessageFlag(it) }, diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index 73f27f73646..be916cf229e 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import app.k9mail.core.android.common.contact.ContactRepository import app.k9mail.legacy.message.controller.MessageReference import app.k9mail.legacy.message.controller.MessagingControllerRegistry import app.k9mail.legacy.message.controller.SimpleMessagingListener @@ -92,6 +93,7 @@ import net.thunderbird.core.logging.Logger import net.thunderbird.core.logging.legacy.Log import net.thunderbird.core.preference.GeneralSettingsManager import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.feature.account.avatar.AvatarMonogramCreator import net.thunderbird.feature.mail.folder.api.OutboxFolderManager import net.thunderbird.feature.mail.message.list.domain.DomainContract import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogFragmentFactory @@ -145,6 +147,9 @@ class MessageListFragment : private val activityListener = MessageListActivityListener() private val actionModeCallback = ActionModeCallback() + private val contactRepository: ContactRepository by inject() + private val avatarMonogramCreator: AvatarMonogramCreator by inject() + private val chooseFolderForMoveLauncher: ActivityResultLauncher = registerForActivityResult(ChooseFolderResultContract(ChooseFolderActivity.Action.MOVE)) { result -> handleChooseFolderResult(result) { folderId, messages -> @@ -348,6 +353,8 @@ class MessageListFragment : relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), clock), themeProvider = featureThemeProvider, featureFlagProvider = featureFlagProvider, + contactRepository = contactRepository, + avatarMonogramCreator = avatarMonogramCreator, ).apply { activeMessage = this@MessageListFragment.activeMessage } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/ComposableMessageViewHolder.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/ComposableMessageViewHolder.kt index 13d92b69dfb..0bd2ceb95f0 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/ComposableMessageViewHolder.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/ComposableMessageViewHolder.kt @@ -2,13 +2,16 @@ package com.fsck.k9.ui.messagelist.item import android.content.Context import androidx.compose.ui.platform.ComposeView +import app.k9mail.core.android.common.contact.ContactRepository import com.fsck.k9.ui.messagelist.MessageListAppearance import com.fsck.k9.ui.messagelist.MessageListItem import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.feature.account.avatar.AvatarMonogramCreator /** * A composable view holder for message list items. */ +@Suppress("LongParameterList") class ComposableMessageViewHolder( private val composeView: ComposeView, private val themeProvider: FeatureThemeProvider, @@ -17,6 +20,8 @@ class ComposableMessageViewHolder( private val onAvatarClick: (MessageListItem) -> Unit, private val onFavouriteClick: (MessageListItem) -> Unit, private val appearance: MessageListAppearance, + private val contactRepository: ContactRepository, + private val avatarMonogramCreator: AvatarMonogramCreator, ) : MessageListViewHolder(composeView) { var uniqueId: Long = -1L @@ -30,6 +35,8 @@ class ComposableMessageViewHolder( item = item, isActive = isActive, isSelected = isSelected, + contactRepository = contactRepository, + avatarMonogramCreator = avatarMonogramCreator, onClick = { onClick(item) }, onLongClick = { onLongClick(item) }, onAvatarClick = { onAvatarClick(item) }, @@ -41,10 +48,12 @@ class ComposableMessageViewHolder( } companion object { - + @Suppress("LongParameterList") fun create( context: Context, themeProvider: FeatureThemeProvider, + contactRepository: ContactRepository, + avatarMonogramCreator: AvatarMonogramCreator, onClick: (MessageListItem) -> Unit, onLongClick: (MessageListItem) -> Unit, onFavouriteClick: (MessageListItem) -> Unit, @@ -56,6 +65,8 @@ class ComposableMessageViewHolder( val holder = ComposableMessageViewHolder( composeView = composeView, themeProvider = themeProvider, + contactRepository = contactRepository, + avatarMonogramCreator = avatarMonogramCreator, onClick = onClick, onLongClick = onLongClick, onAvatarClick = onAvatarClick, diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt index 3ff073a960b..e9059d461b4 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt @@ -1,7 +1,28 @@ package com.fsck.k9.ui.messagelist.item +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import app.k9mail.core.android.common.contact.ContactRepository +import app.k9mail.core.ui.compose.designsystem.atom.CircularProgressIndicator +import app.k9mail.core.ui.compose.designsystem.atom.image.RemoteImage +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme import com.fsck.k9.ui.messagelist.MessageListAppearance import com.fsck.k9.ui.messagelist.MessageListItem import kotlin.time.ExperimentalTime @@ -11,14 +32,17 @@ import kotlinx.datetime.toLocalDateTime import net.thunderbird.core.ui.compose.designsystem.organism.message.ActiveMessageItem import net.thunderbird.core.ui.compose.designsystem.organism.message.ReadMessageItem import net.thunderbird.core.ui.compose.designsystem.organism.message.UnreadMessageItem +import net.thunderbird.feature.account.avatar.AvatarMonogramCreator -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LongMethod") @OptIn(ExperimentalTime::class) @Composable internal fun MessageItemContent( item: MessageListItem, isActive: Boolean, isSelected: Boolean, + contactRepository: ContactRepository, + avatarMonogramCreator: AvatarMonogramCreator, onClick: () -> Unit, onLongClick: () -> Unit, onAvatarClick: () -> Unit, @@ -30,13 +54,32 @@ internal fun MessageItemContent( .toLocalDateTime(TimeZone.currentSystemDefault()) } + val uri by remember(item.displayAddress?.address) { + mutableStateOf( + contactRepository.getPhotoUri( + item.displayAddress?.address ?: "", + ), + ) + } + val monogram by remember(item.displayName.toString(), item.displayAddress?.address) { + mutableStateOf(avatarMonogramCreator.create(item.displayName.toString(), item.displayAddress?.address)) + } + when { isActive -> ActiveMessageItem( sender = "${item.displayName}", subject = item.subject ?: "n/a", preview = item.previewText, receivedAt = receivedAt, - avatar = {}, + avatar = { + if (appearance.showContactPicture) { + ContactImageAvatar( + contactImageUri = uri, + contactImageMonogram = monogram, + onAvatarClick = onAvatarClick, + ) + } + }, onClick = onClick, onLongClick = onLongClick, onLeadingClick = onAvatarClick, @@ -48,12 +91,21 @@ internal fun MessageItemContent( hasAttachments = item.hasAttachments, swapSenderWithSubject = !appearance.senderAboveSubject, ) + item.isRead -> ReadMessageItem( sender = "${item.displayName}", subject = item.subject ?: "n/a", preview = item.previewText, receivedAt = receivedAt, - avatar = {}, + avatar = { + if (appearance.showContactPicture) { + ContactImageAvatar( + contactImageUri = uri, + contactImageMonogram = monogram, + onAvatarClick = onAvatarClick, + ) + } + }, onClick = onClick, onLongClick = onLongClick, onLeadingClick = onAvatarClick, @@ -65,12 +117,21 @@ internal fun MessageItemContent( hasAttachments = item.hasAttachments, swapSenderWithSubject = !appearance.senderAboveSubject, ) + else -> UnreadMessageItem( sender = "${item.displayName}", subject = item.subject ?: "n/a", preview = item.previewText, receivedAt = receivedAt, - avatar = {}, + avatar = { + if (appearance.showContactPicture) { + ContactImageAvatar( + contactImageUri = uri, + contactImageMonogram = monogram, + onAvatarClick = onAvatarClick, + ) + } + }, onClick = onClick, onLongClick = onLongClick, onLeadingClick = onAvatarClick, @@ -84,3 +145,39 @@ internal fun MessageItemContent( ) } } + +@Composable +fun ContactImageAvatar( + contactImageUri: Uri?, + contactImageMonogram: String, + modifier: Modifier = Modifier, + onAvatarClick: () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .padding(MainTheme.spacings.half) + .background(color = MainTheme.colors.primaryContainer.copy(alpha = 0.15f), shape = CircleShape) + .border(width = 1.dp, color = MainTheme.colors.primary, shape = CircleShape) + .clickable(onClick = onAvatarClick), + ) { + contactImageUri?.let { + RemoteImage( + url = it.toString(), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + modifier = modifier + .fillMaxSize() + .clip(CircleShape), + placeholder = { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(MainTheme.sizes.iconAvatar)) { + CircularProgressIndicator(modifier = Modifier.size(MainTheme.sizes.icon)) + } + }, + ) + } ?: run { + TextTitleSmall(text = contactImageMonogram) + } + } +} diff --git a/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt b/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt index c1a4e6f0ea5..a42052cf257 100644 --- a/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt +++ b/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt @@ -438,6 +438,8 @@ class MessageListAdapterTest : RobolectricTest() { relativeDateTimeFormatter = RelativeDateTimeFormatter(context, TestClock()), themeProvider = FakeThemeProvider(), featureFlagProvider = FakeFeatureFlagProvider(), + avatarMonogramCreator = mock(), + contactRepository = mock(), ) }