Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@
-->
<uses-permission android:name="android.permission.REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND" />

<!--
API 31+: lets MeshCompanionDeviceService receive presence (appeared/disappeared) events for the
associated radio, so the mesh service can restart when the radio comes back into range.
-->
<uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />

<!-- Needed to open our bluetooth connection to our paired device (after reboot) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

Expand Down Expand Up @@ -202,6 +208,20 @@
android:foregroundServiceType="connectedDevice|location"
android:exported="false" />

<!--
Companion Device Manager presence service: the system binds it (reviving the process if needed) when the
associated radio appears or disappears, on API 31+ only — older platforms never bind it, so the API-31
superclass is safe to declare here. Exported with the platform-signature BIND permission per CDM contract.
-->
<service
android:name="org.meshtastic.core.service.MeshCompanionDeviceService"
android:exported="true"
android:permission="android.permission.BIND_COMPANION_DEVICE_SERVICE">
<intent-filter>
<action android:name="android.companion.CompanionDeviceService" />
</intent-filter>
</service>

<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.discovery_interrupted_scan_restored
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.service.CompanionPresenceCoordinator
import org.meshtastic.core.service.worker.MeshLogCleanupWorker
import org.meshtastic.feature.discovery.DiscoveryScanEngine
import org.meshtastic.feature.widget.LocalStatsWidgetReceiver
Expand Down Expand Up @@ -121,6 +122,10 @@ open class MeshUtilApplication :
}
}

// Re-assert Companion Device Manager presence observation for the selected radio (it does not reliably
// survive reboots) and keep it reconciled with the selection for the process's life.
applicationScope.launch { get<CompanionPresenceCoordinator>().start() }

// Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB
applicationScope.launch {
val dbManager: DatabaseManager = get()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>` 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() =
Expand All @@ -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<Int> = _associationsRevision.asStateFlow()
open val associationsRevision: StateFlow<Int> = _associationsRevision.asStateFlow()

/** Signals that the association set may have changed; recomputes everything derived from [hasAssociationFor]. */
fun notifyAssociationsChanged() {
Expand All @@ -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) {
Expand Down Expand Up @@ -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<String> {
val manager = companionDeviceManager ?: return emptyList()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>()
val startCalls = mutableListOf<String>()
val stopCalls = mutableListOf<String>()

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<String?>,
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<String?>("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<String?>("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<String?>("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<String?>("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<String?>("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<String?>("x$MAC")

startCoordinator(address, repository, sdkInt = 30)

assertEquals(emptyList(), repository.startCalls)
}
}
Loading