Merge pull request #15 from pezkuwichain/fix/fcm-token-retry

fix(push): retry FCM token requests instead of failing on the first timeout
This commit is contained in:
SatoshiQaziMuhammed
2026-07-31 20:54:09 -07:00
committed by GitHub
@@ -8,11 +8,13 @@ import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsS
import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi
import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureComponent
import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationHandler
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlin.coroutines.CoroutineContext
@@ -56,12 +58,71 @@ class NovaFirebaseMessagingService : FirebaseMessagingService(), CoroutineScope
companion object {
// Three attempts, 1s + 2s of backoff. The caller wraps this in
// withTimeout(SAVING_TIMEOUT = 15s), so the retries plus the attempts
// themselves have to fit inside that budget: a fourth attempt would add
// 4s of backoff and, if FCM answers slowly rather than failing fast,
// could be cut off mid-retry and report a timeout instead of the real
// error. Observed failures return in ~130ms, so this leaves ample room.
private const val TOKEN_RETRY_ATTEMPTS = 3
private const val TOKEN_RETRY_BASE_DELAY_MS = 1_000L
suspend fun getToken(): String? {
return runCatching { FirebaseMessaging.getInstance().token.await() }.getOrNull()
}
/**
* FCM token, retrying on the transient failures Firebase expects callers to
* handle themselves.
*
* getToken() surfaces SERVICE_NOT_AVAILABLE when Google's endpoint is
* momentarily unreachable — an IOException, not a configuration problem, and
* Firebase's own guidance is to retry with backoff. A single attempt meant a
* few seconds of that landed in front of the user as
* "java.util.concurrent.ExecutionException: java.io.IOException:
* SERVICE_NOT_AVAILABLE" and enabling notifications simply failed.
*
* Retries three times with 1s/2s backoff. Only network failures qualify;
* a wrong google-services.json or a missing Play Services install fails
* differently and will not start working on the second try, so those are
* rethrown on the first attempt.
*/
suspend fun requestToken(): String {
return FirebaseMessaging.getInstance().token.await()
var lastError: Exception? = null
repeat(TOKEN_RETRY_ATTEMPTS) { attempt ->
try {
return FirebaseMessaging.getInstance().token.await()
} catch (e: Exception) {
// The IOException arrives wrapped — the message users saw was
// "java.util.concurrent.ExecutionException: java.io.IOException:
// SERVICE_NOT_AVAILABLE" — so catching IOException alone would
// never match and the retry would be dead code.
if (!e.isTransientNetworkFailure()) throw e
lastError = e
if (attempt < TOKEN_RETRY_ATTEMPTS - 1) {
delay(TOKEN_RETRY_BASE_DELAY_MS shl attempt)
}
}
}
throw lastError ?: IOException("Failed to obtain FCM token")
}
/**
* True when the failure is Firebase being momentarily unreachable, which is
* worth another attempt. A misconfigured google-services.json or a missing
* Play Services install fails differently and will not start working on a
* retry, so those are rethrown immediately.
*/
private fun Throwable.isTransientNetworkFailure(): Boolean {
var cause: Throwable? = this
while (cause != null) {
if (cause is IOException) return true
cause = cause.cause
}
return false
}
suspend fun deleteToken() {