i18n(alarm): localize all native notification, channel and chooser texts
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user