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..a03a9ec1e58 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,70 @@ 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. + */ + @Suppress("TooGenericExceptionCaught") + private suspend fun retrySync(userId: UserId, stayAliveDuration: Duration) { + val maxRetries = MAX_SYNC_RETRY + 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 +588,6 @@ class WireNotificationManager @Inject constructor( companion object { private const val TAG = "WireNotificationManager" - private val STAY_ALIVE_TIME_ON_PUSH_DURATION = 1.seconds + private const val MAX_SYNC_RETRY = 3 } } 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..550ada2a96a 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 with network constraints") + } else { + appLogger.d("$TAG: Enqueued NotificationFetchWorker 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 740a665d8df..9c4e8e72b58 100644 --- a/default.json +++ b/default.json @@ -81,7 +81,10 @@ "analytics_app_key": "8ffae535f1836ed5f58fd5c8a11c00eca07c5438", "analytics_server_url": "https://wire.count.ly/", "enable_new_registration": true, - "use_async_flush_logging" : true + "use_strict_mls_filter": false, + "use_async_flush_logging" : true, + "background_notification_retry_enabled": true, + "background_notification_stay_alive_seconds": 5 }, "fdroid": { "application_id": "com.wire", @@ -152,5 +155,7 @@ "is_mls_reset_enabled": true, "use_strict_mls_filter": false, "meetings_enabled": false, - "emm_support_enabled": true + "emm_support_enabled": true, + "background_notification_retry_enabled": false, + "background_notification_stay_alive_seconds": 1 }