Skip to content

Commit

Permalink
Merge pull request #3755 from element-hq/feature/bma/rotateFirebaseToken
Browse files Browse the repository at this point in the history
Rotate firebase token in case of error
  • Loading branch information
bmarty authored Oct 30, 2024
2 parents 1c020bc + 12f5839 commit 2fa85f7
Show file tree
Hide file tree
Showing 17 changed files with 289 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ class PushLoopbackTest @Inject constructor(
val testPushResult = try {
pushService.testPush()
} catch (pusherRejected: PushGatewayFailure.PusherRejected) {
val hasQuickFix = pushService.getCurrentPushProvider()?.canRotateToken() == true
delegate.updateState(
description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_1),
status = NotificationTroubleshootTestState.Status.Failure(false)
status = NotificationTroubleshootTestState.Status.Failure(hasQuickFix)
)
job.cancel()
return
Expand Down Expand Up @@ -96,5 +97,11 @@ class PushLoopbackTest @Inject constructor(
)
}

override suspend fun quickFix(coroutineScope: CoroutineScope) {
delegate.start()
pushService.getCurrentPushProvider()?.rotateToken()
run(coroutineScope)
}

override suspend fun reset() = delegate.reset()
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.libraries.push.api.gateway.PushGatewayFailure
import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.pushproviders.test.FakePushProvider
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Test
Expand Down Expand Up @@ -67,6 +69,41 @@ class PushLoopbackTestTest {
}
}

@Test
fun `test PushLoopbackTest PusherRejected error with quick fix`() = runTest {
val diagnosticPushHandler = DiagnosticPushHandler()
val rotateTokenLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val sut = PushLoopbackTest(
pushService = FakePushService(
testPushBlock = {
throw PushGatewayFailure.PusherRejected()
},
currentPushProvider = {
FakePushProvider(
canRotateTokenResult = { true },
rotateTokenLambda = rotateTokenLambda,
)
}
),
diagnosticPushHandler = diagnosticPushHandler,
clock = FakeSystemClock(),
stringProvider = FakeStringProvider(),
)
launch {
sut.run(this)
}
sut.state.test {
assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true))
assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
val lastItem = awaitItem()
assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(true))
sut.quickFix(this)
assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(true))
rotateTokenLambda.assertions().isCalledOnce()
}
}

@Test
fun `test PushLoopbackTest setup error`() = runTest {
val diagnosticPushHandler = DiagnosticPushHandler()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,10 @@ interface PushProvider {
suspend fun unregister(matrixClient: MatrixClient): Result<Unit>

suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig?

fun canRotateToken(): Boolean

suspend fun rotateToken(): Result<Unit> {
error("rotateToken() not implemented, you need to override this method in your implementation")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class FirebasePushProvider @Inject constructor(
private val firebaseStore: FirebaseStore,
private val pusherSubscriber: PusherSubscriber,
private val isPlayServiceAvailable: IsPlayServiceAvailable,
private val firebaseTokenRotator: FirebaseTokenRotator,
) : PushProvider {
override val index = FirebaseConfig.INDEX
override val name = FirebaseConfig.NAME
Expand Down Expand Up @@ -71,6 +72,12 @@ class FirebasePushProvider @Inject constructor(
}
}

override fun canRotateToken(): Boolean = true

override suspend fun rotateToken(): Result<Unit> {
return firebaseTokenRotator.rotate()
}

companion object {
private val firebaseDistributor = Distributor("Firebase", "Firebase")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ import android.content.SharedPreferences
import androidx.core.content.edit
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import javax.inject.Inject

/**
* This class store the Firebase token in SharedPrefs.
*/
interface FirebaseStore {
fun getFcmToken(): String?
fun fcmTokenFlow(): Flow<String?>
fun storeFcmToken(token: String?)
}

Expand All @@ -29,6 +34,22 @@ class SharedPreferencesFirebaseStore @Inject constructor(
return sharedPreferences.getString(PREFS_KEY_FCM_TOKEN, null)
}

override fun fcmTokenFlow(): Flow<String?> {
val flow = MutableStateFlow(getFcmToken())
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, k ->
if (k == PREFS_KEY_FCM_TOKEN) {
try {
flow.value = getFcmToken()
} catch (e: Exception) {
flow.value = null
}
}
}
return flow
.onStart { sharedPreferences.registerOnSharedPreferenceChangeListener(listener) }
.onCompletion { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
}

override fun storeFcmToken(token: String?) {
sharedPreferences.edit {
putString(PREFS_KEY_FCM_TOKEN, token)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/

package io.element.android.libraries.pushproviders.firebase

import com.google.firebase.messaging.FirebaseMessaging
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

interface FirebaseTokenDeleter {
/**
* Deletes the current Firebase token.
*/
suspend fun delete()
}

@ContributesBinding(AppScope::class)
class DefaultFirebaseTokenDeleter @Inject constructor(
private val isPlayServiceAvailable: IsPlayServiceAvailable,
) : FirebaseTokenDeleter {
override suspend fun delete() {
// 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features'
isPlayServiceAvailable.checkAvailableOrThrow()
suspendCoroutine { continuation ->
try {
FirebaseMessaging.getInstance().deleteToken()
.addOnSuccessListener {
continuation.resume(Unit)
}
.addOnFailureListener { e ->
Timber.e(e, "## deleteFirebaseToken() : failed")
continuation.resumeWithException(e)
}
} catch (e: Throwable) {
Timber.e(e, "## deleteFirebaseToken() : failed")
continuation.resumeWithException(e)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/

package io.element.android.libraries.pushproviders.firebase

import com.google.firebase.messaging.FirebaseMessaging
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

interface FirebaseTokenGetter {
/**
* Read the current Firebase token from FirebaseMessaging.
* If the token does not exist, it will be generated.
*/
suspend fun get(): String
}

@ContributesBinding(AppScope::class)
class DefaultFirebaseTokenGetter @Inject constructor(
private val isPlayServiceAvailable: IsPlayServiceAvailable,
) : FirebaseTokenGetter {
override suspend fun get(): String {
// 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features'
isPlayServiceAvailable.checkAvailableOrThrow()
return suspendCoroutine { continuation ->
try {
FirebaseMessaging.getInstance().token
.addOnSuccessListener { token ->
continuation.resume(token)
}
.addOnFailureListener { e ->
Timber.e(e, "## retrievedFirebaseToken() : failed")
continuation.resumeWithException(e)
}
} catch (e: Throwable) {
Timber.e(e, "## retrievedFirebaseToken() : failed")
continuation.resumeWithException(e)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/

package io.element.android.libraries.pushproviders.firebase

import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject

interface FirebaseTokenRotator {
suspend fun rotate(): Result<Unit>
}

/**
* This class delete the Firebase token and generate a new one.
*/
@ContributesBinding(AppScope::class)
class DefaultFirebaseTokenRotator @Inject constructor(
private val firebaseTokenDeleter: FirebaseTokenDeleter,
private val firebaseTokenGetter: FirebaseTokenGetter,
) : FirebaseTokenRotator {
override suspend fun rotate(): Result<Unit> {
return runCatching {
firebaseTokenDeleter.delete()
firebaseTokenGetter.get()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,9 @@

package io.element.android.libraries.pushproviders.firebase

import com.google.firebase.messaging.FirebaseMessaging
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

interface FirebaseTroubleshooter {
suspend fun troubleshoot(): Result<Unit>
Expand All @@ -26,37 +21,12 @@ interface FirebaseTroubleshooter {
@ContributesBinding(AppScope::class)
class DefaultFirebaseTroubleshooter @Inject constructor(
private val newTokenHandler: FirebaseNewTokenHandler,
private val isPlayServiceAvailable: IsPlayServiceAvailable,
private val firebaseTokenGetter: FirebaseTokenGetter,
) : FirebaseTroubleshooter {
override suspend fun troubleshoot(): Result<Unit> {
return runCatching {
val token = retrievedFirebaseToken()
val token = firebaseTokenGetter.get()
newTokenHandler.handle(token)
}
}

private suspend fun retrievedFirebaseToken(): String {
return suspendCoroutine { continuation ->
// 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features'
if (isPlayServiceAvailable.isAvailable()) {
try {
FirebaseMessaging.getInstance().token
.addOnSuccessListener { token ->
continuation.resume(token)
}
.addOnFailureListener { e ->
Timber.e(e, "## retrievedFirebaseToken() : failed")
continuation.resumeWithException(e)
}
} catch (e: Throwable) {
Timber.e(e, "## retrievedFirebaseToken() : failed")
continuation.resumeWithException(e)
}
} else {
val e = Exception("No valid Google Play Services found. Cannot use FCM.")
Timber.e(e)
continuation.resumeWithException(e)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ interface IsPlayServiceAvailable {
fun isAvailable(): Boolean
}

fun IsPlayServiceAvailable.checkAvailableOrThrow() {
if (!isAvailable()) {
throw Exception("No valid Google Play Services found. Cannot use FCM.").also(Timber::e)
}
}

@ContributesBinding(AppScope::class)
class DefaultIsPlayServiceAvailable @Inject constructor(
@ApplicationContext private val context: Context,
Expand Down
Loading

0 comments on commit 2fa85f7

Please sign in to comment.