Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
26
core/android/common/src/main/AndroidManifest.xml
Normal file
26
core/android/common/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package app.k9mail.core.android.common.camera.provider
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
class CaptureImageFileProvider : FileProvider()
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>() }
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<paths>
|
||||
<cache-path
|
||||
name="captureImage"
|
||||
path="captureImage"
|
||||
/>
|
||||
</paths>
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue