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..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,17 +2,27 @@ 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 +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.flow.first 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 +30,24 @@ class NectarlinkApplication : Application() { /** Updates from GitHub releases (release builds only). */ val updater: AppUpdater by lazy { AppUpdater(this) } + 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() + 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 +57,66 @@ 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) + getSharedPreferences("appearance", Context.MODE_PRIVATE) + .registerOnSharedPreferenceChangeListener(prefListener) + + 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() { + 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) + 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() + } + } + } + + 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/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/core/Core.kt b/android/app/src/main/kotlin/app/nectarlink/android/core/Core.kt index d9e380ca..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 @@ -62,16 +65,22 @@ 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.sync.Mutex +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 @@ -90,7 +99,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 +155,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,13 +222,37 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener * laser update its "off". */ private val remoteOps = Channel Unit>(Channel.UNLIMITED) + + private val opsLock = Any() + @Volatile private var isRunning = false + + private fun enqueueNotificationOp(op: suspend (NectarlinkNode) -> Unit) { + synchronized(opsLock) { + if (isRunning && node != null) { + notificationOps.trySend(op) + } + } + } + + private fun enqueueRemoteOp(op: suspend (NectarlinkNode) -> Unit) { + synchronized(opsLock) { + if (isRunning && 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 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 lifecycleMutex = Mutex() + private val startGuard = StartGuard() private val wakeTimers = java.util.concurrent.ConcurrentHashMap() @Volatile private var lastBattery: app.nectarlink.core.Battery? = null @@ -286,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() @@ -340,10 +386,16 @@ 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 - startJob = scope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { + lifecycleMutex.withLock { + if (node != null || startJob?.isActive == true) return@withLock + val token = startGuard.beginStart() + 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( @@ -362,7 +414,14 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener _state.update { it.copy(status = CoreStatus.Failed(e.message ?: e.javaClass.simpleName)) } return@launch } - node = started + if (!currentRun.isActive || startGuard.isCancelled(token)) { + shutdownAndCloseNode(started) + return@launch + } + 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) @@ -397,10 +456,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) { + currentRunScope.launch(Dispatchers.IO) { _state .map { s -> s.devices.map { d -> listOf(d.id, d.name, d.online, d.canWake, d.wakeState) } @@ -412,7 +472,7 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener app.nectarlink.android.widget.PcWidgetProvider.refreshAll(context, s) } } - scope.launch(Dispatchers.IO) { + currentRunScope.launch(Dispatchers.IO) { var first = true _state .map { s -> @@ -451,13 +511,14 @@ 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) { + 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() 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() @@ -476,17 +537,84 @@ class Core(context: Context, private val scope: CoroutineScope) : EventListener } } } - scope.launch { + currentRunScope.launch { for (op in notificationOps) { runCatching { op(started) }.onFailure { Log.w(TAG, "notification update failed", it) } } } - scope.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. + * Non-blocking and safe to call from any thread. + */ + fun stop() { + startGuard.stop() + scope.launch(Dispatchers.IO) { + lifecycleMutex.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?.let { shutdownAndCloseNode(it) } + 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() + } + + lastReportedBattery = null + localsendMulticast?.runCatching { release() } + wakeTimers.values.forEach { it.cancel() } + wakeTimers.clear() + + _state.update { it.withDevicesOffline().copy(status = CoreStatus.Starting) } + app.nectarlink.android.widget.PcWidgetProvider.refreshAll(context, _state.value) + } + } + } + + private suspend fun shutdownAndCloseNode(node: NectarlinkNode) { + try { + node.shutdown() + } finally { + runCatching { node.close() } + } } // ---- Notifications (from NotificationListener) ---- @@ -498,23 +626,23 @@ 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() } - notificationOps.trySend { node -> + runScope?.launch(Dispatchers.Main) { if (granted) phoneMedia.start() else phoneMedia.stop() } + 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 @@ -595,15 +723,17 @@ 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() 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 +2075,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 +2094,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 +2119,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 +2159,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 +2246,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 +2269,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/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/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/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 } } 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..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 @@ -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,13 @@ 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) + (application as? NectarlinkApplication)?.onBackgroundServiceDisabled() + return START_NOT_STICKY + } if (intent?.action == ACTION_STOP_RINGING) { (application as NectarlinkApplication).core.stopRinging() } @@ -114,6 +122,7 @@ class ConnectionService : LifecycleService() { override fun onDestroy() { multicastLock?.runCatching { release() } + getSystemService(NotificationManager::class.java)?.cancel(ONGOING_ID) super.onDestroy() } @@ -164,6 +173,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 +214,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/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 5d378a84..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 @@ -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() @@ -908,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() } }, @@ -943,6 +950,16 @@ private fun App( onClearClipboardHistory = core::clearClipboardHistory, suggestClipboardActions = suggestClipboardActions, onSetSuggestClipboardActions = preferences::updateSuggestClipboardActions, + backgroundServiceEnabled = backgroundServiceEnabled, + onToggleBackgroundService = { enabled -> + preferences.updateBackgroundServiceEnabled(enabled) + if (enabled) { + core.start() + 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/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/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..e521a113 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,10 @@ Android为省电暂停了NectarLink,导致电脑无法连接。请允许它在后台运行。 已允许在后台保持连接 后台与电池 + 在后台运行 + 让此手机可以被电脑访问。关闭时,NectarLink 仅在打开时连接,通知也会消失。 + 后台连接已关闭 + NectarLink 仅在应用打开时连接。开启以保持此手机可被电脑访问。 电池设置 三星:打开应用信息 › 电池 › 选择“不受限制”,并在设备维护 › 电池 › 后台使用限制中确保 NectarLink 永不休眠。 小米 / HyperOS / MIUI:为 NectarLink 开启自启动,并在应用信息 › 省电策略中选择“无限制”。 @@ -235,6 +239,8 @@ 允许使用相机 让电脑响铃 停止响铃 + 关闭 + 开启 响铃 手机 平板 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3b7f48c1..e4d93a51 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -205,6 +205,10 @@ 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. 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. @@ -243,6 +247,8 @@ Allow camera Ring PC Stop ringing + Turn off + Turn on Ring Phone Tablet 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() 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)) + } +} 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" }