From 24a21109bef47eec4250ba8265213677ba972eaa Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Sun, 18 Oct 2026 06:58:24 +0530 Subject: [PATCH 01/13] android: let users stop the background connection service Signed-off-by: abinanthan-cg --- .../android/service/ConnectionService.kt | 15 +++++++++ .../app/nectarlink/android/ui/MainActivity.kt | 10 ++++++ .../app/nectarlink/android/ui/Preferences.kt | 31 +++++++++++++++++++ .../android/ui/settings/SettingsScreen.kt | 20 ++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 4 +++ android/app/src/main/res/values/strings.xml | 4 +++ .../android/CompanionAndMigrationTest.kt | 7 +++++ 7 files changed, 91 insertions(+) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt b/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt index a86aee3f..4b9cb59f 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt @@ -23,6 +23,7 @@ import app.nectarlink.android.banking.PauseDecider import app.nectarlink.android.clipboard.SendActivity import app.nectarlink.android.core.CoreState import app.nectarlink.android.ui.MainActivity +import app.nectarlink.android.ui.Preferences import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -106,6 +107,12 @@ class ConnectionService : LifecycleService() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) + if (intent?.action == ACTION_STOP_SERVICE) { + Preferences.setBackgroundServiceEnabled(this, false) + stopSelf() + getSystemService(NotificationManager::class.java)?.cancel(ONGOING_ID) + return START_NOT_STICKY + } if (intent?.action == ACTION_STOP_RINGING) { (application as NectarlinkApplication).core.stopRinging() } @@ -114,6 +121,7 @@ class ConnectionService : LifecycleService() { override fun onDestroy() { multicastLock?.runCatching { release() } + getSystemService(NotificationManager::class.java)?.cancel(ONGOING_ID) super.onDestroy() } @@ -164,6 +172,11 @@ class ConnectionService : LifecycleService() { builder.addAction(0, getString(R.string.action_send_clipboard), sendClip) builder.addAction(0, getString(R.string.action_send_files), sendFiles) } + val stopService = PendingIntent.getService( + this, 4, Intent(this, ConnectionService::class.java).setAction(ACTION_STOP_SERVICE), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + builder.addAction(0, getString(R.string.action_stop_service), stopService) return builder.build() } @@ -200,9 +213,11 @@ class ConnectionService : LifecycleService() { private const val CHANNEL_CONNECTION = "connection" private const val CHANNEL_RINGING = "ringing" private const val ACTION_STOP_RINGING = "app.nectarlink.action.STOP_RINGING" + internal const val ACTION_STOP_SERVICE = "app.nectarlink.action.STOP_SERVICE" /** Starts the service when allowed by Android's foreground-service rules. */ fun start(context: Context) { + if (!Preferences.isBackgroundServiceEnabled(context)) return runCatching { ContextCompat.startForegroundService(context, Intent(context, ConnectionService::class.java)) }.onFailure { diff --git a/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt b/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt index 5d378a84..685b6649 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt @@ -783,6 +783,7 @@ private fun App( val recorderSession by recorderFlow.collectAsStateWithLifecycle(recorderInitial) val touchpadSensitivity by preferences.touchpadSensitivity.collectAsStateWithLifecycle() val suggestClipboardActions by preferences.suggestClipboardActions.collectAsStateWithLifecycle() + val backgroundServiceEnabled by preferences.backgroundServiceEnabled.collectAsStateWithLifecycle() val hiveInviteShown by preferences.hiveInviteShown.collectAsStateWithLifecycle() val saveableStateHolder = androidx.compose.runtime.saveable.rememberSaveableStateHolder() @@ -943,6 +944,15 @@ private fun App( onClearClipboardHistory = core::clearClipboardHistory, suggestClipboardActions = suggestClipboardActions, onSetSuggestClipboardActions = preferences::updateSuggestClipboardActions, + backgroundServiceEnabled = backgroundServiceEnabled, + onToggleBackgroundService = { enabled -> + preferences.updateBackgroundServiceEnabled(enabled) + if (enabled) { + ConnectionService.start(context) + } else { + ConnectionService.stop(context) + } + }, onSetTimelineRetentionDays = core::setTimelineRetentionDays, onClearTimeline = core::clearTimeline, onClearMessageCache = core::clearMessageCache, diff --git a/android/app/src/main/kotlin/app/nectarlink/android/ui/Preferences.kt b/android/app/src/main/kotlin/app/nectarlink/android/ui/Preferences.kt index b4ba8326..4fa5a81f 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/ui/Preferences.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/ui/Preferences.kt @@ -24,6 +24,20 @@ class Preferences(context: Context) { private val _suggestClipboardActions = MutableStateFlow(prefs.getBoolean(SUGGEST_CLIPBOARD_ACTIONS, true)) val suggestClipboardActions: StateFlow = _suggestClipboardActions.asStateFlow() + private val _backgroundServiceEnabled = MutableStateFlow(prefs.getBoolean(BACKGROUND_SERVICE_ENABLED, true)) + val backgroundServiceEnabled: StateFlow = _backgroundServiceEnabled.asStateFlow() + + private val preferenceChangeListener = + android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == BACKGROUND_SERVICE_ENABLED) { + _backgroundServiceEnabled.value = prefs.getBoolean(BACKGROUND_SERVICE_ENABLED, true) + } + } + + init { + prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + /** * The invitation to try Hive shows until it's answered, and again after * an update (a reinstall clears this anyway): it remembers the version @@ -93,6 +107,11 @@ class Preferences(context: Context) { } } + fun updateBackgroundServiceEnabled(enabled: Boolean) { + prefs.edit { putBoolean(BACKGROUND_SERVICE_ENABLED, enabled) } + _backgroundServiceEnabled.value = enabled + } + fun updateWalkthrough(change: (WalkthroughPersistence) -> WalkthroughPersistence) { val current = _walkthrough.value val next = change(current).let { @@ -119,6 +138,7 @@ class Preferences(context: Context) { private const val HIVE_INVITE_ANSWERED = "hive_invite_answered_version" private const val TOUCHPAD_SENSITIVITY = "touchpad_sensitivity" private const val SUGGEST_CLIPBOARD_ACTIONS = "suggest_clipboard_actions" + internal const val BACKGROUND_SERVICE_ENABLED = "background_service_enabled" private const val AWAY_MODE = "away_mode" private const val WALKTHROUGH_COMPLETED = "walkthrough_completed" private const val WALKTHROUGH_STEP = "walkthrough_step" @@ -174,6 +194,17 @@ class Preferences(context: Context) { .getSharedPreferences("appearance", Context.MODE_PRIVATE) .edit { putBoolean(AWAY_MODE, enabled) } } + + fun isBackgroundServiceEnabled(context: Context): Boolean = + context.applicationContext + .getSharedPreferences("appearance", Context.MODE_PRIVATE) + .getBoolean(BACKGROUND_SERVICE_ENABLED, true) + + fun setBackgroundServiceEnabled(context: Context, enabled: Boolean) { + context.applicationContext + .getSharedPreferences("appearance", Context.MODE_PRIVATE) + .edit { putBoolean(BACKGROUND_SERVICE_ENABLED, enabled) } + } } } diff --git a/android/app/src/main/kotlin/app/nectarlink/android/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/app/nectarlink/android/ui/settings/SettingsScreen.kt index 4a7fea4f..55907cbe 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/ui/settings/SettingsScreen.kt @@ -114,6 +114,8 @@ fun SettingsScreen( onClearClipboardHistory: () -> Unit = {}, suggestClipboardActions: Boolean = true, onSetSuggestClipboardActions: (Boolean) -> Unit = {}, + backgroundServiceEnabled: Boolean = true, + onToggleBackgroundService: (Boolean) -> Unit = {}, onSetTimelineRetentionDays: (UInt) -> Unit = {}, onClearTimeline: () -> Unit = {}, onClearMessageCache: () -> Unit = {}, @@ -598,6 +600,24 @@ fun SettingsScreen( title = stringResource(R.string.settings_background_title), highlighted = pulsingSectionKey == "background", ) { + val runInBackgroundTitle = stringResource(R.string.settings_run_in_background) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f).padding(end = 12.dp)) { + Text(runInBackgroundTitle, style = MaterialTheme.typography.titleSmall) + Text( + stringResource(R.string.settings_run_in_background_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = backgroundServiceEnabled, + onCheckedChange = onToggleBackgroundService, + colors = app.nectarlink.android.ui.theme.nectarSwitchColors(), + modifier = Modifier.semantics { contentDescription = runInBackgroundTitle }, + ) + } + SettingRowDivider() BackgroundBatteryAccess(state.backgroundUnrestricted) SettingRowDivider() CompanionWakeAccess( diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index fc3eb06d..b146419a 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -199,6 +199,9 @@ Android为省电暂停了NectarLink,导致电脑无法连接。请允许它在后台运行。 已允许在后台保持连接 后台与电池 + 在后台运行 + 让此手机可以被电脑访问。关闭将停止通知和后台连接,NectarLink 仅在应用打开时连接。 + 电池设置 三星:打开应用信息 › 电池 › 选择“不受限制”,并在设备维护 › 电池 › 后台使用限制中确保 NectarLink 永不休眠。 小米 / HyperOS / MIUI:为 NectarLink 开启自启动,并在应用信息 › 省电策略中选择“无限制”。 @@ -235,6 +238,7 @@ 允许使用相机 让电脑响铃 停止响铃 + 停止 响铃 手机 平板 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3b7f48c1..96fbd8ef 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -205,6 +205,9 @@ Android is pausing NectarLink to save battery, so your PC can\'t reach it. Let it run in the background. Allowed to stay connected in the background Background & battery + Run in the background + Keeps this phone reachable from your PC. Turn off to stop the notification and background connection. NectarLink only connects while the app is open. + Battery settings On Samsung: open App info › Battery › select Unrestricted, and in Device care › Battery › Background usage limits make sure NectarLink is never put to sleep. On Xiaomi / HyperOS / MIUI: turn on Autostart for NectarLink, and in App info › Battery saver select No restrictions. @@ -243,6 +246,7 @@ Allow camera Ring PC Stop ringing + Stop Ring Phone Tablet diff --git a/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt b/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt index 3f9c33cc..6e67cae7 100644 --- a/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt +++ b/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt @@ -2,6 +2,7 @@ package app.nectarlink.android import app.nectarlink.android.companion.PcCompanion +import app.nectarlink.android.service.ConnectionService import app.nectarlink.android.ui.Preferences import app.nectarlink.android.ui.theme.Appearance import app.nectarlink.android.ui.theme.Tokens @@ -82,4 +83,10 @@ class CompanionAndMigrationTest { assertEquals("ocean", valid.seed) assertFalse(valid.dynamicColor) } + + @Test + fun `ConnectionService stop action and Preferences background service key`() { + assertEquals("app.nectarlink.action.STOP_SERVICE", ConnectionService.ACTION_STOP_SERVICE) + assertEquals("background_service_enabled", Preferences.BACKGROUND_SERVICE_ENABLED) + } } From 29b5634f5e9ad3cfffe84d407259c34dd0d44e55 Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 14:34:58 +0530 Subject: [PATCH 02/13] android: support stopping and restarting Core node Signed-off-by: abinanthan-cg --- .../app/nectarlink/android/core/Core.kt | 130 ++++++++++++++---- .../nectarlink/android/core/SystemMonitors.kt | 1 + 2 files changed, 103 insertions(+), 28 deletions(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index d9e380ca..862e0dd2 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -62,8 +62,11 @@ import app.nectarlink.android.files.transferTitle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -90,7 +93,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener /** What plays on this phone, for PCs (needs notification access). */ private val phoneMedia = PhoneMedia(this.context) { players -> syncActiveMediaColor() - notificationOps.trySend { it.mediaChanged(players) } + enqueueNotificationOp { it.mediaChanged(players) } } private val platform = PhonePlatform( this.context, @@ -146,43 +149,43 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener onKeyboardFromPc = { pc, on -> setKeyboardFromPc(pc, on) }, ) private val storage = PhoneStorage(this.context) { path -> - notificationOps.trySend { it.storageChanged(path) } + enqueueNotificationOp { it.storageChanged(path) } } private val toggles = PhoneToggles( this.context, onChanged = { state -> - notificationOps.trySend { node -> + enqueueNotificationOp { node -> runCatching { node.togglesChanged(state) }.onFailure { Log.i(TAG, "toggles weren't reported", it) } } }, onCapabilitiesChanged = { refreshNotificationAccess() }, ) private val sms = PhoneSms(this.context) { - notificationOps.trySend { it.smsChanged(null) } + enqueueNotificationOp { it.smsChanged(null) } } private val calls = PhoneCalls( this.context, onChange = { call -> - notificationOps.trySend { node -> + enqueueNotificationOp { node -> runCatching { node.callChanged(call) }.onFailure { Log.i(TAG, "a call wasn't reported", it) } } }, onLogChanged = { - notificationOps.trySend { it.callLogChanged() } + enqueueNotificationOp { it.callLogChanged() } }, ) private val contacts = PhoneContacts(this.context) { - notificationOps.trySend { it.contactsChanged() } + enqueueNotificationOp { it.contactsChanged() } } private val photos = RecentPhotos( this.context, onPhoto = { photo -> - notificationOps.trySend { node -> + enqueueNotificationOp { node -> runCatching { node.photoTaken(photo) }.onFailure { Log.i(TAG, "a new photo wasn't announced", it) } } }, onChanged = { - notificationOps.trySend { it.photosChanged() } + enqueueNotificationOp { it.photosChanged() } }, ) @@ -213,12 +216,26 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener * laser update its "off". */ private val remoteOps = Channel Unit>(Channel.UNLIMITED) + + private fun enqueueNotificationOp(op: suspend (NectarlinkNode) -> Unit) { + if (node != null) { + notificationOps.trySend(op) + } + } + + private fun enqueueRemoteOp(op: suspend (NectarlinkNode) -> Unit) { + if (node != null) { + remoteOps.trySend(op) + } + } + private val _state = MutableStateFlow(CoreState()) private val _messages = MutableSharedFlow(extraBufferCapacity = 8) private val _queuedNotices = MutableSharedFlow(extraBufferCapacity = 8) private val _clipSuggestions = MutableSharedFlow(extraBufferCapacity = 4) private var node: NectarlinkNode? = null private var startJob: Job? = null + private var runJob: Job? = null private var networkChange: Job? = null private val wakeTimers = java.util.concurrent.ConcurrentHashMap() @@ -343,6 +360,9 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener @Synchronized fun start() { if (startJob != null) return + val currentRun = Job() + runJob = currentRun + val runScope = CoroutineScope(scope.coroutineContext + currentRun) startJob = scope.launch(Dispatchers.IO) { initLogging(if (BuildConfig.DEBUG) "debug,iroh=info,swarm_discovery=error" else "info,iroh=warn,swarm_discovery=error") val initialAwayMode = app.nectarlink.android.ui.Preferences.isAwayModeEnabled(context) @@ -362,6 +382,10 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener _state.update { it.copy(status = CoreStatus.Failed(e.message ?: e.javaClass.simpleName)) } return@launch } + if (!currentRun.isActive) { + started.shutdown() + return@launch + } node = started val devices = runCatching { started.pairedDevices() }.getOrDefault(emptyList()) val pcToggles = perPcToggles(started, devices.map { it.id }) @@ -397,10 +421,11 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener localsendPeers = lsPeers, ) } + NotificationListener.instance?.refreshActive() refreshDataRetention() app.nectarlink.android.clipboard.DirectShareTargets.sync(context, _state.value.devices) app.nectarlink.android.widget.PcWidgetProvider.refreshAll(context, _state.value) - scope.launch(Dispatchers.IO) { + runScope.launch(Dispatchers.IO) { _state .map { s -> s.devices.map { d -> listOf(d.id, d.name, d.online, d.canWake, d.wakeState) } @@ -412,7 +437,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener app.nectarlink.android.widget.PcWidgetProvider.refreshAll(context, s) } } - scope.launch(Dispatchers.IO) { + runScope.launch(Dispatchers.IO) { var first = true _state .map { s -> @@ -451,8 +476,8 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener } started.updatePower(powerLevel(), capabilities(NotificationListener.hasAccess(context))) // Elevated, when set up and wireless debugging is on. - scope.launch { Elevated.start() } - scope.launch(Dispatchers.Main) { + runScope.launch { Elevated.start() } + runScope.launch(Dispatchers.Main) { // Allow first Compose frame to draw before registering BroadcastReceivers and ContentObservers on Main. delay(48L) battery.start() @@ -476,12 +501,12 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener } } } - scope.launch { + runScope.launch { for (op in notificationOps) { runCatching { op(started) }.onFailure { Log.w(TAG, "notification update failed", it) } } } - scope.launch { + runScope.launch { for (op in remoteOps) { runCatching { op(started) }.onFailure { Log.w(TAG, "remote input failed", it) } } @@ -489,6 +514,55 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener } } + /** + * Shuts down the active node and all per-run collectors, monitors, and loops. + * Sets status back to [CoreStatus.Starting] and clears node references. + * Safe to call repeatedly, synchronized with [start] so stop and start cannot interleave. + */ + @Synchronized + fun stop() { + val activeJob = startJob + val activeNode = node + val activeRun = runJob + if (activeJob == null && activeNode == null && activeRun == null) return + + activeRun?.cancel() + runJob = null + + runBlocking(Dispatchers.IO) { + activeJob?.cancelAndJoin() + activeNode?.shutdown() + runCatching { Elevated.stop() } + } + node = null + startJob = null + + // Stop monitors and clear active reporting state so a subsequent start re-reports everything cleanly. + lastReportedBattery = null + scope.launch(Dispatchers.Main) { + battery.stop() + accent.stop() + network.stop() + calls.stop() + contacts.stop() + sms.stop() + signal.stop() + toggles.stop() + photos.stop() + phoneMedia.stop() + pcSpeakerPlayer.stop() + } + localsendMulticast?.runCatching { release() } + wakeTimers.values.forEach { it.cancel() } + wakeTimers.clear() + + // Drain any pending operations so they don't replay on the next run. + while (notificationOps.tryReceive().isSuccess) {} + while (remoteOps.tryReceive().isSuccess) {} + + _state.update { it.copy(status = CoreStatus.Starting) } + } + // ---- Notifications (from NotificationListener) ---- /** @@ -499,22 +573,22 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener _state.update { it.copy(notificationAccess = granted) } if (granted && _state.value.devices.isNotEmpty()) ConnectionService.start(context) scope.launch(Dispatchers.Main) { if (granted) phoneMedia.start() else phoneMedia.stop() } - notificationOps.trySend { node -> + enqueueNotificationOp { node -> node.updatePower(powerLevel(), capabilities(granted)) node.notificationsReset(showing) } } fun notificationPosted(notification: Notification) { - notificationOps.trySend { it.notificationPosted(notification) } + enqueueNotificationOp { it.notificationPosted(notification) } } fun notificationRemoved(key: String) { - notificationOps.trySend { it.notificationRemoved(key) } + enqueueNotificationOp { it.notificationRemoved(key) } } fun notificationsRefreshed(showing: List) { - notificationOps.trySend { it.notificationsReset(showing) } + enqueueNotificationOp { it.notificationsReset(showing) } } @Volatile private var lastSensitiveAccess = false @@ -603,7 +677,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener if (contactsAccess) contacts.start() else contacts.stop() if (PhoneSms.canRead(context)) sms.start() else sms.stop() } - notificationOps.trySend { + enqueueNotificationOp { it.updatePower(powerLevel(), capabilities(_state.value.notificationAccess)) if (photoAccess && (!prevPhotos || photoPartialAccess)) it.photosChanged() } @@ -1945,7 +2019,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener } } if (shouldQueue) { - remoteOps.trySend { node -> + enqueueRemoteOp { node -> val (targetId, totalDx, totalDy) = synchronized(pendingMoveLock) { val t = pendingMovePcId val x = pendingMoveDx @@ -1964,7 +2038,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener /** Scrolls on a PC (`dy` > 0 scrolls down, `dx` > 0 scrolls right, in wheel notches). */ fun remoteScroll(id: String, dx: Float, dy: Float, fast: Boolean = true) { - remoteOps.trySend { runCatching { it.remoteScroll(id, dx, dy, fast) } } + enqueueRemoteOp { runCatching { it.remoteScroll(id, dx, dy, fast) } } } /** Presses, releases or clicks a mouse button (`"left"`, `"right"` or `"middle"`). */ @@ -1989,7 +2063,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener /** Moves or hides the laser pointer on a PC (`x`, `y` in `0..1`). */ fun remoteLaser(id: String, on: Boolean, x: Float = 0.5f, y: Float = 0.5f) { - remoteOps.trySend { runCatching { it.remoteLaser(id, on, x, y) } } + enqueueRemoteOp { runCatching { it.remoteLaser(id, on, x, y) } } } /** @@ -2029,16 +2103,16 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener /** Requests an immediate IDR keyframe from a paired PC streaming its screen. */ fun requestPcMirrorKeyframe(id: String) { - remoteOps.trySend { runCatching { it.requestPcMirrorKeyframe(id) } } + enqueueRemoteOp { runCatching { it.requestPcMirrorKeyframe(id) } } } /** Sends a normalized touch, scroll, key, or text event to a paired PC during reverse mirroring. */ fun sendPcMirrorInput(id: String, input: app.nectarlink.core.MirrorInputEvent) { - remoteOps.trySend { runCatching { it.sendPcMirrorInput(id, input) } } + enqueueRemoteOp { runCatching { it.sendPcMirrorInput(id, input) } } } private fun remoteCommand(onStatus: ((RemoteAccess) -> Unit)?, block: suspend (NectarlinkNode) -> Unit) { - remoteOps.trySend { node -> + enqueueRemoteOp { node -> try { block(node) onStatus?.invoke(RemoteAccess.Allowed) @@ -2116,7 +2190,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener s } } - remoteOps.trySend { node -> + enqueueRemoteOp { node -> try { node.setPcAudio(id, wireVol, muted) } catch (e: NectarlinkException) { @@ -2139,7 +2213,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener startJob?.join() val currentNode = node ?: return@launch runCatching { currentNode.setDeviceToggle(pcId, PhoneStorage.TOGGLE_NAME, enabled) } - notificationOps.trySend { + enqueueNotificationOp { it.updatePower(powerLevel(), capabilities(_state.value.notificationAccess)) } } diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/SystemMonitors.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/SystemMonitors.kt index 2bf14903..16e7c580 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/SystemMonitors.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/SystemMonitors.kt @@ -443,6 +443,7 @@ class BatteryMonitor(private val context: Context, private val onChange: (Batter fun stop() { runCatching { context.unregisterReceiver(receiver) } + last = null } } From 24d3ee43f938fd7463925200bb1f39d5decf8e6d Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 14:59:55 +0530 Subject: [PATCH 03/13] android: wire Core lifecycle to app visibility when background connection is off Signed-off-by: abinanthan-cg --- android/app/build.gradle.kts | 1 + .../android/NectarlinkApplication.kt | 59 ++++++++++++++++--- .../android/companion/PcCompanionService.kt | 3 + .../android/service/ConnectionService.kt | 1 + .../android/service/StartupReceiver.kt | 5 ++ .../app/nectarlink/android/ui/MainActivity.kt | 1 + android/gradle/libs.versions.toml | 1 + 7 files changed, 64 insertions(+), 7 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fa868b08..13345c8a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -198,6 +198,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.service) + implementation(libs.androidx.lifecycle.process) implementation(libs.kotlinx.coroutines.android) implementation(libs.camerax.camera2) implementation(libs.camerax.lifecycle) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt b/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt index d48c4680..0751473b 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt @@ -2,17 +2,24 @@ package app.nectarlink.android import android.app.Application +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import app.nectarlink.android.banking.BankingPause import app.nectarlink.android.core.Core import app.nectarlink.android.elevated.Elevated +import app.nectarlink.android.ui.Preferences import app.nectarlink.android.update.AppUpdater import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** Starts the core with the process and keeps it for the app's lifetime. */ -class NectarlinkApplication : Application() { +class NectarlinkApplication : Application(), DefaultLifecycleObserver { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val core: Core by lazy { Core(this, scope) } @@ -20,8 +27,10 @@ class NectarlinkApplication : Application() { /** Updates from GitHub releases (release builds only). */ val updater: AppUpdater by lazy { AppUpdater(this) } + private var pendingStopJob: Job? = null + override fun onCreate() { - super.onCreate() + super.onCreate() // Before the core starts: its network code reads the phone's DNS servers. app.nectarlink.android.core.AndroidContext.install(this) // Before Elevated: it waits while paused for a banking app. @@ -31,12 +40,48 @@ class NectarlinkApplication : Application() { // What the phone offers PCs follows Elevated starting and stopping. Elevated.onChange = { core.refreshNotificationAccess() } Elevated.onClipboardChanged = { text, html -> core.onElevatedClipboardChanged(text, html) } - android.view.Choreographer.getInstance().postFrameCallback { - scope.launch { core.start() } + + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + + if (Preferences.isBackgroundServiceEnabled(this)) { + android.view.Choreographer.getInstance().postFrameCallback { + scope.launch { core.start() } + } + scope.launch { + delay(120) + core.start() + } } - scope.launch { - kotlinx.coroutines.delay(120) - core.start() + } + + override fun onStart(owner: LifecycleOwner) { + pendingStopJob?.cancel() + pendingStopJob = null + core.start() + } + + override fun onStop(owner: LifecycleOwner) { + if (!Preferences.isBackgroundServiceEnabled(this)) { + scheduleStop() } } + + fun onBackgroundServiceDisabled() { + val inForeground = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + if (!inForeground) { + scheduleStop() + } + } + + private fun scheduleStop() { + pendingStopJob?.cancel() + pendingStopJob = scope.launch(Dispatchers.IO) { + delay(STOP_DELAY_MS) + core.stop() + } + } + + companion object { + const val STOP_DELAY_MS = 5_000L + } } diff --git a/android/app/src/main/kotlin/app/nectarlink/android/companion/PcCompanionService.kt b/android/app/src/main/kotlin/app/nectarlink/android/companion/PcCompanionService.kt index 3fbf63db..8fdf5ad5 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/companion/PcCompanionService.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/companion/PcCompanionService.kt @@ -44,6 +44,9 @@ class PcCompanionService : CompanionDeviceService() { private fun wakeAndReconnect() { val appContext = applicationContext ?: return + if (!app.nectarlink.android.ui.Preferences.isBackgroundServiceEnabled(appContext)) { + return + } ConnectionService.start(appContext) (appContext as? NectarlinkApplication)?.core?.reconnectNow() } diff --git a/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt b/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt index 4b9cb59f..ea14225a 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt @@ -111,6 +111,7 @@ class ConnectionService : LifecycleService() { Preferences.setBackgroundServiceEnabled(this, false) stopSelf() getSystemService(NotificationManager::class.java)?.cancel(ONGOING_ID) + (application as? NectarlinkApplication)?.onBackgroundServiceDisabled() return START_NOT_STICKY } if (intent?.action == ACTION_STOP_RINGING) { diff --git a/android/app/src/main/kotlin/app/nectarlink/android/service/StartupReceiver.kt b/android/app/src/main/kotlin/app/nectarlink/android/service/StartupReceiver.kt index 6f7b71be..bd25fe38 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/service/StartupReceiver.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/service/StartupReceiver.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import app.nectarlink.android.NectarlinkApplication import app.nectarlink.android.core.CoreStatus +import app.nectarlink.android.ui.Preferences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -26,6 +27,10 @@ class StartupReceiver : BroadcastReceiver() { else -> return } val app = context.applicationContext as NectarlinkApplication + if (!Preferences.isBackgroundServiceEnabled(context)) { + if (updated) app.updater.onUpdated() + return + } val pending = goAsync() CoroutineScope(Dispatchers.Default).launch { try { diff --git a/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt b/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt index 685b6649..5fe3d161 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt @@ -948,6 +948,7 @@ private fun App( onToggleBackgroundService = { enabled -> preferences.updateBackgroundServiceEnabled(enabled) if (enabled) { + core.start() ConnectionService.start(context) } else { ConnectionService.stop(context) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 6d3ae3ca..abbd65a1 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -20,6 +20,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } From ff7c99a79ca46c5143d1f867d39773e1b508fc95 Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 16:30:12 +0530 Subject: [PATCH 04/13] android: add home card and update background connection strings Signed-off-by: abinanthan-cg --- .../app/nectarlink/android/ui/MainActivity.kt | 6 ++++++ .../app/nectarlink/android/ui/home/HomeScreen.kt | 15 ++++++++++++++- .../app/src/main/res/values-zh-rCN/strings.xml | 8 +++++--- android/app/src/main/res/values/strings.xml | 8 +++++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt b/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt index 5fe3d161..370b9bab 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/ui/MainActivity.kt @@ -909,6 +909,12 @@ private fun App( preferences.update { it.copy(theme = "hive") } }, onDismissHiveInvite = preferences::answerHiveInvite, + backgroundServiceEnabled = backgroundServiceEnabled, + onTurnOnBackgroundService = { + preferences.updateBackgroundServiceEnabled(true) + core.start() + ConnectionService.start(context) + }, onDismissWhatsNewGuide = { preferences.updateWalkthrough { it.dismissWhatsNewGuide() } }, diff --git a/android/app/src/main/kotlin/app/nectarlink/android/ui/home/HomeScreen.kt b/android/app/src/main/kotlin/app/nectarlink/android/ui/home/HomeScreen.kt index 257a5ba6..c62c6c5a 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/ui/home/HomeScreen.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/ui/home/HomeScreen.kt @@ -236,6 +236,8 @@ fun HomeScreen( showHiveInvite: Boolean = false, onTryHive: () -> Unit = {}, onDismissHiveInvite: () -> Unit = {}, + backgroundServiceEnabled: Boolean = true, + onTurnOnBackgroundService: () -> Unit = {}, onOpenFeaturesGuide: () -> Unit = {}, onOpenWalkthrough: () -> Unit = {}, highlightTarget: GuideHighlightTarget? = null, @@ -561,6 +563,16 @@ fun HomeScreen( } } item(key = "update_card", contentType = "update") { UpdateCard(updater) } + if (!backgroundServiceEnabled) { + item(key = "background_connection_off", contentType = "setup") { + SetupCard( + title = stringResource(R.string.home_background_off_title), + text = stringResource(R.string.home_background_off_text), + buttonLabel = stringResource(R.string.action_turn_on), + onAllow = onTurnOnBackgroundService, + ) + } + } if (!state.localNetwork) { item(key = "local_network_setup", contentType = "setup") { val context = LocalContext.current @@ -1194,6 +1206,7 @@ private fun SetupCard( title: String, text: String, guide: String? = null, + buttonLabel: String = stringResource(R.string.action_allow), secondaryLabel: String? = null, onSecondary: (() -> Unit)? = null, onAllow: () -> Unit, @@ -1213,7 +1226,7 @@ private fun SetupCard( } Spacer(Modifier.height(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onAllow) { Text(stringResource(R.string.action_allow)) } + Button(onClick = onAllow) { Text(buttonLabel) } if (secondaryLabel != null && onSecondary != null) { OutlinedButton(onClick = onSecondary) { Text(secondaryLabel) } } diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index b146419a..e521a113 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -200,8 +200,9 @@ 已允许在后台保持连接 后台与电池 在后台运行 - 让此手机可以被电脑访问。关闭将停止通知和后台连接,NectarLink 仅在应用打开时连接。 - + 让此手机可以被电脑访问。关闭时,NectarLink 仅在打开时连接,通知也会消失。 + 后台连接已关闭 + NectarLink 仅在应用打开时连接。开启以保持此手机可被电脑访问。 电池设置 三星:打开应用信息 › 电池 › 选择“不受限制”,并在设备维护 › 电池 › 后台使用限制中确保 NectarLink 永不休眠。 小米 / HyperOS / MIUI:为 NectarLink 开启自启动,并在应用信息 › 省电策略中选择“无限制”。 @@ -238,7 +239,8 @@ 允许使用相机 让电脑响铃 停止响铃 - 停止 + 关闭 + 开启 响铃 手机 平板 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 96fbd8ef..e4d93a51 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -206,8 +206,9 @@ Allowed to stay connected in the background Background & battery Run in the background - Keeps this phone reachable from your PC. Turn off to stop the notification and background connection. NectarLink only connects while the app is open. - + Keeps this phone reachable from your PC. When off, NectarLink connects only while it\'s open, and the notification goes away. + Background connection is off + NectarLink connects only while the app is open. Turn it on to keep this phone reachable from your PC. Battery settings On Samsung: open App info › Battery › select Unrestricted, and in Device care › Battery › Background usage limits make sure NectarLink is never put to sleep. On Xiaomi / HyperOS / MIUI: turn on Autostart for NectarLink, and in App info › Battery saver select No restrictions. @@ -246,7 +247,8 @@ Allow camera Ring PC Stop ringing - Stop + Turn off + Turn on Ring Phone Tablet From 1ce1501474ea54fb5c891c3ed028bcddc0c5112b Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 16:37:41 +0530 Subject: [PATCH 05/13] android: add test for background service disabled guard Signed-off-by: abinanthan-cg --- .../app/nectarlink/android/service/ConnectionService.kt | 4 +++- .../app/nectarlink/android/CompanionAndMigrationTest.kt | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt b/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt index ea14225a..cc625e48 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt @@ -216,9 +216,11 @@ class ConnectionService : LifecycleService() { private const val ACTION_STOP_RINGING = "app.nectarlink.action.STOP_RINGING" internal const val ACTION_STOP_SERVICE = "app.nectarlink.action.STOP_SERVICE" + internal fun shouldStart(backgroundEnabled: Boolean): Boolean = backgroundEnabled + /** Starts the service when allowed by Android's foreground-service rules. */ fun start(context: Context) { - if (!Preferences.isBackgroundServiceEnabled(context)) return + if (!shouldStart(Preferences.isBackgroundServiceEnabled(context))) return runCatching { ContextCompat.startForegroundService(context, Intent(context, ConnectionService::class.java)) }.onFailure { diff --git a/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt b/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt index 6e67cae7..5e125acd 100644 --- a/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt +++ b/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt @@ -85,8 +85,8 @@ class CompanionAndMigrationTest { } @Test - fun `ConnectionService stop action and Preferences background service key`() { - assertEquals("app.nectarlink.action.STOP_SERVICE", ConnectionService.ACTION_STOP_SERVICE) - assertEquals("background_service_enabled", Preferences.BACKGROUND_SERVICE_ENABLED) + fun `ConnectionService does not start when background service is disabled`() { + assertFalse(ConnectionService.shouldStart(backgroundEnabled = false)) + assertTrue(ConnectionService.shouldStart(backgroundEnabled = true)) } } From 252baa7ce4b0842954b18d3397b800f251517280 Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 17:07:43 +0530 Subject: [PATCH 06/13] android: remove unused isActive import in Core Signed-off-by: abinanthan-cg --- android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index 862e0dd2..66a7590c 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -65,7 +65,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow From 4752bfa897c2b8250d53758b68dbf57010b3e04b Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 17:08:42 +0530 Subject: [PATCH 07/13] android: remove weak ConnectionService.shouldStart test Signed-off-by: abinanthan-cg --- .../app/nectarlink/android/service/ConnectionService.kt | 4 +--- .../app/nectarlink/android/CompanionAndMigrationTest.kt | 7 ------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt b/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt index cc625e48..ea14225a 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/service/ConnectionService.kt @@ -216,11 +216,9 @@ class ConnectionService : LifecycleService() { private const val ACTION_STOP_RINGING = "app.nectarlink.action.STOP_RINGING" internal const val ACTION_STOP_SERVICE = "app.nectarlink.action.STOP_SERVICE" - internal fun shouldStart(backgroundEnabled: Boolean): Boolean = backgroundEnabled - /** Starts the service when allowed by Android's foreground-service rules. */ fun start(context: Context) { - if (!shouldStart(Preferences.isBackgroundServiceEnabled(context))) return + if (!Preferences.isBackgroundServiceEnabled(context)) return runCatching { ContextCompat.startForegroundService(context, Intent(context, ConnectionService::class.java)) }.onFailure { diff --git a/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt b/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt index 5e125acd..3f9c33cc 100644 --- a/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt +++ b/android/app/src/test/kotlin/app/nectarlink/android/CompanionAndMigrationTest.kt @@ -2,7 +2,6 @@ package app.nectarlink.android import app.nectarlink.android.companion.PcCompanion -import app.nectarlink.android.service.ConnectionService import app.nectarlink.android.ui.Preferences import app.nectarlink.android.ui.theme.Appearance import app.nectarlink.android.ui.theme.Tokens @@ -83,10 +82,4 @@ class CompanionAndMigrationTest { assertEquals("ocean", valid.seed) assertFalse(valid.dynamicColor) } - - @Test - fun `ConnectionService does not start when background service is disabled`() { - assertFalse(ConnectionService.shouldStart(backgroundEnabled = false)) - assertTrue(ConnectionService.shouldStart(backgroundEnabled = true)) - } } From 90b6773c55b02506972c8b669476d74d2e87ec2a Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 17:10:35 +0530 Subject: [PATCH 08/13] android: clear device online states when Core stops Signed-off-by: abinanthan-cg --- .../kotlin/app/nectarlink/android/core/Core.kt | 2 +- .../app/nectarlink/android/core/CoreState.kt | 8 ++++++++ .../app/nectarlink/android/core/CoreStateTest.kt | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index 66a7590c..615554a6 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -559,7 +559,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener while (notificationOps.tryReceive().isSuccess) {} while (remoteOps.tryReceive().isSuccess) {} - _state.update { it.copy(status = CoreStatus.Starting) } + _state.update { it.withDevicesOffline().copy(status = CoreStatus.Starting) } } // ---- Notifications (from NotificationListener) ---- diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/CoreState.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/CoreState.kt index 97b1527a..159bb8ee 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/CoreState.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/CoreState.kt @@ -287,6 +287,14 @@ data class CoreState( storageRequestedFrom = if (enabled && storageRequestedFrom == id) null else storageRequestedFrom, ) + fun withDevicesOffline(): CoreState = copy( + devices = devices.map { d -> + if (d.online) d.copy(link = Link.Offline(System.currentTimeMillis() / 1000L), wakeState = WakeState.Idle) else d + }.sortedWith(deviceOrder), + keyboardFromPc = emptySet(), + micRequestFrom = null, + ) + /** Folds one core event into the state. */ fun reduce(event: Event): CoreState = when (event) { is Event.DeviceAdded -> { diff --git a/android/app/src/test/kotlin/app/nectarlink/android/core/CoreStateTest.kt b/android/app/src/test/kotlin/app/nectarlink/android/core/CoreStateTest.kt index 5d35a8fb..5d324a5d 100644 --- a/android/app/src/test/kotlin/app/nectarlink/android/core/CoreStateTest.kt +++ b/android/app/src/test/kotlin/app/nectarlink/android/core/CoreStateTest.kt @@ -12,6 +12,7 @@ import app.nectarlink.core.Notification import app.nectarlink.core.PairedDevice import app.nectarlink.core.PairingFailure import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -50,6 +51,20 @@ class CoreStateTest { assertEquals(listOf("a"), state.devices.map { it.id }) } + @Test + fun withDevicesOfflineClearsOnlineStatusAndWakeState() { + var state = CoreState() + .reduce(Event.DeviceAdded(pc("a", "Desk", 10))) + .reduce(Event.LinkChanged("a", Link.Online(relayed = false, usb = false, rttMs = 5u))) + .withWakeState("a", WakeState.Waking) + assertTrue(state.device("a")!!.online) + assertEquals(WakeState.Waking, state.device("a")!!.wakeState) + + state = state.withDevicesOffline() + assertFalse(state.device("a")!!.online) + assertEquals(WakeState.Idle, state.device("a")!!.wakeState) + } + @Test fun pairingFlow() { var state = CoreState() From 2b78060999f43cb62b0467ea7ec39d351fcdbc27 Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 17:19:30 +0530 Subject: [PATCH 09/13] android: guard Core fields and drop ops consistently while stopped Signed-off-by: abinanthan-cg --- .../app/nectarlink/android/core/Core.kt | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index 615554a6..2b6b5de1 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -216,15 +216,22 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener */ private val remoteOps = Channel Unit>(Channel.UNLIMITED) + private val opsLock = Any() + @Volatile private var isRunning = false + private fun enqueueNotificationOp(op: suspend (NectarlinkNode) -> Unit) { - if (node != null) { - notificationOps.trySend(op) + synchronized(opsLock) { + if (isRunning && node != null) { + notificationOps.trySend(op) + } } } private fun enqueueRemoteOp(op: suspend (NectarlinkNode) -> Unit) { - if (node != null) { - remoteOps.trySend(op) + synchronized(opsLock) { + if (isRunning && node != null) { + remoteOps.trySend(op) + } } } @@ -232,10 +239,11 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener private val _messages = MutableSharedFlow(extraBufferCapacity = 8) private val _queuedNotices = MutableSharedFlow(extraBufferCapacity = 8) private val _clipSuggestions = MutableSharedFlow(extraBufferCapacity = 4) - private var node: NectarlinkNode? = null - private var startJob: Job? = null - private var runJob: Job? = null - private var networkChange: Job? = null + @Volatile private var node: NectarlinkNode? = null + @Volatile private var startJob: Job? = null + @Volatile private var runJob: Job? = null + @Volatile private var runScope: CoroutineScope? = null + @Volatile private var networkChange: Job? = null private val wakeTimers = java.util.concurrent.ConcurrentHashMap() @Volatile private var lastBattery: app.nectarlink.core.Battery? = null @@ -385,7 +393,10 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener started.shutdown() return@launch } - node = started + synchronized(opsLock) { + node = started + isRunning = true + } val devices = runCatching { started.pairedDevices() }.getOrDefault(emptyList()) val pcToggles = perPcToggles(started, devices.map { it.id }) val clipHistEnabled = runCatching { started.clipboardHistoryEnabled() }.getOrDefault(true) @@ -533,10 +544,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener activeNode?.shutdown() runCatching { Elevated.stop() } } - node = null startJob = null - - // Stop monitors and clear active reporting state so a subsequent start re-reports everything cleanly. lastReportedBattery = null scope.launch(Dispatchers.Main) { battery.stop() @@ -555,9 +563,13 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener wakeTimers.values.forEach { it.cancel() } wakeTimers.clear() - // Drain any pending operations so they don't replay on the next run. - while (notificationOps.tryReceive().isSuccess) {} - while (remoteOps.tryReceive().isSuccess) {} + synchronized(opsLock) { + isRunning = false + node = null + // Drain any pending operations so they don't replay on the next run. + while (notificationOps.tryReceive().isSuccess) {} + while (remoteOps.tryReceive().isSuccess) {} + } _state.update { it.withDevicesOffline().copy(status = CoreStatus.Starting) } } From b8f84d8e741fafb4283e50b5761d523402a44cc8 Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 17:20:24 +0530 Subject: [PATCH 10/13] android: scope monitor lifecycles to active Core run Signed-off-by: abinanthan-cg --- .../main/kotlin/app/nectarlink/android/core/Core.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index 2b6b5de1..d0218bd1 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -369,7 +369,8 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener if (startJob != null) return val currentRun = Job() runJob = currentRun - val runScope = CoroutineScope(scope.coroutineContext + currentRun) + val currentRunScope = CoroutineScope(scope.coroutineContext + currentRun) + runScope = currentRunScope startJob = scope.launch(Dispatchers.IO) { initLogging(if (BuildConfig.DEBUG) "debug,iroh=info,swarm_discovery=error" else "info,iroh=warn,swarm_discovery=error") val initialAwayMode = app.nectarlink.android.ui.Preferences.isAwayModeEnabled(context) @@ -493,6 +494,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener battery.start() accent.start() network.start() + if (NotificationListener.hasAccess(context)) phoneMedia.start() if (PhoneCalls.canFollow(context) || PhoneCalls.canReadLog(context)) calls.start() if (PhoneContacts.canRead(context)) contacts.start() if (PhoneSms.canRead(context)) sms.start() @@ -583,7 +585,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener fun notificationAccessChanged(granted: Boolean, showing: List) { _state.update { it.copy(notificationAccess = granted) } if (granted && _state.value.devices.isNotEmpty()) ConnectionService.start(context) - scope.launch(Dispatchers.Main) { if (granted) phoneMedia.start() else phoneMedia.stop() } + runScope?.launch(Dispatchers.Main) { if (granted) phoneMedia.start() else phoneMedia.stop() } enqueueNotificationOp { node -> node.updatePower(powerLevel(), capabilities(granted)) node.notificationsReset(showing) @@ -680,8 +682,10 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener } else if (becameSensitive) { NotificationListener.instance?.refreshActive() } - if (node != null) { - scope.launch(Dispatchers.Main) { + val currentScope = runScope + if (node != null && currentScope != null) { + currentScope.launch(Dispatchers.Main) { + if (granted) phoneMedia.start() else phoneMedia.stop() if (_state.value.devices.any { it.online }) toggles.start() else toggles.stop() if (photoAccess) photos.start() else photos.stop() if (PhoneCalls.canFollow(context) || PhoneCalls.canReadLog(context)) calls.start() else calls.stop() From 52ed2bec8f6824a65d83b0fd21af9fb7d2168fb5 Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Mon, 19 Oct 2026 17:24:56 +0530 Subject: [PATCH 11/13] android: make Core start and stop non-blocking and serialize lifecycle Signed-off-by: abinanthan-cg --- .../android/NectarlinkApplication.kt | 5 +- .../app/nectarlink/android/core/Core.kt | 128 ++++++++++-------- 2 files changed, 76 insertions(+), 57 deletions(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt b/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt index 0751473b..be51ea11 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt @@ -77,7 +77,10 @@ class NectarlinkApplication : Application(), DefaultLifecycleObserver { pendingStopJob?.cancel() pendingStopJob = scope.launch(Dispatchers.IO) { delay(STOP_DELAY_MS) - core.stop() + val inForeground = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + if (!inForeground && !Preferences.isBackgroundServiceEnabled(this@NectarlinkApplication)) { + core.stop() + } } } diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index d0218bd1..51985e2b 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -65,7 +65,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -244,6 +245,8 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener @Volatile private var runJob: Job? = null @Volatile private var runScope: CoroutineScope? = null @Volatile private var networkChange: Job? = null + private val lifecycleMutex = Mutex() + private val lifecycleGeneration = java.util.concurrent.atomic.AtomicLong(0L) private val wakeTimers = java.util.concurrent.ConcurrentHashMap() @Volatile private var lastBattery: app.nectarlink.core.Battery? = null @@ -364,14 +367,17 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener * app calls it twice as it opens, and two calls at once used to start * two nodes with one identity, which kept taking the PC from each other. */ - @Synchronized fun start() { - if (startJob != null) return - val currentRun = Job() - runJob = currentRun - val currentRunScope = CoroutineScope(scope.coroutineContext + currentRun) - runScope = currentRunScope - startJob = scope.launch(Dispatchers.IO) { + val gen = lifecycleGeneration.incrementAndGet() + scope.launch(Dispatchers.IO) { + lifecycleMutex.withLock { + if (lifecycleGeneration.get() != gen) return@withLock + if (node != null || startJob?.isActive == true) return@withLock + val currentRun = Job() + runJob = currentRun + val currentRunScope = CoroutineScope(scope.coroutineContext + currentRun) + runScope = currentRunScope + val job = currentRunScope.launch(Dispatchers.IO) { initLogging(if (BuildConfig.DEBUG) "debug,iroh=info,swarm_discovery=error" else "info,iroh=warn,swarm_discovery=error") val initialAwayMode = app.nectarlink.android.ui.Preferences.isAwayModeEnabled(context) val options = NodeOptions( @@ -390,7 +396,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener _state.update { it.copy(status = CoreStatus.Failed(e.message ?: e.javaClass.simpleName)) } return@launch } - if (!currentRun.isActive) { + if (!currentRun.isActive || lifecycleGeneration.get() != gen) { started.shutdown() return@launch } @@ -436,7 +442,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener refreshDataRetention() app.nectarlink.android.clipboard.DirectShareTargets.sync(context, _state.value.devices) app.nectarlink.android.widget.PcWidgetProvider.refreshAll(context, _state.value) - runScope.launch(Dispatchers.IO) { + currentRunScope.launch(Dispatchers.IO) { _state .map { s -> s.devices.map { d -> listOf(d.id, d.name, d.online, d.canWake, d.wakeState) } @@ -448,7 +454,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener app.nectarlink.android.widget.PcWidgetProvider.refreshAll(context, s) } } - runScope.launch(Dispatchers.IO) { + currentRunScope.launch(Dispatchers.IO) { var first = true _state .map { s -> @@ -487,8 +493,8 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener } started.updatePower(powerLevel(), capabilities(NotificationListener.hasAccess(context))) // Elevated, when set up and wireless debugging is on. - runScope.launch { Elevated.start() } - runScope.launch(Dispatchers.Main) { + currentRunScope.launch { Elevated.start() } + currentRunScope.launch(Dispatchers.Main) { // Allow first Compose frame to draw before registering BroadcastReceivers and ContentObservers on Main. delay(48L) battery.start() @@ -513,67 +519,77 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener } } } - runScope.launch { + currentRunScope.launch { for (op in notificationOps) { runCatching { op(started) }.onFailure { Log.w(TAG, "notification update failed", it) } } } - runScope.launch { + currentRunScope.launch { for (op in remoteOps) { runCatching { op(started) }.onFailure { Log.w(TAG, "remote input failed", it) } } } } + startJob = job } +} +} /** * Shuts down the active node and all per-run collectors, monitors, and loops. * Sets status back to [CoreStatus.Starting] and clears node references. - * Safe to call repeatedly, synchronized with [start] so stop and start cannot interleave. + * Non-blocking and safe to call from any thread. */ - @Synchronized fun stop() { - val activeJob = startJob - val activeNode = node - val activeRun = runJob - if (activeJob == null && activeNode == null && activeRun == null) return - - activeRun?.cancel() - runJob = null - - runBlocking(Dispatchers.IO) { - activeJob?.cancelAndJoin() - activeNode?.shutdown() - runCatching { Elevated.stop() } - } - startJob = null - lastReportedBattery = null - scope.launch(Dispatchers.Main) { - battery.stop() - accent.stop() - network.stop() - calls.stop() - contacts.stop() - sms.stop() - signal.stop() - toggles.stop() - photos.stop() - phoneMedia.stop() - pcSpeakerPlayer.stop() - } - localsendMulticast?.runCatching { release() } - wakeTimers.values.forEach { it.cancel() } - wakeTimers.clear() + val gen = lifecycleGeneration.incrementAndGet() + scope.launch(Dispatchers.IO) { + lifecycleMutex.withLock { + if (lifecycleGeneration.get() != gen) return@withLock + val activeJob = startJob + val activeRun = runJob + if (activeJob == null && node == null && activeRun == null) return@withLock + + activeRun?.cancelAndJoin() + activeJob?.cancelAndJoin() + + val activeNode = node + synchronized(opsLock) { + isRunning = false + node = null + // Drain any pending operations so they don't replay on the next run. + while (notificationOps.tryReceive().isSuccess) {} + while (remoteOps.tryReceive().isSuccess) {} + } + startJob = null + runJob = null + runScope = null + + activeNode?.shutdown() + runCatching { Elevated.stop() } + + withContext(Dispatchers.Main) { + battery.stop() + accent.stop() + network.stop() + calls.stop() + contacts.stop() + sms.stop() + signal.stop() + toggles.stop() + photos.stop() + phoneMedia.stop() + pcSpeakerPlayer.stop() + } - synchronized(opsLock) { - isRunning = false - node = null - // Drain any pending operations so they don't replay on the next run. - while (notificationOps.tryReceive().isSuccess) {} - while (remoteOps.tryReceive().isSuccess) {} - } + lastReportedBattery = null + localsendMulticast?.runCatching { release() } + wakeTimers.values.forEach { it.cancel() } + wakeTimers.clear() - _state.update { it.withDevicesOffline().copy(status = CoreStatus.Starting) } + _state.update { it.withDevicesOffline().copy(status = CoreStatus.Starting) } + app.nectarlink.android.widget.PcWidgetProvider.refreshAll(context, _state.value) + } + } } // ---- Notifications (from NotificationListener) ---- From 80fe0f1549affcd43152b787408f2649b13185ed Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Tue, 20 Oct 2026 08:47:19 +0530 Subject: [PATCH 12/13] android: fix background node startup race and close discarded nodes Signed-off-by: abinanthan-cg --- .../app/nectarlink/android/core/Core.kt | 22 ++++++---- .../app/nectarlink/android/core/StartGuard.kt | 22 ++++++++++ .../nectarlink/android/core/StartGuardTest.kt | 40 +++++++++++++++++++ 3 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/kotlin/app/nectarlink/android/core/StartGuard.kt create mode 100644 android/app/src/test/kotlin/app/nectarlink/android/core/StartGuardTest.kt diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index 51985e2b..185dc88a 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -246,7 +246,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener @Volatile private var runScope: CoroutineScope? = null @Volatile private var networkChange: Job? = null private val lifecycleMutex = Mutex() - private val lifecycleGeneration = java.util.concurrent.atomic.AtomicLong(0L) + private val startGuard = StartGuard() private val wakeTimers = java.util.concurrent.ConcurrentHashMap() @Volatile private var lastBattery: app.nectarlink.core.Battery? = null @@ -368,11 +368,10 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener * two nodes with one identity, which kept taking the PC from each other. */ fun start() { - val gen = lifecycleGeneration.incrementAndGet() scope.launch(Dispatchers.IO) { lifecycleMutex.withLock { - if (lifecycleGeneration.get() != gen) return@withLock if (node != null || startJob?.isActive == true) return@withLock + val token = startGuard.beginStart() val currentRun = Job() runJob = currentRun val currentRunScope = CoroutineScope(scope.coroutineContext + currentRun) @@ -396,8 +395,8 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener _state.update { it.copy(status = CoreStatus.Failed(e.message ?: e.javaClass.simpleName)) } return@launch } - if (!currentRun.isActive || lifecycleGeneration.get() != gen) { - started.shutdown() + if (!currentRun.isActive || startGuard.isCancelled(token)) { + shutdownAndCloseNode(started) return@launch } synchronized(opsLock) { @@ -541,10 +540,9 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener * Non-blocking and safe to call from any thread. */ fun stop() { - val gen = lifecycleGeneration.incrementAndGet() + startGuard.stop() scope.launch(Dispatchers.IO) { lifecycleMutex.withLock { - if (lifecycleGeneration.get() != gen) return@withLock val activeJob = startJob val activeRun = runJob if (activeJob == null && node == null && activeRun == null) return@withLock @@ -564,7 +562,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener runJob = null runScope = null - activeNode?.shutdown() + activeNode?.let { shutdownAndCloseNode(it) } runCatching { Elevated.stop() } withContext(Dispatchers.Main) { @@ -592,6 +590,14 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener } } + private suspend fun shutdownAndCloseNode(node: NectarlinkNode) { + try { + node.shutdown() + } finally { + runCatching { node.close() } + } + } + // ---- Notifications (from NotificationListener) ---- /** diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/StartGuard.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/StartGuard.kt new file mode 100644 index 00000000..851beb12 --- /dev/null +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/StartGuard.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package app.nectarlink.android.core + +import java.util.concurrent.atomic.AtomicInteger + +/** + * Tracks stops so that in-flight starts can detect if a stop occurred + * after they began. Multiple start calls do not cancel each other. + */ +internal class StartGuard { + private val stopCount = AtomicInteger(0) + + class Token internal constructor(internal val stopAtStart: Int) + + fun beginStart(): Token = Token(stopCount.get()) + + fun stop() { + stopCount.incrementAndGet() + } + + fun isCancelled(token: Token): Boolean = stopCount.get() != token.stopAtStart +} diff --git a/android/app/src/test/kotlin/app/nectarlink/android/core/StartGuardTest.kt b/android/app/src/test/kotlin/app/nectarlink/android/core/StartGuardTest.kt new file mode 100644 index 00000000..0c680a90 --- /dev/null +++ b/android/app/src/test/kotlin/app/nectarlink/android/core/StartGuardTest.kt @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package app.nectarlink.android.core + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StartGuardTest { + @Test + fun multipleStartsWithoutStopAreNotCancelled() { + val guard = StartGuard() + val token1 = guard.beginStart() + val token2 = guard.beginStart() + + assertFalse(guard.isCancelled(token1)) + assertFalse(guard.isCancelled(token2)) + } + + @Test + fun stopCancelsInFlightStart() { + val guard = StartGuard() + val token = guard.beginStart() + + guard.stop() + + assertTrue(guard.isCancelled(token)) + } + + @Test + fun startAfterStopIsNotCancelled() { + val guard = StartGuard() + val firstToken = guard.beginStart() + guard.stop() + + val nextToken = guard.beginStart() + + assertTrue(guard.isCancelled(firstToken)) + assertFalse(guard.isCancelled(nextToken)) + } +} From bfa0a3be60a68b3451905dcd2c43fab29a9a0757 Mon Sep 17 00:00:00 2001 From: abinanthan-cg Date: Tue, 20 Oct 2026 08:48:19 +0530 Subject: [PATCH 13/13] android: defer background stop while user activities are in progress Signed-off-by: abinanthan-cg --- .../android/NectarlinkApplication.kt | 40 +++++++++++++++++-- .../app/nectarlink/android/core/Core.kt | 19 +++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt b/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt index be51ea11..a753b3b6 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/NectarlinkApplication.kt @@ -2,6 +2,8 @@ package app.nectarlink.android import android.app.Application +import android.content.Context +import android.content.SharedPreferences import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -16,6 +18,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** Starts the core with the process and keeps it for the app's lifetime. */ @@ -29,6 +32,20 @@ class NectarlinkApplication : Application(), DefaultLifecycleObserver { private var pendingStopJob: Job? = null + private val prefListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == Preferences.BACKGROUND_SERVICE_ENABLED) { + if (Preferences.isBackgroundServiceEnabled(this)) { + pendingStopJob?.cancel() + pendingStopJob = null + } else { + if (!isInForeground()) { + scheduleStop() + } + } + } + } + override fun onCreate() { super.onCreate() // Before the core starts: its network code reads the phone's DNS servers. @@ -42,6 +59,8 @@ class NectarlinkApplication : Application(), DefaultLifecycleObserver { Elevated.onClipboardChanged = { text, html -> core.onElevatedClipboardChanged(text, html) } ProcessLifecycleOwner.get().lifecycle.addObserver(this) + getSharedPreferences("appearance", Context.MODE_PRIVATE) + .registerOnSharedPreferenceChangeListener(prefListener) if (Preferences.isBackgroundServiceEnabled(this)) { android.view.Choreographer.getInstance().postFrameCallback { @@ -67,18 +86,30 @@ class NectarlinkApplication : Application(), DefaultLifecycleObserver { } fun onBackgroundServiceDisabled() { - val inForeground = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) - if (!inForeground) { + if (!isInForeground()) { scheduleStop() } } + private fun isInForeground(): Boolean = + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + + private fun isStopEligible(): Boolean = + !isInForeground() && !Preferences.isBackgroundServiceEnabled(this) + private fun scheduleStop() { pendingStopJob?.cancel() pendingStopJob = scope.launch(Dispatchers.IO) { delay(STOP_DELAY_MS) - val inForeground = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) - if (!inForeground && !Preferences.isBackgroundServiceEnabled(this@NectarlinkApplication)) { + if (!isStopEligible()) return@launch + + while (core.busy.value) { + core.busy.first { !it } + delay(STOP_IDLE_DEBOUNCE_MS) + if (!isStopEligible()) return@launch + } + + if (isStopEligible()) { core.stop() } } @@ -86,5 +117,6 @@ class NectarlinkApplication : Application(), DefaultLifecycleObserver { companion object { const val STOP_DELAY_MS = 5_000L + const val STOP_IDLE_DEBOUNCE_MS = 2_000L } } diff --git a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index 185dc88a..662857c8 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt @@ -35,9 +35,12 @@ import app.nectarlink.android.recorder.SavedRecording import app.nectarlink.android.service.ConnectionService import app.nectarlink.android.sms.PhoneSms import app.nectarlink.android.storage.PhoneStorage +import app.nectarlink.android.mic.MicService +import app.nectarlink.android.mirror.MirrorService import app.nectarlink.android.toggles.PhoneToggles import app.nectarlink.android.webcam.WebcamRequests import app.nectarlink.android.webcam.WebcamService +import app.nectarlink.core.FfiQueueStatus import app.nectarlink.core.Event import app.nectarlink.core.EventListener import app.nectarlink.core.FileToSend @@ -70,11 +73,14 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -313,6 +319,19 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener val state: StateFlow = _state.asStateFlow() + /** True while a user-facing stream or transfer is active. */ + val busy: StateFlow = combine( + _state.map { s -> + s.transfers.any { !it.isFinished() } || + s.sendQueue.any { it.status is FfiQueueStatus.Sending || it.status is FfiQueueStatus.Waiting } + }.distinctUntilChanged(), + MirrorService.session.map { it.active }.distinctUntilChanged(), + WebcamService.session.map { it.active || it.connecting }.distinctUntilChanged(), + MicService.streaming.map { it != null }.distinctUntilChanged(), + ) { hasTransfers: Boolean, isMirroring: Boolean, isWebcam: Boolean, isMic: Boolean -> + hasTransfers || isMirroring || isWebcam || isMic + }.stateIn(scope, SharingStarted.Eagerly, false) + /** Short messages for the user (a command failed). */ val messages: SharedFlow = _messages.asSharedFlow()