Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,24 @@
android:foregroundServiceType="connectedDevice|location"
android:exported="false" />

<!-- Quick Settings tile for the radio connection; BIND_QUICK_SETTINGS_TILE limits binding to SystemUI -->
<service
android:name="org.meshtastic.core.service.MeshQuickSettingsTileService"
android:exported="true"
android:icon="@drawable/meshtastic_ic_notification"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>

<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class BundledSqliteGuardTest {
add("org.meshtastic.core.service.BootCompleteReceiver")
add("org.meshtastic.core.service.MeshService")
add("org.meshtastic.core.service.ConversationActionService")
add("org.meshtastic.core.service.MeshQuickSettingsTileService")
add("org.meshtastic.core.nfc.MeshtasticHostApduService")
add("org.meshtastic.feature.widget.LocalStatsWidgetReceiver")
if (BuildConfig.FLAVOR == "google") add("org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctionService")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,6 +38,8 @@ class RadioPrefsImpl(private val dataStore: RadioDataStore, dispatchers: Corouti
override val devAddr: StateFlow<String?> =
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<String?> =
dataStore.data.map { it[KEY_DEV_NAME_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Preferences> =
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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,9 @@ interface MapTileProviderPrefs {
interface RadioPrefs {
val devAddr: StateFlow<String?>

/** 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<String?>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading