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,16 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
android {
namespace = "app.k9mail.core.android.common"
}
dependencies {
api(projects.core.common)
implementation(libs.androidx.webkit)
testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
}

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.CAMERA"/>
<application android:supportsRtl="true">
<provider
android:name=".camera.provider.CaptureImageFileProvider"
android:authorities="${applicationId}.activity"
android:exported="false"
android:grantUriPermissions="true"
>
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/capture_image_file_provider_paths"
/>
<meta-data
android:name="de.cketti.safecontentresolver.ALLOW_INTERNAL_ACCESS"
android:value="true"
/>
</provider>
</application>
</manifest>

View file

@ -0,0 +1,18 @@
package app.k9mail.core.android.common
import app.k9mail.core.android.common.camera.cameraModule
import app.k9mail.core.android.common.contact.contactModule
import net.thunderbird.core.android.common.resources.resourcesAndroidModule
import net.thunderbird.core.common.coreCommonModule
import org.koin.core.module.Module
import org.koin.dsl.module
val coreCommonAndroidModule: Module = module {
includes(resourcesAndroidModule)
includes(coreCommonModule)
includes(contactModule)
includes(cameraModule)
}

View file

@ -0,0 +1,15 @@
@file:JvmName("ContextHelper")
package app.k9mail.core.android.common.activity
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
tailrec fun Context.findActivity(): Activity? {
return if (this is Activity) {
this
} else {
(this as? ContextWrapper)?.baseContext?.findActivity()
}
}

View file

@ -0,0 +1,25 @@
package app.k9mail.core.android.common.activity
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContract
class CreateDocumentResultContract : ActivityResultContract<CreateDocumentResultContract.Input, Uri?>() {
override fun createIntent(context: Context, input: Input): Intent {
return Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType(input.mimeType)
.putExtra(Intent.EXTRA_TITLE, input.title)
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
}
data class Input(
val title: String,
val mimeType: String,
)
}

View file

@ -0,0 +1,57 @@
package app.k9mail.core.android.common.camera
import android.Manifest.permission
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.MediaStore
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityCompat.startActivityForResult
import androidx.core.content.ContextCompat
import app.k9mail.core.android.common.camera.io.CaptureImageFileWriter
class CameraCaptureHandler(
private val captureImageFileWriter: CaptureImageFileWriter,
) {
private lateinit var capturedImageUri: Uri
companion object {
const val REQUEST_IMAGE_CAPTURE: Int = 6
const val CAMERA_PERMISSION_REQUEST_CODE: Int = 100
}
fun getCapturedImageUri(): Uri {
if (::capturedImageUri.isInitialized) {
return capturedImageUri
} else {
throw UninitializedPropertyAccessException("Image Uri not initialized")
}
}
fun canLaunchCamera(context: Context) =
context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
fun openCamera(activity: Activity) {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
capturedImageUri = captureImageFileWriter.getFileUri()
intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri)
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
startActivityForResult(activity, intent, REQUEST_IMAGE_CAPTURE, null)
}
fun requestCameraPermission(activity: Activity) {
ActivityCompat.requestPermissions(
activity,
arrayOf(permission.CAMERA),
CAMERA_PERMISSION_REQUEST_CODE,
)
}
fun hasCameraPermission(context: Context): Boolean {
val hasPermission = ContextCompat.checkSelfPermission(context, permission.CAMERA)
return hasPermission == PackageManager.PERMISSION_GRANTED
}
}

View file

@ -0,0 +1,13 @@
package app.k9mail.core.android.common.camera
import app.k9mail.core.android.common.camera.io.CaptureImageFileWriter
import org.koin.dsl.module
internal val cameraModule = module {
single { CaptureImageFileWriter(context = get()) }
single<CameraCaptureHandler> {
CameraCaptureHandler(
captureImageFileWriter = get(),
)
}
}

View file

@ -0,0 +1,31 @@
package app.k9mail.core.android.common.camera.io
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import java.io.File
class CaptureImageFileWriter(private val context: Context) {
fun getFileUri(): Uri {
val file = getCaptureImageFile()
return FileProvider.getUriForFile(context, "${context.packageName}.activity", file)
}
private fun getCaptureImageFile(): File {
val fileName = "IMG_${System.currentTimeMillis()}$FILE_EXT"
return File(getDirectory(), fileName)
}
private fun getDirectory(): File {
val directory = File(context.cacheDir, DIRECTORY_NAME)
directory.mkdirs()
return directory
}
companion object {
private const val FILE_EXT = ".jpg"
private const val DIRECTORY_NAME = "captureImage"
}
}

View file

@ -0,0 +1,5 @@
package app.k9mail.core.android.common.camera.provider
import androidx.core.content.FileProvider
class CaptureImageFileProvider : FileProvider()

View file

@ -0,0 +1,22 @@
package app.k9mail.core.android.common.compat
import android.os.Build
import android.os.Bundle
import java.io.Serializable
// This class resolves a deprecation warning and issue with the Bundle.getSerializable method
// Fixes https://issuetracker.google.com/issues/314250395
// Could be removed once releases in androidx.core.os.BundleCompat
object BundleCompat {
@JvmStatic
fun <T : Serializable> getSerializable(bundle: Bundle, key: String?, clazz: Class<T>): T? = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> bundle.getSerializable(key, clazz)
else -> {
@Suppress("DEPRECATION")
val serializable = bundle.getSerializable(key)
@Suppress("UNCHECKED_CAST")
if (clazz.isInstance(serializable)) serializable as T else null
}
}
}

View file

@ -0,0 +1,12 @@
package app.k9mail.core.android.common.contact
import android.net.Uri
import net.thunderbird.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 net.thunderbird.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,36 @@
package app.k9mail.core.android.common.contact
import android.content.Context
import kotlin.time.ExperimentalTime
import net.thunderbird.core.common.cache.Cache
import net.thunderbird.core.common.cache.ExpiringCache
import net.thunderbird.core.common.cache.SynchronizedCache
import net.thunderbird.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)) {
@OptIn(ExperimentalTime::class)
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 net.thunderbird.core.common.cache.Cache
import net.thunderbird.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 (columnIndex == -1 || isNull(columnIndex)) null else getString(columnIndex)
}
fun Cursor.getIntOrNull(columnName: String): Int? {
val columnIndex = getColumnIndex(columnName)
return if (columnIndex == -1 || isNull(columnIndex)) null else getInt(columnIndex)
}
fun Cursor.getLongOrNull(columnName: String): Long? {
val columnIndex = getColumnIndex(columnName)
return if (columnIndex == -1 || 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,21 @@
package net.thunderbird.core.android.common.resources
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import net.thunderbird.core.common.resources.ResourceManager
internal class AndroidResourceManager(
private val context: Context,
) : ResourceManager {
override fun stringResource(@StringRes resourceId: Int): String = context.resources.getString(resourceId)
override fun stringResource(@StringRes resourceId: Int, vararg formatArgs: Any?): String =
context.resources.getString(resourceId, *formatArgs)
override fun pluralsString(
@PluralsRes resourceId: Int,
quantity: Int,
vararg formatArgs: Any?,
): String = context.resources.getQuantityString(resourceId, quantity, *formatArgs)
}

View file

@ -0,0 +1,15 @@
package net.thunderbird.core.android.common.resources
import net.thunderbird.core.common.resources.PluralsResourceManager
import net.thunderbird.core.common.resources.ResourceManager
import net.thunderbird.core.common.resources.StringsResourceManager
import org.koin.android.ext.koin.androidApplication
import org.koin.core.module.Module
import org.koin.dsl.module
internal val resourcesAndroidModule: Module = module {
single { AndroidResourceManager(context = androidApplication()) }
single<ResourceManager> { get<AndroidResourceManager>() }
single<StringsResourceManager> { get<AndroidResourceManager>() }
single<PluralsResourceManager> { get<AndroidResourceManager>() }
}

View file

@ -0,0 +1,22 @@
package net.thunderbird.core.android.common.view
import android.webkit.WebView
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
fun WebView.showInDarkMode() = setupThemeMode(darkTheme = true)
fun WebView.showInLightMode() = setupThemeMode(darkTheme = false)
private fun WebView.setupThemeMode(darkTheme: Boolean) {
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(
this.settings,
darkTheme,
)
} else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
WebSettingsCompat.setForceDark(
this.settings,
if (darkTheme) WebSettingsCompat.FORCE_DARK_ON else WebSettingsCompat.FORCE_DARK_OFF,
)
}
}

View file

@ -0,0 +1,6 @@
<paths>
<cache-path
name="captureImage"
path="captureImage"
/>
</paths>

View file

@ -0,0 +1,17 @@
package app.k9mail.core.android.common
import android.content.Context
import org.junit.Test
import org.koin.test.verify.verify
internal class CoreCommonAndroidModuleKtTest {
@Test
fun `should have a valid di module`() {
coreCommonAndroidModule.verify(
extraTypes = listOf(
Context::class,
),
)
}
}

View file

@ -0,0 +1,55 @@
package app.k9mail.core.android.common.compat
import android.os.Bundle
import assertk.assertThat
import assertk.assertions.isEqualTo
import java.io.Serializable
import kotlin.test.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class BundleCompatTest {
@Test
fun `getSerializable returns Serializable`() {
val bundle = Bundle()
val key = "keySerializable"
val serializable = TestSerializable("value")
val clazz = TestSerializable::class.java
bundle.putSerializable(key, serializable)
val result = BundleCompat.getSerializable(bundle, key, clazz)
assertThat(result).isEqualTo(serializable)
}
@Test
fun `getSerializable returns null when class mismatch`() {
val bundle = Bundle()
val key = "keySerializable"
val serializable = TestSerializable("value")
val clazz = OtherTestSerializable::class.java
bundle.putSerializable(key, serializable)
val result = BundleCompat.getSerializable(bundle, key, clazz)
assertThat(result).isEqualTo(null)
}
internal class TestSerializable(
val value: String,
) : Serializable {
companion object {
private const val serialVersionUID = 1L
}
}
internal class OtherTestSerializable(
val value: String,
) : Serializable {
companion object {
private const val serialVersionUID = 2L
}
}
}

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 assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import kotlin.test.Test
import net.thunderbird.core.common.cache.InMemoryCache
import net.thunderbird.core.common.mail.EmailAddress
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 net.thunderbird.core.common.mail.toEmailAddressOrThrow
const val CONTACT_ID = 123L
const val CONTACT_NAME = "user name"
const val CONTACT_LOOKUP_KEY = "0r1-4F314D4F2F294F29"
val CONTACT_EMAIL_ADDRESS = "user@example.com".toEmailAddressOrThrow()
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,12 @@
package app.k9mail.core.android.common.contact
import org.junit.Test
import org.koin.test.verify.verify
internal class ContactKoinModuleKtTest {
@Test
fun `should have a valid di module`() {
contactModule.verify()
}
}

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.assertFailure
import assertk.assertThat
import assertk.assertions.hasMessage
import assertk.assertions.isEqualTo
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))
}
assertFailure {
cursor.map { testThrowingAction(it, "column") }
}.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"))
}
}

View file

@ -0,0 +1,10 @@
package app.k9mail.core.android.common.test
import net.thunderbird.core.common.oauth.OAuthConfigurationFactory
import org.koin.dsl.module
internal val externalModule = module {
single<OAuthConfigurationFactory> {
OAuthConfigurationFactory { emptyMap() }
}
}