diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 9fbd7469e10..8e069a881a1 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -82,6 +82,12 @@
-->
+
+
+
@@ -202,6 +208,20 @@
android:foregroundServiceType="connectedDevice|location"
android:exported="false" />
+
+
+
+
+
+
+
().start() }
+
// Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB
applicationScope.launch {
val dbManager: DatabaseManager = get()
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
index ce9169f573f..88838f8abfe 100644
--- a/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
@@ -66,6 +66,10 @@ class KoinVerificationTest {
// declared as known types even though they're never resolved from the graph.
BleLogLevel::class,
BleLogFormat::class,
+ // CompanionPresenceCoordinator is assembled by a provider fun that hands it the
+ // selected-address StateFlow directly (so its tests need nothing but a
+ // MutableStateFlow); Verify still introspects the constructor param.
+ kotlinx.coroutines.flow.StateFlow::class,
okio.Path::class,
okio.FileSystem::class,
),
diff --git a/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/CompanionAssociationRepositoryTest.kt b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/CompanionAssociationRepositoryTest.kt
index df8f3c6d0d9..df067f0ad3f 100644
--- a/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/CompanionAssociationRepositoryTest.kt
+++ b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/CompanionAssociationRepositoryTest.kt
@@ -206,6 +206,58 @@ class CompanionAssociationRepositoryTest {
// endregion
+ // region presence observation
+
+ @Test
+ // sdk 34, not 31: same production branch (everything below 36 takes the String overload), but Robolectric's
+ // ShadowCompanionDeviceManager only implements startObservingDevicePresence from sdk 33.
+ @Config(sdk = [34])
+ fun `presence observation uses the string API before 36`() {
+ val repository = newRepository()
+ bond(MAC)
+ shadowOf(companionDeviceManager).addAssociation(MAC)
+
+ repository.startObservingPresence(MAC)
+
+ assertEquals(MAC, shadowOf(companionDeviceManager).lastObservingDevicePresenceDeviceAddress)
+ }
+
+ @Test
+ @Config(sdk = [30])
+ fun `presence observation is a no-op before API 31`() {
+ val repository = newRepository()
+ bond(MAC)
+ shadowOf(companionDeviceManager).addAssociation(MAC)
+
+ repository.startObservingPresence(MAC)
+
+ assertNull(shadowOf(companionDeviceManager).lastObservingDevicePresenceDeviceAddress)
+ }
+
+ @Test
+ @Config(sdk = [34])
+ fun `presence observation is a no-op without the companion feature`() {
+ val repository = newRepository(featurePresent = false)
+
+ repository.startObservingPresence(MAC)
+
+ assertNull(shadowOf(companionDeviceManager).lastObservingDevicePresenceDeviceAddress)
+ }
+
+ @Test
+ @Config(sdk = [34])
+ fun `association ids resolve back to the radio mac`() {
+ val repository = newRepository()
+ bond(MAC)
+ shadowOf(companionDeviceManager).addAssociation(MAC)
+
+ val id = companionDeviceManager.myAssociations.single().id
+ assertEquals(MAC.lowercase(), repository.macForAssociationId(id)?.lowercase())
+ assertNull(repository.macForAssociationId(id + 1))
+ }
+
+ // endregion
+
// region bond reconciliation ("disassociate when the radio is removed")
@Test
diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/CompanionAssociationRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/CompanionAssociationRepository.kt
index 7570d12f68e..4926afe8b7f 100644
--- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/CompanionAssociationRepository.kt
+++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/CompanionAssociationRepository.kt
@@ -20,6 +20,8 @@ import android.bluetooth.BluetoothManager
import android.companion.AssociationRequest
import android.companion.BluetoothDeviceFilter
import android.companion.CompanionDeviceManager
+import android.companion.DeviceNotAssociatedException
+import android.companion.ObservingDevicePresenceRequest
import android.content.Context
import android.content.IntentSender
import android.content.pm.PackageManager
@@ -49,11 +51,16 @@ import org.meshtastic.core.model.util.anonymize
*
* API fork: CDM exists since API 26, but the association API was replaced in API 33 — `getMyAssociations()` returning
* `AssociationInfo` (with integer ids for `disassociate(int)`) supersedes `getAssociations(): List` and
- * `disassociate(String)`. Every entry point is additionally guarded on [PackageManager.FEATURE_COMPANION_DEVICE_SETUP],
- * which not all devices declare.
+ * `disassociate(String)`. Presence observation forks again at API 36, where the String overloads of
+ * `startObservingDevicePresence`/`stopObservingDevicePresence` are deprecated in favor of
+ * [android.companion.ObservingDevicePresenceRequest]. Every entry point is additionally guarded on
+ * [PackageManager.FEATURE_COMPANION_DEVICE_SETUP], which not all devices declare.
+ *
+ * Open (not an interface) purely so tests can substitute a recording fake; production has exactly one implementation.
*/
+@Suppress("TooManyFunctions") // Cohesive CDM facade: association CRUD + presence observation, same platform object.
@Single
-class CompanionAssociationRepository(private val context: Context) {
+open class CompanionAssociationRepository(private val context: Context) {
private val companionDeviceManager: CompanionDeviceManager?
get() =
@@ -77,7 +84,7 @@ class CompanionAssociationRepository(private val context: Context) {
* Bumped whenever the association set may have changed (chooser finished, [disassociate] ran). Combine with it to
* re-run [hasAssociationFor] reactively.
*/
- val associationsRevision: StateFlow = _associationsRevision.asStateFlow()
+ open val associationsRevision: StateFlow = _associationsRevision.asStateFlow()
/** Signals that the association set may have changed; recomputes everything derived from [hasAssociationFor]. */
fun notifyAssociationsChanged() {
@@ -92,7 +99,7 @@ class CompanionAssociationRepository(private val context: Context) {
* method returns false. When the bonded set cannot be read (missing `BLUETOOTH_CONNECT`), the association is kept:
* only positive knowledge of an unpair may revoke it.
*/
- fun hasAssociationFor(mac: String): Boolean {
+ open fun hasAssociationFor(mac: String): Boolean {
val associated = associatedMacs().any { it.equals(mac, ignoreCase = true) }
val unpaired = associated && isBonded(mac) == false
if (unpaired) {
@@ -168,6 +175,70 @@ class CompanionAssociationRepository(private val context: Context) {
notifyAssociationsChanged()
}
+ // region presence observation (API 31+)
+
+ /**
+ * Asks the platform to bind `MeshCompanionDeviceService` with presence events for the radio at [mac]. Requires an
+ * existing association and API 31+ (`REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE` is declared in the manifest).
+ * Idempotent, and must be re-asserted on every process start — registration does not reliably survive reboots.
+ *
+ * API fork: the String overload carries presence from 31, is deprecated at 36; from 36 the request form keyed by
+ * association id replaces it.
+ */
+ open fun startObservingPresence(mac: String) {
+ observePresence(mac, start = true)
+ }
+
+ /** Stops presence observation for the radio at [mac]. Safe to call when none is registered. */
+ open fun stopObservingPresence(mac: String) {
+ observePresence(mac, start = false)
+ }
+
+ private fun observePresence(mac: String, start: Boolean) {
+ val manager = companionDeviceManager
+ if (manager == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
+ val verb = if (start) "start" else "stop"
+ try {
+ val dispatched =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
+ dispatchPresenceRequest(manager, mac, start)
+ } else {
+ @Suppress("DEPRECATION")
+ if (start) manager.startObservingDevicePresence(mac) else manager.stopObservingDevicePresence(mac)
+ true
+ }
+ if (dispatched) Logger.i { "Presence observation ${verb}ed for ${mac.anonymize}" }
+ } catch (ex: DeviceNotAssociatedException) {
+ // The association raced away (user disassociated between lookup and call); nothing left to observe.
+ Logger.w(ex) { "Cannot $verb presence observation for ${mac.anonymize}: not associated" }
+ } catch (ex: IllegalStateException) {
+ Logger.w(ex) { "Cannot $verb presence observation for ${mac.anonymize}" }
+ }
+ }
+
+ /** The API 36+ presence form is keyed by association id; false when no association exists for [mac]. */
+ private fun dispatchPresenceRequest(manager: CompanionDeviceManager, mac: String, start: Boolean): Boolean {
+ val id = associationIdFor(mac) ?: return false
+ val request = ObservingDevicePresenceRequest.Builder().setAssociationId(id).build()
+ if (start) manager.startObservingDevicePresence(request) else manager.stopObservingDevicePresence(request)
+ return true
+ }
+
+ /** Resolves a presence event's association id back to the radio's MAC. API 33+ (ids only exist there). */
+ fun macForAssociationId(associationId: Int): String? {
+ val manager = companionDeviceManager
+ if (manager == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null
+ return manager.myAssociations.firstOrNull { it.id == associationId }?.deviceMacAddress?.toString()
+ }
+
+ private fun associationIdFor(mac: String): Int? {
+ val manager = companionDeviceManager
+ if (manager == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null
+ return manager.myAssociations.firstOrNull { it.deviceMacAddress?.toString().equals(mac, ignoreCase = true) }?.id
+ }
+
+ // endregion
+
private fun associatedMacs(): List {
val manager = companionDeviceManager ?: return emptyList()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/CompanionPresenceCoordinatorTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/CompanionPresenceCoordinatorTest.kt
new file mode 100644
index 00000000000..35b1ef792ba
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/CompanionPresenceCoordinatorTest.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.ble.CompanionAssociationRepository
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Reconciliation rules for [CompanionPresenceCoordinator]: presence observation follows the selected, associated BLE
+ * radio and nothing else — old registrations retire on selection change or deselection, and associations that arrive
+ * later (revision bump) are picked up.
+ *
+ * Robolectric supplies only the Context the repository fake's constructor needs; every CDM interaction is recorded by
+ * the fake, so the tests are deterministic.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class CompanionPresenceCoordinatorTest {
+
+ private companion object {
+ const val MAC = "AA:BB:CC:DD:EE:FF"
+ const val OTHER_MAC = "11:22:33:44:55:66"
+ const val SDK_34 = 34
+ }
+
+ /** Records observation calls; association truth is the [associated] set plus the inherited revision flow. */
+ private class RecordingRepository : CompanionAssociationRepository(RuntimeEnvironment.getApplication()) {
+ val associated = mutableSetOf()
+ val startCalls = mutableListOf()
+ val stopCalls = mutableListOf()
+
+ override val associationsRevision = MutableStateFlow(0)
+
+ override fun hasAssociationFor(mac: String): Boolean = associated.any { it.equals(mac, ignoreCase = true) }
+
+ override fun startObservingPresence(mac: String) {
+ startCalls += mac
+ }
+
+ override fun stopObservingPresence(mac: String) {
+ stopCalls += mac
+ }
+ }
+
+ private fun TestScope.startCoordinator(
+ address: MutableStateFlow,
+ repository: RecordingRepository,
+ sdkInt: Int = SDK_34,
+ ) = CompanionPresenceCoordinator(
+ selectedAddressFlow = address,
+ repository = repository,
+ scope = backgroundScope,
+ sdkInt = sdkInt,
+ )
+ .start()
+
+ @Test
+ fun `observes the selected associated radio on start`() = runTest(UnconfinedTestDispatcher()) {
+ val repository = RecordingRepository().apply { associated += MAC }
+ val address = MutableStateFlow("x$MAC")
+
+ startCoordinator(address, repository)
+
+ assertEquals(listOf(MAC), repository.startCalls)
+ assertEquals(emptyList(), repository.stopCalls)
+ }
+
+ @Test
+ fun `selection change retires the old registration and observes the new radio`() =
+ runTest(UnconfinedTestDispatcher()) {
+ val repository = RecordingRepository().apply { associated += listOf(MAC, OTHER_MAC) }
+ val address = MutableStateFlow("x$MAC")
+ startCoordinator(address, repository)
+
+ address.value = "x$OTHER_MAC"
+
+ assertEquals(listOf(MAC, OTHER_MAC), repository.startCalls)
+ assertEquals(listOf(MAC), repository.stopCalls)
+ }
+
+ @Test
+ fun `deselection retires the registration`() = runTest(UnconfinedTestDispatcher()) {
+ val repository = RecordingRepository().apply { associated += MAC }
+ val address = MutableStateFlow("x$MAC")
+ startCoordinator(address, repository)
+
+ address.value = null
+
+ assertEquals(listOf(MAC), repository.startCalls)
+ assertEquals(listOf(MAC), repository.stopCalls)
+ }
+
+ @Test
+ fun `a radio without an association is not observed until one appears`() = runTest(UnconfinedTestDispatcher()) {
+ val repository = RecordingRepository()
+ val address = MutableStateFlow("x$MAC")
+ startCoordinator(address, repository)
+
+ assertEquals(emptyList(), repository.startCalls)
+
+ // The user confirms the CDM chooser: association lands, revision bumps, observation follows.
+ repository.associated += MAC
+ repository.associationsRevision.value += 1
+
+ assertEquals(listOf(MAC), repository.startCalls)
+ }
+
+ @Test
+ fun `non-BLE selections are never observed`() = runTest(UnconfinedTestDispatcher()) {
+ val repository = RecordingRepository().apply { associated += MAC }
+ val address = MutableStateFlow("t192.168.1.10")
+
+ startCoordinator(address, repository)
+
+ assertEquals(emptyList(), repository.startCalls)
+ }
+
+ @Test
+ fun `inert before API 31`() = runTest(UnconfinedTestDispatcher()) {
+ val repository = RecordingRepository().apply { associated += MAC }
+ val address = MutableStateFlow("x$MAC")
+
+ startCoordinator(address, repository, sdkInt = 30)
+
+ assertEquals(emptyList(), repository.startCalls)
+ }
+}
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/CompanionPresencePolicyTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/CompanionPresencePolicyTest.kt
new file mode 100644
index 00000000000..40dba6b7b41
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/CompanionPresencePolicyTest.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import org.junit.Test
+import org.meshtastic.core.model.ConnectionState
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Rules for [CompanionPresencePolicy]: presence events act only on the selected radio, and appeared events never fight
+ * a live connection.
+ */
+class CompanionPresencePolicyTest {
+
+ private companion object {
+ const val MAC = "AA:BB:CC:DD:EE:FF"
+ const val OTHER_MAC = "11:22:33:44:55:66"
+ }
+
+ // region appeared → start
+
+ @Test
+ fun `appeared selected radio starts the service while disconnected`() {
+ listOf(ConnectionState.Disconnected, ConnectionState.Connecting, ConnectionState.DeviceSleep).forEach { state ->
+ assertTrue(
+ CompanionPresencePolicy.shouldStartOnAppeared(MAC, MAC, state),
+ "appeared should start while $state",
+ )
+ }
+ }
+
+ @Test
+ fun `appeared event matches the selected radio case-insensitively`() {
+ assertTrue(CompanionPresencePolicy.shouldStartOnAppeared(MAC.lowercase(), MAC, ConnectionState.Disconnected))
+ }
+
+ @Test
+ fun `appeared event never starts for a non-selected radio`() {
+ // Other associations are just older radios the user still owns; their presence is not a connect request.
+ assertFalse(CompanionPresencePolicy.shouldStartOnAppeared(OTHER_MAC, MAC, ConnectionState.Disconnected))
+ assertFalse(CompanionPresencePolicy.shouldStartOnAppeared(null, MAC, ConnectionState.Disconnected))
+ assertFalse(CompanionPresencePolicy.shouldStartOnAppeared(MAC, null, ConnectionState.Disconnected))
+ }
+
+ @Test
+ fun `appeared event does not restart a live connection`() {
+ assertFalse(CompanionPresencePolicy.shouldStartOnAppeared(MAC, MAC, ConnectionState.Connected))
+ }
+
+ // endregion
+}
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ForegroundStartPolicyTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ForegroundStartPolicyTest.kt
index 6f7021bae91..22fe6751830 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ForegroundStartPolicyTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ForegroundStartPolicyTest.kt
@@ -126,6 +126,40 @@ class ForegroundStartPolicyTest {
}
}
+ @Test
+ fun `a backgrounded companion-presence start is allowed with an association`() {
+ // The trigger only fires for an associated device, so this is the production shape: the system delivered a
+ // device-appeared event, which is exactly the companion-device exemption.
+ RESTRICTED_LEVELS.forEach { sdk ->
+ assertTrue(
+ ForegroundStartPolicy.isForegroundStartAllowed(
+ ServiceStartTrigger.CompanionDevicePresent,
+ appInForeground = false,
+ hasCompanionAssociation = true,
+ sdkInt = sdk,
+ ),
+ "CompanionDevicePresent with an association should be allowed on API $sdk",
+ )
+ }
+ }
+
+ @Test
+ fun `a backgrounded companion-presence start without an association is refused`() {
+ // Unreachable in production (presence events imply an association), but the policy must stay fail-closed if
+ // a caller ever supplies the trigger without the fact.
+ RESTRICTED_LEVELS.forEach { sdk ->
+ assertFalse(
+ ForegroundStartPolicy.isForegroundStartAllowed(
+ ServiceStartTrigger.CompanionDevicePresent,
+ appInForeground = false,
+ hasCompanionAssociation = false,
+ sdkInt = sdk,
+ ),
+ "CompanionDevicePresent without an association should be refused on API $sdk",
+ )
+ }
+ }
+
@Test
fun `a device-address change while the app is visible is allowed`() {
RESTRICTED_LEVELS.forEach { sdk ->
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/CompanionPresenceCoordinator.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/CompanionPresenceCoordinator.kt
new file mode 100644
index 00000000000..093ca6232b1
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/CompanionPresenceCoordinator.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.os.Build
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+import org.meshtastic.core.ble.CompanionAssociationRepository
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Keeps Companion Device Manager presence observation pointed at exactly one radio: the currently selected BLE device,
+ * and only while it holds an association. Selecting a different radio (or disassociating) retires the old registration,
+ * so `MeshCompanionDeviceService` never receives events the presence policy would discard anyway.
+ *
+ * [start]ed once from `Application.onCreate` (the repo's eager-init pattern — deliberately not `createdAtStart`, see
+ * `AppFunctionsModule`), which also serves as the re-assertion the platform needs: presence registration must be
+ * re-declared on every process start because it does not reliably survive reboots.
+ *
+ * Constructed with the bare address flow rather than `RadioInterfaceService` so tests exercise the reconciliation with
+ * nothing but a `MutableStateFlow` and a recording repository fake.
+ */
+class CompanionPresenceCoordinator(
+ private val selectedAddressFlow: StateFlow,
+ private val repository: CompanionAssociationRepository,
+ private val scope: CoroutineScope,
+ private val sdkInt: Int = Build.VERSION.SDK_INT,
+) {
+
+ private val started = AtomicBoolean(false)
+ private var observedMac: String? = null
+
+ /** Begins reconciling presence observation with the selection; idempotent. No-op before API 31. */
+ fun start() {
+ if (sdkInt < Build.VERSION_CODES.S || !started.compareAndSet(false, true)) return
+ scope.launch {
+ combine(selectedAddressFlow, repository.associationsRevision) { address, _ ->
+ CompanionAssociationRepository.bleMacFromFullAddress(address)?.takeIf {
+ repository.hasAssociationFor(it)
+ }
+ }
+ .distinctUntilChanged()
+ .collect { desired -> reconcile(desired) }
+ }
+ }
+
+ private fun reconcile(desired: String?) {
+ val current = observedMac
+ if (current != null && !current.equals(desired, ignoreCase = true)) {
+ repository.stopObservingPresence(current)
+ }
+ if (desired != null && !desired.equals(current, ignoreCase = true)) {
+ repository.startObservingPresence(desired)
+ }
+ observedMac = desired
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/CompanionPresencePolicy.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/CompanionPresencePolicy.kt
new file mode 100644
index 00000000000..9255549db53
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/CompanionPresencePolicy.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import org.meshtastic.core.model.ConnectionState
+
+/**
+ * Decides how `MeshCompanionDeviceService` reacts to Companion Device Manager presence events.
+ *
+ * Deliberately pure, in the mold of [ForegroundStartPolicy]: every platform fact arrives as a parameter so the rules
+ * can be unit tested without a device. Presence events arrive for *any* associated radio, so the rule first insists the
+ * event is about the currently selected one — other associations are simply older radios the user still owns.
+ */
+object CompanionPresencePolicy {
+
+ /**
+ * Whether a device-appeared event should start [MeshService]: the appeared radio is the selected one and there is
+ * no live connection to preserve. Starting while `Connecting` is deliberately allowed — `onStartCommand` is
+ * re-entrant by design (`MainActivity.onStart()` re-issues it on every visit), and the appeared event may be the
+ * first sign the radio is actually reachable again.
+ *
+ * @param appearedMac the MAC the presence event names, if resolvable.
+ * @param selectedBleMac the MAC of the currently selected BLE radio, or null when the selection is not BLE.
+ * @param connectionState the app-wide connection state.
+ */
+ fun shouldStartOnAppeared(
+ appearedMac: String?,
+ selectedBleMac: String?,
+ connectionState: ConnectionState,
+ ): Boolean = isSelectedRadio(appearedMac, selectedBleMac) && connectionState !is ConnectionState.Connected
+
+ private fun isSelectedRadio(eventMac: String?, selectedBleMac: String?): Boolean =
+ eventMac != null && selectedBleMac != null && eventMac.equals(selectedBleMac, ignoreCase = true)
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshCompanionDeviceService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshCompanionDeviceService.kt
new file mode 100644
index 00000000000..bf6102302cc
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshCompanionDeviceService.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.companion.AssociationInfo
+import android.companion.CompanionDeviceService
+import android.companion.DevicePresenceEvent
+import android.os.Build
+import androidx.annotation.RequiresApi
+import co.touchlab.kermit.Logger
+import org.koin.android.ext.android.inject
+import org.meshtastic.core.ble.CompanionAssociationRepository
+import org.meshtastic.core.model.util.anonymize
+import org.meshtastic.core.repository.ConnectionStateProvider
+import org.meshtastic.core.repository.RadioInterfaceService
+
+/**
+ * System-bound Companion Device Manager presence service (Phase 2 of CDM adoption).
+ *
+ * Once `CompanionAssociationRepository.startObservingPresence` registers the selected radio, the platform binds this
+ * service — reviving the process if necessary — when that radio's BLE presence changes. A device-appeared event starts
+ * [MeshService] so the transport reconnects to the persisted address (`MeshServiceOrchestrator.start()` connects on its
+ * own; no scan is involved — the transport builds its Peripheral straight from the MAC). Device-disappeared events are
+ * deliberately unhandled: presence-driven teardown (Phase 3) waits until field data shows appeared-events are
+ * trustworthy across OEMs, because a torn-down service that never restarts is strictly worse than today's idle one.
+ *
+ * The decision lives in [CompanionPresencePolicy]; this class only adapts the three generations of platform callback
+ * (String on 31–32, [AssociationInfo] on 33–35, [DevicePresenceEvent] on 36+) onto it. The action is idempotent —
+ * `onStartCommand` is re-entrant by design — so an OS that delivers an event through more than one callback generation
+ * cannot cause harm.
+ */
+@RequiresApi(Build.VERSION_CODES.S)
+class MeshCompanionDeviceService : CompanionDeviceService() {
+
+ private val radioInterfaceService: RadioInterfaceService by inject()
+ private val connectionStateProvider: ConnectionStateProvider by inject()
+ private val companionAssociationRepository: CompanionAssociationRepository by inject()
+
+ // API 33-35 entry point.
+ override fun onDeviceAppeared(associationInfo: AssociationInfo) {
+ handleAppeared(associationInfo.deviceMacAddress?.toString())
+ }
+
+ // API 31-32 entry point.
+ @Deprecated("Platform entry point on API 31-32 only")
+ override fun onDeviceAppeared(address: String) {
+ handleAppeared(address)
+ }
+
+ // API 36+ entry point. Not calling super keeps the deprecated callbacks from firing a second time.
+ override fun onDevicePresenceEvent(event: DevicePresenceEvent) {
+ when (event.event) {
+ DevicePresenceEvent.EVENT_BLE_APPEARED ->
+ handleAppeared(companionAssociationRepository.macForAssociationId(event.associationId))
+
+ else -> Logger.d { "Ignoring companion presence event ${event.event}" }
+ }
+ }
+
+ private fun handleAppeared(mac: String?) {
+ if (!CompanionPresencePolicy.shouldStartOnAppeared(mac, selectedBleMac(), connectionState())) return
+ Logger.i { "Companion radio ${mac?.anonymize} appeared; starting MeshService" }
+ MeshService.startService(this, ServiceStartTrigger.CompanionDevicePresent, hasCompanionAssociation = true)
+ }
+
+ private fun selectedBleMac(): String? =
+ CompanionAssociationRepository.bleMacFromFullAddress(radioInterfaceService.currentDeviceAddressFlow.value)
+
+ private fun connectionState() = connectionStateProvider.connectionState.value
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceStartTrigger.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceStartTrigger.kt
index 127a3414444..40b1a296d4d 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceStartTrigger.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceStartTrigger.kt
@@ -44,4 +44,11 @@ enum class ServiceStartTrigger {
* restriction.
*/
BootCompleted,
+
+ /**
+ * The associated radio came back into range: the system bound `MeshCompanionDeviceService` and delivered a
+ * device-appeared event. Only exists on API 31+ (Companion Device Manager presence), and by construction the device
+ * holds an association — which is precisely the companion-device exemption to the background-start restriction.
+ */
+ CompanionDevicePresent,
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt
index 7304a1ad075..dc8c991bb68 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt
@@ -42,6 +42,7 @@ import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
+import org.meshtastic.core.service.CompanionPresenceCoordinator
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.service.RadioControllerImpl
import org.meshtastic.core.service.ServiceStartTrigger
@@ -50,6 +51,21 @@ import org.meshtastic.core.service.startService
@Module
@ComponentScan("org.meshtastic.core.service")
class CoreServiceAndroidModule {
+ /**
+ * Provider (not component-scanned) because the coordinator deliberately takes the bare address flow instead of
+ * [RadioInterfaceService] — see its KDoc. Eagerly started from `MeshUtilApplication.onCreate`.
+ */
+ @Single
+ fun companionPresenceCoordinator(
+ radioInterfaceService: RadioInterfaceService,
+ companionAssociationRepository: CompanionAssociationRepository,
+ scope: ServiceScope,
+ ): CompanionPresenceCoordinator = CompanionPresenceCoordinator(
+ selectedAddressFlow = radioInterfaceService.currentDeviceAddressFlow,
+ repository = companionAssociationRepository,
+ scope = scope,
+ )
+
@Suppress("LongParameterList")
@Single(
binds =