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.
|
* 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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
|||||||
@@ -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