From 3efbcaab8bc70737f7933d07f6440279b67482d4 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 24 Nov 2025 13:48:52 -0600
Subject: [PATCH] feat(settings): Add RTTTL ringtone playback in settings
(#3799)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../mesh/navigation/SettingsNavigation.kt | 2 +-
.../composeResources/values/strings.xml | 1 +
.../ExternalNotificationConfigItemList.kt | 84 ++++++++++++++++++-
.../radio/component/RadioConfigScreenList.kt | 3 +
4 files changed, 87 insertions(+), 3 deletions(-)
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
index cf2098ce4..3c201ff2e 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
@@ -135,7 +135,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.EXT_NOTIFICATION ->
- ExternalNotificationConfigScreen(viewModel, onBack = navController::popBackStack)
+ ExternalNotificationConfigScreen(viewModel = viewModel, onBack = navController::popBackStack)
ModuleRoute.STORE_FORWARD ->
StoreForwardConfigScreen(viewModel, onBack = navController::popBackStack)
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 28081255b..e0737f6d3 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -552,6 +552,7 @@
Output duration (milliseconds)
Nag timeout (seconds)
Ringtone
+ Play
Use I2S as buzzer
LoRa
Options
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt
index d43db8eda..f4b83da39 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigItemList.kt
@@ -17,16 +17,28 @@
package org.meshtastic.feature.settings.radio.component
+import android.media.MediaPlayer
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.FolderOpen
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -44,6 +56,7 @@ import org.meshtastic.core.strings.alert_message_vibra
import org.meshtastic.core.strings.external_notification
import org.meshtastic.core.strings.external_notification_config
import org.meshtastic.core.strings.external_notification_enabled
+import org.meshtastic.core.strings.import_label
import org.meshtastic.core.strings.nag_timeout_seconds
import org.meshtastic.core.strings.notifications_on_alert_bell_receipt
import org.meshtastic.core.strings.notifications_on_message_receipt
@@ -52,6 +65,7 @@ import org.meshtastic.core.strings.output_duration_milliseconds
import org.meshtastic.core.strings.output_led_active_high
import org.meshtastic.core.strings.output_led_gpio
import org.meshtastic.core.strings.output_vibra_gpio
+import org.meshtastic.core.strings.play
import org.meshtastic.core.strings.ringtone
import org.meshtastic.core.strings.use_i2s_as_buzzer
import org.meshtastic.core.strings.use_pwm_buzzer
@@ -65,17 +79,51 @@ import org.meshtastic.feature.settings.util.gpioPins
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
+import timber.log.Timber
+import java.io.File
+private const val MAX_RINGTONE_SIZE = 230
+
+@Suppress("LongMethod", "TooGenericExceptionCaught")
@Composable
-fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
+fun ExternalNotificationConfigScreen(
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: RadioConfigViewModel = hiltViewModel(),
+) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val extNotificationConfig = state.moduleConfig.externalNotification
val ringtone = state.ringtone
val formState = rememberConfigState(initialValue = extNotificationConfig)
var ringtoneInput by rememberSaveable(ringtone) { mutableStateOf(ringtone) }
val focusManager = LocalFocusManager.current
+ val context = LocalContext.current
+
+ val launcher =
+ rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ uri?.let {
+ try {
+ context.contentResolver.openInputStream(it)?.use { stream ->
+ stream.bufferedReader().use { reader ->
+ val buffer = CharArray(MAX_RINGTONE_SIZE)
+ val read = reader.read(buffer)
+ if (read > 0) {
+ ringtoneInput = String(buffer, 0, read)
+ Toast.makeText(context, "Imported ringtone", Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(context, "File is empty", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Error importing ringtone")
+ Toast.makeText(context, "Error importing: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
RadioConfigScreenList(
+ modifier = modifier,
title = stringResource(Res.string.external_notification),
onBack = onBack,
configState = formState,
@@ -228,13 +276,45 @@ fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel = hiltViewM
EditTextPreference(
title = stringResource(Res.string.ringtone),
value = ringtoneInput,
- maxSize = 230, // ringtone max_size:231
+ maxSize = MAX_RINGTONE_SIZE,
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { ringtoneInput = it },
+ trailingIcon = {
+ Row {
+ IconButton(onClick = { launcher.launch("*/*") }, enabled = state.connected) {
+ Icon(
+ Icons.Default.FolderOpen,
+ contentDescription = stringResource(Res.string.import_label),
+ )
+ }
+
+ IconButton(
+ onClick = {
+ try {
+ val tempFile = File.createTempFile("ringtone", ".rtttl", context.cacheDir)
+ tempFile.writeText(ringtoneInput)
+ val mediaPlayer = MediaPlayer()
+ mediaPlayer.setDataSource(tempFile.absolutePath)
+ mediaPlayer.prepare()
+ mediaPlayer.start()
+ mediaPlayer.setOnCompletionListener {
+ it.release()
+ tempFile.delete()
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to play ringtone")
+ }
+ },
+ enabled = state.connected,
+ ) {
+ Icon(Icons.Default.PlayArrow, contentDescription = stringResource(Res.string.play))
+ }
+ }
+ },
)
HorizontalDivider()
SwitchPreference(
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
index 16fc3a6ca..128761f34 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
@@ -48,6 +48,7 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.PreferenceFooter
import org.meshtastic.feature.settings.radio.ResponseState
+@Suppress("LongMethod")
@Composable
fun RadioConfigScreenList(
title: String,
@@ -57,6 +58,7 @@ fun RadioConfigScreenList(
configState: ConfigState,
enabled: Boolean,
onSave: (T) -> Unit,
+ modifier: Modifier = Modifier,
content: LazyListScope.() -> Unit,
) {
val focusManager = LocalFocusManager.current
@@ -66,6 +68,7 @@ fun RadioConfigScreenList(
}
Scaffold(
+ modifier = modifier,
topBar = {
MainAppBar(
title = title,