Skip to content
This repository has been archived by the owner on Nov 1, 2022. It is now read-only.

Commit

Permalink
Merge #4507
Browse files Browse the repository at this point in the history
4507: Issue #3859: Add FxA support feature to update push registration r=grigoryk a=jonalmeida



Co-authored-by: mcarare <[email protected]>
Co-authored-by: Jonathan Almeida <[email protected]>
  • Loading branch information
3 people committed Oct 23, 2019
2 parents 6bbc5b1 + d9661b9 commit 321d499
Show file tree
Hide file tree
Showing 13 changed files with 569 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ interface PushProcessor {
*/
fun onError(error: PushError)

/**
* Requests the [PushService] to renew it's registration with it's provider.
*/
fun renewRegistration()

companion object {
/**
* Initialize and installs the PushProcessor into the application.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,7 @@ class PushProcessorTest {
override fun onMessageReceived(message: EncryptedPushMessage) {}

override fun onError(error: PushError) {}

override fun renewRegistration() {}
}
}
3 changes: 3 additions & 0 deletions components/feature/accounts/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ android {

dependencies {
implementation Dependencies.kotlin_coroutines
implementation Dependencies.androidx_work_runtime
implementation Dependencies.androidx_lifecycle_extensions

implementation project(':concept-engine')
implementation project(':concept-push')
implementation project(':feature-tabs')
implementation project(':service-firefox-accounts')
implementation project(':support-webextensions')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/* 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.feature.accounts

import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import mozilla.components.concept.push.PushProcessor
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.ConstellationState
import mozilla.components.concept.sync.DeviceConstellationObserver
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.AccountObserver as SyncAccountObserver
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.manager.ext.withConstellation
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.base.utils.SharedPreferencesCache
import org.json.JSONObject

internal const val PREFERENCE_NAME = "mozac_feature_accounts"
internal const val LAST_VERIFIED = "last_verified_push_subscription"

/**
* A feature used for supporting FxA and push integration where needed. One of the main functions is when FxA notifies
* the device during a sync, that it's unable to reach the device via push messaging; triggering a push
* registration renewal.
*
* @param context The application Android context.
* @param accountManager The FxaAccountManager.
* @param push The push processor instance that needs to be notified.
* @param owner the lifecycle owner for the observer. Defaults to [ProcessLifecycleOwner].
* @param autoPause whether to stop notifying the observer during onPause lifecycle events.
* Defaults to false so that observers are always notified.
*/
class FxaPushSupportFeature(
context: Context,
accountManager: FxaAccountManager,
push: PushProcessor,
owner: LifecycleOwner = ProcessLifecycleOwner.get(),
autoPause: Boolean = false
) {
init {
val constellationObserver = ConstellationObserver(context, push)

val accountObserver = AccountObserver(context, accountManager, constellationObserver, owner, autoPause)

accountManager.register(accountObserver)
}
}

/**
* An [FxaAccountManager] observer to know when an account has been added, so we can begin observing the device
* constellation.
*/
internal class AccountObserver(
private val context: Context,
private val accountManager: FxaAccountManager,
private val observer: DeviceConstellationObserver,
private val lifecycleOwner: LifecycleOwner,
private val autoPause: Boolean
) : SyncAccountObserver {
private val logger = Logger("AccountObserver")

override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
accountManager.withConstellation { constellation ->
constellation.registerDeviceObserver(observer, lifecycleOwner, autoPause)
}
}

override fun onLoggedOut() {
// Delete renewal pref.
preference(context).edit().remove(LAST_VERIFIED).apply()
}
}

/**
* A DeviceConstellation observer to know when we should notify the push feature to begin the registration renewal.
*/
internal class ConstellationObserver(
context: Context,
private val push: PushProcessor,
private val verifier: VerificationDelegate = VerificationDelegate(context)
) : DeviceConstellationObserver {

private val logger = Logger("ConstellationObserver")

override fun onDevicesUpdate(constellation: ConstellationState) {
val updateSubscription = constellation.currentDevice?.subscriptionExpired ?: false

// If our subscription has not expired, we do nothing.
// If our last check was recent (see: PERIODIC_INTERVAL_MILLISECONDS), we do nothing.
if (!updateSubscription || !verifier.allowedToRenew()) {
return
}

logger.warn("We have been notified that our push subscription has expired; renewing registration.")

push.renewRegistration()

verifier.increment()
}
}

/**
* A helper that rate limits how often we should notify our servers to renew push registration.
*
* Implementation notes: This saves the timestamp of our renewal and the number of times we have renewed our
* registration within the [PERIODIC_INTERVAL_MILLISECONDS] interval of time.
*/
internal class VerificationDelegate(context: Context) : SharedPreferencesCache<VerificationState>(context) {
override val logger: Logger = Logger("VerificationDelegate")
override val cacheKey: String = LAST_VERIFIED
override val cacheName: String = PREFERENCE_NAME

override fun VerificationState.toJSON() =
JSONObject().apply {
put(KEY_TIMESTAMP, timestamp)
put(KEY_TOTAL_COUNT, totalCount)
}

override fun fromJSON(obj: JSONObject) =
VerificationState(
obj.getLong(KEY_TIMESTAMP),
obj.getInt(KEY_TOTAL_COUNT)
)

@VisibleForTesting
internal var innerCount: Int = 0
@VisibleForTesting
internal var innerTimestamp: Long = System.currentTimeMillis()

init {
getCached()?.let { cache ->
innerTimestamp = cache.timestamp
innerCount = cache.totalCount
}
}

/**
* Checks whether we're within our rate limiting constraints.
*/
fun allowedToRenew(): Boolean {
val withinTimeFrame = System.currentTimeMillis() - innerTimestamp < PERIODIC_INTERVAL_MILLISECONDS
val withinIntervalCounter = innerCount <= MAX_REQUEST_IN_INTERVAL
val shouldAllow = withinTimeFrame && withinIntervalCounter

// If it's been PERIODIC_INTERVAL_MILLISECONDS since we last checked, we can reset
// out rate limiter and verify now.
if (!withinTimeFrame) {
reset()
return true
}

return shouldAllow
}

/**
* Should be called whenever a successful invocation has taken place and we want to record it.
*/
fun increment() {
val count = innerCount + 1

setToCache(VerificationState(innerTimestamp, count))

innerCount = count
}

private fun reset() {
val timestamp = System.currentTimeMillis()
innerCount = 0
innerTimestamp = timestamp

setToCache(VerificationState(timestamp, 0))
}

companion object {
private const val KEY_TIMESTAMP = "timestamp"
private const val KEY_TOTAL_COUNT = "totalCount"

internal const val PERIODIC_INTERVAL_MILLISECONDS = 24 * 60 * 60 * 1000L // 24 hours
internal const val MAX_REQUEST_IN_INTERVAL = 500 // 500 requests in 24 hours
}
}

internal data class VerificationState(val timestamp: Long, val totalCount: Int)

@VisibleForTesting
internal fun preference(context: Context) = context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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.feature.accounts

import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.DeviceConstellation
import mozilla.components.concept.sync.DeviceConstellationObserver
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.support.test.eq
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.Mockito.`when`
import org.mockito.Mockito.verify
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class AccountObserverTest {

private val context: Context = mock()
private val accountManager: FxaAccountManager = mock()
private val constellationObserver: DeviceConstellationObserver = mock()
private val account: OAuthAccount = mock()

@Test
fun `register device observer for existing accounts`() {
val lifecycle: Lifecycle = mock()
val lifecycleOwner: LifecycleOwner = mock()
val constellation: DeviceConstellation = mock()
val observer = AccountObserver(testContext, accountManager, constellationObserver, lifecycleOwner, false)

`when`(accountManager.authenticatedAccount()).thenReturn(account)
`when`(account.deviceConstellation()).thenReturn(constellation)
`when`(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED)
`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle)

observer.onAuthenticated(account, AuthType.Existing)

verify(constellation).registerDeviceObserver(eq(constellationObserver), eq(lifecycleOwner), anyBoolean())
}

@Test
fun `onLoggedOut removes cache`() {
val observer = AccountObserver(testContext, accountManager, constellationObserver, mock(), false)

preference(testContext).edit().putString(LAST_VERIFIED, "{\"timestamp\": 100, \"totalCount\": 0}").apply()

assertTrue(preference(testContext).contains(LAST_VERIFIED))

observer.onLoggedOut()

assertFalse(preference(testContext).contains(LAST_VERIFIED))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* 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.feature.accounts

import android.content.Context
import mozilla.components.concept.push.PushProcessor
import mozilla.components.concept.sync.ConstellationState
import mozilla.components.concept.sync.Device
import mozilla.components.support.test.mock
import org.junit.Test
import org.mockito.Mockito.`when`
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyZeroInteractions

class ConstellationObserverTest {

private val push: PushProcessor = mock()
private val verifier: VerificationDelegate = mock()
private val state: ConstellationState = mock()
private val device: Device = mock()
private val context: Context = mock()

@Test
fun `do nothing if subscription has not expired`() {
val observer = ConstellationObserver(context, push, verifier)

observer.onDevicesUpdate(state)

verifyZeroInteractions(push)
verifyZeroInteractions(verifier)

`when`(state.currentDevice).thenReturn(device)
`when`(device.subscriptionExpired).thenReturn(false)

observer.onDevicesUpdate(state)

verifyZeroInteractions(push)
}

@Test
fun `do nothing if verifier is false`() {
val observer = ConstellationObserver(context, push, verifier)

observer.onDevicesUpdate(state)

verifyZeroInteractions(push)
verifyZeroInteractions(verifier)

`when`(state.currentDevice).thenReturn(device)
`when`(device.subscriptionExpired).thenReturn(true)
`when`(verifier.allowedToRenew()).thenReturn(false)

verifyZeroInteractions(push)

`when`(device.subscriptionExpired).thenReturn(true)

observer.onDevicesUpdate(state)

verifyZeroInteractions(push)
}

@Test
fun `invoke registration renewal`() {
val observer = ConstellationObserver(context, push, verifier)

`when`(state.currentDevice).thenReturn(device)
`when`(device.subscriptionExpired).thenReturn(true)
`when`(verifier.allowedToRenew()).thenReturn(true)

observer.onDevicesUpdate(state)

verify(push).renewRegistration()
verify(verifier).increment()
}
}
Loading

0 comments on commit 321d499

Please sign in to comment.