diff options
Diffstat (limited to 'mobile/android/android-components/components/service/sync-logins/src')
7 files changed, 617 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/sync-logins/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..816719811c --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt new file mode 100644 index 0000000000..96baa7d646 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.sync.logins + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.async +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry +import mozilla.components.concept.storage.LoginValidationDelegate +import mozilla.components.concept.storage.LoginValidationDelegate.Result +import mozilla.components.concept.storage.LoginsStorage +import mozilla.components.support.base.log.logger.Logger + +/** + * A delegate that will check against [storage] to see if a given Login can be persisted, and return + * information about why it can or cannot. + */ +class DefaultLoginValidationDelegate( + private val storage: Lazy<LoginsStorage>, + private val scope: CoroutineScope = CoroutineScope(IO), + private val crashReporting: CrashReporting? = null, +) : LoginValidationDelegate { + private val logger = Logger("DefaultAddonUpdater") + + /** + * Compares a [Login] to a passed in list of potential dupes [Login] or queries underlying + * storage for potential dupes list of [Login] to determine if it should be updated or created. + */ + override fun shouldUpdateOrCreateAsync(entry: LoginEntry): Deferred<Result> { + return scope.async { + val foundLogin = try { + storage.value.findLoginToUpdate(entry) + } catch (e: LoginsApiException) { + logger.warn("Failure in shouldUpdateOrCreateAsync: $e") + crashReporting?.submitCaughtException(e) + null + } + if (foundLogin == null) Result.CanBeCreated else Result.CanBeUpdated(foundLogin) + } + } +} diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt new file mode 100644 index 0000000000..892930e28d --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.sync.logins + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry +import mozilla.components.concept.storage.LoginStorageDelegate +import mozilla.components.concept.storage.LoginsStorage + +/** + * [LoginStorageDelegate] implementation. + * + * An abstraction that handles the persistence and retrieval of [LoginEntry]s so that Gecko doesn't + * have to. + * + * In order to use this class, attach it to the active [GeckoRuntime] as its `loginStorageDelegate`. + * It is not designed to work with other engines. + * + * This class is part of a complex flow integrating Gecko and Application Services code, which is + * described here: + * + * - GV finds something on a page that it believes could be autofilled + * - GV calls [onLoginFetch] + * - We retrieve all [Login]s with matching domains (if any) from [loginStorage] + * - We return these [Login]s to GV + * - GV autofills one of the returned [Login]s into the page + * - GV calls [onLoginUsed] to let us know which [Login] was used + * - User submits their credentials + * - GV detects something that looks like a login submission + * - ([GeckoLoginStorageDelegate] is not involved with this step) + * `SaveLoginDialogFragment` is shown to the user, who decides whether or not + * to save the [LoginEntry] and gives them a chance to manually adjust the + * username/password fields. + * - `SaveLoginDialogFragment` uses `DefaultLoginValidationDelegate` to determine + * what the result of the operation will be: saving a new login, + * updating an existing login, or filling in a blank username. + * - If the user accepts: GV calls [onLoginSave] with the [LoginEntry] + */ +class GeckoLoginStorageDelegate( + private val loginStorage: Lazy<LoginsStorage>, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), +) : LoginStorageDelegate { + + override fun onLoginUsed(login: Login) { + scope.launch { + loginStorage.value.touch(login.guid) + } + } + + override fun onLoginFetch(domain: String): Deferred<List<Login>> { + return scope.async { + loginStorage.value.getByBaseDomain(domain) + } + } + + @Synchronized + override fun onLoginSave(login: LoginEntry) { + scope.launch { + loginStorage.value.addOrUpdate(login) + } + } +} diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt new file mode 100644 index 0000000000..a16b45cac2 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.sync.logins + +import android.content.Context +import android.content.SharedPreferences +import mozilla.appservices.logins.KeyRegenerationEventReason +import mozilla.appservices.logins.checkCanary +import mozilla.appservices.logins.createCanary +import mozilla.appservices.logins.decryptFields +import mozilla.appservices.logins.recordKeyRegenerationEvent +import mozilla.components.concept.storage.EncryptedLogin +import mozilla.components.concept.storage.KeyGenerationReason +import mozilla.components.concept.storage.KeyManager +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.ManagedKey +import mozilla.components.lib.dataprotect.SecureAbove22Preferences + +/** + * A class that knows how to encrypt & decrypt strings, backed by application-services' logins lib. + * Used for protecting usernames/passwords at rest. + * + * This class manages creation and storage of the encryption key. + * It also keeps track of abnormal events, such as managed key going missing or getting corrupted. + * + * @param context [Context] used for obtaining [SharedPreferences] for managing internal prefs. + * @param securePrefs A [SecureAbove22Preferences] instance used for storing the managed key. + */ +class LoginsCrypto( + private val context: Context, + private val securePrefs: SecureAbove22Preferences, + private val storage: SyncableLoginsStorage, +) : KeyManager() { + private val plaintextPrefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } + + override suspend fun recoverFromKeyLoss(reason: KeyGenerationReason.RecoveryNeeded) { + val telemetryEventReason = when (reason) { + is KeyGenerationReason.RecoveryNeeded.Lost -> KeyRegenerationEventReason.Lost + is KeyGenerationReason.RecoveryNeeded.Corrupt -> KeyRegenerationEventReason.Corrupt + is KeyGenerationReason.RecoveryNeeded.AbnormalState -> KeyRegenerationEventReason.Other + } + recordKeyRegenerationEvent(telemetryEventReason) + storage.conn.getStorage().wipeLocal() + } + + override fun getStoredCanary(): String? { + return plaintextPrefs.getString(CANARY_PHRASE_CIPHERTEXT_KEY, null) + } + + override fun getStoredKey(): String? { + return securePrefs.getString(LOGINS_KEY) + } + + override fun storeKeyAndCanary(key: String) { + // To consider: should this be a non-destructive operation, just in case? + // e.g. if we thought we lost the key, but actually did not, that would let us recover data later on. + // otherwise, if we mess up and override a perfectly good key, the data is gone for good. + securePrefs.putString(LOGINS_KEY, key) + // To detect key corruption or absence, use the newly generated key to encrypt a known string. + // See isKeyValid below. + plaintextPrefs + .edit() + .putString(CANARY_PHRASE_CIPHERTEXT_KEY, createCanary(CANARY_PHRASE_PLAINTEXT, key)) + .apply() + } + + override fun createKey(): String { + return mozilla.appservices.logins.createKey() + } + + override fun isKeyRecoveryNeeded(rawKey: String, canary: String): KeyGenerationReason.RecoveryNeeded? { + return try { + if (checkCanary(canary, CANARY_PHRASE_PLAINTEXT, rawKey)) { + null + } else { + // A bad key should trigger a IncorrectKey, but check this branch just in case. + KeyGenerationReason.RecoveryNeeded.Corrupt + } + } catch (e: IncorrectKey) { + KeyGenerationReason.RecoveryNeeded.Corrupt + } + } + + /** + * Decrypts ciphertext fields within [login], producing a plaintext [Login]. + */ + suspend fun decryptLogin(login: EncryptedLogin): Login { + return decryptLogin(login, getOrGenerateKey()) + } + + /** + * Decrypts ciphertext fields within [login], producing a plaintext [Login]. + * + * This version inputs a ManagedKey. Use this for operations that + * decrypt multiple logins to avoid constructing the key multiple times. + */ + fun decryptLogin(login: EncryptedLogin, key: ManagedKey): Login { + val secFields = decryptFields(login.secFields, key.key) + // Note: The autofill code catches errors on decryptFields and returns + // null, but it's not as easy to recover in this case since the code + // almost certainly going to need to a [Login], so we just throw in + // that case. Decryption errors shouldn't be happen as long as the + // canary checking code below is working correctly + + return Login( + guid = login.guid, + origin = login.origin, + username = secFields.username, + password = secFields.password, + formActionOrigin = login.formActionOrigin, + httpRealm = login.httpRealm, + usernameField = login.usernameField, + passwordField = login.passwordField, + timesUsed = login.timesUsed, + timeCreated = login.timeCreated, + timeLastUsed = login.timeLastUsed, + timePasswordChanged = login.timePasswordChanged, + ) + } + + companion object { + const val PREFS_NAME = "loginsCrypto" + const val LOGINS_KEY = "loginsKey" + const val CANARY_PHRASE_CIPHERTEXT_KEY = "canaryPhrase" + const val CANARY_PHRASE_PLAINTEXT = "a string for checking validity of the key" + } +} diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt new file mode 100644 index 0000000000..8eafd25aab --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt @@ -0,0 +1,280 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.sync.logins + +import android.content.Context +import androidx.annotation.GuardedBy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import mozilla.appservices.logins.DatabaseLoginsStorage +import mozilla.components.concept.storage.EncryptedLogin +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry +import mozilla.components.concept.storage.LoginsStorage +import mozilla.components.concept.sync.SyncableStore +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.utils.logElapsedTime +import java.io.Closeable + +// Current database +const val DB_NAME = "logins2.sqlite" + +// Name of our preferences file +const val PREFS_NAME = "logins" + +// SQLCipher migration status. +// - 0 / unset: We haven't done the SQLCipher migration +// - 1: We performed v1 of the SQLCipher migration +// +// We no longer migrate SQLCipher, if the user hasn't +// successfully migrated - we delete the DB +const val SQL_CIPHER_MIGRATION = "sql-cipher-migration" + +/** + * The telemetry ping from a successful sync + */ +typealias SyncTelemetryPing = mozilla.appservices.sync15.SyncTelemetryPing + +/** + * The base class of all errors emitted by logins storage. + * + * Concrete instances of this class are thrown for operations which are + * not expected to be handled in a meaningful way by the application. + * + * For example, caught Rust panics, SQL errors, failure to generate secure + * random numbers, etc. are all examples of things which will result in a + * concrete `LoginsApiException`. + */ +typealias LoginsApiException = mozilla.appservices.logins.LoginsApiException + +/** + * This indicates that the authentication information (e.g. the [SyncUnlockInfo]) + * provided to [AsyncLoginsStorage.sync] is invalid. This often indicates that it's + * stale and should be refreshed with FxA (however, care should be taken not to + * get into a loop refreshing this information). + */ +typealias SyncAuthInvalidException = mozilla.appservices.logins.LoginsApiException.SyncAuthInvalid + +/** + * This is thrown if `update()` is performed with a record whose GUID + * does not exist. + */ +typealias NoSuchRecordException = mozilla.appservices.logins.LoginsApiException.NoSuchRecord + +/** + * This is thrown on attempts to insert or update a record so that it + * is no longer valid, where "invalid" is defined as such: + * + * - A record with a blank `password` is invalid. + * - A record with a blank `hostname` is invalid. + * - A record that doesn't have a `formSubmitURL` nor a `httpRealm` is invalid. + * - A record that has both a `formSubmitURL` and a `httpRealm` is invalid. + */ +typealias InvalidRecordException = mozilla.appservices.logins.LoginsApiException.InvalidRecord + +/** + * Error encrypting/decrypting logins data + */ +typealias IncorrectKey = mozilla.appservices.logins.LoginsApiException.IncorrectKey + +/** + * Implements [LoginsStorage] and [SyncableStore] using the application-services logins library. + * + * Synchronization is handled via the SyncManager by calling [registerWithSyncManager] + */ +class SyncableLoginsStorage( + private val context: Context, + private val securePrefs: Lazy<SecureAbove22Preferences>, +) : LoginsStorage, SyncableStore, AutoCloseable { + private val plaintextPrefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } + private val logger = Logger("SyncableLoginsStorage") + private val coroutineContext by lazy { Dispatchers.IO } + val crypto by lazy { LoginsCrypto(context, securePrefs.value, this) } + + internal val conn by lazy { + // We do not migrate SQLCipher anymore, we should delete it if it exists + runBlocking(coroutineContext) { + deleteSQLCipherDBIfNeeded() + } + LoginStorageConnection.init(dbPath = context.getDatabasePath(DB_NAME).absolutePath) + LoginStorageConnection + } + + /** + * "Warms up" this storage layer by establishing the database connection. + */ + suspend fun warmUp() = withContext(coroutineContext) { + logElapsedTime(logger, "Warming up storage") { conn } + Unit + } + + /** + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun wipeLocal() = withContext(coroutineContext) { + conn.getStorage().wipeLocal() + } + + /** + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun delete(guid: String): Boolean = withContext(coroutineContext) { + conn.getStorage().delete(guid) + } + + /** + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun get(guid: String): Login? = withContext(coroutineContext) { + conn.getStorage().get(guid)?.toEncryptedLogin()?.let { crypto.decryptLogin(it) } + } + + /** + * @throws [NoSuchRecordException] if the login does not exist. + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(NoSuchRecordException::class, LoginsApiException::class) + override suspend fun touch(guid: String) = withContext(coroutineContext) { + conn.getStorage().touch(guid) + } + + /** + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun list(): List<Login> = withContext(coroutineContext) { + val key = crypto.getOrGenerateKey() + conn.getStorage().list().map { crypto.decryptLogin(it.toEncryptedLogin(), key) } + } + + /** + * @throws [InvalidRecordException] if the record is invalid. + * @throws [IncorrectKey] if the encryption key can't decrypt the login + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(IncorrectKey::class, InvalidRecordException::class, LoginsApiException::class) + override suspend fun add(entry: LoginEntry) = withContext(coroutineContext) { + conn.getStorage().add(entry.toLoginEntry(), crypto.getOrGenerateKey().key).toEncryptedLogin() + } + + /** + * @throws [NoSuchRecordException] if the login does not exist. + * @throws [IncorrectKey] if the encryption key can't decrypt the login + * @throws [InvalidRecordException] if the update would create an invalid record. + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws( + IncorrectKey::class, + NoSuchRecordException::class, + InvalidRecordException::class, + LoginsApiException::class, + ) + override suspend fun update(guid: String, entry: LoginEntry) = withContext(coroutineContext) { + conn.getStorage().update(guid, entry.toLoginEntry(), crypto.getOrGenerateKey().key).toEncryptedLogin() + } + + /** + * @throws [InvalidRecordException] if the update would create an invalid record. + * @throws [IncorrectKey] if the encryption key can't decrypt the login + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(IncorrectKey::class, InvalidRecordException::class, LoginsApiException::class) + override suspend fun addOrUpdate(entry: LoginEntry) = withContext(coroutineContext) { + conn.getStorage().addOrUpdate(entry.toLoginEntry(), crypto.getOrGenerateKey().key).toEncryptedLogin() + } + + override fun registerWithSyncManager() { + conn.getStorage().registerWithSyncManager() + } + + /** + * @throws [LoginsApiException] On unexpected errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun getByBaseDomain(origin: String): List<Login> = withContext(coroutineContext) { + val key = crypto.getOrGenerateKey() + conn.getStorage().getByBaseDomain(origin).map { crypto.decryptLogin(it.toEncryptedLogin(), key) } + } + + /** + * @throws [IncorrectKey] if the encryption key can't decrypt the login + * @throws [LoginsApiException] On unexpected errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun findLoginToUpdate(entry: LoginEntry): Login? = withContext(coroutineContext) { + conn.getStorage().findLoginToUpdate(entry.toLoginEntry(), crypto.getOrGenerateKey().key)?.toLogin() + } + + /** + * @throws [IncorrectKey] if the encryption key can't decrypt the login + */ + override suspend fun decryptLogin(login: EncryptedLogin) = crypto.decryptLogin(login) + + override fun close() { + coroutineContext.cancel() + conn.close() + } + + /* + * We not longer migrate SQLCipher, we delete the DB, key and any + * associated prefs + */ + private suspend fun deleteSQLCipherDBIfNeeded() { + // Older database that was encrypted using SQLCipher + val dbNameSqlCipher = "logins.sqlite" + // Prefs key that we stored the old SQLCipher encryption key + val encryptionKeySqlCipher = "passwords" + + val version = plaintextPrefs.getInt(SQL_CIPHER_MIGRATION, 0) + if (version == 0) { + // Older database that was encrypted using SQLCipher, majority of clients won't + // have anything to actually delete + securePrefs.value.remove(encryptionKeySqlCipher) + val file = context.getDatabasePath(dbNameSqlCipher) + file.delete() + } + plaintextPrefs.edit().putInt(SQL_CIPHER_MIGRATION, 1).apply() + } +} + +/** + * A singleton wrapping a [LoginsStorage] connection. + */ +internal object LoginStorageConnection : Closeable { + @GuardedBy("this") + private var storage: DatabaseLoginsStorage? = null + + internal fun init(dbPath: String = DB_NAME) = synchronized(this) { + if (storage == null) { + storage = DatabaseLoginsStorage(dbPath) + } + storage + } + + internal fun getStorage(): DatabaseLoginsStorage = synchronized(this) { + check(storage != null) { "must call init first" } + return storage!! + } + + override fun close() = synchronized(this) { + check(storage != null) { "must call init first" } + storage!!.close() + storage = null + } +} diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt new file mode 100644 index 0000000000..7141610251 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.sync.logins + +import mozilla.components.concept.storage.EncryptedLogin +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry + +// Convert between application-services data classes and the ones in concept.storage. + +/** + * Convert A-S EncryptedLogin into A-C [EncryptedLogin]. + */ +fun mozilla.appservices.logins.EncryptedLogin.toEncryptedLogin() = EncryptedLogin( + guid = record.id, + origin = fields.origin, + formActionOrigin = fields.formActionOrigin, + httpRealm = fields.httpRealm, + usernameField = fields.usernameField, + passwordField = fields.passwordField, + timesUsed = record.timesUsed, + timeCreated = record.timeCreated, + timeLastUsed = record.timeLastUsed, + timePasswordChanged = record.timePasswordChanged, + secFields = secFields, +) + +/** + * Convert A-S Login into A-C [Login]. + */ +fun mozilla.appservices.logins.Login.toLogin() = Login( + guid = record.id, + origin = fields.origin, + username = secFields.username, + password = secFields.password, + formActionOrigin = fields.formActionOrigin, + httpRealm = fields.httpRealm, + usernameField = fields.usernameField, + passwordField = fields.passwordField, + timesUsed = record.timesUsed, + timeCreated = record.timeCreated, + timeLastUsed = record.timeLastUsed, + timePasswordChanged = record.timePasswordChanged, +) + +/** + * Convert A-C [LoginEntry] into A-S LoginEntry. + */ +fun LoginEntry.toLoginEntry() = mozilla.appservices.logins.LoginEntry( + fields = mozilla.appservices.logins.LoginFields( + origin = origin, + formActionOrigin = formActionOrigin, + httpRealm = httpRealm, + usernameField = usernameField, + passwordField = passwordField, + ), + secFields = mozilla.appservices.logins.SecureLoginFields( + username = username, + password = password, + ), +) + +/** + * Convert A-C [Login] into A-S Login. + */ +fun Login.toLogin() = mozilla.appservices.logins.Login( + record = mozilla.appservices.logins.RecordFields( + id = guid, + timesUsed = timesUsed, + timeCreated = timeCreated, + timeLastUsed = timeLastUsed, + timePasswordChanged = timePasswordChanged, + ), + fields = mozilla.appservices.logins.LoginFields( + origin = origin, + formActionOrigin = formActionOrigin, + httpRealm = httpRealm, + usernameField = usernameField, + passwordField = passwordField, + ), + secFields = mozilla.appservices.logins.SecureLoginFields( + username = username, + password = password, + ), +) diff --git a/mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 |