Skip to content

Commit 0cb3dd8

Browse files
MozLandojonalmeidaMickeyMozjayeshsolanki93
committed
4411: Closes mozilla-mobile#3173: Check push subscriptions on an interval r=grigoryk a=jonalmeida We start a CoroutineWorker that is enqueued when the application starts, but only checks for verifications after 24 hours since the last attempt. Our implementation uses a timestamp to do this check since the Worker can actually run when the app is force-closed, but because it is no longer alive, invocations of `PushProcessors.requireInstance` will throw exceptions and fail silently. This can happen repeatedly if the worker is started, when a user typically doesn't have the app in-memory, without any verification ever happening. Since it's a Coroutine, starting a worker is cheap even if we do no real work (because our last check was less than 24 hours). 4837: Public Suffix List update (20191023-141447) r=jonalmeida a=MickeyMoz 4840: Closes mozilla-mobile#4827: SessionUseCasesTest: Rename misleading test data r=jonalmeida a=jayeshsolanki93 4850: GeckoView update (nightly) (20191025-141049) r=Amejia481 a=MickeyMoz Co-authored-by: Jonathan Almeida <[email protected]> Co-authored-by: MickeyMoz <[email protected]> Co-authored-by: Jayesh Solanki <[email protected]>
5 parents 74e9c55 + 0ee9e79 + 2e810c5 + b0d68fe + e184419 commit 0cb3dd8

File tree

6 files changed

+197
-27
lines changed

6 files changed

+197
-27
lines changed

buildSrc/src/main/java/Gecko.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ internal object GeckoVersions {
66
/**
77
* GeckoView Nightly Version.
88
*/
9-
const val nightly_version = "72.0.20191024082835"
9+
const val nightly_version = "72.0.20191025095546"
1010

1111
/**
1212
* GeckoView Beta Version.

components/feature/push/README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ A component that implements push notifications with a supported push service.
44

55
## Usage
66

7-
Add a supported push service for providing the encrypted messages (for example Firebase Cloud Messaging via `lib-push-firebase`):
7+
Add a supported push service for providing the encrypted messages (for example, Firebase Cloud Messaging via `lib-push-firebase`):
88
```kotlin
99
class FirebasePush : AbstractFirebasePushService()
1010
```
@@ -14,7 +14,7 @@ Create a push configuration with the project info and also place the required se
1414
```kotlin
1515
PushConfig(
1616
senderId = "push-test-f408f",
17-
serverHost = "push.service.mozilla.com",
17+
serverHost = "updates.push.services.mozilla.com",
1818
serviceType = ServiceType.FCM,
1919
protocol = Protocol.HTTPS
2020
)
@@ -62,8 +62,25 @@ Use Gradle to download the library from [maven.mozilla.org](https://maven.mozill
6262
implementation "org.mozilla.components:feature-push:{latest-version}"
6363
```
6464

65+
## Implementation Notes
66+
67+
Why do we need to verify connections, and what happens when we do?
68+
- Various services may need to communicate with us via push messages. Examples: FxA events (send tab, etc), WebPush (a web app receives a push message from its server).
69+
- To send these push messages, services (FxA, random internet servers talking to their web apps) post an HTTP request to a "push endpoint" maintained by [Mozilla's Autopush service][0]. This push endpoint is specific to its recipient - so one instance of an app may have many endpoints associated with it: one for the current FxA device, a few for web apps, etc.
70+
- Important point here: servers (FxA, services behind web apps, etc.) need to be told about subscription info we get from Autopush.
71+
- Here is where things start to get complicated: client (us) and server (Autopush) may disagree on which channels are associated with the current UAID (remember: our subscriptions are per-channel). Channels may expire (TTL'd) or may be deleted by some server's Cron job if they're unused. For example, if this happens, services that use this subscription info (e.g. FxA servers) to communication with their clients (FxA devices) will fail to deliver push messages.
72+
- So the client needs to be able to find out that this is the case, re-create channel subscriptions on Autopush, and update any dependent services with new subscription info (e.g. update the FxA device record for `PushType.Services`, or notify the JS code with a `pushsubscriptionchanged` event for WebPush).
73+
- The Autopush side of this is `verify_connection` API - we're expected to call this periodically, and that library will compare channel registrations that the server knows about vs those that the client knows about.
74+
- If those are misaligned, we need to re-register affected (or, all?) channels, and notify related services so that they may update their own server-side records.
75+
- For FxA, this means that we need to have an instance of the rust FirefoxAccount object around in order to call `setDevicePushSubscriptionAsync` once we re-generate our push subscription.
76+
- For consumers such as Fenix, easiest way to access that method is via an `account manager`.
77+
- However, neither account object itself, nor the account manager, aren't available from within a Worker. It's possible to "re-hydrate" (instantiate rust object from the locally persisted state) a FirefoxAccount instance, but that's a separate can of worms, and needs to be carefully considered.
78+
- Similarly for WebPush (in the future), we will need to have Gecko around in order to fire `pushsubscriptionchanged` javascript events.
79+
6580
## License
6681

6782
This Source Code Form is subject to the terms of the Mozilla Public
6883
License, v. 2.0. If a copy of the MPL was not distributed with this
6984
file, You can obtain one at http://mozilla.org/MPL/2.0/
85+
86+
[0]: https://github.com/mozilla-services/autopush

components/feature/push/src/main/java/mozilla/components/feature/push/AutoPushFeature.kt

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package mozilla.components.feature.push
66

77
import android.content.Context
88
import android.content.SharedPreferences
9+
import androidx.annotation.VisibleForTesting
910
import androidx.lifecycle.LifecycleOwner
1011
import androidx.lifecycle.ProcessLifecycleOwner
1112
import kotlinx.coroutines.CoroutineScope
@@ -61,7 +62,7 @@ import mozilla.appservices.push.PushError as RustPushError
6162
* </code>
6263
*
6364
*/
64-
@Suppress("TooManyFunctions")
65+
@Suppress("TooManyFunctions", "LargeClass")
6566
class AutoPushFeature(
6667
private val context: Context,
6768
private val service: PushService,
@@ -82,6 +83,9 @@ class AutoPushFeature(
8283
// The preference that stores new registration tokens.
8384
private val prefToken: String?
8485
get() = preferences(context).getString(PREF_TOKEN, null)
86+
private var prefLastVerified: Long
87+
get() = preferences(context).getLong(LAST_VERIFIED, System.currentTimeMillis())
88+
set(value) = preferences(context).edit().putLong(LAST_VERIFIED, value).apply()
8589

8690
internal var job: Job = SupervisorJob()
8791
private val scope = CoroutineScope(coroutineContext) + job
@@ -98,12 +102,18 @@ class AutoPushFeature(
98102
}
99103

100104
/**
101-
* Starts the push service provided.
105+
* Starts the push feature and initialization work needed.
102106
*/
103-
override fun initialize() { service.start(context) }
107+
override fun initialize() {
108+
// Starts the push feature.
109+
service.start(context)
110+
111+
// Starts verification of push subscription endpoints.
112+
tryVerifySubscriptions()
113+
}
104114

105115
/**
106-
* Un-subscribes from all push message channels and stops the push service.
116+
* Un-subscribes from all push message channels, stops the push service, and stops periodic verifications.
107117
* This should only be done on an account logout or app data deletion.
108118
*/
109119
override fun shutdown() {
@@ -118,6 +128,9 @@ class AutoPushFeature(
118128
job.cancel()
119129
}
120130
}
131+
132+
// Reset the push subscription check.
133+
prefLastVerified = 0L
121134
}
122135

123136
/**
@@ -239,11 +252,6 @@ class AutoPushFeature(
239252
/**
240253
* Deletes the registration token locally so that it forces the service to get a new one the
241254
* next time hits it's messaging server.
242-
*
243-
* Implementation notes: This shouldn't need to be used unless we're certain. When we introduce
244-
* [a polling service][0] to check if endpoints are expired, we would invoke this.
245-
*
246-
* [0]: https://github.com/mozilla-mobile/android-components/issues/3173
247255
*/
248256
override fun renewRegistration() {
249257
logger.warn("Forcing registration renewal by deleting our (cached) token.")
@@ -259,6 +267,38 @@ class AutoPushFeature(
259267
service.start(context)
260268
}
261269

270+
/**
271+
* Verifies status (active, expired) of the push subscriptions and then notifies observers.
272+
*/
273+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
274+
internal fun verifyActiveSubscriptions() {
275+
DeliveryManager.with(connection) {
276+
scope.launchAndTry {
277+
val notifyObservers = connection.verifyConnection()
278+
279+
if (notifyObservers) {
280+
logger.info("Subscriptions have changed; notifying observers..")
281+
282+
PushType.values().forEach { type ->
283+
val sub = subscribe(type.toChannelId()).toPushSubscription()
284+
subscriptionObservers.notifyObservers { onSubscriptionAvailable(sub) }
285+
}
286+
}
287+
}
288+
}
289+
}
290+
291+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
292+
internal fun tryVerifySubscriptions() {
293+
logger.info("Checking validity of push subscriptions.")
294+
295+
if (shouldVerifyNow()) {
296+
verifyActiveSubscriptions()
297+
298+
prefLastVerified = System.currentTimeMillis()
299+
}
300+
}
301+
262302
private fun CoroutineScope.launchAndTry(block: suspend CoroutineScope.() -> Unit) {
263303
job = launch {
264304
try {
@@ -280,10 +320,20 @@ class AutoPushFeature(
280320
private fun preferences(context: Context): SharedPreferences =
281321
context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
282322

323+
/**
324+
* We should verify if it's been [PERIODIC_INTERVAL_MILLISECONDS] since our last attempt (successful or not).
325+
*/
326+
private fun shouldVerifyNow(): Boolean {
327+
return (System.currentTimeMillis() - prefLastVerified) >= PERIODIC_INTERVAL_MILLISECONDS
328+
}
329+
283330
companion object {
284331
internal const val PREFERENCE_NAME = "mozac_feature_push"
285332
internal const val PREF_TOKEN = "token"
286333
internal const val DB_NAME = "push.sqlite"
334+
335+
internal const val LAST_VERIFIED = "last_verified_push_connection"
336+
internal const val PERIODIC_INTERVAL_MILLISECONDS = 24 * 60 * 60 * 1000L // 24 hours
287337
}
288338
}
289339

components/feature/push/src/test/java/mozilla/components/feature/push/AutoPushFeatureTest.kt

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import mozilla.appservices.push.SubscriptionResponse
1717
import mozilla.components.concept.push.Bus
1818
import mozilla.components.concept.push.EncryptedPushMessage
1919
import mozilla.components.concept.push.PushService
20+
import mozilla.components.feature.push.AutoPushFeature.Companion.LAST_VERIFIED
21+
import mozilla.components.feature.push.AutoPushFeature.Companion.PERIODIC_INTERVAL_MILLISECONDS
2022
import mozilla.components.feature.push.AutoPushFeature.Companion.PREFERENCE_NAME
2123
import mozilla.components.feature.push.AutoPushFeature.Companion.PREF_TOKEN
2224
import mozilla.components.support.test.any
@@ -31,32 +33,37 @@ import org.junit.Before
3133
import org.junit.Test
3234
import org.junit.runner.RunWith
3335
import org.mockito.ArgumentMatchers.anyString
36+
import org.mockito.Mockito.`when`
3437
import org.mockito.Mockito.never
3538
import org.mockito.Mockito.spy
3639
import org.mockito.Mockito.times
3740
import org.mockito.Mockito.verify
41+
import org.mockito.Mockito.verifyNoMoreInteractions
3842

3943
@ExperimentalCoroutinesApi
4044
@RunWith(AndroidJUnit4::class)
4145
class AutoPushFeatureTest {
4246

47+
var lastVerified: Long
48+
get() = preference(testContext).getLong(LAST_VERIFIED, System.currentTimeMillis())
49+
set(value) = preference(testContext).edit().putLong(LAST_VERIFIED, value).apply()
50+
4351
@Before
4452
fun setup() {
45-
preference(testContext)
46-
.edit()
47-
.clear()
48-
.apply()
53+
lastVerified = 0L
4954
}
5055

5156
@Test
5257
fun `initialize starts push service`() {
5358
val service: PushService = mock()
5459
val config = PushConfig("push-test")
55-
val feature = AutoPushFeature(testContext, service, config)
60+
val feature = spy(AutoPushFeature(testContext, service, config))
5661

5762
feature.initialize()
5863

5964
verify(service).start(testContext)
65+
66+
verifyNoMoreInteractions(service)
6067
}
6168

6269
@Test
@@ -74,8 +81,10 @@ class AutoPushFeatureTest {
7481

7582
preference(testContext).edit().putString(PREF_TOKEN, "token").apply()
7683

77-
AutoPushFeature(testContext, mock(), mock(), connection = connection,
78-
coroutineContext = coroutineContext)
84+
AutoPushFeature(
85+
testContext, mock(), mock(), connection = connection,
86+
coroutineContext = coroutineContext
87+
)
7988

8089
verify(connection).updateToken("token")
8190
}
@@ -213,14 +222,107 @@ class AutoPushFeatureTest {
213222
assertNull(pref)
214223
}
215224

216-
private fun preference(context: Context): SharedPreferences {
217-
return context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
225+
@Test
226+
fun `verifyActiveSubscriptions notifies subscribers`() = runBlockingTest {
227+
val connection: PushConnection = spy(TestPushConnection(true))
228+
val owner: LifecycleOwner = mock()
229+
val lifecycle: Lifecycle = mock()
230+
val observers: PushSubscriptionObserver = mock()
231+
val feature = spy(AutoPushFeature(testContext, mock(), mock(), coroutineContext, connection))
232+
whenever(owner.lifecycle).thenReturn(lifecycle)
233+
whenever(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED)
234+
235+
feature.registerForSubscriptions(observers)
236+
237+
// When there are NO subscription updates, observers should not be notified.
238+
feature.verifyActiveSubscriptions()
239+
240+
verify(observers, never()).onSubscriptionAvailable(any())
241+
242+
// When there are subscription updates, observers should not be notified.
243+
whenever(connection.verifyConnection()).thenReturn(true)
244+
feature.verifyActiveSubscriptions()
245+
246+
verify(observers, times(2)).onSubscriptionAvailable(any())
247+
}
248+
249+
@Test
250+
fun `initialize executes verifyActiveSubscriptions after interval`() = runBlockingTest {
251+
val feature = spy(
252+
AutoPushFeature(
253+
context = testContext,
254+
service = mock(),
255+
config = mock(),
256+
coroutineContext = coroutineContext,
257+
connection = mock()
258+
)
259+
)
260+
261+
lastVerified = System.currentTimeMillis() - VERIFY_NOW
262+
263+
feature.initialize()
264+
265+
verify(feature).tryVerifySubscriptions()
266+
}
267+
268+
@Test
269+
fun `initialize does not execute verifyActiveSubscription before interval`() = runBlockingTest {
270+
val feature = spy(
271+
AutoPushFeature(
272+
context = testContext,
273+
service = mock(),
274+
config = mock(),
275+
coroutineContext = coroutineContext,
276+
connection = mock()
277+
)
278+
)
279+
280+
lastVerified = System.currentTimeMillis() - SKIP_INTERVAL
281+
282+
feature.initialize()
283+
284+
verify(feature, never()).verifyActiveSubscriptions()
285+
}
286+
287+
@Test
288+
fun `verifySubscriptions notifies observers`() = runBlockingTest {
289+
val owner: LifecycleOwner = mock()
290+
val lifecycle: Lifecycle = mock()
291+
val native: PushConnection = TestPushConnection(true)
292+
val feature = spy(
293+
AutoPushFeature(
294+
context = testContext,
295+
service = mock(),
296+
config = mock(),
297+
coroutineContext = coroutineContext,
298+
connection = native
299+
)
300+
)
301+
`when`(owner.lifecycle).thenReturn(lifecycle)
302+
`when`(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED)
303+
304+
feature.registerForSubscriptions(object : PushSubscriptionObserver {
305+
override fun onSubscriptionAvailable(subscription: AutoPushSubscription) {
306+
assertEquals("https://fool", subscription.endpoint)
307+
}
308+
}, owner, false)
309+
310+
feature.verifyActiveSubscriptions()
311+
}
312+
313+
companion object {
314+
private fun preference(context: Context): SharedPreferences {
315+
return context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
316+
}
317+
318+
private const val SKIP_INTERVAL = 23 * 60 * 60 * 1000L // 23 hours; less than interval
319+
private const val VERIFY_NOW = PERIODIC_INTERVAL_MILLISECONDS + (10 * 60 * 1000) // interval + 10 mins
218320
}
219321

220322
class TestPushConnection(private val init: Boolean = false) : PushConnection {
221323
override suspend fun subscribe(channelId: String, scope: String) =
222324
SubscriptionResponse(
223-
"992a0f0542383f1ea5ef51b7cf4ae6c4",
325+
channelId,
224326
SubscriptionInfo("https://foo", KeyInfo("auth", "p256dh"))
225327
)
226328

@@ -230,9 +332,7 @@ class AutoPushFeatureTest {
230332

231333
override suspend fun updateToken(token: String) = true
232334

233-
override suspend fun verifyConnection(): Boolean {
234-
TODO("not implemented")
235-
}
335+
override suspend fun verifyConnection(): Boolean = false
236336

237337
override fun decrypt(
238338
channelId: String,

components/feature/session/src/test/java/mozilla/components/feature/session/SessionUseCasesTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ class SessionUseCasesTest {
6262
useCases.loadData("Should load in WebView", "text/plain", session = selectedSession)
6363
verify(selectedEngineSession).loadData("Should load in WebView", "text/plain", "UTF-8")
6464

65-
useCases.loadData("ahr0cdovl21vemlsbgeub3jn==", "text/plain", "base64", selectedSession)
66-
verify(selectedEngineSession).loadData("ahr0cdovl21vemlsbgeub3jn==", "text/plain", "base64")
65+
useCases.loadData("Should also load in WebView", "text/plain", "base64", selectedSession)
66+
verify(selectedEngineSession).loadData("Should also load in WebView", "text/plain", "base64")
6767
}
6868

6969
@Test

docs/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ permalink: /changelog/
3838
* `browser-engine-gecko-beta`: GeckoView 71.0
3939
* `browser-engine-gecko-nightly`: GeckoView 72.0
4040

41+
* **feature-push**
42+
* The `AutoPushFeature` now checks (once every 24 hours) to verify and renew push subscriptions if expired after a cold boot.
43+
4144
# 18.0.0
4245

4346
* [Commits](https://github.com/mozilla-mobile/android-components/compare/v17.0.0...v18.0.0)

0 commit comments

Comments
 (0)