From ec96dc74dc7c15e6a7abb591f6dc00def0c0325a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 9 Mar 2026 17:01:45 +0100 Subject: [PATCH 01/13] Add `isNetworkBlocked` and `isInAirGappedEnvironment` to `NetworkMonitor` --- .../networkmonitor/api/NetworkMonitor.kt | 8 +++++- .../impl/DefaultNetworkMonitor.kt | 25 ++++++++++++++++--- .../networkmonitor/test/FakeNetworkMonitor.kt | 12 +++++++-- 3 files changed, 38 insertions(+), 7 deletions(-) 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 7adc5ac1023..31c7013631e 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 @@ -24,5 +24,11 @@ interface NetworkMonitor { /** * Checks if the active network is being blocked by Doze, even if it's available. */ - fun isNetworkBlocked(): Boolean + val isNetworkBlocked: StateFlow + + /** + * A flow indicating whether the app is running in an air-gapped environment. + * An air-gapped environment is an environment that is not connected to the internet, and where the app can only communicate with a limited set of servers. + */ + val isInAirGappedEnvironment: StateFlow } 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 ebe5d6ed628..a7c1920106e 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 @@ -13,6 +13,7 @@ package io.element.android.features.networkmonitor.impl import android.content.Context import android.net.ConnectivityManager import android.net.Network +import android.net.NetworkCapabilities import android.net.NetworkRequest import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -25,6 +26,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow @@ -39,13 +41,12 @@ import java.util.concurrent.atomic.AtomicInteger @SingleIn(AppScope::class) class DefaultNetworkMonitor( @ApplicationContext context: Context, - @AppCoroutineScope - appCoroutineScope: CoroutineScope, + @AppCoroutineScope 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 isNetworkBlocked = MutableStateFlow(false) + override val isInAirGappedEnvironment = MutableStateFlow(false) override val connectivity: StateFlow = callbackFlow { @@ -63,6 +64,22 @@ class DefaultNetworkMonitor( } } + override fun onBlockedStatusChanged(network: Network, blocked: Boolean) { + Timber.d("Network ${network.networkHandle} blocked status changed: $blocked.") + if (network.networkHandle == connectivityManager.activeNetwork?.networkHandle) { + // If the network is blocked, it means that Doze is preventing the app from using the network, even if it's available. + isNetworkBlocked.value = blocked + } + } + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + if (network.networkHandle == connectivityManager.activeNetwork?.networkHandle) { + // If the network doesn't have the NET_CAPABILITY_VALIDATED capability, it means that the network is not able to reach the internet + // (according to Google), which is a common case in air-gapped environments. + isInAirGappedEnvironment.value = !networkCapabilities.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + } + override fun onAvailable(network: Network) { if (activeNetworksCount.incrementAndGet() > 0) { trySendBlocking(NetworkStatus.Connected) 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 d501eb5b5c9..e979301c9ee 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 @@ -14,8 +14,16 @@ import kotlinx.coroutines.flow.MutableStateFlow class FakeNetworkMonitor( initialStatus: NetworkStatus = NetworkStatus.Connected, - private val isNetworkBlockedLambda: () -> Boolean = { false }, ) : NetworkMonitor { override val connectivity = MutableStateFlow(initialStatus) - override fun isNetworkBlocked(): Boolean = isNetworkBlockedLambda() + override val isNetworkBlocked = MutableStateFlow(false) + override val isInAirGappedEnvironment = MutableStateFlow(false) + + fun givenNetworkBlocked(isBlocked: Boolean) { + isNetworkBlocked.value = isBlocked + } + + fun givenIsInAirGappedEnvironment(isInAirGapped: Boolean) { + isInAirGappedEnvironment.value = isInAirGapped + } } From bb6b493181513f881041dec82fb5dba0673ba7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 9 Mar 2026 17:01:59 +0100 Subject: [PATCH 02/13] Remove `NetworkBlockedChecker` --- .../impl/NetworkBlockedChecker.kt | 31 ------------------- .../FetchPendingNotificationsWorker.kt | 3 +- 2 files changed, 2 insertions(+), 32 deletions(-) delete mode 100644 features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkBlockedChecker.kt 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 deleted file mode 100644 index 624f1ce6c7c..00000000000 --- a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkBlockedChecker.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt index fd9008839fa..a14c20d53b9 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt @@ -160,7 +160,8 @@ class FetchPendingNotificationsWorker( networkTimeoutSpans.finish() // 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())) { + val isNetworkBlocked = networkMonitor.isNetworkBlocked.first() + if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = isNetworkBlocked)) { pushHistoryService.insertOrUpdatePushRequests(requests.map { request -> request.copy(retries = request.retries + 1) }) From 75bc189373438039e0665d8466fde655f37f9fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 9 Mar 2026 15:30:27 +0100 Subject: [PATCH 03/13] Improve the DI of `SyncPendingNotificationsRequestBuilder` to simplify its usage --- .../push/impl/push/DefaultPushHandler.kt | 10 +-- .../SyncPendingNotificationsRequestBuilder.kt | 27 ++++++- ...cPendingNotificationsRequestBuilderTest.kt | 74 ------------------- 3 files changed, 25 insertions(+), 86 deletions(-) delete mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index 5ed4223616f..0c43480bd69 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -30,7 +30,6 @@ import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.flow.first import timber.log.Timber @@ -49,7 +48,7 @@ class DefaultPushHandler( private val analyticsService: AnalyticsService, private val systemClock: SystemClock, private val workManagerScheduler: WorkManagerScheduler, - private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, + private val syncPendingNotificationsRequestFactory: SyncPendingNotificationsRequestBuilder.Factory, resultProcessor: NotificationResultProcessor, ) : PushHandler { init { @@ -134,12 +133,7 @@ class DefaultPushHandler( if (!workManagerScheduler.hasPendingWork(userId, WorkManagerRequestType.NOTIFICATION_SYNC)) { Timber.d("No pending worker for push notifications found") - workManagerScheduler.submit( - SyncPendingNotificationsRequestBuilder( - sessionId = userId, - buildVersionSdkIntProvider = buildVersionSdkIntProvider, - ) - ) + workManagerScheduler.submit(syncPendingNotificationsRequestFactory.create(userId)) } } catch (e: Exception) { Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt index 5aa40cadb5b..5c9e5935685 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt @@ -13,7 +13,13 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.workDataOf +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder.Companion.SESSION_ID import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper @@ -21,13 +27,26 @@ import io.element.android.libraries.workmanager.api.WorkManagerWorkerType import io.element.android.libraries.workmanager.api.workManagerTag import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider -class SyncPendingNotificationsRequestBuilder( - private val sessionId: SessionId, - private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, -) : WorkManagerRequestBuilder { +interface SyncPendingNotificationsRequestBuilder : WorkManagerRequestBuilder { + interface Factory { + fun create(sessionId: SessionId): SyncPendingNotificationsRequestBuilder + } + companion object { const val SESSION_ID = "session_id" } +} + +@AssistedInject +class DefaultSyncPendingNotificationsRequestBuilder( + @Assisted private val sessionId: SessionId, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, +) : SyncPendingNotificationsRequestBuilder { + @AssistedFactory + @ContributesBinding(AppScope::class) + interface Factory : SyncPendingNotificationsRequestBuilder.Factory { + override fun create(sessionId: SessionId): DefaultSyncPendingNotificationsRequestBuilder + } override suspend fun build(): Result> { val type = WorkManagerWorkerType.Unique( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt deleted file mode 100644 index c7d54973e30..00000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import androidx.work.OneTimeWorkRequest -import androidx.work.hasKeyWithValueOfType -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.workmanager.api.WorkManagerRequestType -import io.element.android.libraries.workmanager.api.WorkManagerWorkerType -import io.element.android.libraries.workmanager.api.workManagerTag -import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class SyncPendingNotificationsRequestBuilderTest { - @Test - fun `build - success API 33`() = runTest { - val request = createSyncPendingNotificationsRequestBuilder( - sessionId = A_SESSION_ID, - sdkVersion = 33, - ) - - val results = request.build() - assertThat(results.isSuccess).isTrue() - results.getOrNull()!!.first().let { result -> - assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java) - result.request.run { - assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) - assertThat(workSpec.input.hasKeyWithValueOfType(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() - // True in API 33+ - assertThat(workSpec.expedited).isTrue() - assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) - } - } - } - - @Test - fun `build - success API 32 and lower`() = runTest { - val request = createSyncPendingNotificationsRequestBuilder( - sessionId = A_SESSION_ID, - sdkVersion = 32, - ) - - val results = request.build() - assertThat(results.isSuccess).isTrue() - - results.getOrNull()!!.first().let { result -> - assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java) - result.request.run { - assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) - assertThat(workSpec.input.hasKeyWithValueOfType(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() - // False before API 33 - assertThat(workSpec.expedited).isFalse() - assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) - } - } - } -} - -private fun createSyncPendingNotificationsRequestBuilder( - sessionId: SessionId, - sdkVersion: Int = 33, -) = SyncPendingNotificationsRequestBuilder( - sessionId = sessionId, - buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), -) From a3133a7d315f28ddbab09b9b0173e9a140d104ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 9 Mar 2026 15:32:14 +0100 Subject: [PATCH 04/13] Expose the air-gapped state in `EnterpriseService.isAirGappedEnvironment()` since it's an enterprise feature --- .../features/enterprise/api/EnterpriseService.kt | 6 ++++++ .../enterprise/impl/DefaultEnterpriseService.kt | 3 +++ .../enterprise/test/FakeEnterpriseService.kt | 5 +++++ .../SyncPendingNotificationsRequestBuilder.kt | 2 ++ .../push/impl/push/DefaultPushHandlerTest.kt | 14 +++++++++++--- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 65fe3fe0870..9c00228d0d9 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -40,6 +40,12 @@ interface EnterpriseService { */ fun getNoisyNotificationChannelId(sessionId: SessionId): String? + /** + * Checks if the app is running in an air-gapped environment. An air-gapped environment is an environment that is not connected to the internet, + * and where the app can only communicate with a limited set of servers. + */ + fun isInAirGappedEnvironment(): Flow + companion object { const val ANY_ACCOUNT_PROVIDER = "*" } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index 932d082fd96..383863a952f 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -45,4 +45,7 @@ class DefaultEnterpriseService : EnterpriseService { } override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null + + // Act as no-op + override fun isInAirGappedEnvironment(): Flow = flowOf(false) } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index 3c17a4de7c6..787d9df4175 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -30,6 +30,7 @@ class FakeEnterpriseService( private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() }, + private val isInAirGappedEnvironmentResult: () -> Flow = { lambdaError() }, ) : EnterpriseService { private val brandColorState = MutableStateFlow(initialBrandColor) private val semanticColorsState = MutableStateFlow(initialSemanticColors) @@ -74,4 +75,8 @@ class FakeEnterpriseService( override fun getNoisyNotificationChannelId(sessionId: SessionId): String? { return getNoisyNotificationChannelIdResult(sessionId) } + + override fun isInAirGappedEnvironment(): Flow { + return isInAirGappedEnvironmentResult() + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt index 5c9e5935685..13356d4ec8d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt @@ -18,6 +18,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder.Companion.SESSION_ID import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder @@ -41,6 +42,7 @@ interface SyncPendingNotificationsRequestBuilder : WorkManagerRequestBuilder { class DefaultSyncPendingNotificationsRequestBuilder( @Assisted private val sessionId: SessionId, private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, + private val enterpriseService: EnterpriseService, ) : SyncPendingNotificationsRequestBuilder { @AssistedFactory @ContributesBinding(AppScope::class) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index 733b2b64be7..413ef503aeb 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -26,15 +26,16 @@ import io.element.android.libraries.push.impl.history.PushHistoryService import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler +import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService -import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -216,7 +217,6 @@ class DefaultPushHandlerTest { workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), systemClock: FakeSystemClock = FakeSystemClock(), - buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor( emit = { Result.success(Unit) }, start = {}, @@ -238,8 +238,16 @@ class DefaultPushHandlerTest { analyticsService = analyticsService, systemClock = systemClock, workManagerScheduler = workManagerScheduler, - buildVersionSdkIntProvider = buildVersionSdkIntProvider, resultProcessor = resultProcessor, + syncPendingNotificationsRequestFactory = object : SyncPendingNotificationsRequestBuilder.Factory { + override fun create(sessionId: SessionId): SyncPendingNotificationsRequestBuilder { + return object : SyncPendingNotificationsRequestBuilder { + override suspend fun build(): Result> { + return Result.success(emptyList()) + } + } + } + } ) } } From dd07d526bd60d2451d8cf70ea30e5791084c3a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 9 Mar 2026 16:00:06 +0100 Subject: [PATCH 05/13] Add network constraints based on the air-gapped status --- .../SyncPendingNotificationsRequestBuilder.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt index 13356d4ec8d..30f98d7465d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt @@ -8,8 +8,12 @@ package io.element.android.libraries.push.impl.workmanager +import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.os.Build +import androidx.work.Constraints import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.workDataOf @@ -27,6 +31,8 @@ import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.api.WorkManagerWorkerType import io.element.android.libraries.workmanager.api.workManagerTag import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import kotlinx.coroutines.flow.first +import timber.log.Timber interface SyncPendingNotificationsRequestBuilder : WorkManagerRequestBuilder { interface Factory { @@ -55,6 +61,31 @@ class DefaultSyncPendingNotificationsRequestBuilder( name = workManagerTag(sessionId = sessionId, requestType = WorkManagerRequestType.NOTIFICATION_SYNC), policy = ExistingWorkPolicy.APPEND_OR_REPLACE, ) + + val networkRequestBuilder = NetworkRequest.Builder() + // Allow any kind of network that can have internet connectivity. + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_VPN) + .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) + // By default, the network request will require the device to not be in VPN, but since some customers use a VPN to connect to their homeserver, + // we need to allow VPN networks. + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + + // If we're in an air-gapped environment, we shouldn't validate internet connectivity, as the checker will fail and the worker won't run at all. + // Note this will always be false for FOSS, since the feature is only enabled in Element Pro. + if (enterpriseService.isInAirGappedEnvironment().first()) { + Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request") + networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + Timber.d("Not in an air-gapped environment, adding NET_CAPABILITY_VALIDATED to the network request") + networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + val networkConstraints = Constraints.Builder() + .setRequiredNetworkRequest(networkRequestBuilder.build(), NetworkType.NOT_REQUIRED) + .build() + val request = OneTimeWorkRequestBuilder() .setInputData(workDataOf(SESSION_ID to sessionId.value)) .apply { @@ -65,8 +96,10 @@ class DefaultSyncPendingNotificationsRequestBuilder( setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) } } + .setConstraints(networkConstraints) .setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC)) .build() + return Result.success(listOf(WorkManagerRequestWrapper(request, type))) } } From fd043c572a4e2a21a372332691bf32fdb7b7f7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 9 Mar 2026 16:00:40 +0100 Subject: [PATCH 06/13] Add tests for `DefaultSyncPendingNotificationsRequestBuilder` --- features/networkmonitor/impl/build.gradle.kts | 5 + ...cPendingNotificationsRequestBuilderTest.kt | 124 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt diff --git a/features/networkmonitor/impl/build.gradle.kts b/features/networkmonitor/impl/build.gradle.kts index ba754ec1db3..5adc383d641 100644 --- a/features/networkmonitor/impl/build.gradle.kts +++ b/features/networkmonitor/impl/build.gradle.kts @@ -1,4 +1,5 @@ import extension.setupDependencyInjection +import extension.testCommonDependencies /* * Copyright (c) 2025 Element Creations Ltd. @@ -23,4 +24,8 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.di) api(projects.features.networkmonitor.api) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.networkmonitor.test) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt new file mode 100644 index 00000000000..dc18246b2cb --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import android.net.NetworkCapabilities +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.OneTimeWorkRequest +import androidx.work.hasKeyWithValueOfType +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultSyncPendingNotificationsRequestBuilderTest { + @Test + fun `build - success API 33`() = runTest { + val request = createSyncPendingNotificationsRequestBuilder( + sessionId = A_SESSION_ID, + sdkVersion = 33, + ) + + val results = request.build() + assertThat(results.isSuccess).isTrue() + results.getOrNull()!!.first().let { result -> + assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java) + result.request.run { + assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) + assertThat(workSpec.input.hasKeyWithValueOfType(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() + assertThat(workSpec.hasConstraints()).isTrue() + // True in API 33+ + assertThat(workSpec.expedited).isTrue() + assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) + } + } + } + + @Test + fun `build - success API 32 and lower`() = runTest { + val request = createSyncPendingNotificationsRequestBuilder( + sessionId = A_SESSION_ID, + sdkVersion = 32, + ) + + val results = request.build() + assertThat(results.isSuccess).isTrue() + + results.getOrNull()!!.first().let { result -> + assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java) + result.request.run { + assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) + assertThat(workSpec.input.hasKeyWithValueOfType(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() + assertThat(workSpec.hasConstraints()).isTrue() + // False before API 33 + assertThat(workSpec.expedited).isFalse() + assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) + } + } + } + + @Test + fun `build - has NET_CAPABILITY_VALIDATED constraint if not in air-gapped env`() = runTest { + val request = createSyncPendingNotificationsRequestBuilder( + sessionId = A_SESSION_ID, + sdkVersion = 33, + isInAirGapEnvironment = false, + ) + + val results = request.build() + assertThat(results.isSuccess).isTrue() + results.getOrNull()!!.first().let { result -> + result.request.run { + assertThat(workSpec.hasConstraints()).isTrue() + val networkRequest = workSpec.constraints.requiredNetworkRequest + assertThat(networkRequest).isNotNull() + assertThat(networkRequest!!.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isTrue() + } + } + } + + @Test + fun `build - does not have NET_CAPABILITY_VALIDATED constraint if in air-gapped env`() = runTest { + val request = createSyncPendingNotificationsRequestBuilder( + sessionId = A_SESSION_ID, + sdkVersion = 33, + isInAirGapEnvironment = true, + ) + + val results = request.build() + assertThat(results.isSuccess).isTrue() + results.getOrNull()!!.first().let { result -> + result.request.run { + assertThat(workSpec.hasConstraints()).isTrue() + val networkRequest = workSpec.constraints.requiredNetworkRequest + assertThat(networkRequest).isNotNull() + assertThat(networkRequest!!.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isFalse() + } + } + } +} + +private fun createSyncPendingNotificationsRequestBuilder( + sessionId: SessionId, + sdkVersion: Int = 33, + isInAirGapEnvironment: Boolean = false, +) = DefaultSyncPendingNotificationsRequestBuilder( + sessionId = sessionId, + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), + enterpriseService = FakeEnterpriseService(isInAirGappedEnvironmentResult = { flowOf(isInAirGapEnvironment) }), +) From 0fcf8d82da6e962d7967038012b14a7dbddcbaf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 9 Mar 2026 17:08:00 +0100 Subject: [PATCH 07/13] Add a feature flag to disable the problematic part of the added constraints: Requesting the OS to have internet connectivity before starting a fetch notifications worker. This feature flag is enabled by default, but can be disabled in case it's problematic. --- .../libraries/featureflag/api/FeatureFlags.kt | 8 ++++++ .../SyncPendingNotificationsRequestBuilder.kt | 5 +++- ...cPendingNotificationsRequestBuilderTest.kt | 27 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 3b47726b804..4f1fe30d105 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -147,4 +147,12 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + ValidateNetworkWhenSchedulingNotificationFetching( + key = "feature.validate_network_when_scheduling_notification_fetching", + title = "validate internet connectivity when scheduling notification fetching", + description = "Only fetch events for push notifications when the device has internet connectivity. " + + "Enabling this can be problematic in air-gapped environments.", + defaultValue = { true }, + isFinished = false, + ), } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt index 30f98d7465d..d1a9c447120 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt @@ -23,6 +23,8 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.ContributesBinding import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder.Companion.SESSION_ID import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder @@ -49,6 +51,7 @@ class DefaultSyncPendingNotificationsRequestBuilder( @Assisted private val sessionId: SessionId, private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, private val enterpriseService: EnterpriseService, + private val featureFlagService: FeatureFlagService, ) : SyncPendingNotificationsRequestBuilder { @AssistedFactory @ContributesBinding(AppScope::class) @@ -77,7 +80,7 @@ class DefaultSyncPendingNotificationsRequestBuilder( if (enterpriseService.isInAirGappedEnvironment().first()) { Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request") networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - } else { + } else if (featureFlagService.isFeatureEnabled(FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching)) { Timber.d("Not in an air-gapped environment, adding NET_CAPABILITY_VALIDATED to the network request") networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt index dc18246b2cb..c86bcaeec5a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt @@ -14,6 +14,8 @@ import androidx.work.OneTimeWorkRequest import androidx.work.hasKeyWithValueOfType import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.workmanager.api.WorkManagerRequestType @@ -111,14 +113,39 @@ class DefaultSyncPendingNotificationsRequestBuilderTest { } } } + + @Test + fun `build - does not have NET_CAPABILITY_VALIDATED constraint if feature flag is disabled`() = runTest { + val request = createSyncPendingNotificationsRequestBuilder( + sessionId = A_SESSION_ID, + sdkVersion = 33, + isInAirGapEnvironment = false, + featureFlagService = FakeFeatureFlagService(initialState = mapOf( + FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching.key to false + )), + ) + + val results = request.build() + assertThat(results.isSuccess).isTrue() + results.getOrNull()!!.first().let { result -> + result.request.run { + assertThat(workSpec.hasConstraints()).isTrue() + val networkRequest = workSpec.constraints.requiredNetworkRequest + assertThat(networkRequest).isNotNull() + assertThat(networkRequest!!.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isFalse() + } + } + } } private fun createSyncPendingNotificationsRequestBuilder( sessionId: SessionId, sdkVersion: Int = 33, isInAirGapEnvironment: Boolean = false, + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ) = DefaultSyncPendingNotificationsRequestBuilder( sessionId = sessionId, buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), enterpriseService = FakeEnterpriseService(isInAirGappedEnvironmentResult = { flowOf(isInAirGapEnvironment) }), + featureFlagService = featureFlagService, ) From d656afd78c30c51d8ef31d93c0aa359c77bd3608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 10 Mar 2026 11:00:37 +0100 Subject: [PATCH 08/13] Simplify checking for air-gapped status: remove it from `EnterpriseService`, check in `DefaultNetworkMonitor` if the build is enterprise before updating the value. --- .../android/features/enterprise/api/EnterpriseService.kt | 6 ------ .../features/enterprise/impl/DefaultEnterpriseService.kt | 3 --- .../features/enterprise/test/FakeEnterpriseService.kt | 5 ----- .../features/networkmonitor/impl/DefaultNetworkMonitor.kt | 7 +++++++ .../workmanager/SyncPendingNotificationsRequestBuilder.kt | 6 +++--- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 9c00228d0d9..65fe3fe0870 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -40,12 +40,6 @@ interface EnterpriseService { */ fun getNoisyNotificationChannelId(sessionId: SessionId): String? - /** - * Checks if the app is running in an air-gapped environment. An air-gapped environment is an environment that is not connected to the internet, - * and where the app can only communicate with a limited set of servers. - */ - fun isInAirGappedEnvironment(): Flow - companion object { const val ANY_ACCOUNT_PROVIDER = "*" } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index 383863a952f..932d082fd96 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -45,7 +45,4 @@ class DefaultEnterpriseService : EnterpriseService { } override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null - - // Act as no-op - override fun isInAirGappedEnvironment(): Flow = flowOf(false) } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index 787d9df4175..3c17a4de7c6 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -30,7 +30,6 @@ class FakeEnterpriseService( private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() }, - private val isInAirGappedEnvironmentResult: () -> Flow = { lambdaError() }, ) : EnterpriseService { private val brandColorState = MutableStateFlow(initialBrandColor) private val semanticColorsState = MutableStateFlow(initialSemanticColors) @@ -75,8 +74,4 @@ class FakeEnterpriseService( override fun getNoisyNotificationChannelId(sessionId: SessionId): String? { return getNoisyNotificationChannelIdResult(sessionId) } - - override fun isInAirGappedEnvironment(): Flow { - return isInAirGappedEnvironmentResult() - } } 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 a7c1920106e..2047c81653d 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 @@ -20,6 +20,7 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -42,6 +43,7 @@ import java.util.concurrent.atomic.AtomicInteger class DefaultNetworkMonitor( @ApplicationContext context: Context, @AppCoroutineScope appCoroutineScope: CoroutineScope, + private val buildMeta: BuildMeta, ) : NetworkMonitor { private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java) @@ -73,6 +75,11 @@ class DefaultNetworkMonitor( } override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + if (!buildMeta.isEnterpriseBuild) { + // The air-gapped environment detection is only relevant for the enterprise build. + return + } + if (network.networkHandle == connectivityManager.activeNetwork?.networkHandle) { // If the network doesn't have the NET_CAPABILITY_VALIDATED capability, it means that the network is not able to reach the internet // (according to Google), which is a common case in air-gapped environments. diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt index d1a9c447120..7d627a2c839 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt @@ -22,7 +22,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.SessionId @@ -50,7 +50,7 @@ interface SyncPendingNotificationsRequestBuilder : WorkManagerRequestBuilder { class DefaultSyncPendingNotificationsRequestBuilder( @Assisted private val sessionId: SessionId, private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, - private val enterpriseService: EnterpriseService, + private val networkMonitor: NetworkMonitor, private val featureFlagService: FeatureFlagService, ) : SyncPendingNotificationsRequestBuilder { @AssistedFactory @@ -77,7 +77,7 @@ class DefaultSyncPendingNotificationsRequestBuilder( // If we're in an air-gapped environment, we shouldn't validate internet connectivity, as the checker will fail and the worker won't run at all. // Note this will always be false for FOSS, since the feature is only enabled in Element Pro. - if (enterpriseService.isInAirGappedEnvironment().first()) { + if (networkMonitor.isInAirGappedEnvironment.first()) { Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request") networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } else if (featureFlagService.isFeatureEnabled(FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching)) { From 03595f2e968de80b58d9d8959b43f8fb1da4d9ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 9 Mar 2026 16:01:00 +0100 Subject: [PATCH 09/13] Bump enterprise repo --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index 1fd0d297d94..09ad6ac1539 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 1fd0d297d944186e3af2773e1c5db2938d60f74b +Subproject commit 09ad6ac15392491c911d6c08514eb5871194cec6 From 0b63f74fc8f229515827dc4d2f5b1e5deb515281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 10 Mar 2026 13:01:21 +0100 Subject: [PATCH 10/13] Fix docs and copyright notice --- .../android/features/networkmonitor/api/NetworkMonitor.kt | 4 ++-- .../DefaultSyncPendingNotificationsRequestBuilderTest.kt | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) 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 31c7013631e..1884ca0e6f3 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 @@ -27,8 +27,8 @@ interface NetworkMonitor { val isNetworkBlocked: StateFlow /** - * A flow indicating whether the app is running in an air-gapped environment. + * A flow indicating whether the app is running in an air-gapped environment. * An air-gapped environment is an environment that is not connected to the internet, and where the app can only communicate with a limited set of servers. - */ + */ val isInAirGappedEnvironment: StateFlow } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt index c86bcaeec5a..0fe71fa8b8a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. From a3afd9832f56db327d43399397f763367bdb4937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 10 Mar 2026 13:02:05 +0100 Subject: [PATCH 11/13] Make `SyncPendingNotificationsRequestBuilder.Factory` a `fun interface` for easily creating fakes Add `FakeSyncPendingNotificationsRequestBuilder` --- .../SyncPendingNotificationsRequestBuilder.kt | 2 +- .../push/impl/push/DefaultPushHandlerTest.kt | 12 +++--------- ...yncPendingNotificationsRequestBuilderTest.kt | 5 ++--- libraries/push/test/build.gradle.kts | 1 + ...akeSyncPendingNotificationsRequestBuilder.kt | 17 +++++++++++++++++ 5 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt index 7d627a2c839..bdb8389febf 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.first import timber.log.Timber interface SyncPendingNotificationsRequestBuilder : WorkManagerRequestBuilder { - interface Factory { + fun interface Factory { fun create(sessionId: SessionId): SyncPendingNotificationsRequestBuilder } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index 413ef503aeb..1f7f64bf903 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -27,13 +27,13 @@ import io.element.android.libraries.push.impl.notifications.FakeNotificationResu import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder +import io.element.android.libraries.push.test.workmanager.FakeSyncPendingNotificationsRequestBuilder import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder -import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -239,14 +239,8 @@ class DefaultPushHandlerTest { systemClock = systemClock, workManagerScheduler = workManagerScheduler, resultProcessor = resultProcessor, - syncPendingNotificationsRequestFactory = object : SyncPendingNotificationsRequestBuilder.Factory { - override fun create(sessionId: SessionId): SyncPendingNotificationsRequestBuilder { - return object : SyncPendingNotificationsRequestBuilder { - override suspend fun build(): Result> { - return Result.success(emptyList()) - } - } - } + syncPendingNotificationsRequestFactory = SyncPendingNotificationsRequestBuilder.Factory { + FakeSyncPendingNotificationsRequestBuilder() } ) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt index 0fe71fa8b8a..a8daf5bcffb 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt @@ -12,7 +12,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.work.OneTimeWorkRequest import androidx.work.hasKeyWithValueOfType import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.SessionId @@ -21,7 +21,6 @@ import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerWorkerType import io.element.android.libraries.workmanager.api.workManagerTag import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith @@ -145,6 +144,6 @@ private fun createSyncPendingNotificationsRequestBuilder( ) = DefaultSyncPendingNotificationsRequestBuilder( sessionId = sessionId, buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), - enterpriseService = FakeEnterpriseService(isInAirGappedEnvironmentResult = { flowOf(isInAirGapEnvironment) }), + networkMonitor = FakeNetworkMonitor().apply { givenIsInAirGappedEnvironment(isInAirGapEnvironment) }, featureFlagService = featureFlagService, ) diff --git a/libraries/push/test/build.gradle.kts b/libraries/push/test/build.gradle.kts index 3a0b5532aea..475d4a4ae5b 100644 --- a/libraries/push/test/build.gradle.kts +++ b/libraries/push/test/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(projects.libraries.push.impl) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.workmanager.api) implementation(projects.tests.testutils) implementation(libs.androidx.core) implementation(libs.coil.compose) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt new file mode 100644 index 00000000000..ef0e38991e4 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt @@ -0,0 +1,17 @@ +/* + * 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. + */ + +package io.element.android.libraries.push.test.workmanager + +import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper + +class FakeSyncPendingNotificationsRequestBuilder( + private val build: () -> Result> = { Result.success(emptyList()) }, +) : SyncPendingNotificationsRequestBuilder { + override suspend fun build(): Result> = build.invoke() +} From 24ac3640967fe2586bbf66c8755c23494a9b67d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 10 Mar 2026 13:05:46 +0100 Subject: [PATCH 12/13] Restore `NetworkBlockedChecker` so we can initialize `DefaultNetworkMonitor.isNetworkBlocked` with its contents We use a separate component to avoid propagating the `@file:Suppress("DEPRECATION")` to the whole `DefaultNetworkMonitor` file --- .../impl/DefaultNetworkMonitor.kt | 2 +- .../impl/NetworkBlockedChecker.kt | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkBlockedChecker.kt 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 2047c81653d..949db720cec 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 @@ -47,7 +47,7 @@ class DefaultNetworkMonitor( ) : NetworkMonitor { private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java) - override val isNetworkBlocked = MutableStateFlow(false) + override val isNetworkBlocked = MutableStateFlow(NetworkBlockedChecker(connectivityManager).isNetworkBlocked()) override val isInAirGappedEnvironment = MutableStateFlow(false) 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 00000000000..7f90209c904 --- /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 synchronously 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 in the file this would be called. + */ +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 + } +} From 35ae93825cbb7b33e2046e75c8f8fc2d1cabaeeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 10 Mar 2026 13:12:54 +0100 Subject: [PATCH 13/13] Bump enterprise repo again --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index 09ad6ac1539..cdde60c158e 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 09ad6ac15392491c911d6c08514eb5871194cec6 +Subproject commit cdde60c158ecd0987a3ba6fd79a4617551aff463