feat(settings): Add RTTTL ringtone playback in settings (#3799)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2025-11-24 13:48:52 -06:00
committed by GitHub
parent 4b93065c7e
commit 3efbcaab8b
4 changed files with 87 additions and 3 deletions

View File

@@ -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)

View File

@@ -552,6 +552,7 @@
<string name="output_duration_milliseconds">Output duration (milliseconds)</string>
<string name="nag_timeout_seconds">Nag timeout (seconds)</string>
<string name="ringtone">Ringtone</string>
<string name="play">Play</string>
<string name="use_i2s_as_buzzer">Use I2S as buzzer</string>
<string name="lora_config">LoRa</string>
<string name="options">Options</string>

View File

@@ -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(

View File

@@ -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 <T : MessageLite> RadioConfigScreenList(
title: String,
@@ -57,6 +58,7 @@ fun <T : MessageLite> RadioConfigScreenList(
configState: ConfigState<T>,
enabled: Boolean,
onSave: (T) -> Unit,
modifier: Modifier = Modifier,
content: LazyListScope.() -> Unit,
) {
val focusManager = LocalFocusManager.current
@@ -66,6 +68,7 @@ fun <T : MessageLite> RadioConfigScreenList(
}
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = title,