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:
committed by
GitHub
parent
de7f2990ae
commit
f77098ed47
Submodule enterprise updated: 1fd0d297d9...cdde60c158
@@ -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<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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -13,18 +13,21 @@ 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
|
||||
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
|
||||
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 +42,13 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
@SingleIn(AppScope::class)
|
||||
class DefaultNetworkMonitor(
|
||||
@ApplicationContext context: Context,
|
||||
@AppCoroutineScope
|
||||
appCoroutineScope: CoroutineScope,
|
||||
@AppCoroutineScope appCoroutineScope: CoroutineScope,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : 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(NetworkBlockedChecker(connectivityManager).isNetworkBlocked())
|
||||
override val isInAirGappedEnvironment = MutableStateFlow(false)
|
||||
|
||||
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) {
|
||||
if (activeNetworksCount.incrementAndGet() > 0) {
|
||||
trySendBlocking(NetworkStatus.Connected)
|
||||
|
||||
@@ -14,10 +14,10 @@ import android.net.ConnectivityManager
|
||||
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)
|
||||
* 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(
|
||||
private val connectivityManager: ConnectivityManager,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -8,32 +8,87 @@
|
||||
|
||||
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
|
||||
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.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
|
||||
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 {
|
||||
fun interface Factory {
|
||||
fun create(sessionId: SessionId): SyncPendingNotificationsRequestBuilder
|
||||
}
|
||||
|
||||
class SyncPendingNotificationsRequestBuilder(
|
||||
private val sessionId: SessionId,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : WorkManagerRequestBuilder {
|
||||
companion object {
|
||||
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>> {
|
||||
val type = WorkManagerWorkerType.Unique(
|
||||
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 (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>()
|
||||
.setInputData(workDataOf(SESSION_ID to sessionId.value))
|
||||
.apply {
|
||||
@@ -44,8 +99,10 @@ class SyncPendingNotificationsRequestBuilder(
|
||||
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
}
|
||||
}
|
||||
.setConstraints(networkConstraints)
|
||||
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
.build()
|
||||
|
||||
return Result.success(listOf(WorkManagerRequestWrapper(request, type)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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
|
||||
@@ -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.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,10 @@ class DefaultPushHandlerTest {
|
||||
analyticsService = analyticsService,
|
||||
systemClock = systemClock,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||
resultProcessor = resultProcessor,
|
||||
syncPendingNotificationsRequestFactory = SyncPendingNotificationsRequestBuilder.Factory {
|
||||
FakeSyncPendingNotificationsRequestBuilder()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user