i18n(alarm): localize all native notification, channel and chooser texts
Build & Deploy PluriWave / Análisis de código (push) Successful in 40s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s

Centralize every native-side user-facing string in a single
AlarmNotificationStrings store written by Flutter via a new
setNotificationStrings MethodChannel whenever the app locale changes,
and read at notification/channel build time (with English fallbacks)
even when the engine is dead. This replaces the hardcoded Spanish text
in the ringing notification ("Alarma PluriWave", "Posponer", "Detener"),
the pre-notice notification ("Posponer", "Omitir esta vez"), both
notification channels (names + descriptions) and the file-action
choosers ("Abrir carpeta", "Abrir grabación").

The per-alarm preNoticeTemplate/snoozeCountdown template+label args are
dropped from scheduleAlarm and the persisted spec and folded into the
shared store, so a locale change now also relocalizes already-scheduled
alarms. Channels are re-created on each use so their name/description
refresh after a language switch.

Adds alarmRingingNotificationTitle, alarmFire/PreNoticeChannelName,
alarmFire/PreNoticeChannelDescription and openFolder/openRecording
chooser keys across all 13 locales (reusing snoozeAction, stopAlarmAction,
skipNextAction, snoozeAgainAction). Rewrites the template test around
setNotificationStrings. Kotlin is static-reviewed only; no Android build
environment available here.
This commit is contained in:
Javier Bautista Fernández
2026-06-30 15:41:29 +02:00
parent 481944815f
commit ffd09a2179
34 changed files with 639 additions and 144 deletions
@@ -0,0 +1,72 @@
package es.freetimelab.pluriwave
import android.content.Context
/**
* Localized strings for native alarm notifications, channels and choosers.
*
* Flutter is the single source of truth for i18n: it pushes the current-locale
* strings via the `setNotificationStrings` MethodChannel whenever the app locale
* is (re)configured. They are persisted in device-protected storage so the
* native side can read them when building a notification or channel even while
* the Flutter engine is dead (alarm fired from a killed app, after reboot, in
* direct-boot). Every getter falls back to English when a value is unset.
*/
object AlarmNotificationStrings {
private const val PREFS = "pluriwave_alarm_strings"
const val KEY_RING_TITLE = "ringTitle"
const val KEY_SNOOZE = "snoozeLabel"
const val KEY_STOP = "stopLabel"
const val KEY_SKIP = "skipLabel"
const val KEY_SNOOZE_AGAIN = "snoozeAgainLabel"
const val KEY_FIRE_CHANNEL_NAME = "fireChannelName"
const val KEY_FIRE_CHANNEL_DESC = "fireChannelDescription"
const val KEY_PRE_NOTICE_CHANNEL_NAME = "preNoticeChannelName"
const val KEY_PRE_NOTICE_CHANNEL_DESC = "preNoticeChannelDescription"
const val KEY_PRE_NOTICE_TEMPLATE = "preNoticeTemplate"
const val KEY_SNOOZE_COUNTDOWN_TEMPLATE = "snoozeCountdownTemplate"
const val KEY_OPEN_FOLDER = "openFolderTitle"
const val KEY_OPEN_RECORDING = "openRecordingTitle"
/** Persists the localized strings pushed by Flutter. Blank values are removed. */
fun save(context: Context, values: Map<String, Any?>) {
val editor = prefs(context).edit()
for ((key, value) in values) {
val str = value as? String
if (str.isNullOrBlank()) editor.remove(key) else editor.putString(key, str)
}
editor.apply()
}
fun ringTitle(context: Context) = get(context, KEY_RING_TITLE, "PluriWave alarm")
fun snoozeLabel(context: Context) = get(context, KEY_SNOOZE, "Snooze")
fun stopLabel(context: Context) = get(context, KEY_STOP, "Stop")
fun skipLabel(context: Context) = get(context, KEY_SKIP, "Skip this time")
fun snoozeAgainLabel(context: Context) = get(context, KEY_SNOOZE_AGAIN, "Snooze again")
fun fireChannelName(context: Context) = get(context, KEY_FIRE_CHANNEL_NAME, "Ringing alarms")
fun fireChannelDescription(context: Context) =
get(context, KEY_FIRE_CHANNEL_DESC, "Urgent sound and screen when a music alarm must ring")
fun preNoticeChannelName(context: Context) =
get(context, KEY_PRE_NOTICE_CHANNEL_NAME, "Alarm reminders")
fun preNoticeChannelDescription(context: Context) =
get(context, KEY_PRE_NOTICE_CHANNEL_DESC, "Silent notifications before the alarm")
fun openFolderTitle(context: Context) = get(context, KEY_OPEN_FOLDER, "Open folder")
fun openRecordingTitle(context: Context) = get(context, KEY_OPEN_RECORDING, "Open recording")
fun preNoticeText(context: Context, minutes: Long): String =
format(get(context, KEY_PRE_NOTICE_TEMPLATE, "Starts in {minutes} min"), minutes)
fun snoozeCountdownText(context: Context, minutes: Long): String =
format(get(context, KEY_SNOOZE_COUNTDOWN_TEMPLATE, "Rings in {minutes} min"), minutes)
private fun format(template: String, minutes: Long): String =
template.replace("{minutes}", minutes.toString())
private fun get(context: Context, key: String, fallback: String): String =
prefs(context).getString(key, null)?.takeIf { it.isNotBlank() } ?: fallback
private fun prefs(context: Context) =
context.applicationContext.createDeviceProtectedStorageContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -42,11 +42,7 @@ class AlarmScheduler(private val context: Context) {
snoozeMinutes: Int = 5,
fallbackStationName: String? = null,
fallbackStationUrl: String? = null,
fadeInSegundos: Int = 0,
preNoticeTemplate: String? = null,
snoozeCountdownTemplate: String? = null,
snoozeAgainLabel: String? = null,
snoozeStopLabel: String? = null
fadeInSegundos: Int = 0
): Boolean {
val existing = readSpec(id)
val preservedSnooze = preserveNativeSnooze(
@@ -77,11 +73,7 @@ class AlarmScheduler(private val context: Context) {
fallbackSound = fallbackSound,
volume = volume.coerceIn(0f, 1f),
fadeInSegundos = fadeInSegundos.coerceIn(0, 60),
timezoneId = TimeZone.getDefault().id,
preNoticeTemplate = preNoticeTemplate,
snoozeCountdownTemplate = snoozeCountdownTemplate,
snoozeAgainLabel = snoozeAgainLabel,
snoozeStopLabel = snoozeStopLabel
timezoneId = TimeZone.getDefault().id
)
return scheduleSpec(spec, persistOnSuccess = true)
}
@@ -168,7 +160,6 @@ class AlarmScheduler(private val context: Context) {
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -189,7 +180,6 @@ class AlarmScheduler(private val context: Context) {
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
}
)
Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}")
@@ -483,11 +473,7 @@ class AlarmScheduler(private val context: Context) {
private fun postSnoozeCountdownNotification(spec: NativeAlarmSpec, remaining: Long) {
ensurePreNoticeChannel()
val text = if (spec.snoozeCountdownTemplate.isNullOrBlank()) {
"Rings in $remaining min"
} else {
spec.snoozeCountdownTemplate.replace("{minutes}", remaining.toString())
}
val text = AlarmNotificationStrings.snoozeCountdownText(appContext, remaining)
val openIntent = PendingIntent.getActivity(
appContext,
@@ -531,8 +517,8 @@ class AlarmScheduler(private val context: Context) {
.setOnlyAlertOnce(true)
.setOngoing(true)
.setContentIntent(openIntent)
.addAction(0, spec.snoozeAgainLabel ?: "Snooze again", againIntent)
.addAction(0, spec.snoozeStopLabel ?: "Stop", stopIntent)
.addAction(0, AlarmNotificationStrings.snoozeAgainLabel(appContext), againIntent)
.addAction(0, AlarmNotificationStrings.stopLabel(appContext), stopIntent)
.build()
try {
@@ -548,12 +534,14 @@ class AlarmScheduler(private val context: Context) {
private fun ensurePreNoticeChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(PluriWaveAlarmReceiver.CHANNEL_ID) != null) return
// Re-create each time so the localized name/description refresh after a
// locale change (Android updates them on an existing channel).
val channel = NotificationChannel(
PluriWaveAlarmReceiver.CHANNEL_ID,
"Preavisos de alarmas",
AlarmNotificationStrings.preNoticeChannelName(appContext),
NotificationManager.IMPORTANCE_LOW
).apply {
description = AlarmNotificationStrings.preNoticeChannelDescription(appContext)
setSound(null, null)
enableVibration(false)
}
@@ -905,15 +893,7 @@ class AlarmScheduler(private val context: Context) {
val fallbackSound: String?,
val volume: Float,
val fadeInSegundos: Int = 0,
val timezoneId: String,
// Nullable for backward compat: old persisted alarms without this field
// fall back to the English default in the receiver. Schema stays v3.
val preNoticeTemplate: String? = null,
// Localized template + button labels for the per-minute snooze countdown
// notification. Nullable so pre-existing persisted alarms keep working.
val snoozeCountdownTemplate: String? = null,
val snoozeAgainLabel: String? = null,
val snoozeStopLabel: String? = null
val timezoneId: String
) {
fun toJson(): JSONObject = JSONObject().apply {
put("schemaVersion", 3)
@@ -940,10 +920,6 @@ class AlarmScheduler(private val context: Context) {
put("volume", volume)
put("fadeInSegundos", fadeInSegundos)
put("timezoneId", timezoneId)
put("preNoticeTemplate", preNoticeTemplate)
put("snoozeCountdownTemplate", snoozeCountdownTemplate)
put("snoozeAgainLabel", snoozeAgainLabel)
put("snoozeStopLabel", snoozeStopLabel)
}
companion object {
@@ -980,12 +956,7 @@ class AlarmScheduler(private val context: Context) {
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
volume = json.optDouble("volume", 0.85).toFloat(),
fadeInSegundos = json.optInt("fadeInSegundos", 0).coerceIn(0, 60),
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id),
preNoticeTemplate = json.optString("preNoticeTemplate").takeIf { it.isNotBlank() },
snoozeCountdownTemplate =
json.optString("snoozeCountdownTemplate").takeIf { it.isNotBlank() },
snoozeAgainLabel = json.optString("snoozeAgainLabel").takeIf { it.isNotBlank() },
snoozeStopLabel = json.optString("snoozeStopLabel").takeIf { it.isNotBlank() }
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id)
)
}
}
@@ -1000,9 +971,6 @@ class AlarmScheduler(private val context: Context) {
private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L
private const val SCHEDULE_UNICA = "unica"
private const val SCHEDULE_DIAS_SEMANA = "diasSemana"
// Intent extra key for the localized pre-notice template string.
// Declared once here; PluriWaveAlarmReceiver reads it via this constant.
const val EXTRA_PRE_NOTICE_TEMPLATE = "preNoticeTemplate"
}
}
@@ -115,11 +115,7 @@ class MainActivity : AudioServiceActivity() {
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5,
fallbackStationName = call.argument<String>("fallbackStationName"),
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0,
preNoticeTemplate = call.argument<String>("preNoticeTemplate"),
snoozeCountdownTemplate = call.argument<String>("snoozeCountdownTemplate"),
snoozeAgainLabel = call.argument<String>("snoozeAgainLabel"),
snoozeStopLabel = call.argument<String>("snoozeStopLabel")
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0
)
result.success(scheduled)
}
@@ -209,6 +205,16 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel getNativeSnoozeState")
result.success(alarmScheduler.nativeSnoozeStates())
}
"setNotificationStrings" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
AlarmNotificationStrings.save(
this,
args.entries.associate { (k, v) -> k.toString() to v }
)
}
result.success(null)
}
else -> result.notImplemented()
}
}
@@ -427,7 +433,9 @@ class MainActivity : AudioServiceActivity() {
for (intent in candidates) {
try {
startActivity(Intent.createChooser(intent, "Abrir carpeta"))
startActivity(
Intent.createChooser(intent, AlarmNotificationStrings.openFolderTitle(this))
)
Log.d(tag, "file_actions.viewDirectory launched path=$path")
return true
} catch (_: ActivityNotFoundException) {
@@ -471,7 +479,9 @@ class MainActivity : AudioServiceActivity() {
clipData = ClipData.newUri(contentResolver, "recording", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(intent, "Abrir grabación"))
startActivity(
Intent.createChooser(intent, AlarmNotificationStrings.openRecordingTitle(this))
)
Log.d(tag, "file_actions.openFile launched path=$path")
true
} catch (_: ActivityNotFoundException) {
@@ -51,8 +51,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
title,
snoozeMinutes,
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L),
intent.getStringExtra(AlarmScheduler.EXTRA_PRE_NOTICE_TEMPLATE)
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
)
}
ACTION_POSTPONE_NEXT -> {
@@ -135,13 +134,12 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
title: String,
snoozeMinutes: Int,
triggerAtMillis: Long,
occurrenceAtMillis: Long,
preNoticeTemplate: String? = null
occurrenceAtMillis: Long
) {
ensureChannel(context)
val remaining = computeRemainingMinutes(triggerAtMillis)
val contentText = formatPreNoticeText(preNoticeTemplate, remaining)
val contentText = AlarmNotificationStrings.preNoticeText(context, remaining)
val openAppIntent = PendingIntent.getActivity(
context,
@@ -189,8 +187,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
.setSilent(true)
.setAutoCancel(true)
.setContentIntent(openAppIntent)
.addAction(0, "Posponer", postponeNextIntent)
.addAction(0, "Omitir esta vez", skipNextIntent)
.addAction(0, AlarmNotificationStrings.snoozeLabel(context), postponeNextIntent)
.addAction(0, AlarmNotificationStrings.skipLabel(context), skipNextIntent)
.build()
try {
@@ -208,30 +206,17 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
private fun computeRemainingMinutes(triggerAtMillis: Long): Long =
maxOf(1L, (triggerAtMillis - System.currentTimeMillis()) / 60_000L)
/**
* Formats the pre-notice notification text by replacing the `{minutes}`
* placeholder in [template] with [remaining]. Falls back to an English
* default if [template] is null or blank.
*/
private fun formatPreNoticeText(template: String?, remaining: Long): String {
if (template.isNullOrBlank()) {
return "Starts in $remaining min"
}
return template.replace("{minutes}", remaining.toString())
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(CHANNEL_ID)
if (existing != null) return
// Re-create each time so the localized name/description refresh after a
// locale change (Android updates them on an existing channel).
val channel = NotificationChannel(
CHANNEL_ID,
"Preavisos de alarmas",
AlarmNotificationStrings.preNoticeChannelName(context),
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Notificaciones silenciosas 30 minutos antes de la alarma"
description = AlarmNotificationStrings.preNoticeChannelDescription(context)
setSound(null, null)
enableVibration(false)
}
@@ -388,7 +388,7 @@ class PluriWaveAlarmService : Service() {
) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave")
.setContentTitle(AlarmNotificationStrings.ringTitle(this))
.setContentText(
if (stationName.isNullOrBlank()) title else "$title - $stationName"
)
@@ -399,8 +399,8 @@ class PluriWaveAlarmService : Service() {
.setAutoCancel(false)
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
.addAction(0, "Posponer", snoozePendingIntent(alarmId, snoozeMinutes))
.addAction(0, "Detener", stopPendingIntent(alarmId))
.addAction(0, AlarmNotificationStrings.snoozeLabel(this), snoozePendingIntent(alarmId, snoozeMinutes))
.addAction(0, AlarmNotificationStrings.stopLabel(this), stopPendingIntent(alarmId))
.build()
private fun openAlarmPendingIntent(
@@ -553,13 +553,16 @@ class PluriWaveAlarmService : Service() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
migrateLegacyChannels(context, manager)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
// Re-create each time (not early-returning when present) so the
// localized name/description refresh after a locale change. Android
// updates name + description on an existing channel; importance and
// sound stay fixed from first creation.
val channel = NotificationChannel(
CHANNEL_ID,
"Alarmas sonando",
AlarmNotificationStrings.fireChannelName(context),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Sonido y pantalla urgente cuando una alarma musical debe sonar"
description = AlarmNotificationStrings.fireChannelDescription(context)
enableVibration(true)
setSound(
Settings.System.DEFAULT_ALARM_ALERT_URI,