Skip to content

feat(service): Companion Device Manager associations for BLE radios (Phase 1) - #6477

Draft
jamesarich wants to merge 1 commit into
mainfrom
feat/cdm-association-phase1
Draft

feat(service): Companion Device Manager associations for BLE radios (Phase 1)#6477
jamesarich wants to merge 1 commit into
mainfrom
feat/cdm-association-phase1

Conversation

@jamesarich

@jamesarich jamesarich commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Why

Android 12+ refuses startForegroundService() from the background. PR #6471 made that refusal safe (the policy declines the start instead of letting the watchdog kill the process), but the DeviceAddressChanged trigger still strands: when a device switch completes after the app is backgrounded, MeshService simply doesn't start until the user reopens the app.

A Companion Device Manager association plus REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND (API 31 — exactly coextensive with the background-start restriction, verified via api-versions.xml) is a named exemption to that restriction. This is Phase 1 of the phased CDM adoption plan: associations only. No CompanionDeviceService, presence observation, or presence-driven teardown (Phase 2/3), and deliberately no REQUEST_COMPANION_RUN_IN_BACKGROUND / REQUEST_COMPANION_USE_DATA_IN_BACKGROUND — the foreground service makes those broad battery-leniency grants unnecessary.

🌟 What changed

  • Manifest: companion FGS-start permission + <uses-feature android:name="android.software.companion_device_setup" android:required="false"/>.
  • CompanionAssociationRepository (core/ble, androidMain): hasAssociationFor / associate / disassociate with the API fork inside (26–32 deprecated string API vs 33+ AssociationInfo/disassociate(int)), every entry point guarded on FEATURE_COMPANION_DEVICE_SETUP. The CDM chooser IntentSenders surface on a flow; MainActivity launches them via StartIntentSenderForResult and the association truth is always re-queried from the platform, never inferred from the result.
  • Pairing hook: after a successful bond on API 31+, AndroidScannerViewModel requests an association (BluetoothDeviceFilter + setSingleDevice(true)). Fire-and-forget: user cancel or failure leaves the connect flow exactly as before — associations are strictly additive.
  • Migration prompt: one-time, dismissible card on the Connections screen for already-paired radios, shown only while connected over BLE (the CDM sheet scans, so the radio must be present). Strings go through Crowdin resources.
  • Policy: ForegroundStartPolicy.isForegroundStartAllowed gains hasCompanionAssociation — a backgrounded DeviceAddressChanged start becomes legal for an associated radio. MeshServiceStarter keeps its try/catch, so the exemption claim is verified by the OS rather than trusted.
  • Removal hygiene: there is no in-app BLE "forget" flow (removeBond has no callers; recents are TCP-only) — radios are removed by unpairing. So hasAssociationFor reconciles against the bonded set: an association whose device is no longer bonded is disassociated and no longer counts. Only positive knowledge revokes — a disabled adapter reports an empty bonded set and is ignored.

Applies to google + fdroid identically (androidMain only); desktop/iOS untouched.

🧪 Testing performed

  • ForegroundStartPolicyTest: new parameter across trigger × SDK matrix, incl. background DeviceAddressChanged allowed with association / refused without, and the background-start ↔ while-in-use invariant now checked with and without an association.
  • New CompanionAssociationRepositoryTest (Robolectric, per-test @Config(sdk = 31/32/34)): both API forks, feature-absent no-ops, chooser emission via onDeviceFound (26–32) and onAssociationPending (33+), single-device request shape, case-insensitive MAC match, stale-association drop, adapter-off keep.
  • AndroidScannerViewModelBondingTest: successful bond requests an association without gating the connect.
  • Full baseline green: spotlessApply spotlessCheck detekt assembleDebug test allTests.

📲 On-device gate (before merge)

The trigger this exemption rescues is a DeviceAddressChanged that lands while the app is backgrounded — the user never "switches devices from the background"; the switch completes asynchronously after they leave. Two real producers:

  1. Slow selection/bonding: tap a radio (bonding can take several seconds), background the app before the transition completes. Narrow race, but it is the scenario the trigger's KDoc exists for.
  2. Firmware updates (the practical repro): DFU/OTA flows call setDeviceAddress programmatically — SecureDfuHandler/Esp32OtaUpdateHandler park the address on "n" during the flash and the update flow switches back to the radio afterwards. Backgrounding the app during a multi-minute update is completely normal, and without this exemption the switch-back is refused (Skipping MeshService start in logs) and the radio stays disconnected until the app is reopened.

Gate: with an associated radio, start a firmware update, background the app mid-flash, and verify the reconnect start proceeds (no Skipping MeshService start) — on both google and fdroid flavors. (The Phase 2 PR #6479 adds the much more natural presence path — radio power-cycle while backgrounded — which exercises this same policy branch.)

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 60d04262-ed67-43f4-b6ea-5054febe9725

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…Phase 1)

Android 12+ refuses startForegroundService() from the background, which
strands the DeviceAddressChanged trigger when a device switch completes
after the app is backgrounded. A Companion Device Manager association
plus REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND (API 31,
exactly coextensive with the restriction) is a named exemption.

- Manifest: companion FGS-start permission + optional
  companion_device_setup uses-feature. Deliberately no RUN_IN_BACKGROUND /
  USE_DATA_IN_BACKGROUND battery-leniency grants.
- New CompanionAssociationRepository (core/ble androidMain): API fork
  (26-32 string API vs 33+ AssociationInfo), feature-guarded, chooser
  IntentSenders surfaced via flow and launched by MainActivity.
- Pairing hook: successful bond offers an association (fire-and-forget,
  never gates the connect).
- One-time dismissible migration prompt on the Connections screen while
  connected over BLE.
- ForegroundStartPolicy gains hasCompanionAssociation; a backgrounded
  DeviceAddressChanged start becomes legal for an associated radio. The
  starter keeps its try/catch: the OS verifies the exemption claim.
- Stale-association hygiene: an association whose device is no longer
  bonded (adapter ON) is disassociated — unpairing is how radios are
  removed; there is no in-app BLE forget flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jamesarich
jamesarich force-pushed the feat/cdm-association-phase1 branch from d9efb64 to b5cae14 Compare August 1, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant