Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 18:55:42 +01:00
parent a629de6271
commit 3cef7c5092
2161 changed files with 246605 additions and 2 deletions

View file

@ -0,0 +1,13 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
android {
namespace = "app.k9mail.core.android.common"
}
dependencies {
api(projects.core.common)
testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
}

View file

@ -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)
}

View file

@ -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?,
)

View file

@ -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,
)
}
}

View file

@ -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"

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,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()
}
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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()
}
}
}

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

@ -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()
}
}

View file

@ -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,
)

View file

@ -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()
}
}
}

View file

@ -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
}
}

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
}
}

View file

@ -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) },
),
)
}
}
}

View file

@ -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"))
}
}