Skip to content

Commit c8c3424

Browse files
committed
Closes mozilla-mobile#3173: Check push subscriptions on interval
1 parent 9ff8f1b commit c8c3424

File tree

3 files changed

+191
-24
lines changed

3 files changed

+191
-24
lines changed

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,

0 commit comments

Comments
 (0)