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,
)
}