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 =