From 3e598507d9c5fba6fc68f35b887a7cfa6fb7d359 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Fri, 31 Oct 2025 11:13:53 +0100 Subject: [PATCH 1/3] feat: add background notification retry logic with configurable stay-alive duration --- .../notification/WireNotificationManager.kt | 65 ++++++++++++++++++- .../services/WireFirebaseMessagingService.kt | 17 ++++- .../kotlin/customization/FeatureConfigs.kt | 12 ++++ default.json | 8 ++- 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt index 70464618b06..77d300e6c37 100644 --- a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt @@ -19,6 +19,7 @@ package com.wire.android.notification import androidx.annotation.VisibleForTesting +import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.appLogger import com.wire.android.di.KaliumCoreLogic @@ -47,6 +48,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable @@ -62,10 +64,12 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.net.UnknownHostException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) @@ -165,13 +169,69 @@ class WireNotificationManager @Inject constructor( val observeMessagesJob = observeMessageNotificationsOnceJob(userId) val observeCallsJob = observeCallNotificationsOnceJob(userId) - appLogger.d("$TAG start syncing") - syncLifecycleManager.syncTemporarily(userId, STAY_ALIVE_TIME_ON_PUSH_DURATION) + val stayAliveDuration = BuildConfig.BACKGROUND_NOTIFICATION_STAY_ALIVE_SECONDS.seconds + + if (BuildConfig.BACKGROUND_NOTIFICATION_RETRY_ENABLED) { + appLogger.d("$TAG start syncing with retry logic and extended duration (${stayAliveDuration.inWholeSeconds}s)") + retrySync(userId, stayAliveDuration) + } else { + appLogger.d("$TAG start syncing without retry logic, default duration (${stayAliveDuration.inWholeSeconds}s)") + syncLifecycleManager.syncTemporarily(userId, stayAliveDuration) + } observeMessagesJob?.cancel("$TAG checked the notifications once, canceling observing.") observeCallsJob?.cancel("$TAG checked the calls once, canceling observing.") } + /** + * Retries sync operation with exponential backoff for transient network failures. + * This is critical for background notifications during Doze mode where network + * may not be immediately available despite WorkManager constraints. + */ + private suspend fun retrySync(userId: UserId, stayAliveDuration: Duration) { + val maxRetries = 3 + var attempt = 0 + var lastException: Exception? = null + + while (attempt < maxRetries) { + try { + appLogger.d("$TAG Sync attempt ${attempt + 1}/$maxRetries") + syncLifecycleManager.syncTemporarily(userId, stayAliveDuration) + appLogger.i("$TAG Sync succeeded on attempt ${attempt + 1}") + return // Success, exit retry loop + } catch (e: Exception) { + lastException = e + + // Only retry on network-related errors + val isRetryable = when (e) { + is UnknownHostException -> true + else -> e.cause is UnknownHostException + } + + if (!isRetryable) { + appLogger.w("$TAG Non-retryable error during sync: ${e.message}") + throw e + } + + attempt++ + if (attempt < maxRetries) { + // Exponential backoff: 1s, 2s, 4s + val delaySeconds = (1L shl (attempt - 1)) + appLogger.w("$TAG Network error on attempt $attempt, retrying in ${delaySeconds}s: ${e.message}") + delay(delaySeconds.seconds) + } else { + appLogger.e("$TAG All $maxRetries sync attempts failed with network errors") + } + } + } + + // If we exhausted all retries, log and rethrow + lastException?.let { + appLogger.e("$TAG Sync failed after $maxRetries attempts: ${it.message}") + throw it + } + } + private suspend fun observeMessageNotificationsOnceJob(userId: UserId): Job? { val isMessagesAlreadyObserving = observingWhileRunningJobs.userJobs[userId]?.run { messagesJob.isActive } @@ -527,6 +587,5 @@ class WireNotificationManager @Inject constructor( companion object { private const val TAG = "WireNotificationManager" - private val STAY_ALIVE_TIME_ON_PUSH_DURATION = 1.seconds } } diff --git a/app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt b/app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt index 750107f4798..8ec8361adf2 100644 --- a/app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt +++ b/app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt @@ -18,7 +18,9 @@ package com.wire.android.services +import androidx.work.Constraints import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager @@ -78,11 +80,22 @@ class WireFirebaseMessagingService : FirebaseMessagingService() { } private fun enqueueNotificationFetchWorker(userId: String) { - val request = OneTimeWorkRequestBuilder() + val requestBuilder = OneTimeWorkRequestBuilder() .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setInputData(workDataOf(NotificationFetchWorker.USER_ID_INPUT_DATA to userId)) - .build() + // Only add network constraints if background notification retry feature is enabled + if (BuildConfig.BACKGROUND_NOTIFICATION_RETRY_ENABLED) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + requestBuilder.setConstraints(constraints) + appLogger.d("$TAG: Enqueued NotificationFetchWorker for user=$userId with network constraints") + } else { + appLogger.d("$TAG: Enqueued NotificationFetchWorker for user=$userId without network constraints") + } + + val request = requestBuilder.build() val workManager = WorkManager.getInstance(applicationContext) workManager.enqueueUniqueWork( diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index aa2ea45ce1e..f5ca2be26bf 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -125,4 +125,16 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { MEETINGS_ENABLED("meetings_enabled", ConfigType.BOOLEAN), USE_ASYNC_FLUSH_LOGGING("use_async_flush_logging", ConfigType.BOOLEAN), + + /** + * Background notification retry logic + * Enables retry with exponential backoff for background notification sync failures + */ + BACKGROUND_NOTIFICATION_RETRY_ENABLED("background_notification_retry_enabled", ConfigType.BOOLEAN), + + /** + * Extended stay-alive duration (in seconds) when background notification retry is enabled + * Controls how long the sync connection stays alive after receiving a push notification + */ + BACKGROUND_NOTIFICATION_STAY_ALIVE_SECONDS("background_notification_stay_alive_seconds", ConfigType.INT), } diff --git a/default.json b/default.json index 81d464fa38e..9a6a3dd84db 100644 --- a/default.json +++ b/default.json @@ -82,7 +82,9 @@ "analytics_server_url": "https://wire.count.ly/", "enable_new_registration": true, "use_strict_mls_filter": false, - "use_async_flush_logging" : true + "use_async_flush_logging" : true, + "background_notification_retry_enabled": true, + "background_notification_stay_alive_seconds": 5 }, "fdroid": { "application_id": "com.wire", @@ -153,5 +155,7 @@ "is_mls_reset_enabled": true, "use_strict_mls_filter": true, "meetings_enabled": false, - "emm_support_enabled": true + "emm_support_enabled": true, + "background_notification_retry_enabled": false, + "background_notification_stay_alive_seconds": 1 } From 37c0e1bd11ad76eefd07cb71727c219f81422f7e Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Fri, 31 Oct 2025 11:18:35 +0100 Subject: [PATCH 2/3] fix logs --- .../com/wire/android/services/WireFirebaseMessagingService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt b/app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt index 8ec8361adf2..550ada2a96a 100644 --- a/app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt +++ b/app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt @@ -90,9 +90,9 @@ class WireFirebaseMessagingService : FirebaseMessagingService() { .setRequiredNetworkType(NetworkType.CONNECTED) .build() requestBuilder.setConstraints(constraints) - appLogger.d("$TAG: Enqueued NotificationFetchWorker for user=$userId with network constraints") + appLogger.d("$TAG: Enqueued NotificationFetchWorker with network constraints") } else { - appLogger.d("$TAG: Enqueued NotificationFetchWorker for user=$userId without network constraints") + appLogger.d("$TAG: Enqueued NotificationFetchWorker without network constraints") } val request = requestBuilder.build() From 2eb9989e2e3b5f874f3cf7f70c3db7e403c533d8 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Fri, 7 Nov 2025 10:39:49 +0100 Subject: [PATCH 3/3] feat: set network constraint for sync retries in WireNotificationManager --- .../com/wire/android/notification/WireNotificationManager.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt index 77d300e6c37..a03a9ec1e58 100644 --- a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt @@ -188,8 +188,9 @@ class WireNotificationManager @Inject constructor( * This is critical for background notifications during Doze mode where network * may not be immediately available despite WorkManager constraints. */ + @Suppress("TooGenericExceptionCaught") private suspend fun retrySync(userId: UserId, stayAliveDuration: Duration) { - val maxRetries = 3 + val maxRetries = MAX_SYNC_RETRY var attempt = 0 var lastException: Exception? = null @@ -587,5 +588,6 @@ class WireNotificationManager @Inject constructor( companion object { private const val TAG = "WireNotificationManager" + private const val MAX_SYNC_RETRY = 3 } }