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: ![Disconnect from the node](../../assets/screenshots/connections_disconnect.png) +## 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: