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,26 @@
plugins {
id(ThunderbirdPlugins.Library.android)
alias(libs.plugins.kotlin.parcelize)
}
android {
namespace = "net.thunderbird.core.android.account"
}
dependencies {
api(projects.feature.account.api)
api(projects.feature.account.storage.api)
implementation(projects.feature.notification.api)
api(projects.mail.common)
implementation(projects.core.common)
implementation(projects.core.preference.api)
implementation(projects.feature.mail.account.api)
implementation(projects.feature.mail.folder.api)
implementation(projects.backend.api)
testImplementation(projects.feature.account.fake)
}

View file

@ -0,0 +1,60 @@
package net.thunderbird.core.android.account
import net.thunderbird.core.preference.storage.Storage
interface AccountDefaultsProvider {
/**
* Apply default values to the account.
*
* This method should only be called when creating a new account.
*/
fun applyDefaults(account: LegacyAccount)
/**
* Apply any additional default values to the account.
*
* This method should be called when updating an existing account.
*/
fun applyOverwrites(account: LegacyAccount, storage: Storage)
companion object {
const val DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE = 131072
@JvmStatic
val DEFAULT_MESSAGE_FORMAT = MessageFormat.HTML
const val DEFAULT_MESSAGE_FORMAT_AUTO = false
const val DEFAULT_MESSAGE_READ_RECEIPT = false
const val DEFAULT_QUOTED_TEXT_SHOWN = true
const val DEFAULT_QUOTE_PREFIX = ">"
@JvmStatic
val DEFAULT_QUOTE_STYLE = QuoteStyle.PREFIX
const val DEFAULT_REMOTE_SEARCH_NUM_RESULTS = 25
const val DEFAULT_REPLY_AFTER_QUOTE = false
const val DEFAULT_RINGTONE_URI = "content://settings/system/notification_sound"
const val DEFAULT_SORT_ASCENDING = false
@JvmStatic
val DEFAULT_SORT_TYPE = SortType.SORT_DATE
const val DEFAULT_STRIP_SIGNATURE = true
const val DEFAULT_SYNC_INTERVAL = 60
/**
* Specifies how many messages will be shown in a folder by default. This number is set
* on each new folder and can be incremented with "Load more messages..." by the
* VISIBLE_LIMIT_INCREMENT
*/
const val DEFAULT_VISIBLE_LIMIT = 25
const val NO_OPENPGP_KEY: Long = 0
const val UNASSIGNED_ACCOUNT_NUMBER = -1
// TODO : Remove once storage is migrated to new format
const val COLOR = 0x0099CC
}
}

View file

@ -0,0 +1,24 @@
package net.thunderbird.core.android.account
import kotlinx.coroutines.flow.Flow
import net.thunderbird.feature.mail.account.api.AccountManager
@Deprecated(
message = "Use net.thunderbird.feature.mail.account.api.AccountManager<TAccount : BaseAccount> instead",
replaceWith = ReplaceWith(
expression = "AccountManager<LegacyAccount>",
"net.thunderbird.feature.mail.account.api.AccountManager",
"app.k9mail.legacy.account.LegacyAccount",
),
)
interface AccountManager : AccountManager<LegacyAccount> {
override fun getAccounts(): List<LegacyAccount>
override fun getAccountsFlow(): Flow<List<LegacyAccount>>
override fun getAccount(accountUuid: String): LegacyAccount?
override fun getAccountFlow(accountUuid: String): Flow<LegacyAccount?>
fun addAccountRemovedListener(listener: AccountRemovedListener)
override fun moveAccount(account: LegacyAccount, newPosition: Int)
fun addOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener)
fun removeOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener)
override fun saveAccount(account: LegacyAccount)
}

View file

@ -0,0 +1,5 @@
package net.thunderbird.core.android.account
fun interface AccountRemovedListener {
fun onAccountRemoved(account: LegacyAccount)
}

View file

@ -0,0 +1,5 @@
package net.thunderbird.core.android.account
fun interface AccountsChangeListener {
fun onAccountsChanged()
}

View file

@ -0,0 +1,16 @@
package net.thunderbird.core.android.account
@Suppress("MagicNumber")
enum class DeletePolicy(@JvmField val setting: Int) {
NEVER(0),
SEVEN_DAYS(1),
ON_DELETE(2),
MARK_AS_READ(3),
;
companion object {
fun fromInt(initialSetting: Int): DeletePolicy {
return entries.find { it.setting == initialSetting } ?: error("DeletePolicy $initialSetting unknown")
}
}
}

View file

@ -0,0 +1,16 @@
package net.thunderbird.core.android.account
import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
enum class Expunge {
EXPUNGE_IMMEDIATELY,
EXPUNGE_MANUALLY,
EXPUNGE_ON_POLL,
;
fun toBackendExpungePolicy(): ExpungePolicy = when (this) {
EXPUNGE_IMMEDIATELY -> ExpungePolicy.IMMEDIATELY
EXPUNGE_MANUALLY -> ExpungePolicy.MANUALLY
EXPUNGE_ON_POLL -> ExpungePolicy.ON_POLL
}
}

View file

@ -0,0 +1,9 @@
package net.thunderbird.core.android.account
enum class FolderMode {
NONE,
ALL,
FIRST_CLASS,
FIRST_AND_SECOND_CLASS,
NOT_SECOND_CLASS,
}

View file

@ -0,0 +1,20 @@
package net.thunderbird.core.android.account
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Identity(
val description: String? = null,
val name: String? = null,
val email: String? = null,
val signature: String? = null,
val signatureUse: Boolean = false,
val replyTo: String? = null,
) : Parcelable {
// TODO remove when callers are converted to Kotlin
fun withName(name: String?) = copy(name = name)
fun withSignature(signature: String?) = copy(signature = signature)
fun withSignatureUse(signatureUse: Boolean) = copy(signatureUse = signatureUse)
fun withEmail(email: String?) = copy(email = email)
}

View file

@ -0,0 +1,636 @@
package net.thunderbird.core.android.account
import com.fsck.k9.mail.Address
import com.fsck.k9.mail.ServerSettings
import java.util.Calendar
import java.util.Date
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY
import net.thunderbird.feature.account.Account
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.storage.profile.AvatarDto
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.notification.NotificationSettings
// This needs to be in sync with K9.DEFAULT_VISIBLE_LIMIT
const val DEFAULT_VISIBLE_LIMIT = 25
/**
* Account stores all of the settings for a single account defined by the user. Each account is defined by a UUID.
*/
@Deprecated("Use LegacyAccountWrapper instead")
@Suppress("TooManyFunctions")
open class LegacyAccount(
override val uuid: String,
val isSensitiveDebugLoggingEnabled: () -> Boolean = { false },
) : Account, BaseAccount {
// [Account]
override val id: AccountId = AccountIdFactory.of(uuid)
// [BaseAccount]
@get:Synchronized
@set:Synchronized
override var name: String? = null
set(value) {
field = value?.takeIf { it.isNotEmpty() }
}
@get:Synchronized
@set:Synchronized
override var email: String
get() = identities[0].email!!
set(email) {
val newIdentity = identities[0].copy(email = email)
identities[0] = newIdentity
}
// [AccountProfile]
val displayName: String
get() = name ?: email
@get:Synchronized
@set:Synchronized
var chipColor = 0
@get:Synchronized
@set:Synchronized
var avatar: AvatarDto = AvatarDto(
avatarType = AvatarTypeDto.MONOGRAM,
avatarMonogram = null,
avatarImageUri = null,
avatarIconName = null,
)
// Uncategorized
@get:Synchronized
@set:Synchronized
var deletePolicy = DeletePolicy.NEVER
@get:Synchronized
@set:Synchronized
private var internalIncomingServerSettings: ServerSettings? = null
@get:Synchronized
@set:Synchronized
private var internalOutgoingServerSettings: ServerSettings? = null
var incomingServerSettings: ServerSettings
get() = internalIncomingServerSettings ?: error("Incoming server settings not set yet")
set(value) {
internalIncomingServerSettings = value
}
var outgoingServerSettings: ServerSettings
get() = internalOutgoingServerSettings ?: error("Outgoing server settings not set yet")
set(value) {
internalOutgoingServerSettings = value
}
@get:Synchronized
@set:Synchronized
var oAuthState: String? = null
@get:Synchronized
@set:Synchronized
var alwaysBcc: String? = null
/**
* -1 for never.
*/
@get:Synchronized
@set:Synchronized
var automaticCheckIntervalMinutes = 0
@get:Synchronized
@set:Synchronized
var displayCount = 0
set(value) {
if (field != value) {
field = value.takeIf { it != -1 } ?: DEFAULT_VISIBLE_LIMIT
isChangedVisibleLimits = true
}
}
@get:Synchronized
@set:Synchronized
var isNotifyNewMail = false
@get:Synchronized
@set:Synchronized
var folderNotifyNewMailMode = FolderMode.ALL
@get:Synchronized
@set:Synchronized
var isNotifySelfNewMail = false
@get:Synchronized
@set:Synchronized
var isNotifyContactsMailOnly = false
@get:Synchronized
@set:Synchronized
var isIgnoreChatMessages = false
@get:Synchronized
@set:Synchronized
var legacyInboxFolder: String? = null
@get:Synchronized
@set:Synchronized
var importedDraftsFolder: String? = null
@get:Synchronized
@set:Synchronized
var importedSentFolder: String? = null
@get:Synchronized
@set:Synchronized
var importedTrashFolder: String? = null
@get:Synchronized
@set:Synchronized
var importedArchiveFolder: String? = null
@get:Synchronized
@set:Synchronized
var importedSpamFolder: String? = null
@get:Synchronized
@set:Synchronized
var inboxFolderId: Long? = null
@get:Synchronized
@set:Synchronized
var draftsFolderId: Long? = null
@get:Synchronized
@set:Synchronized
var sentFolderId: Long? = null
@get:Synchronized
@set:Synchronized
var trashFolderId: Long? = null
@get:Synchronized
@set:Synchronized
var archiveFolderId: Long? = null
@get:Synchronized
@set:Synchronized
var spamFolderId: Long? = null
@get:Synchronized
var draftsFolderSelection = SpecialFolderSelection.AUTOMATIC
@get:Synchronized
var sentFolderSelection = SpecialFolderSelection.AUTOMATIC
@get:Synchronized
var trashFolderSelection = SpecialFolderSelection.AUTOMATIC
@get:Synchronized
var archiveFolderSelection = SpecialFolderSelection.AUTOMATIC
@get:Synchronized
var spamFolderSelection = SpecialFolderSelection.AUTOMATIC
@get:Synchronized
@set:Synchronized
var importedAutoExpandFolder: String? = null
@get:Synchronized
@set:Synchronized
var autoExpandFolderId: Long? = null
@get:Synchronized
@set:Synchronized
var folderDisplayMode = FolderMode.NOT_SECOND_CLASS
@get:Synchronized
@set:Synchronized
var folderSyncMode = FolderMode.FIRST_CLASS
@get:Synchronized
@set:Synchronized
var folderPushMode = FolderMode.NONE
@get:Synchronized
@set:Synchronized
var accountNumber = 0
@get:Synchronized
@set:Synchronized
var isNotifySync = false
@get:Synchronized
@set:Synchronized
var sortType: SortType = SortType.SORT_DATE
var sortAscending: MutableMap<SortType, Boolean> = mutableMapOf()
@get:Synchronized
@set:Synchronized
var showPictures = ShowPictures.NEVER
@get:Synchronized
@set:Synchronized
var isSignatureBeforeQuotedText = false
@get:Synchronized
@set:Synchronized
var expungePolicy = Expunge.EXPUNGE_IMMEDIATELY
@get:Synchronized
@set:Synchronized
var maxPushFolders = 0
@get:Synchronized
@set:Synchronized
var idleRefreshMinutes = 0
@get:JvmName("useCompression")
@get:Synchronized
@set:Synchronized
var useCompression = true
@get:Synchronized
@set:Synchronized
var isSendClientInfoEnabled = true
@get:Synchronized
@set:Synchronized
var isSubscribedFoldersOnly = false
@get:Synchronized
@set:Synchronized
var maximumPolledMessageAge = 0
@get:Synchronized
@set:Synchronized
var maximumAutoDownloadMessageSize = 0
@get:Synchronized
@set:Synchronized
var messageFormat = MessageFormat.HTML
@get:Synchronized
@set:Synchronized
var isMessageFormatAuto = false
@get:Synchronized
@set:Synchronized
var isMessageReadReceipt = false
@get:Synchronized
@set:Synchronized
var quoteStyle = QuoteStyle.PREFIX
@get:Synchronized
@set:Synchronized
var quotePrefix: String? = null
@get:Synchronized
@set:Synchronized
var isDefaultQuotedTextShown = false
@get:Synchronized
@set:Synchronized
var isReplyAfterQuote = false
@get:Synchronized
@set:Synchronized
var isStripSignature = false
@get:Synchronized
@set:Synchronized
var isSyncRemoteDeletions = false
@get:Synchronized
@set:Synchronized
var openPgpProvider: String? = null
set(value) {
field = value?.takeIf { it.isNotEmpty() }
}
@get:Synchronized
@set:Synchronized
var openPgpKey: Long = 0
@get:Synchronized
@set:Synchronized
var autocryptPreferEncryptMutual = false
@get:Synchronized
@set:Synchronized
var isOpenPgpHideSignOnly = false
@get:Synchronized
@set:Synchronized
var isOpenPgpEncryptSubject = false
@get:Synchronized
@set:Synchronized
var isOpenPgpEncryptAllDrafts = false
@get:Synchronized
@set:Synchronized
var isMarkMessageAsReadOnView = false
@get:Synchronized
@set:Synchronized
var isMarkMessageAsReadOnDelete = false
@get:Synchronized
@set:Synchronized
var isAlwaysShowCcBcc = false
// Temporarily disabled
@get:Synchronized
@set:Synchronized
var isRemoteSearchFullText = false
get() = false
@get:Synchronized
@set:Synchronized
var remoteSearchNumResults = 0
set(value) {
field = value.coerceAtLeast(0)
}
@get:Synchronized
@set:Synchronized
var isUploadSentMessages = false
@get:Synchronized
@set:Synchronized
var lastSyncTime: Long = 0
@get:Synchronized
@set:Synchronized
var lastFolderListRefreshTime: Long = 0
@get:Synchronized
var isFinishedSetup = false
@get:Synchronized
@set:Synchronized
var messagesNotificationChannelVersion = 0
@get:Synchronized
@set:Synchronized
var isChangedVisibleLimits = false
/**
* Database ID of the folder that was last selected for a copy or move operation.
*
* Note: For now this value isn't persisted. So it will be reset when K-9 Mail is restarted.
*/
@get:Synchronized
var lastSelectedFolderId: Long? = null
@get:Synchronized
@set:Synchronized
var identities: MutableList<Identity> = mutableListOf()
set(value) {
field = value.toMutableList()
}
@get:Synchronized
var notificationSettings = NotificationSettings()
@get:Synchronized
@set:Synchronized
var senderName: String?
get() = identities[0].name
set(name) {
val newIdentity = identities[0].withName(name)
identities[0] = newIdentity
}
@get:Synchronized
@set:Synchronized
var signatureUse: Boolean
get() = identities[0].signatureUse
set(signatureUse) {
val newIdentity = identities[0].withSignatureUse(signatureUse)
identities[0] = newIdentity
}
@get:Synchronized
@set:Synchronized
var signature: String?
get() = identities[0].signature
set(signature) {
val newIdentity = identities[0].withSignature(signature)
identities[0] = newIdentity
}
@get:JvmName("shouldMigrateToOAuth")
@get:Synchronized
@set:Synchronized
var shouldMigrateToOAuth = false
@get:JvmName("folderPathDelimiter")
@get:Synchronized
@set:Synchronized
var folderPathDelimiter: FolderPathDelimiter = "/"
/**
* @param automaticCheckIntervalMinutes or -1 for never.
*/
@Synchronized
fun updateAutomaticCheckIntervalMinutes(automaticCheckIntervalMinutes: Int): Boolean {
val oldInterval = this.automaticCheckIntervalMinutes
this.automaticCheckIntervalMinutes = automaticCheckIntervalMinutes
return oldInterval != automaticCheckIntervalMinutes
}
@Synchronized
fun setDraftsFolderId(folderId: Long?, selection: SpecialFolderSelection) {
draftsFolderId = folderId
draftsFolderSelection = selection
}
@Deprecated("use AccountWrapper instead")
@Synchronized
fun hasDraftsFolder(): Boolean {
return draftsFolderId != null
}
@Synchronized
fun setSentFolderId(folderId: Long?, selection: SpecialFolderSelection) {
sentFolderId = folderId
sentFolderSelection = selection
}
@Deprecated("use AccountWrapper instead")
@Synchronized
fun hasSentFolder(): Boolean {
return sentFolderId != null
}
@Synchronized
fun setTrashFolderId(folderId: Long?, selection: SpecialFolderSelection) {
trashFolderId = folderId
trashFolderSelection = selection
}
@Deprecated("use AccountWrapper instead")
@Synchronized
fun hasTrashFolder(): Boolean {
return trashFolderId != null
}
@Synchronized
fun setArchiveFolderId(folderId: Long?, selection: SpecialFolderSelection) {
archiveFolderId = folderId
archiveFolderSelection = selection
}
@Deprecated("use AccountWrapper instead")
@Synchronized
fun hasArchiveFolder(): Boolean {
return archiveFolderId != null
}
@Synchronized
fun setSpamFolderId(folderId: Long?, selection: SpecialFolderSelection) {
spamFolderId = folderId
spamFolderSelection = selection
}
@Deprecated("use AccountWrapper instead")
@Synchronized
fun hasSpamFolder(): Boolean {
return spamFolderId != null
}
@Synchronized
fun isSortAscending(sortType: SortType): Boolean {
return sortAscending.getOrPut(sortType) { sortType.isDefaultAscending }
}
@Synchronized
fun setSortAscending(sortType: SortType, sortAscending: Boolean) {
this.sortAscending[sortType] = sortAscending
}
@Synchronized
fun replaceIdentities(identities: List<Identity>) {
this.identities = identities.toMutableList()
}
@Synchronized
fun getIdentity(index: Int): Identity {
if (index !in identities.indices) error("Identity with index $index not found")
return identities[index]
}
fun isAnIdentity(addresses: Array<Address>?): Boolean {
if (addresses == null) return false
return addresses.any { address -> isAnIdentity(address) }
}
fun isAnIdentity(address: Address): Boolean {
return findIdentity(address) != null
}
@Synchronized
fun findIdentity(address: Address): Identity? {
return identities.find { identity ->
identity.email.equals(address.address, ignoreCase = true)
}
}
@Suppress("MagicNumber")
val earliestPollDate: Date?
get() {
val age = maximumPolledMessageAge.takeIf { it >= 0 } ?: return null
val now = Calendar.getInstance()
now[Calendar.HOUR_OF_DAY] = 0
now[Calendar.MINUTE] = 0
now[Calendar.SECOND] = 0
now[Calendar.MILLISECOND] = 0
if (age < 28) {
now.add(Calendar.DATE, age * -1)
} else {
when (age) {
28 -> now.add(Calendar.MONTH, -1)
56 -> now.add(Calendar.MONTH, -2)
84 -> now.add(Calendar.MONTH, -3)
168 -> now.add(Calendar.MONTH, -6)
365 -> now.add(Calendar.YEAR, -1)
}
}
return now.time
}
@Deprecated("use AccountWrapper instead")
val isOpenPgpProviderConfigured: Boolean
get() = openPgpProvider != null
@Deprecated("use AccountWrapper instead")
@Synchronized
fun hasOpenPgpKey(): Boolean {
return openPgpKey != NO_OPENPGP_KEY
}
@Synchronized
fun setLastSelectedFolderId(folderId: Long) {
lastSelectedFolderId = folderId
}
@Synchronized
fun resetChangeMarkers() {
isChangedVisibleLimits = false
}
@Synchronized
fun markSetupFinished() {
isFinishedSetup = true
}
@Synchronized
fun updateNotificationSettings(
block: (
oldNotificationSettings: NotificationSettings,
) -> NotificationSettings,
) {
notificationSettings = block(notificationSettings)
}
override fun toString(): String {
return if (isSensitiveDebugLoggingEnabled()) displayName else uuid
}
override fun equals(other: Any?): Boolean {
return if (other is LegacyAccount) {
other.uuid == uuid
} else {
super.equals(other)
}
}
override fun hashCode(): Int {
return uuid.hashCode()
}
companion object {
/**
* Fixed name of outbox - not actually displayed.
*/
const val OUTBOX_NAME = "Outbox"
const val INTERVAL_MINUTES_NEVER = -1
}
}

View file

@ -0,0 +1,152 @@
package net.thunderbird.core.android.account
import com.fsck.k9.mail.ServerSettings
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY
import net.thunderbird.core.common.mail.Protocols
import net.thunderbird.feature.account.Account
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.storage.profile.ProfileDto
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.notification.NotificationSettings
/**
* A immutable wrapper for the [LegacyAccount] class.
*
* This class is used to store the account data in a way that is safe to pass between threads.
*
* Use LegacyAccountWrapper.from(account) to create a wrapper from an account.
* Use LegacyAccountWrapper.to(wrapper) to create an account from a wrapper.
*/
data class LegacyAccountWrapper(
val isSensitiveDebugLoggingEnabled: () -> Boolean = { false },
// [Account]
override val id: AccountId,
// [BaseAccount]
override val name: String?,
override val email: String,
// [AccountProfile]
val profile: ProfileDto,
// Uncategorized
val deletePolicy: DeletePolicy = DeletePolicy.NEVER,
val incomingServerSettings: ServerSettings,
val outgoingServerSettings: ServerSettings,
val oAuthState: String? = null,
val alwaysBcc: String? = null,
val automaticCheckIntervalMinutes: Int = 0,
val displayCount: Int = 0,
val isNotifyNewMail: Boolean = false,
val folderNotifyNewMailMode: FolderMode = FolderMode.ALL,
val isNotifySelfNewMail: Boolean = false,
val isNotifyContactsMailOnly: Boolean = false,
val isIgnoreChatMessages: Boolean = false,
val legacyInboxFolder: String? = null,
val importedDraftsFolder: String? = null,
val importedSentFolder: String? = null,
val importedTrashFolder: String? = null,
val importedArchiveFolder: String? = null,
val importedSpamFolder: String? = null,
val inboxFolderId: Long? = null,
val draftsFolderId: Long? = null,
val sentFolderId: Long? = null,
val trashFolderId: Long? = null,
val archiveFolderId: Long? = null,
val spamFolderId: Long? = null,
val draftsFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC,
val sentFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC,
val trashFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC,
val archiveFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC,
val spamFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC,
val importedAutoExpandFolder: String? = null,
val autoExpandFolderId: Long? = null,
val folderDisplayMode: FolderMode = FolderMode.NOT_SECOND_CLASS,
val folderSyncMode: FolderMode = FolderMode.FIRST_CLASS,
val folderPushMode: FolderMode = FolderMode.NONE,
val accountNumber: Int = 0,
val isNotifySync: Boolean = false,
val sortType: SortType = SortType.SORT_DATE,
val sortAscending: Map<SortType, Boolean> = emptyMap(),
val showPictures: ShowPictures = ShowPictures.NEVER,
val isSignatureBeforeQuotedText: Boolean = false,
val expungePolicy: Expunge = Expunge.EXPUNGE_IMMEDIATELY,
val maxPushFolders: Int = 0,
val idleRefreshMinutes: Int = 0,
val useCompression: Boolean = true,
val isSendClientInfoEnabled: Boolean = true,
val isSubscribedFoldersOnly: Boolean = false,
val maximumPolledMessageAge: Int = 0,
val maximumAutoDownloadMessageSize: Int = 0,
val messageFormat: MessageFormat = MessageFormat.HTML,
val isMessageFormatAuto: Boolean = false,
val isMessageReadReceipt: Boolean = false,
val quoteStyle: QuoteStyle = QuoteStyle.PREFIX,
val quotePrefix: String? = null,
val isDefaultQuotedTextShown: Boolean = false,
val isReplyAfterQuote: Boolean = false,
val isStripSignature: Boolean = false,
val isSyncRemoteDeletions: Boolean = false,
val openPgpProvider: String? = null,
val openPgpKey: Long = 0,
val autocryptPreferEncryptMutual: Boolean = false,
val isOpenPgpHideSignOnly: Boolean = false,
val isOpenPgpEncryptSubject: Boolean = false,
val isOpenPgpEncryptAllDrafts: Boolean = false,
val isMarkMessageAsReadOnView: Boolean = false,
val isMarkMessageAsReadOnDelete: Boolean = false,
val isAlwaysShowCcBcc: Boolean = false,
val isRemoteSearchFullText: Boolean = false,
val remoteSearchNumResults: Int = 0,
val isUploadSentMessages: Boolean = false,
val lastSyncTime: Long = 0,
val lastFolderListRefreshTime: Long = 0,
val isFinishedSetup: Boolean = false,
val messagesNotificationChannelVersion: Int = 0,
val isChangedVisibleLimits: Boolean = false,
val lastSelectedFolderId: Long? = null,
val identities: List<Identity>,
val notificationSettings: NotificationSettings = NotificationSettings(),
val senderName: String? = identities[0].name,
val signatureUse: Boolean = identities[0].signatureUse,
val signature: String? = identities[0].signature,
val shouldMigrateToOAuth: Boolean = false,
val folderPathDelimiter: FolderPathDelimiter = "/",
) : Account, BaseAccount {
override val uuid: String = id.asRaw()
fun hasDraftsFolder(): Boolean {
return draftsFolderId != null
}
fun hasSentFolder(): Boolean {
return sentFolderId != null
}
fun hasTrashFolder(): Boolean {
return trashFolderId != null
}
fun hasArchiveFolder(): Boolean {
return archiveFolderId != null
}
fun hasSpamFolder(): Boolean {
return spamFolderId != null
}
fun isOpenPgpProviderConfigured(): Boolean {
return openPgpProvider != null
}
fun hasOpenPgpKey(): Boolean {
return openPgpKey != NO_OPENPGP_KEY
}
fun isIncomingServerPop3(): Boolean =
incomingServerSettings.type == Protocols.POP3
}

View file

@ -0,0 +1,12 @@
package net.thunderbird.core.android.account
import kotlinx.coroutines.flow.Flow
import net.thunderbird.feature.account.AccountId
interface LegacyAccountWrapperManager {
fun getAll(): Flow<List<LegacyAccountWrapper>>
fun getById(id: AccountId): Flow<LegacyAccountWrapper?>
suspend fun update(account: LegacyAccountWrapper)
}

View file

@ -0,0 +1,7 @@
package net.thunderbird.core.android.account
enum class MessageFormat {
TEXT,
HTML,
AUTO,
}

View file

@ -0,0 +1,6 @@
package net.thunderbird.core.android.account
enum class QuoteStyle {
PREFIX,
HEADER,
}

View file

@ -0,0 +1,7 @@
package net.thunderbird.core.android.account
enum class ShowPictures {
NEVER,
ALWAYS,
ONLY_FROM_CONTACTS,
}

View file

@ -0,0 +1,11 @@
package net.thunderbird.core.android.account
enum class SortType(val isDefaultAscending: Boolean) {
SORT_DATE(false),
SORT_ARRIVAL(false),
SORT_SUBJECT(true),
SORT_SENDER(true),
SORT_UNREAD(true),
SORT_FLAGGED(true),
SORT_ATTACHMENT(true),
}

View file

@ -0,0 +1,195 @@
package net.thunderbird.core.android.account
import assertk.assertThat
import assertk.assertions.isEqualTo
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import kotlin.test.Test
import net.thunderbird.account.fake.FakeAccountData.ACCOUNT_ID
import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_COLOR
import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_NAME
import net.thunderbird.feature.account.storage.profile.AvatarDto
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
import net.thunderbird.feature.account.storage.profile.ProfileDto
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.notification.NotificationSettings
class LegacyAccountWrapperTest {
@Suppress("LongMethod")
@Test
fun `should set defaults`() {
// arrange
val expected = createAccountWrapper()
// act
val result = LegacyAccountWrapper(
isSensitiveDebugLoggingEnabled = isSensitiveDebugLoggingEnabled,
id = ACCOUNT_ID,
name = PROFILE_NAME,
email = email,
profile = profile,
incomingServerSettings = incomingServerSettings,
outgoingServerSettings = outgoingServerSettings,
identities = identities,
)
// assert
assertThat(expected).isEqualTo(result)
}
private companion object {
val isSensitiveDebugLoggingEnabled = { true }
const val email = "demo@example.com"
val avatar = AvatarDto(
avatarType = AvatarTypeDto.MONOGRAM,
avatarMonogram = null,
avatarImageUri = null,
avatarIconName = null,
)
val profile = ProfileDto(
id = ACCOUNT_ID,
name = PROFILE_NAME,
color = PROFILE_COLOR,
avatar = avatar,
)
val incomingServerSettings = ServerSettings(
type = "imap",
host = "imap.example.com",
port = 993,
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = "test",
password = "password",
clientCertificateAlias = null,
)
val outgoingServerSettings = ServerSettings(
type = "smtp",
host = "smtp.example.com",
port = 465,
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = "test",
password = "password",
clientCertificateAlias = null,
)
val identities = mutableListOf(
Identity(
email = "demo@example.com",
name = "identityName",
signatureUse = true,
signature = "signature",
description = "Demo User",
),
)
val notificationSettings = NotificationSettings()
@Suppress("LongMethod")
fun createAccountWrapper(): LegacyAccountWrapper {
return LegacyAccountWrapper(
isSensitiveDebugLoggingEnabled = isSensitiveDebugLoggingEnabled,
// [Account]
id = ACCOUNT_ID,
// [BaseAccount]
name = PROFILE_NAME,
email = email,
// [AccountProfile]
profile = profile,
// Uncategorized
deletePolicy = DeletePolicy.NEVER,
incomingServerSettings = incomingServerSettings,
outgoingServerSettings = outgoingServerSettings,
oAuthState = null,
alwaysBcc = null,
automaticCheckIntervalMinutes = 0,
displayCount = 0,
isNotifyNewMail = false,
folderNotifyNewMailMode = FolderMode.ALL,
isNotifySelfNewMail = false,
isNotifyContactsMailOnly = false,
isIgnoreChatMessages = false,
legacyInboxFolder = null,
importedDraftsFolder = null,
importedSentFolder = null,
importedTrashFolder = null,
importedArchiveFolder = null,
importedSpamFolder = null,
inboxFolderId = null,
draftsFolderId = null,
sentFolderId = null,
trashFolderId = null,
archiveFolderId = null,
spamFolderId = null,
draftsFolderSelection = SpecialFolderSelection.AUTOMATIC,
sentFolderSelection = SpecialFolderSelection.AUTOMATIC,
trashFolderSelection = SpecialFolderSelection.AUTOMATIC,
archiveFolderSelection = SpecialFolderSelection.AUTOMATIC,
spamFolderSelection = SpecialFolderSelection.AUTOMATIC,
importedAutoExpandFolder = null,
autoExpandFolderId = null,
folderDisplayMode = FolderMode.NOT_SECOND_CLASS,
folderSyncMode = FolderMode.FIRST_CLASS,
folderPushMode = FolderMode.NONE,
accountNumber = 0,
isNotifySync = false,
sortType = SortType.SORT_DATE,
sortAscending = emptyMap(),
showPictures = ShowPictures.NEVER,
isSignatureBeforeQuotedText = false,
expungePolicy = Expunge.EXPUNGE_IMMEDIATELY,
maxPushFolders = 0,
idleRefreshMinutes = 0,
useCompression = true,
isSendClientInfoEnabled = true,
isSubscribedFoldersOnly = false,
maximumPolledMessageAge = 0,
maximumAutoDownloadMessageSize = 0,
messageFormat = MessageFormat.HTML,
isMessageFormatAuto = false,
isMessageReadReceipt = false,
quoteStyle = QuoteStyle.PREFIX,
quotePrefix = null,
isDefaultQuotedTextShown = false,
isReplyAfterQuote = false,
isStripSignature = false,
isSyncRemoteDeletions = false,
openPgpProvider = null,
openPgpKey = 0,
autocryptPreferEncryptMutual = false,
isOpenPgpHideSignOnly = false,
isOpenPgpEncryptSubject = false,
isOpenPgpEncryptAllDrafts = false,
isMarkMessageAsReadOnView = false,
isMarkMessageAsReadOnDelete = false,
isAlwaysShowCcBcc = false,
isRemoteSearchFullText = false,
remoteSearchNumResults = 0,
isUploadSentMessages = false,
lastSyncTime = 0,
lastFolderListRefreshTime = 0,
isFinishedSetup = false,
messagesNotificationChannelVersion = 0,
isChangedVisibleLimits = false,
lastSelectedFolderId = null,
identities = identities,
notificationSettings = notificationSettings,
senderName = identities[0].name,
signatureUse = identities[0].signatureUse,
signature = identities[0].signature,
shouldMigrateToOAuth = false,
)
}
}
}

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

View file

@ -0,0 +1,11 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
android {
namespace = "net.thunderbird.core.android.contact"
}
dependencies {
implementation(projects.mail.common)
}

View file

@ -0,0 +1,47 @@
package net.thunderbird.core.android.contact
import android.content.Intent
import android.net.Uri
import android.provider.ContactsContract
import com.fsck.k9.mail.Address
object ContactIntentHelper {
@JvmStatic
fun getContactPickerIntent(): Intent {
return Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI)
}
/**
* Get Intent to add information to an existing contact or add a new one.
*
* @param address An {@link Address} instance containing the email address
* of the entity you want to add to the contacts. Optionally
* the instance also contains the (display) name of that
* entity.
*/
fun getAddEmailContactIntent(address: Address): Intent {
return Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
data = Uri.fromParts("mailto", address.address, null)
putExtra(ContactsContract.Intents.EXTRA_CREATE_DESCRIPTION, address.toString())
if (address.personal != null) {
putExtra(ContactsContract.Intents.Insert.NAME, address.personal)
}
}
}
/**
* Get Intent to add a phone number to an existing contact or add a new one.
*
* @param phoneNumber
* The phone number to add to a contact, or to use when creating a new contact.
*/
fun getAddPhoneContactIntent(phoneNumber: String): Intent {
return Intent(Intent.ACTION_INSERT_OR_EDIT).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
type = ContactsContract.Contacts.CONTENT_ITEM_TYPE
putExtra(ContactsContract.Intents.Insert.PHONE, Uri.decode(phoneNumber))
}
}
}

View file

@ -0,0 +1,12 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
android {
namespace = "net.thunderbird.core.android.logging"
}
dependencies {
implementation(libs.timber)
implementation(libs.commons.io)
}

View file

@ -0,0 +1,13 @@
package net.thunderbird.core.android.logging
import org.koin.dsl.module
val loggingModule = module {
factory<ProcessExecutor> { RealProcessExecutor() }
factory<LogFileWriter> {
LogcatLogFileWriter(
contentResolver = get(),
processExecutor = get(),
)
}
}

View file

@ -0,0 +1,31 @@
package net.thunderbird.core.android.logging
import android.content.ContentResolver
import android.net.Uri
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils
interface LogFileWriter {
suspend fun writeLogTo(contentUri: Uri)
}
class LogcatLogFileWriter(
private val contentResolver: ContentResolver,
private val processExecutor: ProcessExecutor,
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : LogFileWriter {
override suspend fun writeLogTo(contentUri: Uri) {
return withContext(coroutineDispatcher) {
val outputStream = contentResolver.openOutputStream(contentUri, "wt")
?: error("Error opening contentUri for writing")
outputStream.use {
processExecutor.exec("logcat -d").use { inputStream ->
IOUtils.copy(inputStream, outputStream)
}
}
}
}
}

View file

@ -0,0 +1,14 @@
package net.thunderbird.core.android.logging
import java.io.InputStream
interface ProcessExecutor {
fun exec(command: String): InputStream
}
class RealProcessExecutor : ProcessExecutor {
override fun exec(command: String): InputStream {
val process = Runtime.getRuntime().exec(command)
return process.inputStream
}
}

View file

@ -0,0 +1,86 @@
package net.thunderbird.core.android.logging
import android.content.ContentResolver
import android.net.Uri
import assertk.assertThat
import assertk.assertions.isEqualTo
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
class LogcatLogFileWriterTest {
private val contentUri = mock<Uri>()
private val outputStream = ByteArrayOutputStream()
@Test
fun `write log to contentUri`() = runBlocking {
val logData = "a".repeat(10_000)
val logFileWriter = LogcatLogFileWriter(
contentResolver = createContentResolver(),
processExecutor = createProcessExecutor(logData),
coroutineDispatcher = Dispatchers.Unconfined,
)
logFileWriter.writeLogTo(contentUri)
assertThat(outputStream.toByteArray().decodeToString()).isEqualTo(logData)
}
@Test(expected = FileNotFoundException::class)
fun `contentResolver throws`() = runBlocking {
val logFileWriter = LogcatLogFileWriter(
contentResolver = createThrowingContentResolver(FileNotFoundException()),
processExecutor = createProcessExecutor("irrelevant"),
coroutineDispatcher = Dispatchers.Unconfined,
)
logFileWriter.writeLogTo(contentUri)
}
@Test(expected = IOException::class)
fun `processExecutor throws`() = runBlocking {
val logFileWriter = LogcatLogFileWriter(
contentResolver = createContentResolver(),
processExecutor = ThrowingProcessExecutor(IOException()),
coroutineDispatcher = Dispatchers.Unconfined,
)
logFileWriter.writeLogTo(contentUri)
}
private fun createContentResolver(): ContentResolver {
return mock {
on { openOutputStream(contentUri, "wt") } doReturn outputStream
}
}
private fun createThrowingContentResolver(exception: Exception): ContentResolver {
return mock {
on { openOutputStream(contentUri, "wt") } doAnswer { throw exception }
}
}
private fun createProcessExecutor(logData: String): DataProcessExecutor {
return DataProcessExecutor(logData.toByteArray(charset = Charsets.US_ASCII))
}
}
private class DataProcessExecutor(val data: ByteArray) : ProcessExecutor {
override fun exec(command: String): InputStream {
return ByteArrayInputStream(data)
}
}
private class ThrowingProcessExecutor(val exception: Exception) : ProcessExecutor {
override fun exec(command: String): InputStream {
throw exception
}
}

View file

@ -0,0 +1,17 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
android {
namespace = "net.thunderbird.core.android.network"
}
dependencies {
api(projects.core.common)
implementation(projects.core.logging.api)
implementation(projects.core.logging.implLegacy)
testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View file

@ -0,0 +1,25 @@
package net.thunderbird.core.android.network
import android.os.Build
import android.net.ConnectivityManager as SystemConnectivityManager
interface ConnectivityManager {
fun start()
fun stop()
fun isNetworkAvailable(): Boolean
fun addListener(listener: ConnectivityChangeListener)
fun removeListener(listener: ConnectivityChangeListener)
}
interface ConnectivityChangeListener {
fun onConnectivityChanged()
fun onConnectivityLost()
}
internal fun ConnectivityManager(systemConnectivityManager: SystemConnectivityManager): ConnectivityManager {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> ConnectivityManagerApi24(systemConnectivityManager)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> ConnectivityManagerApi23(systemConnectivityManager)
else -> ConnectivityManagerApi21(systemConnectivityManager)
}
}

View file

@ -0,0 +1,66 @@
package net.thunderbird.core.android.network
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkRequest
import net.thunderbird.core.logging.legacy.Log
import android.net.ConnectivityManager as SystemConnectivityManager
@Suppress("DEPRECATION")
internal class ConnectivityManagerApi21(
private val systemConnectivityManager: SystemConnectivityManager,
) : ConnectivityManagerBase() {
private var isRunning = false
private var lastNetworkType: Int? = null
private var wasConnected: Boolean? = null
private val networkCallback = object : NetworkCallback() {
override fun onAvailable(network: Network) {
Log.v("Network available: $network")
notifyIfConnectivityHasChanged()
}
override fun onLost(network: Network) {
Log.v("Network lost: $network")
notifyIfConnectivityHasChanged()
}
private fun notifyIfConnectivityHasChanged() {
val networkType = systemConnectivityManager.activeNetworkInfo?.type
val isConnected = isNetworkAvailable()
synchronized(this@ConnectivityManagerApi21) {
if (networkType != lastNetworkType || isConnected != wasConnected) {
lastNetworkType = networkType
wasConnected = isConnected
if (isConnected) {
notifyOnConnectivityChanged()
} else {
notifyOnConnectivityLost()
}
}
}
}
}
@Synchronized
override fun start() {
if (!isRunning) {
isRunning = true
val networkRequest = NetworkRequest.Builder().build()
systemConnectivityManager.registerNetworkCallback(networkRequest, networkCallback)
}
}
@Synchronized
override fun stop() {
if (isRunning) {
isRunning = false
systemConnectivityManager.unregisterNetworkCallback(networkCallback)
}
}
override fun isNetworkAvailable(): Boolean = systemConnectivityManager.activeNetworkInfo?.isConnected == true
}

View file

@ -0,0 +1,73 @@
package net.thunderbird.core.android.network
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import androidx.annotation.RequiresApi
import net.thunderbird.core.logging.legacy.Log
import android.net.ConnectivityManager as SystemConnectivityManager
@RequiresApi(Build.VERSION_CODES.M)
internal class ConnectivityManagerApi23(
private val systemConnectivityManager: SystemConnectivityManager,
) : ConnectivityManagerBase() {
private var isRunning = false
private var lastActiveNetwork: Network? = null
private var wasConnected: Boolean? = null
private val networkCallback = object : NetworkCallback() {
override fun onAvailable(network: Network) {
Log.v("Network available: $network")
notifyIfActiveNetworkOrConnectivityHasChanged()
}
override fun onLost(network: Network) {
Log.v("Network lost: $network")
notifyIfActiveNetworkOrConnectivityHasChanged()
}
private fun notifyIfActiveNetworkOrConnectivityHasChanged() {
val activeNetwork = systemConnectivityManager.activeNetwork
val isConnected = isNetworkAvailable()
synchronized(this@ConnectivityManagerApi23) {
if (activeNetwork != lastActiveNetwork || isConnected != wasConnected) {
lastActiveNetwork = activeNetwork
wasConnected = isConnected
if (isConnected) {
notifyOnConnectivityChanged()
} else {
notifyOnConnectivityLost()
}
}
}
}
}
@Synchronized
override fun start() {
if (!isRunning) {
isRunning = true
val networkRequest = NetworkRequest.Builder().build()
systemConnectivityManager.registerNetworkCallback(networkRequest, networkCallback)
}
}
@Synchronized
override fun stop() {
if (isRunning) {
isRunning = false
systemConnectivityManager.unregisterNetworkCallback(networkCallback)
}
}
override fun isNetworkAvailable(): Boolean {
val activeNetwork = systemConnectivityManager.activeNetwork ?: return false
val networkCapabilities = systemConnectivityManager.getNetworkCapabilities(activeNetwork)
return networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
}
}

View file

@ -0,0 +1,65 @@
package net.thunderbird.core.android.network
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import androidx.annotation.RequiresApi
import net.thunderbird.core.logging.legacy.Log
import android.net.ConnectivityManager as SystemConnectivityManager
@RequiresApi(Build.VERSION_CODES.N)
internal class ConnectivityManagerApi24(
private val systemConnectivityManager: SystemConnectivityManager,
) : ConnectivityManagerBase() {
private var isRunning = false
private var isNetworkAvailable: Boolean? = null
private val networkCallback = object : NetworkCallback() {
override fun onAvailable(network: Network) {
Log.v("Network available: $network")
synchronized(this@ConnectivityManagerApi24) {
isNetworkAvailable = true
notifyOnConnectivityChanged()
}
}
override fun onLost(network: Network) {
Log.v("Network lost: $network")
synchronized(this@ConnectivityManagerApi24) {
isNetworkAvailable = false
notifyOnConnectivityLost()
}
}
}
@Synchronized
override fun start() {
if (!isRunning) {
isRunning = true
systemConnectivityManager.registerDefaultNetworkCallback(networkCallback)
}
}
@Synchronized
override fun stop() {
if (isRunning) {
isRunning = false
systemConnectivityManager.unregisterNetworkCallback(networkCallback)
}
}
override fun isNetworkAvailable(): Boolean {
return synchronized(this) { isNetworkAvailable } ?: isNetworkAvailableSynchronous()
}
// Sometimes this will return 'true' even though networkCallback has already received onLost().
// That's why isNetworkAvailable() prefers the state derived from the callbacks over this method.
private fun isNetworkAvailableSynchronous(): Boolean {
val activeNetwork = systemConnectivityManager.activeNetwork ?: return false
val networkCapabilities = systemConnectivityManager.getNetworkCapabilities(activeNetwork)
return networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
}
}

View file

@ -0,0 +1,31 @@
package net.thunderbird.core.android.network
import java.util.concurrent.CopyOnWriteArraySet
internal abstract class ConnectivityManagerBase : ConnectivityManager {
private val listeners = CopyOnWriteArraySet<ConnectivityChangeListener>()
@Synchronized
override fun addListener(listener: ConnectivityChangeListener) {
listeners.add(listener)
}
@Synchronized
override fun removeListener(listener: ConnectivityChangeListener) {
listeners.remove(listener)
}
@Synchronized
protected fun notifyOnConnectivityChanged() {
for (listener in listeners) {
listener.onConnectivityChanged()
}
}
@Synchronized
protected fun notifyOnConnectivityLost() {
for (listener in listeners) {
listener.onConnectivityLost()
}
}
}

View file

@ -0,0 +1,10 @@
package net.thunderbird.core.android.network
import android.content.Context
import org.koin.dsl.module
import android.net.ConnectivityManager as SystemConnectivityManager
val coreAndroidNetworkModule = module {
single { get<Context>().getSystemService(Context.CONNECTIVITY_SERVICE) as SystemConnectivityManager }
single { ConnectivityManager(systemConnectivityManager = get()) }
}

View file

@ -0,0 +1,13 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
android {
namespace = "app.k9mail.core.android.permissions"
}
dependencies {
testImplementation(libs.androidx.test.core)
testImplementation(libs.robolectric)
testImplementation(libs.assertk)
}

View file

@ -0,0 +1,38 @@
package app.k9mail.core.android.permissions
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
/**
* Checks if a [Permission] has been granted to the app.
*/
class AndroidPermissionChecker(
private val context: Context,
) : PermissionChecker {
override fun checkPermission(permission: Permission): PermissionState {
return when (permission) {
Permission.Contacts -> {
checkSelfPermission(Manifest.permission.READ_CONTACTS)
}
Permission.Notifications -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
} else {
PermissionState.GrantedImplicitly
}
}
}
}
private fun checkSelfPermission(permission: String): PermissionState {
return if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) {
PermissionState.Granted
} else {
PermissionState.Denied
}
}
}

View file

@ -0,0 +1,14 @@
package app.k9mail.core.android.permissions
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
/**
* Checks if the Android version the app is running on supports runtime permissions.
*/
internal class AndroidPermissionsModelChecker : PermissionsModelChecker {
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.M)
override fun hasRuntimePermissions(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
}
}

View file

@ -0,0 +1,9 @@
package app.k9mail.core.android.permissions
import org.koin.core.module.Module
import org.koin.dsl.module
val corePermissionsAndroidModule: Module = module {
factory<PermissionChecker> { AndroidPermissionChecker(context = get()) }
factory<PermissionsModelChecker> { AndroidPermissionsModelChecker() }
}

View file

@ -0,0 +1,9 @@
package app.k9mail.core.android.permissions
/**
* System permissions we ask for during onboarding.
*/
enum class Permission {
Contacts,
Notifications,
}

View file

@ -0,0 +1,8 @@
package app.k9mail.core.android.permissions
/**
* Checks if a [Permission] has been granted to the app.
*/
interface PermissionChecker {
fun checkPermission(permission: Permission): PermissionState
}

View file

@ -0,0 +1,10 @@
package app.k9mail.core.android.permissions
enum class PermissionState {
/**
* The permission is not a runtime permission in the Android version we're running on.
*/
GrantedImplicitly,
Granted,
Denied,
}

View file

@ -0,0 +1,8 @@
package app.k9mail.core.android.permissions
/**
* Checks what permission model the system is using.
*/
interface PermissionsModelChecker {
fun hasRuntimePermissions(): Boolean
}

View file

@ -0,0 +1,66 @@
package app.k9mail.core.android.permissions
import android.Manifest
import android.app.Application
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(minSdk = Build.VERSION_CODES.TIRAMISU)
class AndroidPermissionCheckerTest {
private val application: Application = ApplicationProvider.getApplicationContext()
private val shadowApplication = Shadows.shadowOf(application)
private val permissionChecker = AndroidPermissionChecker(application)
@Test
fun `granted READ_CONTACTS permission`() {
shadowApplication.grantPermissions(Manifest.permission.READ_CONTACTS)
val result = permissionChecker.checkPermission(Permission.Contacts)
assertThat(result).isEqualTo(PermissionState.Granted)
}
@Test
fun `denied READ_CONTACTS permission`() {
shadowApplication.denyPermissions(Manifest.permission.READ_CONTACTS)
val result = permissionChecker.checkPermission(Permission.Contacts)
assertThat(result).isEqualTo(PermissionState.Denied)
}
@Test
fun `granted POST_NOTIFICATIONS permission`() {
shadowApplication.grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
val result = permissionChecker.checkPermission(Permission.Notifications)
assertThat(result).isEqualTo(PermissionState.Granted)
}
@Test
fun `denied POST_NOTIFICATIONS permission`() {
shadowApplication.denyPermissions(Manifest.permission.POST_NOTIFICATIONS)
val result = permissionChecker.checkPermission(Permission.Notifications)
assertThat(result).isEqualTo(PermissionState.Denied)
}
@Test
@Config(minSdk = Build.VERSION_CODES.S_V2, maxSdk = Build.VERSION_CODES.S_V2)
fun `POST_NOTIFICATIONS permission not available`() {
val result = permissionChecker.checkPermission(Permission.Notifications)
assertThat(result).isEqualTo(PermissionState.GrantedImplicitly)
}
}

View file

@ -0,0 +1,20 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
android {
namespace = "net.thunderbird.core.android.testing"
}
dependencies {
api(libs.junit)
api(libs.robolectric)
implementation(projects.core.logging.api)
implementation(projects.core.preference.api)
implementation(projects.core.preference.impl)
api(libs.koin.core)
api(libs.mockito.core)
api(libs.mockito.kotlin)
}

View file

@ -0,0 +1,78 @@
package net.thunderbird.core.android.preferences
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.preference.storage.InMemoryStorage
import net.thunderbird.core.preference.storage.Storage
import net.thunderbird.core.preference.storage.StorageEditor
import net.thunderbird.core.preference.storage.StoragePersister
import net.thunderbird.core.preference.storage.StorageUpdater
class TestStoragePersister(
private val logger: Logger,
) : StoragePersister {
private val values = mutableMapOf<String, Any?>()
override fun loadValues(): Storage {
return InMemoryStorage(
values = values.mapValues { (_, value) ->
value?.toString() ?: ""
},
logger,
)
}
override fun createStorageEditor(storageUpdater: StorageUpdater): StorageEditor {
return InMemoryStorageEditor(storageUpdater)
}
private inner class InMemoryStorageEditor(private val storageUpdater: StorageUpdater) : StorageEditor {
private val removals = mutableSetOf<String>()
private val changes = mutableMapOf<String, String>()
private var alreadyCommitted = false
override fun putBoolean(key: String, value: Boolean) = apply {
changes[key] = value.toString()
removals.remove(key)
}
override fun putInt(key: String, value: Int) = apply {
changes[key] = value.toString()
removals.remove(key)
}
override fun putLong(key: String, value: Long) = apply {
changes[key] = value.toString()
removals.remove(key)
}
override fun putString(key: String, value: String?) = apply {
if (value == null) {
remove(key)
} else {
changes[key] = value
removals.remove(key)
}
}
override fun remove(key: String) = apply {
removals.add(key)
changes.remove(key)
}
override fun commit(): Boolean {
if (alreadyCommitted) throw AssertionError("StorageEditor.commit() called more than once")
alreadyCommitted = true
storageUpdater.updateStorage(::writeValues)
return true
}
private fun writeValues(currentStorage: Storage): Storage {
val updatedValues = currentStorage.getAll() - removals + changes
values.clear()
values.putAll(updatedValues.mapValues { (_, value) -> value })
return InMemoryStorage(updatedValues, logger)
}
}
}

View file

@ -0,0 +1,23 @@
package net.thunderbird.core.android.testing
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.mockito.kotlin.KStubbing
object MockHelper {
@JvmStatic
fun <T> mockBuilder(classToMock: Class<T>): T {
return mock(classToMock) { invocation ->
val mock = invocation.mock
if (invocation.method.returnType.isInstance(mock)) {
mock
} else {
Mockito.RETURNS_DEFAULTS.answer(invocation)
}
}
}
inline fun <reified T : Any> mockBuilder(stubbing: KStubbing<T>.(T) -> Unit = {}): T {
return mockBuilder(T::class.java).apply { KStubbing(this).stubbing(this) }
}
}

View file

@ -0,0 +1,15 @@
package net.thunderbird.core.android.testing
import android.app.Application
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* A Robolectric test that does not create an instance of our [Application].
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = EmptyApplication::class)
abstract class RobolectricTest
class EmptyApplication : Application()

View file

@ -0,0 +1,3 @@
package net.thunderbird.core.android.testing
fun String.removeNewlines(): String = replace("([\\r\\n])".toRegex(), "")

View file

@ -0,0 +1,7 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.core.architecture"
}

View file

@ -0,0 +1,12 @@
package net.thunderbird.core.architecture.data
/**
* Mapper definition for converting between domain models and data transfer objects (DTOs).
*
* @param TDomain The domain model type.
* @param TDto The data transfer object type.
*/
interface DataMapper<TDomain, TDto> {
fun toDomain(dto: TDto): TDomain
fun toDto(domain: TDomain): TDto
}

View file

@ -0,0 +1,25 @@
package net.thunderbird.core.architecture.model
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Abstract base for ID factories.
*
* This class provides a default implementation for creating and generating IDs.
* It uses UUIDs as the underlying representation of the ID.
*
* Example usage:
*
* ```kotlin
* class AccountIdFactory : BaseIdFactory<AccountId>()
* ```
*
* @param T The type of the ID.
*/
@OptIn(ExperimentalUuidApi::class)
abstract class BaseIdFactory<T> : IdFactory<T> {
override fun of(raw: String): Id<T> = Id(Uuid.parse(raw))
override fun create(): Id<T> = Id(Uuid.random())
}

View file

@ -0,0 +1,23 @@
package net.thunderbird.core.architecture.model
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Represents a unique identifier for an entity.
*
* @param T The type of the entity.
*
* @property value The underlying UUID value.
*/
@OptIn(ExperimentalUuidApi::class)
@JvmInline
value class Id<T>(val value: Uuid) {
/**
* Returns the raw string representation of the ID.
*/
fun asRaw(): String {
return value.toString()
}
}

View file

@ -0,0 +1,22 @@
package net.thunderbird.core.architecture.model
/**
* Factory interface for creating and generating IDs.
*/
interface IdFactory<T> {
/**
* Creates an [Id] from a raw string representation.
*
* @param raw The raw string representation of the ID.
* @return An instance of [Id] representing the ID.
*/
fun of(raw: String): Id<T>
/**
* Creates a new [Id].
*
* @return A new instance of [Id] representing the created ID.
*/
fun create(): Id<T>
}

View file

@ -0,0 +1,8 @@
package net.thunderbird.core.architecture.model
/**
* Interface representing an entity with a unique identifier.
*/
interface Identifiable<T> {
val id: Id<T>
}

View file

@ -0,0 +1,31 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.core.common"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core.logging.implLegacy)
implementation(projects.core.logging.api)
implementation(projects.core.logging.implFile)
}
commonTest.dependencies {
implementation(projects.core.testing)
}
jvmMain.dependencies {
implementation(libs.androidx.annotation)
}
}
compilerOptions {
freeCompilerArgs.addAll(
listOf(
"-Xexpect-actual-classes",
),
)
}
}

View file

@ -0,0 +1,4 @@
package net.thunderbird.core.common.resources
actual typealias StringRes = androidx.annotation.StringRes
actual typealias PluralsRes = androidx.annotation.PluralsRes

View file

@ -0,0 +1,3 @@
package net.thunderbird.core.common.resources
actual typealias ResourceNotFoundException = android.content.res.Resources.NotFoundException

View file

@ -0,0 +1,19 @@
package net.thunderbird.core.common
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import net.thunderbird.core.common.oauth.InMemoryOAuthConfigurationProvider
import net.thunderbird.core.common.oauth.OAuthConfigurationProvider
import org.koin.core.module.Module
import org.koin.dsl.module
val coreCommonModule: Module = module {
@OptIn(ExperimentalTime::class)
single<Clock> { Clock.System }
single<OAuthConfigurationProvider> {
InMemoryOAuthConfigurationProvider(
configurationFactory = get(),
)
}
}

View file

@ -0,0 +1,24 @@
package net.thunderbird.core.common.action
enum class SwipeAction(val removesItem: Boolean) {
None(removesItem = false),
ToggleSelection(removesItem = false),
ToggleRead(removesItem = false),
ToggleStar(removesItem = false),
Archive(removesItem = true),
ArchiveDisabled(removesItem = false),
ArchiveSetupArchiveFolder(removesItem = false),
Delete(removesItem = true),
Spam(removesItem = true),
Move(removesItem = true),
}
data class SwipeActions(
val leftAction: SwipeAction,
val rightAction: SwipeAction,
) {
companion object {
const val KEY_SWIPE_ACTION_LEFT = "swipeLeftAction"
const val KEY_SWIPE_ACTION_RIGHT = "swipeRightAction"
}
}

View file

@ -0,0 +1,12 @@
package net.thunderbird.core.common.cache
interface Cache<KEY : Any, VALUE : Any?> {
operator fun get(key: KEY): VALUE?
operator fun set(key: KEY, value: VALUE)
fun hasKey(key: KEY): Boolean
fun clear()
}

View file

@ -0,0 +1,51 @@
package net.thunderbird.core.common.cache
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
class ExpiringCache<KEY : Any, VALUE : Any?>
@OptIn(ExperimentalTime::class)
constructor(
private val clock: Clock,
private val delegateCache: Cache<KEY, VALUE> = InMemoryCache(),
private var lastClearTime: Instant = clock.now(),
private val cacheTimeValidity: Long = CACHE_TIME_VALIDITY_IN_MILLIS,
) : Cache<KEY, VALUE> {
override fun get(key: KEY): VALUE? {
recycle()
return delegateCache[key]
}
override fun set(key: KEY, value: VALUE) {
recycle()
delegateCache[key] = value
}
override fun hasKey(key: KEY): Boolean {
recycle()
return delegateCache.hasKey(key)
}
override fun clear() {
@OptIn(ExperimentalTime::class)
lastClearTime = clock.now()
delegateCache.clear()
}
private fun recycle() {
if (isExpired()) {
clear()
}
}
private fun isExpired(): Boolean {
@OptIn(ExperimentalTime::class)
return (clock.now() - lastClearTime).inWholeMilliseconds >= cacheTimeValidity
}
private companion object {
const val CACHE_TIME_VALIDITY_IN_MILLIS = 30_000L
}
}

View file

@ -0,0 +1,21 @@
package net.thunderbird.core.common.cache
class InMemoryCache<KEY : Any, VALUE : Any?>(
private val cache: MutableMap<KEY, VALUE> = mutableMapOf(),
) : Cache<KEY, VALUE> {
override fun get(key: KEY): VALUE? {
return cache[key]
}
override fun set(key: KEY, value: VALUE) {
cache[key] = value
}
override fun hasKey(key: KEY): Boolean {
return cache.containsKey(key)
}
override fun clear() {
cache.clear()
}
}

View file

@ -0,0 +1,30 @@
package net.thunderbird.core.common.cache
class SynchronizedCache<KEY : Any, VALUE : Any?>(
private val delegateCache: Cache<KEY, VALUE>,
) : Cache<KEY, VALUE> {
override fun get(key: KEY): VALUE? {
synchronized(delegateCache) {
return delegateCache[key]
}
}
override fun set(key: KEY, value: VALUE) {
synchronized(delegateCache) {
delegateCache[key] = value
}
}
override fun hasKey(key: KEY): Boolean {
synchronized(delegateCache) {
return delegateCache.hasKey(key)
}
}
override fun clear() {
synchronized(delegateCache) {
delegateCache.clear()
}
}
}

View file

@ -0,0 +1,62 @@
@file:OptIn(ExperimentalTime::class)
package net.thunderbird.core.common.cache
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
class TimeLimitedCache<TKey : Any, TValue : Any?>(
private val clock: Clock = Clock.System,
private val cache: MutableMap<TKey, Entry<TValue>> = mutableMapOf(),
) : Cache<TKey, TimeLimitedCache.Entry<TValue>> {
companion object {
private val DEFAULT_EXPIRATION_TIME = 1.hours
}
override fun get(key: TKey): Entry<TValue>? {
recycle(key)
return cache[key]
}
fun getValue(key: TKey): TValue? = get(key)?.value
fun set(key: TKey, value: TValue, expiresIn: Duration = DEFAULT_EXPIRATION_TIME) {
set(key, Entry(value, creationTime = clock.now(), expiresIn))
}
override fun set(key: TKey, value: Entry<TValue>) {
cache[key] = value
}
override fun hasKey(key: TKey): Boolean {
recycle(key)
return key in cache
}
override fun clear() {
cache.clear()
}
fun clearExpired() {
cache.entries.removeAll { (_, entry) ->
entry.expiresAt < clock.now()
}
}
private fun recycle(key: TKey) {
val entry = cache[key] ?: return
if (entry.expiresAt < clock.now()) {
cache.remove(key)
}
}
data class Entry<TValue : Any?>(
val value: TValue,
val creationTime: Instant,
val expiresIn: Duration,
val expiresAt: Instant = creationTime + expiresIn,
)
}

View file

@ -0,0 +1,3 @@
package net.thunderbird.core.common.domain.usecase.validation
interface ValidationError

View file

@ -0,0 +1,7 @@
package net.thunderbird.core.common.domain.usecase.validation
sealed interface ValidationResult {
data object Success : ValidationResult
data class Failure(val error: ValidationError) : ValidationResult
}

View file

@ -0,0 +1,22 @@
package net.thunderbird.core.common.exception
import kotlinx.coroutines.runBlocking
import net.thunderbird.core.logging.file.FileLogSink
import net.thunderbird.core.logging.legacy.Log
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
class ExceptionHandler(
private val defaultHandler: Thread.UncaughtExceptionHandler?,
) : Thread.UncaughtExceptionHandler, KoinComponent {
private val syncDebugFileLogSink: FileLogSink by inject(named("syncDebug"))
override fun uncaughtException(t: Thread, e: Throwable) {
Log.e("UncaughtException", e.toString(), e)
runBlocking {
syncDebugFileLogSink.flushAndCloseBuffer()
}
defaultHandler?.uncaughtException(t, e)
}
}

View file

@ -0,0 +1,26 @@
package net.thunderbird.core.common.exception
open class MessagingException(
override val message: String?,
val isPermanentFailure: Boolean,
override val cause: Throwable?,
) : Exception(message, cause) {
constructor(cause: Throwable?) : this(message = null, cause = cause, isPermanentFailure = false)
constructor(message: String?) : this(message = message, cause = null, isPermanentFailure = false)
constructor(message: String?, isPermanentFailure: Boolean) : this(
message = message,
cause = null,
isPermanentFailure = isPermanentFailure,
)
constructor(message: String?, cause: Throwable?) : this(
message = message,
cause = cause,
isPermanentFailure = false,
)
companion object {
private const val serialVersionUID = -1
}
}

View file

@ -0,0 +1,28 @@
@file:JvmName("ThrowableExtensions")
package net.thunderbird.core.common.exception
val Throwable.rootCauseMassage: String?
get() {
var rootCause = this
var nextCause: Throwable? = null
do {
nextCause = rootCause.cause?.also {
rootCause = it
}
} while (nextCause != null)
if (rootCause is MessagingException) {
return rootCause.message
}
// Remove the namespace on the exception so we have a fighting chance of seeing more
// of the error in the notification.
val simpleName = rootCause::class.simpleName
val message = rootCause.localizedMessage
return if (message.isNullOrBlank()) {
simpleName
} else {
"$simpleName: $message"
}
}

View file

@ -0,0 +1,64 @@
package net.thunderbird.core.common.inject
import org.koin.core.definition.Definition
import org.koin.core.module.KoinDslMarker
import org.koin.core.module.Module
import org.koin.core.parameter.parametersOf
import org.koin.core.qualifier.Qualifier
import org.koin.core.qualifier.named
import org.koin.core.scope.Scope
// This file must be deleted once https://github.com/InsertKoinIO/koin/pull/1951 is merged to
// Koin and released on 4.2.0
/**
* Defines a singleton list of elements of type [T].
*
* This function creates a singleton definition for a mutable list of elements.
* Each element in the list is resolved from the provided [items] definitions.
*
* @param T The type of elements in the list.
* @param items Vararg of [Definition]s that will be resolved and added to the list.
* @param qualifier Optional [Qualifier] to distinguish this list from others of the same type.
* If null, a default qualifier based on the type [T] will be used.
*/
@KoinDslMarker
inline fun <reified T> Module.singleListOf(vararg items: Definition<T>, qualifier: Qualifier? = null) {
single(qualifier ?: defaultListQualifier<T>(), createdAtStart = true) {
items.map { definition -> definition(this, parametersOf()) }
}
}
/**
* Resolves a [List] of instances of type [T].
* This is a helper function for Koin's multibinding feature.
*
* It uses the [defaultListQualifier] if no [qualifier] is provided.
*
* @param T The type of instances in the list.
* @param qualifier An optional [Qualifier] to distinguish between different lists of the same type.
* @return The resolved [MutableList] of instances of type [T].
*/
inline fun <reified T> Scope.getList(qualifier: Qualifier? = null) =
get<List<T>>(qualifier ?: defaultListQualifier<T>())
/**
* Creates a qualifier for a set of a specific type.
*
* This is used to differentiate between different sets of the same type when injecting dependencies.
*
* @param T The type of the elements in the set.
* @return A qualifier for the set.
*/
inline fun <reified T> defaultListQualifier() =
defaultCollectionQualifier<List<T>, T>()
/**
* Creates a default [Qualifier] for a collection binding.
*
* @param TCollection The type of the collection (e.g., `List`, `List`).
* @param T The type of the elements in the collection.
* @return A [Qualifier] that can be used to identify the specific collection binding.
*/
inline fun <reified TCollection : Collection<T>, reified T> defaultCollectionQualifier() =
named("${TCollection::class.qualifiedName}<${T::class.qualifiedName}>")

Some files were not shown because too many files have changed in this diff Show more