diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d897c790cff..b11ee42fca8 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -71,7 +71,6 @@ # WrapperWorkerFactory resolves inner workers by class name from input data. # Keep names stable so existing enqueued work remains resolvable after minification. # See docs/minification-workmanager-compat.md --keepnames class com.wire.kalium.logic.sync.PendingMessagesSenderWorker -keepnames class com.wire.kalium.logic.sync.periodic.UserConfigSyncWorker -keepnames class com.wire.kalium.logic.sync.periodic.UpdateApiVersionsWorker -keepnames class com.wire.kalium.logic.sync.receiver.asset.AudioNormalizedLoudnessWorker diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ad9c467008e..d53999f040f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ + @@ -349,6 +350,11 @@ android:exported="false" android:foregroundServiceType="specialUse" /> + + (Landroid/content/Context;Lcom/wire/kalium/logic/GlobalKaliumScope;)V SPLcom/wire/kalium/logic/sync/GlobalWorkSchedulerImpl;->schedulePeriodicApiVersionUpdate()V -Lcom/wire/kalium/logic/sync/PendingMessagesSenderWorker; Lcom/wire/kalium/logic/sync/WorkSchedulerImplKt; SPLcom/wire/kalium/logic/sync/WorkSchedulerImplKt;->()V SPLcom/wire/kalium/logic/sync/WorkSchedulerImplKt;->buildConnectedPeriodicWorkRequest$default(Lkotlin/reflect/KClass;Lcom/wire/kalium/logic/data/id/QualifiedID;ZILjava/lang/Object;)Landroidx/work/PeriodicWorkRequest; @@ -40417,4 +40416,4 @@ SPLorg/slf4j/helpers/Util;->()V SPLorg/slf4j/helpers/Util;->safeGetBooleanSystemProperty(Ljava/lang/String;)Z SPLorg/slf4j/helpers/Util;->safeGetSystemProperty(Ljava/lang/String;)Ljava/lang/String; Lorg/slf4j/spi/MDCAdapter; -Lorg/slf4j/spi/SLF4JServiceProvider; \ No newline at end of file +Lorg/slf4j/spi/SLF4JServiceProvider; diff --git a/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt b/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt index 0dfa1919d78..c095759aed7 100644 --- a/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt +++ b/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt @@ -22,6 +22,7 @@ import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.di.KaliumCoreLogic import com.wire.android.notification.NotificationChannelsManager import com.wire.android.notification.WireNotificationManager +import com.wire.android.services.SendPendingMessagesAfterForegroundSyncUseCase import com.wire.android.util.CurrentScreenManager import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.CoreLogic @@ -30,6 +31,8 @@ import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.auth.LogoutCallback import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase +import com.wire.kalium.network.NetworkState +import com.wire.kalium.network.NetworkStateObserver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.awaitClose @@ -61,6 +64,8 @@ class GlobalObserversManager @Inject constructor( private val notificationChannelsManager: NotificationChannelsManager, private val userDataStoreProvider: UserDataStoreProvider, private val currentScreenManager: CurrentScreenManager, + private val sendPendingMessagesAfterForegroundSync: SendPendingMessagesAfterForegroundSyncUseCase, + private val networkStateObserver: NetworkStateObserver, ) { // TODO(tests): refactor so scope/dispatcher can be injected and properly stopped private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io()) @@ -78,6 +83,7 @@ class GlobalObserversManager @Inject constructor( } scope.handleLogouts() scope.handleDeleteEphemeralMessageEndDate() + scope.retryPendingMessagesOnAppOpen() } private suspend fun setUpNotifications() { @@ -162,4 +168,36 @@ class GlobalObserversManager @Inject constructor( .collect { userId -> coreLogic.getSessionScope(userId).messages.deleteEphemeralMessageEndDate() } } } + + private fun CoroutineScope.retryPendingMessagesOnAppOpen() { + launch { + currentScreenManager.isAppVisibleFlow() + .filter { isAppVisible -> isAppVisible } + .collectLatest { + val networkState = networkStateObserver.observeNetworkState().value + if (networkState !is NetworkState.ConnectedWithInternet) { + appLogger.i("$TAG: no internet connection, skipping pending messages retry on app open") + return@collectLatest + } + + when (val result = coreLogic.getGlobalScope().session.currentSession()) { + is CurrentSessionResult.Success -> { + val accountInfo = result.accountInfo + if (accountInfo.isValid()) { + sendPendingMessagesAfterForegroundSync(accountInfo.userId) + } else { + appLogger.w("$TAG: current session is invalid, skipping pending messages retry on app open") + } + } + + is CurrentSessionResult.Failure -> + appLogger.w("$TAG: unable to get current valid session, skipping pending messages retry on app open: $result") + } + } + } + } + + private companion object { + private const val TAG = "GlobalObserversManager" + } } diff --git a/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt b/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt index f1817f5d721..34ac651b006 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt @@ -54,6 +54,7 @@ import com.wire.android.notification.broadcastreceivers.PlayPauseAudioMessageRec import com.wire.android.notification.broadcastreceivers.StopAudioMessageReceiver import com.wire.android.search.SearchMetroViewModelBindings import com.wire.android.services.CallService +import com.wire.android.services.PendingMessagesForegroundService import com.wire.android.services.PersistentWebSocketService import com.wire.android.services.PlayingAudioMessageService import com.wire.android.ui.AppLockActivity @@ -125,6 +126,7 @@ interface WireApplicationGraph : ViewModelGraph { fun inject(activity: CallActivity) fun inject(activity: OngoingCallActivity) fun inject(service: PersistentWebSocketService) + fun inject(service: PendingMessagesForegroundService) fun inject(service: CallService) fun inject(service: PlayingAudioMessageService) fun inject(receiver: StartServiceReceiver) diff --git a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DynamicReceiversManager.kt b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DynamicReceiversManager.kt index 7c162f518ae..63801d1c638 100644 --- a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DynamicReceiversManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DynamicReceiversManager.kt @@ -20,10 +20,12 @@ package com.wire.android.notification.broadcastreceivers import android.content.Context import android.content.Intent import android.content.IntentFilter +import androidx.core.content.ContextCompat import com.wire.android.BuildConfig.EMM_SUPPORT_ENABLED import com.wire.android.appLogger import com.wire.android.emm.ManagedConfigurationsReceiver import com.wire.android.di.ApplicationContext +import com.wire.kalium.logic.sync.PendingMessagesForegroundSync import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.SingleIn @@ -35,35 +37,51 @@ import dev.zacsweers.metro.SingleIn @SingleIn(AppScope::class) class DynamicReceiversManager @Inject constructor( @ApplicationContext val context: Context, - private val managedConfigurationsReceiver: ManagedConfigurationsReceiver + private val managedConfigurationsReceiver: ManagedConfigurationsReceiver, + private val pendingMessagesScheduledReceiver: PendingMessagesScheduledReceiver, ) { @Volatile private var isRegistered = false fun registerAll() { - if (EMM_SUPPORT_ENABLED) { - synchronized(this) { - if (!isRegistered) { + synchronized(this) { + if (!isRegistered) { + if (EMM_SUPPORT_ENABLED) { appLogger.i("$TAG Registering Runtime ManagedConfigurations Broadcast receiver") context.registerReceiver(managedConfigurationsReceiver, IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)) - isRegistered = true - } else { - appLogger.w("$TAG Receiver already registered, skipping") } + + appLogger.i("$TAG Registering PendingMessagesScheduledReceiver") + val pendingMessagesIntentFilter = IntentFilter(PendingMessagesForegroundSync.ACTION_SENDING_OF_PENDING_MESSAGES_SCHEDULED) + .apply { addAction(PendingMessagesForegroundSync.ACTION_SENDING_OF_PENDING_MESSAGES_CANCELLED) } + ContextCompat.registerReceiver( + context, + pendingMessagesScheduledReceiver, + pendingMessagesIntentFilter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + + isRegistered = true + } else { + appLogger.w("$TAG Receiver already registered, skipping") } } } fun unregisterAll() { - if (EMM_SUPPORT_ENABLED) { - synchronized(this) { - if (isRegistered) { + synchronized(this) { + if (isRegistered) { + if (EMM_SUPPORT_ENABLED) { appLogger.i("$TAG Unregistering Runtime ManagedConfigurations Broadcast receiver") context.unregisterReceiver(managedConfigurationsReceiver) - isRegistered = false - } else { - appLogger.w("$TAG Receiver not registered, skipping unregister") } + + appLogger.i("$TAG Unregistering PendingMessagesScheduledReceiver") + context.unregisterReceiver(pendingMessagesScheduledReceiver) + + isRegistered = false + } else { + appLogger.w("$TAG Receiver not registered, skipping unregister") } } } diff --git a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/PendingMessagesScheduledReceiver.kt b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/PendingMessagesScheduledReceiver.kt new file mode 100644 index 00000000000..2dc78f4b272 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/PendingMessagesScheduledReceiver.kt @@ -0,0 +1,67 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.notification.broadcastreceivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.wire.android.appLogger +import com.wire.android.services.ServicesManager +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.sync.PendingMessagesForegroundSync +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +@SingleIn(AppScope::class) +class PendingMessagesScheduledReceiver @Inject constructor( + private val servicesManager: ServicesManager, +) : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + PendingMessagesForegroundSync.ACTION_SENDING_OF_PENDING_MESSAGES_SCHEDULED -> { + val userId = intent.userId() + if (userId == null) { + appLogger.w("$TAG: missing user id, skipping pending messages foreground service") + return + } + servicesManager.startPendingMessagesForegroundService(userId) + } + + PendingMessagesForegroundSync.ACTION_SENDING_OF_PENDING_MESSAGES_CANCELLED -> + servicesManager.stopPendingMessagesForegroundService() + + else -> { + appLogger.w("$TAG: unexpected action ${intent.action}") + return + } + } + } + + companion object { + private const val TAG = "PendingMessagesScheduledReceiver" + } +} + +private fun Intent.userId(): UserId? { + val value = getStringExtra(PendingMessagesForegroundSync.EXTRA_USER_ID_VALUE) + val domain = getStringExtra(PendingMessagesForegroundSync.EXTRA_USER_ID_DOMAIN) + return if (value != null && domain != null) UserId(value, domain) else null +} diff --git a/app/src/main/kotlin/com/wire/android/services/PendingMessagesForegroundService.kt b/app/src/main/kotlin/com/wire/android/services/PendingMessagesForegroundService.kt new file mode 100644 index 00000000000..6805515cafd --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/services/PendingMessagesForegroundService.kt @@ -0,0 +1,294 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.services + +import android.app.ForegroundServiceStartNotAllowedException +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import com.wire.android.R +import com.wire.android.appLogger +import com.wire.android.di.KaliumCoreLogic +import com.wire.android.di.metro.wireApplicationGraph +import com.wire.android.notification.NotificationChannelsManager +import com.wire.android.notification.NotificationConstants.PENDING_MESSAGES_SYNC_CHANNEL_ID +import com.wire.android.notification.NotificationConstants.PENDING_MESSAGES_SYNC_CHANNEL_NAME +import com.wire.android.notification.NotificationIds +import com.wire.android.notification.openAppPendingIntent +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.lifecycle.SyncLifecycleManager +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.session.CurrentSessionResult +import com.wire.kalium.logic.sync.PendingMessagesForegroundSync +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +class PendingMessagesForegroundService : Service() { + + @Inject + @KaliumCoreLogic + lateinit var coreLogic: CoreLogic + + @Inject + lateinit var dispatcherProvider: DispatcherProvider + + @Inject + lateinit var notificationChannelsManager: NotificationChannelsManager + + @Inject + private lateinit var sendPendingMessagesAfterForegroundSync: SendPendingMessagesAfterForegroundSyncUseCase + + private val scope by lazy { + CoroutineScope(SupervisorJob() + dispatcherProvider.io()) + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + wireApplicationGraph.inject(this) + super.onCreate() + isServiceStarted = true + startAsForeground(createNotification(waitingForConnection = true)) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!isServiceStarted) { + isServiceStarted = true + startAsForeground(createNotification(waitingForConnection = true)) + } + + val userId = intent?.userId() + if (userId == null) { + appLogger.w("$TAG: missing user id, skipping pending messages send") + stopSelf(startId) + return START_NOT_STICKY + } + + scope.launch { + run(userId) + stopSelf(startId) + } + + return START_NOT_STICKY + } + + private suspend fun run(userId: UserId) { + val connected = withTimeoutOrNull(MAX_WAIT_FOR_NETWORK_MINUTES.minutes) { + networkAvailability().first { it } + } ?: false + + if (!connected) { + appLogger.i("$TAG: network did not become available before timeout") + return + } + + startAsForeground(createNotification(waitingForConnection = false)) + + PendingMessagesForegroundSyncHandler(coreLogic, sendPendingMessagesAfterForegroundSync).sendPendingMessagesForCurrentSession(userId) + } + + private fun networkAvailability(): Flow = callbackFlow { + val connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + fun emitCurrentAvailability() { + trySend(connectivityManager.hasValidatedInternet()) + } + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = emitCurrentAvailability() + override fun onLost(network: Network) = emitCurrentAvailability() + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) = emitCurrentAvailability() + } + + emitCurrentAvailability() + connectivityManager.registerNetworkCallback( + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(), + callback + ) + awaitClose { connectivityManager.unregisterNetworkCallback(callback) } + } + + private fun ConnectivityManager.hasValidatedInternet(): Boolean = + activeNetwork + ?.let(::getNetworkCapabilities) + ?.let { + it.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + it.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } == true + + private fun startAsForeground(notification: Notification) { + notificationChannelsManager.createRegularChannel(PENDING_MESSAGES_SYNC_CHANNEL_ID, PENDING_MESSAGES_SYNC_CHANNEL_NAME) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + ServiceCompat.startForeground( + this, + NotificationIds.PENDING_MESSAGES_SYNC_NOTIFICATION_ID.ordinal, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } catch (e: ForegroundServiceStartNotAllowedException) { + appLogger.e("$TAG: failure while starting foreground: $e") + stopSelf() + } + } else { + ServiceCompat.startForeground( + this, + NotificationIds.PENDING_MESSAGES_SYNC_NOTIFICATION_ID.ordinal, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } + } + + private fun createNotification(waitingForConnection: Boolean): Notification = + NotificationCompat.Builder(this, PENDING_MESSAGES_SYNC_CHANNEL_ID) + .setContentText( + if (waitingForConnection) { + resources.getString(R.string.pending_messages_notification_waiting) + } else { + resources.getString(R.string.pending_messages_notification_sending) + } + ) + .setSmallIcon(R.drawable.websocket_notification_icon_small) + .setContentIntent(openAppPendingIntent(this)) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setAutoCancel(false) + .setOngoing(true) + .build() + + override fun onTimeout(startId: Int, fgsType: Int) { + appLogger.w("$TAG: foreground service timeout reached") + stopSelf(startId) + } + + override fun onDestroy() { + super.onDestroy() + scope.cancel("PendingMessagesForegroundService was destroyed") + isServiceStarted = false + } + + companion object { + private const val TAG = "PendingMessagesForegroundService" + private const val MAX_WAIT_FOR_NETWORK_MINUTES = 30 + + fun newIntent(context: Context, userId: UserId): Intent = + Intent(context, PendingMessagesForegroundService::class.java) + .putExtra(PendingMessagesForegroundSync.EXTRA_USER_ID_VALUE, userId.value) + .putExtra(PendingMessagesForegroundSync.EXTRA_USER_ID_DOMAIN, userId.domain) + + fun stopIntent(context: Context): Intent = + Intent(context, PendingMessagesForegroundService::class.java) + + var isServiceStarted = false + } +} + +internal class PendingMessagesForegroundSyncHandler( + private val currentSession: suspend () -> CurrentSessionResult, + private val sendPendingMessagesAfterForegroundSync: suspend (UserId) -> Unit, +) { + constructor( + coreLogic: CoreLogic, + sendPendingMessagesAfterForegroundSync: SendPendingMessagesAfterForegroundSyncUseCase, + ) : this( + currentSession = { coreLogic.getGlobalScope().session.currentSession() }, + sendPendingMessagesAfterForegroundSync = sendPendingMessagesAfterForegroundSync::invoke + ) + + suspend fun sendPendingMessagesForCurrentSession(scheduledUserId: UserId) { + when (val result = currentSession()) { + is CurrentSessionResult.Success -> { + val accountInfo = result.accountInfo + if (accountInfo.isValid() && accountInfo.userId == scheduledUserId) { + sendPendingMessagesAfterForegroundSync(accountInfo.userId) + } else if (accountInfo.isValid()) { + appLogger.w( + "$TAG: scheduled user ${scheduledUserId.toLogString()} does not match current session " + + "${accountInfo.userId.toLogString()}, skipping pending messages send" + ) + } else { + appLogger.w("$TAG: current session is invalid, skipping pending messages send") + } + } + + is CurrentSessionResult.Failure -> + appLogger.w("$TAG: unable to get current valid session: $result") + } + appLogger.w("$TAG: foreground service finished messages sending") + } + + private companion object { + private const val TAG = "PendingMessagesForegroundService" + } +} + +private fun Intent.userId(): UserId? { + val value = getStringExtra(PendingMessagesForegroundSync.EXTRA_USER_ID_VALUE) + val domain = getStringExtra(PendingMessagesForegroundSync.EXTRA_USER_ID_DOMAIN) + return if (value != null && domain != null) UserId(value, domain) else null +} + +class SendPendingMessagesAfterForegroundSyncUseCase @Inject constructor( + @KaliumCoreLogic private val coreLogic: CoreLogic, + private val syncLifecycleManager: SyncLifecycleManager, +) { + suspend operator fun invoke(userId: UserId) { + syncLifecycleManager.syncTemporarily( + userId = userId, + stayAliveExtraDuration = 3.seconds, + waitForNextSyncState = true, + ) { + val userSessionScope = coreLogic.getSessionScope(userId) + appLogger.i( + "$TAG: sending pending messages for ${userId.toLogString()}, " + + "syncState=${userSessionScope.syncManager.syncState.value}, " + + "userSessionScope=$userSessionScope" + ) + userSessionScope.sendPendingMessages() + } + } + + private companion object { + private const val TAG = "SendPendingMessagesAfterForegroundSyncUseCase" + } +} diff --git a/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt b/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt index a99d6b354d5..3fe1bdb3c2e 100644 --- a/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt +++ b/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt @@ -138,6 +138,19 @@ class ServicesManager @Inject constructor( fun isPersistentWebSocketServiceRunning(): Boolean = PersistentWebSocketService.isServiceStarted + // Pending messages foreground sync + fun startPendingMessagesForegroundService(userId: UserId) { + if (PendingMessagesForegroundService.isServiceStarted) { + appLogger.i("$TAG: PendingMessagesForegroundService already started, not starting again") + } else { + startService(PendingMessagesForegroundService.newIntent(context, userId)) + } + } + + fun stopPendingMessagesForegroundService() { + stopService(PendingMessagesForegroundService.stopIntent(context)) + } + // Playing AudioMessage service fun startPlayingAudioMessageService() { if (PlayingAudioMessageService.isServiceStarted) { diff --git a/app/src/main/kotlin/com/wire/android/util/lifecycle/SyncLifecycleManager.kt b/app/src/main/kotlin/com/wire/android/util/lifecycle/SyncLifecycleManager.kt index 9009877f498..5e8820192ed 100644 --- a/app/src/main/kotlin/com/wire/android/util/lifecycle/SyncLifecycleManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/lifecycle/SyncLifecycleManager.kt @@ -22,12 +22,14 @@ import com.wire.android.appLogger import com.wire.android.di.KaliumCoreLogic import com.wire.android.util.CurrentScreenManager import com.wire.kalium.logger.KaliumLogger.Companion.ApplicationFlow.SYNC -import com.wire.kalium.logger.obfuscateDomain import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.session.GetAllSessionsResult import com.wire.kalium.logic.sync.SyncRequestResult +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -36,9 +38,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.SingleIn import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -94,21 +93,43 @@ class SyncLifecycleManager @Inject constructor( * releasing sync. * If there are more ongoing sync requests, this will */ - suspend fun syncTemporarily(userId: UserId, stayAliveExtraDuration: Duration = 0.seconds) { - logger.d( - "Handling connection policy for push notification of " + - "user=${userId.value.obfuscateId()}@${userId.domain.obfuscateDomain()}" - ) + suspend fun syncTemporarily( + userId: UserId, + stayAliveExtraDuration: Duration = 0.seconds, + waitForNextSyncState: Boolean = false + ) { + syncTemporarily(userId, stayAliveExtraDuration, waitForNextSyncState) { + Unit + } + } + + /** + * Attempts to perform sync, runs [actionWhenLive] after reaching live sync, then holds + * the sync request for [stayAliveExtraDuration] before releasing it. + */ + suspend fun syncTemporarily( + userId: UserId, + stayAliveExtraDuration: Duration = 0.seconds, + waitForNextSyncState: Boolean = false, + actionWhenLive: suspend () -> Unit + ) { coreLogic.getSessionScope(userId).run { logger.d("Starting Sync request") syncExecutor.request { logger.d("Waiting until live") - when (waitUntilLiveOrFailure()) { + val syncRequestResult = if (waitForNextSyncState) { + waitUntilNextLiveOrFailure() + } else { + waitUntilLiveOrFailure() + } + when (syncRequestResult) { is SyncRequestResult.Failure -> logger.w("Failed waiting until live") - is SyncRequestResult.Success -> + is SyncRequestResult.Success -> { + actionWhenLive() delay(stayAliveExtraDuration) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99b9479c8de..5b518ac9a2f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1927,4 +1927,9 @@ In group conversations, the group admin can overwrite this setting. Added apps have access to the content of this conversation. You were promoted to group admin More information about this backend + + + Waiting for connection + Sending messages + diff --git a/app/src/main/startup-prof.txt b/app/src/main/startup-prof.txt index 94d9aaf352d..7958e9fb8ac 100644 --- a/app/src/main/startup-prof.txt +++ b/app/src/main/startup-prof.txt @@ -30668,7 +30668,6 @@ Lcom/wire/kalium/logic/sync/GlobalWorkScheduler; Lcom/wire/kalium/logic/sync/GlobalWorkSchedulerImpl; SPLcom/wire/kalium/logic/sync/GlobalWorkSchedulerImpl;->(Landroid/content/Context;Lcom/wire/kalium/logic/GlobalKaliumScope;)V SPLcom/wire/kalium/logic/sync/GlobalWorkSchedulerImpl;->schedulePeriodicApiVersionUpdate()V -Lcom/wire/kalium/logic/sync/PendingMessagesSenderWorker; Lcom/wire/kalium/logic/sync/WorkSchedulerImplKt; SPLcom/wire/kalium/logic/sync/WorkSchedulerImplKt;->()V SPLcom/wire/kalium/logic/sync/WorkSchedulerImplKt;->buildConnectedPeriodicWorkRequest$default(Lkotlin/reflect/KClass;Lcom/wire/kalium/logic/data/id/QualifiedID;ZILjava/lang/Object;)Landroidx/work/PeriodicWorkRequest; @@ -40417,4 +40416,4 @@ SPLorg/slf4j/helpers/Util;->()V SPLorg/slf4j/helpers/Util;->safeGetBooleanSystemProperty(Ljava/lang/String;)Z SPLorg/slf4j/helpers/Util;->safeGetSystemProperty(Ljava/lang/String;)Ljava/lang/String; Lorg/slf4j/spi/MDCAdapter; -Lorg/slf4j/spi/SLF4JServiceProvider; \ No newline at end of file +Lorg/slf4j/spi/SLF4JServiceProvider; diff --git a/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt b/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt index daea238ada6..26cfc5ec86c 100644 --- a/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt @@ -25,6 +25,7 @@ import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.framework.TestUser import com.wire.android.notification.NotificationChannelsManager import com.wire.android.notification.WireNotificationManager +import com.wire.android.services.SendPendingMessagesAfterForegroundSyncUseCase import com.wire.android.util.CurrentScreenManager import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.CoreLogic @@ -40,6 +41,8 @@ import com.wire.kalium.logic.feature.call.usecase.EndCallOnConversationChangeUse import com.wire.kalium.logic.feature.message.MessageScope import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase +import com.wire.kalium.network.NetworkState +import com.wire.kalium.network.NetworkStateObserver import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -166,6 +169,108 @@ class GlobalObserversManagerTest { coVerify(exactly = 0) { arrangement.messageScope.deleteEphemeralMessageEndDate() } } + @Test + fun `given app visible and valid session, when app opens, then retry sending pending messages`() { + val (arrangement, manager) = Arrangement() + .withCurrentSession(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + .withAppVisibleFlow(true) + .arrange() + + manager.observe() + + coVerify(exactly = 1) { arrangement.sendPendingMessagesAfterForegroundSync(TestUser.SELF_USER.id) } + } + + @Test + fun `given app visible, valid session, and not connected, when app opens, then do not retry sending pending messages`() { + val (arrangement, manager) = Arrangement() + .withCurrentSession(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + .withNetworkState(NetworkState.NotConnected) + .withAppVisibleFlow(true) + .arrange() + + manager.observe() + + coVerify(exactly = 0) { arrangement.sendPendingMessagesAfterForegroundSync(any()) } + } + + @Test + fun `given app visible, valid session, and connected without internet, when app opens, then do not retry sending pending messages`() { + val (arrangement, manager) = Arrangement() + .withCurrentSession(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + .withNetworkState(NetworkState.ConnectedWithoutInternet) + .withAppVisibleFlow(true) + .arrange() + + manager.observe() + + coVerify(exactly = 0) { arrangement.sendPendingMessagesAfterForegroundSync(any()) } + } + + @Test + fun `given app not visible and valid session, when observing app open, then do not retry sending pending messages`() { + val (arrangement, manager) = Arrangement() + .withCurrentSession(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + .withAppVisibleFlow(false) + .arrange() + + manager.observe() + + coVerify(exactly = 0) { arrangement.sendPendingMessagesAfterForegroundSync(any()) } + } + + @Test + fun `given app visible and invalid session, when app opens, then do not retry sending pending messages`() { + val (arrangement, manager) = Arrangement() + .withCurrentSession(CurrentSessionResult.Success(AccountInfo.Invalid(TestUser.SELF_USER.id, LogoutReason.DELETED_ACCOUNT))) + .withAppVisibleFlow(true) + .arrange() + + manager.observe() + + coVerify(exactly = 0) { arrangement.sendPendingMessagesAfterForegroundSync(any()) } + } + + @Test + fun `given app visible and no session, when app opens, then do not retry sending pending messages`() { + val (arrangement, manager) = Arrangement() + .withCurrentSession(CurrentSessionResult.Failure.SessionNotFound) + .withAppVisibleFlow(true) + .arrange() + + manager.observe() + + coVerify(exactly = 0) { arrangement.sendPendingMessagesAfterForegroundSync(any()) } + } + + @Test + fun `given app visible and session failure, when app opens, then do not retry sending pending messages`() { + val (arrangement, manager) = Arrangement() + .withCurrentSession(CurrentSessionResult.Failure.Generic(CoreFailure.Unknown(RuntimeException("error")))) + .withAppVisibleFlow(true) + .arrange() + + manager.observe() + + coVerify(exactly = 0) { arrangement.sendPendingMessagesAfterForegroundSync(any()) } + } + + @Test + fun `given valid session when app becomes visible then retry sending pending messages`() { + val appVisibleFlow = MutableStateFlow(false) + val (arrangement, manager) = Arrangement() + .withCurrentSession(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + .withAppVisibleFlow(appVisibleFlow) + .arrange() + + manager.observe() + coVerify(exactly = 0) { arrangement.sendPendingMessagesAfterForegroundSync(any()) } + + appVisibleFlow.value = true + + coVerify(exactly = 1) { arrangement.sendPendingMessagesAfterForegroundSync(TestUser.SELF_USER.id) } + } + @Test fun `given app visible and session failure, when handling ephemeral messages, then do not call deleteEphemeralMessageEndDate`() { val (arrangement, manager) = Arrangement() @@ -253,6 +358,12 @@ class GlobalObserversManagerTest { @MockK lateinit var messageScope: MessageScope + @MockK + lateinit var sendPendingMessagesAfterForegroundSync: SendPendingMessagesAfterForegroundSyncUseCase + + @MockK + lateinit var networkStateObserver: NetworkStateObserver + private val manager by lazy { GlobalObserversManager( dispatcherProvider = TestDispatcherProvider(), @@ -261,6 +372,8 @@ class GlobalObserversManagerTest { notificationManager = notificationManager, userDataStoreProvider = userDataStoreProvider, currentScreenManager = currentScreenManager, + sendPendingMessagesAfterForegroundSync = sendPendingMessagesAfterForegroundSync, + networkStateObserver = networkStateObserver, ) } @@ -278,10 +391,13 @@ class GlobalObserversManagerTest { every { userSessionScope.calls } returns callsScope every { userSessionScope.messages } returns messageScope coEvery { messageScope.deleteEphemeralMessageEndDate() } returns Unit + coEvery { sendPendingMessagesAfterForegroundSync(any()) } returns Unit withPersistentWebSocketConnectionStatuses(emptyList()) withValidAccounts(emptyList()) withCurrentSessionFlow(CurrentSessionResult.Failure.SessionNotFound) + withCurrentSession(CurrentSessionResult.Failure.SessionNotFound) withAppVisibleFlow(true) + withNetworkState(NetworkState.ConnectedWithInternet) } fun withValidAccounts(list: List>): Arrangement = apply { @@ -301,8 +417,20 @@ class GlobalObserversManagerTest { coEvery { coreLogic.getGlobalScope().session.currentSessionFlow() } returns flowOf(result) } + fun withCurrentSession(result: CurrentSessionResult): Arrangement = apply { + coEvery { coreLogic.getGlobalScope().session.currentSession() } returns result + } + + fun withNetworkState(state: NetworkState): Arrangement = apply { + every { networkStateObserver.observeNetworkState() } returns MutableStateFlow(state) + } + fun withAppVisibleFlow(isVisible: Boolean) = apply { - coEvery { currentScreenManager.isAppVisibleFlow() } returns MutableStateFlow(isVisible) + withAppVisibleFlow(MutableStateFlow(isVisible)) + } + + fun withAppVisibleFlow(flow: MutableStateFlow) = apply { + coEvery { currentScreenManager.isAppVisibleFlow() } returns flow } fun arrange() = this to manager diff --git a/app/src/test/kotlin/com/wire/android/framework/fake/FakeSyncExecutor.kt b/app/src/test/kotlin/com/wire/android/framework/fake/FakeSyncExecutor.kt index ff956d009d3..a7f3a55efb6 100644 --- a/app/src/test/kotlin/com/wire/android/framework/fake/FakeSyncExecutor.kt +++ b/app/src/test/kotlin/com/wire/android/framework/fake/FakeSyncExecutor.kt @@ -26,8 +26,10 @@ import com.wire.kalium.util.DelicateKaliumApi open class FakeSyncExecutor : SyncExecutor() { var waitUntilLiveCount = 0 + var waitUntilNextLiveCount = 0 var requestCount = 0 open fun onWaitUntilLiveOrFailure(): SyncRequestResult = SyncRequestResult.Success.also { waitUntilLiveCount++ } + open fun onWaitUntilNextLiveOrFailure(): SyncRequestResult = SyncRequestResult.Success.also { waitUntilNextLiveCount++ } open fun onWaitUntilOrFailure(syncState: SyncState): SyncRequestResult = SyncRequestResult.Success open fun onKeepSyncAlwaysOn() {} @@ -49,6 +51,8 @@ open class FakeSyncExecutor : SyncExecutor() { override suspend fun waitUntilLiveOrFailure(): SyncRequestResult = onWaitUntilLiveOrFailure() + override suspend fun waitUntilNextLiveOrFailure(): SyncRequestResult = onWaitUntilNextLiveOrFailure() + @DelicateKaliumApi(message = "By calling this, Sync will run indefinitely.") override fun keepSyncAlwaysOn() { onKeepSyncAlwaysOn() diff --git a/app/src/test/kotlin/com/wire/android/notification/broadcastreceivers/PendingMessagesScheduledReceiverTest.kt b/app/src/test/kotlin/com/wire/android/notification/broadcastreceivers/PendingMessagesScheduledReceiverTest.kt new file mode 100644 index 00000000000..1981971b78b --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/notification/broadcastreceivers/PendingMessagesScheduledReceiverTest.kt @@ -0,0 +1,101 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.notification.broadcastreceivers + +import android.content.Context +import android.content.Intent +import com.wire.android.services.ServicesManager +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.sync.PendingMessagesForegroundSync +import io.mockk.every +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class PendingMessagesScheduledReceiverTest { + + @Test + fun `given scheduled pending messages broadcast with user id when received then starts foreground service for user`() = runTest { + val (arrangement, receiver) = Arrangement().arrange() + + receiver.onReceive(arrangement.context, scheduledIntent(USER_ID)) + + verify(exactly = 1) { arrangement.servicesManager.startPendingMessagesForegroundService(USER_ID) } + } + + @Test + fun `given scheduled pending messages broadcast without user id when received then does not start foreground service`() = runTest { + val (arrangement, receiver) = Arrangement().arrange() + + receiver.onReceive(arrangement.context, scheduledIntent()) + + verify(exactly = 0) { arrangement.servicesManager.startPendingMessagesForegroundService(any()) } + } + + @Test + fun `given unexpected broadcast when received then does not start foreground service`() = runTest { + val (arrangement, receiver) = Arrangement().arrange() + + receiver.onReceive(arrangement.context, mockk { every { action } returns "unexpected-action" }) + + verify(exactly = 0) { arrangement.servicesManager.startPendingMessagesForegroundService(any()) } + } + + @Test + fun `given cancelled pending messages broadcast when received then stops foreground service`() = runTest { + val (arrangement, receiver) = Arrangement().arrange() + + receiver.onReceive( + context = arrangement.context, + intent = mockk { every { action } returns PendingMessagesForegroundSync.ACTION_SENDING_OF_PENDING_MESSAGES_CANCELLED } + ) + + verify(exactly = 1) { arrangement.servicesManager.stopPendingMessagesForegroundService() } + verify(exactly = 0) { arrangement.servicesManager.startPendingMessagesForegroundService(any()) } + } + + private class Arrangement { + + @MockK + lateinit var context: Context + + @MockK(relaxUnitFun = true) + lateinit var servicesManager: ServicesManager + + init { + MockKAnnotations.init(this) + } + + fun arrange(): Pair = + this to PendingMessagesScheduledReceiver(servicesManager) + } + + private companion object { + private val USER_ID = UserId("user", "wire.com") + + fun scheduledIntent(userId: UserId? = null): Intent = + mockk { + every { action } returns PendingMessagesForegroundSync.ACTION_SENDING_OF_PENDING_MESSAGES_SCHEDULED + every { getStringExtra(PendingMessagesForegroundSync.EXTRA_USER_ID_VALUE) } returns userId?.value + every { getStringExtra(PendingMessagesForegroundSync.EXTRA_USER_ID_DOMAIN) } returns userId?.domain + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/services/PendingMessagesForegroundSyncHandlerTest.kt b/app/src/test/kotlin/com/wire/android/services/PendingMessagesForegroundSyncHandlerTest.kt new file mode 100644 index 00000000000..f305900305c --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/services/PendingMessagesForegroundSyncHandlerTest.kt @@ -0,0 +1,104 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.services + +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.logic.data.auth.AccountInfo +import com.wire.kalium.logic.data.logout.LogoutReason +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.session.CurrentSessionResult +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test + +class PendingMessagesForegroundSyncHandlerTest { + + @Test + fun `given current session is valid when sending pending messages then sends only for current user`() = runTest { + val currentUserId = UserId("currentUser", "wire.com") + val inactiveUserId = UserId("inactiveUser", "wire.com") + val sentPendingMessagesFor = mutableListOf() + val handler = PendingMessagesForegroundSyncHandler( + currentSession = { CurrentSessionResult.Success(AccountInfo.Valid(currentUserId)) }, + sendPendingMessagesAfterForegroundSync = { sentPendingMessagesFor.add(it) } + ) + + handler.sendPendingMessagesForCurrentSession(currentUserId) + + assertEquals(listOf(currentUserId), sentPendingMessagesFor) + assertFalse(sentPendingMessagesFor.contains(inactiveUserId)) + } + + @Test + fun `given current session is for different user when sending pending messages then does not send`() = runTest { + val currentUserId = UserId("currentUser", "wire.com") + val scheduledUserId = UserId("scheduledUser", "wire.com") + val sentPendingMessagesFor = mutableListOf() + val handler = PendingMessagesForegroundSyncHandler( + currentSession = { CurrentSessionResult.Success(AccountInfo.Valid(currentUserId)) }, + sendPendingMessagesAfterForegroundSync = { sentPendingMessagesFor.add(it) } + ) + + handler.sendPendingMessagesForCurrentSession(scheduledUserId) + + assertEquals(emptyList(), sentPendingMessagesFor) + } + + @Test + fun `given current session is invalid when sending pending messages then does not send`() = runTest { + val currentUserId = UserId("currentUser", "wire.com") + val sentPendingMessagesFor = mutableListOf() + val handler = PendingMessagesForegroundSyncHandler( + currentSession = { + CurrentSessionResult.Success(AccountInfo.Invalid(currentUserId, LogoutReason.SELF_SOFT_LOGOUT)) + }, + sendPendingMessagesAfterForegroundSync = { sentPendingMessagesFor.add(it) } + ) + + handler.sendPendingMessagesForCurrentSession(currentUserId) + + assertEquals(emptyList(), sentPendingMessagesFor) + } + + @Test + fun `given current session is missing when sending pending messages then does not send`() = runTest { + val sentPendingMessagesFor = mutableListOf() + val handler = PendingMessagesForegroundSyncHandler( + currentSession = { CurrentSessionResult.Failure.SessionNotFound }, + sendPendingMessagesAfterForegroundSync = { sentPendingMessagesFor.add(it) } + ) + + handler.sendPendingMessagesForCurrentSession(UserId("currentUser", "wire.com")) + + assertEquals(emptyList(), sentPendingMessagesFor) + } + + @Test + fun `given current session lookup fails when sending pending messages then does not send`() = runTest { + val sentPendingMessagesFor = mutableListOf() + val handler = PendingMessagesForegroundSyncHandler( + currentSession = { CurrentSessionResult.Failure.Generic(CoreFailure.Unknown(null)) }, + sendPendingMessagesAfterForegroundSync = { sentPendingMessagesFor.add(it) } + ) + + handler.sendPendingMessagesForCurrentSession(UserId("currentUser", "wire.com")) + + assertEquals(emptyList(), sentPendingMessagesFor) + } +} diff --git a/app/src/test/kotlin/com/wire/android/services/SendPendingMessagesAfterForegroundSyncUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/services/SendPendingMessagesAfterForegroundSyncUseCaseTest.kt new file mode 100644 index 00000000000..915dd7ac62f --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/services/SendPendingMessagesAfterForegroundSyncUseCaseTest.kt @@ -0,0 +1,116 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.services + +import com.wire.android.framework.fake.FakeSyncExecutor +import com.wire.android.util.CurrentScreenManager +import com.wire.android.util.lifecycle.SyncLifecycleManager +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.sync.SyncState +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.UserSessionScope +import com.wire.kalium.logic.sync.SendPendingMessagesUseCase +import com.wire.kalium.logic.sync.SyncRequestResult +import com.wire.kalium.logic.sync.SyncStateObserver +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +@Suppress("DEPRECATION") +class SendPendingMessagesAfterForegroundSyncUseCaseTest { + + @Test + fun `given next sync reaches live when sending pending messages then sends inside a single temporary sync request`() = runTest { + val (arrangement, useCase) = Arrangement() + .arrange() + + useCase(USER_ID) + + assertEquals(1, arrangement.syncExecutor.requestCount) + assertEquals(0, arrangement.syncExecutor.waitUntilLiveCount) + assertEquals(1, arrangement.syncExecutor.waitUntilNextLiveCount) + coVerify(exactly = 1) { arrangement.sendPendingMessages() } + } + + @Test + fun `given next sync fails when sending pending messages then does not send`() = runTest { + val (arrangement, useCase) = Arrangement() + .withNextSyncRequestResult(SyncRequestResult.Failure(CoreFailure.Unknown(RuntimeException("sync failed")))) + .arrange() + + useCase(USER_ID) + + assertEquals(1, arrangement.syncExecutor.requestCount) + assertEquals(0, arrangement.syncExecutor.waitUntilLiveCount) + assertEquals(1, arrangement.syncExecutor.waitUntilNextLiveCount) + coVerify(exactly = 0) { arrangement.sendPendingMessages() } + } + + private class Arrangement { + + @MockK + lateinit var coreLogic: CoreLogic + + @MockK + lateinit var currentScreenManager: CurrentScreenManager + + @MockK + lateinit var userSessionScope: UserSessionScope + + @MockK + lateinit var syncStateObserver: SyncStateObserver + + @MockK + lateinit var sendPendingMessages: SendPendingMessagesUseCase + + var syncExecutor = FakeSyncExecutor() + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { coreLogic.getSessionScope(USER_ID) } returns userSessionScope + every { userSessionScope.syncManager } returns syncStateObserver + every { syncStateObserver.syncState } returns MutableStateFlow(SyncState.Live) + every { userSessionScope.sendPendingMessages } returns sendPendingMessages + coEvery { sendPendingMessages() } returns SendPendingMessagesUseCase.Result.Success + } + + fun withNextSyncRequestResult(syncRequestResult: SyncRequestResult) = apply { + syncExecutor = object : FakeSyncExecutor() { + override fun onWaitUntilNextLiveOrFailure(): SyncRequestResult = + syncRequestResult.also { waitUntilNextLiveCount++ } + } + } + + fun arrange(): Pair { + every { userSessionScope.syncExecutor } returns syncExecutor + val syncLifecycleManager = SyncLifecycleManager(currentScreenManager, coreLogic) + return this to SendPendingMessagesAfterForegroundSyncUseCase(coreLogic, syncLifecycleManager) + } + } + + private companion object { + private val USER_ID = UserId("currentUser", "wire.com") + } +} diff --git a/app/src/test/kotlin/com/wire/android/util/lifecycle/SyncLifecycleManagerTest.kt b/app/src/test/kotlin/com/wire/android/util/lifecycle/SyncLifecycleManagerTest.kt index c0e8385902b..372fc15e2a9 100644 --- a/app/src/test/kotlin/com/wire/android/util/lifecycle/SyncLifecycleManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/util/lifecycle/SyncLifecycleManagerTest.kt @@ -21,11 +21,13 @@ package com.wire.android.util.lifecycle import com.wire.android.framework.TestUser import com.wire.android.framework.fake.FakeSyncExecutor import com.wire.android.util.CurrentScreenManager +import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.feature.UserSessionScope import com.wire.kalium.logic.feature.session.GetAllSessionsResult import com.wire.kalium.logic.feature.session.ObserveSessionsUseCase +import com.wire.kalium.logic.sync.SyncRequestResult import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every @@ -33,10 +35,15 @@ import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.milliseconds class SyncLifecycleManagerTest { @@ -98,6 +105,104 @@ class SyncLifecycleManagerTest { assertEquals(1, arrangement.syncExecutor.waitUntilLiveCount) } + @Test + fun givenSyncReachesLive_whenRequestingTemporarySyncWithAction_thenShouldRunActionBeforeStayAliveDelayCompletes() = runTest { + val events = mutableListOf() + val (arrangement, syncLifecycleManager) = Arrangement() + .withAppInTheForeground() + .withSyncExecutor(object : FakeSyncExecutor() { + override fun onWaitUntilLiveOrFailure(): SyncRequestResult { + events += "live" + return super.onWaitUntilLiveOrFailure() + } + }) + .arrange() + + val syncJob = launch { + syncLifecycleManager.syncTemporarily(TestUser.SELF_USER_ID, 100.milliseconds) { + events += "action" + } + } + + runCurrent() + + assertEquals(listOf("live", "action"), events) + assertTrue(syncJob.isActive) + + advanceTimeBy(100.milliseconds) + advanceUntilIdle() + + assertFalse(syncJob.isActive) + assertEquals(1, arrangement.syncExecutor.requestCount) + assertEquals(1, arrangement.syncExecutor.waitUntilLiveCount) + assertEquals(0, arrangement.syncExecutor.waitUntilNextLiveCount) + } + + @Test + fun givenSyncReachesNextLive_whenRequestingTemporarySyncWithAction_thenShouldUseNextLiveWait() = runTest { + val events = mutableListOf() + val (arrangement, syncLifecycleManager) = Arrangement() + .withAppInTheForeground() + .withSyncExecutor(object : FakeSyncExecutor() { + override fun onWaitUntilNextLiveOrFailure(): SyncRequestResult { + events += "nextLive" + return super.onWaitUntilNextLiveOrFailure() + } + }) + .arrange() + + syncLifecycleManager.syncTemporarily( + userId = TestUser.SELF_USER_ID, + waitForNextSyncState = true, + ) { + events += "action" + } + + assertEquals(listOf("nextLive", "action"), events) + assertEquals(1, arrangement.syncExecutor.requestCount) + assertEquals(0, arrangement.syncExecutor.waitUntilLiveCount) + assertEquals(1, arrangement.syncExecutor.waitUntilNextLiveCount) + } + + @Test + fun givenSyncFailsBeforeLive_whenRequestingTemporarySyncWithAction_thenShouldNotRunAction() = runTest { + val (arrangement, syncLifecycleManager) = Arrangement() + .withAppInTheForeground() + .withSyncRequestResult(SyncRequestResult.Failure(CoreFailure.Unknown(RuntimeException("sync failed")))) + .arrange() + var actionInvocationCount = 0 + + syncLifecycleManager.syncTemporarily(TestUser.SELF_USER_ID) { + actionInvocationCount++ + } + + assertEquals(0, actionInvocationCount) + assertEquals(1, arrangement.syncExecutor.requestCount) + assertEquals(1, arrangement.syncExecutor.waitUntilLiveCount) + assertEquals(0, arrangement.syncExecutor.waitUntilNextLiveCount) + } + + @Test + fun givenNextSyncStateFails_whenRequestingTemporarySyncWithAction_thenShouldNotRunAction() = runTest { + val (arrangement, syncLifecycleManager) = Arrangement() + .withAppInTheForeground() + .withNextSyncRequestResult(SyncRequestResult.Failure(CoreFailure.Unknown(RuntimeException("sync failed")))) + .arrange() + var actionInvocationCount = 0 + + syncLifecycleManager.syncTemporarily( + userId = TestUser.SELF_USER_ID, + waitForNextSyncState = true, + ) { + actionInvocationCount++ + } + + assertEquals(0, actionInvocationCount) + assertEquals(1, arrangement.syncExecutor.requestCount) + assertEquals(0, arrangement.syncExecutor.waitUntilLiveCount) + assertEquals(1, arrangement.syncExecutor.waitUntilNextLiveCount) + } + private class Arrangement { @MockK @@ -139,6 +244,24 @@ class SyncLifecycleManagerTest { every { currentScreenManager.isAppVisibleFlow() } returns MutableStateFlow(true) } + fun withSyncExecutor(syncExecutor: FakeSyncExecutor) = apply { + this.syncExecutor = syncExecutor + } + + fun withSyncRequestResult(syncRequestResult: SyncRequestResult) = apply { + syncExecutor = object : FakeSyncExecutor() { + override fun onWaitUntilLiveOrFailure(): SyncRequestResult = + syncRequestResult.also { waitUntilLiveCount++ } + } + } + + fun withNextSyncRequestResult(syncRequestResult: SyncRequestResult) = apply { + syncExecutor = object : FakeSyncExecutor() { + override fun onWaitUntilNextLiveOrFailure(): SyncRequestResult = + syncRequestResult.also { waitUntilNextLiveCount++ } + } + } + fun arrange() = this to syncLifecycleManager.also { every { userSessionScope.syncExecutor } returns syncExecutor } diff --git a/core/notification/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt b/core/notification/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt index d6db391acfa..94fd44550cd 100644 --- a/core/notification/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt +++ b/core/notification/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt @@ -46,6 +46,9 @@ object NotificationConstants { const val MESSAGE_SYNC_CHANNEL_ID = "com.wire.android.message_synchronization" const val MESSAGE_SYNC_CHANNEL_NAME = "Message synchronization" + const val PENDING_MESSAGES_SYNC_CHANNEL_ID = "com.wire.android.pending_messages_synchronization" + const val PENDING_MESSAGES_SYNC_CHANNEL_NAME = "Pending messages synchronization" + const val OTHER_CHANNEL_ID = "com.wire.android.other" const val OTHER_CHANNEL_NAME = "Other essential actions" @@ -97,4 +100,5 @@ enum class NotificationIds { PERSISTENT_CHECK_NOTIFICATION_ID, PLAYING_AUDIO_MESSAGE_ID, UPLOADING_DATA_NOTIFICATION_ID, + PENDING_MESSAGES_SYNC_NOTIFICATION_ID, } diff --git a/kalium b/kalium index 7fed478f72d..3554b9087b3 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 7fed478f72d9f885211a0f088ab40c96cc5670ec +Subproject commit 3554b9087b32426e6b308ab572f50d6c05736980