Skip to content

android: let users stop the background connection service - #22

Merged
thtbee merged 13 commits into
thtbee:mainfrom
Abinanthan-CG:feat/android-stop-background-service
Oct 20, 2026
Merged

thtbee merged 13 commits into
thtbee:mainfrom
Abinanthan-CG:feat/android-stop-background-service

Conversation

@Abinanthan-CG

Copy link
Copy Markdown
Contributor

Problem

On Android, NectarLink runs an always-on foreground service (ConnectionService) with an ongoing notification ("Connected to PC" / "Waiting for your PC"). There was previously no way for the user to stop it: swiping the ongoing notification does nothing, and force-stopping the app gets undone quickly because external events (such as Android rebinding NotificationListener or BLE wakeups) trigger ConnectionService.start(...).

Solution: Central-Guard Approach

  1. Persistent Setting: Added backgroundServiceEnabled (default true, preserving existing behavior) stored in SharedPreferences ("appearance"), accessible synchronously from non-UI components (services/receivers) via Preferences.isBackgroundServiceEnabled(context) and Preferences.setBackgroundServiceEnabled(context, boolean).
  2. Central Guard: In ConnectionService.start(context), return early if backgroundServiceEnabled is false. Because all callers restart the service via ConnectionService.start(...), this single guard prevents any automatic foreground service recreation when the user has turned the background service off.
  3. Notification "Stop" Action: Added a "Stop" action button (action_stop_service) to ongoingNotification(...) whose PendingIntent sends ACTION_STOP_SERVICE to onStartCommand(). This sets backgroundServiceEnabled = false, dismisses the notification, and stops the foreground service via stopSelf().
  4. Settings Screen Toggle: Added a "Run in the background" switch under Settings › Background & battery with an explanation. Toggling it on starts ConnectionService, and toggling it off stops ConnectionService.
  5. Foreground App Functionality: When the setting is disabled, opening the app and connecting to a PC in the foreground continues to work normally without running the foreground service.

Note

If Android's Notification Access (NotificationListener) is enabled, the system service can still keep the application process alive in the background as an active listener, but NectarLink will no longer run an ongoing foreground service, post an ongoing notification, or hold a multicast lock when stopped.

Testing

  • Unit tests: Added verification for ConnectionService.ACTION_STOP_SERVICE and Preferences.BACKGROUND_SERVICE_ENABLED in CompanionAndMigrationTest.
  • Automated checks: Ran ./gradlew testDebugUnitTest, ./gradlew lintDebug, and ./gradlew assembleDebug (all passing). Also verified license headers via scripts/check-headers.ps1 and formatting via cargo fmt --all --check.

Manual Test Verification Steps

  1. Pair with a PC and observe the ongoing notification ("Connected to PC").
  2. Tap the "Stop" action button on the ongoing notification: verify that the ongoing notification disappears immediately and the background service stops.
  3. Force-stop the app from Android system settings: verify that the notification does not return when the process is recreated.
  4. Open NectarLink, navigate to Settings › Background & battery, and toggle "Run in the background" back on: verify that the ongoing notification returns.

@Abinanthan-CG
Abinanthan-CG requested a review from thtbee as a code owner October 18, 2026 01:30
@Abinanthan-CG
Abinanthan-CG force-pushed the feat/android-stop-background-service branch from eaa94ef to 9805bb6 Compare October 18, 2026 03:03
@jochenkirstaetter

Copy link
Copy Markdown
Contributor

Hi,

I have also noticed that the Android app literally cannot be stopped. Or at least not easily and that's a current issue for me.

Little suggestion: Maybe you could have started an issue and therefore discussion with this first?

I'd like to see coming to the Android part.

Cheers

@thtbee thtbee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, it's a real gap, and Jochen ran into it too. The code itself is clean: the single check in ConnectionService.start() covers every caller, it follows the existing patterns (the static helpers next to away mode, nectarSwitchColors, the switch's content description), and the Simplified Chinese strings are there.

There's one problem that needs a different approach, though, plus a few smaller points.

1. Turning it off doesn't stop the connection. The phone's connection doesn't live in ConnectionService: the core starts with the app process (NectarlinkApplication.onCreate calls core.start()), and the service only adds the ongoing notification, the multicast lock that local discovery needs, and protection from Android killing the process. With notification access granted, which most people have, Android keeps the process alive through NotificationListener, so after Stop the phone stays connected to the PC. It just loses the multicast lock, so discovery and reconnects get less reliable. And if Android does kill the process, the listener being rebound (or a companion wake-up) starts the core again. So people would get a half-off state, while the setting says NectarLink only connects while the app is open.

For the setting to mean what it says, the core has to go offline when it's off and no NectarLink screen is open, and come back when the app opens. That needs a small addition to the core (a way to pause and resume the node) and a hook in Core.kt for the app going to and from the background. I'm happy to add the core part, or to point you at the exact places if you'd like to do it.

2. One tap turns it all off, with no sign of it. Stop on the ongoing notification turns the background connection off for good, the notification disappears, and nothing in the app says it's off; the only way back is the switch in Settings. A mistaken tap would quietly stop notifications reaching the PC. Please:

  • label the action Turn off, which says more than Stop next to "Connected to PC";
  • show a note on Home while it's off, for example "Background connection is off" with a Turn on button.

3. Wording. The hint's last sentence reads as how it works today. Something like: "Keeps this phone reachable from your PC. When off, NectarLink connects only while it's open, and the notification goes away." Also, both strings.xml files gained an empty line after the hint.

4. The test. It only checks that two constants equal their own values. A test of the behavior (for example that ConnectionService.start does nothing while the setting is off) would be worth having; otherwise it's fine to drop it.

Thanks again. With point 1 sorted, this will be a good addition.

@Abinanthan-CG

Copy link
Copy Markdown
Contributor Author

hey bee bro thanks for the detailed review, super helpful and you're right, i missed that the core starts with the app process itself so my switch only half works

ill fix the smaller points now

  • rename the action to "Turn off"
  • add a "Background connection is off" card on home with a turn on button
  • reword the hint like you suggested and remove the extra blank lines in both strings files
  • replace the test with one that checks ConnectionService.start does nothing while the setting is off

for point 1 id really like your help with the core pause/resume if you dont mind, you know that part way better than me. once its in ill do the kotlin side, pause when the app goes to background with the setting off and resume when it opens. or if you want me to try the core part too just point me to the exact places and ill give it a go

no rush at all, thanks again!

thtbee added a commit that referenced this pull request Oct 18, 2026
The phone app can stop its node while in the background and start a new
one when opened (GitHub #22). This checks that stopping lets go of the
USB port and everything else a new node binds, three times over, while
the old handle is still alive.
@thtbee

thtbee commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Good news: the core doesn't need a pause. Stopping the node and starting a new one in the same process already works, and 417e13d adds a test for it: three rounds of stop and start while the old node is still around, keeping the identity, pairing and USB port.

So it's all in Core.kt:

  1. start() runs once per process (if (startJob != null) return). Add a stop() that calls node?.shutdown(), then clears node and startJob and sets the status back to starting.
  2. What start() launches for each run (the _state collectors and the notificationOps and remoteOps loops) needs its own child job that stop() cancels. Otherwise every restart adds another copy. The two loops hold on to started, so let the next run consume the channels with the new node.
  3. A new node starts with no notifications. After it starts, call NotificationListener.instance?.refreshActive() so PCs get what's showing.
  4. Drive it with ProcessLifecycleOwner: stop a few seconds after ON_STOP when the setting is off, and start on ON_START. The delay keeps a quick app switch or the share sheet from dropping the PC.
  5. Please check the monitors (battery, network, and so on) and Elevated still behave while node is null.

Happy to review it when it's up.

@jochenkirstaetter

Copy link
Copy Markdown
Contributor

Hi @thtbee

So, this means that if I'm away from my connected desktop. I mean out of the vicinity, like driving away, then the app powers down completely?

My main concern is background battery usage because of attempts to reconnect to the desktop which is not possible for x amount of time. This would be a serious problem, if the app does not shutdown completely.

Or am I reading this wrongly?

Cheers

@thtbee

thtbee commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Good question, Jochen. Two cases:

  • Background connection off (what this PR adds): the node stops when the app leaves the screen, so nothing runs and nothing tries to reconnect until you open the app again.
  • On (the default): when the PC can't be reached, the phone retries with a wait that doubles from 1 s up to 30 s, so at most one small attempt every 30 s, and Android's Doze stretches that further. It reconnects right away when the PC shows up on the network again.

That 30 s limit is also why I flagged the supervisor wake in connected() on #23.

@jochenkirstaetter

Copy link
Copy Markdown
Contributor

Hi @thtbee

Ok, let's see how this plays out.
PR #23 has been updated, and compulsory comments have been handled.

Removed wake_supervisor from Node::connected:
• Node::connected no longer calls self.shared.wake_supervisor(peer) when returning Err(Error::Offline), preventing local status/notification/clipboard/battery/media calls on an offline device from waking the supervisor out of exponential reconnect backoff.

@Abinanthan-CG
Abinanthan-CG force-pushed the feat/android-stop-background-service branch from 9805bb6 to 81a1b32 Compare October 19, 2026 11:15
@Abinanthan-CG

Copy link
Copy Markdown
Contributor Author

thanks @thtbee , all done and tested on my phone (oppo, android 11)

core now stops and starts with the app going to background and foreground like you suggested, with a 5s delay and a mutex so start and stop dont overlap. ops get dropped while its stopped and device states go offline. also did the Turn off wording, the home card, the hint text and the blank lines. replaced the weak test with a CoreState one since the project only has junit, no robolectric

tested on my phone: turning it off and leaving the app disconnects the pc, force stop stays off, opening the app reconnects and shows the card, turn on brings everything back, and a quick reopen right after turning off doesnt freeze

let me know if anything looks off!

@thtbee thtbee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. I ran it on an Android 16 emulator to check the start and stop.

  1. With Run in the background on (the default), a background start ends with no node. start() bumps lifecycleGeneration on every call, including the ones that return early because a start is already running, and the start job then shuts its node down because the generation moved on. onCreate calls start() twice (the frame callback and the 120 ms delay), so when Android starts the process without an activity (boot, the companion wake, the service), the first node starts, gets shut down within a second, and nothing starts another. On the emulator: node started at 00.337, swarm discovery's mailbox closed at 01.059, nothing after that. With the activity it only recovers because onStart calls start() once more. Only stop() should cancel a pending start, for example a counter that only stop() bumps.
  2. With it off, everything stops 5 s after the app leaves the foreground, including things the user is in the middle of: mirroring the phone to the PC (which means leaving the app), a file transfer, the webcam, the mic. The stop should wait while one of those is active and run once it ends.
  3. Small: after activeNode.shutdown(), call activeNode.close() so the old node's runtime and threads go right away instead of at the next GC.

Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
…tion is off

Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
@Abinanthan-CG
Abinanthan-CG force-pushed the feat/android-stop-background-service branch from 728282f to 52ed2be Compare October 20, 2026 02:28
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
Signed-off-by: abinanthan-cg <dabinanthan@gmail.com>
@Abinanthan-CG

Copy link
Copy Markdown
Contributor Author

Hi @thtbee, thanks for testing it on the emulator and for the detailed notes, good catch on that race

pushed 2 more commits for your 3 points

  1. start() no longer cancels itself, only stop() can cancel a pending start now (separate counter that only stop() bumps) so the double start in onCreate is harmless. added a small unit test for it
  2. with the setting off, stop now waits while mirroring, file transfer, webcam or mic is active, then stops about 2s after they end. coming back to the app cancels it
  3. added close() after shutdown() so the old node is released right away

tested on my oppo android 11

  • setting on, rebooted phone without opening the app, it connected to the pc by itself
  • setting on, opened the app, connects like before
  • setting off, mirroring stays connected after leaving the app, disconnects after i close the mirror window
  • setting off, big file transfer finishes first then disconnects
  • setting off, idle, disconnects after about 5s

didnt test webcam or mic on my phone. gradle tests, lint and header check pass

@thtbee thtbee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked it again on the emulator: a background start with the setting on keeps its node, with it off the node stops about 5 s after leaving the app and comes back on return, and coming back right as it stops ends with one node every time. Thanks for working through all of it.

@thtbee
thtbee merged commit a41502b into thtbee:main Oct 20, 2026
6 checks passed
@thtbee

thtbee commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Merged, thanks. One small thing I noticed while testing: phone as speaker (the PC's audio playing on the phone) isn't in busy, so with the setting off it stops about 5 s after leaving the app. PcSpeakerPlayer would need a flag for "frames arrived in the last few seconds". If you'd like to take it, go ahead; otherwise I'll add it.

@Abinanthan-CG

Copy link
Copy Markdown
Contributor Author

thanks for merging! took the phone as speaker one, opened a pr for it, will link it here in a bit

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants