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,