From fe4554703cd3f4768fbef42acf5a5da091257723 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 25 Feb 2026 13:04:07 +0100 Subject: [PATCH] Check if network access if blocked when fetching notifications (#6247) * Add `NetworkMonitor.isNetworkBlocked()`, use it to check if Doze prevented us from loading notifications * Only check if network is blocked after checking if we have a network available, otherwise it's always `true` * Extract `NetworkBlockedChecker` to handle deprecations more carefully --- .../networkmonitor/api/NetworkMonitor.kt | 5 +++ .../impl/DefaultNetworkMonitor.kt | 3 ++ .../impl/NetworkBlockedChecker.kt | 31 ++++++++++++++ .../networkmonitor/test/FakeNetworkMonitor.kt | 6 ++- .../workmanager/FetchNotificationsWorker.kt | 41 +++++++++++++------ 5 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkBlockedChecker.kt diff --git a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkMonitor.kt b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkMonitor.kt index 484a210380..7adc5ac102 100644 --- a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkMonitor.kt +++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkMonitor.kt @@ -20,4 +20,9 @@ interface NetworkMonitor { * A flow containing the current network connectivity status. */ val connectivity: StateFlow + + /** + * Checks if the active network is being blocked by Doze, even if it's available. + */ + fun isNetworkBlocked(): Boolean } diff --git a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt index 8e10ceeabf..ebe5d6ed62 100644 --- a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt +++ b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt @@ -43,6 +43,9 @@ class DefaultNetworkMonitor( appCoroutineScope: CoroutineScope, ) : NetworkMonitor { private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java) + private val blockedNetworkBlockedChecker = NetworkBlockedChecker(connectivityManager) + + override fun isNetworkBlocked(): Boolean = blockedNetworkBlockedChecker.isNetworkBlocked() override val connectivity: StateFlow = callbackFlow { diff --git a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkBlockedChecker.kt b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkBlockedChecker.kt new file mode 100644 index 0000000000..624f1ce6c7 --- /dev/null +++ b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkBlockedChecker.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("DEPRECATION") + +package io.element.android.features.networkmonitor.impl + +import android.annotation.SuppressLint +import android.net.ConnectivityManager +import android.net.NetworkInfo + +/** + * Helper to check if the active network in [ConnectivityManager] is blocked. + * + * This is extracted to its own class because it uses deprecated APIs (but the only ones that are reliable) + * and we don't want to suppress deprecations everywhere. + */ +class NetworkBlockedChecker( + private val connectivityManager: ConnectivityManager, +) { + // The permission is granted by the manifest, false positive + @SuppressLint("MissingPermission") + fun isNetworkBlocked(): Boolean { + // This call is deprecated, but it seems like it's the only reliable way to tell if doze has blocked network access + return connectivityManager.activeNetworkInfo?.detailedState == NetworkInfo.DetailedState.BLOCKED + } +} diff --git a/features/networkmonitor/test/src/main/kotlin/io/element/android/features/networkmonitor/test/FakeNetworkMonitor.kt b/features/networkmonitor/test/src/main/kotlin/io/element/android/features/networkmonitor/test/FakeNetworkMonitor.kt index 37d569dbc6..d501eb5b5c 100644 --- a/features/networkmonitor/test/src/main/kotlin/io/element/android/features/networkmonitor/test/FakeNetworkMonitor.kt +++ b/features/networkmonitor/test/src/main/kotlin/io/element/android/features/networkmonitor/test/FakeNetworkMonitor.kt @@ -12,6 +12,10 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import kotlinx.coroutines.flow.MutableStateFlow -class FakeNetworkMonitor(initialStatus: NetworkStatus = NetworkStatus.Connected) : NetworkMonitor { +class FakeNetworkMonitor( + initialStatus: NetworkStatus = NetworkStatus.Connected, + private val isNetworkBlockedLambda: () -> Boolean = { false }, +) : NetworkMonitor { override val connectivity = MutableStateFlow(initialStatus) + override fun isNetworkBlocked(): Boolean = isNetworkBlockedLambda() } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt index 72c3768deb..25517f9e91 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt @@ -34,6 +34,7 @@ import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analytics.api.recordTransaction +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first @@ -57,28 +58,21 @@ class FetchNotificationsWorker( override suspend fun doWork(): Result { Timber.d("FetchNotificationsWorker started") val requests = workerDataConverter.deserialize(inputData) ?: return Result.failure() - // Wait for network to be available, but not more than 10 seconds + val networkTimeoutSpans = requests.mapNotNull { request -> val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId.value)) parent?.startChild("Waiting for network connectivity", "await_network") } + + // Wait for network to be available, but not more than 10 seconds val hasNetwork = withTimeoutOrNull(10.seconds) { networkMonitor.connectivity.first { it == NetworkStatus.Connected } } != null - for (span in networkTimeoutSpans) { - span.finish() - } + networkTimeoutSpans.finish() - if (!hasNetwork) { - Timber.w("No network, retrying later") - for (request in requests) { - val eventId = request.eventId.value - analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId)) - val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) - // Since we're retrying, start a new transaction - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) - } + // If there is a problem with the updated network values, report it and retry if needed + if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = networkMonitor.isNetworkBlocked())) { return Result.retry() } @@ -157,6 +151,25 @@ class FetchNotificationsWorker( return Result.success() } + private fun reportConnectivityError(requests: List, hasNetwork: Boolean, isNetworkBlocked: Boolean): Boolean { + return if (!hasNetwork || isNetworkBlocked) { + for (request in requests) { + val eventId = request.eventId.value + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId)) { + it.putExtraData("has_network_connection", hasNetwork.toString()) + it.putExtraData("is_network_blocked", isNetworkBlocked.toString()) + } + val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) + // Since we're retrying, start a new transaction + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) + } + Timber.w("FetchNotificationsWorker will retry. Has network connectivity: $hasNetwork. Is network blocked: $isNetworkBlocked") + true + } else { + false + } + } + private suspend fun performOpportunisticSyncIfNeeded( groupedRequests: Map>, ) { @@ -174,3 +187,5 @@ class FetchNotificationsWorker( @AssistedFactory interface Factory : MetroWorkerFactory.WorkerInstanceFactory } + +private fun Collection.finish() = forEach { it.finish() }