Commit b3dfb223 authored by Grisha Kruglov's avatar Grisha Kruglov
Browse files

Part 3: Rust SyncManager integration


Co-authored-by: default avatarArturo Mejia <arturomejiamarmol@gmail.com>
parent eff8affc
......@@ -122,6 +122,7 @@ object Dependencies {
const val mozilla_sync_logins = "org.mozilla.appservices:logins:${Versions.mozilla_appservices}"
const val mozilla_places = "org.mozilla.appservices:places:${Versions.mozilla_appservices}"
const val mozilla_sync_manager = "org.mozilla.appservices:syncmanager:${Versions.mozilla_appservices}"
const val mozilla_push = "org.mozilla.appservices:push:${Versions.mozilla_appservices}"
......
......@@ -63,7 +63,6 @@ dependencies {
testImplementation Dependencies.mozilla_places
testImplementation Dependencies.testing_mockwebserver
testImplementation Dependencies.androidx_work_testing
testImplementation Dependencies.mozilla_full_megazord_forUnitTests
}
......
......@@ -25,6 +25,13 @@ const val DB_NAME = "places.sqlite"
* Writer is always the same, as guaranteed by [PlacesApi].
*/
internal interface Connection : Closeable {
/**
* This should be removed. See: https://github.com/mozilla/application-services/issues/1877
*
* @return raw internal handle that could be used for referencing underlying [PlacesApi]. Use it with SyncManager.
*/
fun getHandle(): Long
fun reader(): PlacesReaderConnection
fun writer(): PlacesWriterConnection
......@@ -57,6 +64,11 @@ internal object RustPlacesConnection : Connection {
cachedReader = api!!.openReader()
}
override fun getHandle(): Long {
check(api != null) { "must call init first" }
return api!!.getHandle()
}
override fun reader(): PlacesReaderConnection = synchronized(this) {
check(cachedReader != null) { "must call init first" }
return cachedReader!!
......
......@@ -184,4 +184,13 @@ open class PlacesBookmarksStorage(context: Context) : PlacesStorage(context), Bo
}
}
}
/**
* This should be removed. See: https://github.com/mozilla/application-services/issues/1877
*
* @return raw internal handle that could be used for referencing underlying [PlacesApi]. Use it with SyncManager.
*/
override fun getHandle(): Long {
return places.getHandle()
}
}
......@@ -6,6 +6,7 @@ package mozilla.components.browser.storage.sync
import android.content.Context
import kotlinx.coroutines.withContext
import mozilla.appservices.places.PlacesApi
import mozilla.appservices.places.VisitObservation
import mozilla.components.concept.storage.HistoryAutocompleteResult
import mozilla.components.concept.storage.HistoryStorage
......@@ -177,4 +178,13 @@ open class PlacesHistoryStorage(context: Context) : PlacesStorage(context), Hist
}
}
}
/**
* This should be removed. See: https://github.com/mozilla/application-services/issues/1877
*
* @return raw internal handle that could be used for referencing underlying [PlacesApi]. Use it with SyncManager.
*/
override fun getHandle(): Long {
return places.getHandle()
}
}
......@@ -501,6 +501,11 @@ class PlacesHistoryStorageTest {
fail()
}
override fun getHandle(): Long {
fail()
return 0L
}
override fun close() {
fail()
}
......@@ -533,6 +538,11 @@ class PlacesHistoryStorageTest {
override fun syncBookmarks(syncInfo: SyncAuthInfo) {}
override fun getHandle(): Long {
fail()
return 0L
}
override fun close() {
fail()
}
......@@ -565,6 +575,11 @@ class PlacesHistoryStorageTest {
fail()
}
override fun getHandle(): Long {
fail()
return 0L
}
override fun close() {
fail()
}
......@@ -601,6 +616,11 @@ class PlacesHistoryStorageTest {
fail()
}
override fun getHandle(): Long {
fail()
return 0L
}
override fun close() {
fail()
}
......
......@@ -4,6 +4,7 @@
@file:SuppressWarnings("TooManyFunctions")
package mozilla.components.concept.sync
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.Deferred
import mozilla.components.support.base.observer.Observable
......@@ -51,9 +52,10 @@ interface DeviceConstellation : Observable<DeviceEventsObserver> {
/**
* Set name of the current device.
* @param name New device name.
* @param context An application context, used for updating internal caches.
* @return A [Deferred] that will be resolved with a success flag once operation is complete.
*/
fun setDeviceNameAsync(name: String): Deferred<Boolean>
fun setDeviceNameAsync(name: String, context: Context): Deferred<Boolean>
/**
* Set a [DevicePushSubscription] for the current device.
......
......@@ -53,6 +53,13 @@ interface SyncableStore {
* @return [SyncStatus] A status object describing how sync went.
*/
suspend fun sync(authInfo: SyncAuthInfo): SyncStatus
/**
* This should be removed. See: https://github.com/mozilla/application-services/issues/1877
*
* @return raw internal handle that could be used for referencing underlying [PlacesApi]. Use it with SyncManager.
*/
fun getHandle(): Long
}
/**
......
......@@ -17,6 +17,7 @@ import mozilla.components.concept.sync.AuthType
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.service.fxa.SyncEngine
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.sync.toSyncEngines
import mozilla.components.service.fxa.toAuthType
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.base.log.logger.Logger
......@@ -248,6 +249,14 @@ class FxaWebChannelFeature(
return status
}
private fun JSONArray.toStringList(): List<String> {
val result = mutableListOf<String>()
for (i in 0 until this.length()) {
this.optString(i, null)?.let { result.add(it) }
}
return result
}
/**
* Handles the [COMMAND_OAUTH_LOGIN] event from the web-channel.
*/
......@@ -255,12 +264,14 @@ class FxaWebChannelFeature(
val authType: AuthType
val code: String
val state: String
val declinedEngines: List<String>?
try {
val data = payload.getJSONObject("data")
authType = data.getString("action").toAuthType()
code = data.getString("code")
state = data.getString("state")
declinedEngines = data.optJSONArray("declinedSyncEngines")?.toStringList()
} catch (e: JSONException) {
// TODO ideally, this should log to Sentry.
logger.error("Error while processing WebChannel oauth-login command", e)
......@@ -270,7 +281,8 @@ class FxaWebChannelFeature(
accountManager.finishAuthenticationAsync(FxaAuthData(
authType = authType,
code = code,
state = state
state = state,
declinedEngines = declinedEngines?.toSyncEngines()
))
return null
......
......@@ -159,7 +159,7 @@ class FxaWebChannelFeatureTest {
val messageHandler = argumentCaptor<MessageHandler>()
val responseToTheWebChannel = argumentCaptor<JSONObject>()
val port = mock<Port>()
val expectedEngines = setOf(SyncEngine.HISTORY)
val expectedEngines = setOf(SyncEngine.History)
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
......@@ -201,7 +201,7 @@ class FxaWebChannelFeatureTest {
val messageHandler = argumentCaptor<MessageHandler>()
val responseToTheWebChannel = argumentCaptor<JSONObject>()
val port = mock<Port>()
val expectedEngines = setOf(SyncEngine.HISTORY)
val expectedEngines = setOf(SyncEngine.History)
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
......@@ -250,7 +250,7 @@ class FxaWebChannelFeatureTest {
val messageHandler = argumentCaptor<MessageHandler>()
val responseToTheWebChannel = argumentCaptor<JSONObject>()
val port = mock<Port>()
val expectedEngines = setOf(SyncEngine.HISTORY, SyncEngine.BOOKMARKS, SyncEngine.PASSWORDS)
val expectedEngines = setOf(SyncEngine.History, SyncEngine.Bookmarks, SyncEngine.Passwords)
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
......@@ -301,7 +301,7 @@ class FxaWebChannelFeatureTest {
val messageHandler = argumentCaptor<MessageHandler>()
val responseToTheWebChannel = argumentCaptor<JSONObject>()
val port = mock<Port>()
val expectedEngines = setOf(SyncEngine.HISTORY, SyncEngine.BOOKMARKS, SyncEngine.PASSWORDS)
val expectedEngines = setOf(SyncEngine.History, SyncEngine.Bookmarks, SyncEngine.Passwords)
val logoutDeferred = CompletableDeferred<Unit>()
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
......@@ -363,7 +363,7 @@ class FxaWebChannelFeatureTest {
val messageHandler = argumentCaptor<MessageHandler>()
val responseToTheWebChannel = argumentCaptor<JSONObject>()
val port = mock<Port>()
val expectedEngines = setOf(SyncEngine.HISTORY, SyncEngine.BOOKMARKS, SyncEngine.PASSWORDS)
val expectedEngines = setOf(SyncEngine.History, SyncEngine.Bookmarks, SyncEngine.Passwords)
val logoutDeferred = CompletableDeferred<Unit>()
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
......@@ -425,7 +425,7 @@ class FxaWebChannelFeatureTest {
val messageHandler = argumentCaptor<MessageHandler>()
val responseToTheWebChannel = argumentCaptor<JSONObject>()
val port = mock<Port>()
val expectedEngines = setOf(SyncEngine.HISTORY, SyncEngine.BOOKMARKS, SyncEngine.PASSWORDS)
val expectedEngines = setOf(SyncEngine.History, SyncEngine.Bookmarks, SyncEngine.Passwords)
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
......@@ -477,7 +477,7 @@ class FxaWebChannelFeatureTest {
val messageHandler = argumentCaptor<MessageHandler>()
val responseToTheWebChannel = argumentCaptor<JSONObject>()
val port = mock<Port>()
val expectedEngines = setOf(SyncEngine.HISTORY, SyncEngine.BOOKMARKS, SyncEngine.PASSWORDS)
val expectedEngines = setOf(SyncEngine.History, SyncEngine.Bookmarks, SyncEngine.Passwords)
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
......@@ -591,13 +591,13 @@ class FxaWebChannelFeatureTest {
messageHandler.value.onPortConnected(port)
// Action: signin
verifyOauthLogin("signin", AuthType.Signin, "fffs", "fsdf32", messageHandler.value, accountManager)
verifyOauthLogin("signin", AuthType.Signin, "fffs", "fsdf32", null, messageHandler.value, accountManager)
// Signup.
verifyOauthLogin("signup", AuthType.Signup, "anotherCode1", "anotherState2", messageHandler.value, accountManager)
verifyOauthLogin("signup", AuthType.Signup, "anotherCode1", "anotherState2", setOf(SyncEngine.Passwords), messageHandler.value, accountManager)
// Pairing.
verifyOauthLogin("pairing", AuthType.Pairing, "anotherCode2", "anotherState3", messageHandler.value, accountManager)
verifyOauthLogin("pairing", AuthType.Pairing, "anotherCode2", "anotherState3", null, messageHandler.value, accountManager)
// Some other action.
verifyOauthLogin("newAction", AuthType.OtherExternal("newAction"), "anotherCode3", "anotherState4", messageHandler.value, accountManager)
verifyOauthLogin("newAction", AuthType.OtherExternal("newAction"), "anotherCode3", "anotherState4", null, messageHandler.value, accountManager)
}
// Receiving an oauth-login message account manager refuses the request
......@@ -628,13 +628,13 @@ class FxaWebChannelFeatureTest {
messageHandler.value.onPortConnected(port)
// Action: signin
verifyOauthLogin("signin", AuthType.Signin, "fffs", "fsdf32", messageHandler.value, accountManager)
verifyOauthLogin("signin", AuthType.Signin, "fffs", "fsdf32", setOf(SyncEngine.Passwords, SyncEngine.Bookmarks), messageHandler.value, accountManager)
// Signup.
verifyOauthLogin("signup", AuthType.Signup, "anotherCode1", "anotherState2", messageHandler.value, accountManager)
verifyOauthLogin("signup", AuthType.Signup, "anotherCode1", "anotherState2", null, messageHandler.value, accountManager)
// Pairing.
verifyOauthLogin("pairing", AuthType.Pairing, "anotherCode2", "anotherState3", messageHandler.value, accountManager)
verifyOauthLogin("pairing", AuthType.Pairing, "anotherCode2", "anotherState3", null, messageHandler.value, accountManager)
// Some other action.
verifyOauthLogin("newAction", AuthType.OtherExternal("newAction"), "anotherCode3", "anotherState4", messageHandler.value, accountManager)
verifyOauthLogin("newAction", AuthType.OtherExternal("newAction"), "anotherCode3", "anotherState4", null, messageHandler.value, accountManager)
}
// Receiving can-link-account returns 'ok=true' message (for now)
......@@ -648,7 +648,7 @@ class FxaWebChannelFeatureTest {
val messageHandler = argumentCaptor<MessageHandler>()
val jsonFromWebChannel = argumentCaptor<JSONObject>()
val port = mock<Port>()
val expectedEngines = setOf(SyncEngine.HISTORY, SyncEngine.BOOKMARKS)
val expectedEngines = setOf(SyncEngine.History, SyncEngine.Bookmarks)
WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
......@@ -768,19 +768,20 @@ class FxaWebChannelFeatureTest {
.getBoolean("ok")
}
private fun verifyOauthLogin(action: String, expectedAuthType: AuthType, code: String, state: String, messageHandler: MessageHandler, accountManager: FxaAccountManager) {
val jsonToWebChannel = jsonOauthLogin(action, code, state)
private fun verifyOauthLogin(action: String, expectedAuthType: AuthType, code: String, state: String, declined: Set<SyncEngine>?, messageHandler: MessageHandler, accountManager: FxaAccountManager) {
val jsonToWebChannel = jsonOauthLogin(action, code, state, declined ?: emptySet())
messageHandler.onPortMessage(jsonToWebChannel, mock())
val expectedAuthData = FxaAuthData(
authType = expectedAuthType,
code = code,
state = state
state = state,
declinedEngines = declined ?: emptySet()
)
verify(accountManager).finishAuthenticationAsync(expectedAuthData)
}
private fun jsonOauthLogin(action: String, code: String, state: String): JSONObject {
private fun jsonOauthLogin(action: String, code: String, state: String, declined: Set<SyncEngine>): JSONObject {
return JSONObject(
"""{
"message":{
......@@ -790,7 +791,8 @@ class FxaWebChannelFeatureTest {
"action":"$action",
"redirect":"urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
"code":"$code",
"state":"$state"
"state":"$state",
"declinedSyncEngines":${declined.map { "${it.nativeName}," }.filterNotNull()}
}
}
}""".trimIndent()
......
......@@ -26,6 +26,11 @@ Useful companion components:
* [feature-accounts](https://github.com/mozilla-mobile/android-components/tree/master/components/feature/accounts), provides a `tabs` integration on top of `FxaAccountManager`, to handle display of web sign-in UI.
* [browser-storage-sync](https://github.com/mozilla-mobile/android-components/tree/master/components/browser/storage-sync), provides data storage layers compatible with Firefox Sync.
## Before using this component
Products sending telemetry and using this component *must request* a data-review following [this process](https://wiki.mozilla.org/Firefox/Data_Collection).
This component provides data collection using the [Glean SDK](https://mozilla.github.io/glean/book/index.html).
The list of metrics being collected is available in the [metrics documentation](../../support/sync-telemetry/docs/metrics.md).
## Usage
### Setting up the dependency
......@@ -42,8 +47,8 @@ Additionally, see `feature-accounts`
```kotlin
// Make the two "syncable" stores accessible to account manager's sync machinery.
GlobalSyncableStoreProvider.configureStore("history" to historyStorage)
GlobalSyncableStoreProvider.configureStore("bookmarks" to bookmarksStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.History to historyStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Bookmarks to bookmarksStorage)
val accountManager = FxaAccountManager(
context = this,
......@@ -53,7 +58,7 @@ val accountManager = FxaAccountManager(
type = DeviceType.MOBILE,
capabilities = setOf(DeviceCapability.SEND_TAB)
),
syncConfig = SyncConfig(setOf("history", "bookmarks"), syncPeriodInMinutes = 15L)
syncConfig = SyncConfig(setOf(SyncEngine.History, SyncEngine.Bookmarks), syncPeriodInMinutes = 15L)
)
// Observe changes to the account and profile.
......@@ -75,7 +80,7 @@ launch { accountManager.initAsync().await() }
// 'Sync Now' button binding.
findViewById<View>(R.id.buttonSync).setOnClickListener {
accountManager.syncNowAsync()
accountManager.syncNowAsync(SyncReason.User)
}
// 'Sign-in' button binding.
......@@ -97,7 +102,7 @@ findViewById<View>(R.id.buttonLogout).setOnClickListener {
findViewById<View>(R.id.disablePeriodicSync).setOnClickListener {
launch {
accountManager.setSyncConfigAsync(
SyncConfig(setOf("history", "bookmarks")
SyncConfig(setOf(SyncReason.History, SyncReason.Bookmarks)
).await()
}
}
......@@ -106,11 +111,20 @@ findViewById<View>(R.id.disablePeriodicSync).setOnClickListener {
findViewById<View>(R.id.enablePeriodicSync).setOnClickListener {
launch {
accountManager.setSyncConfigAsync(
SyncConfig(setOf("history", "bookmarks"), syncPeriodInMinutes = 60L)
SyncConfig(setOf(SyncReason.History, SyncReason.Bookmarks), syncPeriodInMinutes = 60L)
).await()
}
}
// Globally disabled syncing an engine - this affects all Firefox Sync clients.
findViewById<View>(R.id.globallyDisableHistoryEngine).setOnClickListener {
SyncEnginesStorage.setStatus(SyncEngine.History, false)
accountManager.syncNowAsync(SyncReason.EngineChange)
}
// Get current status of SyncEngines. Note that this may change after every sync, as other Firefox Sync clients can change it.
val engineStatusMap = SyncEnginesStorage.getStatus() // type is: Map<SyncEngine, Boolean>
// This is expected to be called from the webview/geckoview integration, which intercepts page loads and gets
// 'code' and 'state' out of the 'successful sign-in redirect' url.
fun onLoginComplete(code: String, state: String) {
......
......@@ -33,9 +33,11 @@ dependencies {
// Parts of this dependency are typealiase'd or are otherwise part of this module's public API.
api Dependencies.mozilla_fxa
implementation Dependencies.mozilla_sync_manager
// Observable is part of public API of the FxaAccountManager.
api project(':support-base')
implementation project(':support-sync-telemetry')
implementation project(':support-ktx')
implementation Dependencies.kotlin_stdlib
......
......@@ -44,14 +44,24 @@ data class SyncConfig(
/**
* Describes possible sync engines that device can support.
*
* @property nativeName Internally, Rust SyncManager represents engines as strings. Forward-compatibility
* with new engines is one of the reasons for this. E.g. during any sync, an engine may appear that we
* do not know about. At the public API level, we expose a concrete [SyncEngine] type to allow for more
* robust integrations. We do not expose "unknown" engines via our public API, but do handle them
* internally (by persisting their enabled/disabled status).
*/
enum class SyncEngine(val nativeName: String) {
HISTORY("history"),
BOOKMARKS("bookmarks"),
PASSWORDS("passwords"),
sealed class SyncEngine(val nativeName: String) {
// When adding new types, make sure to think through implications for the SyncManager.
// See https://github.com/mozilla-mobile/android-components/issues/4557
object History : SyncEngine("history")
object Bookmarks : SyncEngine("bookmarks")
object Passwords : SyncEngine("passwords")
data class Other(val name: String) : SyncEngine(name)
/**
* This engine is used internally, but hidden from the public API because we don't fully support
* this data type right now.
*/
internal object Forms : SyncEngine("forms")
}
......@@ -4,6 +4,7 @@
package mozilla.components.service.fxa
import android.content.Context
import androidx.annotation.GuardedBy
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
......@@ -81,11 +82,12 @@ class FxaDeviceConstellation(
deviceObserverRegistry.register(observer, owner, autoPause)
}
override fun setDeviceNameAsync(name: String): Deferred<Boolean> {
override fun setDeviceNameAsync(name: String, context: Context): Deferred<Boolean> {
return scope.async {
val rename = handleFxaExceptions(logger, "changing device name") {
account.setDeviceDisplayName(name)
}
FxaDeviceSettingsCache(context).updateCachedName(name)
// See the latest device (name) changes after changing it.
val refreshDevices = refreshDevicesAsync().await()
......
/* 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.fxa
import android.content.Context
import android.content.SharedPreferences
import mozilla.appservices.syncmanager.DeviceSettings
import mozilla.appservices.syncmanager.DeviceType
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.base.utils.SharedPreferencesCache
import org.json.JSONObject
import java.lang.IllegalArgumentException
import java.lang.IllegalStateException
private const val CACHE_NAME = "FxaDeviceSettingsCache"
private const val CACHE_KEY = CACHE_NAME
private const val KEY_FXA_DEVICE_ID = "kid"
private const val KEY_DEVICE_NAME = "syncKey"
private const val KEY_DEVICE_TYPE = "tokenServerUrl"
/**
* A thin wrapper around [SharedPreferences] which knows how to serialize/deserialize [DeviceSettings].
*
* This class exists to provide background sync workers with access to [DeviceSettings].
*/
class FxaDeviceSettingsCache(context: Context) : SharedPreferencesCache<DeviceSettings>(context) {
override val logger = Logger("SyncAuthInfoCache")
override val cacheKey = CACHE_KEY
override val cacheName = CACHE_NAME
override fun DeviceSettings.toJSON(): JSONObject {
return JSONObject().also {
it.put(KEY_FXA_DEVICE_ID, this.fxaDeviceId)
it.put(KEY_DEVICE_NAME, this.name)
it.put(KEY_DEVICE_TYPE, this.type.name)
}
}
override fun fromJSON(obj: JSONObject): DeviceSettings {
return DeviceSettings(
fxaDeviceId = obj.getString(KEY_FXA_DEVICE_ID),
name = obj.getString(KEY_DEVICE_NAME),
type = obj.getString(KEY_DEVICE_TYPE).toDeviceType()
)
}
/**
* @param name New device name to write into the cache.
*/
fun updateCachedName(name: String) {
val cached = getCached() ?: throw IllegalStateException("Trying to update cached value in an empty cache")
setToCache(cached.copy(name = name))
}
private fun String.toDeviceType(): DeviceType {
return when (this) {
"DESKTOP" -> DeviceType.DESKTOP
"MOBILE" -> DeviceType.MOBILE
"TABLET" -> DeviceType.TABLET
"VR" -> DeviceType.VR
"TV" -> DeviceType.TV
else -> throw IllegalArgumentException("Unknown device type in cached string: $this")
}
}
}
......@@ -7,7 +7,7 @@ import android.content.Context
import android.content.SharedPreferences
import mozilla.components.concept.sync.SyncAuthInfo
import mozilla.components.support.base.log.logger.Logger
import org.json.JSONException
import mozilla.components.support.base.utils.SharedPreferencesCache
import org.json.JSONObject