diff --git a/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java b/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java deleted file mode 100644 index 5ec36956d8a4df56eee62843e517c9f26c8c1c8a..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.java +++ /dev/null @@ -1,229 +0,0 @@ -package com.fsck.k9.preferences; - - -import java.io.InputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import android.content.Context; - -import com.fsck.k9.K9RobolectricTest; -import com.fsck.k9.Preferences; -import com.fsck.k9.mail.AuthType; -import kotlin.text.Charsets; -import okio.Buffer; -import org.junit.Before; -import org.junit.Test; -import org.robolectric.RuntimeEnvironment; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - - -public class SettingsImporterTest extends K9RobolectricTest { - private final Context context = RuntimeEnvironment.getApplication(); - - @Before - public void before() { - deletePreExistingAccounts(); - } - - private void deletePreExistingAccounts() { - Preferences preferences = Preferences.getPreferences(); - preferences.clearAccounts(); - } - - @Test(expected = SettingsImportExportException.class) - public void importSettings_throwsExceptionOnBlankFile() throws SettingsImportExportException { - InputStream inputStream = inputStreamOf(""); - List accountUuids = new ArrayList<>(); - - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); - } - - @Test(expected = SettingsImportExportException.class) - public void importSettings_throwsExceptionOnMissingFormat() throws SettingsImportExportException { - InputStream inputStream = inputStreamOf(""); - List accountUuids = new ArrayList<>(); - - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); - } - - @Test(expected = SettingsImportExportException.class) - public void importSettings_throwsExceptionOnInvalidFormat() throws SettingsImportExportException { - InputStream inputStream = inputStreamOf(""); - List accountUuids = new ArrayList<>(); - - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); - } - - @Test(expected = SettingsImportExportException.class) - public void importSettings_throwsExceptionOnNonPositiveFormat() throws SettingsImportExportException { - InputStream inputStream = inputStreamOf(""); - List accountUuids = new ArrayList<>(); - - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); - } - - @Test(expected = SettingsImportExportException.class) - public void importSettings_throwsExceptionOnMissingVersion() throws SettingsImportExportException { - InputStream inputStream = inputStreamOf(""); - List accountUuids = new ArrayList<>(); - - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); - } - - @Test(expected = SettingsImportExportException.class) - public void importSettings_throwsExceptionOnInvalidVersion() throws SettingsImportExportException { - InputStream inputStream = inputStreamOf(""); - List accountUuids = new ArrayList<>(); - - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); - } - - @Test(expected = SettingsImportExportException.class) - public void importSettings_throwsExceptionOnNonPositiveVersion() throws SettingsImportExportException { - InputStream inputStream = inputStreamOf(""); - List accountUuids = new ArrayList<>(); - - SettingsImporter.importSettings(context, inputStream, true, accountUuids, true); - } - - @Test - public void parseSettings_account() throws SettingsImportExportException { - String validUUID = UUID.randomUUID().toString(); - InputStream inputStream = inputStreamOf("" + - "Account"); - List accountUuids = new ArrayList<>(); - accountUuids.add("1"); - - SettingsImporter.Imported results = SettingsImporter.parseSettings(inputStream, true, accountUuids, true); - - assertEquals(1, results.accounts.size()); - assertEquals("Account", results.accounts.get(validUUID).name); - assertEquals(validUUID, results.accounts.get(validUUID).uuid); - } - - @Test - public void parseSettings_account_identities() throws SettingsImportExportException { - String validUUID = UUID.randomUUID().toString(); - InputStream inputStream = inputStreamOf("" + - "Account" + - "user@gmail.com" + - ""); - List accountUuids = new ArrayList<>(); - accountUuids.add("1"); - - SettingsImporter.Imported results = SettingsImporter.parseSettings(inputStream, true, accountUuids, true); - - assertEquals(1, results.accounts.size()); - assertEquals(validUUID, results.accounts.get(validUUID).uuid); - assertEquals(1, results.accounts.get(validUUID).identities.size()); - assertEquals("user@gmail.com", results.accounts.get(validUUID).identities.get(0).email); - } - - - @Test - public void parseSettings_account_cram_md5() throws SettingsImportExportException { - String validUUID = UUID.randomUUID().toString(); - InputStream inputStream = inputStreamOf("" + - "Account" + - "CRAM_MD5" + - ""); - List accountUuids = new ArrayList<>(); - accountUuids.add(validUUID); - - SettingsImporter.Imported results = SettingsImporter.parseSettings(inputStream, true, accountUuids, false); - - assertEquals("Account", results.accounts.get(validUUID).name); - assertEquals(validUUID, results.accounts.get(validUUID).uuid); - assertEquals(AuthType.CRAM_MD5, results.accounts.get(validUUID).incoming.authenticationType); - } - - @Test - public void importSettings_disablesAccountsNeedingPasswords() throws SettingsImportExportException { - String validUUID = UUID.randomUUID().toString(); - InputStream inputStream = inputStreamOf("" + - "Account" + - "" + - "SSL_TLS_REQUIRED" + - "user@gmail.com" + - "CRAM_MD5" + - "googlemail.com" + - "" + - "" + - "SSL_TLS_REQUIRED" + - "user@googlemail.com" + - "CRAM_MD5" + - "googlemail.com" + - "" + - "b" + - "user@gmail.com" + - ""); - List accountUuids = new ArrayList<>(); - accountUuids.add(validUUID); - - SettingsImporter.ImportResults results = SettingsImporter.importSettings( - context, inputStream, true, accountUuids, false); - - assertEquals(0, results.erroneousAccounts.size()); - assertEquals(1, results.importedAccounts.size()); - assertEquals("Account", results.importedAccounts.get(0).imported.name); - assertEquals(validUUID, results.importedAccounts.get(0).imported.uuid); - assertTrue(results.importedAccounts.get(0).incomingPasswordNeeded); - assertTrue(results.importedAccounts.get(0).outgoingPasswordNeeded); - } - - @Test - public void getImportStreamContents_account() throws SettingsImportExportException { - String validUUID = UUID.randomUUID().toString(); - InputStream inputStream = inputStreamOf("" + - "" + - "" + - "Account" + - "" + - "" + - "user@gmail.com" + - "" + - "" + - "" + - ""); - - SettingsImporter.ImportContents results = SettingsImporter.getImportStreamContents(inputStream); - - assertEquals(false, results.globalSettings); - assertEquals(1, results.accounts.size()); - assertEquals("Account", results.accounts.get(0).name); - assertEquals(validUUID, results.accounts.get(0).uuid); - } - - @Test - public void getImportStreamContents_alternativeName() throws SettingsImportExportException { - String validUUID = UUID.randomUUID().toString(); - InputStream inputStream = inputStreamOf("" + - "" + - "" + - "" + - "" + - "" + - "user@gmail.com" + - "" + - "" + - "" + - ""); - - SettingsImporter.ImportContents results = SettingsImporter.getImportStreamContents(inputStream); - - assertEquals(false, results.globalSettings); - assertEquals(1, results.accounts.size()); - assertEquals("user@gmail.com", results.accounts.get(0).name); - assertEquals(validUUID, results.accounts.get(0).uuid); - } - - private InputStream inputStreamOf(String data) { - return new Buffer() - .writeString(data, Charsets.UTF_8) - .inputStream(); - } -} diff --git a/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.kt b/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..fe1af0e3309ac035868a846fa265fdd1ee5df2a0 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/preferences/SettingsImporterTest.kt @@ -0,0 +1,318 @@ +package com.fsck.k9.preferences + +import android.content.Context +import assertk.all +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.extracting +import assertk.assertions.first +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import assertk.assertions.key +import assertk.assertions.prop +import com.fsck.k9.K9RobolectricTest +import com.fsck.k9.Preferences +import com.fsck.k9.mail.AuthType +import com.fsck.k9.preferences.SettingsImporter.AccountDescription +import com.fsck.k9.preferences.SettingsImporter.AccountDescriptionPair +import com.fsck.k9.preferences.SettingsImporter.ImportContents +import com.fsck.k9.preferences.SettingsImporter.ImportResults +import com.fsck.k9.preferences.SettingsImporter.ImportedAccount +import com.fsck.k9.preferences.SettingsImporter.ImportedIdentity +import com.fsck.k9.preferences.SettingsImporter.ImportedServer +import java.util.UUID +import org.junit.Before +import org.junit.Test +import org.robolectric.RuntimeEnvironment + +class SettingsImporterTest : K9RobolectricTest() { + private val context: Context = RuntimeEnvironment.getApplication() + + @Before + fun before() { + deletePreExistingAccounts() + } + + private fun deletePreExistingAccounts() { + val preferences = Preferences.getPreferences() + preferences.clearAccounts() + } + + @Test + fun `importSettings() should throw on empty file`() { + val inputStream = "".byteInputStream() + val accountUuids = emptyList() + + assertFailure { + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + }.isInstanceOf() + } + + @Test + fun `importSettings() should throw on missing format attribute`() { + val inputStream = """""".byteInputStream() + val accountUuids = emptyList() + + assertFailure { + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + }.isInstanceOf() + } + + @Test + fun `importSettings() should throw on invalid format attribute value`() { + val inputStream = """""".byteInputStream() + val accountUuids = emptyList() + + assertFailure { + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + }.isInstanceOf() + } + + @Test + fun `importSettings() should throw on invalid format version`() { + val inputStream = """""".byteInputStream() + val accountUuids = emptyList() + + assertFailure { + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + }.isInstanceOf() + } + + @Test + fun `importSettings() should throw on missing version attribute`() { + val inputStream = """""".byteInputStream() + val accountUuids = emptyList() + + assertFailure { + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + }.isInstanceOf() + } + + @Test + fun `importSettings() should throws on invalid version attribute value`() { + val inputStream = """""".byteInputStream() + val accountUuids = emptyList() + + assertFailure { + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + }.isInstanceOf() + } + + @Test + fun `importSettings() should throw on invalid version`() { + val inputStream = """""".byteInputStream() + val accountUuids = emptyList() + + assertFailure { + SettingsImporter.importSettings(context, inputStream, true, accountUuids, true) + }.isInstanceOf() + } + + @Test + fun `parseSettings() should return accounts`() { + val accountUuid = UUID.randomUUID().toString() + val inputStream = + """ + + + + Account + + + + """.trimIndent().byteInputStream() + val accountUuids = listOf("1") + + val results = SettingsImporter.parseSettings(inputStream, true, accountUuids, true) + + assertThat(results.accounts).all { + hasSize(1) + key(accountUuid).all { + prop(ImportedAccount::uuid).isEqualTo(accountUuid) + prop(ImportedAccount::name).isEqualTo("Account") + } + } + } + + @Test + fun `parseSettings() should return identities`() { + val accountUuid = UUID.randomUUID().toString() + val inputStream = + """ + + + + Account + + + user@gmail.com + + + + + + """.trimIndent().byteInputStream() + val accountUuids = listOf("1") + + val results = SettingsImporter.parseSettings(inputStream, true, accountUuids, true) + + assertThat(results.accounts).all { + hasSize(1) + key(accountUuid).all { + prop(ImportedAccount::uuid).isEqualTo(accountUuid) + prop(ImportedAccount::identities).extracting(ImportedIdentity::email).containsExactly("user@gmail.com") + } + } + } + + @Test + fun `parseSettings() should parse incoming server authentication type`() { + val accountUuid = UUID.randomUUID().toString() + val inputStream = + """ + + + + Account + + CRAM_MD5 + + + + + """.trimIndent().byteInputStream() + val accountUuids = listOf(accountUuid) + + val results = SettingsImporter.parseSettings(inputStream, true, accountUuids, false) + + assertThat(results.accounts) + .key(accountUuid) + .prop(ImportedAccount::incoming) + .prop(ImportedServer::authenticationType) + .isEqualTo(AuthType.CRAM_MD5) + } + + @Test + fun `importSettings() should disable accounts needing passwords`() { + val accountUuid = UUID.randomUUID().toString() + val inputStream = + """ + + + + Account + + SSL_TLS_REQUIRED + user@gmail.com + CRAM_MD5 + googlemail.com + + + SSL_TLS_REQUIRED + user@googlemail.com + CRAM_MD5 + googlemail.com + + + b + + + + user@gmail.com + + + + + + """.trimIndent().byteInputStream() + val accountUuids = listOf(accountUuid) + + val results = SettingsImporter.importSettings(context, inputStream, true, accountUuids, false) + + assertThat(results).all { + prop(ImportResults::erroneousAccounts).isEmpty() + prop(ImportResults::importedAccounts).all { + hasSize(1) + first().all { + prop(AccountDescriptionPair::imported).all { + prop(AccountDescription::uuid).isEqualTo(accountUuid) + prop(AccountDescription::name).isEqualTo("Account") + } + prop(AccountDescriptionPair::incomingPasswordNeeded).isTrue() + prop(AccountDescriptionPair::outgoingPasswordNeeded).isTrue() + } + } + } + } + + @Test + fun `getImportStreamContents() should return list of accounts`() { + val accountUuid = UUID.randomUUID().toString() + val inputStream = + """ + + + + Account + + + user@gmail.com + + + + + + """.trimIndent().byteInputStream() + + val results = SettingsImporter.getImportStreamContents(inputStream) + + assertThat(results).all { + prop(ImportContents::globalSettings).isFalse() + prop(ImportContents::accounts).all { + hasSize(1) + first().all { + prop(AccountDescription::uuid).isEqualTo(accountUuid) + prop(AccountDescription::name).isEqualTo("Account") + } + } + } + } + + @Test + fun `getImportStreamContents() should return email address as account name when no account name provided`() { + val accountUuid = UUID.randomUUID().toString() + val inputStream = + """ + + + + + + + user@gmail.com + + + + + + """.trimIndent().byteInputStream() + + val results = SettingsImporter.getImportStreamContents(inputStream) + + assertThat(results).all { + prop(ImportContents::globalSettings).isFalse() + prop(ImportContents::accounts).all { + hasSize(1) + first().all { + prop(AccountDescription::uuid).isEqualTo(accountUuid) + prop(AccountDescription::name).isEqualTo("user@gmail.com") + } + } + } + } +} diff --git a/app/k9mail/build.gradle.kts b/app/k9mail/build.gradle.kts index 88d056375ee4eb81a67b93335fecfc42357e58c8..de2a72c97dab8d4abf1f656cce7e8010aa014452 100644 --- a/app/k9mail/build.gradle.kts +++ b/app/k9mail/build.gradle.kts @@ -66,7 +66,7 @@ android { testApplicationId = "foundation.e.mail.tests" versionCode = 37011 - versionName = "6.711" + versionName = "6.712-SNAPSHOT" // Keep in sync with the resource string array "supported_languages" resourceConfigurations.addAll( diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 52f1e7ad31a2da79035776b11e0e05dbc22ec5fc..faa84be720fda4b8e74a747e0ab886e6c639f0b6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -11,6 +11,12 @@ k9mail.keyPassword= ``` +## One-time setup for F-Droid builds + +1. Install _fdroidserver_ by following the [installation instructions](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools). +2. [Sign up for a Gitlab account](https://gitlab.com/users/sign_up) and fork the [fdroiddata](https://gitlab.com/fdroid/fdroiddata) repository. +3. Clone your fork of the _fdroiddata_ repository. + ## Release a beta version 1. Update versionCode and versionName in `app/k9mail/build.gradle` @@ -49,7 +55,15 @@ ### Create release on F-Droid -TODO +1. Fetch the latest changes from the _fdroiddata_ repository. +2. Switch to a new branch in your copy of the _fdroiddata_ repository. +3. Edit `metadata/com.fsck.k9.yml` to create a new entry for the version you want to release. Usually it's copy & paste of the previous entry and adjusting `versionName`, `versionCode`, and `commit` (use the tag name). Leave `CurrentVersion` and `CurrentVersionCode` unchanged. Those specify which version is the stable/recommended build. +4. Commit the changes. Message: "Update K-9 Mail to $newVersionName" +5. Run `fdroid build --latest com.fsck.k9` to build the project using F-Droid's toolchain. +6. Push the changes to your fork of the _fdroiddata_ repository. +7. Open a merge request on Gitlab. (The message from the server after the push in the previous step should contain a URL) +8. Select the _App update_ template and fill it out. +9. Create merge request and the F-Droid team will do the rest. ### Create release on Google Play diff --git a/docs/architecture/adr/0000-adr-template.md b/docs/architecture/adr/0000-adr-template.md new file mode 100644 index 0000000000000000000000000000000000000000..5d752b15f26f4fc49c319f23d7dc7a0d64cb9cfa --- /dev/null +++ b/docs/architecture/adr/0000-adr-template.md @@ -0,0 +1,29 @@ +# [NNNN] - [Descriptive Title in Title Case] + +## Status + + + +- **Status** + +## Context + + + +## Decision + + + +## Consequences + + + +- **Positive Consequences** + + - consequence 1 + - consequence 2 + +- **Negative Consequences** + + - consequence 1 + - consequence 2 diff --git a/docs/architecture/adr/0001-switch-from-java-to-kotlin.md b/docs/architecture/adr/0001-switch-from-java-to-kotlin.md new file mode 100644 index 0000000000000000000000000000000000000000..8ae1e5aa044a193c2df13d217f2c1fdbe1366031 --- /dev/null +++ b/docs/architecture/adr/0001-switch-from-java-to-kotlin.md @@ -0,0 +1,32 @@ +# 0001 - Switch from Java to Kotlin + +## Status + +- **Accepted** + +## Context + +We've been using Java as our primary language for Android development. While Java has served us well, it has certain +limitations in terms of null safety, verbosity, functional programming, and more. Kotlin, officially supported by +Google for Android development, offers solutions to many of these issues and provides more modern language features +that can improve productivity, maintainability, and overall code quality. + +## Decision + +Switch our primary programming language for Android development from Java to Kotlin. This will involve rewriting our +existing Java codebase in Kotlin and writing all new code in Kotlin. To facilitate the transition, we will gradually +refactor our existing Java codebase to Kotlin. + +## Consequences + +- **Positive Consequences** + + - Improved null safety, reducing potential for null pointer exceptions. + - Increased code readability and maintainability due to less verbose syntax. + - Availability of modern language features such as coroutines for asynchronous programming, and extension functions. + - Officially supported by Google for Android development, ensuring future-proof development. + +- **Negative Consequences** + + - The process of refactoring existing Java code to Kotlin can be time-consuming. + - Potential for introduction of new bugs during refactoring. diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e57ff4fa87a8e238f66a90e888c4bc0fa09af7f3 --- /dev/null +++ b/docs/architecture/adr/README.md @@ -0,0 +1,51 @@ +# Architecture Decision Records + +This [folder](/docs/architecture/adr) contains the architecture decision records (ADRs) for our project. + +ADRs are short text documents that serve as a historical context for the architecture decisions we make over the +course of the project. + +## What is an ADR? + +An Architecture Decision Record (ADR) is a document that captures an important architectural decision made along +with its context and consequences. ADRs record the decision making process and allow others to understand the +rationale behind decisions, providing insight and facilitating future decision-making processes. + +## Format of an ADR + +We adhere to Michael Nygard's [ADR format proposal](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions), +where each ADR document should contain: + +1. **Title**: A short descriptive name for the decision. +2. **Status**: The current status of the decision (proposed, accepted, rejected, deprecated, superseded) +3. **Context**: The context that motivates this decision. +4. **Decision**: The change that we're proposing and/or doing. +5. **Consequences**: What becomes easier or more difficult to do and any risks introduced as a result of the decision. + +## Creating a new ADR + +When creating a new ADR, please follow the provided [ADR template file](0000-adr-template.md) and ensure that your +document is clear and concise. + +## Directory Structure + +The ADRs will be stored in a directory named `docs/adr`, and each ADR will be a file named `NNNN-title-with-dashes.md` +where `NNNN` is a four-digit number that is increased by 1 for every new adr. + +## ADR Life Cycle + +The life cycle of an ADR is as follows: + +1. **Proposed**: The ADR is under consideration. +2. **Accepted**: The decision described in the ADR has been accepted and should be adhered to, unless it is superseded by another ADR. +3. **Rejected**: The decision described in the ADR has been rejected. +4. **Deprecated**: The decision described in the ADR is no longer relevant due to changes in system context. +5. **Superseded**: The decision described in the ADR has been replaced by another decision. + +Each ADR will have a status indicating its current life-cycle stage. An ADR can be updated over time, either to change +the status or to add more information. + +## Contributions + +We welcome contributions in the form of new ADRs or updates to existing ones. Please ensure all contributions follow +the standard format and provide clear and concise information. diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/item/EmailAddressItem.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/item/EmailAddressItem.kt index b4fd9d522f8ca747a95afb61685c0a1f3413ff02..5e4d61ea910214890435c2030feaa50a4fed143d 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/item/EmailAddressItem.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/item/EmailAddressItem.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.platform.LocalContext import app.k9mail.core.common.domain.usecase.validation.ValidationError import app.k9mail.core.ui.compose.designsystem.molecule.input.EmailAddressInput import app.k9mail.feature.account.common.ui.item.ListItem -import app.k9mail.feature.account.server.settings.ui.common.mapper.toResourceString +import app.k9mail.feature.account.setup.ui.autodiscovery.toResourceString @Composable internal fun LazyItemScope.EmailAddressItem(