Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
7
feature/migration/launcher/api/build.gradle.kts
Normal file
7
feature/migration/launcher/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.feature.migration.launcher.api"
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package app.k9mail.feature.migration.launcher.api
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
|
||||
interface MigrationManager {
|
||||
/**
|
||||
* Returns whether the features to import accounts by scanning QR codes and importing directly from another
|
||||
* app are included in the app.
|
||||
*/
|
||||
fun isFeatureIncluded(): Boolean
|
||||
|
||||
/**
|
||||
* Returns an [ActivityResultContract] that can be used to start the QR code scanner. In case of success a
|
||||
* content: URI to the account settings in the XML format supported by `SettingsImporter` is returned.
|
||||
*/
|
||||
fun getQrCodeActivityResultContract(): ActivityResultContract<Unit, Uri?>
|
||||
}
|
||||
11
feature/migration/launcher/noop/build.gradle.kts
Normal file
11
feature/migration/launcher/noop/build.gradle.kts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.feature.migration.launcher.noop"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.feature.migration.launcher.api)
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package app.k9mail.feature.migration.launcher
|
||||
|
||||
import app.k9mail.feature.migration.launcher.api.MigrationManager
|
||||
import app.k9mail.feature.migration.launcher.noop.NoOpMigrationManager
|
||||
import org.koin.dsl.module
|
||||
|
||||
val featureMigrationModule = module {
|
||||
single<MigrationManager> { NoOpMigrationManager() }
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package app.k9mail.feature.migration.launcher.noop
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import app.k9mail.feature.migration.launcher.api.MigrationManager
|
||||
|
||||
internal class NoOpMigrationManager : MigrationManager {
|
||||
override fun isFeatureIncluded() = false
|
||||
|
||||
override fun getQrCodeActivityResultContract(): ActivityResultContract<Unit, Uri?> {
|
||||
return ThrowingActivityResultContract()
|
||||
}
|
||||
}
|
||||
|
||||
private class ThrowingActivityResultContract : ActivityResultContract<Unit, Uri?>() {
|
||||
override fun createIntent(context: Context, input: Unit): Intent {
|
||||
error("Feature not enabled")
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
error("Feature not enabled")
|
||||
}
|
||||
}
|
||||
12
feature/migration/launcher/thunderbird/build.gradle.kts
Normal file
12
feature/migration/launcher/thunderbird/build.gradle.kts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.feature.migration.launcher.thunderbird"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.feature.migration.launcher.api)
|
||||
implementation(projects.feature.migration.qrcode)
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package app.k9mail.feature.migration.launcher
|
||||
|
||||
import app.k9mail.feature.migration.launcher.api.MigrationManager
|
||||
import app.k9mail.feature.migration.launcher.thunderbird.TbMigrationManager
|
||||
import app.k9mail.feature.migration.qrcode.qrCodeModule
|
||||
import org.koin.dsl.module
|
||||
|
||||
val featureMigrationModule = module {
|
||||
includes(qrCodeModule)
|
||||
|
||||
single<MigrationManager> { TbMigrationManager() }
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package app.k9mail.feature.migration.launcher.thunderbird
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import app.k9mail.feature.migration.launcher.api.MigrationManager
|
||||
import app.k9mail.feature.migration.qrcode.ui.QrCodeScannerActivityContract
|
||||
|
||||
internal class TbMigrationManager : MigrationManager {
|
||||
override fun isFeatureIncluded() = true
|
||||
|
||||
override fun getQrCodeActivityResultContract(): ActivityResultContract<Unit, Uri?> {
|
||||
return QrCodeScannerActivityContract()
|
||||
}
|
||||
}
|
||||
13
feature/migration/provider/build.gradle.kts
Normal file
13
feature/migration/provider/build.gradle.kts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.legacy.core)
|
||||
|
||||
implementation(libs.okio)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.feature.migration.provider"
|
||||
}
|
||||
17
feature/migration/provider/src/main/AndroidManifest.xml
Normal file
17
feature/migration/provider/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
|
||||
<provider
|
||||
android:name="app.k9mail.feature.migration.provider.SettingsProvider"
|
||||
android:authorities="${applicationId}.settings"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedContentProvider"
|
||||
/>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
package app.k9mail.feature.migration.provider
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.Signature
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.fsck.k9.helper.MimeTypeUtil
|
||||
import com.fsck.k9.helper.mapToSet
|
||||
import com.fsck.k9.preferences.SettingsExporter
|
||||
import kotlin.concurrent.thread
|
||||
import net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
/**
|
||||
* A `ContentProvider` that makes settings available to another app.
|
||||
*
|
||||
* This can be used when migrating from one app to another, e.g. from K-9 Mail to Thunderbird for Android.
|
||||
* Only apps on the allowlist (see [isTrustedCaller()][SettingsProvider.isTrustedCaller]) are allowed access to the
|
||||
* settings (including passwords).
|
||||
*/
|
||||
class SettingsProvider : ContentProvider(), KoinComponent {
|
||||
private val accountManager: AccountManager by inject()
|
||||
private val settingsExporter: SettingsExporter by inject()
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String {
|
||||
return MimeTypeUtil.K9_SETTINGS_MIME_TYPE
|
||||
}
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||
if (!isTrustedCaller()) {
|
||||
Log.d("Caller must be in the allowlist")
|
||||
return null
|
||||
}
|
||||
|
||||
val (readFileDescriptor, writeFileDescriptor) = ParcelFileDescriptor.createPipe()
|
||||
|
||||
thread {
|
||||
val accountUuids = accountManager.getAccounts().mapToSet { it.uuid }
|
||||
ParcelFileDescriptor.AutoCloseOutputStream(writeFileDescriptor).use { outputStream ->
|
||||
settingsExporter.exportPreferences(
|
||||
outputStream,
|
||||
includeGlobals = true,
|
||||
accountUuids,
|
||||
includePasswords = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return readFileDescriptor
|
||||
}
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor? {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
/**
|
||||
* A trusted caller must appear exactly in our allowlist map - its package map must map to a known signature.
|
||||
* In case of any deviation (multiple signers, certificate rotation), assume that the caller isn't trusted.
|
||||
*/
|
||||
// Based on https://searchfox.org/mozilla-esr68/source/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/AuthStateProvider.java
|
||||
@Suppress("ReturnCount")
|
||||
private fun isTrustedCaller(): Boolean {
|
||||
val packageManager = context!!.packageManager
|
||||
|
||||
// Signatures can be easily obtained locally. For an APK in question, unzip it and run:
|
||||
// keytool -printcert -file META-INF/SIGNATURE.RSA
|
||||
// SHA256 certificate fingerprint is what's listed below.
|
||||
|
||||
// We will only service query requests from callers that exactly match our allowlist.
|
||||
// Allowlist is local to this function to avoid exposing it to the world more than necessary.
|
||||
val packageAllowlist = listOf(
|
||||
// K-9 Mail (our signing key)
|
||||
"com.fsck.k9" to "55c8a523b97335f5bf60dfe8a9f3e1dde744516d9357e80a925b7b22e4f55524",
|
||||
// K-9 Mail (F-Droid)
|
||||
"com.fsck.k9" to "c430665e3662253b2078dcda350c2c6ce44d915a3d8a147b63ced619bb9e8576",
|
||||
// Thunderbird for Android (release)
|
||||
"net.thunderbird.android" to "b6524779b3dbbc5ac17a5ac271ddb29dcfbf723578c238e03c3c217811356dd1",
|
||||
// Thunderbird for Android (beta)
|
||||
"net.thunderbird.android.beta" to "056bfafb450249502fd9226228704c2529e1b822da06760d47a85c9557741fbd",
|
||||
// Thunderbird for Android (daily)
|
||||
"net.thunderbird.android.daily" to "c48d74a75c45cd362b0ff2c1e9756f541dee816163e3684a9fd59f6c3ae949b2",
|
||||
)
|
||||
|
||||
val callerPackage = callingPackage ?: return false
|
||||
val expectedHashes = packageAllowlist
|
||||
.asSequence()
|
||||
.filter { it.first == callerPackage }
|
||||
.map { it.second }
|
||||
.toList()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?: return false
|
||||
|
||||
val callerSignature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
getSignaturePostApi28(packageManager, callerPackage)
|
||||
} else {
|
||||
getSignaturePreApi28(packageManager, callerPackage)
|
||||
}
|
||||
|
||||
if (callerSignature == null) {
|
||||
Log.v("Couldn't retrieve caller signature")
|
||||
return false
|
||||
}
|
||||
|
||||
val callerSignatureHash = callerSignature.toByteArray().toByteString().sha256().hex()
|
||||
val result = callerSignatureHash in expectedHashes
|
||||
if (result) {
|
||||
Log.d("Caller %s signature fingerprint matches %s", callerPackage, callerSignatureHash)
|
||||
} else {
|
||||
Log.d("Failed! Signature mismatch for calling package %s (%s)", callerPackage, callerSignatureHash)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun getSignaturePreApi28(packageManager: PackageManager, callerPackage: String): Signature? {
|
||||
// For older APIs, we use the deprecated `signatures` field, which isn't aware of certificate rotation.
|
||||
val packageInfo = packageManager.getPackageInfo(callerPackage, PackageManager.GET_SIGNATURES)
|
||||
|
||||
// We don't expect our callers to have multiple signers, so we don't service such requests.
|
||||
val signatures = packageInfo.signatures
|
||||
if (signatures == null || signatures.size != 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
// In case of signature rotation, this will report the oldest used certificate, pretending that the signature
|
||||
// rotation never took place. We can only rely on our allowlist being up-to-date in this case.
|
||||
return signatures.firstOrNull()
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.P)
|
||||
@Suppress("ReturnCount")
|
||||
private fun getSignaturePostApi28(packageManager: PackageManager, callerPackage: String): Signature? {
|
||||
// For API28+, we can perform some extra checks.
|
||||
val packageInfo = packageManager.getPackageInfo(callerPackage, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||
|
||||
// We don't expect our callers to have multiple signers, so we don't service such requests.
|
||||
val signingInfo = packageInfo.signingInfo
|
||||
if (signingInfo == null || signingInfo.hasMultipleSigners()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// We currently don't support servicing requests from callers that performed certificate rotation.
|
||||
if (signingInfo.hasPastSigningCertificates()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return signingInfo.signingCertificateHistory?.firstOrNull()
|
||||
}
|
||||
}
|
||||
28
feature/migration/qrcode/build.gradle.kts
Normal file
28
feature/migration/qrcode/build.gradle.kts
Normal 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)
|
||||
}
|
||||
478
feature/migration/qrcode/qr-code-format.md
Normal file
478
feature/migration/qrcode/qr-code-format.md
Normal 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`
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
feature/migration/qrcode/src/main/AndroidManifest.xml
Normal file
29
feature/migration/qrcode/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package app.k9mail.feature.migration.qrcode.settings
|
||||
|
||||
internal fun interface UuidGenerator {
|
||||
fun generateUuid(): String
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">L’accessu à l’apparechju-fotò hè richiestu</string>
|
||||
<string name="migration_qrcode_permission_denied_message">Accidite à i parametri d’Android, picchichjate Permessi, è permittite l’accessu à l’apparechju-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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="migration_qrcode_permission_denied_title">L’accès à l’appareil photo est nécessaire</string>
|
||||
<string name="migration_qrcode_permission_denied_message">Accédez aux paramètres d’Android, touchez les autorisations et accordez l’accès à l’appareil 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
|
|
@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue