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(