From d37b4d97491ca81032336d21215d975238c5b221 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sun, 18 Oct 2026 07:17:37 -0500
Subject: [PATCH 1/5] feat(service): add a Quick Settings tile for the radio
connection
The foreground service owns the radio link on Android, so the tile shows
live connection state and connects or disconnects the selected radio
instead of only opening the app.
- `MeshQuickSettingsTileService` is an active tile, refreshed through
`TileService.requestListeningState` whenever the mapped state changes.
- `quickSettingsTileState` maps connection state, the selected radio and
our short name onto the tile and the tap action.
- A tap disconnects the same way the Connect tab does (deselect the
radio), reconnects through the reselect repair path while the service
runs, and otherwise starts the service with a new `QuickSettingsTile`
trigger. The platform lists no exemption for a tile tap, so that start
is allowed only while the app is visible or exempt from battery
optimizations; when refused, or with no radio selected, the tile opens
the Connect tab.
- `MeshService.startService` now reports whether the start went through.
---
androidApp/src/main/AndroidManifest.xml | 18 +++
.../meshtastic/app/BundledSqliteGuardTest.kt | 1 +
.../res/drawable/meshtastic_ic_tile_radio.xml | 27 ++++
.../core/service/ForegroundStartPolicyTest.kt | 44 ++++++
.../service/QuickSettingsTileStateTest.kt | 123 ++++++++++++++++
.../core/service/ForegroundStartContext.kt | 6 +
.../core/service/ForegroundStartPolicy.kt | 5 +
.../service/MeshQuickSettingsTileService.kt | 112 +++++++++++++++
.../meshtastic/core/service/MeshService.kt | 40 +++---
.../core/service/MeshServiceStarter.kt | 14 +-
.../service/QuickSettingsTileController.kt | 134 ++++++++++++++++++
.../core/service/QuickSettingsTileState.kt | 114 +++++++++++++++
.../core/service/ServiceStartTrigger.kt | 6 +
docs/en/user/connections.md | 11 ++
14 files changed, 631 insertions(+), 24 deletions(-)
create mode 100644 core/resources/src/androidMain/res/drawable/meshtastic_ic_tile_radio.xml
create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/QuickSettingsTileStateTest.kt
create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshQuickSettingsTileService.kt
create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt
create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileState.kt
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index edeba531d34..17be94a8e83 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -227,6 +227,24 @@
android:foregroundServiceType="connectedDevice|location"
android:exported="false" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..4ce406b8d22 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,50 @@ class ForegroundStartPolicyTest {
}
}
+ @Test
+ fun `a tile tap from the background is refused while battery optimized`() {
+ RESTRICTED_LEVELS.forEach { sdk ->
+ assertFalse(
+ ForegroundStartPolicy.isForegroundStartAllowed(
+ ServiceStartTrigger.QuickSettingsTile,
+ appInForeground = false,
+ ignoringBatteryOptimizations = false,
+ sdkInt = sdk,
+ ),
+ "QuickSettingsTile should be refused while backgrounded on API $sdk",
+ )
+ }
+ }
+
+ @Test
+ fun `a tile tap is allowed once battery optimizations are off`() {
+ RESTRICTED_LEVELS.forEach { sdk ->
+ assertTrue(
+ ForegroundStartPolicy.isForegroundStartAllowed(
+ ServiceStartTrigger.QuickSettingsTile,
+ appInForeground = false,
+ ignoringBatteryOptimizations = true,
+ sdkInt = sdk,
+ ),
+ "QuickSettingsTile should be allowed without battery optimizations 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/ForegroundStartContext.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartContext.kt
index c10061dd352..ca4a42b5feb 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartContext.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartContext.kt
@@ -17,6 +17,8 @@
package org.meshtastic.core.service
import android.app.ActivityManager
+import android.content.Context
+import android.os.PowerManager
/**
* Whether this process currently holds a user-visible component.
@@ -33,3 +35,7 @@ internal fun isAppInForeground(): Boolean {
ActivityManager.getMyMemoryState(state)
return state.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
}
+
+/** Whether the user exempted this app from battery optimizations, a documented background-start exemption. */
+internal fun isIgnoringBatteryOptimizations(context: Context): Boolean =
+ context.getSystemService(PowerManager::class.java)?.isIgnoringBatteryOptimizations(context.packageName) == true
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..64f944a51dd 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,14 @@ object ForegroundStartPolicy {
* opening the app ([ServiceStartTrigger.UserInterface]).
*
* @param appInForeground whether this process currently holds a user-visible component.
+ * @param ignoringBatteryOptimizations whether the user has exempted this app from battery optimizations, a
+ * documented exemption that only the Quick Settings tile relies on.
* @param sdkInt the running platform API level.
*/
fun isForegroundStartAllowed(
trigger: ServiceStartTrigger,
appInForeground: Boolean,
+ ignoringBatteryOptimizations: Boolean = false,
sdkInt: Int = Build.VERSION.SDK_INT,
): Boolean = when {
// Before Android 12 there is no background-start restriction at all.
@@ -70,6 +73,8 @@ object ForegroundStartPolicy {
trigger == ServiceStartTrigger.BootCompleted -> true
+ trigger == ServiceStartTrigger.QuickSettingsTile -> appInForeground || ignoringBatteryOptimizations
+
// 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..e5c352c3230
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshQuickSettingsTileService.kt
@@ -0,0 +1,112 @@
+/*
+ * 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.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()
+ }
+
+ 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..c5107444859 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,32 @@ 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)) {
+ val ignoringBatteryOptimizations = isIgnoringBatteryOptimizations(context)
+ if (!ForegroundStartPolicy.isForegroundStartAllowed(trigger, appInForeground, ignoringBatteryOptimizations)) {
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..c3ca33f50bf
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt
@@ -0,0 +1,134 @@
+/*
+ * 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.first
+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 selected address is null until DataStore's first load in a freshly started process.
+ val address =
+ withTimeoutOrNull(ADDRESS_SETTLE_MS) {
+ radioInterfaceService.currentDeviceAddressFlow.first { it != null }
+ }
+ 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..6f1d961ee7c 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. The platform lists no exemption for a tile click, so the start is legal
+ * only while the app is visible or exempt from battery optimizations; otherwise the tile opens the app instead.
+ */
+ QuickSettingsTile,
}
diff --git a/docs/en/user/connections.md b/docs/en/user/connections.md
index 5821b3c4cf0..c8cea9fe0f5 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. With battery optimization turned off for Meshtastic, the tile can reconnect without opening the app. On a locked phone the tile asks you to unlock first.
+
## Desktop Connections
On Desktop (Linux/macOS/Windows), the app supports:
From c5ab35990278346a2983c8a8f2c9a8e82ac24398 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sun, 18 Oct 2026 11:49:26 -0500
Subject: [PATCH 2/5] fix(service): refresh a newly added tile and let a tap
start the service
An active tile is bound only when the app requests it, so one added while
the connection was steady stayed blank until the next state change; it now
requests a refresh in `onTileAdded`.
SystemUI temp-allowlists the app for a tile click ("tile onclick", seen on
API 36), so the tile now always attempts the foreground start and falls
back to opening the app when the platform refuses it. The battery
optimization check is gone.
---
.../core/service/ForegroundStartPolicyTest.kt | 20 ++-----------------
.../core/service/ForegroundStartContext.kt | 6 ------
.../core/service/ForegroundStartPolicy.kt | 7 +++----
.../service/MeshQuickSettingsTileService.kt | 7 +++++++
.../core/service/MeshServiceStarter.kt | 3 +--
.../core/service/ServiceStartTrigger.kt | 4 ++--
docs/en/user/connections.md | 2 +-
7 files changed, 16 insertions(+), 33 deletions(-)
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 4ce406b8d22..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
@@ -114,31 +114,15 @@ class ForegroundStartPolicyTest {
}
@Test
- fun `a tile tap from the background is refused while battery optimized`() {
- RESTRICTED_LEVELS.forEach { sdk ->
- assertFalse(
- ForegroundStartPolicy.isForegroundStartAllowed(
- ServiceStartTrigger.QuickSettingsTile,
- appInForeground = false,
- ignoringBatteryOptimizations = false,
- sdkInt = sdk,
- ),
- "QuickSettingsTile should be refused while backgrounded on API $sdk",
- )
- }
- }
-
- @Test
- fun `a tile tap is allowed once battery optimizations are off`() {
+ fun `a tile tap from the background is attempted`() {
RESTRICTED_LEVELS.forEach { sdk ->
assertTrue(
ForegroundStartPolicy.isForegroundStartAllowed(
ServiceStartTrigger.QuickSettingsTile,
appInForeground = false,
- ignoringBatteryOptimizations = true,
sdkInt = sdk,
),
- "QuickSettingsTile should be allowed without battery optimizations on API $sdk",
+ "QuickSettingsTile should be attempted while backgrounded on API $sdk",
)
}
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartContext.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartContext.kt
index ca4a42b5feb..c10061dd352 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartContext.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ForegroundStartContext.kt
@@ -17,8 +17,6 @@
package org.meshtastic.core.service
import android.app.ActivityManager
-import android.content.Context
-import android.os.PowerManager
/**
* Whether this process currently holds a user-visible component.
@@ -35,7 +33,3 @@ internal fun isAppInForeground(): Boolean {
ActivityManager.getMyMemoryState(state)
return state.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
}
-
-/** Whether the user exempted this app from battery optimizations, a documented background-start exemption. */
-internal fun isIgnoringBatteryOptimizations(context: Context): Boolean =
- context.getSystemService(PowerManager::class.java)?.isIgnoringBatteryOptimizations(context.packageName) == true
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 64f944a51dd..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
@@ -53,14 +53,11 @@ object ForegroundStartPolicy {
* opening the app ([ServiceStartTrigger.UserInterface]).
*
* @param appInForeground whether this process currently holds a user-visible component.
- * @param ignoringBatteryOptimizations whether the user has exempted this app from battery optimizations, a
- * documented exemption that only the Quick Settings tile relies on.
* @param sdkInt the running platform API level.
*/
fun isForegroundStartAllowed(
trigger: ServiceStartTrigger,
appInForeground: Boolean,
- ignoringBatteryOptimizations: Boolean = false,
sdkInt: Int = Build.VERSION.SDK_INT,
): Boolean = when {
// Before Android 12 there is no background-start restriction at all.
@@ -73,7 +70,9 @@ object ForegroundStartPolicy {
trigger == ServiceStartTrigger.BootCompleted -> true
- trigger == ServiceStartTrigger.QuickSettingsTile -> appInForeground || ignoringBatteryOptimizations
+ // 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
index e5c352c3230..4d100891e74 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshQuickSettingsTileService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshQuickSettingsTileService.kt
@@ -17,6 +17,7 @@
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
@@ -54,6 +55,12 @@ class MeshQuickSettingsTileService :
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()
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 c5107444859..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
@@ -35,8 +35,7 @@ import co.touchlab.kermit.Logger
*/
fun MeshService.Companion.startService(context: Context, trigger: ServiceStartTrigger): Boolean {
val appInForeground = isAppInForeground()
- val ignoringBatteryOptimizations = isIgnoringBatteryOptimizations(context)
- if (!ForegroundStartPolicy.isForegroundStartAllowed(trigger, appInForeground, ignoringBatteryOptimizations)) {
+ 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."
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 6f1d961ee7c..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
@@ -46,8 +46,8 @@ enum class ServiceStartTrigger {
BootCompleted,
/**
- * The user tapped the Quick Settings tile. The platform lists no exemption for a tile click, so the start is legal
- * only while the app is visible or exempt from battery optimizations; otherwise the tile opens the app instead.
+ * 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/docs/en/user/connections.md b/docs/en/user/connections.md
index c8cea9fe0f5..c34e5609977 100644
--- a/docs/en/user/connections.md
+++ b/docs/en/user/connections.md
@@ -161,7 +161,7 @@ On Android you can add a **Meshtastic** tile to Quick Settings: pull down the no
- **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. With battery optimization turned off for Meshtastic, the tile can reconnect without opening the app. On a locked phone the tile asks you to unlock first.
+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
From 8e1a2b21325e84486c4c2a692a2df193c9119ce7 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Tue, 20 Oct 2026 11:29:37 -0500
Subject: [PATCH 3/5] feat(service): use the Meshtastic logo for the Quick
Settings tile
---
androidApp/src/main/AndroidManifest.xml | 2 +-
.../res/drawable/meshtastic_ic_tile_radio.xml | 27 -------------------
2 files changed, 1 insertion(+), 28 deletions(-)
delete mode 100644 core/resources/src/androidMain/res/drawable/meshtastic_ic_tile_radio.xml
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 17be94a8e83..d5448148845 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -231,7 +231,7 @@
diff --git a/core/resources/src/androidMain/res/drawable/meshtastic_ic_tile_radio.xml b/core/resources/src/androidMain/res/drawable/meshtastic_ic_tile_radio.xml
deleted file mode 100644
index 50a717e22c8..00000000000
--- a/core/resources/src/androidMain/res/drawable/meshtastic_ic_tile_radio.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
From 6301c0ed5820c03d32b70a33cfdb22b1741ec074 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 15:32:50 -0500
Subject: [PATCH 4/5] fix(service): stop the tile stalling when no radio was
ever selected
A tap waited the full 2 s for a non-null address, because `devAddr` reads
null both before DataStore loads and when nothing was saved. `onClick` now
falls back to `RadioPrefs.awaitDevAddr()`, which returns once the store loads.
---
.../core/prefs/radio/RadioPrefsImpl.kt | 3 +
.../core/prefs/radio/RadioPrefsImplTest.kt | 81 +++++++++++++++++++
.../core/repository/AppPreferences.kt | 3 +
.../service/QuickSettingsTileController.kt | 8 +-
...SharedRadioInterfaceServiceLivenessTest.kt | 3 +
.../core/testing/FakeAppPreferences.kt | 3 +
6 files changed, 96 insertions(+), 5 deletions(-)
create mode 100644 core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImplTest.kt
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt
index 6f4cf80ebc5..fbf8efe57a0 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -37,6 +38,8 @@ class RadioPrefsImpl(private val dataStore: RadioDataStore, dispatchers: Corouti
override val devAddr: StateFlow =
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/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt
index c3ca33f50bf..0fd39b58a86 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt
@@ -23,7 +23,6 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
@@ -89,11 +88,10 @@ class QuickSettingsTileController(
* platform will not let the service start from here; opening the app then starts it the usual way.
*/
suspend fun onClick(): Boolean {
- // The selected address is null until DataStore's first load in a freshly started process.
+ // The live selection when there is one; otherwise the saved one, which is null until DataStore's first load.
val address =
- withTimeoutOrNull(ADDRESS_SETTLE_MS) {
- radioInterfaceService.currentDeviceAddressFlow.first { it != null }
- }
+ radioInterfaceService.currentDeviceAddressFlow.value
+ ?: withTimeoutOrNull(ADDRESS_SETTLE_MS) { radioPrefs.awaitDevAddr() }
val tile =
quickSettingsTileState(
connectionState = radioController.connectionState.value,
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?) {
From 48f70b32aca3ebe3ccb619839ce85f07da4329b0 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 22 Oct 2026 04:22:25 -0500
Subject: [PATCH 5/5] fix(service): open Connections when the tile cannot read
the saved radio
A DataStore read failure in `awaitDevAddr()` escaped `onClick()` before the no-device fallback, so the tap did nothing.
---
.../QuickSettingsTileControllerTest.kt | 104 ++++++++++++++++++
.../service/QuickSettingsTileController.kt | 10 +-
2 files changed, 113 insertions(+), 1 deletion(-)
create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/QuickSettingsTileControllerTest.kt
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/QuickSettingsTileControllerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/QuickSettingsTileControllerTest.kt
new file mode 100644
index 00000000000..1a2491bbc3a
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/QuickSettingsTileControllerTest.kt
@@ -0,0 +1,104 @@
+/*
+ * 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 androidx.test.core.app.ApplicationProvider
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.answering.throws
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.mock
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioPrefs
+import org.meshtastic.core.takserver.MeshToCotBroadcaster
+import org.meshtastic.core.takserver.TAKMeshIntegration
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeRadioController
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.IOException
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class QuickSettingsTileControllerTest {
+
+ @Test
+ fun `a failed read of the saved radio opens the Connect tab`() = runTest {
+ val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
+ every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null)
+ val radioPrefs: RadioPrefs = mock(MockMode.autofill)
+ everySuspend { radioPrefs.awaitDevAddr() } throws IOException("disk read failed")
+ val appScope = object : ApplicationCoroutineScope, CoroutineScope by backgroundScope {}
+
+ val controller =
+ QuickSettingsTileController(
+ context = ApplicationProvider.getApplicationContext(),
+ radioController = FakeRadioController(),
+ radioInterfaceService = radioInterfaceService,
+ radioPrefs = radioPrefs,
+ nodeRepository = FakeNodeRepository(),
+ orchestrator = idleOrchestrator(radioInterfaceService),
+ appScope = appScope,
+ )
+
+ assertTrue(controller.onClick())
+ }
+
+ private fun idleOrchestrator(radioInterfaceService: RadioInterfaceService): MeshServiceOrchestrator {
+ val dispatcher = StandardTestDispatcher()
+ val dispatchers = CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
+ return MeshServiceOrchestrator(
+ radioInterfaceService = radioInterfaceService,
+ serviceStateWriter = mock(MockMode.autofill),
+ nodeManager = mock(MockMode.autofill),
+ messageProcessor = mock(MockMode.autofill),
+ serviceNotifications = mock(MockMode.autofill),
+ takServerManager = mock(MockMode.autofill),
+ takMeshIntegration =
+ TAKMeshIntegration(
+ takServerManager = mock(MockMode.autofill),
+ commandSender = mock(MockMode.autofill),
+ serviceRepository = mock(MockMode.autofill),
+ meshConfigHandler = mock(MockMode.autofill),
+ nodeRepository = mock(MockMode.autofill),
+ meshToCotBroadcaster =
+ MeshToCotBroadcaster(
+ takServerManager = mock(MockMode.autofill),
+ nodeRepository = mock(MockMode.autofill),
+ takPrefs = mock(MockMode.autofill),
+ dispatchers = dispatchers,
+ ),
+ takPrefs = mock(MockMode.autofill),
+ ),
+ takPrefs = mock(MockMode.autofill),
+ databaseManager = mock(MockMode.autofill),
+ connectionManager = mock(MockMode.autofill),
+ dispatchers = dispatchers,
+ localNetworkAccess = LocalNetworkAccess { true },
+ )
+ }
+}
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
index 0fd39b58a86..bb91798a4c2 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/QuickSettingsTileController.kt
@@ -34,6 +34,7 @@ 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.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
/**
@@ -91,7 +92,14 @@ class QuickSettingsTileController(
// 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() }
+ ?: withTimeoutOrNull(ADDRESS_SETTLE_MS) {
+ try {
+ radioPrefs.awaitDevAddr()
+ } catch (e: IOException) {
+ Logger.w(e) { "Quick Settings tile could not read the saved radio" }
+ null
+ }
+ }
val tile =
quickSettingsTileState(
connectionState = radioController.connectionState.value,