diff --git a/feature/migration/qrcode/build.gradle.kts b/feature/migration/qrcode/build.gradle.kts new file mode 100644 index 00000000000..487f20783b0 --- /dev/null +++ b/feature/migration/qrcode/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "app.k9mail.feature.migration.qrcode" + resourcePrefix = "migration_qrcode_" +} + +dependencies { + implementation(projects.core.common) + implementation(libs.moshi) + implementation(libs.timber) +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/AccountData.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/AccountData.kt new file mode 100644 index 00000000000..b65b74c4500 --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/AccountData.kt @@ -0,0 +1,105 @@ +package app.k9mail.feature.migration.qrcode + +import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.net.Hostname +import app.k9mail.core.common.net.Port + +internal data class AccountData( + val sequenceNumber: Int, + val sequenceEnd: Int, + val accounts: List, +) { + data class Account( + val accountName: String, + val incomingServer: IncomingServer, + val outgoingServerGroups: List, + ) + + data class IncomingServer( + val protocol: IncomingServerProtocol, + val hostname: Hostname, + val port: Port, + val connectionSecurity: ConnectionSecurity, + val authenticationType: AuthenticationType, + val username: String, + val password: String?, + ) + + data class OutgoingServer( + val protocol: OutgoingServerProtocol, + val hostname: Hostname, + val port: Port, + val connectionSecurity: ConnectionSecurity, + val authenticationType: AuthenticationType, + val username: String, + val password: String?, + ) + + data class OutgoingServerGroup( + val outgoingServer: OutgoingServer, + val identities: List, + ) + + data class Identity( + val emailAddress: EmailAddress, + val displayName: String, + ) + + @Suppress("MagicNumber") + enum class IncomingServerProtocol(val intValue: Int) { + Imap(0), + Pop3(1), + ; + + companion object { + fun fromInt(value: Int): IncomingServerProtocol { + return requireNotNull(entries.find { it.intValue == value }) { "Unsupported value: $value" } + } + } + } + + @Suppress("MagicNumber") + enum class OutgoingServerProtocol(val intValue: Int) { + Smtp(0), + ; + + companion object { + fun fromInt(value: Int): OutgoingServerProtocol { + return requireNotNull(entries.find { it.intValue == value }) { "Unsupported value: $value" } + } + } + } + + @Suppress("MagicNumber") + enum class ConnectionSecurity(val intValue: Int) { + Plain(0), + TryStartTls(1), + AlwaysStartTls(2), + Tls(3), + ; + + companion object { + fun fromInt(value: Int): ConnectionSecurity { + return requireNotNull(entries.find { it.intValue == value }) { "Unsupported value: $value" } + } + } + } + + @Suppress("MagicNumber") + enum class AuthenticationType(val intValue: Int) { + None(0), + PasswordCleartext(1), + PasswordEncrypted(2), + Gssapi(3), + Ntlm(4), + TlsCertificate(5), + OAuth2(6), + ; + + companion object { + fun fromInt(value: Int): AuthenticationType { + return requireNotNull(entries.find { it.intValue == value }) { "Unsupported value: $value" } + } + } + } +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodeData.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodeData.kt new file mode 100644 index 00000000000..14324322559 --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodeData.kt @@ -0,0 +1,44 @@ +package app.k9mail.feature.migration.qrcode + +internal data class QrCodeData( + val version: Int, + val misc: Misc, + val accounts: List, +) { + data class Misc( + val sequenceNumber: Int, + val sequenceEnd: Int, + ) + + data class Account( + val incomingServer: IncomingServer, + val outgoingServers: List, + ) + + data class IncomingServer( + val protocol: Int, + val hostname: String, + val port: Int, + val connectionSecurity: Int, + val authenticationType: Int, + val username: String, + val accountName: String?, + val password: String?, + ) + + data class OutgoingServer( + val protocol: Int, + val hostname: String, + val port: Int, + val connectionSecurity: Int, + val authenticationType: Int, + val username: String, + val password: String?, + val identities: List, + ) + + data class Identity( + val emailAddress: String, + val displayName: String, + ) +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadAdapter.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadAdapter.kt new file mode 100644 index 00000000000..11d1d9b87a0 --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadAdapter.kt @@ -0,0 +1,153 @@ +package app.k9mail.feature.migration.qrcode + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import timber.log.Timber + +internal class QrCodePayloadAdapter : JsonAdapter() { + override fun fromJson(jsonReader: JsonReader): QrCodeData? { + jsonReader.beginArray() + + val version = jsonReader.nextInt() + if (version != 1) { + // We don't even attempt to read something that is newer than version 1. + Timber.d("Unsupported version: %s", version) + return null + } + + val misc = readMiscellaneousData(jsonReader) + + val accounts = buildList { + do { + add(readAccount(jsonReader)) + } while (jsonReader.hasNext()) + } + + jsonReader.endArray() + + return QrCodeData(version, misc, accounts) + } + + private fun readMiscellaneousData(jsonReader: JsonReader): QrCodeData.Misc { + jsonReader.beginArray() + + val sequenceNumber = jsonReader.nextInt() + val sequenceEnd = jsonReader.nextInt() + + skipAdditionalArrayEntries(jsonReader) + jsonReader.endArray() + + return QrCodeData.Misc( + sequenceNumber, + sequenceEnd, + ) + } + + private fun readAccount(jsonReader: JsonReader): QrCodeData.Account { + val incomingServer = readIncomingServer(jsonReader) + val outgoingServers = readOutgoingServers(jsonReader) + + return QrCodeData.Account(incomingServer, outgoingServers) + } + + private fun readIncomingServer(jsonReader: JsonReader): QrCodeData.IncomingServer { + jsonReader.beginArray() + + val protocol = jsonReader.nextInt() + val hostname = jsonReader.nextString() + val port = jsonReader.nextInt() + val connectionSecurity = jsonReader.nextInt() + val authenticationType = jsonReader.nextInt() + val username = jsonReader.nextString() + val accountName = if (jsonReader.hasNext()) jsonReader.nextString() else null + val password = if (jsonReader.hasNext()) jsonReader.nextString() else null + + skipAdditionalArrayEntries(jsonReader) + jsonReader.endArray() + + return QrCodeData.IncomingServer( + protocol, + hostname, + port, + connectionSecurity, + authenticationType, + username, + accountName, + password, + ) + } + + private fun readOutgoingServers(jsonReader: JsonReader): List { + jsonReader.beginArray() + + val outgoingServers = buildList { + do { + add(readOutgoingServer(jsonReader)) + } while (jsonReader.hasNext()) + } + + jsonReader.endArray() + + return outgoingServers + } + + private fun readOutgoingServer(jsonReader: JsonReader): QrCodeData.OutgoingServer { + jsonReader.beginArray() + + jsonReader.beginArray() + + val protocol = jsonReader.nextInt() + val hostname = jsonReader.nextString() + val port = jsonReader.nextInt() + val connectionSecurity = jsonReader.nextInt() + val authenticationType = jsonReader.nextInt() + val username = jsonReader.nextString() + val password = if (jsonReader.hasNext()) jsonReader.nextString() else null + + skipAdditionalArrayEntries(jsonReader) + jsonReader.endArray() + + val identities = buildList { + do { + add(readIdentity(jsonReader)) + } while (jsonReader.hasNext()) + } + + jsonReader.endArray() + + return QrCodeData.OutgoingServer( + protocol, + hostname, + port, + connectionSecurity, + authenticationType, + username, + password, + identities, + ) + } + + private fun readIdentity(jsonReader: JsonReader): QrCodeData.Identity { + jsonReader.beginArray() + + val emailAddress = jsonReader.nextString() + val displayName = jsonReader.nextString() + + skipAdditionalArrayEntries(jsonReader) + jsonReader.endArray() + + return QrCodeData.Identity(emailAddress, displayName) + } + + private fun skipAdditionalArrayEntries(jsonReader: JsonReader) { + // For forward compatibility allow additional array elements. + while (jsonReader.hasNext()) { + jsonReader.readJsonValue() + } + } + + override fun toJson(jsonWriter: JsonWriter, value: QrCodeData?) { + throw UnsupportedOperationException("not implemented") + } +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadMapper.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadMapper.kt new file mode 100644 index 00000000000..94b6ed8b22b --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadMapper.kt @@ -0,0 +1,94 @@ +package app.k9mail.feature.migration.qrcode + +import app.k9mail.core.common.mail.toUserEmailAddress +import app.k9mail.core.common.net.toHostname +import app.k9mail.core.common.net.toPort + +internal class QrCodePayloadMapper( + private val qrCodePayloadValidator: QrCodePayloadValidator = QrCodePayloadValidator(), +) { + fun toAccountData(data: QrCodeData): AccountData? { + return if (qrCodePayloadValidator.isValid(data)) { + mapToAccountData(data) + } else { + null + } + } + + private fun mapToAccountData(data: QrCodeData): AccountData { + return AccountData( + sequenceNumber = data.misc.sequenceNumber, + sequenceEnd = data.misc.sequenceEnd, + accounts = data.accounts.map { account -> mapAccount(account) }, + ) + } + + private fun mapAccount(account: QrCodeData.Account): AccountData.Account { + val incomingServer = mapIncomingServer(account.incomingServer) + val outgoingServerGroups = mapOutgoingServerGroups(account.outgoingServers) + val accountName = mapAccountName( + accountName = account.incomingServer.accountName, + identity = outgoingServerGroups.first().identities.first(), + ) + + return AccountData.Account( + accountName = accountName, + incomingServer = incomingServer, + outgoingServerGroups = outgoingServerGroups, + ) + } + + private fun mapAccountName(accountName: String?, identity: AccountData.Identity): String { + // When setting up an account in Thunderbird, the account name matches the email address. We can avoid this + // duplication in the encoded data by omitting the account name when it matches the email address. + // This method will return the email address of the first identity in case the account name is null or the empty + // string. + return accountName?.takeIf { it.isNotEmpty() } ?: identity.emailAddress.toString() + } + + private fun mapIncomingServer(incomingServer: QrCodeData.IncomingServer): AccountData.IncomingServer { + return AccountData.IncomingServer( + protocol = AccountData.IncomingServerProtocol.fromInt(incomingServer.protocol), + hostname = incomingServer.hostname.toHostname(), + port = incomingServer.port.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.fromInt(incomingServer.connectionSecurity), + authenticationType = AccountData.AuthenticationType.fromInt(incomingServer.authenticationType), + username = incomingServer.username, + password = incomingServer.password, + ) + } + + private fun mapOutgoingServerGroups( + outgoingServers: List, + ): List { + return outgoingServers.map { outgoingServer -> + AccountData.OutgoingServerGroup( + outgoingServer = mapOutgoingServer(outgoingServer), + identities = mapIdentities(outgoingServer.identities), + ) + } + } + + private fun mapOutgoingServer(outgoingServer: QrCodeData.OutgoingServer): AccountData.OutgoingServer { + return AccountData.OutgoingServer( + protocol = AccountData.OutgoingServerProtocol.fromInt(outgoingServer.protocol), + hostname = outgoingServer.hostname.toHostname(), + port = outgoingServer.port.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.fromInt(outgoingServer.connectionSecurity), + authenticationType = AccountData.AuthenticationType.fromInt(outgoingServer.authenticationType), + username = outgoingServer.username, + password = outgoingServer.password, + ) + } + + private fun mapIdentities(identities: List): List { + return identities.map { identity -> mapIdentity(identity) } + } + + private fun mapIdentity(identity: QrCodeData.Identity): AccountData.Identity { + return AccountData.Identity( + emailAddress = identity.emailAddress.toUserEmailAddress(), + displayName = identity.displayName, + ) + } +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadParser.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadParser.kt new file mode 100644 index 00000000000..862df0ea379 --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadParser.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.migration.qrcode + +import com.squareup.moshi.JsonDataException +import java.io.IOException +import timber.log.Timber + +internal class QrCodePayloadParser( + private val qrCodePayloadAdapter: QrCodePayloadAdapter = QrCodePayloadAdapter(), +) { + /** + * Parses the QR code payload as JSON and reads it into [QrCodeData]. + * + * @return [QrCodeData] if the JSON was parsed successfully and has the correct structure, `null` otherwise. + */ + fun parse(payload: String): QrCodeData? { + return try { + qrCodePayloadAdapter.fromJson(payload) + } catch (e: JsonDataException) { + Timber.d(e, "Failed to parse JSON") + null + } catch (e: IOException) { + Timber.d(e, "Unexpected IOException") + null + } + } +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadReader.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadReader.kt new file mode 100644 index 00000000000..5cd249ca831 --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadReader.kt @@ -0,0 +1,12 @@ +package app.k9mail.feature.migration.qrcode + +internal class QrCodePayloadReader( + private val parser: QrCodePayloadParser = QrCodePayloadParser(), + private val mapper: QrCodePayloadMapper = QrCodePayloadMapper(), +) { + fun read(payload: String): AccountData? { + val parsedData = parser.parse(payload) ?: return null + + return mapper.toAccountData(parsedData) + } +} diff --git a/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadValidator.kt b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadValidator.kt new file mode 100644 index 00000000000..82eab50cffe --- /dev/null +++ b/feature/migration/qrcode/src/main/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadValidator.kt @@ -0,0 +1,138 @@ +package app.k9mail.feature.migration.qrcode + +import app.k9mail.core.common.mail.EmailAddressParserException +import app.k9mail.core.common.mail.toUserEmailAddress +import app.k9mail.core.common.net.toHostname +import app.k9mail.core.common.net.toPort +import timber.log.Timber + +@Suppress("TooManyFunctions") +internal class QrCodePayloadValidator { + fun isValid(data: QrCodeData): Boolean { + if (data.version != 1) { + Timber.d("Unsupported version: %s", data.version) + return false + } + + return try { + validateData(data) + true + } catch (e: IllegalArgumentException) { + Timber.d(e, "QR code payload failed validation") + false + } + } + + private fun validateData(data: QrCodeData) { + require(data.accounts.isNotEmpty()) { "Account array must not be empty" } + + for (account in data.accounts) { + validateAccount(account) + } + } + + private fun validateAccount(account: QrCodeData.Account) { + validateIncomingServer(account.incomingServer) + validateOutgoingServers(account.outgoingServers) + } + + private fun validateIncomingServer(incomingServer: QrCodeData.IncomingServer) { + validateIncomingServerProtocol(incomingServer.protocol) + validateHostname(incomingServer.hostname) + validatePort(incomingServer.port) + validateConnectionSecurity(incomingServer.connectionSecurity) + validateAuthenticationType(incomingServer.authenticationType) + validateUsername(incomingServer.username) + validateAccountName(incomingServer.accountName) + validatePassword(incomingServer.password) + } + + private fun validateOutgoingServers(outgoingServers: List) { + require(outgoingServers.isNotEmpty()) { "List of outgoing servers must not be empty" } + + for (outgoingServer in outgoingServers) { + validateOutgoingServer(outgoingServer) + } + } + + private fun validateOutgoingServer(outgoingServer: QrCodeData.OutgoingServer) { + validateOutgoingServerProtocol(outgoingServer.protocol) + validateHostname(outgoingServer.hostname) + validatePort(outgoingServer.port) + validateConnectionSecurity(outgoingServer.connectionSecurity) + validateAuthenticationType(outgoingServer.authenticationType) + validateUsername(outgoingServer.username) + validatePassword(outgoingServer.password) + + validateIdentities(outgoingServer.identities) + } + + private fun validateIdentities(identities: List) { + require(identities.isNotEmpty()) { "List of identities must not be empty" } + + for (identity in identities) { + validateIdentity(identity) + } + } + + private fun validateIdentity(identity: QrCodeData.Identity) { + validateEmailAddress(identity.emailAddress) + validateDisplayName(identity.displayName) + } + + private fun validateAccountName(accountName: String?) { + require(accountName == null || isSingleLine(accountName)) { "Account name must not contain line break" } + } + + private fun validateIncomingServerProtocol(protocol: Int) { + AccountData.IncomingServerProtocol.fromInt(protocol) + } + + private fun validateOutgoingServerProtocol(protocol: Int) { + AccountData.OutgoingServerProtocol.fromInt(protocol) + } + + private fun validateHostname(hostname: String) { + hostname.toHostname() + } + + private fun validatePort(port: Int) { + port.toPort() + } + + private fun validateConnectionSecurity(value: Int) { + AccountData.ConnectionSecurity.fromInt(value) + } + + private fun validateAuthenticationType(value: Int) { + AccountData.AuthenticationType.fromInt(value) + } + + private fun validateUsername(username: String) { + require(isSingleLine(username)) { "Username must not contain line break" } + } + + private fun validatePassword(password: String?) { + require(password == null || isSingleLine(password)) { "Password must not contain line break" } + } + + private fun validateEmailAddress(emailAddress: String) { + try { + emailAddress.toUserEmailAddress() + } catch (e: EmailAddressParserException) { + throw IllegalArgumentException("Email address failed to parse", e) + } + } + + private fun validateDisplayName(displayName: String) { + require(isSingleLine(displayName)) { "Display name must not contain a line break" } + } + + private fun isSingleLine(text: String): Boolean { + return !text.contains(LINE_BREAK) + } + + companion object { + private val LINE_BREAK = "[\\r\\n]".toRegex() + } +} diff --git a/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadMapperTest.kt b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadMapperTest.kt new file mode 100644 index 00000000000..a4661c8fa1a --- /dev/null +++ b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadMapperTest.kt @@ -0,0 +1,141 @@ +package app.k9mail.feature.migration.qrcode + +import app.k9mail.core.common.mail.toUserEmailAddress +import app.k9mail.core.common.net.toHostname +import app.k9mail.core.common.net.toPort +import assertk.assertThat +import assertk.assertions.first +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.prop +import kotlin.test.Test + +class QrCodePayloadMapperTest { + private val mapper = QrCodePayloadMapper() + + @Test + fun `valid input should be mapped to expected output`() { + val input = INPUT + + val result = mapper.toAccountData(input) + + assertThat(result).isEqualTo(OUTPUT) + } + + @Test + fun `use email address of first identity when account name is the empty string`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(accountName = "") + } + + val result = mapper.toAccountData(input) + + assertThat(result).isNotNull() + .prop(AccountData::accounts).first() + .prop(AccountData.Account::accountName).isEqualTo("user@domain.example") + } + + @Test + fun `use email address of first identity when account name is missing`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(accountName = null, password = null) + } + + val result = mapper.toAccountData(input) + + assertThat(result).isNotNull() + .prop(AccountData::accounts).first() + .prop(AccountData.Account::accountName).isEqualTo("user@domain.example") + } + + companion object { + private val INPUT = QrCodeData( + version = 1, + misc = QrCodeData.Misc( + sequenceNumber = 1, + sequenceEnd = 1, + ), + accounts = listOf( + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.domain.example", + port = 993, + connectionSecurity = 3, + authenticationType = 1, + username = "user@domain.example", + accountName = "Account name", + password = "password", + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.domain.example", + port = 465, + connectionSecurity = 3, + authenticationType = 1, + username = "user@domain.example", + password = "password", + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@domain.example", + displayName = "Firstname Lastname", + ), + ), + ), + ), + ), + ), + ) + + private val OUTPUT = AccountData( + sequenceNumber = 1, + sequenceEnd = 1, + accounts = listOf( + AccountData.Account( + accountName = "Account name", + incomingServer = AccountData.IncomingServer( + protocol = AccountData.IncomingServerProtocol.Imap, + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.Tls, + authenticationType = AccountData.AuthenticationType.PasswordCleartext, + username = "user@domain.example", + password = "password", + ), + outgoingServerGroups = listOf( + AccountData.OutgoingServerGroup( + outgoingServer = AccountData.OutgoingServer( + protocol = AccountData.OutgoingServerProtocol.Smtp, + hostname = "smtp.domain.example".toHostname(), + port = 465.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.Tls, + authenticationType = AccountData.AuthenticationType.PasswordCleartext, + username = "user@domain.example", + password = "password", + ), + identities = listOf( + AccountData.Identity( + emailAddress = "user@domain.example".toUserEmailAddress(), + displayName = "Firstname Lastname", + ), + ), + ), + ), + ), + ), + ) + } + + private fun QrCodeData.updateIncomingServer( + block: (QrCodeData.IncomingServer) -> QrCodeData.IncomingServer, + ): QrCodeData { + return copy( + accounts = accounts.map { account -> + account.copy( + incomingServer = block(account.incomingServer), + ) + }, + ) + } +} diff --git a/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadParserTest.kt b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadParserTest.kt new file mode 100644 index 00000000000..a68799a462a --- /dev/null +++ b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadParserTest.kt @@ -0,0 +1,446 @@ +package app.k9mail.feature.migration.qrcode + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import kotlin.test.Test + +@Suppress("LongMethod") +class QrCodePayloadParserTest { + private val parser = QrCodePayloadParser() + + @Test + fun `one account, one identity, no account name, no passwords`() { + val payload = """[1,[1,1],""" + + """[0,"imap.domain.example",993,3,2,"username"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username"],""" + + """["user@domain.example","Firstname Lastname"]]]]""" + + val result = parser.parse(payload) + + assertThat(result).isNotNull().isEqualTo( + QrCodeData( + version = 1, + misc = QrCodeData.Misc( + sequenceNumber = 1, + sequenceEnd = 1, + ), + accounts = listOf( + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.domain.example", + port = 993, + connectionSecurity = 3, + authenticationType = 2, + username = "username", + accountName = null, + password = null, + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.domain.example", + port = 587, + connectionSecurity = 2, + authenticationType = 1, + username = "username", + password = null, + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@domain.example", + displayName = "Firstname Lastname", + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Test + fun `one account, one identity, with account name, no passwords`() { + val payload = """[1,[1,1],""" + + """[0,"imap.domain.example",993,3,2,"username","Personal"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username"],""" + + """["user@domain.example","Firstname Lastname"]]]]""" + + val result = parser.parse(payload) + + assertThat(result).isNotNull().isEqualTo( + QrCodeData( + version = 1, + misc = QrCodeData.Misc( + sequenceNumber = 1, + sequenceEnd = 1, + ), + accounts = listOf( + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.domain.example", + port = 993, + connectionSecurity = 3, + authenticationType = 2, + username = "username", + accountName = "Personal", + password = null, + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.domain.example", + port = 587, + connectionSecurity = 2, + authenticationType = 1, + username = "username", + password = null, + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@domain.example", + displayName = "Firstname Lastname", + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Test + fun `one account, one identity, no account name, with passwords`() { + val payload = """[1,[1,1],""" + + """[0,"imap.domain.example",993,3,2,"username","","imap-password"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username","smtp-password"],""" + + """["user@domain.example","Firstname Lastname"]]]]""" + + val result = parser.parse(payload) + + assertThat(result).isNotNull().isEqualTo( + QrCodeData( + version = 1, + misc = QrCodeData.Misc( + sequenceNumber = 1, + sequenceEnd = 1, + ), + accounts = listOf( + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.domain.example", + port = 993, + connectionSecurity = 3, + authenticationType = 2, + username = "username", + accountName = "", + password = "imap-password", + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.domain.example", + port = 587, + connectionSecurity = 2, + authenticationType = 1, + username = "username", + password = "smtp-password", + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@domain.example", + displayName = "Firstname Lastname", + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Test + fun `one account, one identity, with account name, with passwords`() { + val payload = """[1,[1,1],""" + + """[0,"imap.domain.example",993,3,2,"username","Personal","imap-password"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username","smtp-password"],""" + + """["user@domain.example","Firstname Lastname"]]]]""" + + val result = parser.parse(payload) + + assertThat(result).isNotNull().isEqualTo( + QrCodeData( + version = 1, + misc = QrCodeData.Misc( + sequenceNumber = 1, + sequenceEnd = 1, + ), + accounts = listOf( + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.domain.example", + port = 993, + connectionSecurity = 3, + authenticationType = 2, + username = "username", + accountName = "Personal", + password = "imap-password", + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.domain.example", + port = 587, + connectionSecurity = 2, + authenticationType = 1, + username = "username", + password = "smtp-password", + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@domain.example", + displayName = "Firstname Lastname", + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Test + fun `one account, two identities`() { + val payload = """[1,[1,1],""" + + """[0,"imap.domain.example",993,3,2,"username","","imap-password"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username","smtp-password"],""" + + """["user@domain.example","Firstname Lastname"],""" + + """["alias@domain.example","Nickname Lastname"]]]]""" + + val result = parser.parse(payload) + + assertThat(result).isNotNull().isEqualTo( + QrCodeData( + version = 1, + misc = QrCodeData.Misc( + sequenceNumber = 1, + sequenceEnd = 1, + ), + accounts = listOf( + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.domain.example", + port = 993, + connectionSecurity = 3, + authenticationType = 2, + username = "username", + accountName = "", + password = "imap-password", + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.domain.example", + port = 587, + connectionSecurity = 2, + authenticationType = 1, + username = "username", + password = "smtp-password", + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@domain.example", + displayName = "Firstname Lastname", + ), + QrCodeData.Identity( + emailAddress = "alias@domain.example", + displayName = "Nickname Lastname", + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Test + fun `two accounts, two identities each`() { + val payload = """[1,[1,1],""" + + """[0,"imap.domain.example",993,3,2,"username","","imap-password"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username","smtp-password"],""" + + """["user@domain.example","Firstname Lastname"],""" + + """["alias@domain.example","Nickname"]]],""" + + """[0,"imap.company.example",143,2,1,"user@company.example","","company-password"],""" + + """[[[0,"smtp.company.example",465,3,1,"user@company.example","company-password"],""" + + """["user@company.example","Firstname Lastname"],""" + + """["alias@company.example","Nickname Lastname"]]]]""" + + val result = parser.parse(payload) + + assertThat(result).isNotNull().isEqualTo( + QrCodeData( + version = 1, + misc = QrCodeData.Misc( + sequenceNumber = 1, + sequenceEnd = 1, + ), + accounts = listOf( + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.domain.example", + port = 993, + connectionSecurity = 3, + authenticationType = 2, + username = "username", + accountName = "", + password = "imap-password", + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.domain.example", + port = 587, + connectionSecurity = 2, + authenticationType = 1, + username = "username", + password = "smtp-password", + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@domain.example", + displayName = "Firstname Lastname", + ), + QrCodeData.Identity( + emailAddress = "alias@domain.example", + displayName = "Nickname", + ), + ), + ), + ), + ), + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.company.example", + port = 143, + connectionSecurity = 2, + authenticationType = 1, + username = "user@company.example", + accountName = "", + password = "company-password", + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.company.example", + port = 465, + connectionSecurity = 3, + authenticationType = 1, + username = "user@company.example", + password = "company-password", + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@company.example", + displayName = "Firstname Lastname", + ), + QrCodeData.Identity( + emailAddress = "alias@company.example", + displayName = "Nickname Lastname", + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Test + fun `additional array entries in incoming server array, outgoing server array, and identity array`() { + val payload = """[1,[1,1],""" + + """[0,"imap.domain.example",993,3,2,"username","","password","extra"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username","password","extra"],""" + + """["user@domain.example","Firstname Lastname","extra"]]]]""" + + val result = parser.parse(payload) + + assertThat(result).isNotNull().isEqualTo( + QrCodeData( + version = 1, + misc = QrCodeData.Misc( + sequenceNumber = 1, + sequenceEnd = 1, + ), + accounts = listOf( + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.domain.example", + port = 993, + connectionSecurity = 3, + authenticationType = 2, + username = "username", + accountName = "", + password = "password", + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.domain.example", + port = 587, + connectionSecurity = 2, + authenticationType = 1, + username = "username", + password = "password", + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@domain.example", + displayName = "Firstname Lastname", + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Test + fun `additional array entries in meta array`() { + val payload = """[1,[1,1,"extra"],""" + + """[0,"imap.domain.example",993,3,2,"username","","password","extra"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username","password","extra"],""" + + """["user@domain.example","Firstname Lastname","extra"]]]]""" + + val result = parser.parse(payload) + + assertThat(result).isNotNull() + } + + @Test + fun `URL instead of valid payload`() { + val payload = "https://domain.example/path" + + val result = parser.parse(payload) + + assertThat(result).isNull() + } + + @Test + fun `incomplete payload`() { + val payload = "[1,[" + + val result = parser.parse(payload) + + assertThat(result).isNull() + } +} diff --git a/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadReaderTest.kt b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadReaderTest.kt new file mode 100644 index 00000000000..852a335fdc0 --- /dev/null +++ b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadReaderTest.kt @@ -0,0 +1,168 @@ +package app.k9mail.feature.migration.qrcode + +import app.k9mail.core.common.mail.toUserEmailAddress +import app.k9mail.core.common.net.toHostname +import app.k9mail.core.common.net.toPort +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import kotlin.test.Test + +@Suppress("LongMethod") +class QrCodePayloadReaderTest { + private val reader = QrCodePayloadReader() + + @Test + fun `one account, one identity, no passwords`() { + val payload = """[1,[1,1],""" + + """[0,"imap.domain.example",993,3,2,"username","My Account"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username"],""" + + """["user@domain.example","Firstname Lastname"]]]]""" + + val result = reader.read(payload) + + assertThat(result).isNotNull().isEqualTo( + AccountData( + sequenceNumber = 1, + sequenceEnd = 1, + accounts = listOf( + AccountData.Account( + accountName = "My Account", + incomingServer = AccountData.IncomingServer( + protocol = AccountData.IncomingServerProtocol.Imap, + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.Tls, + authenticationType = AccountData.AuthenticationType.PasswordEncrypted, + username = "username", + password = null, + ), + outgoingServerGroups = listOf( + AccountData.OutgoingServerGroup( + outgoingServer = AccountData.OutgoingServer( + protocol = AccountData.OutgoingServerProtocol.Smtp, + hostname = "smtp.domain.example".toHostname(), + port = 587.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.AlwaysStartTls, + authenticationType = AccountData.AuthenticationType.PasswordCleartext, + username = "username", + password = null, + ), + identities = listOf( + AccountData.Identity( + emailAddress = "user@domain.example".toUserEmailAddress(), + displayName = "Firstname Lastname", + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Test + fun `two accounts, two identities each`() { + val payload = """[1,[2,3],""" + + """[0,"imap.domain.example",993,3,2,"username","","imap-password"],""" + + """[[[0,"smtp.domain.example",587,2,1,"username","smtp-password"],""" + + """["user@domain.example","Firstname Lastname"],""" + + """["alias@domain.example","Nickname"]]],""" + + """[0,"imap.company.example",143,2,1,"user@company.example","","company-password"],""" + + """[[[0,"smtp.company.example",465,3,1,"user@company.example","company-password"],""" + + """["user@company.example","Firstname Lastname"],""" + + """["alias@company.example","Nickname Lastname"]]]]""" + + val result = reader.read(payload) + + assertThat(result).isNotNull().isEqualTo( + AccountData( + sequenceNumber = 2, + sequenceEnd = 3, + accounts = listOf( + AccountData.Account( + accountName = "user@domain.example", + incomingServer = AccountData.IncomingServer( + protocol = AccountData.IncomingServerProtocol.Imap, + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.Tls, + authenticationType = AccountData.AuthenticationType.PasswordEncrypted, + username = "username", + password = "imap-password", + ), + outgoingServerGroups = listOf( + AccountData.OutgoingServerGroup( + outgoingServer = AccountData.OutgoingServer( + protocol = AccountData.OutgoingServerProtocol.Smtp, + hostname = "smtp.domain.example".toHostname(), + port = 587.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.AlwaysStartTls, + authenticationType = AccountData.AuthenticationType.PasswordCleartext, + username = "username", + password = "smtp-password", + ), + identities = listOf( + AccountData.Identity( + emailAddress = "user@domain.example".toUserEmailAddress(), + displayName = "Firstname Lastname", + ), + AccountData.Identity( + emailAddress = "alias@domain.example".toUserEmailAddress(), + displayName = "Nickname", + ), + ), + ), + ), + ), + AccountData.Account( + accountName = "user@company.example", + incomingServer = AccountData.IncomingServer( + protocol = AccountData.IncomingServerProtocol.Imap, + hostname = "imap.company.example".toHostname(), + port = 143.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.AlwaysStartTls, + authenticationType = AccountData.AuthenticationType.PasswordCleartext, + username = "user@company.example", + password = "company-password", + ), + outgoingServerGroups = listOf( + AccountData.OutgoingServerGroup( + outgoingServer = AccountData.OutgoingServer( + protocol = AccountData.OutgoingServerProtocol.Smtp, + hostname = "smtp.company.example".toHostname(), + port = 465.toPort(), + connectionSecurity = AccountData.ConnectionSecurity.Tls, + authenticationType = AccountData.AuthenticationType.PasswordCleartext, + username = "user@company.example", + password = "company-password", + ), + identities = listOf( + AccountData.Identity( + emailAddress = "user@company.example".toUserEmailAddress(), + displayName = "Firstname Lastname", + ), + AccountData.Identity( + emailAddress = "alias@company.example".toUserEmailAddress(), + displayName = "Nickname Lastname", + ), + ), + ), + ), + ), + ), + ), + ) + } + + @Test + fun `invalid payload`() { + val payload = "https://domain.example/path" + + val result = reader.read(payload) + + assertThat(result).isNull() + } +} diff --git a/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadValidatorTest.kt b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadValidatorTest.kt new file mode 100644 index 00000000000..eb0187d5ae7 --- /dev/null +++ b/feature/migration/qrcode/src/test/kotlin/app/k9mail/feature/migration/qrcode/QrCodePayloadValidatorTest.kt @@ -0,0 +1,355 @@ +package app.k9mail.feature.migration.qrcode + +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import kotlin.test.Test + +class QrCodePayloadValidatorTest { + private val validator = QrCodePayloadValidator() + + @Test + fun `valid input`() { + val input = INPUT + + val result = validator.isValid(input) + + assertThat(result).isTrue() + } + + @Test + fun `invalid account name`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(accountName = "contains\nline break") + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `incoming server with missing password`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(password = null) + } + + val result = validator.isValid(input) + + assertThat(result).isTrue() + } + + @Test + fun `outgoing server with missing password`() { + val input = INPUT.updateOutgoingServer { server -> + server.copy(password = null) + } + + val result = validator.isValid(input) + + assertThat(result).isTrue() + } + + @Test + fun `unsupported version number`() { + val input = INPUT.copy(version = 2) + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `empty list of accounts`() { + val input = INPUT.copy(accounts = emptyList()) + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `empty list of outgoing servers`() { + val input = INPUT.copy( + accounts = INPUT.accounts.map { it.copy(outgoingServers = emptyList()) }, + ) + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `empty list of identities`() { + val input = INPUT.copy( + accounts = INPUT.accounts.map { account -> + account.copy( + outgoingServers = account.outgoingServers.map { outgoingServer -> + outgoingServer.copy(identities = emptyList()) + }, + ) + }, + ) + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid incoming server protocol`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(protocol = 2) + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid incoming server hostname`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(hostname = "invalid hostname") + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid incoming server port`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(port = 100_000) + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid incoming server connection security`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(connectionSecurity = 100) + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid incoming server authentication type`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(authenticationType = 100) + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid incoming server username`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(username = "contains\nline break") + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid incoming server password`() { + val input = INPUT.updateIncomingServer { server -> + server.copy(password = "contains\nline break") + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid outgoing server protocol`() { + val input = INPUT.updateOutgoingServer { server -> + server.copy(protocol = 1) + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid outgoing server hostname`() { + val input = INPUT.updateOutgoingServer { server -> + server.copy(hostname = "invalid hostname") + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid outgoing server port`() { + val input = INPUT.updateOutgoingServer { server -> + server.copy(port = 100_000) + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid outgoing server connection security`() { + val input = INPUT.updateOutgoingServer { server -> + server.copy(connectionSecurity = 100) + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid outgoing server authentication type`() { + val input = INPUT.updateOutgoingServer { server -> + server.copy(authenticationType = 100) + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid outgoing server username`() { + val input = INPUT.updateOutgoingServer { server -> + server.copy(username = "contains\nline break") + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid outgoing server password`() { + val input = INPUT.updateOutgoingServer { server -> + server.copy(password = "contains\nline break") + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid identity email address`() { + val input = INPUT.updateIdentity { identity -> + identity.copy(emailAddress = "invalid") + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + @Test + fun `invalid identity display name`() { + val input = INPUT.updateIdentity { identity -> + identity.copy(displayName = "contains\nline break") + } + + val result = validator.isValid(input) + + assertThat(result).isFalse() + } + + companion object { + private val INPUT = QrCodeData( + version = 1, + misc = QrCodeData.Misc( + sequenceNumber = 1, + sequenceEnd = 1, + ), + accounts = listOf( + QrCodeData.Account( + incomingServer = QrCodeData.IncomingServer( + protocol = 0, + hostname = "imap.domain.example", + port = 993, + connectionSecurity = 3, + authenticationType = 1, + username = "user@domain.example", + accountName = "Account name", + password = "password", + ), + outgoingServers = listOf( + QrCodeData.OutgoingServer( + protocol = 0, + hostname = "smtp.domain.example", + port = 465, + connectionSecurity = 3, + authenticationType = 1, + username = "user@domain.example", + password = "password", + identities = listOf( + QrCodeData.Identity( + emailAddress = "user@domain.example", + displayName = "Firstname Lastname", + ), + ), + ), + ), + ), + ), + ) + } + + private fun QrCodeData.updateIncomingServer( + block: (QrCodeData.IncomingServer) -> QrCodeData.IncomingServer, + ): QrCodeData { + return copy( + accounts = accounts.map { account -> + account.copy( + incomingServer = block(account.incomingServer), + ) + }, + ) + } + + private fun QrCodeData.updateOutgoingServer( + block: (QrCodeData.OutgoingServer) -> QrCodeData.OutgoingServer, + ): QrCodeData { + return copy( + accounts = accounts.map { account -> + account.copy( + outgoingServers = account.outgoingServers.map(block), + ) + }, + ) + } + + private fun QrCodeData.updateIdentity( + block: (QrCodeData.Identity) -> QrCodeData.Identity, + ): QrCodeData { + return copy( + accounts = accounts.map { account -> + account.copy( + outgoingServers = account.outgoingServers.map { server -> + server.copy( + identities = server.identities.map(block), + ) + }, + ) + }, + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 386ce4b7a0a..625d7305c73 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -74,6 +74,7 @@ include( include( ":feature:migration:provider", + ":feature:migration:qrcode", ) include(