summaryrefslogtreecommitdiff
path: root/core/android/common/src
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /core/android/common/src
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'core/android/common/src')
-rw-r--r--core/android/common/src/main/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModule.kt12
-rw-r--r--core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/Contact.kt12
-rw-r--r--core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactDataSource.kt86
-rw-r--r--core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactKoinModule.kt34
-rw-r--r--core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactPermissionResolver.kt16
-rw-r--r--core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt48
-rw-r--r--core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/CursorExtensions.kt37
-rw-r--r--core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/EmptyCursor.kt26
-rw-r--r--core/android/common/src/test/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModuleKtTest.kt22
-rw-r--r--core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/AndroidContactPermissionResolverTest.kt43
-rw-r--r--core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt143
-rw-r--r--core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt19
-rw-r--r--core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactKoinModuleKtTest.kt25
-rw-r--r--core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContentResolverContactDataSourceTest.kt120
-rw-r--r--core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/TestContactPermissionResolver.kt9
-rw-r--r--core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtAccessTest.kt100
-rw-r--r--core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtTest.kt33
17 files changed, 785 insertions, 0 deletions
diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModule.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModule.kt
new file mode 100644
index 0000000..5a0b599
--- /dev/null
+++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModule.kt
@@ -0,0 +1,12 @@
+package app.k9mail.core.android.common
+
+import app.k9mail.core.android.common.contact.contactModule
+import app.k9mail.core.common.coreCommonModule
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+val coreCommonAndroidModule: Module = module {
+ includes(coreCommonModule)
+
+ includes(contactModule)
+}
diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/Contact.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/Contact.kt
new file mode 100644
index 0000000..cc5d3d1
--- /dev/null
+++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/Contact.kt
@@ -0,0 +1,12 @@
+package app.k9mail.core.android.common.contact
+
+import android.net.Uri
+import app.k9mail.core.common.mail.EmailAddress
+
+data class Contact(
+ val id: Long,
+ val name: String?,
+ val emailAddress: EmailAddress,
+ val uri: Uri,
+ val photoUri: Uri?,
+)
diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactDataSource.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactDataSource.kt
new file mode 100644
index 0000000..934cacc
--- /dev/null
+++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactDataSource.kt
@@ -0,0 +1,86 @@
+package app.k9mail.core.android.common.contact
+
+import android.content.ContentResolver
+import android.database.Cursor
+import android.net.Uri
+import android.provider.ContactsContract
+import app.k9mail.core.android.common.database.EmptyCursor
+import app.k9mail.core.android.common.database.getLongOrThrow
+import app.k9mail.core.android.common.database.getStringOrNull
+import app.k9mail.core.common.mail.EmailAddress
+
+interface ContactDataSource {
+
+ fun getContactFor(emailAddress: EmailAddress): Contact?
+
+ fun hasContactFor(emailAddress: EmailAddress): Boolean
+}
+
+internal class ContentResolverContactDataSource(
+ private val contentResolver: ContentResolver,
+ private val contactPermissionResolver: ContactPermissionResolver,
+) : ContactDataSource {
+
+ override fun getContactFor(emailAddress: EmailAddress): Contact? {
+ getCursorFor(emailAddress).use { cursor ->
+ if (cursor.moveToFirst()) {
+ val contactId = cursor.getLongOrThrow(ContactsContract.CommonDataKinds.Email._ID)
+ val lookupKey = cursor.getStringOrNull(ContactsContract.Contacts.LOOKUP_KEY)
+ val uri = ContactsContract.Contacts.getLookupUri(contactId, lookupKey)
+
+ val name = cursor.getStringOrNull(ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME)
+
+ val photoUri = cursor.getStringOrNull(ContactsContract.CommonDataKinds.Photo.PHOTO_URI)
+ ?.let { photoUriString -> Uri.parse(photoUriString) }
+
+ return Contact(
+ id = contactId,
+ name = name,
+ emailAddress = emailAddress,
+ uri = uri,
+ photoUri = photoUri,
+ )
+ } else {
+ return null
+ }
+ }
+ }
+
+ override fun hasContactFor(emailAddress: EmailAddress): Boolean {
+ getCursorFor(emailAddress).use { cursor ->
+ return cursor.count > 0
+ }
+ }
+
+ private fun getCursorFor(emailAddress: EmailAddress): Cursor {
+ return if (contactPermissionResolver.hasContactPermission()) {
+ val uri = Uri.withAppendedPath(
+ ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
+ Uri.encode(emailAddress.address),
+ )
+
+ contentResolver.query(
+ uri,
+ PROJECTION,
+ null,
+ null,
+ SORT_ORDER,
+ ) ?: EmptyCursor()
+ } else {
+ EmptyCursor()
+ }
+ }
+
+ private companion object {
+
+ private const val SORT_ORDER = ContactsContract.Contacts.DISPLAY_NAME +
+ ", " + ContactsContract.CommonDataKinds.Email._ID
+
+ private val PROJECTION = arrayOf(
+ ContactsContract.CommonDataKinds.Email._ID,
+ ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME,
+ ContactsContract.CommonDataKinds.Photo.PHOTO_URI,
+ ContactsContract.Contacts.LOOKUP_KEY,
+ )
+ }
+}
diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactKoinModule.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactKoinModule.kt
new file mode 100644
index 0000000..db931ee
--- /dev/null
+++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactKoinModule.kt
@@ -0,0 +1,34 @@
+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
+import app.k9mail.core.common.mail.EmailAddress
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+internal val contactModule = module {
+ single<Cache<EmailAddress, Contact?>>(named(CACHE_NAME)) {
+ SynchronizedCache(
+ delegateCache = ExpiringCache(clock = get()),
+ )
+ }
+ factory<ContactDataSource> {
+ ContentResolverContactDataSource(
+ contentResolver = get<Context>().contentResolver,
+ contactPermissionResolver = get(),
+ )
+ }
+ factory<ContactRepository> {
+ CachingContactRepository(
+ cache = get(named(CACHE_NAME)),
+ dataSource = get(),
+ )
+ }
+ factory<ContactPermissionResolver> {
+ AndroidContactPermissionResolver(context = get())
+ }
+}
+
+internal const val CACHE_NAME = "ContactCache"
diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactPermissionResolver.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactPermissionResolver.kt
new file mode 100644
index 0000000..d0490c1
--- /dev/null
+++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactPermissionResolver.kt
@@ -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
+ }
+}
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
new file mode 100644
index 0000000..3378a2d
--- /dev/null
+++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt
@@ -0,0 +1,48 @@
+package app.k9mail.core.android.common.contact
+
+import app.k9mail.core.common.cache.Cache
+import app.k9mail.core.common.mail.EmailAddress
+
+interface ContactRepository {
+
+ fun getContactFor(emailAddress: EmailAddress): Contact?
+
+ fun hasContactFor(emailAddress: EmailAddress): Boolean
+
+ fun hasAnyContactFor(emailAddresses: List<EmailAddress>): Boolean
+}
+
+interface CachingRepository {
+ fun clearCache()
+}
+
+internal class CachingContactRepository(
+ private val cache: Cache<EmailAddress, Contact?>,
+ private val dataSource: ContactDataSource,
+) : ContactRepository, CachingRepository {
+
+ override fun getContactFor(emailAddress: EmailAddress): Contact? {
+ if (cache.hasKey(emailAddress)) {
+ return cache[emailAddress]
+ }
+
+ return dataSource.getContactFor(emailAddress).also {
+ cache[emailAddress] = it
+ }
+ }
+
+ override fun hasContactFor(emailAddress: EmailAddress): Boolean {
+ if (cache.hasKey(emailAddress)) {
+ return cache[emailAddress] != null
+ }
+
+ return dataSource.hasContactFor(emailAddress)
+ }
+
+ override fun hasAnyContactFor(emailAddresses: List<EmailAddress>): Boolean =
+ emailAddresses.any { emailAddress -> hasContactFor(emailAddress) }
+
+ override fun clearCache() {
+ cache.clear()
+ }
+}
diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/CursorExtensions.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/CursorExtensions.kt
new file mode 100644
index 0000000..44b82b9
--- /dev/null
+++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/CursorExtensions.kt
@@ -0,0 +1,37 @@
+package app.k9mail.core.android.common.database
+
+import android.database.Cursor
+
+fun <T> Cursor.map(block: (Cursor) -> T): List<T> {
+ return List(count) { index ->
+ moveToPosition(index)
+ block(this)
+ }
+}
+
+fun Cursor.getStringOrNull(columnName: String): String? {
+ val columnIndex = getColumnIndex(columnName)
+ return if (isNull(columnIndex)) null else getString(columnIndex)
+}
+
+fun Cursor.getIntOrNull(columnName: String): Int? {
+ val columnIndex = getColumnIndex(columnName)
+ return if (isNull(columnIndex)) null else getInt(columnIndex)
+}
+
+fun Cursor.getLongOrNull(columnName: String): Long? {
+ val columnIndex = getColumnIndex(columnName)
+ return if (isNull(columnIndex)) null else getLong(columnIndex)
+}
+
+fun Cursor.getStringOrThrow(columnName: String): String {
+ return getStringOrNull(columnName) ?: error("Column $columnName must not be null")
+}
+
+fun Cursor.getIntOrThrow(columnName: String): Int {
+ return getIntOrNull(columnName) ?: error("Column $columnName must not be null")
+}
+
+fun Cursor.getLongOrThrow(columnName: String): Long {
+ return getLongOrNull(columnName) ?: error("Column $columnName must not be null")
+}
diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/EmptyCursor.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/EmptyCursor.kt
new file mode 100644
index 0000000..a5a4d6f
--- /dev/null
+++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/EmptyCursor.kt
@@ -0,0 +1,26 @@
+package app.k9mail.core.android.common.database
+
+import android.database.AbstractCursor
+
+/**
+ * A dummy class that provides an empty cursor
+ */
+class EmptyCursor : AbstractCursor() {
+ override fun getCount() = 0
+
+ override fun getColumnNames() = arrayOf<String>()
+
+ override fun getString(column: Int) = null
+
+ override fun getShort(column: Int): Short = 0
+
+ override fun getInt(column: Int) = 0
+
+ override fun getLong(column: Int): Long = 0
+
+ override fun getFloat(column: Int) = 0f
+
+ override fun getDouble(column: Int) = 0.0
+
+ override fun isNull(column: Int) = true
+}
diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModuleKtTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModuleKtTest.kt
new file mode 100644
index 0000000..5d1a3d7
--- /dev/null
+++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModuleKtTest.kt
@@ -0,0 +1,22 @@
+package app.k9mail.core.android.common
+
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.koinApplication
+import org.koin.test.check.checkModules
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+internal class CoreCommonAndroidModuleKtTest {
+
+ @Test
+ fun `should have a valid di module`() {
+ koinApplication {
+ modules(coreCommonAndroidModule)
+ androidContext(RuntimeEnvironment.getApplication())
+ checkModules()
+ }
+ }
+}
diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/AndroidContactPermissionResolverTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/AndroidContactPermissionResolverTest.kt
new file mode 100644
index 0000000..ea0dc67
--- /dev/null
+++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/AndroidContactPermissionResolverTest.kt
@@ -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)
+ }
+}
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
new file mode 100644
index 0000000..5ea564e
--- /dev/null
+++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt
@@ -0,0 +1,143 @@
+package app.k9mail.core.android.common.contact
+
+import app.k9mail.core.common.cache.InMemoryCache
+import app.k9mail.core.common.mail.EmailAddress
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+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.doReturn
+import org.mockito.kotlin.doReturnConsecutively
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class CachingContactRepositoryTest {
+
+ private val dataSource = mock<ContactDataSource>()
+ private val cache = InMemoryCache<EmailAddress, Contact?>()
+
+ private val testSubject = CachingContactRepository(cache = cache, dataSource = dataSource)
+
+ @Before
+ fun setUp() {
+ cache.clear()
+ }
+
+ @Test
+ fun `getContactFor() returns null if no contact exists`() {
+ val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `getContactFor() returns contact if it exists`() {
+ dataSource.stub { on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturn CONTACT }
+
+ val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isEqualTo(CONTACT)
+ }
+
+ @Test
+ fun `getContactFor() caches contact`() {
+ dataSource.stub {
+ on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturnConsecutively listOf(
+ CONTACT,
+ CONTACT.copy(id = 567L),
+ )
+ }
+
+ val result1 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+ val result2 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result1).isEqualTo(result2)
+ }
+
+ @Test
+ fun `getContactFor() caches null`() {
+ dataSource.stub {
+ on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturnConsecutively listOf(
+ null,
+ CONTACT,
+ )
+ }
+
+ val result1 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+ val result2 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result1).isEqualTo(result2)
+ }
+
+ @Test
+ fun `getContactFor() returns cached contact`() {
+ cache[CONTACT_EMAIL_ADDRESS] = CONTACT
+
+ val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isEqualTo(CONTACT)
+ }
+
+ @Test
+ fun `hasContactFor() returns false if no contact exists`() {
+ val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `hasContactFor() returns false if cached contact is null`() {
+ cache[CONTACT_EMAIL_ADDRESS] = null
+
+ val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `hasContactFor() returns true if contact exists`() {
+ dataSource.stub { on { hasContactFor(CONTACT_EMAIL_ADDRESS) } doReturn true }
+
+ val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isTrue()
+ }
+
+ @Test
+ fun `hasAnyContactFor() returns false if no contact exists`() {
+ val result = testSubject.hasAnyContactFor(listOf(CONTACT_EMAIL_ADDRESS))
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `hasAnyContactFor() returns false if list is empty`() {
+ val result = testSubject.hasAnyContactFor(listOf())
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `hasAnyContactFor() returns true if contact exists`() {
+ dataSource.stub { on { hasContactFor(CONTACT_EMAIL_ADDRESS) } doReturn true }
+
+ val result = testSubject.hasAnyContactFor(listOf(CONTACT_EMAIL_ADDRESS))
+
+ assertThat(result).isTrue()
+ }
+
+ @Test
+ fun `clearCache() clears cache`() {
+ cache[CONTACT_EMAIL_ADDRESS] = CONTACT
+
+ testSubject.clearCache()
+
+ assertThat(cache[CONTACT_EMAIL_ADDRESS]).isNull()
+ }
+}
diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt
new file mode 100644
index 0000000..600ca31
--- /dev/null
+++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt
@@ -0,0 +1,19 @@
+package app.k9mail.core.android.common.contact
+
+import android.net.Uri
+import app.k9mail.core.common.mail.EmailAddress
+
+const val CONTACT_ID = 123L
+const val CONTACT_NAME = "user name"
+const val CONTACT_LOOKUP_KEY = "0r1-4F314D4F2F294F29"
+val CONTACT_EMAIL_ADDRESS = EmailAddress("user@example.com")
+val CONTACT_URI: Uri = Uri.parse("content://com.android.contacts/contacts/lookup/$CONTACT_LOOKUP_KEY/$CONTACT_ID")
+val CONTACT_PHOTO_URI: Uri = Uri.parse("content://com.android.contacts/display_photo/$CONTACT_ID")
+
+val CONTACT = Contact(
+ id = CONTACT_ID,
+ name = CONTACT_NAME,
+ emailAddress = CONTACT_EMAIL_ADDRESS,
+ uri = CONTACT_URI,
+ photoUri = CONTACT_PHOTO_URI,
+)
diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactKoinModuleKtTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactKoinModuleKtTest.kt
new file mode 100644
index 0000000..863a7d1
--- /dev/null
+++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactKoinModuleKtTest.kt
@@ -0,0 +1,25 @@
+package app.k9mail.core.android.common.contact
+
+import app.k9mail.core.android.common.coreCommonAndroidModule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.koinApplication
+import org.koin.test.check.checkModules
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+internal class ContactKoinModuleKtTest {
+ @Test
+ fun `should have a valid di module`() {
+ koinApplication {
+ modules(coreCommonAndroidModule)
+
+ modules(contactModule)
+
+ androidContext(RuntimeEnvironment.getApplication())
+ checkModules()
+ }
+ }
+}
diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContentResolverContactDataSourceTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContentResolverContactDataSourceTest.kt
new file mode 100644
index 0000000..d74ef93
--- /dev/null
+++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContentResolverContactDataSourceTest.kt
@@ -0,0 +1,120 @@
+package app.k9mail.core.android.common.contact
+
+import android.content.ContentResolver
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.provider.ContactsContract
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
+import assertk.assertions.isNull
+import assertk.assertions.isTrue
+import kotlin.test.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.stub
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class ContentResolverContactDataSourceTest {
+ private val contactPermissionResolver = TestContactPermissionResolver(hasPermission = true)
+ private val contentResolver = mock<ContentResolver>()
+
+ private val testSubject = ContentResolverContactDataSource(
+ contentResolver = contentResolver,
+ contactPermissionResolver = contactPermissionResolver,
+ )
+
+ @Test
+ fun `getContactForEmail() returns null if permission is not granted`() {
+ contactPermissionResolver.hasContactPermission = false
+
+ val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `getContactForEmail() returns null if no contact is found`() {
+ setupContactProvider(setupEmptyContactCursor())
+
+ val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `getContactForEmail() returns contact if a contact is found`() {
+ setupContactProvider(setupContactCursor())
+
+ val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isEqualTo(CONTACT)
+ }
+
+ @Test
+ fun `hasContactForEmail() returns false if permission is not granted`() {
+ contactPermissionResolver.hasContactPermission = false
+
+ val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `hasContactForEmail() returns false if no contact is found`() {
+ setupContactProvider(setupEmptyContactCursor())
+
+ val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isFalse()
+ }
+
+ @Test
+ fun `hasContactForEmail() returns true if a contact is found`() {
+ setupContactProvider(setupContactCursor())
+
+ val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
+
+ assertThat(result).isTrue()
+ }
+
+ private fun setupContactProvider(contactCursor: Cursor) {
+ val emailUri = Uri.withAppendedPath(
+ ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
+ Uri.encode(CONTACT_EMAIL_ADDRESS.address),
+ )
+
+ contentResolver.stub {
+ on {
+ query(eq(emailUri), eq(PROJECTION), anyOrNull(), anyOrNull(), eq(SORT_ORDER))
+ } doReturn contactCursor
+ }
+ }
+
+ private fun setupEmptyContactCursor(): Cursor {
+ return MatrixCursor(PROJECTION)
+ }
+
+ private fun setupContactCursor(): Cursor {
+ return MatrixCursor(PROJECTION).apply {
+ addRow(arrayOf(CONTACT_ID, CONTACT_NAME, CONTACT_PHOTO_URI, CONTACT_LOOKUP_KEY))
+ }
+ }
+
+ private companion object {
+ val PROJECTION = arrayOf(
+ ContactsContract.CommonDataKinds.Email._ID,
+ ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME,
+ ContactsContract.CommonDataKinds.Photo.PHOTO_URI,
+ ContactsContract.Contacts.LOOKUP_KEY,
+ )
+
+ const val SORT_ORDER = ContactsContract.Contacts.DISPLAY_NAME +
+ ", " + ContactsContract.CommonDataKinds.Email._ID
+ }
+}
diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/TestContactPermissionResolver.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/TestContactPermissionResolver.kt
new file mode 100644
index 0000000..d0b7623
--- /dev/null
+++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/TestContactPermissionResolver.kt
@@ -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
+ }
+}
diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtAccessTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtAccessTest.kt
new file mode 100644
index 0000000..d228ae4
--- /dev/null
+++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtAccessTest.kt
@@ -0,0 +1,100 @@
+package app.k9mail.core.android.common.database
+
+import android.database.Cursor
+import android.database.MatrixCursor
+import assertk.assertThat
+import assertk.assertions.hasMessage
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFailure
+import assertk.assertions.isNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.ParameterizedRobolectricTestRunner
+
+data class CursorExtensionsAccessTestData<T : Any>(
+ val name: String,
+ val value: T,
+ val access: (Cursor, String) -> T?,
+ val throwingAccess: (Cursor, String) -> T,
+) {
+ override fun toString(): String = name
+}
+
+@RunWith(ParameterizedRobolectricTestRunner::class)
+class CursorExtensionsKtAccessTest(data: CursorExtensionsAccessTestData<Any>) {
+
+ private val testValue = data.value
+ private val testAction = data.access
+ private val testThrowingAction = data.throwingAccess
+
+ @Test
+ fun `testAction should return null if column is null`() {
+ val cursor = MatrixCursor(arrayOf("column")).apply {
+ addRow(arrayOf(null))
+ }
+
+ val result = cursor.map { testAction(it, "column") }
+
+ assertThat(result[0]).isNull()
+ }
+
+ @Test
+ fun `testAction should return value if column is not null`() {
+ val cursor = MatrixCursor(arrayOf("column")).apply {
+ addRow(arrayOf(testValue))
+ }
+
+ val result = cursor.map { testAction(it, "column") }
+
+ assertThat(result[0]).isEqualTo(testValue)
+ }
+
+ @Test
+ fun `testThrowingAction should throw if column is null`() {
+ val cursor = MatrixCursor(arrayOf("column")).apply {
+ addRow(arrayOf(null))
+ }
+
+ assertThat {
+ cursor.map { testThrowingAction(it, "column") }
+ }.isFailure().hasMessage("Column column must not be null")
+ }
+
+ @Test
+ fun `testThrowingAction should return value if column is not null`() {
+ val cursor = MatrixCursor(arrayOf("column")).apply {
+ addRow(arrayOf(testValue))
+ }
+
+ val result = cursor.map { testThrowingAction(it, "column") }
+
+ assertThat(result[0]).isEqualTo(testValue)
+ }
+
+ companion object {
+ @JvmStatic
+ @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
+ fun data(): Collection<CursorExtensionsAccessTestData<Any>> {
+ return listOf(
+ CursorExtensionsAccessTestData(
+ name = "getString",
+ value = "value",
+ access = { cursor, column -> cursor.getStringOrNull(column) },
+ throwingAccess = { cursor, column -> cursor.getStringOrThrow(column) },
+ ),
+ CursorExtensionsAccessTestData(
+ name = "getInt",
+ value = Int.MAX_VALUE,
+ access = { cursor, column -> cursor.getIntOrNull(column) },
+ throwingAccess = { cursor, column -> cursor.getIntOrThrow(column) },
+ ),
+ CursorExtensionsAccessTestData(
+ name = "getLong",
+ value = Long.MAX_VALUE,
+ access = { cursor, column -> cursor.getLongOrNull(column) },
+ throwingAccess = { cursor, column -> cursor.getLongOrThrow(column) },
+ ),
+ )
+ }
+ }
+}
diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtTest.kt
new file mode 100644
index 0000000..7601712
--- /dev/null
+++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtTest.kt
@@ -0,0 +1,33 @@
+package app.k9mail.core.android.common.database
+
+import android.database.MatrixCursor
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class CursorExtensionsKtTest {
+
+ @Test
+ fun `map should return an empty list if cursor is empty`() {
+ val cursor = MatrixCursor(arrayOf("column"))
+
+ val result = cursor.map { it.getStringOrNull("column") }
+
+ assertThat(result).isEqualTo(emptyList<String>())
+ }
+
+ @Test
+ fun `map should return a list of mapped values`() {
+ val cursor = MatrixCursor(arrayOf("column")).apply {
+ addRow(arrayOf("value1"))
+ addRow(arrayOf("value2"))
+ }
+
+ val result = cursor.map { it.getStringOrNull("column") }
+
+ assertThat(result).isEqualTo(listOf("value1", "value2"))
+ }
+}