Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,28 @@
plugins {
id(ThunderbirdPlugins.Library.androidCompose)
}
android {
namespace = "app.k9mail.feature.migration.qrcode"
resourcePrefix = "migration_qrcode_"
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.android.account)
implementation(projects.legacy.common)
implementation(projects.legacy.ui.base)
implementation(projects.core.ui.compose.designsystem)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.moshi)
implementation(libs.okio)
implementation(libs.zxing)
testImplementation(projects.core.logging.testing)
testImplementation(projects.core.ui.compose.testing)
testImplementation(projects.core.ui.compose.theme2.k9mail)
}

View file

@ -0,0 +1,478 @@
# Thunderbird "Export for Mobile" QR code format (version 1)
This specification describes the data format of the QR code payload used by Thunderbird desktop to export account to
Thunderbird Mobile.
## Specification versions
### Version 1 (2025-02-06)
Initial version.
## Terms
### Reader
A reader is the software parsing the QR code payload after it has been scanned, e.g. Thunderbird for Android.
### Writer
A writer is the software creating QR codes that follow this specification, e.g. Thunderbird for desktop.
### Account
For the purpose of this specification an account is the combination of an `IncomingServer` object and the
`OutgoingServerGroups` object that is directly following this `IncomingServer` object.
See [root element](#root-element).
### Version
Whenever this document refers to a version without qualifier, the [specification version](#specification-versions) is
meant. It is different from the [format version](#formatversion) that is expected to stay the same until a
backward-incompatible change to the data format needs to be made.
## General structure
The QR code payload is JSON, encoded using UTF-8. The data objects like incoming server, outgoing server, and identities
are mapped to JSON arrays.
The data format is extensible by allowing arrays to contain additional elements from the ones specified in the initial
version. This applies to all arrays unless otherwise stated, but it is usually also explicitly spelled out.
For forward-compatibility a reader must ignore additional array values not listed in the version of the specification it
implements. A writer must not add elements to an array that are not part of the version of the specification it
implements.
### Motivation
We chose JSON because it's a widely supported format that is easily extensible, i.e. it's easy to add additional
properties later. We decided to use nested JSON arrays because it leads to compact output and space in QR codes is very
limited.
The downside of using JSON arrays is that it's most likely more work for implementations. For most object oriented
languages there exist libraries to map objects to JSON objects. However, library-assisted mapping of objects to JSON
arrays is not something that is typically available.
Another issue worth mentioning is that using JSON arrays makes the output harder to read for humans.
This is not a great data format. But it's one that seems to work well within the constraints of a QR code.
### Compatibility
If you intend to create a reader that is not using all of the information present in the payload, you must still
validate all of the properties, even the ones you're not using. This is to avoid situations where QR codes are being
recognized as valid by one (incomplete) reader but not another (full) reader.
### Multiple QR codes
The data format supports encoding an unlimited number of accounts to be able to export multiple accounts at the same
time. However, the payload size of a QR code is limited. So when exporting multiple accounts, multiple QR codes might
have to be used. The `SequenceNumber`/`SequenceEnd` mechanism (see below) is used to signal to the reader how many
QR codes are part of an export and in which order.
Note: This data format doesn't support spreading data for one account over multiple QR codes. So the amount of data that
can be used to encode one account is limited by the maximum QR code size. So far this hasn't been a problem in practice.
It's up to the writer to decide on a strategy of how many accounts to store in one QR code. But since larger QR codes
are harder to scan, it's probably a good idea to aim for a specific (maximum) QR code size, rather than using a fixed
number of accounts per QR code.
## Data format
### Root element
- Available since: version 1
- Type: Array
The root element of the JSON document is an array containing the following elements in this order:
- `FormatVersion`
- `MiscellaneousData`
- `IncomingServer`
- `OutgoingServerGroups`
The array may contain additional `IncomingServer` and `OutgoingServerGroups` elements. But they always have to appear in
pairs in exactly this order because only together they make up an account.
### `FormatVersion`
- Available since: version 1
- Type: Integer
- Value: 1
The version of the data format. This only needs to change if the existing mechanism for extensibility isn't sufficient
and the data format itself has to be changed in a backward-incompatible way. Of course the new data format might not
use a JSON array as root element. In that case the incompatibility can be detected without this property.
This document only describes format version 1. If a reader encounters any other value, it needs to either explicitly
support that specific version or fail with an error.
### `MiscellaneousData`
- Available since: version 1
- Type: Array
This array contains the following elements in this order:
- `SequenceNumber`
- `SequenceEnd`
Future versions of this specification may add additional values to this array.
### `SequenceNumber`
- Available since: version 1
- Type: Integer
Information about multiple accounts might not fit into a single QR code. This property contains the 1-based index of
the current QR code. A reader can use this information together with `SequenceEnd` to figure out how many QR codes in a
sequence have already been read and how many are still missing.
### `SequenceEnd`
- Available since: version 1
- Type: Integer
Information about multiple accounts might not fit into a single QR code. This property contains the number of QR codes
used for a single export operation. A reader can use this information together with `SequenceNumber` to figure out how
many QR codes in a sequence have already been read and how many are still missing.
### `IncomingServer`
- Available since: version 1
- Type: Array
This type contains properties that are only present once in an account, mostly information about the incoming server .
The first element in this array is `IncomingProtocol`. Its value determines the contents of the rest of the array.
For forward-compatibility a reader must skip reading an account when an unsupported `IncomingProtocol` value is
encountered.
Note: Since the size of this array depends on the value of `IncomingProtocol`, future specifications can only add
properties on a per protocol basis.
### `IncomingProtocol`
- Available since: version 1
- Type: Integer
- Values:
- 0 (`IMAP`; available since: version 1)
- 1 (`POP3`; available since: version 1)
For the values 0 and 1 the contents of the `IncomingServer` array are as follows:
- `IncomingProtocol`
- `Hostname`
- `Port`
- `ConnectionSecurity`
- `AuthenticationType`
- `Username`
- `AccountName` (optional)
- `Password` (optional)
For forward-compatibility a reader must skip reading an account when an unsupported `IncomingProtocol` value is
encountered. It must also skip the account if any of the other properties contain unsupported values.
A writer may omit optional elements from the array if the subsequent elements are also omitted.
#### Writing and reading `AccountName`
If the account name is equal to the email address of the first identity, the writer may omit the value. If the element
can't be omitted (because one of the following elements is present), `null` or the empty string may be used instead.
A reader must use the email address of the first identity as account name if `AccountName` is omitted or its value is
`null` or the empty string.
#### Writing and reading `Password`
If the writer doesn't want to include the password, it may omit the `Password` element.
For forward-compatibility a reader must treat a `Password` value of `null` or the empty string like an omitted
password.
### `Hostname`
- Available since: version 1
- Type: String
A server hostname. Currently only ASCII-only hostnames are allowed. This includes the ASCII Compatible Encoding (ACE) of
Internationalized Domain Names (IDN).
### `Port`
- Available since: version 1
- Type: Integer
- Values: 1-65535
The TCP port used by an incoming or outgoing server.
### `ConnectionSecurity`
- Available since: version 1
- Type: Integer
- Values:
- 0 (`Plain`; available since: version 1)
- 1 (legacy, do not use; reserved since: version 1)
- 2 (`AlwaysStartTls`; available since: version 1)
- 3 (`Tls`; available since: version 1)
Describes if and how to use TLS to secure the connection to a server.
### `AuthenticationType`
- Available since: version 1
- Type: Integer
- Values:
- 0 (`None`; available since: version 1)
- 1 (`PasswordCleartext`; available since: version 1)
- 2 (`PasswordEncrypted`; available since: version 1)
- 3 (`Gssapi`; available since: version 1)
- 4 (`Ntlm`; available since: version 1)
- 5 (`TlsCertificate`; available since: version 1)
- 6 (`OAuth2`; available since: version 1)
The authentication method to use.
### `Username`
- Available since: version 1
- Type: String
The username to use for authentication.
### `AccountName`
- Available since: version 1
- Type: String
The name of the account. If the value is `null` or the empty string, a reader must use the email address of the first
identity as the value of the account name.
A reader must use the email address of the first identity it is able to successfully read.
Note: This can lead to the reader using a different account name than the writer intended. But an unintended account
name is deemed preferable to the whole account having to be skipped because the reader doesn't support reading the first
identity.
### `Password`
- Available since: version 1
- Type: String
The password to use for authentication.
### `OutgoingServerGroups`
- Available since: version 1
- Type: Array
The array contains one or more `OutgoingServerGroup` elements.
### `OutgoingServerGroup`
- Available since: version 1
- Type: Array
This array contains the following elements in this order:
- `OutgoingServer`
- `Identity`
The array may contain additional `Identity` elements.
A reader must skip the `OutgoingServerGroup` if it fails to read the `OutgoingServer` or all `Identity` elements.
### `OutgoingServer`
- Available since: version 1
- Type: Array
The first element in this array is `OutgoingProtocol`. Its value determines the contents of the rest of the array.
Note: Since the size of this array depends on the value of `OutgoingProtocol`, future specifications can only add
properties on a per protocol basis.
### `OutgoingProtocol`
- Available since: version 1
- Type: Integer
- Values:
- 0 (`SMTP`; available since: version 1)
For the value 0 the contents of the `OutgoingServer` array are as follows:
- `OutgoingProtocol`
- `Hostname`
- `Port`
- `ConnectionSecurity`
- `AuthenticationType`
- `Username`
- `Password` (optional)
For forward-compatibility a reader must skip reading the `OutgoingServerGroup` when an unsupported `OutgoingProtocol`
value is encountered. It must also skip the `OutgoingServerGroup` if any of the other properties contain unsupported
values.
#### Writing and reading `Password`
If the writer doesn't want to include the password, it may omit the `Password` element.
For forward-compatibility a reader must treat a `Password` value of `null` or the empty string like an omitted
password.
### `Identity`
- Available since: version 1
- Type: Array
This array contains the following elements in this order:
- `EmailAddress`
- `DisplayName`
A reader must skip this identity if any of the elements contain unsupported values.
Future versions of this specification may add additional values to this array.
### `EmailAddress`
- Available since: version 1
- Type: String
The email address to use for outgoing messages.
Currently only ASCII-only email addresses are allowed.
### `DisplayName`
- Available since: version 1
- Type: String
The name to use in outgoing messages.
## Examples
### One IMAP account
```json
[
1,
[1, 1],
[0, "imap.domain.example", 993, 3, 1, "user@domain.example"],
[
[
[0, "smtp.domain.example", 465, 3, 1, "user@domain.example"],
["user@domain.example", "Jane Doe"]
]
]
]
```
- Format version: 1
- Sequence: 1 of 1 (there's no other QR code to scan)
- Incoming server:
- Protocol: `IMAP`
- Hostname: `imap.domain.example`
- Port: `993`
- Connection security: `Tls`
- Authentication type: `PasswordCleartext`
- Username: `user@domain.example`
- Password: _not present_
- Account name: `user@domain.example` (implicitly defined via the email address of the first identity)
- Outgoing server:
- Protocol: `SMTP`
- Hostname: `smtp.domain.example`
- Port: `465`
- Connection security: `Tls`
- Authentication type: `PasswordCleartext`
- Username: `user@domain.example`
- Password: _not present_
- Identity:
- Email: `user@domain.example`
- Display name: `Jane Doe`
### Two IMAP accounts
```json
[
1,
[1, 2],
[
0,
"imap.company.example",
993,
3,
6,
"user@company.example",
"user@company.example",
""
],
[
[
[0, "smtp.company.example", 465, 3, 6, "user@company.example", ""],
["user@company.example", "Jane Doe"]
]
],
[
0,
"imap.domain.example",
993,
3,
1,
"jane@domain.example",
"Jane (Personal)",
""
],
[
[
[0, "smtp.domain.example", 465, 3, 1, "jane@domain.example", ""],
["jane@domain.example", "Jane"]
]
]
]
```
- Format version: 1
- Sequence: 1 of 2 (there's one more QR code to scan)
- Incoming server:
- Protocol: `IMAP`
- Hostname: `imap.company.example`
- Port: `993`
- Connection security: `Tls`
- Authentication type: `OAuth2`
- Username: `user@company.example`
- Password: _not present_
- Account name: `user@company.example`
- Outgoing server:
- Protocol: `SMTP`
- Hostname: `smtp.company.example`
- Port: `465`
- Connection security: `Tls`
- Authentication type: `OAuth2`
- Username: `user@company.example`
- Password: _not present_
- Identity:
- Email: `user@company.example`
- Display name: `Jane Doe`
---
- Incoming server:
- Protocol: `IMAP`
- Hostname: `imap.domain.example`
- Port: `993`
- Connection security: `Tls`
- Authentication type: `PasswordCleartext`
- Username: `jane@domain.example`
- Password: _not present_
- Account name: `Jane (Personal)`
- Outgoing server:
- Protocol: `SMTP`
- Hostname: `smtp.domain.example`
- Port: `465`
- Connection security: `Tls`
- Authentication type: `PasswordCleartext`
- Username: `jane@domain.example`
- Password: _not present_
- Identity:
- Email: `jane@domain.example`
- Display name: `Jane`

View file

@ -0,0 +1,15 @@
package app.k9mail.feature.migration.qrcode.ui
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State
internal class NoOpQrCodeScannerViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), QrCodeScannerContract.ViewModel {
override val cameraUseCasesProvider = UseCase.CameraUseCasesProvider { emptyList() }
override fun event(event: Event) = Unit
}

View file

@ -0,0 +1,18 @@
package app.k9mail.feature.migration.qrcode.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
import app.k9mail.core.ui.compose.designsystem.atom.Surface
@PreviewScreenSizes
@Composable
fun PermissionDeniedContentPreview() {
PreviewWithTheme(isDarkTheme = true) {
Surface {
PermissionDeniedContent(
onGoToSettingsClick = {},
)
}
}
}

View file

@ -0,0 +1,49 @@
package app.k9mail.feature.migration.qrcode.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState
@Preview
@Composable
fun QrCodeScannerScreenPreview_permission_unknown() {
PreviewWithTheme(isDarkTheme = true) {
QrCodeScannerScreen(
finishWithResult = {},
finish = {},
viewModel = NoOpQrCodeScannerViewModel(
initialState = State(cameraPermissionState = UiPermissionState.Unknown),
),
)
}
}
@Preview
@Composable
fun QrCodeScannerScreenPreview_permission_granted() {
PreviewWithTheme(isDarkTheme = true) {
QrCodeScannerScreen(
finishWithResult = {},
finish = {},
viewModel = NoOpQrCodeScannerViewModel(
initialState = State(cameraPermissionState = UiPermissionState.Granted),
),
)
}
}
@Preview
@Composable
fun QrCodeScannerScreenPreview_permission_denied() {
PreviewWithTheme(isDarkTheme = true) {
QrCodeScannerScreen(
finishWithResult = {},
finish = {},
viewModel = NoOpQrCodeScannerViewModel(
initialState = State(cameraPermissionState = UiPermissionState.Denied),
),
)
}
}

View file

@ -0,0 +1,35 @@
package app.k9mail.feature.migration.qrcode.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.DisplayText
@Preview
@Composable
fun QrCodeScannerViewPreview_initial() {
PreviewWithTheme(isDarkTheme = true) {
Surface {
QrCodeScannerView(
cameraUseCasesProvider = { emptyList() },
displayText = DisplayText.HelpText,
onDoneClick = {},
)
}
}
}
@Preview
@Composable
fun QrCodeScannerViewPreview_one_qr_code_scanned() {
PreviewWithTheme(isDarkTheme = true) {
Surface {
QrCodeScannerView(
cameraUseCasesProvider = { emptyList() },
DisplayText.ProgressText(scannedCount = 1, totalCount = 2),
onDoneClick = {},
)
}
}
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.CAMERA"/>
<application tools:ignore="MissingApplicationIcon">
<activity
android:name=".ui.QrCodeScannerActivity"
android:exported="false"
android:theme="@style/Theme.Material3.Dark.NoActionBar"
/>
<provider
android:name=".settings.QrCodeSettingsFileProvider"
android:authorities="${applicationId}.qrcode.settings"
android:exported="false"
android:grantUriPermissions="true"
>
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/migration_qrcode_file_provider_paths"
/>
</provider>
</application>
</manifest>

View file

@ -0,0 +1,55 @@
package app.k9mail.feature.migration.qrcode
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase
import app.k9mail.feature.migration.qrcode.domain.usecase.QrCodePayloadReader
import app.k9mail.feature.migration.qrcode.domain.usecase.QrCodeSettingsWriter
import app.k9mail.feature.migration.qrcode.payload.QrCodePayloadAdapter
import app.k9mail.feature.migration.qrcode.payload.QrCodePayloadMapper
import app.k9mail.feature.migration.qrcode.payload.QrCodePayloadParser
import app.k9mail.feature.migration.qrcode.payload.QrCodePayloadValidator
import app.k9mail.feature.migration.qrcode.settings.DefaultUuidGenerator
import app.k9mail.feature.migration.qrcode.settings.UuidGenerator
import app.k9mail.feature.migration.qrcode.settings.XmlSettingWriter
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val qrCodeModule = module {
viewModel {
QrCodeScannerViewModel(
qrCodePayloadReader = get(),
qrCodeSettingsWriter = get(),
)
}
factory { QrCodePayloadAdapter() }
factory { QrCodePayloadParser(qrCodePayloadAdapter = get()) }
factory { QrCodePayloadValidator() }
factory {
QrCodePayloadMapper(
qrCodePayloadValidator = get(),
deletePolicyProvider = get(),
)
}
factory<UseCase.QrCodePayloadReader> {
QrCodePayloadReader(
parser = get(),
mapper = get(),
)
}
factory<UseCase.QrCodeSettingsWriter> {
QrCodeSettingsWriter(
context = get(),
xmlSettingWriter = get(),
)
}
factory<UuidGenerator> { DefaultUuidGenerator() }
factory {
XmlSettingWriter(
uuidGenerator = get(),
)
}
}

View file

@ -0,0 +1,24 @@
package app.k9mail.feature.migration.qrcode.domain
import android.net.Uri
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.Account
import androidx.camera.core.UseCase as CameraUseCase
internal interface QrCodeDomainContract {
interface UseCase {
fun interface CameraUseCasesProvider {
fun getUseCases(): List<CameraUseCase>
}
fun interface QrCodePayloadReader {
fun read(payload: String): AccountData?
}
fun interface QrCodeSettingsWriter {
fun write(accounts: List<Account>): Uri
}
}
}

View file

@ -0,0 +1,72 @@
package app.k9mail.feature.migration.qrcode.domain.entity
import net.thunderbird.core.android.account.DeletePolicy
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.net.Hostname
import net.thunderbird.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 deletePolicy: DeletePolicy,
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,
)
enum class IncomingServerProtocol {
Imap,
Pop3,
}
enum class OutgoingServerProtocol {
Smtp,
}
enum class ConnectionSecurity {
Plain,
AlwaysStartTls,
Tls,
}
enum class AuthenticationType {
None,
PasswordCleartext,
PasswordEncrypted,
TlsCertificate,
OAuth2,
}
}

View file

@ -0,0 +1,67 @@
package app.k9mail.feature.migration.qrcode.domain.usecase
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.LuminanceSource
import com.google.zxing.NotFoundException
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.multi.qrcode.QRCodeMultiReader
import net.thunderbird.core.logging.legacy.Log
/**
* An [ImageAnalysis.Analyzer] that scans for QR codes and notifies the listener for each one found.
*/
internal class QrCodeAnalyzer(
private val qrCodeListener: (String) -> Unit,
) : ImageAnalysis.Analyzer {
private val qrCodeReader = QRCodeMultiReader()
override fun analyze(image: ImageProxy) {
val plane = image.planes[0]
val buffer = plane.buffer
val data = ByteArray(buffer.remaining()).also { buffer.get(it) }
val height = image.height
val width = image.width
val dataWidth = width + ((plane.rowStride - plane.pixelStride * width) / plane.pixelStride)
val luminanceSource = PlanarYUVLuminanceSource(data, dataWidth, height, 0, 0, width, height, false)
val results = decodeSource(luminanceSource)
for (result in results) {
qrCodeListener(result)
}
image.close()
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
private fun decodeSource(source: LuminanceSource): List<String> {
return try {
val bitmap = createBinaryBitmap(source)
val results = qrCodeReader.decodeMultiple(bitmap, DECODER_HINTS)
results.map { it.text }
} catch (e: NotFoundException) {
emptyList()
} catch (e: Exception) {
Log.e(e, "Error while trying to read QR code")
emptyList()
} finally {
qrCodeReader.reset()
}
}
private fun createBinaryBitmap(source: LuminanceSource): BinaryBitmap {
return BinaryBitmap(HybridBinarizer(source))
}
companion object {
private val DECODER_HINTS = mapOf(
DecodeHintType.CHARACTER_SET to "UTF-8",
DecodeHintType.TRY_HARDER to true,
)
}
}

View file

@ -0,0 +1,46 @@
package app.k9mail.feature.migration.qrcode.domain.usecase
import android.util.Size
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.UseCase
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase.CameraUseCasesProvider
import java.util.concurrent.Executors
/**
* Returns a CameraX [ImageAnalysis] instance that will scan for QR codes and notify the provided listener.
*/
internal class QrCodeImageAnalysisProvider(
private val qrCodeListener: (String) -> Unit,
) : CameraUseCasesProvider {
override fun getUseCases(): List<UseCase> {
val qrCodeAnalyzer = QrCodeAnalyzer(qrCodeListener)
val resolutionSelector = ResolutionSelector.Builder()
.setResolutionStrategy(
ResolutionStrategy(
Size(TARGET_WIDTH, TARGET_HEIGHT),
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER,
),
)
.build()
val qrCodeImageAnalysis = ImageAnalysis.Builder()
.setImageQueueDepth(1)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setResolutionSelector(resolutionSelector)
.build()
.apply {
setAnalyzer(Executors.newSingleThreadExecutor(), qrCodeAnalyzer)
}
return listOf(qrCodeImageAnalysis)
}
companion object {
private const val TARGET_WIDTH = 1920
private const val TARGET_HEIGHT = 1080
}
}

View file

@ -0,0 +1,17 @@
package app.k9mail.feature.migration.qrcode.domain.usecase
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData
import app.k9mail.feature.migration.qrcode.payload.QrCodePayloadMapper
import app.k9mail.feature.migration.qrcode.payload.QrCodePayloadParser
internal class QrCodePayloadReader(
private val parser: QrCodePayloadParser,
private val mapper: QrCodePayloadMapper,
) : UseCase.QrCodePayloadReader {
override fun read(payload: String): AccountData? {
val parsedData = parser.parse(payload) ?: return null
return mapper.toAccountData(parsedData)
}
}

View file

@ -0,0 +1,44 @@
package app.k9mail.feature.migration.qrcode.domain.usecase
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.Account
import app.k9mail.feature.migration.qrcode.settings.XmlSettingWriter
import java.io.File
internal class QrCodeSettingsWriter(
private val context: Context,
private val xmlSettingWriter: XmlSettingWriter,
) : UseCase.QrCodeSettingsWriter {
override fun write(accounts: List<Account>): Uri {
val file = getSettingsFile()
writeSettingsToFile(file, accounts)
val authority = "${context.packageName}.qrcode.settings"
return FileProvider.getUriForFile(context, authority, file)
}
private fun writeSettingsToFile(file: File, accounts: List<Account>) {
file.outputStream().use { outputStream ->
xmlSettingWriter.writeSettings(outputStream, accounts)
}
}
private fun getSettingsFile(): File {
return File(getDirectory(), FILENAME)
}
private fun getDirectory(): File {
val directory = File(context.filesDir, DIRECTORY_NAME)
directory.mkdirs()
return directory
}
companion object {
private const val DIRECTORY_NAME = "qrcode"
private const val FILENAME = "settings.k9s"
}
}

View file

@ -0,0 +1,47 @@
@file:Suppress("MagicNumber")
package app.k9mail.feature.migration.qrcode.payload
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.AuthenticationType
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.ConnectionSecurity
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.IncomingServerProtocol
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.OutgoingServerProtocol
internal fun Int.toIncomingServerProtocol(): IncomingServerProtocol {
return when (this) {
0 -> IncomingServerProtocol.Imap
1 -> IncomingServerProtocol.Pop3
else -> throw IllegalArgumentException("Unsupported value: $this")
}
}
internal fun Int.toOutgoingServerProtocol(): OutgoingServerProtocol {
return when (this) {
0 -> OutgoingServerProtocol.Smtp
else -> throw IllegalArgumentException("Unsupported value: $this")
}
}
internal fun Int.toConnectionSecurity(): ConnectionSecurity {
return when (this) {
0 -> ConnectionSecurity.Plain
1 -> ConnectionSecurity.AlwaysStartTls // TryStartTls, but we treat it like AlwaysStartTls
2 -> ConnectionSecurity.AlwaysStartTls
3 -> ConnectionSecurity.Tls
else -> throw IllegalArgumentException("Unsupported value: $this")
}
}
@Suppress("ThrowsCount")
internal fun Int.toAuthenticationType(): AuthenticationType {
return when (this) {
0 -> AuthenticationType.None
1 -> AuthenticationType.PasswordCleartext
2 -> AuthenticationType.PasswordEncrypted
3 -> throw IllegalArgumentException("Unsupported authentication method: Gssapi")
4 -> throw IllegalArgumentException("Unsupported authentication method: Ntlm")
5 -> AuthenticationType.TlsCertificate
6 -> AuthenticationType.OAuth2
else -> throw IllegalArgumentException("Unsupported value: $this")
}
}

View file

@ -0,0 +1,44 @@
package app.k9mail.feature.migration.qrcode.payload
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.payload
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import net.thunderbird.core.logging.legacy.Log
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.
Log.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 authentiLogionType = 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,
authentiLogionType,
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 authentiLogionType = 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,
authentiLogionType,
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,111 @@
package app.k9mail.feature.migration.qrcode.payload
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.IncomingServerProtocol
import com.fsck.k9.account.DeletePolicyProvider
import net.thunderbird.core.android.account.DeletePolicy
import net.thunderbird.core.common.mail.Protocols
import net.thunderbird.core.common.mail.toUserEmailAddress
import net.thunderbird.core.common.net.toHostname
import net.thunderbird.core.common.net.toPort
internal class QrCodePayloadMapper(
private val qrCodePayloadValidator: QrCodePayloadValidator,
private val deletePolicyProvider: DeletePolicyProvider,
) {
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(),
)
val deletePolicy = getDeletePolicy(incomingServer.protocol)
return AccountData.Account(
accountName = accountName,
deletePolicy = deletePolicy,
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 = incomingServer.protocol.toIncomingServerProtocol(),
hostname = incomingServer.hostname.toHostname(),
port = incomingServer.port.toPort(),
connectionSecurity = incomingServer.connectionSecurity.toConnectionSecurity(),
authenticationType = incomingServer.authenticationType.toAuthenticationType(),
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 = outgoingServer.protocol.toOutgoingServerProtocol(),
hostname = outgoingServer.hostname.toHostname(),
port = outgoingServer.port.toPort(),
connectionSecurity = outgoingServer.connectionSecurity.toConnectionSecurity(),
authenticationType = outgoingServer.authenticationType.toAuthenticationType(),
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,
)
}
private fun getDeletePolicy(protocol: IncomingServerProtocol): DeletePolicy {
val accountType = when (protocol) {
IncomingServerProtocol.Imap -> Protocols.IMAP
IncomingServerProtocol.Pop3 -> Protocols.POP3
}
return deletePolicyProvider.getDeletePolicy(accountType)
}
}

View file

@ -0,0 +1,26 @@
package app.k9mail.feature.migration.qrcode.payload
import com.squareup.moshi.JsonDataException
import java.io.IOException
import net.thunderbird.core.logging.legacy.Log
internal class QrCodePayloadParser(
private val 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) {
Log.d(e, "Failed to parse JSON")
null
} catch (e: IOException) {
Log.d(e, "Unexpected IOException")
null
}
}
}

View file

@ -0,0 +1,138 @@
package app.k9mail.feature.migration.qrcode.payload
import net.thunderbird.core.common.mail.EmailAddressParserException
import net.thunderbird.core.common.mail.toUserEmailAddress
import net.thunderbird.core.common.net.toHostname
import net.thunderbird.core.common.net.toPort
import net.thunderbird.core.logging.legacy.Log
@Suppress("TooManyFunctions")
internal class QrCodePayloadValidator {
fun isValid(data: QrCodeData): Boolean {
if (data.version != 1) {
Log.d("Unsupported version: %s", data.version)
return false
}
return try {
validateData(data)
true
} catch (e: IllegalArgumentException) {
Log.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) {
protocol.toIncomingServerProtocol()
}
private fun validateOutgoingServerProtocol(protocol: Int) {
protocol.toOutgoingServerProtocol()
}
private fun validateHostname(hostname: String) {
hostname.toHostname()
}
private fun validatePort(port: Int) {
port.toPort()
}
private fun validateConnectionSecurity(value: Int) {
value.toConnectionSecurity()
}
private fun validateAuthenticationType(value: Int) {
value.toAuthenticationType()
}
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,9 @@
package app.k9mail.feature.migration.qrcode.settings
import java.util.UUID
internal class DefaultUuidGenerator : UuidGenerator {
override fun generateUuid(): String {
return UUID.randomUUID().toString()
}
}

View file

@ -0,0 +1,8 @@
package app.k9mail.feature.migration.qrcode.settings
import androidx.core.content.FileProvider
/**
* Used to expose account information read from QR codes via a content URI to the settings import code.
*/
class QrCodeSettingsFileProvider : FileProvider()

View file

@ -0,0 +1,37 @@
package app.k9mail.feature.migration.qrcode.settings
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.AuthenticationType
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.ConnectionSecurity
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.IncomingServerProtocol
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.OutgoingServerProtocol
internal fun IncomingServerProtocol.mapToSettingsString(): String {
return when (this) {
IncomingServerProtocol.Imap -> "IMAP"
IncomingServerProtocol.Pop3 -> "POP3"
}
}
internal fun OutgoingServerProtocol.mapToSettingsString(): String {
return when (this) {
OutgoingServerProtocol.Smtp -> "SMTP"
}
}
internal fun ConnectionSecurity.mapToSettingsString(): String {
return when (this) {
ConnectionSecurity.Plain -> "NONE"
ConnectionSecurity.AlwaysStartTls -> "STARTTLS_REQUIRED"
ConnectionSecurity.Tls -> "SSL_TLS_REQUIRED"
}
}
internal fun AuthenticationType.mapToSettingsString(): String {
return when (this) {
AuthenticationType.None -> "NONE"
AuthenticationType.PasswordCleartext -> "PLAIN"
AuthenticationType.PasswordEncrypted -> "CRAM_MD5"
AuthenticationType.TlsCertificate -> "EXTERNAL"
AuthenticationType.OAuth2 -> "XOAUTH2"
}
}

View file

@ -0,0 +1,5 @@
package app.k9mail.feature.migration.qrcode.settings
internal fun interface UuidGenerator {
fun generateUuid(): String
}

View file

@ -0,0 +1,186 @@
package app.k9mail.feature.migration.qrcode.settings
import android.util.Xml
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.Account
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.Identity
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.IncomingServer
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.OutgoingServer
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.OutgoingServerGroup
import java.io.OutputStream
import net.thunderbird.core.android.account.DeletePolicy
import org.xmlpull.v1.XmlSerializer
// TODO: This duplicates much of the code in SettingsExporter. Add an abstraction layer for the input data, so we can
// use a single XML writer class for exporting accounts and writing QR code payloads to a settings file.
@Suppress("TooManyFunctions")
internal class XmlSettingWriter(
private val uuidGenerator: UuidGenerator,
) {
fun writeSettings(outputStream: OutputStream, accounts: List<Account>) {
val serializer = Xml.newSerializer()
serializer.setOutput(outputStream, "UTF-8")
serializer.startDocument(null, true)
// Output with indentation
serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true)
serializer.writeRoot(accounts)
serializer.endDocument()
serializer.flush()
}
private fun XmlSerializer.writeRoot(accounts: List<Account>) {
startTag(null, ROOT_ELEMENT)
attribute(null, VERSION_ATTRIBUTE, SETTINGS_VERSION.toString())
attribute(null, FILE_FORMAT_ATTRIBUTE, FILE_FORMAT_VERSION.toString())
writeAccounts(accounts)
endTag(null, ROOT_ELEMENT)
}
private fun XmlSerializer.writeAccounts(accounts: List<Account>) {
startTag(null, ACCOUNTS_ELEMENT)
for (account in accounts) {
writeAccount(account)
}
endTag(null, ACCOUNTS_ELEMENT)
}
private fun XmlSerializer.writeAccount(account: Account) {
val accountUuid = uuidGenerator.generateUuid()
startTag(null, ACCOUNT_ELEMENT)
attribute(null, UUID_ATTRIBUTE, accountUuid)
writeElement(NAME_ELEMENT, account.accountName)
writeSettings(account)
writeIncomingServer(account.incomingServer)
writeOutgoingServers(account.outgoingServerGroups)
endTag(null, ACCOUNT_ELEMENT)
}
private fun XmlSerializer.writeSettings(account: Account) {
startTag(null, SETTINGS_ELEMENT)
writeKeyValue("deletePolicy", account.deletePolicy.toSettingsFileValue())
endTag(null, SETTINGS_ELEMENT)
}
private fun XmlSerializer.writeKeyValue(key: String, value: String?) {
startTag(null, VALUE_ELEMENT)
attribute(null, KEY_ATTRIBUTE, key)
if (value != null) {
text(value)
}
endTag(null, VALUE_ELEMENT)
}
private fun XmlSerializer.writeIncomingServer(incomingServer: IncomingServer) {
startTag(null, INCOMING_SERVER_ELEMENT)
attribute(null, TYPE_ATTRIBUTE, incomingServer.protocol.mapToSettingsString())
writeElement(HOST_ELEMENT, incomingServer.hostname.value)
writeElement(PORT_ELEMENT, incomingServer.port.value.toString())
writeElement(CONNECTION_SECURITY_ELEMENT, incomingServer.connectionSecurity.mapToSettingsString())
writeElement(AUTHENTICATION_TYPE_ELEMENT, incomingServer.authenticationType.mapToSettingsString())
writeElement(USERNAME_ELEMENT, incomingServer.username)
incomingServer.password?.let { password ->
writeElement(PASSWORD_ELEMENT, password)
}
endTag(null, INCOMING_SERVER_ELEMENT)
}
private fun XmlSerializer.writeOutgoingServers(outgoingServerGroups: List<OutgoingServerGroup>) {
val outgoingServerGroup = outgoingServerGroups.first()
writeOutgoingServer(outgoingServerGroup.outgoingServer)
writeIdentities(outgoingServerGroup.identities)
}
private fun XmlSerializer.writeOutgoingServer(outgoingServer: OutgoingServer) {
startTag(null, OUTGOING_SERVER_ELEMENT)
attribute(null, TYPE_ATTRIBUTE, outgoingServer.protocol.mapToSettingsString())
writeElement(HOST_ELEMENT, outgoingServer.hostname.value)
writeElement(PORT_ELEMENT, outgoingServer.port.value.toString())
writeElement(CONNECTION_SECURITY_ELEMENT, outgoingServer.connectionSecurity.mapToSettingsString())
writeElement(AUTHENTICATION_TYPE_ELEMENT, outgoingServer.authenticationType.mapToSettingsString())
writeElement(USERNAME_ELEMENT, outgoingServer.username)
outgoingServer.password?.let { password ->
writeElement(PASSWORD_ELEMENT, password)
}
endTag(null, OUTGOING_SERVER_ELEMENT)
}
private fun XmlSerializer.writeIdentities(identities: List<Identity>) {
startTag(null, IDENTITIES_ELEMENT)
for (identity in identities) {
writeIdentity(identity)
}
endTag(null, IDENTITIES_ELEMENT)
}
private fun XmlSerializer.writeIdentity(identity: Identity) {
startTag(null, IDENTITY_ELEMENT)
writeElement(NAME_ELEMENT, identity.displayName)
writeElement(EMAIL_ELEMENT, identity.emailAddress.address)
endTag(null, IDENTITY_ELEMENT)
}
private fun XmlSerializer.writeElement(elementName: String, value: String) {
startTag(null, elementName)
text(value)
endTag(null, elementName)
}
companion object {
// It's not necessary to keep this in sync with com.fsck.k9.preferences.Settings.VERSION because the import
// code supports importing old settings files.
private const val SETTINGS_VERSION = 99
private const val FILE_FORMAT_VERSION = 1
private const val ROOT_ELEMENT = "k9settings"
private const val VERSION_ATTRIBUTE = "version"
private const val FILE_FORMAT_ATTRIBUTE = "format"
private const val ACCOUNTS_ELEMENT = "accounts"
private const val ACCOUNT_ELEMENT = "account"
private const val UUID_ATTRIBUTE = "uuid"
private const val SETTINGS_ELEMENT = "settings"
private const val VALUE_ELEMENT = "value"
private const val KEY_ATTRIBUTE = "key"
private const val INCOMING_SERVER_ELEMENT = "incoming-server"
private const val OUTGOING_SERVER_ELEMENT = "outgoing-server"
private const val TYPE_ATTRIBUTE = "type"
private const val HOST_ELEMENT = "host"
private const val PORT_ELEMENT = "port"
private const val CONNECTION_SECURITY_ELEMENT = "connection-security"
private const val AUTHENTICATION_TYPE_ELEMENT = "authentication-type"
private const val USERNAME_ELEMENT = "username"
private const val PASSWORD_ELEMENT = "password"
private const val IDENTITIES_ELEMENT = "identities"
private const val IDENTITY_ELEMENT = "identity"
private const val NAME_ELEMENT = "name"
private const val EMAIL_ELEMENT = "email"
}
}
private fun DeletePolicy.toSettingsFileValue(): String {
return when (this) {
DeletePolicy.NEVER -> "NEVER"
DeletePolicy.SEVEN_DAYS -> error("Unsupported value")
DeletePolicy.ON_DELETE -> "DELETE"
DeletePolicy.MARK_AS_READ -> "MARK_AS_READ"
}
}

View file

@ -0,0 +1,72 @@
package app.k9mail.feature.migration.qrcode.ui
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.core.UseCase
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.lifecycle.awaitInstance
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner
import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase.CameraUseCasesProvider
import android.graphics.Color as AndroidColor
/**
* Displays a camera preview and includes the provided CameraX [UseCase]s.
*/
@Composable
internal fun CameraPreviewView(
cameraUseCasesProvider: CameraUseCasesProvider,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
TextTitleLarge(
text = "Camera preview",
textAlign = TextAlign.Center,
modifier = modifier.background(Color.DarkGray),
)
return
}
val lensFacing = CameraSelector.LENS_FACING_BACK
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
val previewView = remember {
PreviewView(context).apply {
setBackgroundColor(AndroidColor.TRANSPARENT)
scaleType = PreviewView.ScaleType.FIT_CENTER
}
}
LaunchedEffect(lensFacing) {
val cameraProvider = ProcessCameraProvider.awaitInstance(context)
val cameraxSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build()
val preview = Preview.Builder().build()
val cameraUseCases = cameraUseCasesProvider.getUseCases()
cameraProvider.unbindAll()
@Suppress("SpreadOperator")
cameraProvider.bindToLifecycle(lifecycleOwner, cameraxSelector, preview, *cameraUseCases.toTypedArray())
preview.setSurfaceProvider(previewView.surfaceProvider)
}
AndroidView(
factory = { previewView },
modifier = modifier,
)
}

View file

@ -0,0 +1,50 @@
package app.k9mail.feature.migration.qrcode.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveContent
import app.k9mail.core.ui.compose.theme2.MainTheme
import app.k9mail.feature.migration.qrcode.R
import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
@Composable
internal fun PermissionDeniedContent(
onGoToSettingsClick: () -> Unit,
) {
ResponsiveContent(
modifier = Modifier.testTagAsResourceId("PermissionDeniedContent"),
) { contentPadding ->
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxHeight()
.padding(MainTheme.spacings.double)
.padding(contentPadding),
) {
TextTitleLarge(text = stringResource(R.string.migration_qrcode_permission_denied_title))
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
TextBodyLarge(text = stringResource(R.string.migration_qrcode_permission_denied_message))
Spacer(modifier = Modifier.height(MainTheme.spacings.triple))
ButtonFilled(
text = stringResource(R.string.migration_qrcode_go_to_settings_button_text),
onClick = onGoToSettingsClick,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.testTagAsResourceId("GoToSettingsButton"),
)
}
}
}

View file

@ -0,0 +1,35 @@
package app.k9mail.feature.migration.qrcode.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.compose.setContent
import com.fsck.k9.ui.base.K9Activity
import net.thunderbird.core.ui.theme.api.FeatureThemeProvider
import org.koin.android.ext.android.inject
class QrCodeScannerActivity : K9Activity() {
private val themeProvider: FeatureThemeProvider by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
themeProvider.WithTheme(darkTheme = true) {
QrCodeScannerScreen(
finishWithResult = ::finishWithResult,
finish = ::finish,
)
}
}
}
private fun finishWithResult(result: Uri) {
val resultIntent = Intent().apply {
data = result
}
setResult(RESULT_OK, resultIntent)
finish()
}
}

View file

@ -0,0 +1,17 @@
package app.k9mail.feature.migration.qrcode.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContract
class QrCodeScannerActivityContract : ActivityResultContract<Unit, Uri?>() {
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(context, QrCodeScannerActivity::class.java)
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent?.data.takeIf { resultCode == Activity.RESULT_OK }
}
}

View file

@ -0,0 +1,42 @@
package app.k9mail.feature.migration.qrcode.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonOutlined
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
import app.k9mail.core.ui.compose.theme2.MainTheme
import app.k9mail.feature.migration.qrcode.R
import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
@Composable
internal fun QrCodeScannerBottomContent(
text: String,
onDoneClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
TextBodyLarge(
text = text,
modifier = Modifier
.testTagAsResourceId("ScannedStatus")
.padding(vertical = MainTheme.spacings.double)
.padding(start = MainTheme.spacings.double)
.weight(1f),
)
ButtonOutlined(
text = stringResource(R.string.migration_qrcode_done_button_text),
onClick = onDoneClick,
modifier = Modifier
.testTagAsResourceId("DoneButton")
.padding(MainTheme.spacings.double),
)
}
}

View file

@ -0,0 +1,52 @@
package app.k9mail.feature.migration.qrcode.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState
@Composable
internal fun QrCodeScannerContent(
cameraUseCasesProvider: UseCase.CameraUseCasesProvider,
state: State,
onEvent: (Event) -> Unit,
) {
Surface(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding(),
) {
when (state.cameraPermissionState) {
UiPermissionState.Unknown -> {
// Display empty surface while we're waiting for the camera permission request to return a result
}
UiPermissionState.Granted -> {
QrCodeScannerView(
cameraUseCasesProvider = cameraUseCasesProvider,
displayText = state.displayText,
onDoneClick = { onEvent(Event.DoneClicked) },
)
}
UiPermissionState.Denied -> {
PermissionDeniedContent(
onGoToSettingsClick = { onEvent(Event.GoToSettingsClicked) },
)
}
UiPermissionState.Waiting -> {
// We've launched Android's app info screen and are now waiting for the user to return to our app.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
// Once the user has returned to the app, notify the view model about it.
onEvent(Event.ReturnedFromAppInfoScreen)
}
}
}
}
}

View file

@ -0,0 +1,43 @@
package app.k9mail.feature.migration.qrcode.ui
import android.net.Uri
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase
internal interface QrCodeScannerContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect> {
val cameraUseCasesProvider: UseCase.CameraUseCasesProvider
}
data class State(
val cameraPermissionState: UiPermissionState = UiPermissionState.Unknown,
val displayText: DisplayText = DisplayText.HelpText,
)
sealed interface Event {
data object StartScreen : Event
data class CameraPermissionResult(val success: Boolean) : Event
data object GoToSettingsClicked : Event
data object ReturnedFromAppInfoScreen : Event
data object DoneClicked : Event
}
sealed interface Effect {
data object RequestCameraPermission : Effect
data object GoToAppInfoScreen : Effect
data class ReturnResult(val contentUri: Uri) : Effect
data object Cancel : Effect
}
enum class UiPermissionState {
Unknown,
Granted,
Denied,
Waiting,
}
sealed interface DisplayText {
data object HelpText : DisplayText
data class ProgressText(val scannedCount: Int, val totalCount: Int) : DisplayText
}
}

View file

@ -0,0 +1,67 @@
package app.k9mail.feature.migration.qrcode.ui
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event
import net.thunderbird.core.logging.legacy.Log
import org.koin.androidx.compose.koinViewModel
@Composable
internal fun QrCodeScannerScreen(
finishWithResult: (Uri) -> Unit,
finish: () -> Unit,
viewModel: QrCodeScannerContract.ViewModel = koinViewModel<QrCodeScannerViewModel>(),
) {
val cameraPermissionLauncher = rememberLauncherForActivityResult(RequestPermission()) { success ->
viewModel.event(Event.CameraPermissionResult(success))
}
val context = LocalContext.current
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
Effect.RequestCameraPermission -> cameraPermissionLauncher.requestCameraPermission()
Effect.GoToAppInfoScreen -> context.goToAppInfoScreen()
is Effect.ReturnResult -> finishWithResult(effect.contentUri)
Effect.Cancel -> finish()
}
}
LaunchedEffect(key1 = Unit) {
dispatch(Event.StartScreen)
}
QrCodeScannerContent(
cameraUseCasesProvider = viewModel.cameraUseCasesProvider,
state = state.value,
onEvent = dispatch,
)
}
private fun Context.goToAppInfoScreen() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Log.e(e, "Error opening Android's app settings")
}
}
private fun ManagedActivityResultLauncher<String, Boolean>.requestCameraPermission() {
launch(Manifest.permission.CAMERA)
}

View file

@ -0,0 +1,54 @@
package app.k9mail.feature.migration.qrcode.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.theme2.MainTheme
import app.k9mail.feature.migration.qrcode.R
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.DisplayText
import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
@Composable
internal fun QrCodeScannerView(
cameraUseCasesProvider: UseCase.CameraUseCasesProvider,
displayText: DisplayText,
onDoneClick: () -> Unit,
) {
Column(modifier = Modifier.testTagAsResourceId("QrCodeScannerView")) {
CameraPreviewView(
cameraUseCasesProvider = cameraUseCasesProvider,
modifier = Modifier
.fillMaxWidth()
.padding(
top = MainTheme.spacings.double,
start = MainTheme.spacings.double,
end = MainTheme.spacings.double,
)
.weight(1f),
)
QrCodeScannerBottomContent(
text = buildString(displayText),
onDoneClick = onDoneClick,
)
}
}
@Composable
@ReadOnlyComposable
private fun buildString(text: DisplayText): String {
return when (text) {
DisplayText.HelpText -> {
stringResource(R.string.migration_qrcode_scanning_instructions)
}
is DisplayText.ProgressText -> {
stringResource(R.string.migration_qrcode_scanning_progress, text.scannedCount, text.totalCount)
}
}
}

View file

@ -0,0 +1,162 @@
package app.k9mail.feature.migration.qrcode.ui
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.migration.qrcode.domain.QrCodeDomainContract.UseCase
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData
import app.k9mail.feature.migration.qrcode.domain.entity.AccountData.Account
import app.k9mail.feature.migration.qrcode.domain.usecase.QrCodeImageAnalysisProvider
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.DisplayText
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Effect
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.Event
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.State
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerContract.UiPermissionState
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.ByteString
import okio.ByteString.Companion.encodeUtf8
private typealias PayloadCallback = (String) -> Unit
private typealias CameraUseCaseProviderFactory = (PayloadCallback) -> UseCase.CameraUseCasesProvider
@Suppress("TooManyFunctions")
internal class QrCodeScannerViewModel(
private val qrCodePayloadReader: UseCase.QrCodePayloadReader,
private val qrCodeSettingsWriter: UseCase.QrCodeSettingsWriter,
createCameraUseCaseProvider: CameraUseCaseProviderFactory = { callback -> QrCodeImageAnalysisProvider(callback) },
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), QrCodeScannerContract.ViewModel {
private val supportedPayloadHashes = mutableSetOf<ByteString>()
private val unsupportedPayloadHashes = ArrayDeque<ByteString>()
private val accountDataList = mutableListOf<AccountData>()
private var scannedCount = 0
private var totalCount = 0
override val cameraUseCasesProvider: UseCase.CameraUseCasesProvider =
createCameraUseCaseProvider(::handleQrCodeScanned)
override fun event(event: Event) {
when (event) {
Event.StartScreen -> handleOneTimeEvent(event, ::handleStartScreen)
is Event.CameraPermissionResult -> handleCameraPermissionResult(event.success)
Event.GoToSettingsClicked -> handleGoToSettingsClicked()
Event.ReturnedFromAppInfoScreen -> handleReturnedFromAndroidSettings()
Event.DoneClicked -> handleDoneClicked()
}
}
private fun handleStartScreen() {
requestCameraPermission()
}
private fun handleCameraPermissionResult(success: Boolean) {
updateState {
it.copy(cameraPermissionState = if (success) UiPermissionState.Granted else UiPermissionState.Denied)
}
}
private fun handleGoToSettingsClicked() {
emitEffect(Effect.GoToAppInfoScreen)
viewModelScope.launch {
// Delay updating the UI to make sure Android's app settings screen is active and our activity is paused.
// We want to prevent QrCodeScannerContent triggering the permission dialog again before the user had a
// chance to enter Android's app settings screen.
delay(APP_SETTINGS_DELAY)
updateState {
it.copy(cameraPermissionState = UiPermissionState.Waiting)
}
}
}
private fun handleReturnedFromAndroidSettings() {
updateState {
it.copy(cameraPermissionState = UiPermissionState.Unknown)
}
requestCameraPermission()
}
private fun requestCameraPermission() {
emitEffect(Effect.RequestCameraPermission)
}
private fun handleQrCodeScanned(payload: String) {
val payloadHash = payload.sha1
if (payloadHash in supportedPayloadHashes || payloadHash in unsupportedPayloadHashes) {
// This QR code has already been scanned. Skip further processing.
return
}
val accountData = qrCodePayloadReader.read(payload)
if (accountData != null) {
handleSupportedPayload(accountData)
supportedPayloadHashes.add(payloadHash)
} else {
if (unsupportedPayloadHashes.size > MAX_NUMBER_OF_UNSUPPORTED_PAYLOADS) {
unsupportedPayloadHashes.removeFirst()
}
unsupportedPayloadHashes.add(payloadHash)
}
}
private fun handleSupportedPayload(accountData: AccountData) {
if (accountData.sequenceEnd == totalCount) {
accountDataList.add(accountData)
scannedCount = accountDataList.size
} else {
// Total QR code count doesn't match previous value. The user has probably started over.
supportedPayloadHashes.clear()
accountDataList.clear()
accountDataList.add(accountData)
scannedCount = 1
totalCount = accountData.sequenceEnd
}
updateState {
it.copy(displayText = DisplayText.ProgressText(scannedCount, totalCount))
}
if (accountDataList.size == accountData.sequenceEnd) {
startAccountImport()
}
}
private fun handleDoneClicked() {
startAccountImport()
}
private fun startAccountImport() {
val accounts = mergeAccounts(accountDataList)
if (accounts.isEmpty()) {
emitEffect(Effect.Cancel)
return
}
viewModelScope.launch {
val contentUri = withContext(backgroundDispatcher) {
qrCodeSettingsWriter.write(accounts)
}
emitEffect(Effect.ReturnResult(contentUri))
}
}
private fun mergeAccounts(accountDataList: List<AccountData>): List<Account> {
return accountDataList.flatMap { it.accounts }
}
private val String.sha1: ByteString
get() = encodeUtf8().sha1()
}
private const val APP_SETTINGS_DELAY = 100L
private const val MAX_NUMBER_OF_UNSUPPORTED_PAYLOADS = 5

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">الوصولُ إلى الكاميرا مطلوب</string>
<string name="migration_qrcode_permission_denied_message">انتقل إلى إعدادات أندرويد، وانقر على الأذونات، واسمح بالوصول إلى الكاميرا.</string>
<string name="migration_qrcode_go_to_settings_button_text">انتقل إلى الإعدادات</string>
<string name="migration_qrcode_scanning_progress">تم فحص %1$s من %2$s</string>
<string name="migration_qrcode_scanning_instructions">قم بمحاذاة رمز الاستجابة السريعة المقدم من ثندربرد سطح المكتب</string>
<string name="migration_qrcode_done_button_text">تم</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Патрэбны доступ да камеры</string>
<string name="migration_qrcode_permission_denied_message">Перайдзіце ў налады Android, абярыце \"Дазволы\" і дазвольце праграме доступ да камеры.</string>
<string name="migration_qrcode_go_to_settings_button_text">Перайсці ў налады</string>
<string name="migration_qrcode_done_button_text">Завершана</string>
<string name="migration_qrcode_scanning_progress">Прасканавана %1$s з %2$s</string>
<string name="migration_qrcode_scanning_instructions">Адскануйце QR-код з Thunderbird для камп\'ютараў</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Камерата се нуждае от достъп</string>
<string name="migration_qrcode_permission_denied_message">Отидете в настройките на Android, изберете \'разрешения\' и дайте достъп до камерата.</string>
<string name="migration_qrcode_go_to_settings_button_text">Към настройките</string>
<string name="migration_qrcode_scanning_progress">Сканиране на %1$s от %2$s</string>
<string name="migration_qrcode_scanning_instructions">Подравяване на QR кода, предоставен от Thunderbird Desktop</string>
<string name="migration_qrcode_done_button_text">Готово</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">থান্ডারবার্ড ডেস্কটপ থেকে কিউআর কোড ক্যামেরায় আনো</string>
<string name="migration_qrcode_scanning_progress">%2$s এর %1$sটি স্ক্যানকৃত</string>
<string name="migration_qrcode_permission_denied_message">অ্যান্ড্রয়েড পছন্দসমূহে যাও, অনুমতিসমূহ টিপ দাও এবং ক্যামেরার অনুমতি দাও।</string>
<string name="migration_qrcode_permission_denied_title">ক্যামেরা অনুমতি প্রয়োজন</string>
<string name="migration_qrcode_go_to_settings_button_text">পছন্দসমূহে যাও</string>
<string name="migration_qrcode_done_button_text">সম্পূর্ণ</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Ret eo kaout ar c\'hamera</string>
<string name="migration_qrcode_permission_denied_message">Kit da arventennoù Android, pouezit war an aotreoù, hag aotreit haeziñ gant ar c\'hamera.</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Es necessita accés a la càmera</string>
<string name="migration_qrcode_permission_denied_message">Aneu a la configuració d\'Android, premeu «permisos» i permeteu l\'accés a la càmera.</string>
<string name="migration_qrcode_go_to_settings_button_text">Vés a la configuració</string>
<string name="migration_qrcode_scanning_progress">S\'han escanejat %1$s de %2$s</string>
<string name="migration_qrcode_done_button_text">Fet</string>
<string name="migration_qrcode_scanning_instructions">Alineeu el codi QR proporcionat des de la versió d\'escriptori del Thunderbird</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">Piazzate u codice QR pruvistu da Thunderbird Desktop</string>
<string name="migration_qrcode_permission_denied_title">Laccessu à lapparechju-fotò hè richiestu</string>
<string name="migration_qrcode_permission_denied_message">Accidite à i parametri dAndroid, picchichjate Permessi, è permittite laccessu à lapparechju-fotò.</string>
<string name="migration_qrcode_go_to_settings_button_text">Andà à i parametri</string>
<string name="migration_qrcode_scanning_progress">%1$s codici analizati nantà %2$s</string>
<string name="migration_qrcode_done_button_text">Fattu</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Vyžadován přístup k fotoaparátu</string>
<string name="migration_qrcode_permission_denied_message">Přejděte do nastavení systému Android, otevřete oprávnění a povolte přístup k fotoaparátu.</string>
<string name="migration_qrcode_go_to_settings_button_text">Přejít do nastavení</string>
<string name="migration_qrcode_scanning_progress">Naskenováno %1$s z %2$s</string>
<string name="migration_qrcode_done_button_text">Hotovo</string>
<string name="migration_qrcode_scanning_instructions">Namiřte fotoaparát na QR kód poskytnutý Thunderbirdem na počítači</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Mae angen mynediad at y camera</string>
<string name="migration_qrcode_permission_denied_message">Ewch i osodiadau Android, pwyso ar Permissions a chaniatáu mynediad i\'r camera.</string>
<string name="migration_qrcode_go_to_settings_button_text">Ewch i\'r gosodiadau</string>
<string name="migration_qrcode_scanning_progress">Wedi sganio %1$s o %2$s</string>
<string name="migration_qrcode_scanning_instructions">Aliniwch y cod QR ddarparwyd gan Thunderbird y Bwrdd Gwaith</string>
<string name="migration_qrcode_done_button_text">Gorffen</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Kamerazugriff erforderlich</string>
<string name="migration_qrcode_permission_denied_message">Gehe zu den Android-Einstellungen, tippe auf Berechtigungen und erlaube den Zugriff auf die Kamera.</string>
<string name="migration_qrcode_go_to_settings_button_text">Zu den Einstellungen gehen</string>
<string name="migration_qrcode_scanning_progress">%1$s von %2$s gescannt</string>
<string name="migration_qrcode_done_button_text">Fertig</string>
<string name="migration_qrcode_scanning_instructions">QR-Code mit dem von Thunderbird Desktop abgleichen</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">Ευθυγραμμίστε τον κωδικό QR που παρέχεται από το Thunderbird για υπολογιστές</string>
<string name="migration_qrcode_permission_denied_title">Απαιτείται πρόσβαση στην κάμερα</string>
<string name="migration_qrcode_permission_denied_message">Μεταβείτε στις ρυθμίσεις του Android, επιλέξτε «Δικαιώματα» και επιτρέψτε την πρόσβαση στην κάμερα.</string>
<string name="migration_qrcode_go_to_settings_button_text">Μετάβαση στις ρυθμίσεις</string>
<string name="migration_qrcode_scanning_progress">Σαρώθηκαν %1$s από %2$s</string>
<string name="migration_qrcode_done_button_text">Τέλος</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Camera access needed</string>
<string name="migration_qrcode_permission_denied_message">Go to Android settings, tap permissions, and allow access to the camera.</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_go_to_settings_button_text">Iri al agordoj</string>
<string name="migration_qrcode_permission_denied_title">Alireblo al fotilo estas bezonata</string>
<string name="migration_qrcode_permission_denied_message">Iru al Androidon agordoj, tuŝetu permesojn, kaj donu alireblon al la fotilo.</string>
<string name="migration_qrcode_scanning_progress">%1$s de %2$s skanitaj</string>
<string name="migration_qrcode_done_button_text">Farita</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Se necesita acceso a la cámara</string>
<string name="migration_qrcode_permission_denied_message">Ve a los ajustes de Android, toca en «permisos» y concede el acceso a la cámara.</string>
<string name="migration_qrcode_go_to_settings_button_text">Ir a los ajustes</string>
<string name="migration_qrcode_scanning_progress">Se han escaneado %1$s de %2$s</string>
<string name="migration_qrcode_done_button_text">Hecho</string>
<string name="migration_qrcode_scanning_instructions">Captura el código QR que aparece en la versión de escritorio de Thunderbird</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Vajalik on ligipääs kaamerale</string>
<string name="migration_qrcode_permission_denied_message">Ava Androidi seadistused, klõpsi õiguste seadistamisel ja luba ligipääs kaamerale.</string>
<string name="migration_qrcode_go_to_settings_button_text">Ava seadistused</string>
<string name="migration_qrcode_scanning_progress">Skaneeritud %1$s/%2$s</string>
<string name="migration_qrcode_done_button_text">Valmis</string>
<string name="migration_qrcode_scanning_instructions">Joonda Thunderbird töölauaversiooni pakutud QR-kood</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Kamerarako sarbidea behar da</string>
<string name="migration_qrcode_permission_denied_message">Joan Android ezarpenetara, ikutu baimenak, eta baimendu kamerarako sarbidea.</string>
<string name="migration_qrcode_go_to_settings_button_text">Joan ezarpenetara</string>
<string name="migration_qrcode_scanning_progress">%1$s(e)tik %2$s eskaneatuta</string>
<string name="migration_qrcode_done_button_text">Egina</string>
<string name="migration_qrcode_scanning_instructions">Atzitu mahaigaineko Thunderbird-ek emandako QR kodea</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">نیازمند دسترسی دوربین</string>
<string name="migration_qrcode_permission_denied_message">رفتن به تنظیمات اندروید، زدن روی اجازه‌ها و اجازه به دسترسی به دوربین.</string>
<string name="migration_qrcode_go_to_settings_button_text">رفتن به تنظیمات</string>
<string name="migration_qrcode_scanning_progress">%1$s از %2$s پوییده</string>
<string name="migration_qrcode_done_button_text">انجام شد</string>
<string name="migration_qrcode_scanning_instructions">کد QR ارائه شده از Thunderbird Desktop را دنبال کنید</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">Aseta suoraan Thunderbirdin tietokonesovelluksen antama QR-koodi</string>
<string name="migration_qrcode_permission_denied_title">Kameran käyttöoikeus vaaditaan</string>
<string name="migration_qrcode_permission_denied_message">Siirry Androidin asetuksiin, napauta oikeudet ja salli kameran käyttöoikeus.</string>
<string name="migration_qrcode_go_to_settings_button_text">Siirry asetuksiin</string>
<string name="migration_qrcode_scanning_progress">Skannattu %1$s/%2$s</string>
<string name="migration_qrcode_done_button_text">Valmis</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Laccès à lappareil photo est nécessaire</string>
<string name="migration_qrcode_permission_denied_message">Accédez aux paramètres dAndroid, touchez les autorisations et accordez laccès à lappareil photo.</string>
<string name="migration_qrcode_go_to_settings_button_text">Accéder aux paramètres</string>
<string name="migration_qrcode_scanning_progress">%1$s codes sur %2$s a été lus</string>
<string name="migration_qrcode_done_button_text">Terminé</string>
<string name="migration_qrcode_scanning_instructions">Alignez le code QR fourni par Thunderbird pour ordinateur</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Kameratagong fereaske</string>
<string name="migration_qrcode_permission_denied_message">Gean nei Android-ynstellingen, tik op toestemmingen, en stean tagong ta de kamera ta.</string>
<string name="migration_qrcode_go_to_settings_button_text">Gea nei ynstellingen</string>
<string name="migration_qrcode_scanning_progress">Scand %1$s fan %2$s</string>
<string name="migration_qrcode_done_button_text">Dien</string>
<string name="migration_qrcode_scanning_instructions">De QR-koade fan Thunderbird desktop útlynje</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Rochtain ceamara ag teastáil</string>
<string name="migration_qrcode_scanning_progress">Scanadh %1$s de %2$s</string>
<string name="migration_qrcode_done_button_text">Déanta</string>
<string name="migration_qrcode_permission_denied_message">Téigh chuig socruithe Android, tapáil ceadanna, agus ceadaigh rochtain ar an gceamara.</string>
<string name="migration_qrcode_go_to_settings_button_text">Téigh go dtí socruithe</string>
<string name="migration_qrcode_scanning_instructions">Ailínigh an cód QR a cuireadh ar fáil ó Thunderbird Desktop</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">Co-thaobhaich leis a chòd QR a fhuair thu o Thunderbird Desktop</string>
<string name="migration_qrcode_permission_denied_title">Tha feum air cead-inntrigidh dhan chamara</string>
<string name="migration_qrcode_permission_denied_message">Tadhail air roghainnean Android, thoir gnogag air na ceadan is thoir seachad cead-inntrigidh dhan chamara.</string>
<string name="migration_qrcode_go_to_settings_button_text">Tadhail air na roghainnean</string>
<string name="migration_qrcode_scanning_progress">Chaidh %1$s à %2$s a sganadh</string>
<string name="migration_qrcode_done_button_text">Dèanta</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Kamerához való hozzáférés szükséges</string>
<string name="migration_qrcode_done_button_text">Kész</string>
<string name="migration_qrcode_permission_denied_message">Menjen az Android beállításaihoz, koppintson az engedélyekre, és engedélyezze a kamerához való hozzáférést.</string>
<string name="migration_qrcode_go_to_settings_button_text">Menjen a beállításokhoz</string>
<string name="migration_qrcode_scanning_progress">%1$s %2$s beolvasása</string>
<string name="migration_qrcode_scanning_instructions">Igazítsa be a Thunderbird Desktopból kapott QR-kódot</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Akses kamera diperlukan</string>
<string name="migration_qrcode_permission_denied_message">Buka pengaturan Android, ketuk izin, dan izinkan akses kamera.</string>
<string name="migration_qrcode_scanning_instructions">Sejajarkan kode Respons Cepat (QR) yang disediakan Thunderbird Desktop</string>
<string name="migration_qrcode_go_to_settings_button_text">Buka pengaturan</string>
<string name="migration_qrcode_scanning_progress">Memindai %1$s dari %2$s</string>
<string name="migration_qrcode_done_button_text">Selesai</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_go_to_settings_button_text">Farðu í stillingar</string>
<string name="migration_qrcode_scanning_instructions">Stilltu af QR-kóðann frá borðtölvuútgáfu Thunderbird</string>
<string name="migration_qrcode_permission_denied_title">Þarfnast aðgangs að myndavél</string>
<string name="migration_qrcode_permission_denied_message">Farðu í stillingar Android, ýttu á heimildir og leyfðu aðgang að myndavélinni.</string>
<string name="migration_qrcode_scanning_progress">Skannað %1$s af %2$s</string>
<string name="migration_qrcode_done_button_text">Búið</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">Inquadra il QRcode fornito da Thunderbird Desktop</string>
<string name="migration_qrcode_permission_denied_title">Accesso alla fotocamera necessario</string>
<string name="migration_qrcode_permission_denied_message">Vai in impostazioni, permessi e abilita l\'accesso alla fotocamera</string>
<string name="migration_qrcode_scanning_progress">Scansionato %1$s di %2$s</string>
<string name="migration_qrcode_done_button_text">Fatto</string>
<string name="migration_qrcode_go_to_settings_button_text">Vai in impostazioni</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">יישר את קוד ה־QR שסופק מת\'אנדרבירד למחשב</string>
<string name="migration_qrcode_permission_denied_title">דרושה גישה למצלמה</string>
<string name="migration_qrcode_permission_denied_message">לך להגדרות אנדרואיד, לחץ על הרשאות, ותן גישה למצלמה.</string>
<string name="migration_qrcode_go_to_settings_button_text">לך להגדרות</string>
<string name="migration_qrcode_scanning_progress">נסרקו %1$s מתוך %2$s</string>
<string name="migration_qrcode_done_button_text">סיום</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_go_to_settings_button_text">設定へ移動</string>
<string name="migration_qrcode_done_button_text">完了</string>
<string name="migration_qrcode_permission_denied_title">カメラへのアクセス権が必要です</string>
<string name="migration_qrcode_permission_denied_message">Android の設定で許可をタップして、カメラへのアクセスを許可してください。</string>
<string name="migration_qrcode_scanning_progress">%1$s / %2$s 個をスキャンしました</string>
<string name="migration_qrcode_scanning_instructions">デスクトップ版 Thunderbird に表示される QR コードに合わせてください</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_go_to_settings_button_text">Баптауларға өту</string>
<string name="migration_qrcode_done_button_text">Дайын</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Būtina fotoaparato prieiga</string>
<string name="migration_qrcode_permission_denied_message">Eikite į „Android“ nustatymus, palieskite leidimus ir suteikite prieigą prie fotoaparato.</string>
<string name="migration_qrcode_go_to_settings_button_text">Eiti į nustatymus</string>
<string name="migration_qrcode_scanning_progress">Nuskenuota %1$s iš %2$s</string>
<string name="migration_qrcode_done_button_text">Atlikta</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">Skann QR-koden i Thunderbird på PC</string>
<string name="migration_qrcode_permission_denied_title">Kameratilgang kreves</string>
<string name="migration_qrcode_scanning_progress">Skannet %1$s av %2$s</string>
<string name="migration_qrcode_go_to_settings_button_text">Gå til innstillinger</string>
<string name="migration_qrcode_done_button_text">Ferdig</string>
<string name="migration_qrcode_permission_denied_message">Gå til Android-innstillingene, trykk på tillatelser, og gi tilgang til kamera.</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Cameratoegang vereist</string>
<string name="migration_qrcode_permission_denied_message">Ga naar Android-instellingen, tik op toestemmingen, en sta toegang tot de camera toe.</string>
<string name="migration_qrcode_go_to_settings_button_text">Ga naar instellingen</string>
<string name="migration_qrcode_scanning_progress">%1$s van %2$s gescand</string>
<string name="migration_qrcode_done_button_text">Gereed</string>
<string name="migration_qrcode_scanning_instructions">De QR-code van Thunderbird desktop uitlijnen</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Kamera-tilgjenge er påkravd</string>
<string name="migration_qrcode_permission_denied_message">Gå til Android-innstillingar, trykk på løyve, og tillat tilgjenge til kameraet.</string>
<string name="migration_qrcode_go_to_settings_button_text">Gå til innstillingar</string>
<string name="migration_qrcode_scanning_progress">Skanna %1$s av %2$s</string>
<string name="migration_qrcode_done_button_text">Ferdig</string>
<string name="migration_qrcode_scanning_instructions">Rett kameraet mot QR-koden gjeve frå Thunderbird Desktop</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_go_to_settings_button_text">Przejdź do ustawień</string>
<string name="migration_qrcode_scanning_progress">Przeskanowano %1$s z %2$s</string>
<string name="migration_qrcode_done_button_text">Gotowe</string>
<string name="migration_qrcode_permission_denied_title">Wymagany dostęp do aparatu</string>
<string name="migration_qrcode_permission_denied_message">Przejdź do ustawień systemu Android, stuknij uprawnienia i zezwól na dostęp do aparatu.</string>
<string name="migration_qrcode_scanning_instructions">Dopasuj kod QR dostarczony z komputerowej wersji Thunderbirda</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Precisa de acesso à câmera</string>
<string name="migration_qrcode_permission_denied_message">Abra as configurações do Android, toque em permissões e conceda acesso à câmera.</string>
<string name="migration_qrcode_go_to_settings_button_text">Ir para configurações</string>
<string name="migration_qrcode_scanning_progress">Capturado %1$s de %2$s</string>
<string name="migration_qrcode_done_button_text">Pronto</string>
<string name="migration_qrcode_scanning_instructions">Alinhe com o código QR fornecido pelo Thunderbird de computador</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_go_to_settings_button_text">Ir para as configurações</string>
<string name="migration_qrcode_permission_denied_title">Precisa de acesso à câmara</string>
<string name="migration_qrcode_permission_denied_message">Abra as configurações do Android, toque em permissões e conceda acesso à câmara.</string>
<string name="migration_qrcode_scanning_progress">Capturado %1$s de %2$s</string>
<string name="migration_qrcode_done_button_text">Pronto</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Accesul la cameră este necesar</string>
<string name="migration_qrcode_go_to_settings_button_text">Accesați «Setări»</string>
<string name="migration_qrcode_permission_denied_message">Mergeți la «Setări» Android, selectați «Permisiuni» și permiteți accesul la cameră.</string>
<string name="migration_qrcode_scanning_progress">Scanat %1$s din %2$s</string>
<string name="migration_qrcode_done_button_text">Gata</string>
<string name="migration_qrcode_scanning_instructions">Îndreptați camera către codul QR furnizat de către versiunea Thunderbird de calculator pentru a începe</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_go_to_settings_button_text">Перейти в настройки</string>
<string name="migration_qrcode_done_button_text">Готово</string>
<string name="migration_qrcode_permission_denied_title">Необходим доступ к камере</string>
<string name="migration_qrcode_permission_denied_message">Перейдите в настройки Android, выберите \"Разрешения\" и предоставьте приложению доступ к камере.</string>
<string name="migration_qrcode_scanning_progress">Считано %1$s из %2$s</string>
<string name="migration_qrcode_scanning_instructions">Считайте QR-код, созданный Thunderbird для ПК</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Vyžadovaný prístup ku kamere</string>
<string name="migration_qrcode_permission_denied_message">Otvorte nastavenia Androidu, kliknite na oprávnenia a povoľte prístup ku kamere.</string>
<string name="migration_qrcode_go_to_settings_button_text">Ísť na nastavenia</string>
<string name="migration_qrcode_scanning_progress">Zoskenovaných %1$s z %2$s</string>
<string name="migration_qrcode_done_button_text">Dokončené</string>
<string name="migration_qrcode_scanning_instructions">Zarovnajte QR kód poskytnutý počítačovou verziou Thunderbird</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">Poravnajte QR-kodo iz Thunderbirda za namizje.</string>
<string name="migration_qrcode_permission_denied_title">Potreben je dostop do fotoaparata.</string>
<string name="migration_qrcode_permission_denied_message">Pojdite v nastavitve Androida, se dotaknite Dovoljenja in omogočite dostop do fotoaparata.</string>
<string name="migration_qrcode_go_to_settings_button_text">Pojdite v nastavitve.</string>
<string name="migration_qrcode_scanning_progress">Prebrano %1$s od %2$s.</string>
<string name="migration_qrcode_done_button_text">Končaj</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Lypset leje përdorimi kamere</string>
<string name="migration_qrcode_permission_denied_message">Kaloni te rregullime Android-i, prekni mbi lejet dhe lejoni përdorim të kamerës.</string>
<string name="migration_qrcode_go_to_settings_button_text">Kaloni te rregullimet</string>
<string name="migration_qrcode_scanning_progress">Është skanuar %1$s nga %2$s</string>
<string name="migration_qrcode_done_button_text">U bë</string>
<string name="migration_qrcode_scanning_instructions">Vendosni brenda kuadratit kodin QR të dhënë nga Thunderbird-i për Desktop</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_scanning_instructions">Поравнајте QR кôд који сте добили из Thunderbird-а за рачунар</string>
<string name="migration_qrcode_permission_denied_title">Потребан је приступ камери</string>
<string name="migration_qrcode_permission_denied_message">Идите у Android подешавања, додирните дозволе и дозволите приступ камери.</string>
<string name="migration_qrcode_go_to_settings_button_text">Иди у подешавања</string>
<string name="migration_qrcode_scanning_progress">Скенирано %1$s од %2$s</string>
<string name="migration_qrcode_done_button_text">Готово</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_go_to_settings_button_text">Gå till inställningar</string>
<string name="migration_qrcode_scanning_progress">Skannade %1$s av %2$s</string>
<string name="migration_qrcode_done_button_text">Klar</string>
<string name="migration_qrcode_permission_denied_title">Kameraåtkomst behövs</string>
<string name="migration_qrcode_permission_denied_message">Gå till Android-inställningar, tryck på behörigheter och tillåt åtkomst till kameran.</string>
<string name="migration_qrcode_scanning_instructions">Rikta in QR-koden som tillhandahålls från Thunderbird för datorer</string>
</resources>

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_message">ஆண்ட்ராய்டு அமைப்புகளுக்குச் சென்று, அனுமதிகளைத் தட்டவும், கேமராவை அணுக அனுமதிக்கவும்.</string>
<string name="migration_qrcode_go_to_settings_button_text">அமைப்புகளுக்குச் செல்லுங்கள்</string>
<string name="migration_qrcode_scanning_progress">%2$s இன் %1$s ஐ ச்கேன் செய்தது</string>
<string name="migration_qrcode_done_button_text">முடிந்தது</string>
<string name="migration_qrcode_scanning_instructions">தண்டர்பேர்ட் டெச்க்டாப்பில் இருந்து வழங்கப்பட்ட QR குறியீட்டை சீரமைக்கவும்</string>
<string name="migration_qrcode_permission_denied_title">கேமரா அணுகல் தேவை</string>
</resources>

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_permission_denied_title">Kamera erişimi gerekli</string>
<string name="migration_qrcode_permission_denied_message">Android ayarlarına gidin, izinlere dokunun ve kamera erişimine izin verin.</string>
<string name="migration_qrcode_go_to_settings_button_text">Ayarlara git</string>
<string name="migration_qrcode_scanning_progress">%1$s / %2$s tarandı</string>
<string name="migration_qrcode_done_button_text">Tamam</string>
<string name="migration_qrcode_scanning_instructions">Bilgisayarınızdaki Thunderbird\'ün verdiği QR kodunu okutun</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="migration_qrcode_go_to_settings_button_text">Перейти до Налаштувань</string>
<string name="migration_qrcode_scanning_progress">Скановано %1$s з %2$s</string>
<string name="migration_qrcode_done_button_text">Готово</string>
<string name="migration_qrcode_permission_denied_title">Потрібен доступ до камери</string>
<string name="migration_qrcode_permission_denied_message">Перейдіть до налаштувань Android, торкніться Дозволи та дозвольте доступ до камери.</string>
<string name="migration_qrcode_scanning_instructions">Вирівняйте QR-код, наданий Thunderbird для комп\'ютера</string>
</resources>

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