diff --git a/enterprise b/enterprise index 1fd0d297d94..cdde60c158e 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 1fd0d297d944186e3af2773e1c5db2938d60f74b +Subproject commit cdde60c158ecd0987a3ba6fd79a4617551aff463 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..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 @@ -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/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/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..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 @@ -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 = 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) 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 index 624f1ce6c7c..7f90209c904 100644 --- 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 @@ -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, 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 + } } 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/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/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) }) 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..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 @@ -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> { 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() .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))) } } 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..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 @@ -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() + } ) } } 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..a8daf5bcffb --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/DefaultSyncPendingNotificationsRequestBuilderTest.kt @@ -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(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() + } + } + } + + @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, +) 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), -) 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() +}