Merge pull request #6746 from thundernest/contact_permission

Message details: Hide "Add to contacts" button when contacts permission is missing
This commit is contained in:
cketti 2023-03-14 15:57:09 +01:00 committed by GitHub
commit cbea30e5b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 127 additions and 46 deletions

View file

@ -11,6 +11,7 @@ val messageDetailsUiModule = module {
folderRepository = get(),
contactSettingsProvider = get(),
contactRepository = get(),
contactPermissionResolver = get(),
clipboardManager = get(),
accountManager = get(),
participantFormatter = get(),

View file

@ -0,0 +1,6 @@
package com.fsck.k9.ui.messagedetails
data class MessageDetailsAppearance(
val showContactPicture: Boolean,
val alwaysHideAddToContactsButton: Boolean,
)

View file

@ -121,13 +121,17 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
progressBar.isVisible = false
errorView.isVisible = false
recyclerView.isVisible = true
setMessageDetails(recyclerView, state.details, state.showContactPicture)
setMessageDetails(recyclerView, state.details, state.appearance)
}
}
}
}
private fun setMessageDetails(recyclerView: RecyclerView, details: MessageDetailsUi, showContactPicture: Boolean) {
private fun setMessageDetails(
recyclerView: RecyclerView,
details: MessageDetailsUi,
appearance: MessageDetailsAppearance,
) {
val itemAdapter = ItemAdapter<GenericItem>().apply {
add(MessageDateItem(details.date ?: getString(R.string.message_details_missing_date)))
@ -135,15 +139,15 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
add(CryptoStatusItem(details.cryptoDetails))
}
addParticipants(details.from, R.string.message_details_from_section_title, showContactPicture)
addParticipants(details.sender, R.string.message_details_sender_section_title, showContactPicture)
addParticipants(details.replyTo, R.string.message_details_replyto_section_title, showContactPicture)
addParticipants(details.from, R.string.message_details_from_section_title, appearance)
addParticipants(details.sender, R.string.message_details_sender_section_title, appearance)
addParticipants(details.replyTo, R.string.message_details_replyto_section_title, appearance)
add(MessageDetailsDividerItem())
addParticipants(details.to, R.string.message_details_to_section_title, showContactPicture)
addParticipants(details.cc, R.string.message_details_cc_section_title, showContactPicture)
addParticipants(details.bcc, R.string.message_details_bcc_section_title, showContactPicture)
addParticipants(details.to, R.string.message_details_to_section_title, appearance)
addParticipants(details.cc, R.string.message_details_cc_section_title, appearance)
addParticipants(details.bcc, R.string.message_details_bcc_section_title, appearance)
if (details.folder != null) {
addFolderName(details.folder)
@ -163,14 +167,21 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
private fun ItemAdapter<GenericItem>.addParticipants(
participants: List<Participant>,
@StringRes title: Int,
showContactPicture: Boolean,
appearance: MessageDetailsAppearance,
) {
if (participants.isNotEmpty()) {
val extraText = if (participants.size > 1) participants.size.toString() else null
add(SectionHeaderItem(title = getString(title), extra = extraText))
for (participant in participants) {
add(ParticipantItem(contactPictureLoader, showContactPicture, participant))
add(
ParticipantItem(
contactPictureLoader,
appearance.showContactPicture,
appearance.alwaysHideAddToContactsButton,
participant,
),
)
}
}
}

View file

@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.k9mail.core.android.common.contact.ContactPermissionResolver
import app.k9mail.core.android.common.contact.ContactRepository
import app.k9mail.core.common.mail.EmailAddress
import com.fsck.k9.Account
@ -34,6 +35,7 @@ internal class MessageDetailsViewModel(
private val folderRepository: FolderRepository,
private val contactSettingsProvider: ContactSettingsProvider,
private val contactRepository: ContactRepository,
private val contactPermissionResolver: ContactPermissionResolver,
private val clipboardManager: ClipboardManager,
private val accountManager: AccountManager,
private val participantFormatter: MessageDetailsParticipantFormatter,
@ -67,8 +69,13 @@ internal class MessageDetailsViewModel(
folder = folder?.toFolderInfo(),
)
MessageDetailsState.DataLoaded(
val messageDetailsAppearance = MessageDetailsAppearance(
showContactPicture = contactSettingsProvider.isShowContactPicture,
alwaysHideAddToContactsButton = !contactPermissionResolver.hasContactPermission(),
)
MessageDetailsState.DataLoaded(
appearance = messageDetailsAppearance,
details = messageDetailsUi,
)
} catch (e: Exception) {
@ -155,7 +162,7 @@ sealed interface MessageDetailsState {
object Loading : MessageDetailsState
object Error : MessageDetailsState
data class DataLoaded(
val showContactPicture: Boolean,
val appearance: MessageDetailsAppearance,
val details: MessageDetailsUi,
) : MessageDetailsState
}

View file

@ -13,6 +13,7 @@ import com.mikepenz.fastadapter.items.AbstractItem
internal class ParticipantItem(
private val contactPictureLoader: ContactPictureLoader,
private val showContactsPicture: Boolean,
private val alwaysHideAddContactsButton: Boolean,
val participant: Participant,
) : AbstractItem<ParticipantItem.ViewHolder>() {
override val type: Int = R.id.message_details_participant
@ -44,7 +45,7 @@ internal class ParticipantItem(
}
email.text = participant.emailAddress
menuAddContact.isVisible = !participant.isInContacts
menuAddContact.isVisible = !item.alwaysHideAddContactsButton && !participant.isInContacts
if (item.showContactsPicture) {
item.contactPictureLoader.setContactPicture(contactPicture, participant.address)

View file

@ -174,7 +174,7 @@
<ID>LongParameterList:ImapSync.kt$ImapSync$( syncConfig: SyncConfig, remoteFolder: ImapFolder, unsyncedMessages: List&lt;ImapMessage&gt;, smallMessages: MutableList&lt;ImapMessage&gt;, largeMessages: MutableList&lt;ImapMessage&gt;, progress: AtomicInteger, todo: Int, listener: SyncListener, )</ID>
<ID>LongParameterList:MessageDatabaseHelpers.kt$( folderId: Long, deleted: Boolean = false, uid: String? = null, subject: String = "", date: Long = 0L, flags: String = "", senderList: String = "", toList: String = "", ccList: String = "", bccList: String = "", replyToList: String = "", attachmentCount: Int = 0, internalDate: Long = 0L, messageIdHeader: String? = null, previewType: DatabasePreviewType = DatabasePreviewType.NONE, preview: String = "", mimeType: String = "text/plain", normalizedSubjectHash: Long = 0L, empty: Boolean = false, read: Boolean = false, flagged: Boolean = false, answered: Boolean = false, forwarded: Boolean = false, messagePartId: Long = 0L, encryptionType: String? = null, newMessage: Boolean = false, )</ID>
<ID>LongParameterList:MessageDatabaseHelpers.kt$( type: Int = 0, root: Int? = null, parent: Int = -1, seq: Int = 0, mimeType: String = "text/plain", decodedBodySize: Int = 0, displayName: String? = null, header: String? = null, encoding: String = "7bit", charset: String? = null, dataLocation: Int = 0, data: ByteArray? = null, preamble: String? = null, epilogue: String? = null, boundary: String? = null, contentId: String? = null, serverExtra: String? = null, directory: File? = null, )</ID>
<ID>LongParameterList:MessageDetailsViewModel.kt$MessageDetailsViewModel$( private val resources: Resources, private val messageRepository: MessageRepository, private val folderRepository: FolderRepository, private val contactSettingsProvider: ContactSettingsProvider, private val contactRepository: ContactRepository, private val clipboardManager: ClipboardManager, private val accountManager: AccountManager, private val participantFormatter: MessageDetailsParticipantFormatter, private val folderNameFormatter: FolderNameFormatter, )</ID>
<ID>LongParameterList:MessageDetailsViewModel.kt$MessageDetailsViewModel$( private val resources: Resources, private val messageRepository: MessageRepository, private val folderRepository: FolderRepository, private val contactSettingsProvider: ContactSettingsProvider, private val contactRepository: ContactRepository, private val contactPermissionResolver: ContactPermissionResolver, private val clipboardManager: ClipboardManager, private val accountManager: AccountManager, private val participantFormatter: MessageDetailsParticipantFormatter, private val folderNameFormatter: FolderNameFormatter, )</ID>
<ID>LongParameterList:MessageListAdapterTest.kt$MessageListAdapterTest$( account: Account = Account(SOME_ACCOUNT_UUID), subject: String? = "irrelevant", threadCount: Int = 0, messageDate: Long = 0L, internalDate: Long = 0L, displayName: CharSequence = "irrelevant", displayAddress: Address? = Address.parse("irrelevant@domain.example").first(), previewText: String = "irrelevant", isMessageEncrypted: Boolean = false, isRead: Boolean = false, isStarred: Boolean = false, isAnswered: Boolean = false, isForwarded: Boolean = false, hasAttachments: Boolean = false, uniqueId: Long = 0L, folderId: Long = 0L, messageUid: String = "irrelevant", databaseId: Long = 0L, threadRoot: Long = 0L, )</ID>
<ID>LongParameterList:MessageListAdapterTest.kt$MessageListAdapterTest$( fontSizes: FontSizes = createFontSizes(), previewLines: Int = 0, stars: Boolean = true, senderAboveSubject: Boolean = false, showContactPicture: Boolean = true, showingThreadedList: Boolean = true, backGroundAsReadIndicator: Boolean = false, showAccountChip: Boolean = false, density: UiDensity = UiDensity.Default, )</ID>
<ID>LongParameterList:MessagePartDatabaseHelpers.kt$( type: Int = MessagePartType.UNKNOWN, root: Long? = null, parent: Long = -1, seq: Int = 0, mimeType: String? = null, decodedBodySize: Int? = null, displayName: String? = null, header: String? = null, encoding: String? = null, charset: String? = null, dataLocation: Int = DataLocation.MISSING, data: ByteArray? = null, preamble: String? = null, epilogue: String? = null, boundary: String? = null, contentId: String? = null, serverExtra: String? = null, )</ID>

View file

@ -1,13 +1,9 @@
package app.k9mail.core.android.common.contact
import android.Manifest
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import app.k9mail.core.android.common.database.EmptyCursor
import app.k9mail.core.android.common.database.getLongOrThrow
import app.k9mail.core.android.common.database.getStringOrNull
@ -21,8 +17,8 @@ interface ContactDataSource {
}
internal class ContentResolverContactDataSource(
private val context: Context,
private val contentResolver: ContentResolver = context.contentResolver,
private val contentResolver: ContentResolver,
private val contactPermissionResolver: ContactPermissionResolver,
) : ContactDataSource {
override fun getContactFor(emailAddress: EmailAddress): Contact? {
@ -57,12 +53,12 @@ internal class ContentResolverContactDataSource(
}
private fun getCursorFor(emailAddress: EmailAddress): Cursor {
val uri = Uri.withAppendedPath(
ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
Uri.encode(emailAddress.address),
)
return if (contactPermissionResolver.hasContactPermission()) {
val uri = Uri.withAppendedPath(
ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
Uri.encode(emailAddress.address),
)
return if (hasContactPermission()) {
contentResolver.query(
uri,
PROJECTION,
@ -75,13 +71,6 @@ internal class ContentResolverContactDataSource(
}
}
private fun hasContactPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CONTACTS,
) == PackageManager.PERMISSION_GRANTED
}
private companion object {
private const val SORT_ORDER = ContactsContract.Contacts.DISPLAY_NAME +

View file

@ -1,5 +1,6 @@
package app.k9mail.core.android.common.contact
import android.content.Context
import app.k9mail.core.common.cache.Cache
import app.k9mail.core.common.cache.ExpiringCache
import app.k9mail.core.common.cache.SynchronizedCache
@ -14,7 +15,10 @@ internal val contactModule = module {
)
}
factory<ContactDataSource> {
ContentResolverContactDataSource(context = get())
ContentResolverContactDataSource(
contentResolver = get<Context>().contentResolver,
contactPermissionResolver = get(),
)
}
factory<ContactRepository> {
CachingContactRepository(
@ -22,6 +26,9 @@ internal val contactModule = module {
dataSource = get(),
)
}
factory<ContactPermissionResolver> {
AndroidContactPermissionResolver(context = get())
}
}
internal const val CACHE_NAME = "ContactCache"

View file

@ -0,0 +1,16 @@
package app.k9mail.core.android.common.contact
import android.Manifest.permission.READ_CONTACTS
import android.content.Context
import android.content.pm.PackageManager.PERMISSION_GRANTED
import androidx.core.content.ContextCompat
interface ContactPermissionResolver {
fun hasContactPermission(): Boolean
}
internal class AndroidContactPermissionResolver(private val context: Context) : ContactPermissionResolver {
override fun hasContactPermission(): Boolean {
return ContextCompat.checkSelfPermission(context, READ_CONTACTS) == PERMISSION_GRANTED
}
}

View file

@ -0,0 +1,43 @@
package app.k9mail.core.android.common.contact
import android.Manifest
import assertk.assertThat
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows
@RunWith(RobolectricTestRunner::class)
class AndroidContactPermissionResolverTest {
private val application = RuntimeEnvironment.getApplication()
private val testSubject = AndroidContactPermissionResolver(context = application)
@Test
fun `hasPermission() with contact permission`() {
grantContactPermission()
val result = testSubject.hasContactPermission()
assertThat(result).isTrue()
}
@Test
fun `hasPermission() without contact permission`() {
denyContactPermission()
val result = testSubject.hasContactPermission()
assertThat(result).isFalse()
}
private fun grantContactPermission() {
Shadows.shadowOf(application).grantPermissions(Manifest.permission.READ_CONTACTS)
}
private fun denyContactPermission() {
Shadows.shadowOf(application).denyPermissions(Manifest.permission.READ_CONTACTS)
}
}

View file

@ -1,6 +1,5 @@
package app.k9mail.core.android.common.contact
import android.Manifest
import android.content.ContentResolver
import android.database.Cursor
import android.database.MatrixCursor
@ -12,7 +11,6 @@ import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import kotlin.test.Test
import org.junit.Before
import org.junit.runner.RunWith
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
@ -20,27 +18,20 @@ import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.stub
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows
@RunWith(RobolectricTestRunner::class)
internal class ContentResolverContactDataSourceTest {
private val contactPermissionResolver = TestContactPermissionResolver(hasPermission = true)
private val contentResolver = mock<ContentResolver>()
private val testSubject = ContentResolverContactDataSource(
context = RuntimeEnvironment.getApplication(),
contentResolver = contentResolver,
contactPermissionResolver = contactPermissionResolver,
)
@Before
fun setUp() {
Shadows.shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.READ_CONTACTS)
}
@Test
fun `getContactForEmail() returns null if permission is not granted`() {
Shadows.shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.READ_CONTACTS)
contactPermissionResolver.hasContactPermission = false
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
@ -67,7 +58,7 @@ internal class ContentResolverContactDataSourceTest {
@Test
fun `hasContactForEmail() returns false if permission is not granted`() {
Shadows.shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.READ_CONTACTS)
contactPermissionResolver.hasContactPermission = false
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)

View file

@ -0,0 +1,9 @@
package app.k9mail.core.android.common.contact
class TestContactPermissionResolver(hasPermission: Boolean) : ContactPermissionResolver {
var hasContactPermission = hasPermission
override fun hasContactPermission(): Boolean {
return hasContactPermission
}
}