Add network constraints for fetching notifications with WorkManager (#6305)

* Add `isNetworkBlocked` and `isInAirGappedEnvironment` to `NetworkMonitor`.

* Improve the DI of `SyncPendingNotificationsRequestBuilder` to simplify its usage.

* Only update `isInAirGappedEnvironment` in `DefaultNetworkManager` if the current build is an enterprise one.

* Add network constraints to `DefaultSyncPendingNotificationsRequestBuilder` based on the air-gapped status.

* Add a feature flag to disable the new check, in case it doesn't work as expected.
This commit is contained in:
Jorge Martin Espinosa
2026-03-10 13:44:31 +01:00
committed by GitHub
parent de7f2990ae
commit f77098ed47
15 changed files with 298 additions and 100 deletions

View File

@@ -24,5 +24,11 @@ interface NetworkMonitor {
/** /**
* Checks if the active network is being blocked by Doze, even if it's available. * Checks if the active network is being blocked by Doze, even if it's available.
*/ */
fun isNetworkBlocked(): Boolean val isNetworkBlocked: StateFlow<Boolean>
/**
* 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<Boolean>
} }

View File

@@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2025 Element Creations Ltd.
@@ -23,4 +24,8 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.di) implementation(projects.libraries.di)
api(projects.features.networkmonitor.api) api(projects.features.networkmonitor.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.networkmonitor.test)
} }

View File

@@ -13,18 +13,21 @@ package io.element.android.features.networkmonitor.impl
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus 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.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
@@ -39,13 +42,13 @@ import java.util.concurrent.atomic.AtomicInteger
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class DefaultNetworkMonitor( class DefaultNetworkMonitor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@AppCoroutineScope @AppCoroutineScope appCoroutineScope: CoroutineScope,
appCoroutineScope: CoroutineScope, private val buildMeta: BuildMeta,
) : NetworkMonitor { ) : NetworkMonitor {
private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java) private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val blockedNetworkBlockedChecker = NetworkBlockedChecker(connectivityManager)
override fun isNetworkBlocked(): Boolean = blockedNetworkBlockedChecker.isNetworkBlocked() override val isNetworkBlocked = MutableStateFlow(NetworkBlockedChecker(connectivityManager).isNetworkBlocked())
override val isInAirGappedEnvironment = MutableStateFlow(false)
override val connectivity: StateFlow<NetworkStatus> = callbackFlow { override val connectivity: StateFlow<NetworkStatus> = callbackFlow {
@@ -63,6 +66,27 @@ 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 (!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.
isInAirGappedEnvironment.value = !networkCapabilities.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
}
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
if (activeNetworksCount.incrementAndGet() > 0) { if (activeNetworksCount.incrementAndGet() > 0) {
trySendBlocking(NetworkStatus.Connected) trySendBlocking(NetworkStatus.Connected)

View File

@@ -14,10 +14,10 @@ import android.net.ConnectivityManager
import android.net.NetworkInfo import android.net.NetworkInfo
/** /**
* Helper to check if the active network in [ConnectivityManager] is blocked. * 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) * 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. * and we don't want to suppress deprecations everywhere in the file this would be called.
*/ */
class NetworkBlockedChecker( class NetworkBlockedChecker(
private val connectivityManager: ConnectivityManager, private val connectivityManager: ConnectivityManager,

View File

@@ -14,8 +14,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeNetworkMonitor( class FakeNetworkMonitor(
initialStatus: NetworkStatus = NetworkStatus.Connected, initialStatus: NetworkStatus = NetworkStatus.Connected,
private val isNetworkBlockedLambda: () -> Boolean = { false },
) : NetworkMonitor { ) : NetworkMonitor {
override val connectivity = MutableStateFlow(initialStatus) 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
}
} }

View File

@@ -147,4 +147,12 @@ enum class FeatureFlags(
defaultValue = { false }, defaultValue = { false },
isFinished = 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,
),
} }

View File

@@ -30,7 +30,6 @@ import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.libraries.workmanager.api.WorkManagerScheduler
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService 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 io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import timber.log.Timber import timber.log.Timber
@@ -49,7 +48,7 @@ class DefaultPushHandler(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val systemClock: SystemClock, private val systemClock: SystemClock,
private val workManagerScheduler: WorkManagerScheduler, private val workManagerScheduler: WorkManagerScheduler,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, private val syncPendingNotificationsRequestFactory: SyncPendingNotificationsRequestBuilder.Factory,
resultProcessor: NotificationResultProcessor, resultProcessor: NotificationResultProcessor,
) : PushHandler { ) : PushHandler {
init { init {
@@ -134,12 +133,7 @@ class DefaultPushHandler(
if (!workManagerScheduler.hasPendingWork(userId, WorkManagerRequestType.NOTIFICATION_SYNC)) { if (!workManagerScheduler.hasPendingWork(userId, WorkManagerRequestType.NOTIFICATION_SYNC)) {
Timber.d("No pending worker for push notifications found") Timber.d("No pending worker for push notifications found")
workManagerScheduler.submit( workManagerScheduler.submit(syncPendingNotificationsRequestFactory.create(userId))
SyncPendingNotificationsRequestBuilder(
sessionId = userId,
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
)
)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")

View File

@@ -160,7 +160,8 @@ class FetchPendingNotificationsWorker(
networkTimeoutSpans.finish() networkTimeoutSpans.finish()
// If there is a problem with the updated network values, report it and retry if needed // 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 -> pushHistoryService.insertOrUpdatePushRequests(requests.map { request ->
request.copy(retries = request.retries + 1) request.copy(retries = request.retries + 1)
}) })

View File

@@ -8,32 +8,87 @@
package io.element.android.libraries.push.impl.workmanager package io.element.android.libraries.push.impl.workmanager
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build import android.os.Build
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy import androidx.work.OutOfQuotaPolicy
import androidx.work.workDataOf 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.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 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.WorkManagerRequestBuilder
import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
import io.element.android.libraries.workmanager.api.WorkManagerWorkerType import io.element.android.libraries.workmanager.api.WorkManagerWorkerType
import io.element.android.libraries.workmanager.api.workManagerTag import io.element.android.libraries.workmanager.api.workManagerTag
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.flow.first
import timber.log.Timber
interface SyncPendingNotificationsRequestBuilder : WorkManagerRequestBuilder {
fun interface Factory {
fun create(sessionId: SessionId): SyncPendingNotificationsRequestBuilder
}
class SyncPendingNotificationsRequestBuilder(
private val sessionId: SessionId,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : WorkManagerRequestBuilder {
companion object { companion object {
const val SESSION_ID = "session_id" const val SESSION_ID = "session_id"
} }
}
@AssistedInject
class DefaultSyncPendingNotificationsRequestBuilder(
@Assisted private val sessionId: SessionId,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
private val networkMonitor: NetworkMonitor,
private val featureFlagService: FeatureFlagService,
) : SyncPendingNotificationsRequestBuilder {
@AssistedFactory
@ContributesBinding(AppScope::class)
interface Factory : SyncPendingNotificationsRequestBuilder.Factory {
override fun create(sessionId: SessionId): DefaultSyncPendingNotificationsRequestBuilder
}
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> { override suspend fun build(): Result<List<WorkManagerRequestWrapper>> {
val type = WorkManagerWorkerType.Unique( val type = WorkManagerWorkerType.Unique(
name = workManagerTag(sessionId = sessionId, requestType = WorkManagerRequestType.NOTIFICATION_SYNC), name = workManagerTag(sessionId = sessionId, requestType = WorkManagerRequestType.NOTIFICATION_SYNC),
policy = ExistingWorkPolicy.APPEND_OR_REPLACE, 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 (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)) {
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<FetchPendingNotificationsWorker>() val request = OneTimeWorkRequestBuilder<FetchPendingNotificationsWorker>()
.setInputData(workDataOf(SESSION_ID to sessionId.value)) .setInputData(workDataOf(SESSION_ID to sessionId.value))
.apply { .apply {
@@ -44,8 +99,10 @@ class SyncPendingNotificationsRequestBuilder(
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
} }
} }
.setConstraints(networkConstraints)
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC)) .setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
.build() .build()
return Result.success(listOf(WorkManagerRequestWrapper(request, type))) return Result.success(listOf(WorkManagerRequestWrapper(request, type)))
} }
} }

View File

@@ -26,6 +26,8 @@ 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.notifications.FakeNotificationResultProcessor
import io.element.android.libraries.push.impl.test.DefaultTestPush 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.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.pushproviders.api.PushData
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret 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.FakeUserPushStore
@@ -34,7 +36,6 @@ import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.Fa
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.services.analytics.test.FakeAnalyticsService 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.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -216,7 +217,6 @@ class DefaultPushHandlerTest {
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
systemClock: FakeSystemClock = FakeSystemClock(), systemClock: FakeSystemClock = FakeSystemClock(),
buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33),
resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor( resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor(
emit = { Result.success(Unit) }, emit = { Result.success(Unit) },
start = {}, start = {},
@@ -238,8 +238,10 @@ class DefaultPushHandlerTest {
analyticsService = analyticsService, analyticsService = analyticsService,
systemClock = systemClock, systemClock = systemClock,
workManagerScheduler = workManagerScheduler, workManagerScheduler = workManagerScheduler,
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
resultProcessor = resultProcessor, resultProcessor = resultProcessor,
syncPendingNotificationsRequestFactory = SyncPendingNotificationsRequestBuilder.Factory {
FakeSyncPendingNotificationsRequestBuilder()
}
) )
} }
} }

View File

@@ -0,0 +1,149 @@
/*
* 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.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.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
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
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<String>(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<String>(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()
}
}
}
@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),
networkMonitor = FakeNetworkMonitor().apply { givenIsInAirGappedEnvironment(isInAirGapEnvironment) },
featureFlagService = featureFlagService,
)

View File

@@ -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<String>(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<String>(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),
)

View File

@@ -21,6 +21,7 @@ dependencies {
implementation(projects.libraries.push.impl) implementation(projects.libraries.push.impl)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.workmanager.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View File

@@ -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<List<WorkManagerRequestWrapper>> = { Result.success(emptyList()) },
) : SyncPendingNotificationsRequestBuilder {
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> = build.invoke()
}