diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index b87add6771b..4db6fb5c2cd 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -217,6 +217,9 @@ coding_rate
collapse_chart
collapsed
communicate_off_the_grid
+companion_association_dismiss
+companion_association_prompt
+companion_association_set_up
### COMPASS ###
compass_bearing
compass_bearing_na
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 8fdb99f01a6..9fbd7469e10 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -72,6 +72,16 @@
+
+
+
@@ -89,6 +99,11 @@
android:name="android.hardware.bluetooth_le"
android:required="false" />
+
+
+
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index b328ebcda8d..2a0cd0f740b 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -31,6 +31,8 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
@@ -41,8 +43,10 @@ import androidx.compose.runtime.remember
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import co.touchlab.kermit.Logger
import com.eygraber.uri.toKmpUri
import kotlinx.coroutines.launch
@@ -58,6 +62,7 @@ import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
+import org.meshtastic.core.ble.CompanionAssociationRepository
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcScannerEffect
@@ -110,6 +115,18 @@ class MainActivity : AppCompatActivity() {
private val usbRepository: UsbRepository by inject()
+ private val companionAssociationRepository: CompanionAssociationRepository by inject()
+
+ /**
+ * Launches the Companion Device Manager chooser dialogs that [CompanionAssociationRepository.associate] emits. The
+ * result payload is deliberately ignored: on confirm the platform records the association itself, so the single
+ * source of truth stays `hasAssociationFor` — the revision bump just tells observers to re-query it.
+ */
+ private val companionChooserLauncher =
+ registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
+ companionAssociationRepository.notifyAssociationsChanged()
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
@@ -168,6 +185,16 @@ class MainActivity : AppCompatActivity() {
}
}
+ // Companion association choosers may only appear over a visible activity; anything emitted while stopped is
+ // simply dropped by the STARTED gate, which is correct — the flows that request them re-offer naturally.
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ companionAssociationRepository.chooserRequests.collect { sender ->
+ companionChooserLauncher.launch(IntentSenderRequest.Builder(sender).build())
+ }
+ }
+ }
+
// Listen for new intents (e.g. deep links, NFC) without overriding onNewIntent
addOnNewIntentListener { intent -> handleIntent(intent) }
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
new file mode 100644
index 00000000000..df8f3c6d0d9
--- /dev/null
+++ b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/CompanionAssociationRepositoryTest.kt
@@ -0,0 +1,236 @@
+/*
+ * 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.ble
+
+import android.app.Application
+import android.app.PendingIntent
+import android.bluetooth.BluetoothManager
+import android.companion.CompanionDeviceManager
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.content.pm.PackageManager
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Coverage for [CompanionAssociationRepository]'s two platform forks — the deprecated string-based association API (API
+ * 26-32) and the `AssociationInfo` API (33+) — plus the guards around them: the `FEATURE_COMPANION_DEVICE_SETUP`
+ * availability gate and the bond-reconciliation rule that an association may only be dropped on *positive* knowledge of
+ * an unpair (adapter ON and the device absent from the bonded set).
+ *
+ * Per-test `@Config(sdk = ...)` pins each fork: 31/32 exercise the legacy branch on restricted-start levels, 34 the
+ * modern branch.
+ */
+@RunWith(RobolectricTestRunner::class)
+class CompanionAssociationRepositoryTest {
+
+ private companion object {
+ const val MAC = "AA:BB:CC:DD:EE:FF"
+ const val OTHER_MAC = "11:22:33:44:55:66"
+ }
+
+ private val context: Application
+ get() = RuntimeEnvironment.getApplication()
+
+ private fun newRepository(featurePresent: Boolean = true): CompanionAssociationRepository {
+ shadowOf(context.packageManager).setSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP, featurePresent)
+ return CompanionAssociationRepository(context)
+ }
+
+ private val companionDeviceManager: CompanionDeviceManager
+ get() = context.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
+
+ /** Puts [mac] in the adapter's bonded set with the adapter ON, the state where reconciliation trusts the set. */
+ private fun bond(mac: String) {
+ val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
+ shadowOf(adapter).setEnabled(true)
+ shadowOf(adapter).setBondedDevices(setOf(adapter.getRemoteDevice(mac)))
+ }
+
+ private fun setAdapterEnabled(enabled: Boolean) {
+ val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
+ shadowOf(adapter).setEnabled(enabled)
+ }
+
+ // region API fork: legacy (26-32) vs AssociationInfo (33+)
+
+ @Test
+ @Config(sdk = [31])
+ fun `legacy fork sees and removes a string association`() {
+ val repository = newRepository()
+ bond(MAC)
+ shadowOf(companionDeviceManager).addAssociation(MAC)
+
+ assertTrue(repository.hasAssociationFor(MAC))
+ assertFalse(repository.hasAssociationFor(OTHER_MAC))
+
+ repository.disassociate(MAC)
+ assertFalse(repository.hasAssociationFor(MAC))
+ }
+
+ @Test
+ @Config(sdk = [34])
+ fun `AssociationInfo fork sees and removes an association by id`() {
+ val repository = newRepository()
+ bond(MAC)
+ shadowOf(companionDeviceManager).addAssociation(MAC)
+
+ assertTrue(repository.hasAssociationFor(MAC))
+ assertFalse(repository.hasAssociationFor(OTHER_MAC))
+
+ repository.disassociate(MAC)
+ assertFalse(repository.hasAssociationFor(MAC))
+ assertTrue(companionDeviceManager.myAssociations.isEmpty())
+ }
+
+ @Test
+ @Config(sdk = [34])
+ fun `association mac comparison is case-insensitive`() {
+ val repository = newRepository()
+ bond(MAC)
+ shadowOf(companionDeviceManager).addAssociation(MAC.lowercase())
+
+ assertTrue(repository.hasAssociationFor(MAC))
+ }
+
+ // endregion
+
+ // region availability guard
+
+ @Test
+ @Config(sdk = [34])
+ fun `without the companion feature everything is an inert no-op`() {
+ val repository = newRepository(featurePresent = false)
+ bond(MAC)
+
+ assertFalse(repository.hasAssociationFor(MAC))
+ repository.associate(MAC)
+ assertNull(shadowOf(companionDeviceManager).lastAssociationRequest)
+ repository.disassociate(MAC) // must not throw
+ }
+
+ // endregion
+
+ // region associate() chooser flow
+
+ @Test
+ @Config(sdk = [32])
+ fun `associate builds a single-device request and emits the chooser on API 26-32`() = runTest {
+ val repository = newRepository()
+ bond(MAC)
+
+ repository.associate(MAC)
+
+ val shadow = shadowOf(companionDeviceManager)
+ assertNotNull(shadow.lastAssociationRequest, "associate() should reach CompanionDeviceManager")
+ assertTrue(shadow.lastAssociationRequest.isSingleDevice)
+
+ // The platform hands the chooser back through the callback; the repository must surface it to the activity.
+ val received = collectChoosers(repository)
+ @Suppress("DEPRECATION")
+ shadow.lastAssociationCallback.onDeviceFound(chooserIntentSender())
+ testScheduler.runCurrent()
+ assertEquals(1, received.size)
+ }
+
+ @Test
+ @Config(sdk = [34])
+ fun `associate emits the chooser from onAssociationPending on API 33+`() = runTest {
+ val repository = newRepository()
+ bond(MAC)
+
+ repository.associate(MAC)
+
+ val shadow = shadowOf(companionDeviceManager)
+ assertNotNull(shadow.lastAssociationRequest)
+
+ val received = collectChoosers(repository)
+ shadow.lastAssociationCallback.onAssociationPending(chooserIntentSender())
+ testScheduler.runCurrent()
+ assertEquals(1, received.size)
+ }
+
+ /**
+ * Collects [CompanionAssociationRepository.chooserRequests] into the returned list. The scheduler is run so the
+ * subscription is live before the caller fires the platform callback — the flow has no replay, so an emission
+ * before subscription would be dropped and the test would hang instead of failing crisply.
+ */
+ private fun TestScope.collectChoosers(repository: CompanionAssociationRepository): List {
+ val received = mutableListOf()
+ backgroundScope.launch { repository.chooserRequests.collect { received += it } }
+ testScheduler.runCurrent()
+ return received
+ }
+
+ @Test
+ @Config(sdk = [34])
+ fun `associate is a no-op when an association already exists`() {
+ val repository = newRepository()
+ bond(MAC)
+ shadowOf(companionDeviceManager).addAssociation(MAC)
+
+ repository.associate(MAC)
+
+ assertNull(shadowOf(companionDeviceManager).lastAssociationRequest)
+ }
+
+ private fun chooserIntentSender() =
+ PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW), PendingIntent.FLAG_IMMUTABLE).intentSender
+
+ // endregion
+
+ // region bond reconciliation ("disassociate when the radio is removed")
+
+ @Test
+ @Config(sdk = [34])
+ fun `an association whose device was unpaired is dropped`() {
+ val repository = newRepository()
+ shadowOf(companionDeviceManager).addAssociation(MAC)
+ // Adapter ON with MAC absent from the bonded set = positive knowledge the user unpaired the radio.
+ bond(OTHER_MAC)
+
+ assertFalse(repository.hasAssociationFor(MAC))
+ assertTrue(companionDeviceManager.myAssociations.isEmpty(), "the stale association should be disassociated")
+ }
+
+ @Test
+ @Config(sdk = [34])
+ fun `an association is kept while the adapter is off`() {
+ val repository = newRepository()
+ shadowOf(companionDeviceManager).addAssociation(MAC)
+ // A disabled adapter reports an EMPTY bonded set; that must never count as an unpair.
+ setAdapterEnabled(false)
+
+ assertTrue(repository.hasAssociationFor(MAC))
+ assertEquals(1, companionDeviceManager.myAssociations.size)
+ }
+
+ // endregion
+}
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
new file mode 100644
index 00000000000..7570d12f68e
--- /dev/null
+++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/CompanionAssociationRepository.kt
@@ -0,0 +1,204 @@
+/*
+ * 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.ble
+
+import android.bluetooth.BluetoothManager
+import android.companion.AssociationRequest
+import android.companion.BluetoothDeviceFilter
+import android.companion.CompanionDeviceManager
+import android.content.Context
+import android.content.IntentSender
+import android.content.pm.PackageManager
+import android.os.Build
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.koin.core.annotation.Single
+import org.meshtastic.core.model.InterfaceId
+import org.meshtastic.core.model.util.anonymize
+
+/**
+ * Manages Companion Device Manager (CDM) associations for Meshtastic radios.
+ *
+ * An association is the OS-level record that this app "companions" a specific Bluetooth device. Holding one (together
+ * with `REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND`) exempts the app from the Android 12+ background
+ * foreground-service-start restriction, which is what lets a `DeviceAddressChanged` start succeed after the app has
+ * been backgrounded. Associations are strictly additive: every flow that works without one keeps working without one.
+ *
+ * The CDM chooser is a system activity: [associate] only *requests* an association and emits the chooser's
+ * [IntentSender] on [chooserRequests]; the current activity must launch it and the user must confirm. The result is
+ * never trusted directly — [hasAssociationFor] always re-queries the platform.
+ *
+ * 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.
+ */
+@Single
+class CompanionAssociationRepository(private val context: Context) {
+
+ private val companionDeviceManager: CompanionDeviceManager?
+ get() =
+ if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP)) {
+ context.getSystemService(Context.COMPANION_DEVICE_SERVICE) as? CompanionDeviceManager
+ } else {
+ null
+ }
+
+ private val _chooserRequests = MutableSharedFlow(extraBufferCapacity = 1)
+
+ /**
+ * CDM chooser dialogs waiting to be launched. Collected by the foreground activity, which launches each sender via
+ * `StartIntentSenderForResult` and calls [notifyAssociationsChanged] once the chooser finishes.
+ */
+ val chooserRequests: SharedFlow = _chooserRequests.asSharedFlow()
+
+ private val _associationsRevision = MutableStateFlow(0)
+
+ /**
+ * 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()
+
+ /** Signals that the association set may have changed; recomputes everything derived from [hasAssociationFor]. */
+ fun notifyAssociationsChanged() {
+ _associationsRevision.value += 1
+ }
+
+ /**
+ * Whether an association currently exists for the radio at [mac].
+ *
+ * Also the disassociation point for removed radios: radios are "forgotten" by unpairing (in-app or in system
+ * Bluetooth settings), so an association whose device is no longer bonded is stale — it is dropped here and the
+ * 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 {
+ val associated = associatedMacs().any { it.equals(mac, ignoreCase = true) }
+ val unpaired = associated && isBonded(mac) == false
+ if (unpaired) {
+ Logger.i { "Dropping stale companion association for unpaired device ${mac.anonymize}" }
+ disassociate(mac)
+ }
+ return associated && !unpaired
+ }
+
+ /**
+ * Requests a CDM association for the radio at [mac], emitting the system chooser's [IntentSender] on
+ * [chooserRequests]. No-op when CDM is unavailable or an association already exists. Failure or user cancellation
+ * leaves everything as it was.
+ */
+ fun associate(mac: String) {
+ val manager = companionDeviceManager ?: return
+ if (hasAssociationFor(mac)) return
+
+ val request =
+ AssociationRequest.Builder()
+ .addDeviceFilter(BluetoothDeviceFilter.Builder().setAddress(mac).build())
+ .setSingleDevice(true)
+ .build()
+
+ Logger.i { "Requesting companion association for ${mac.anonymize}" }
+ manager.associate(
+ request,
+ object : CompanionDeviceManager.Callback() {
+ // API 33+ entry point; the deprecated override below covers API 26-32.
+ override fun onAssociationPending(intentSender: IntentSender) {
+ onChooserReady(intentSender)
+ }
+
+ @Deprecated("Called by the platform on API 26-32 only")
+ override fun onDeviceFound(intentSender: IntentSender) {
+ onChooserReady(intentSender)
+ }
+
+ override fun onFailure(error: CharSequence?) {
+ Logger.w { "Companion association request failed for ${mac.anonymize}: $error" }
+ }
+ },
+ null,
+ )
+ }
+
+ private fun onChooserReady(intentSender: IntentSender) {
+ if (!_chooserRequests.tryEmit(intentSender)) {
+ Logger.w { "Dropped companion association chooser: no capacity" }
+ }
+ }
+
+ /** Removes any association held for the radio at [mac]. Safe to call when none exists. */
+ fun disassociate(mac: String) {
+ val manager = companionDeviceManager ?: return
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ manager.myAssociations
+ .filter { it.deviceMacAddress?.toString().equals(mac, ignoreCase = true) }
+ .forEach { manager.disassociate(it.id) }
+ } else {
+ @Suppress("DEPRECATION")
+ if (manager.associations.any { it.equals(mac, ignoreCase = true) }) {
+ @Suppress("DEPRECATION")
+ manager.disassociate(mac)
+ }
+ }
+ Logger.i { "Disassociated companion device ${mac.anonymize}" }
+ } catch (ex: IllegalArgumentException) {
+ // The platform throws when the association vanished between the lookup and the call; nothing to undo.
+ Logger.w(ex) { "Disassociate failed for ${mac.anonymize}" }
+ }
+ notifyAssociationsChanged()
+ }
+
+ private fun associatedMacs(): List {
+ val manager = companionDeviceManager ?: return emptyList()
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ manager.myAssociations.mapNotNull { it.deviceMacAddress?.toString() }
+ } else {
+ @Suppress("DEPRECATION")
+ manager.associations
+ }
+ }
+
+ /**
+ * True/false when the bonded set is readable; null when it is not. The adapter must be ON: a disabled adapter
+ * reports an EMPTY bonded set, which must not be mistaken for "the user unpaired this radio".
+ */
+ private fun isBonded(mac: String): Boolean? {
+ val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
+ return try {
+ if (adapter?.isEnabled != true) return null
+ adapter.bondedDevices?.any { it.address.equals(mac, ignoreCase = true) }
+ } catch (ex: SecurityException) {
+ Logger.d(ex) { "Cannot read bonded devices; keeping companion association" }
+ null
+ }
+ }
+
+ companion object {
+ /**
+ * Extracts the bare Bluetooth MAC from an app-level full address ("x" + MAC, see [InterfaceId.BLUETOOTH]).
+ * Returns null for non-BLE addresses.
+ */
+ fun bleMacFromFullAddress(fullAddress: String?): String? =
+ fullAddress?.takeIf { it.length > 1 && it.first() == InterfaceId.BLUETOOTH.id }?.substring(1)
+ }
+}
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
index 5b48c14ff06..56c6c016843 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
@@ -124,6 +124,15 @@ class UiPrefsImpl(private val dataStore: UiDataStore, dispatchers: CoroutineDisp
scope.launch { dataStore.edit { it[KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF] = shown } }
}
+ override val companionAssociationPromptDismissed: StateFlow =
+ dataStore.data
+ .map { it[KEY_COMPANION_ASSOCIATION_PROMPT_DISMISSED] ?: false }
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setCompanionAssociationPromptDismissed(dismissed: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_COMPANION_ASSOCIATION_PROMPT_DISMISSED] = dismissed } }
+ }
+
override val showQuickChat: StateFlow =
dataStore.data.map { it[KEY_SHOW_QUICK_CHAT_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
@@ -294,6 +303,7 @@ class UiPrefsImpl(private val dataStore: UiDataStore, dispatchers: CoroutineDisp
companion object {
val KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF = booleanPreferencesKey("has_shown_not_paired_warning")
+ val KEY_COMPANION_ASSOCIATION_PROMPT_DISMISSED = booleanPreferencesKey("companion-association-prompt-dismissed")
val KEY_SHOW_QUICK_CHAT_PREF = booleanPreferencesKey("show-quick-chat")
val KEY_EVENT_THEME_ENABLED = booleanPreferencesKey("event-theme-enabled")
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index c2f26b3d2c4..b0725770693 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -120,6 +120,11 @@ interface UiPrefs {
fun setHasShownNotPairedWarning(shown: Boolean)
+ /** Whether the user dismissed the one-time companion-device (CDM) association prompt for already-paired radios. */
+ val companionAssociationPromptDismissed: StateFlow
+
+ fun setCompanionAssociationPromptDismissed(dismissed: Boolean)
+
val showQuickChat: StateFlow
fun setShowQuickChat(show: Boolean)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 4be92ff63bf..0b5d858115b 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -238,6 +238,9 @@
Collapse chart
Collapsed
Communicate off-the-grid with your friends and community without cell service.
+ Dismiss companion pairing suggestion
+ Let Android remember this radio as a companion device. This keeps the connection service reliable when the app is in the background.
+ Set up
Bearing: %1$s
Bearing: N/A
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 6ec89ac6dea..6f7021bae91 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
@@ -50,10 +50,17 @@ class ForegroundStartPolicyTest {
@Test
fun `before Android 12 every trigger may start in the background`() {
ServiceStartTrigger.entries.forEach { trigger ->
- assertTrue(
- ForegroundStartPolicy.isForegroundStartAllowed(trigger, appInForeground = false, sdkInt = ANDROID_11),
- "$trigger should be allowed pre-Android-12",
- )
+ listOf(true, false).forEach { associated ->
+ assertTrue(
+ ForegroundStartPolicy.isForegroundStartAllowed(
+ trigger,
+ appInForeground = false,
+ hasCompanionAssociation = associated,
+ sdkInt = ANDROID_11,
+ ),
+ "$trigger should be allowed pre-Android-12 (associated=$associated)",
+ )
+ }
}
}
@@ -64,6 +71,7 @@ class ForegroundStartPolicyTest {
ForegroundStartPolicy.isForegroundStartAllowed(
ServiceStartTrigger.UserInterface,
appInForeground = true,
+ hasCompanionAssociation = false,
sdkInt = sdk,
),
"UserInterface should be allowed on API $sdk",
@@ -78,6 +86,7 @@ class ForegroundStartPolicyTest {
ForegroundStartPolicy.isForegroundStartAllowed(
ServiceStartTrigger.BootCompleted,
appInForeground = false,
+ hasCompanionAssociation = false,
sdkInt = sdk,
),
"BootCompleted should be allowed on API $sdk",
@@ -86,12 +95,13 @@ class ForegroundStartPolicyTest {
}
@Test
- fun `a backgrounded device-address change is refused`() {
+ fun `a backgrounded device-address change without an association is refused`() {
RESTRICTED_LEVELS.forEach { sdk ->
assertFalse(
ForegroundStartPolicy.isForegroundStartAllowed(
ServiceStartTrigger.DeviceAddressChanged,
appInForeground = false,
+ hasCompanionAssociation = false,
sdkInt = sdk,
),
"DeviceAddressChanged should be refused while backgrounded on API $sdk",
@@ -100,19 +110,39 @@ class ForegroundStartPolicyTest {
}
@Test
- fun `a device-address change while the app is visible is allowed`() {
+ fun `a backgrounded device-address change with a companion association is allowed`() {
+ // REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND exists from API 31, exactly the level where
+ // the background-start restriction begins — so on every restricted level the association is an exemption.
RESTRICTED_LEVELS.forEach { sdk ->
assertTrue(
ForegroundStartPolicy.isForegroundStartAllowed(
ServiceStartTrigger.DeviceAddressChanged,
- appInForeground = true,
+ appInForeground = false,
+ hasCompanionAssociation = true,
sdkInt = sdk,
),
- "DeviceAddressChanged should be allowed while visible on API $sdk",
+ "DeviceAddressChanged with a companion association should be allowed on API $sdk",
)
}
}
+ @Test
+ fun `a device-address change while the app is visible is allowed`() {
+ RESTRICTED_LEVELS.forEach { sdk ->
+ listOf(true, false).forEach { associated ->
+ assertTrue(
+ ForegroundStartPolicy.isForegroundStartAllowed(
+ ServiceStartTrigger.DeviceAddressChanged,
+ appInForeground = true,
+ hasCompanionAssociation = associated,
+ sdkInt = sdk,
+ ),
+ "DeviceAddressChanged should be allowed while visible on API $sdk (associated=$associated)",
+ )
+ }
+ }
+ }
+
// endregion
// region service type selection
@@ -236,24 +266,36 @@ class ForegroundStartPolicyTest {
*/
@Test
fun `a permitted background start never asks for a while-in-use restricted type`() {
+ // Checked with and without a companion association: the association widens WHICH starts are permitted, but
+ // it must never widen the types a background start claims — location stays while-in-use restricted.
RESTRICTED_LEVELS.forEach { sdk ->
- ServiceStartTrigger.entries
- .filter { ForegroundStartPolicy.isForegroundStartAllowed(it, appInForeground = false, sdkInt = sdk) }
- .forEach { trigger ->
- val types =
- ForegroundStartPolicy.foregroundServiceType(
- hasLocationPermission = true,
+ listOf(true, false).forEach { associated ->
+ ServiceStartTrigger.entries
+ .filter {
+ ForegroundStartPolicy.isForegroundStartAllowed(
+ it,
appInForeground = false,
+ hasCompanionAssociation = associated,
sdkInt = sdk,
)
- if (sdk >= ANDROID_14) {
- assertEquals(
- 0,
- types and LOCATION,
- "$trigger on API $sdk is permitted in the background but claims location",
- )
}
- }
+ .forEach { trigger ->
+ val types =
+ ForegroundStartPolicy.foregroundServiceType(
+ hasLocationPermission = true,
+ appInForeground = false,
+ sdkInt = sdk,
+ )
+ if (sdk >= ANDROID_14) {
+ assertEquals(
+ 0,
+ types and LOCATION,
+ "$trigger on API $sdk (associated=$associated) is permitted in the background " +
+ "but claims location",
+ )
+ }
+ }
+ }
}
}
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartPolicy.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartPolicy.kt
index c9c9b6f6683..d2f4e1dd7f6 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartPolicy.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartPolicy.kt
@@ -53,11 +53,16 @@ object ForegroundStartPolicy {
* opening the app ([ServiceStartTrigger.UserInterface]).
*
* @param appInForeground whether this process currently holds a user-visible component.
+ * @param hasCompanionAssociation whether a Companion Device Manager association exists for the selected device.
+ * Together with the declared `REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND` permission (API 31,
+ * exactly coextensive with the background-start restriction) this is a named exemption, so a backgrounded
+ * [ServiceStartTrigger.DeviceAddressChanged] start becomes legal for an associated radio.
* @param sdkInt the running platform API level.
*/
fun isForegroundStartAllowed(
trigger: ServiceStartTrigger,
appInForeground: Boolean,
+ hasCompanionAssociation: Boolean,
sdkInt: Int = Build.VERSION.SDK_INT,
): Boolean = when {
// Before Android 12 there is no background-start restriction at all.
@@ -70,8 +75,10 @@ object ForegroundStartPolicy {
trigger == ServiceStartTrigger.BootCompleted -> true
- // No exemption of its own — legal only while the app is genuinely still in the foreground.
- else -> appInForeground
+ // No exemption of its own — legal while the app is still in the foreground, or (from API 31, where both the
+ // restriction and the companion-device exemption begin) when the selected radio holds a CDM association.
+ // The caller keeps its try/catch either way: the exemption claim is verified by the OS, never trusted.
+ else -> appInForeground || hasCompanionAssociation
}
/**
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceStarter.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceStarter.kt
index fe2a17c2da7..4ad18d52e25 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceStarter.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceStarter.kt
@@ -31,9 +31,13 @@ import co.touchlab.kermit.Logger
* trigger, and the dominant one — the user opening the app — fires on every `MainActivity.onStart()`. A background
* retry has no exemption the original caller lacked, so it can only fail again.
*/
-fun MeshService.Companion.startService(context: Context, trigger: ServiceStartTrigger) {
+fun MeshService.Companion.startService(
+ context: Context,
+ trigger: ServiceStartTrigger,
+ hasCompanionAssociation: Boolean = false,
+) {
val appInForeground = isAppInForeground()
- if (!ForegroundStartPolicy.isForegroundStartAllowed(trigger, appInForeground)) {
+ if (!ForegroundStartPolicy.isForegroundStartAllowed(trigger, appInForeground, hasCompanionAssociation)) {
Logger.i {
"Skipping MeshService start: trigger=$trigger is not permitted to start a foreground service while " +
"backgrounded. Waiting for the next user-visible start."
@@ -41,7 +45,10 @@ fun MeshService.Companion.startService(context: Context, trigger: ServiceStartTr
return
}
- Logger.i { "Starting MeshService (trigger=$trigger, appInForeground=$appInForeground)" }
+ Logger.i {
+ "Starting MeshService (trigger=$trigger, appInForeground=$appInForeground, " +
+ "hasCompanionAssociation=$hasCompanionAssociation)"
+ }
try {
context.startForegroundService(createIntent(context))
} catch (ex: IllegalStateException) {
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 356c312c28e..7304a1ad075 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
@@ -20,6 +20,7 @@ import android.content.Context
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
+import org.meshtastic.core.ble.CompanionAssociationRepository
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.di.ServiceScope
import org.meshtastic.core.repository.AdminController
@@ -77,6 +78,7 @@ class CoreServiceAndroidModule {
notificationManager: NotificationManager,
messageProcessor: Lazy,
radioConfigRepository: RadioConfigRepository,
+ companionAssociationRepository: CompanionAssociationRepository,
scope: ServiceScope,
): RadioController = RadioControllerImpl(
serviceRepository = serviceRepository,
@@ -95,6 +97,19 @@ class CoreServiceAndroidModule {
messageProcessor = messageProcessor,
radioConfigRepository = radioConfigRepository,
scope = scope,
- onDeviceAddressChanged = { MeshService.startService(context, ServiceStartTrigger.DeviceAddressChanged) },
+ onDeviceAddressChanged = {
+ // The address change may complete after the app has been backgrounded; a CDM association for the newly
+ // selected radio is the only exemption that can make that start legal, so look it up right here.
+ val selectedBleMac =
+ CompanionAssociationRepository.bleMacFromFullAddress(
+ radioInterfaceService.currentDeviceAddressFlow.value,
+ )
+ MeshService.startService(
+ context,
+ ServiceStartTrigger.DeviceAddressChanged,
+ hasCompanionAssociation =
+ selectedBleMac?.let(companionAssociationRepository::hasAssociationFor) ?: false,
+ )
+ },
)
}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index 0834a03c22a..48f2e109a82 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -146,6 +146,12 @@ class FakeUiPrefs : UiPrefs {
hasShownNotPairedWarning.value = shown
}
+ override val companionAssociationPromptDismissed = MutableStateFlow(false)
+
+ override fun setCompanionAssociationPromptDismissed(dismissed: Boolean) {
+ companionAssociationPromptDismissed.value = dismissed
+ }
+
override val showQuickChat = MutableStateFlow(true)
override fun setShowQuickChat(show: Boolean) {
diff --git a/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
index 5d1686db029..63b7366f11b 100644
--- a/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
+++ b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
@@ -16,6 +16,9 @@
*/
package org.meshtastic.feature.connections
+import android.companion.CompanionDeviceManager
+import android.content.Context
+import android.content.pm.PackageManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import kotlinx.coroutines.CancellationException
@@ -26,6 +29,7 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.jetbrains.compose.resources.getString
import org.junit.runner.RunWith
+import org.meshtastic.core.ble.CompanionAssociationRepository
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bonding_failed_retry
@@ -36,6 +40,7 @@ import org.meshtastic.core.testing.failBondWithSecurityException
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -80,6 +85,7 @@ class AndroidScannerViewModelBondingTest {
networkRepository = harness.networkRepository,
dispatchers = harness.dispatchers,
bluetoothRepository = harness.bluetoothRepository,
+ companionAssociationRepository = CompanionAssociationRepository(RuntimeEnvironment.getApplication()),
usbRepository = inertUsbRepository(),
uiPrefs = harness.uiPrefs,
firmwareRecoveryDataSource = harness.firmwareRecoveryDataSource,
@@ -180,6 +186,26 @@ class AndroidScannerViewModelBondingTest {
)
}
+ @Test
+ fun `successful bond offers a companion association without gating the connect`() =
+ runTest(harness.testDispatcher) {
+ // The association is additive: the request must reach CompanionDeviceManager, and the transport must arm
+ // regardless of whether the user ever confirms the system chooser.
+ val application = RuntimeEnvironment.getApplication()
+ shadowOf(application.packageManager).setSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP, true)
+
+ viewModel.onSelected(ScannerViewModelHarness.unbondedBleEntry(mac))
+ testScheduler.advanceUntilIdle()
+
+ val companionDeviceManager =
+ application.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
+ assertNotNull(
+ shadowOf(companionDeviceManager).lastAssociationRequest,
+ "a successful bond should request a companion association",
+ )
+ assertEquals(expectedFullAddress, harness.radioController.lastSetDeviceAddress)
+ }
+
@Test
fun `already bonded entry arms the transport without bonding`() = runTest(harness.testDispatcher) {
// R6: selecting an already-bonded device connects directly without invoking createBond().
diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
index 944d2e57722..9fb1085d8b1 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
+++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
@@ -16,16 +16,20 @@
*/
package org.meshtastic.feature.connections
+import android.os.Build
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.ble.BluetoothRepository
+import org.meshtastic.core.ble.CompanionAssociationRepository
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.model.util.anonymize
@@ -40,6 +44,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bonding_failed_permissions
import org.meshtastic.core.resources.bonding_failed_retry
import org.meshtastic.core.resources.usb_permission_denied
+import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.connections.model.AndroidUsbDeviceData
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
@@ -56,6 +61,7 @@ class AndroidScannerViewModel(
networkRepository: NetworkRepository,
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
private val bluetoothRepository: BluetoothRepository,
+ private val companionAssociationRepository: CompanionAssociationRepository,
private val usbRepository: UsbRepository,
uiPrefs: UiPrefs,
firmwareRecoveryDataSource: FirmwareRecoveryDataSource,
@@ -113,11 +119,45 @@ class AndroidScannerViewModel(
}
}
if (armTransport) {
+ maybeAssociateCompanionDevice(entry.device.address)
changeDeviceAddress(entry.fullAddress)
}
}
}
+ /**
+ * Offers a Companion Device Manager association right after a successful bond (Android 12+, where the association
+ * buys the background service-start exemption). Strictly additive: user cancellation or failure leaves the connect
+ * flow exactly as it was — the chooser is fire-and-forget and never gates [changeDeviceAddress].
+ */
+ private fun maybeAssociateCompanionDevice(mac: String) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
+ companionAssociationRepository.associate(mac)
+ }
+
+ /**
+ * The migration prompt for radios paired before companion associations existed: visible while an unassociated BLE
+ * radio is selected on Android 12+ and the user has not dismissed it. The connected-state gate lives in the UI.
+ */
+ override val companionAssociationPromptVisible: StateFlow =
+ combine(
+ selectedAddressFlow,
+ uiPrefs.companionAssociationPromptDismissed,
+ companionAssociationRepository.associationsRevision,
+ ) { address, dismissed, _ ->
+ val mac = CompanionAssociationRepository.bleMacFromFullAddress(address)
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
+ !dismissed &&
+ mac != null &&
+ !companionAssociationRepository.hasAssociationFor(mac)
+ }
+ .stateInWhileSubscribed(initialValue = false)
+
+ override fun requestCompanionAssociationForSelectedDevice() {
+ val mac = CompanionAssociationRepository.bleMacFromFullAddress(selectedAddressFlow.value) ?: return
+ companionAssociationRepository.associate(mac)
+ }
+
override fun requestPermission(entry: DeviceListEntry.Usb) {
val usbData = entry.usbData as? AndroidUsbDeviceData ?: return
usbRepository
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
index 898524ebe0a..46dfd3297df 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
@@ -653,6 +653,23 @@ open class ScannerViewModel(
changeDeviceAddress(entry.fullAddress)
}
+ // ── Companion device association (Android-only; every default below is an inert no-op) ────
+
+ /**
+ * Whether the connected radio should be offered the one-time companion-device association prompt: a platform
+ * (Android 12+) companion record that keeps background service starts legal for this radio. Combined with the
+ * connected-state gate in the UI, because the association chooser scans and therefore needs the radio present.
+ */
+ open val companionAssociationPromptVisible: StateFlow = MutableStateFlow(false).asStateFlow()
+
+ /** Requests a companion association for the currently selected BLE radio. */
+ open fun requestCompanionAssociationForSelectedDevice() {}
+
+ /** Permanently retires the companion-association prompt; associations made while pairing are unaffected. */
+ fun dismissCompanionAssociationPrompt() {
+ uiPrefs.setCompanionAssociationPromptDismissed(true)
+ }
+
/** Platform hook for requesting USB permission before connecting; default is a no-op. */
protected open fun requestPermission(entry: DeviceListEntry.Usb) = Unit
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
index d5896d48097..0fb73b8c004 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
@@ -38,6 +38,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -66,6 +67,9 @@ import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_disabled
+import org.meshtastic.core.resources.companion_association_dismiss
+import org.meshtastic.core.resources.companion_association_prompt
+import org.meshtastic.core.resources.companion_association_set_up
import org.meshtastic.core.resources.connections
import org.meshtastic.core.resources.disconnect
import org.meshtastic.core.resources.firmware_event_ended_banner
@@ -92,6 +96,7 @@ import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.RecoveryCard
import org.meshtastic.core.ui.icon.Bluetooth
+import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.Language
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
@@ -152,6 +157,7 @@ fun ConnectionsScreen(
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val persistedDeviceName by scanModel.persistedDeviceName.collectAsStateWithLifecycle()
val pendingRecovery by scanModel.pendingRecovery.collectAsStateWithLifecycle()
+ val companionPromptEligible by scanModel.companionAssociationPromptVisible.collectAsStateWithLifecycle()
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@@ -417,6 +423,17 @@ fun ConnectionsScreen(
}
}
+ // One-time migration offer for radios paired before companion associations existed. Gated on
+ // Connected because the system's association chooser scans for the radio — it can only
+ // succeed while the device is provably present. Dismissible and strictly additive.
+ if (companionPromptEligible && connectionState is ConnectionState.Connected) {
+ Spacer(modifier = Modifier.height(8.dp))
+ CompanionAssociationPromptCard(
+ onSetUp = scanModel::requestCompanionAssociationForSelectedDevice,
+ onDismiss = scanModel::dismissCompanionAssociationPrompt,
+ )
+ }
+
// Transport selector sits between the connection card and device list; it controls only the
// visible discovery pane, not the globally selected/connected device shown above.
TransportSelector(
@@ -567,6 +584,43 @@ private fun FirmwareUpdateNoticeCard(notice: FirmwareUpdateNotice, onAction: ()
}
}
+/**
+ * Dismissible one-time offer to register the connected radio as an OS companion device, which keeps background service
+ * starts legal for it (Android 12+). Informational tone on purpose: nothing is wrong, so it must not look like the
+ * error-styled [RecoveryCard]s around it.
+ */
+@Composable
+private fun CompanionAssociationPromptCard(onSetUp: () -> Unit, onDismiss: () -> Unit) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ ) {
+ Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.Top) {
+ Icon(
+ imageVector = MeshtasticIcons.Bluetooth,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
+ Text(
+ text = stringResource(Res.string.companion_association_prompt),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Button(modifier = Modifier.padding(top = 12.dp), onClick = onSetUp) {
+ Text(stringResource(Res.string.companion_association_set_up))
+ }
+ }
+ IconButton(onClick = onDismiss) {
+ Icon(
+ imageVector = MeshtasticIcons.Close,
+ contentDescription = stringResource(Res.string.companion_association_dismiss),
+ )
+ }
+ }
+ }
+}
+
/** Body for the CONNECTED state — sits inside the shared outer Card in [ConnectionsScreen]. */
@Composable
private fun ConnectedDeviceContent(