From 3118afdba0d762cbab6edea8ef9da8018ced6818 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sat, 8 Aug 2026 03:00:16 +0200 Subject: [PATCH] fix(android): keep active shares alive in background --- localization/strings.json | 51 ++++++++++++++ shared/src/androidMain/AndroidManifest.xml | 8 +++ .../com/vnidrop/app/Platform.android.kt | 5 +- .../AndroidBackgroundSharingController.kt | 18 +++++ .../background/BackgroundSharingService.kt | 69 +++++++++++++++++++ .../composeResources/values-de/strings.xml | 3 + .../composeResources/values-es/strings.xml | 3 + .../composeResources/values-fr/strings.xml | 3 + .../composeResources/values-it/strings.xml | 3 + .../composeResources/values-nl/strings.xml | 3 + .../composeResources/values-pl/strings.xml | 3 + .../composeResources/values-pt/strings.xml | 3 + .../composeResources/values-ru/strings.xml | 3 + .../composeResources/values/strings.xml | 3 + .../kotlin/com/vnidrop/app/AppGraph.kt | 7 ++ .../kotlin/com/vnidrop/app/Platform.kt | 2 + .../BackgroundSharingCoordinator.kt | 38 ++++++++++ .../BackgroundSharingCoordinatorTest.kt | 63 +++++++++++++++++ .../kotlin/com/vnidrop/app/Platform.jvm.kt | 2 + 19 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/background/AndroidBackgroundSharingController.kt create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/background/BackgroundSharingService.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/background/BackgroundSharingCoordinator.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/background/BackgroundSharingCoordinatorTest.kt diff --git a/localization/strings.json b/localization/strings.json index 49e9fda..15682f5 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -1740,6 +1740,57 @@ "ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне." } }, + "notifications_background_sharing_body": { + "context": "Android foreground-service notification: explains why VniDrop stays active.", + "targets": [ + "kmp" + ], + "translations": { + "en": "VniDrop is ready to share your files in the background.", + "fr": "VniDrop est prêt à partager vos fichiers en arrière-plan.", + "es": "VniDrop está listo para compartir sus archivos en segundo plano.", + "it": "VniDrop è pronto a condividere i tuoi file in background.", + "de": "VniDrop kann Ihre Dateien im Hintergrund freigeben.", + "pt": "O VniDrop está pronto para partilhar os seus ficheiros em segundo plano.", + "pl": "VniDrop jest gotowy do udostępniania plików w tle.", + "nl": "VniDrop is klaar om uw bestanden op de achtergrond te delen.", + "ru": "VniDrop готов отправлять ваши файлы в фоновом режиме." + } + }, + "notifications_background_sharing_channel": { + "context": "Android system notification channel for an active outgoing share.", + "targets": [ + "kmp" + ], + "translations": { + "en": "Active transfers", + "fr": "Transferts actifs", + "es": "Transferencias activas", + "it": "Trasferimenti attivi", + "de": "Aktive Übertragungen", + "pt": "Transferências ativas", + "pl": "Aktywne transfery", + "nl": "Actieve overdrachten", + "ru": "Активные передачи" + } + }, + "notifications_background_sharing_title": { + "context": "Android foreground-service notification title while an outgoing share is available.", + "targets": [ + "kmp" + ], + "translations": { + "en": "Sharing in the background", + "fr": "Partage en arrière-plan", + "es": "Compartiendo en segundo plano", + "it": "Condivisione in background", + "de": "Freigabe im Hintergrund", + "pt": "Partilha em segundo plano", + "pl": "Udostępnianie w tle", + "nl": "Delen op de achtergrond", + "ru": "Отправка в фоне" + } + }, "notifications_enabled_message": { "context": "Settings > Notifications: confirmation when notifications are enabled.", "translations": { diff --git a/shared/src/androidMain/AndroidManifest.xml b/shared/src/androidMain/AndroidManifest.xml index ef2a932..f078fe9 100644 --- a/shared/src/androidMain/AndroidManifest.xml +++ b/shared/src/androidMain/AndroidManifest.xml @@ -2,7 +2,15 @@ + + + + + diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt index fda519c..013c7c6 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt @@ -8,6 +8,7 @@ import android.os.Build import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vnidrop.app.background.AndroidBackgroundSharingController import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.notifications.rememberAndroidLocalNotificationService import com.vnidrop.app.feature.receive.ExternalInvitationController @@ -18,7 +19,8 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitati val context = activity.applicationContext val fileSystemService = rememberFileSystemService() val notificationService = rememberAndroidLocalNotificationService(activity) - return remember(context, fileSystemService, notificationService) { + val backgroundSharingController = remember(context) { AndroidBackgroundSharingController(context) } + return remember(context, fileSystemService, notificationService, backgroundSharingController) { AppDependencies( environment = PlatformEnvironment( name = "Android ${Build.VERSION.SDK_INT}", @@ -30,6 +32,7 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitati deviceInfoProvider = AndroidDeviceInfoProvider(context), fileSystemService = fileSystemService, localNotificationService = notificationService, + backgroundSharingController = backgroundSharingController, externalInvitations = externalInvitations, ) } diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/background/AndroidBackgroundSharingController.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/background/AndroidBackgroundSharingController.kt new file mode 100644 index 0000000..a940ea4 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/background/AndroidBackgroundSharingController.kt @@ -0,0 +1,18 @@ +package com.vnidrop.app.background + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat + +class AndroidBackgroundSharingController( + private val context: Context, +) : BackgroundSharingController { + override fun setSharingActive(active: Boolean) { + val intent = Intent(context, BackgroundSharingService::class.java) + if (active) { + ContextCompat.startForegroundService(context, intent) + } else { + context.stopService(intent) + } + } +} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/background/BackgroundSharingService.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/background/BackgroundSharingService.kt new file mode 100644 index 0000000..8777ceb --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/background/BackgroundSharingService.kt @@ -0,0 +1,69 @@ +package com.vnidrop.app.background + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.IBinder +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.notifications_background_sharing_body +import vnidrop.shared.generated.resources.notifications_background_sharing_channel +import vnidrop.shared.generated.resources.notifications_background_sharing_title + +class BackgroundSharingService : Service() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + ensureNotificationChannel() + startForeground(NotificationId, createNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + stopForeground(STOP_FOREGROUND_REMOVE) + super.onDestroy() + } + + private fun ensureNotificationChannel() { + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel( + ChannelId, + localizedString(Res.string.notifications_background_sharing_channel), + NotificationManager.IMPORTANCE_LOW, + ), + ) + } + + private fun createNotification(): Notification { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + val contentIntent = launchIntent?.let { + PendingIntent.getActivity( + this, + 0, + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + return Notification.Builder(this, ChannelId) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setContentTitle(localizedString(Res.string.notifications_background_sharing_title)) + .setContentText(localizedString(Res.string.notifications_background_sharing_body)) + .setOngoing(true) + .setContentIntent(contentIntent) + .build() + } + + private fun localizedString(resource: org.jetbrains.compose.resources.StringResource): String = + runBlocking { getString(resource) } + + private companion object { + const val ChannelId = "vnidrop-background-sharing" + const val NotificationId = 3_427 + } +} diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 8218b6b..a8e129f 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -116,6 +116,9 @@ Einstellungen Netzwerk Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft. + VniDrop kann Ihre Dateien im Hintergrund freigeben. + Aktive Übertragungen + Freigabe im Hintergrund Mitteilungen aktiviert. Mitteilungen erlauben Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 0b06c85..240e511 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -116,6 +116,9 @@ Ajustes Red Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano. + VniDrop está listo para compartir sus archivos en segundo plano. + Transferencias activas + Compartiendo en segundo plano Notificaciones activadas. Permitir notificaciones Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 4d09439..a486f21 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -116,6 +116,9 @@ Réglages Réseau Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan. + VniDrop est prêt à partager vos fichiers en arrière-plan. + Transferts actifs + Partage en arrière-plan Notifications activées. Autoriser les notifications Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 15d643b..6eef42a 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -116,6 +116,9 @@ Impostazioni Rete Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background. + VniDrop è pronto a condividere i tuoi file in background. + Trasferimenti attivi + Condivisione in background Notifiche attivate. Consenti le notifiche Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni. diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index c2c7ff0..dafcb17 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -116,6 +116,9 @@ Instellingen Netwerk Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait. + VniDrop is klaar om uw bestanden op de achtergrond te delen. + Actieve overdrachten + Delen op de achtergrond Meldingen ingeschakeld. Meldingen toestaan Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen. diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 0ccd2ba..d2956ab 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -116,6 +116,9 @@ Ustawienia Sieć Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle. + VniDrop jest gotowy do udostępniania plików w tle. + Aktywne transfery + Udostępnianie w tle Powiadomienia włączone. Zezwól na powiadomienia Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach. diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 267acc3..906fafb 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -116,6 +116,9 @@ Definições Rede Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano. + O VniDrop está pronto para partilhar os seus ficheiros em segundo plano. + Transferências ativas + Partilha em segundo plano Notificações ativadas. Permitir notificações As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições. diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 79d24db..587c10a 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -116,6 +116,9 @@ Настройки Сеть Получайте уведомления об активности передач, пока VniDrop работает в фоне. + VniDrop готов отправлять ваши файлы в фоновом режиме. + Активные передачи + Отправка в фоне Уведомления включены. Разрешить уведомления Уведомления отключены для VniDrop. Вы можете включить их в Настройках. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index d9e49b8..89628a4 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -116,6 +116,9 @@ Settings Network Get notified about transfer activity while VniDrop is in the background. + VniDrop is ready to share your files in the background. + Active transfers + Sharing in the background Notifications enabled. Allow notifications Notifications are turned off for VniDrop. You can enable them in Settings. diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt index 304d917..e95458c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -2,6 +2,7 @@ package com.vnidrop.app import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreRepository +import com.vnidrop.app.background.BackgroundSharingCoordinator import com.vnidrop.app.diagnostics.DiagnosticsCoordinator import com.vnidrop.app.diagnostics.createDiagnosticsTransport import com.vnidrop.app.feature.approvals.ApprovalCoordinator @@ -66,6 +67,11 @@ class AppGraph( messages = messages, scope = applicationScope, ) + private val backgroundSharingCoordinator = BackgroundSharingCoordinator( + repository = coreRepository, + controller = dependencies.backgroundSharingController, + scope = applicationScope, + ) init { AppLogger.initialize(dependencies.environment.defaultCoreDataDir) @@ -73,6 +79,7 @@ class AppGraph( } fun close() { + backgroundSharingCoordinator.stop() coreRepository.shutdown() applicationScope.cancel() } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt index bdc2982..581eb33 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt @@ -3,6 +3,7 @@ package com.vnidrop.app import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.feature.receive.ExternalInvitationController +import com.vnidrop.app.background.BackgroundSharingController enum class UiPlatform { Android, @@ -39,5 +40,6 @@ data class AppDependencies( val deviceInfoProvider: DeviceInfoProvider, val fileSystemService: FileSystemService, val localNotificationService: LocalNotificationService, + val backgroundSharingController: BackgroundSharingController, val externalInvitations: ExternalInvitationController, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/background/BackgroundSharingCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/background/BackgroundSharingCoordinator.kt new file mode 100644 index 0000000..c1a9485 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/background/BackgroundSharingCoordinator.kt @@ -0,0 +1,38 @@ +package com.vnidrop.app.background + +import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** Keeps the platform process eligible to serve an outgoing share while its UI is backgrounded. */ +fun interface BackgroundSharingController { + fun setSharingActive(active: Boolean) +} + +class BackgroundSharingCoordinator( + repository: CoreGateway, + private val controller: BackgroundSharingController, + scope: CoroutineScope, +) { + init { + scope.launch { + repository.state + .map(::requiresBackgroundSharing) + .distinctUntilChanged() + .collect(controller::setSharingActive) + } + } + + fun stop() = controller.setSharingActive(false) +} + +internal fun requiresBackgroundSharing(state: CoreState): Boolean = + state.isInitialized && state.transfers.any { transfer -> + transfer.direction == TransferDirection.Send && + transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing) + } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/background/BackgroundSharingCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/background/BackgroundSharingCoordinatorTest.kt new file mode 100644 index 0000000..644925c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/background/BackgroundSharingCoordinatorTest.kt @@ -0,0 +1,63 @@ +package com.vnidrop.app.background + +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.support.FakeCoreGateway +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class BackgroundSharingCoordinatorTest { + @Test + fun keepsThePlatformActiveOnlyWhileAnOutgoingShareIsAvailable() = runTest { + val core = FakeCoreGateway() + val activeStates = mutableListOf() + val coordinator = BackgroundSharingCoordinator( + repository = core, + controller = BackgroundSharingController(activeStates::add), + scope = backgroundScope, + ) + + runCurrent() + core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(transfer(TransferDirection.Receive, TransferStatus.Receiving))) + runCurrent() + assertEquals(listOf(false), activeStates) + + core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(transfer(TransferDirection.Send, TransferStatus.Importing))) + runCurrent() + assertEquals(listOf(false, true), activeStates) + + core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(transfer(TransferDirection.Send, TransferStatus.Sharing))) + runCurrent() + assertEquals(listOf(false, true), activeStates) + + core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(transfer(TransferDirection.Send, TransferStatus.Stopped))) + runCurrent() + assertEquals(listOf(false, true, false), activeStates) + + coordinator.stop() + assertEquals(listOf(false, true, false, false), activeStates) + } + + private fun transfer(direction: TransferDirection, status: TransferStatus) = Transfer( + localId = "local", + transferId = 1UL, + direction = direction, + status = status, + peerId = null, + transferName = "Photos", + contentHash = "hash", + fileCount = 1UL, + totalSize = 1UL, + ticket = "ticket", + accessPolicy = ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 1L, + ) +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt index 0aae810..4181a47 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt @@ -2,6 +2,7 @@ package com.vnidrop.app import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vnidrop.app.background.BackgroundSharingController import com.vnidrop.app.core.rememberFileSystemService import com.vnidrop.app.notifications.JvmLocalNotificationService import com.vnidrop.app.feature.receive.ExternalInvitationController @@ -22,6 +23,7 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController deviceInfoProvider = JvmDeviceInfoProvider, fileSystemService = fileSystemService, localNotificationService = JvmLocalNotificationService(), + backgroundSharingController = BackgroundSharingController { }, externalInvitations = externalInvitations, ) }