Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
26
core/android/account/build.gradle.kts
Normal file
26
core/android/account/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.thunderbird.core.android.account
|
||||
|
||||
fun interface AccountRemovedListener {
|
||||
fun onAccountRemoved(account: LegacyAccount)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.thunderbird.core.android.account
|
||||
|
||||
fun interface AccountsChangeListener {
|
||||
fun onAccountsChanged()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package net.thunderbird.core.android.account
|
||||
|
||||
enum class FolderMode {
|
||||
NONE,
|
||||
ALL,
|
||||
FIRST_CLASS,
|
||||
FIRST_AND_SECOND_CLASS,
|
||||
NOT_SECOND_CLASS,
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package net.thunderbird.core.android.account
|
||||
|
||||
enum class MessageFormat {
|
||||
TEXT,
|
||||
HTML,
|
||||
AUTO,
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package net.thunderbird.core.android.account
|
||||
|
||||
enum class QuoteStyle {
|
||||
PREFIX,
|
||||
HEADER,
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package net.thunderbird.core.android.account
|
||||
|
||||
enum class ShowPictures {
|
||||
NEVER,
|
||||
ALWAYS,
|
||||
ONLY_FROM_CONTACTS,
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
16
core/android/common/build.gradle.kts
Normal file
16
core/android/common/build.gradle.kts
Normal 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)
|
||||
}
|
||||
26
core/android/common/src/main/AndroidManifest.xml
Normal file
26
core/android/common/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<application android:supportsRtl="true">
|
||||
<provider
|
||||
android:name=".camera.provider.CaptureImageFileProvider"
|
||||
android:authorities="${applicationId}.activity"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
>
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/capture_image_file_provider_paths"
|
||||
/>
|
||||
<meta-data
|
||||
android:name="de.cketti.safecontentresolver.ALLOW_INTERNAL_ACCESS"
|
||||
android:value="true"
|
||||
/>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package app.k9mail.core.android.common
|
||||
|
||||
import app.k9mail.core.android.common.camera.cameraModule
|
||||
import app.k9mail.core.android.common.contact.contactModule
|
||||
import net.thunderbird.core.android.common.resources.resourcesAndroidModule
|
||||
import net.thunderbird.core.common.coreCommonModule
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
val coreCommonAndroidModule: Module = module {
|
||||
includes(resourcesAndroidModule)
|
||||
|
||||
includes(coreCommonModule)
|
||||
|
||||
includes(contactModule)
|
||||
|
||||
includes(cameraModule)
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
@file:JvmName("ContextHelper")
|
||||
|
||||
package app.k9mail.core.android.common.activity
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
|
||||
tailrec fun Context.findActivity(): Activity? {
|
||||
return if (this is Activity) {
|
||||
this
|
||||
} else {
|
||||
(this as? ContextWrapper)?.baseContext?.findActivity()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package app.k9mail.core.android.common.activity
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
|
||||
class CreateDocumentResultContract : ActivityResultContract<CreateDocumentResultContract.Input, Uri?>() {
|
||||
override fun createIntent(context: Context, input: Input): Intent {
|
||||
return Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType(input.mimeType)
|
||||
.putExtra(Intent.EXTRA_TITLE, input.title)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
|
||||
}
|
||||
|
||||
data class Input(
|
||||
val title: String,
|
||||
val mimeType: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package app.k9mail.core.android.common.camera
|
||||
|
||||
import android.Manifest.permission
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.ActivityCompat.startActivityForResult
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.k9mail.core.android.common.camera.io.CaptureImageFileWriter
|
||||
|
||||
class CameraCaptureHandler(
|
||||
private val captureImageFileWriter: CaptureImageFileWriter,
|
||||
) {
|
||||
|
||||
private lateinit var capturedImageUri: Uri
|
||||
|
||||
companion object {
|
||||
const val REQUEST_IMAGE_CAPTURE: Int = 6
|
||||
const val CAMERA_PERMISSION_REQUEST_CODE: Int = 100
|
||||
}
|
||||
|
||||
fun getCapturedImageUri(): Uri {
|
||||
if (::capturedImageUri.isInitialized) {
|
||||
return capturedImageUri
|
||||
} else {
|
||||
throw UninitializedPropertyAccessException("Image Uri not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
fun canLaunchCamera(context: Context) =
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
|
||||
|
||||
fun openCamera(activity: Activity) {
|
||||
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
capturedImageUri = captureImageFileWriter.getFileUri()
|
||||
intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri)
|
||||
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
startActivityForResult(activity, intent, REQUEST_IMAGE_CAPTURE, null)
|
||||
}
|
||||
|
||||
fun requestCameraPermission(activity: Activity) {
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(permission.CAMERA),
|
||||
CAMERA_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
fun hasCameraPermission(context: Context): Boolean {
|
||||
val hasPermission = ContextCompat.checkSelfPermission(context, permission.CAMERA)
|
||||
return hasPermission == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package app.k9mail.core.android.common.camera
|
||||
|
||||
import app.k9mail.core.android.common.camera.io.CaptureImageFileWriter
|
||||
import org.koin.dsl.module
|
||||
|
||||
internal val cameraModule = module {
|
||||
single { CaptureImageFileWriter(context = get()) }
|
||||
single<CameraCaptureHandler> {
|
||||
CameraCaptureHandler(
|
||||
captureImageFileWriter = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package app.k9mail.core.android.common.camera.io
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
class CaptureImageFileWriter(private val context: Context) {
|
||||
|
||||
fun getFileUri(): Uri {
|
||||
val file = getCaptureImageFile()
|
||||
return FileProvider.getUriForFile(context, "${context.packageName}.activity", file)
|
||||
}
|
||||
|
||||
private fun getCaptureImageFile(): File {
|
||||
val fileName = "IMG_${System.currentTimeMillis()}$FILE_EXT"
|
||||
return File(getDirectory(), fileName)
|
||||
}
|
||||
|
||||
private fun getDirectory(): File {
|
||||
val directory = File(context.cacheDir, DIRECTORY_NAME)
|
||||
directory.mkdirs()
|
||||
|
||||
return directory
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_EXT = ".jpg"
|
||||
private const val DIRECTORY_NAME = "captureImage"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package app.k9mail.core.android.common.camera.provider
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
class CaptureImageFileProvider : FileProvider()
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package app.k9mail.core.android.common.compat
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import java.io.Serializable
|
||||
|
||||
// This class resolves a deprecation warning and issue with the Bundle.getSerializable method
|
||||
// Fixes https://issuetracker.google.com/issues/314250395
|
||||
// Could be removed once releases in androidx.core.os.BundleCompat
|
||||
object BundleCompat {
|
||||
|
||||
@JvmStatic
|
||||
fun <T : Serializable> getSerializable(bundle: Bundle, key: String?, clazz: Class<T>): T? = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> bundle.getSerializable(key, clazz)
|
||||
else -> {
|
||||
@Suppress("DEPRECATION")
|
||||
val serializable = bundle.getSerializable(key)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
if (clazz.isInstance(serializable)) serializable as T else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.net.Uri
|
||||
import net.thunderbird.core.common.mail.EmailAddress
|
||||
|
||||
data class Contact(
|
||||
val id: Long,
|
||||
val name: String?,
|
||||
val emailAddress: EmailAddress,
|
||||
val uri: Uri,
|
||||
val photoUri: Uri?,
|
||||
)
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import app.k9mail.core.android.common.database.EmptyCursor
|
||||
import app.k9mail.core.android.common.database.getLongOrThrow
|
||||
import app.k9mail.core.android.common.database.getStringOrNull
|
||||
import net.thunderbird.core.common.mail.EmailAddress
|
||||
|
||||
interface ContactDataSource {
|
||||
|
||||
fun getContactFor(emailAddress: EmailAddress): Contact?
|
||||
|
||||
fun hasContactFor(emailAddress: EmailAddress): Boolean
|
||||
}
|
||||
|
||||
internal class ContentResolverContactDataSource(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val contactPermissionResolver: ContactPermissionResolver,
|
||||
) : ContactDataSource {
|
||||
|
||||
override fun getContactFor(emailAddress: EmailAddress): Contact? {
|
||||
getCursorFor(emailAddress).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val contactId = cursor.getLongOrThrow(ContactsContract.CommonDataKinds.Email._ID)
|
||||
val lookupKey = cursor.getStringOrNull(ContactsContract.Contacts.LOOKUP_KEY)
|
||||
val uri = ContactsContract.Contacts.getLookupUri(contactId, lookupKey)
|
||||
|
||||
val name = cursor.getStringOrNull(ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME)
|
||||
|
||||
val photoUri = cursor.getStringOrNull(ContactsContract.CommonDataKinds.Photo.PHOTO_URI)
|
||||
?.let { photoUriString -> Uri.parse(photoUriString) }
|
||||
|
||||
return Contact(
|
||||
id = contactId,
|
||||
name = name,
|
||||
emailAddress = emailAddress,
|
||||
uri = uri,
|
||||
photoUri = photoUri,
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasContactFor(emailAddress: EmailAddress): Boolean {
|
||||
getCursorFor(emailAddress).use { cursor ->
|
||||
return cursor.count > 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCursorFor(emailAddress: EmailAddress): Cursor {
|
||||
return if (contactPermissionResolver.hasContactPermission()) {
|
||||
val uri = Uri.withAppendedPath(
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
|
||||
Uri.encode(emailAddress.address),
|
||||
)
|
||||
|
||||
contentResolver.query(
|
||||
uri,
|
||||
PROJECTION,
|
||||
null,
|
||||
null,
|
||||
SORT_ORDER,
|
||||
) ?: EmptyCursor()
|
||||
} else {
|
||||
EmptyCursor()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val SORT_ORDER = ContactsContract.Contacts.DISPLAY_NAME +
|
||||
", " + ContactsContract.CommonDataKinds.Email._ID
|
||||
|
||||
private val PROJECTION = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email._ID,
|
||||
ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Photo.PHOTO_URI,
|
||||
ContactsContract.Contacts.LOOKUP_KEY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.content.Context
|
||||
import kotlin.time.ExperimentalTime
|
||||
import net.thunderbird.core.common.cache.Cache
|
||||
import net.thunderbird.core.common.cache.ExpiringCache
|
||||
import net.thunderbird.core.common.cache.SynchronizedCache
|
||||
import net.thunderbird.core.common.mail.EmailAddress
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
internal val contactModule = module {
|
||||
single<Cache<EmailAddress, Contact?>>(named(CACHE_NAME)) {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
SynchronizedCache(
|
||||
delegateCache = ExpiringCache(clock = get()),
|
||||
)
|
||||
}
|
||||
factory<ContactDataSource> {
|
||||
ContentResolverContactDataSource(
|
||||
contentResolver = get<Context>().contentResolver,
|
||||
contactPermissionResolver = get(),
|
||||
)
|
||||
}
|
||||
factory<ContactRepository> {
|
||||
CachingContactRepository(
|
||||
cache = get(named(CACHE_NAME)),
|
||||
dataSource = get(),
|
||||
)
|
||||
}
|
||||
factory<ContactPermissionResolver> {
|
||||
AndroidContactPermissionResolver(context = get())
|
||||
}
|
||||
}
|
||||
|
||||
internal const val CACHE_NAME = "ContactCache"
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.Manifest.permission.READ_CONTACTS
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
interface ContactPermissionResolver {
|
||||
fun hasContactPermission(): Boolean
|
||||
}
|
||||
|
||||
internal class AndroidContactPermissionResolver(private val context: Context) : ContactPermissionResolver {
|
||||
override fun hasContactPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, READ_CONTACTS) == PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import net.thunderbird.core.common.cache.Cache
|
||||
import net.thunderbird.core.common.mail.EmailAddress
|
||||
|
||||
interface ContactRepository {
|
||||
|
||||
fun getContactFor(emailAddress: EmailAddress): Contact?
|
||||
|
||||
fun hasContactFor(emailAddress: EmailAddress): Boolean
|
||||
|
||||
fun hasAnyContactFor(emailAddresses: List<EmailAddress>): Boolean
|
||||
}
|
||||
|
||||
interface CachingRepository {
|
||||
fun clearCache()
|
||||
}
|
||||
|
||||
internal class CachingContactRepository(
|
||||
private val cache: Cache<EmailAddress, Contact?>,
|
||||
private val dataSource: ContactDataSource,
|
||||
) : ContactRepository, CachingRepository {
|
||||
|
||||
override fun getContactFor(emailAddress: EmailAddress): Contact? {
|
||||
if (cache.hasKey(emailAddress)) {
|
||||
return cache[emailAddress]
|
||||
}
|
||||
|
||||
return dataSource.getContactFor(emailAddress).also {
|
||||
cache[emailAddress] = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasContactFor(emailAddress: EmailAddress): Boolean {
|
||||
if (cache.hasKey(emailAddress)) {
|
||||
return cache[emailAddress] != null
|
||||
}
|
||||
|
||||
return dataSource.hasContactFor(emailAddress)
|
||||
}
|
||||
|
||||
override fun hasAnyContactFor(emailAddresses: List<EmailAddress>): Boolean =
|
||||
emailAddresses.any { emailAddress -> hasContactFor(emailAddress) }
|
||||
|
||||
override fun clearCache() {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package app.k9mail.core.android.common.database
|
||||
|
||||
import android.database.Cursor
|
||||
|
||||
fun <T> Cursor.map(block: (Cursor) -> T): List<T> {
|
||||
return List(count) { index ->
|
||||
moveToPosition(index)
|
||||
block(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun Cursor.getStringOrNull(columnName: String): String? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return if (columnIndex == -1 || isNull(columnIndex)) null else getString(columnIndex)
|
||||
}
|
||||
|
||||
fun Cursor.getIntOrNull(columnName: String): Int? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return if (columnIndex == -1 || isNull(columnIndex)) null else getInt(columnIndex)
|
||||
}
|
||||
|
||||
fun Cursor.getLongOrNull(columnName: String): Long? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return if (columnIndex == -1 || isNull(columnIndex)) null else getLong(columnIndex)
|
||||
}
|
||||
|
||||
fun Cursor.getStringOrThrow(columnName: String): String {
|
||||
return getStringOrNull(columnName) ?: error("Column $columnName must not be null")
|
||||
}
|
||||
|
||||
fun Cursor.getIntOrThrow(columnName: String): Int {
|
||||
return getIntOrNull(columnName) ?: error("Column $columnName must not be null")
|
||||
}
|
||||
|
||||
fun Cursor.getLongOrThrow(columnName: String): Long {
|
||||
return getLongOrNull(columnName) ?: error("Column $columnName must not be null")
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package app.k9mail.core.android.common.database
|
||||
|
||||
import android.database.AbstractCursor
|
||||
|
||||
/**
|
||||
* A dummy class that provides an empty cursor
|
||||
*/
|
||||
class EmptyCursor : AbstractCursor() {
|
||||
override fun getCount() = 0
|
||||
|
||||
override fun getColumnNames() = arrayOf<String>()
|
||||
|
||||
override fun getString(column: Int) = null
|
||||
|
||||
override fun getShort(column: Int): Short = 0
|
||||
|
||||
override fun getInt(column: Int) = 0
|
||||
|
||||
override fun getLong(column: Int): Long = 0
|
||||
|
||||
override fun getFloat(column: Int) = 0f
|
||||
|
||||
override fun getDouble(column: Int) = 0.0
|
||||
|
||||
override fun isNull(column: Int) = true
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package net.thunderbird.core.android.common.resources
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import net.thunderbird.core.common.resources.ResourceManager
|
||||
|
||||
internal class AndroidResourceManager(
|
||||
private val context: Context,
|
||||
) : ResourceManager {
|
||||
override fun stringResource(@StringRes resourceId: Int): String = context.resources.getString(resourceId)
|
||||
|
||||
override fun stringResource(@StringRes resourceId: Int, vararg formatArgs: Any?): String =
|
||||
context.resources.getString(resourceId, *formatArgs)
|
||||
|
||||
override fun pluralsString(
|
||||
@PluralsRes resourceId: Int,
|
||||
quantity: Int,
|
||||
vararg formatArgs: Any?,
|
||||
): String = context.resources.getQuantityString(resourceId, quantity, *formatArgs)
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package net.thunderbird.core.android.common.resources
|
||||
|
||||
import net.thunderbird.core.common.resources.PluralsResourceManager
|
||||
import net.thunderbird.core.common.resources.ResourceManager
|
||||
import net.thunderbird.core.common.resources.StringsResourceManager
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
internal val resourcesAndroidModule: Module = module {
|
||||
single { AndroidResourceManager(context = androidApplication()) }
|
||||
single<ResourceManager> { get<AndroidResourceManager>() }
|
||||
single<StringsResourceManager> { get<AndroidResourceManager>() }
|
||||
single<PluralsResourceManager> { get<AndroidResourceManager>() }
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package net.thunderbird.core.android.common.view
|
||||
|
||||
import android.webkit.WebView
|
||||
import androidx.webkit.WebSettingsCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
|
||||
fun WebView.showInDarkMode() = setupThemeMode(darkTheme = true)
|
||||
fun WebView.showInLightMode() = setupThemeMode(darkTheme = false)
|
||||
|
||||
private fun WebView.setupThemeMode(darkTheme: Boolean) {
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(
|
||||
this.settings,
|
||||
darkTheme,
|
||||
)
|
||||
} else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
|
||||
WebSettingsCompat.setForceDark(
|
||||
this.settings,
|
||||
if (darkTheme) WebSettingsCompat.FORCE_DARK_ON else WebSettingsCompat.FORCE_DARK_OFF,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<paths>
|
||||
<cache-path
|
||||
name="captureImage"
|
||||
path="captureImage"
|
||||
/>
|
||||
</paths>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package app.k9mail.core.android.common
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.Test
|
||||
import org.koin.test.verify.verify
|
||||
|
||||
internal class CoreCommonAndroidModuleKtTest {
|
||||
|
||||
@Test
|
||||
fun `should have a valid di module`() {
|
||||
coreCommonAndroidModule.verify(
|
||||
extraTypes = listOf(
|
||||
Context::class,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package app.k9mail.core.android.common.compat
|
||||
|
||||
import android.os.Bundle
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import java.io.Serializable
|
||||
import kotlin.test.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class BundleCompatTest {
|
||||
|
||||
@Test
|
||||
fun `getSerializable returns Serializable`() {
|
||||
val bundle = Bundle()
|
||||
val key = "keySerializable"
|
||||
val serializable = TestSerializable("value")
|
||||
val clazz = TestSerializable::class.java
|
||||
bundle.putSerializable(key, serializable)
|
||||
|
||||
val result = BundleCompat.getSerializable(bundle, key, clazz)
|
||||
|
||||
assertThat(result).isEqualTo(serializable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSerializable returns null when class mismatch`() {
|
||||
val bundle = Bundle()
|
||||
val key = "keySerializable"
|
||||
val serializable = TestSerializable("value")
|
||||
val clazz = OtherTestSerializable::class.java
|
||||
bundle.putSerializable(key, serializable)
|
||||
|
||||
val result = BundleCompat.getSerializable(bundle, key, clazz)
|
||||
|
||||
assertThat(result).isEqualTo(null)
|
||||
}
|
||||
|
||||
internal class TestSerializable(
|
||||
val value: String,
|
||||
) : Serializable {
|
||||
companion object {
|
||||
private const val serialVersionUID = 1L
|
||||
}
|
||||
}
|
||||
|
||||
internal class OtherTestSerializable(
|
||||
val value: String,
|
||||
) : Serializable {
|
||||
companion object {
|
||||
private const val serialVersionUID = 2L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.Manifest
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AndroidContactPermissionResolverTest {
|
||||
private val application = RuntimeEnvironment.getApplication()
|
||||
private val testSubject = AndroidContactPermissionResolver(context = application)
|
||||
|
||||
@Test
|
||||
fun `hasPermission() with contact permission`() {
|
||||
grantContactPermission()
|
||||
|
||||
val result = testSubject.hasContactPermission()
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPermission() without contact permission`() {
|
||||
denyContactPermission()
|
||||
|
||||
val result = testSubject.hasContactPermission()
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
private fun grantContactPermission() {
|
||||
Shadows.shadowOf(application).grantPermissions(Manifest.permission.READ_CONTACTS)
|
||||
}
|
||||
|
||||
private fun denyContactPermission() {
|
||||
Shadows.shadowOf(application).denyPermissions(Manifest.permission.READ_CONTACTS)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import kotlin.test.Test
|
||||
import net.thunderbird.core.common.cache.InMemoryCache
|
||||
import net.thunderbird.core.common.mail.EmailAddress
|
||||
import org.junit.Before
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.doReturnConsecutively
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stub
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
internal class CachingContactRepositoryTest {
|
||||
|
||||
private val dataSource = mock<ContactDataSource>()
|
||||
private val cache = InMemoryCache<EmailAddress, Contact?>()
|
||||
|
||||
private val testSubject = CachingContactRepository(cache = cache, dataSource = dataSource)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() returns null if no contact exists`() {
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() returns contact if it exists`() {
|
||||
dataSource.stub { on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturn CONTACT }
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isEqualTo(CONTACT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() caches contact`() {
|
||||
dataSource.stub {
|
||||
on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturnConsecutively listOf(
|
||||
CONTACT,
|
||||
CONTACT.copy(id = 567L),
|
||||
)
|
||||
}
|
||||
|
||||
val result1 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
val result2 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result1).isEqualTo(result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() caches null`() {
|
||||
dataSource.stub {
|
||||
on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturnConsecutively listOf(
|
||||
null,
|
||||
CONTACT,
|
||||
)
|
||||
}
|
||||
|
||||
val result1 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
val result2 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result1).isEqualTo(result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactFor() returns cached contact`() {
|
||||
cache[CONTACT_EMAIL_ADDRESS] = CONTACT
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isEqualTo(CONTACT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactFor() returns false if no contact exists`() {
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactFor() returns false if cached contact is null`() {
|
||||
cache[CONTACT_EMAIL_ADDRESS] = null
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactFor() returns true if contact exists`() {
|
||||
dataSource.stub { on { hasContactFor(CONTACT_EMAIL_ADDRESS) } doReturn true }
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasAnyContactFor() returns false if no contact exists`() {
|
||||
val result = testSubject.hasAnyContactFor(listOf(CONTACT_EMAIL_ADDRESS))
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasAnyContactFor() returns false if list is empty`() {
|
||||
val result = testSubject.hasAnyContactFor(listOf())
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasAnyContactFor() returns true if contact exists`() {
|
||||
dataSource.stub { on { hasContactFor(CONTACT_EMAIL_ADDRESS) } doReturn true }
|
||||
|
||||
val result = testSubject.hasAnyContactFor(listOf(CONTACT_EMAIL_ADDRESS))
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearCache() clears cache`() {
|
||||
cache[CONTACT_EMAIL_ADDRESS] = CONTACT
|
||||
|
||||
testSubject.clearCache()
|
||||
|
||||
assertThat(cache[CONTACT_EMAIL_ADDRESS]).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.net.Uri
|
||||
import net.thunderbird.core.common.mail.toEmailAddressOrThrow
|
||||
|
||||
const val CONTACT_ID = 123L
|
||||
const val CONTACT_NAME = "user name"
|
||||
const val CONTACT_LOOKUP_KEY = "0r1-4F314D4F2F294F29"
|
||||
val CONTACT_EMAIL_ADDRESS = "user@example.com".toEmailAddressOrThrow()
|
||||
val CONTACT_URI: Uri = Uri.parse("content://com.android.contacts/contacts/lookup/$CONTACT_LOOKUP_KEY/$CONTACT_ID")
|
||||
val CONTACT_PHOTO_URI: Uri = Uri.parse("content://com.android.contacts/display_photo/$CONTACT_ID")
|
||||
|
||||
val CONTACT = Contact(
|
||||
id = CONTACT_ID,
|
||||
name = CONTACT_NAME,
|
||||
emailAddress = CONTACT_EMAIL_ADDRESS,
|
||||
uri = CONTACT_URI,
|
||||
photoUri = CONTACT_PHOTO_URI,
|
||||
)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import org.junit.Test
|
||||
import org.koin.test.verify.verify
|
||||
|
||||
internal class ContactKoinModuleKtTest {
|
||||
|
||||
@Test
|
||||
fun `should have a valid di module`() {
|
||||
contactModule.verify()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import kotlin.test.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stub
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
internal class ContentResolverContactDataSourceTest {
|
||||
private val contactPermissionResolver = TestContactPermissionResolver(hasPermission = true)
|
||||
private val contentResolver = mock<ContentResolver>()
|
||||
|
||||
private val testSubject = ContentResolverContactDataSource(
|
||||
contentResolver = contentResolver,
|
||||
contactPermissionResolver = contactPermissionResolver,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getContactForEmail() returns null if permission is not granted`() {
|
||||
contactPermissionResolver.hasContactPermission = false
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactForEmail() returns null if no contact is found`() {
|
||||
setupContactProvider(setupEmptyContactCursor())
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getContactForEmail() returns contact if a contact is found`() {
|
||||
setupContactProvider(setupContactCursor())
|
||||
|
||||
val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isEqualTo(CONTACT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactForEmail() returns false if permission is not granted`() {
|
||||
contactPermissionResolver.hasContactPermission = false
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactForEmail() returns false if no contact is found`() {
|
||||
setupContactProvider(setupEmptyContactCursor())
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasContactForEmail() returns true if a contact is found`() {
|
||||
setupContactProvider(setupContactCursor())
|
||||
|
||||
val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS)
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
private fun setupContactProvider(contactCursor: Cursor) {
|
||||
val emailUri = Uri.withAppendedPath(
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
|
||||
Uri.encode(CONTACT_EMAIL_ADDRESS.address),
|
||||
)
|
||||
|
||||
contentResolver.stub {
|
||||
on {
|
||||
query(eq(emailUri), eq(PROJECTION), anyOrNull(), anyOrNull(), eq(SORT_ORDER))
|
||||
} doReturn contactCursor
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupEmptyContactCursor(): Cursor {
|
||||
return MatrixCursor(PROJECTION)
|
||||
}
|
||||
|
||||
private fun setupContactCursor(): Cursor {
|
||||
return MatrixCursor(PROJECTION).apply {
|
||||
addRow(arrayOf(CONTACT_ID, CONTACT_NAME, CONTACT_PHOTO_URI, CONTACT_LOOKUP_KEY))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PROJECTION = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email._ID,
|
||||
ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Photo.PHOTO_URI,
|
||||
ContactsContract.Contacts.LOOKUP_KEY,
|
||||
)
|
||||
|
||||
const val SORT_ORDER = ContactsContract.Contacts.DISPLAY_NAME +
|
||||
", " + ContactsContract.CommonDataKinds.Email._ID
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package app.k9mail.core.android.common.contact
|
||||
|
||||
class TestContactPermissionResolver(hasPermission: Boolean) : ContactPermissionResolver {
|
||||
var hasContactPermission = hasPermission
|
||||
|
||||
override fun hasContactPermission(): Boolean {
|
||||
return hasContactPermission
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package app.k9mail.core.android.common.database
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
|
||||
data class CursorExtensionsAccessTestData<T : Any>(
|
||||
val name: String,
|
||||
val value: T,
|
||||
val access: (Cursor, String) -> T?,
|
||||
val throwingAccess: (Cursor, String) -> T,
|
||||
) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
class CursorExtensionsKtAccessTest(data: CursorExtensionsAccessTestData<Any>) {
|
||||
|
||||
private val testValue = data.value
|
||||
private val testAction = data.access
|
||||
private val testThrowingAction = data.throwingAccess
|
||||
|
||||
@Test
|
||||
fun `testAction should return null if column is null`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf(null))
|
||||
}
|
||||
|
||||
val result = cursor.map { testAction(it, "column") }
|
||||
|
||||
assertThat(result[0]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testAction should return value if column is not null`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf(testValue))
|
||||
}
|
||||
|
||||
val result = cursor.map { testAction(it, "column") }
|
||||
|
||||
assertThat(result[0]).isEqualTo(testValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testThrowingAction should throw if column is null`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf(null))
|
||||
}
|
||||
|
||||
assertFailure {
|
||||
cursor.map { testThrowingAction(it, "column") }
|
||||
}.hasMessage("Column column must not be null")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testThrowingAction should return value if column is not null`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf(testValue))
|
||||
}
|
||||
|
||||
val result = cursor.map { testThrowingAction(it, "column") }
|
||||
|
||||
assertThat(result[0]).isEqualTo(testValue)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun data(): Collection<CursorExtensionsAccessTestData<Any>> {
|
||||
return listOf(
|
||||
CursorExtensionsAccessTestData(
|
||||
name = "getString",
|
||||
value = "value",
|
||||
access = { cursor, column -> cursor.getStringOrNull(column) },
|
||||
throwingAccess = { cursor, column -> cursor.getStringOrThrow(column) },
|
||||
),
|
||||
CursorExtensionsAccessTestData(
|
||||
name = "getInt",
|
||||
value = Int.MAX_VALUE,
|
||||
access = { cursor, column -> cursor.getIntOrNull(column) },
|
||||
throwingAccess = { cursor, column -> cursor.getIntOrThrow(column) },
|
||||
),
|
||||
CursorExtensionsAccessTestData(
|
||||
name = "getLong",
|
||||
value = Long.MAX_VALUE,
|
||||
access = { cursor, column -> cursor.getLongOrNull(column) },
|
||||
throwingAccess = { cursor, column -> cursor.getLongOrThrow(column) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package app.k9mail.core.android.common.database
|
||||
|
||||
import android.database.MatrixCursor
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CursorExtensionsKtTest {
|
||||
|
||||
@Test
|
||||
fun `map should return an empty list if cursor is empty`() {
|
||||
val cursor = MatrixCursor(arrayOf("column"))
|
||||
|
||||
val result = cursor.map { it.getStringOrNull("column") }
|
||||
|
||||
assertThat(result).isEqualTo(emptyList<String>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `map should return a list of mapped values`() {
|
||||
val cursor = MatrixCursor(arrayOf("column")).apply {
|
||||
addRow(arrayOf("value1"))
|
||||
addRow(arrayOf("value2"))
|
||||
}
|
||||
|
||||
val result = cursor.map { it.getStringOrNull("column") }
|
||||
|
||||
assertThat(result).isEqualTo(listOf("value1", "value2"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package app.k9mail.core.android.common.test
|
||||
|
||||
import net.thunderbird.core.common.oauth.OAuthConfigurationFactory
|
||||
import org.koin.dsl.module
|
||||
|
||||
internal val externalModule = module {
|
||||
single<OAuthConfigurationFactory> {
|
||||
OAuthConfigurationFactory { emptyMap() }
|
||||
}
|
||||
}
|
||||
11
core/android/contact/build.gradle.kts
Normal file
11
core/android/contact/build.gradle.kts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "net.thunderbird.core.android.contact"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.mail.common)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
12
core/android/logging/build.gradle.kts
Normal file
12
core/android/logging/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
17
core/android/network/build.gradle.kts
Normal file
17
core/android/network/build.gradle.kts
Normal 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)
|
||||
}
|
||||
6
core/android/network/src/main/AndroidManifest.xml
Normal file
6
core/android/network/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
13
core/android/permissions/build.gradle.kts
Normal file
13
core/android/permissions/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package app.k9mail.core.android.permissions
|
||||
|
||||
/**
|
||||
* System permissions we ask for during onboarding.
|
||||
*/
|
||||
enum class Permission {
|
||||
Contacts,
|
||||
Notifications,
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package app.k9mail.core.android.permissions
|
||||
|
||||
/**
|
||||
* Checks what permission model the system is using.
|
||||
*/
|
||||
interface PermissionsModelChecker {
|
||||
fun hasRuntimePermissions(): Boolean
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
20
core/android/testing/build.gradle.kts
Normal file
20
core/android/testing/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.thunderbird.core.android.testing
|
||||
|
||||
fun String.removeNewlines(): String = replace("([\\r\\n])".toRegex(), "")
|
||||
7
core/architecture/api/build.gradle.kts
Normal file
7
core/architecture/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.kmp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "net.thunderbird.core.architecture"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
31
core/common/build.gradle.kts
Normal file
31
core/common/build.gradle.kts
Normal 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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
actual typealias StringRes = androidx.annotation.StringRes
|
||||
actual typealias PluralsRes = androidx.annotation.PluralsRes
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
actual typealias ResourceNotFoundException = android.content.res.Resources.NotFoundException
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
12
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/Cache.kt
vendored
Normal file
12
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/Cache.kt
vendored
Normal 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()
|
||||
}
|
||||
51
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/ExpiringCache.kt
vendored
Normal file
51
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/ExpiringCache.kt
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
21
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/InMemoryCache.kt
vendored
Normal file
21
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/InMemoryCache.kt
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
30
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/SynchronizedCache.kt
vendored
Normal file
30
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/SynchronizedCache.kt
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
62
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/TimeLimitedCache.kt
vendored
Normal file
62
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/TimeLimitedCache.kt
vendored
Normal 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.thunderbird.core.common.domain.usecase.validation
|
||||
|
||||
interface ValidationError
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue