0
0
mirror of https://github.com/thunderbird/thunderbird-android.git synced 2024-09-19 11:42:14 +02:00

Add QR code payload parser

This commit is contained in:
cketti 2024-09-11 19:02:56 +02:00
parent 008a5e281c
commit b57719ed6d
13 changed files with 1697 additions and 0 deletions

View File

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

View File

@ -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<Account>,
) {
data class Account(
val accountName: String,
val incomingServer: IncomingServer,
val outgoingServerGroups: List<OutgoingServerGroup>,
)
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<Identity>,
)
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" }
}
}
}
}

View File

@ -0,0 +1,44 @@
package app.k9mail.feature.migration.qrcode
internal data class QrCodeData(
val version: Int,
val misc: Misc,
val accounts: List<Account>,
) {
data class Misc(
val sequenceNumber: Int,
val sequenceEnd: Int,
)
data class Account(
val incomingServer: IncomingServer,
val outgoingServers: List<OutgoingServer>,
)
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<Identity>,
)
data class Identity(
val emailAddress: String,
val displayName: String,
)
}

View File

@ -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<QrCodeData>() {
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<QrCodeData.OutgoingServer> {
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")
}
}

View File

@ -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<QrCodeData.OutgoingServer>,
): List<AccountData.OutgoingServerGroup> {
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<QrCodeData.Identity>): List<AccountData.Identity> {
return identities.map { identity -> mapIdentity(identity) }
}
private fun mapIdentity(identity: QrCodeData.Identity): AccountData.Identity {
return AccountData.Identity(
emailAddress = identity.emailAddress.toUserEmailAddress(),
displayName = identity.displayName,
)
}
}

View File

@ -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
}
}
}

View File

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

View File

@ -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<QrCodeData.OutgoingServer>) {
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<QrCodeData.Identity>) {
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()
}
}

View File

@ -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),
)
},
)
}
}

View File

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

View File

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

View File

@ -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),
)
},
)
},
)
}
}

View File

@ -74,6 +74,7 @@ include(
include(
":feature:migration:provider",
":feature:migration:qrcode",
)
include(