diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index edeba531d34..d5448148845 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -227,6 +227,24 @@
android:foregroundServiceType="connectedDevice|location"
android:exported="false" />
+
+
+
+
+
+
+
+
+
=
dataStore.data.map { it[KEY_DEV_ADDR_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
+ override suspend fun awaitDevAddr(): String? = dataStore.data.first()[KEY_DEV_ADDR_PREF]
+
override val devName: StateFlow =
dataStore.data.map { it[KEY_DEV_NAME_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImplTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImplTest.kt
new file mode 100644
index 00000000000..a0f0085d547
--- /dev/null
+++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImplTest.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.prefs.radio
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import okio.FileSystem
+import okio.Path
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.asRadioDataStore
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.uuid.Uuid
+
+class RadioPrefsImplTest {
+ private lateinit var tmpDir: Path
+ private lateinit var testDispatcher: TestDispatcher
+ private lateinit var testScope: TestScope
+ private lateinit var dispatchers: CoroutineDispatchers
+
+ @BeforeTest
+ fun setup() {
+ // Standard, not Unconfined: the store must not have loaded when the first read happens.
+ testDispatcher = StandardTestDispatcher()
+ testScope = TestScope(testDispatcher)
+ tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "radioPrefsTest-${Uuid.random()}"
+ FileSystem.SYSTEM.createDirectories(tmpDir)
+ dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ testScope.cancel()
+ FileSystem.SYSTEM.deleteRecursively(tmpDir)
+ }
+
+ private fun store(): DataStore =
+ PreferenceDataStoreFactory.createWithPath(scope = testScope, produceFile = { tmpDir / "radio.preferences_pb" })
+
+ @Test
+ fun `awaitDevAddr is null when no address was ever saved`() = testScope.runTest {
+ val prefs = RadioPrefsImpl(store().asRadioDataStore(), dispatchers)
+
+ assertNull(prefs.awaitDevAddr())
+ }
+
+ @Test
+ fun `awaitDevAddr returns the saved address before devAddr has loaded`() = testScope.runTest {
+ val radio = store()
+ radio.edit { it[RadioPrefsImpl.KEY_DEV_ADDR_PREF] = "x00:11:22:33:44:55" }
+
+ val prefs = RadioPrefsImpl(radio.asRadioDataStore(), dispatchers)
+
+ assertNull(prefs.devAddr.value)
+ assertEquals("x00:11:22:33:44:55", prefs.awaitDevAddr())
+ }
+}
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 2f71548f6f8..f0e11b50489 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
@@ -374,6 +374,9 @@ interface MapTileProviderPrefs {
interface RadioPrefs {
val devAddr: StateFlow
+ /** The saved address once the store has loaded; [devAddr] reads null until then. */
+ suspend fun awaitDevAddr(): String?
+
/** The persisted user-visible name of the connected device (e.g. "Meshtastic_1234"). */
val devName: StateFlow
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..76bfc0d7b1d 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
@@ -113,6 +113,34 @@ class ForegroundStartPolicyTest {
}
}
+ @Test
+ fun `a tile tap from the background is attempted`() {
+ RESTRICTED_LEVELS.forEach { sdk ->
+ assertTrue(
+ ForegroundStartPolicy.isForegroundStartAllowed(
+ ServiceStartTrigger.QuickSettingsTile,
+ appInForeground = false,
+ sdkInt = sdk,
+ ),
+ "QuickSettingsTile should be attempted while backgrounded on API $sdk",
+ )
+ }
+ }
+
+ @Test
+ fun `a tile tap while the app is visible is allowed`() {
+ RESTRICTED_LEVELS.forEach { sdk ->
+ assertTrue(
+ ForegroundStartPolicy.isForegroundStartAllowed(
+ ServiceStartTrigger.QuickSettingsTile,
+ appInForeground = true,
+ sdkInt = sdk,
+ ),
+ "QuickSettingsTile should be allowed while visible on API $sdk",
+ )
+ }
+ }
+
// endregion
// region service type selection
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/QuickSettingsTileStateTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/QuickSettingsTileStateTest.kt
new file mode 100644
index 00000000000..fb0d374fa86
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/QuickSettingsTileStateTest.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.service.quicksettings.Tile
+import org.junit.Test
+import org.meshtastic.core.model.ConnectionState
+import kotlin.test.assertEquals
+
+class QuickSettingsTileStateTest {
+
+ private fun tile(
+ connectionState: ConnectionState,
+ hasSelectedDevice: Boolean = true,
+ serviceRunning: Boolean = true,
+ ourShortName: String? = "ABCD",
+ ) = quickSettingsTileState(connectionState, hasSelectedDevice, serviceRunning, ourShortName)
+
+ @Test
+ fun `connected is active with our short name and taps disconnect`() {
+ assertEquals(
+ QuickSettingsTileState(
+ Tile.STATE_ACTIVE,
+ QuickSettingsTileSubtitle.NodeName("ABCD"),
+ QuickSettingsTileAction.Disconnect,
+ ),
+ tile(ConnectionState.Connected),
+ )
+ }
+
+ @Test
+ fun `connected before our node is known falls back to the connected label`() {
+ listOf(null, "", " ").forEach { name ->
+ assertEquals(
+ QuickSettingsTileSubtitle.Connected,
+ tile(ConnectionState.Connected, ourShortName = name).subtitle,
+ )
+ }
+ }
+
+ @Test
+ fun `connecting is active and a tap cancels it`() {
+ assertEquals(
+ QuickSettingsTileState(
+ Tile.STATE_ACTIVE,
+ QuickSettingsTileSubtitle.Connecting,
+ QuickSettingsTileAction.Disconnect,
+ ),
+ tile(ConnectionState.Connecting),
+ )
+ }
+
+ @Test
+ fun `a sleeping radio is inactive and a tap connects`() {
+ assertEquals(
+ QuickSettingsTileState(
+ Tile.STATE_INACTIVE,
+ QuickSettingsTileSubtitle.DeviceSleeping,
+ QuickSettingsTileAction.Connect,
+ ),
+ tile(ConnectionState.DeviceSleep),
+ )
+ }
+
+ @Test
+ fun `a disconnected remembered radio is inactive and a tap connects`() {
+ assertEquals(
+ QuickSettingsTileState(
+ Tile.STATE_INACTIVE,
+ QuickSettingsTileSubtitle.Disconnected,
+ QuickSettingsTileAction.Connect,
+ ),
+ tile(ConnectionState.Disconnected),
+ )
+ }
+
+ @Test
+ fun `with no radio selected every state opens the connect tab`() {
+ listOf(
+ ConnectionState.Connected,
+ ConnectionState.Connecting,
+ ConnectionState.DeviceSleep,
+ ConnectionState.Disconnected,
+ )
+ .forEach { state ->
+ assertEquals(
+ QuickSettingsTileState(
+ Tile.STATE_INACTIVE,
+ QuickSettingsTileSubtitle.NoDeviceSelected,
+ QuickSettingsTileAction.OpenConnections,
+ ),
+ tile(state, hasSelectedDevice = false),
+ "$state",
+ )
+ }
+ }
+
+ @Test
+ fun `a stale connected state with the service stopped reads as disconnected`() {
+ assertEquals(
+ QuickSettingsTileState(
+ Tile.STATE_INACTIVE,
+ QuickSettingsTileSubtitle.Disconnected,
+ QuickSettingsTileAction.Connect,
+ ),
+ tile(ConnectionState.Connected, serviceRunning = false),
+ )
+ }
+}
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..abfcdf7b2cf 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
@@ -70,6 +70,10 @@ object ForegroundStartPolicy {
trigger == ServiceStartTrigger.BootCompleted -> true
+ // SystemUI temp-allowlists the app for a tile click (reason "tile onclick"); a refusal on a release without
+ // that falls back to opening the app.
+ trigger == ServiceStartTrigger.QuickSettingsTile -> true
+
// No exemption of its own — legal only while the app is genuinely still in the foreground.
else -> appInForeground
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshQuickSettingsTileService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshQuickSettingsTileService.kt
new file mode 100644
index 00000000000..4d100891e74
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshQuickSettingsTileService.kt
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.app.PendingIntent
+import android.content.ComponentName
+import android.content.Intent
+import android.os.Build
+import android.service.quicksettings.TileService
+import androidx.core.net.toUri
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.connected
+import org.meshtastic.core.resources.connecting
+import org.meshtastic.core.resources.device_sleeping
+import org.meshtastic.core.resources.disconnected
+import org.meshtastic.core.resources.getStringSuspend
+import org.meshtastic.core.resources.no_device_selected
+
+/**
+ * Quick Settings tile for the radio link: shows the connection and connects or disconnects the selected radio. An
+ * active tile, so it listens only when [QuickSettingsTileController] asks the system to refresh it.
+ */
+class MeshQuickSettingsTileService :
+ TileService(),
+ KoinComponent {
+
+ private val controller: QuickSettingsTileController by inject()
+ private val scope: CoroutineScope = MainScope()
+ private var listenJob: Job? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ controller.startObserving()
+ }
+
+ // An active tile is bound only on request, so one added while the state is steady would stay blank.
+ override fun onTileAdded() {
+ super.onTileAdded()
+ requestListeningState(this, ComponentName(this, MeshQuickSettingsTileService::class.java))
+ }
+
+ override fun onStartListening() {
+ super.onStartListening()
+ listenJob?.cancel()
+ listenJob = scope.launch { controller.state.collect { render(it) } }
+ }
+
+ override fun onStopListening() {
+ listenJob?.cancel()
+ listenJob = null
+ super.onStopListening()
+ }
+
+ override fun onClick() {
+ super.onClick()
+ if (isSecure && isLocked) unlockAndRun(::handleClick) else handleClick()
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun handleClick() {
+ scope.launch { if (controller.onClick()) openConnections() }
+ }
+
+ private suspend fun render(state: QuickSettingsTileState) {
+ val tile = qsTile ?: return
+ tile.state = state.state
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ tile.subtitle =
+ when (val subtitle = state.subtitle) {
+ is QuickSettingsTileSubtitle.NodeName -> subtitle.shortName
+ QuickSettingsTileSubtitle.Connected -> getStringSuspend(Res.string.connected)
+ QuickSettingsTileSubtitle.Connecting -> getStringSuspend(Res.string.connecting)
+ QuickSettingsTileSubtitle.DeviceSleeping -> getStringSuspend(Res.string.device_sleeping)
+ QuickSettingsTileSubtitle.Disconnected -> getStringSuspend(Res.string.disconnected)
+ QuickSettingsTileSubtitle.NoDeviceSelected -> getStringSuspend(Res.string.no_device_selected)
+ }
+ }
+ tile.updateTile()
+ }
+
+ private fun openConnections() {
+ val intent =
+ Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/connections".toUri())
+ .setPackage(packageName)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE))
+ } else {
+ @Suppress("DEPRECATION")
+ startActivityAndCollapse(intent)
+ }
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index 7cdc5909c55..6e9d856c619 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -71,6 +71,8 @@ class MeshService : Service() {
private val radioOperationLock: RadioOperationLock by inject()
+ private val quickSettingsTileController: QuickSettingsTileController by inject()
+
private var isServiceInitialized = false
/**
@@ -128,6 +130,7 @@ class MeshService : Service() {
// Keep conversation shortcuts published for the whole service lifetime (not only during an Android Auto
// session) so message notifications can link to them and get Conversations-section treatment on phones.
conversationShortcutPublisher.startObserving(serviceScope)
+ quickSettingsTileController.startObserving()
// Hold a wake lock for as long as — and no longer than — some radio operation is in flight.
serviceScope.launch {
@@ -202,31 +205,28 @@ class MeshService : Service() {
*/
private fun scheduleDeviceAddressResolution() {
addressWaitJob?.cancel()
- addressWaitJob =
- serviceScope.launch {
- val resolved =
- withTimeoutOrNull(DEVICE_ADDRESS_SETTLE_MS) {
- radioInterfaceService.currentDeviceAddressFlow.first(::isValidDeviceAddress)
- }
- when (
- serviceStayAliveDecision(resolved, radioOperationLock.isActive, transportFactory::isAddressValid)
- ) {
- ServiceStayAliveDecision.STAY_FOR_DEVICE -> {
- Logger.i {
- "MeshService: selected device resolved (${resolved.anonymize}) after address-flow wait"
- }
- acquireWakeLock()
+ addressWaitJob = serviceScope.launch {
+ val resolved =
+ withTimeoutOrNull(DEVICE_ADDRESS_SETTLE_MS) {
+ radioInterfaceService.currentDeviceAddressFlow.first(::isValidDeviceAddress)
+ }
+ when (serviceStayAliveDecision(resolved, radioOperationLock.isActive, transportFactory::isAddressValid)) {
+ ServiceStayAliveDecision.STAY_FOR_DEVICE -> {
+ Logger.i {
+ "MeshService: selected device resolved (${resolved.anonymize}) after address-flow wait"
}
+ acquireWakeLock()
+ }
- ServiceStayAliveDecision.STAY_FOR_OPERATION ->
- Logger.i { "MeshService: no device selected, but a radio operation is in flight; staying up" }
+ ServiceStayAliveDecision.STAY_FOR_OPERATION ->
+ Logger.i { "MeshService: no device selected, but a radio operation is in flight; staying up" }
- ServiceStayAliveDecision.STOP -> {
- Logger.i { "MeshService: no connectable device selected after address flow settled; stopping" }
- stopServiceCleanly()
- }
+ ServiceStayAliveDecision.STOP -> {
+ Logger.i { "MeshService: no connectable device selected after address flow settled; stopping" }
+ stopServiceCleanly()
}
}
+ }
}
/** Releases everything this service holds and stops it. Safe to call before it ever reached the foreground. */
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..d1cef6fae92 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
@@ -30,26 +30,31 @@ import co.touchlab.kermit.Logger
* There is deliberately no retry. Every context that can legally promote this app to the foreground already has its own
* 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.
+ *
+ * @return whether the start was requested and accepted by the platform; false when it was skipped or refused.
*/
-fun MeshService.Companion.startService(context: Context, trigger: ServiceStartTrigger) {
+fun MeshService.Companion.startService(context: Context, trigger: ServiceStartTrigger): Boolean {
val appInForeground = isAppInForeground()
if (!ForegroundStartPolicy.isForegroundStartAllowed(trigger, appInForeground)) {
Logger.i {
"Skipping MeshService start: trigger=$trigger is not permitted to start a foreground service while " +
"backgrounded. Waiting for the next user-visible start."
}
- return
+ return false
}
Logger.i { "Starting MeshService (trigger=$trigger, appInForeground=$appInForeground)" }
- try {
+ return try {
context.startForegroundService(createIntent(context))
+ true
} catch (ex: IllegalStateException) {
// ForegroundServiceStartNotAllowedException extends IllegalStateException (via ServiceStartNotAllowedException)
// and only exists from API 31; catching the supertype keeps this call site free of an API-gated class
// reference. The service was never created, so there is nothing to tear down here.
Logger.w(ex) { "Rejected MeshService foreground start (trigger=$trigger)" }
+ false
} catch (ex: SecurityException) {
Logger.w(ex) { "Missing permission for MeshService foreground start (trigger=$trigger)" }
+ false
}
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt
new file mode 100644
index 00000000000..0fd39b58a86
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.content.ComponentName
+import android.content.Context
+import android.service.quicksettings.TileService
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
+import org.meshtastic.core.common.util.isValidDeviceAddress
+import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioController
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioPrefs
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Backs [MeshQuickSettingsTileService]: publishes the tile's state and performs its taps on the application scope, so a
+ * tap's work outlives the tile service, which the system unbinds as soon as the shade closes.
+ */
+@Single
+@Suppress("LongParameterList")
+class QuickSettingsTileController(
+ private val context: Context,
+ private val radioController: RadioController,
+ private val radioInterfaceService: RadioInterfaceService,
+ private val radioPrefs: RadioPrefs,
+ nodeRepository: NodeRepository,
+ private val orchestrator: MeshServiceOrchestrator,
+ private val appScope: ApplicationCoroutineScope,
+) {
+ private val observing = AtomicBoolean(false)
+
+ val state: Flow =
+ combine(
+ radioController.connectionState,
+ radioInterfaceService.currentDeviceAddressFlow,
+ nodeRepository.ourNodeInfo.map { it?.user?.short_name },
+ ) { connectionState, address, shortName ->
+ quickSettingsTileState(
+ connectionState,
+ isValidDeviceAddress(address),
+ orchestrator.isRunning,
+ shortName,
+ )
+ }
+ .distinctUntilChanged()
+
+ /** Asks the system to refresh the tile on every state change. Idempotent; runs for the life of the process. */
+ fun startObserving() {
+ if (!observing.compareAndSet(false, true)) return
+ val component = ComponentName(context, MeshQuickSettingsTileService::class.java)
+ appScope.launch {
+ // The first emission refreshes a tile left stale by a previous process.
+ state.collect { _ ->
+ safeCatching { TileService.requestListeningState(context, component) }
+ .onFailure { error -> Logger.w(error) { "Quick Settings tile refresh request failed" } }
+ }
+ }
+ }
+
+ /**
+ * Performs a tap on the tile.
+ *
+ * @return true when the tile must open the Connect tab instead, either because no radio is selected or because the
+ * platform will not let the service start from here; opening the app then starts it the usual way.
+ */
+ suspend fun onClick(): Boolean {
+ // The live selection when there is one; otherwise the saved one, which is null until DataStore's first load.
+ val address =
+ radioInterfaceService.currentDeviceAddressFlow.value
+ ?: withTimeoutOrNull(ADDRESS_SETTLE_MS) { radioPrefs.awaitDevAddr() }
+ val tile =
+ quickSettingsTileState(
+ connectionState = radioController.connectionState.value,
+ hasSelectedDevice = isValidDeviceAddress(address),
+ serviceRunning = orchestrator.isRunning,
+ ourShortName = null,
+ )
+ return when (tile.action) {
+ QuickSettingsTileAction.OpenConnections -> true
+
+ QuickSettingsTileAction.Disconnect -> {
+ appScope.launch {
+ radioPrefs.setDevName(null)
+ safeCatching { radioController.setDeviceAddress(NO_DEVICE_SELECTED) }
+ .onFailure { Logger.w(it) { "Quick Settings tile disconnect failed" } }
+ }
+ false
+ }
+
+ QuickSettingsTileAction.Connect ->
+ if (orchestrator.isRunning && address != null) {
+ // Reselecting the current radio is the Connect tab's own repair path for a dropped link.
+ appScope.launch {
+ safeCatching { radioController.setDeviceAddress(address) }
+ .onFailure { Logger.w(it) { "Quick Settings tile reconnect failed" } }
+ }
+ false
+ } else {
+ !MeshService.startService(context, ServiceStartTrigger.QuickSettingsTile)
+ }
+ }
+ }
+
+ private companion object {
+ const val ADDRESS_SETTLE_MS = 2_000L
+ const val NO_DEVICE_SELECTED = "n"
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileState.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileState.kt
new file mode 100644
index 00000000000..7a8b2c5a2a4
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileState.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.service.quicksettings.Tile
+import org.meshtastic.core.model.ConnectionState
+
+/** What a tap on the Quick Settings tile does. */
+enum class QuickSettingsTileAction {
+ /** Bring the selected radio's link up. */
+ Connect,
+
+ /** Deselect the radio, the same disconnect the Connect tab performs. */
+ Disconnect,
+
+ /** No radio is selected, so the tile can only send the user to the Connect tab. */
+ OpenConnections,
+}
+
+/** The tile's secondary line. */
+sealed interface QuickSettingsTileSubtitle {
+ data class NodeName(val shortName: String) : QuickSettingsTileSubtitle
+
+ data object Connected : QuickSettingsTileSubtitle
+
+ data object Connecting : QuickSettingsTileSubtitle
+
+ data object DeviceSleeping : QuickSettingsTileSubtitle
+
+ data object Disconnected : QuickSettingsTileSubtitle
+
+ data object NoDeviceSelected : QuickSettingsTileSubtitle
+}
+
+/** Everything the tile renders, with [state] one of `Tile.STATE_ACTIVE` or `Tile.STATE_INACTIVE`. */
+data class QuickSettingsTileState(
+ val state: Int,
+ val subtitle: QuickSettingsTileSubtitle,
+ val action: QuickSettingsTileAction,
+)
+
+/**
+ * Maps the radio link onto the tile.
+ *
+ * A selected radio that is still connecting reads as active, matching the Connect tab, which treats any selected device
+ * as in progress and offers disconnect; a tap then cancels the attempt. [serviceRunning] guards against a stale
+ * [ConnectionState.Connected] after the service has stopped.
+ */
+fun quickSettingsTileState(
+ connectionState: ConnectionState,
+ hasSelectedDevice: Boolean,
+ serviceRunning: Boolean,
+ ourShortName: String?,
+): QuickSettingsTileState = when {
+ !hasSelectedDevice ->
+ QuickSettingsTileState(
+ Tile.STATE_INACTIVE,
+ QuickSettingsTileSubtitle.NoDeviceSelected,
+ QuickSettingsTileAction.OpenConnections,
+ )
+
+ !serviceRunning ->
+ QuickSettingsTileState(
+ Tile.STATE_INACTIVE,
+ QuickSettingsTileSubtitle.Disconnected,
+ QuickSettingsTileAction.Connect,
+ )
+
+ else ->
+ when (connectionState) {
+ ConnectionState.Connected ->
+ QuickSettingsTileState(
+ Tile.STATE_ACTIVE,
+ ourShortName?.takeIf { it.isNotBlank() }?.let(QuickSettingsTileSubtitle::NodeName)
+ ?: QuickSettingsTileSubtitle.Connected,
+ QuickSettingsTileAction.Disconnect,
+ )
+
+ ConnectionState.Connecting ->
+ QuickSettingsTileState(
+ Tile.STATE_ACTIVE,
+ QuickSettingsTileSubtitle.Connecting,
+ QuickSettingsTileAction.Disconnect,
+ )
+
+ ConnectionState.DeviceSleep ->
+ QuickSettingsTileState(
+ Tile.STATE_INACTIVE,
+ QuickSettingsTileSubtitle.DeviceSleeping,
+ QuickSettingsTileAction.Connect,
+ )
+
+ ConnectionState.Disconnected ->
+ QuickSettingsTileState(
+ Tile.STATE_INACTIVE,
+ QuickSettingsTileSubtitle.Disconnected,
+ QuickSettingsTileAction.Connect,
+ )
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceStartTrigger.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceStartTrigger.kt
index 127a3414444..d10ca6e6a2f 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceStartTrigger.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceStartTrigger.kt
@@ -44,4 +44,10 @@ enum class ServiceStartTrigger {
* restriction.
*/
BootCompleted,
+
+ /**
+ * The user tapped the Quick Settings tile. SystemUI temp-allowlists the app for the click, so the start is
+ * attempted; if the platform refuses it, the tile opens the app instead.
+ */
+ QuickSettingsTile,
}
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
index fb149860d31..149c5203267 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
@@ -460,6 +460,9 @@ class SharedRadioInterfaceServiceLivenessTest {
private class DeferredRadioPrefs(saved: String?) : RadioPrefs {
override val devAddr = MutableStateFlow(saved)
override val devName = MutableStateFlow(null)
+
+ override suspend fun awaitDevAddr(): String? = devAddr.value
+
private val pending = ArrayDeque()
override fun setDevAddr(address: String?) {
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 0398ed4c1cc..717480fce49 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
@@ -339,6 +339,9 @@ class FakeMapTileProviderPrefs : MapTileProviderPrefs {
class FakeRadioPrefs : RadioPrefs {
override val devAddr = MutableStateFlow(null)
+
+ override suspend fun awaitDevAddr(): String? = devAddr.value
+
override val devName = MutableStateFlow(null)
override fun setDevAddr(address: String?) {
diff --git a/docs/en/user/connections.md b/docs/en/user/connections.md
index 5821b3c4cf0..c34e5609977 100644
--- a/docs/en/user/connections.md
+++ b/docs/en/user/connections.md
@@ -152,6 +152,17 @@ To disconnect, tap the disconnect button on the **Connect** tab:

+## Quick Settings Tile
+
+On Android you can add a **Meshtastic** tile to Quick Settings: pull down the notification shade, tap the edit (pencil) button, and drag the tile into place.
+
+- **Connected** - the tile is on and shows your node's short name. Tap it to disconnect, the same as the disconnect button on the **Connect** tab.
+- **Connecting** - the tile is on and reads "Connecting". Tap it to cancel.
+- **Disconnected** or **Device sleeping** - the tile is off. Tap it to reconnect to the radio you last selected.
+- **No device selected** - tap the tile to open the **Connect** tab and pick a radio.
+
+Disconnecting forgets the selected radio, so after a disconnect the tile opens the **Connect** tab. If Android does not let the app reconnect in the background, the tile opens the app instead and the connection starts from there. On a locked phone the tile asks you to unlock first.
+
## Desktop Connections
On Desktop (Linux/macOS/Windows), the app supports: