chore: merge origin/main

This commit is contained in:
2026-05-28 18:22:12 +02:00
18 changed files with 1376 additions and 186 deletions
+6 -6
View File
@@ -94,7 +94,7 @@ jobs:
- name: Build AAB release
run: flutter build appbundle --release
- name: Verificar firma del AAB
- name: Verificar firma
env:
KEYSTORE_PASSWORD: ${{ secrets.PLURIWAVE_KEYSTORE_PASSWORD }}
run: |
@@ -103,10 +103,10 @@ jobs:
echo "ERROR: no se pudo leer el keystore de firma"
exit 1
}
echo "$KEYSTORE_INFO" | grep -E "SHA1:|SHA256:" || true
echo ""
echo "=== Huellas del AAB (desde ZIP) ==="
unzip -p build/app/outputs/bundle/release/app-release.aab META-INF/CERT.RSA | keytool -printcert 2>/dev/null | grep -E "SHA1:|SHA256:" || echo "(huellas no extraídas, build generado)"
echo "$KEYSTORE_INFO" | grep -E "SHA1:|SHA256:" || {
echo "ERROR: no se encontraron huellas SHA1/SHA256 en el keystore"
exit 1
}
- name: Publicar en ftl-builds (Zimaboard)
run: |
@@ -171,4 +171,4 @@ jobs:
MSG="❌ *PluriWave* build FAILED · rama ${BRANCH} · ${COMMIT}"
fi
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=221721467" -d "parse_mode=Markdown" -d "text=${MSG}" || true
-d "chat_id=221721467" -d "parse_mode=Markdown" -d "text=${MSG}" || true
+8 -2
View File
@@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
@@ -66,19 +67,24 @@
<receiver
android:name=".PluriWaveAlarmReceiver"
android:exported="false">
android:exported="false"
android:directBootAware="true">
<intent-filter>
<action android:name="es.freetimelab.pluriwave.alarm.FIRE"/>
<action android:name="es.freetimelab.pluriwave.alarm.PRE_NOTICE"/>
<action android:name="es.freetimelab.pluriwave.alarm.SKIP_NEXT"/>
<action android:name="es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"/>
</intent-filter>
</receiver>
<receiver
android:name=".PluriWaveBootReceiver"
android:exported="true">
android:exported="true"
android:directBootAware="true">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED"/>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.USER_UNLOCKED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.TIME_SET"/>
<action android:name="android.intent.action.TIMEZONE_CHANGED"/>
@@ -7,12 +7,16 @@ import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import org.json.JSONArray
import org.json.JSONObject
import java.util.Calendar
import java.util.TimeZone
class AlarmScheduler(private val context: Context) {
private val tag = "PluriWave"
private val appContext = context.applicationContext
private val alarmManager =
context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
fun scheduleAlarm(
id: String,
@@ -22,95 +26,145 @@ class AlarmScheduler(private val context: Context) {
stationName: String?,
stationUrl: String?,
fallbackSound: String?,
volume: Float
volume: Float,
hour: Int? = null,
minute: Int? = null,
scheduleType: String? = null,
weekdays: List<Int> = emptyList(),
oneShotDateMillis: Long? = null,
snoozeUntilMillis: Long? = null,
snoozeOriginMillis: Long? = null,
lastHandledAtMillis: Long? = null,
soundOnVacation: Boolean = true,
snoozeMinutes: Int = 5
): Boolean {
val existing = readSpec(id)
val preservedSnooze = preserveNativeSnooze(
existing = existing,
requestedTriggerAtMillis = triggerAtMillis,
requestedSnoozeUntilMillis = snoozeUntilMillis
)
val spec = NativeAlarmSpec(
id = id,
title = title,
enabled = true,
triggerAtMillis = triggerAtMillis,
preNoticeAtMillis = preNoticeAtMillis,
hour = hour ?: localHour(triggerAtMillis),
minute = minute ?: localMinute(triggerAtMillis),
scheduleType = scheduleType ?: SCHEDULE_UNICA,
weekdays = weekdays,
oneShotDateMillis = oneShotDateMillis,
snoozeUntilMillis = preservedSnooze?.first ?: snoozeUntilMillis,
snoozeOriginMillis = preservedSnooze?.second ?: snoozeOriginMillis,
lastHandledAtMillis = lastHandledAtMillis,
soundOnVacation = soundOnVacation,
snoozeMinutes = sanitizeSnoozeMinutes(snoozeMinutes),
stationName = stationName,
stationUrl = stationUrl,
fallbackSound = fallbackSound,
volume = volume.coerceIn(0f, 1f),
timezoneId = TimeZone.getDefault().id
)
return scheduleSpec(spec, persistOnSuccess = true)
}
private fun scheduleSpec(spec: NativeAlarmSpec, persistOnSuccess: Boolean): Boolean {
val nextTrigger = computeNextTriggerMillis(spec)
Log.d(
tag,
"alarm.schedule id=$id title=$title triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis canExact=${canScheduleExactAlarms()}"
"alarm.schedule id=${spec.id} title=${spec.title} trigger=$nextTrigger type=${spec.scheduleType} snooze=${spec.snoozeUntilMillis} canExact=${canScheduleExactAlarms()}"
)
val alarmIntent = PendingIntent.getBroadcast(
context,
requestCode(id, 1),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, stationName)
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, stationUrl)
putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, fallbackSound)
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, volume)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val showIntent = PendingIntent.getActivity(
context,
requestCode(id, 2),
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val mainScheduled = scheduleMainAlarm(id, triggerAtMillis, showIntent, alarmIntent)
if (mainScheduled) {
saveScheduledAlarm(
id,
title,
triggerAtMillis,
preNoticeAtMillis,
stationName,
stationUrl,
fallbackSound,
volume
)
} else {
removeScheduledAlarm(id)
if (nextTrigger == null) {
Log.d(tag, "alarm.schedule no next trigger id=${spec.id}")
removeScheduledAlarm(spec.id)
cancelPending("fire", pendingFireIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
cancelPending("show", pendingShowIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
cancelPending("preNotice", pendingPreNoticeIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
return true
}
val now = System.currentTimeMillis()
val scheduledSpec = spec.copy(
triggerAtMillis = nextTrigger,
preNoticeAtMillis = if (spec.snoozeUntilMillis == null) {
nextTrigger - PRE_NOTICE_MILLIS
} else {
0L
}
)
val alarmIntent = fireIntent(scheduledSpec)
val showIntent = showIntent(scheduledSpec)
val mainScheduled = scheduleMainAlarm(
scheduledSpec.id,
scheduledSpec.triggerAtMillis,
showIntent,
alarmIntent
)
if (!mainScheduled) {
Log.w(tag, "alarm.schedule main alarm fallback failed or degraded id=$id")
Log.w(tag, "alarm.schedule main failed but keeping spec for future resync id=${scheduledSpec.id}")
saveScheduledAlarm(scheduledSpec)
return false
}
if (preNoticeAtMillis > now) {
if (persistOnSuccess) {
saveScheduledAlarm(scheduledSpec)
}
schedulePreNotice(scheduledSpec)
return true
}
private fun schedulePreNotice(spec: NativeAlarmSpec) {
val now = System.currentTimeMillis()
if (spec.snoozeUntilMillis != null) {
cancelPending("preNotice", pendingPreNoticeIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
Log.d(tag, "alarm.schedule preNotice skipped for snooze id=${spec.id}")
return
}
if (spec.preNoticeAtMillis > now) {
try {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
preNoticeAtMillis,
spec.preNoticeAtMillis,
PendingIntent.getBroadcast(
context,
requestCode(id, 3),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
appContext,
requestCode(spec.id, 3),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
putExtra(
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
Log.d(tag, "alarm.schedule preNotice OK id=$id")
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
} catch (_: SecurityException) {
// The main alarm is already scheduled with setAlarmClock.
Log.w(tag, "alarm.schedule preNotice SecurityException id=$id")
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
}
} else if (triggerAtMillis > now) {
context.sendBroadcast(
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
} else if (spec.triggerAtMillis > now) {
appContext.sendBroadcast(
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
putExtra(
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
}
)
Log.d(tag, "alarm.schedule preNotice immediate id=$id")
Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}")
} else {
Log.d(tag, "alarm.schedule preNotice skipped id=$id")
Log.d(tag, "alarm.schedule preNotice skipped id=${spec.id}")
}
return true
}
private fun scheduleMainAlarm(
@@ -141,10 +195,7 @@ class AlarmScheduler(private val context: Context) {
)
Log.d(tag, "alarm.schedule setExactAndAllowWhileIdle fallback OK id=$id")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Log.e(
tag,
"alarm.schedule exact permission missing; refusing inexact fallback id=$id"
)
Log.e(tag, "alarm.schedule exact permission missing; refusing inexact fallback id=$id")
return false
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setAndAllowWhileIdle(
@@ -168,22 +219,88 @@ class AlarmScheduler(private val context: Context) {
}
}
fun onAlarmFired(id: String) {
val spec = readSpec(id) ?: return
val firedAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
val next = spec.copy(
snoozeUntilMillis = null,
snoozeOriginMillis = null,
lastHandledAtMillis = firedAt,
enabled = spec.scheduleType != SCHEDULE_UNICA
)
if (next.enabled) {
scheduleSpec(next, persistOnSuccess = true)
} else {
removeScheduledAlarm(id)
}
}
fun skipNext(id: String) {
val spec = readSpec(id) ?: return
val next = spec.copy(
snoozeUntilMillis = null,
snoozeOriginMillis = null,
lastHandledAtMillis = spec.triggerAtMillis,
enabled = spec.scheduleType != SCHEDULE_UNICA
)
if (next.enabled) {
scheduleSpec(next, persistOnSuccess = true)
} else {
cancelAlarm(id)
}
}
fun snooze(id: String, minutes: Int) {
val spec = readSpec(id) ?: return
val safeMinutes = sanitizeSnoozeMinutes(minutes)
val snoozeUntil = System.currentTimeMillis() + safeMinutes * 60_000L
scheduleSpec(
spec.copy(
snoozeUntilMillis = snoozeUntil,
snoozeOriginMillis = spec.snoozeOriginMillis ?: spec.triggerAtMillis
),
persistOnSuccess = true
)
}
fun postponeNext(id: String, minutes: Int): Long? {
val spec = readSpec(id) ?: return null
val safeMinutes = sanitizeSnoozeMinutes(minutes)
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
val target = occurrenceAt + safeMinutes * 60_000L
val now = System.currentTimeMillis()
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
Log.d(
tag,
"alarm.postponeNext id=$id minutes=$safeMinutes occurrence=$occurrenceAt target=$snoozeUntil"
)
scheduleSpec(
spec.copy(
snoozeUntilMillis = snoozeUntil,
snoozeOriginMillis = occurrenceAt,
snoozeMinutes = safeMinutes
),
persistOnSuccess = true
)
return occurrenceAt
}
fun cancelAlarm(id: String) {
Log.d(tag, "alarm.cancel id=$id")
removeScheduledAlarm(id)
cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
NotificationManagerCompat.from(context).cancel(
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
NotificationManagerCompat.from(context).cancel(
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id)
)
}
fun dismissFireNotification(id: String) {
NotificationManagerCompat.from(context).cancel(
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id)
)
}
@@ -194,27 +311,10 @@ class AlarmScheduler(private val context: Context) {
}
fun reschedulePersistedAlarms() {
val now = System.currentTimeMillis()
for (id in prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()) {
val raw = prefs().getString("$KEY_ALARM_PREFIX$id", null) ?: continue
val spec = readSpec(id) ?: continue
try {
val data = JSONObject(raw)
val triggerAt = data.optLong("triggerAtMillis", 0L)
if (triggerAt <= now) {
Log.d(tag, "alarm.reschedule skip stale id=$id triggerAt=$triggerAt")
removeScheduledAlarm(id)
continue
}
scheduleAlarm(
id = id,
title = data.optString("title", "PluriWave"),
triggerAtMillis = triggerAt,
preNoticeAtMillis = data.optLong("preNoticeAtMillis", 0L),
stationName = data.optString("stationName").takeIf { it.isNotBlank() },
stationUrl = data.optString("stationUrl").takeIf { it.isNotBlank() },
fallbackSound = data.optString("fallbackSound").takeIf { it.isNotBlank() },
volume = data.optDouble("volume", 0.85).toFloat()
)
scheduleSpec(spec, persistOnSuccess = true)
Log.d(tag, "alarm.reschedule OK id=$id")
} catch (error: Throwable) {
Log.e(tag, "alarm.reschedule failed id=$id", error)
@@ -222,33 +322,113 @@ class AlarmScheduler(private val context: Context) {
}
}
private fun saveScheduledAlarm(
id: String,
title: String,
triggerAtMillis: Long,
preNoticeAtMillis: Long,
stationName: String?,
stationUrl: String?,
fallbackSound: String?,
volume: Float
) {
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
ids.add(id)
val data = JSONObject().apply {
put("title", title)
put("triggerAtMillis", triggerAtMillis)
put("preNoticeAtMillis", preNoticeAtMillis)
put("stationName", stationName)
put("stationUrl", stationUrl)
put("fallbackSound", fallbackSound)
put("volume", volume)
fun pendingAlarmCount(): Int =
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
private fun preserveNativeSnooze(
existing: NativeAlarmSpec?,
requestedTriggerAtMillis: Long,
requestedSnoozeUntilMillis: Long?
): Pair<Long, Long>? {
if (requestedSnoozeUntilMillis != null || existing == null) return null
val snoozeUntil = existing.snoozeUntilMillis ?: return null
val snoozeOrigin = existing.snoozeOriginMillis ?: return null
if (snoozeUntil <= System.currentTimeMillis()) return null
if (snoozeOrigin != requestedTriggerAtMillis) return null
Log.d(
tag,
"alarm.schedule preserving native snooze id=${existing.id} origin=$snoozeOrigin until=$snoozeUntil"
)
return snoozeUntil to snoozeOrigin
}
private fun computeNextTriggerMillis(spec: NativeAlarmSpec): Long? {
val now = System.currentTimeMillis()
spec.snoozeUntilMillis?.let { if (it > now) return it }
if (!spec.enabled) return null
val base = maxOf(now, (spec.lastHandledAtMillis ?: 0L) + 60_000L)
return when (spec.scheduleType) {
SCHEDULE_UNICA -> computeOneShot(spec, base)
SCHEDULE_DIAS_SEMANA -> computeWeekday(spec, base)
else -> computeDaily(spec, base)
}
}
private fun computeOneShot(spec: NativeAlarmSpec, baseMillis: Long): Long? {
val candidate = Calendar.getInstance().apply {
timeInMillis = spec.oneShotDateMillis ?: spec.triggerAtMillis
set(Calendar.HOUR_OF_DAY, spec.hour)
set(Calendar.MINUTE, spec.minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
return candidate.timeInMillis.takeIf { it > baseMillis }
}
private fun computeDaily(spec: NativeAlarmSpec, baseMillis: Long): Long? {
val candidate = Calendar.getInstance().apply {
timeInMillis = baseMillis
set(Calendar.HOUR_OF_DAY, spec.hour)
set(Calendar.MINUTE, spec.minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (candidate.timeInMillis <= baseMillis) {
candidate.add(Calendar.DAY_OF_YEAR, 1)
}
return candidate.timeInMillis
}
private fun computeWeekday(spec: NativeAlarmSpec, baseMillis: Long): Long? {
if (spec.weekdays.isEmpty()) return null
val candidate = Calendar.getInstance().apply {
timeInMillis = baseMillis
set(Calendar.HOUR_OF_DAY, spec.hour)
set(Calendar.MINUTE, spec.minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
for (i in 0 until 370) {
if (candidate.timeInMillis > baseMillis &&
spec.weekdays.contains(dartWeekday(candidate))
) {
return candidate.timeInMillis
}
candidate.add(Calendar.DAY_OF_YEAR, 1)
}
return null
}
private fun dartWeekday(calendar: Calendar): Int =
when (calendar.get(Calendar.DAY_OF_WEEK)) {
Calendar.MONDAY -> 1
Calendar.TUESDAY -> 2
Calendar.WEDNESDAY -> 3
Calendar.THURSDAY -> 4
Calendar.FRIDAY -> 5
Calendar.SATURDAY -> 6
else -> 7
}
private fun saveScheduledAlarm(spec: NativeAlarmSpec) {
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
ids.add(spec.id)
prefs().edit()
.putStringSet(KEY_IDS, ids)
.putString("$KEY_ALARM_PREFIX$id", data.toString())
.putString("$KEY_ALARM_PREFIX${spec.id}", spec.toJson().toString())
.apply()
}
private fun readSpec(id: String): NativeAlarmSpec? {
val raw = prefs().getString("$KEY_ALARM_PREFIX$id", null) ?: return null
return try {
NativeAlarmSpec.fromJson(JSONObject(raw))
} catch (error: Throwable) {
Log.e(tag, "alarm.readSpec failed id=$id", error)
null
}
}
private fun removeScheduledAlarm(id: String) {
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
ids.remove(id)
@@ -258,7 +438,9 @@ class AlarmScheduler(private val context: Context) {
.apply()
}
private fun prefs() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun prefs() =
appContext.createDeviceProtectedStorageContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun cancelPending(name: String, pendingIntent: PendingIntent?) {
if (pendingIntent == null) {
@@ -270,11 +452,52 @@ class AlarmScheduler(private val context: Context) {
Log.d(tag, "alarm.cancel $name OK")
}
private fun fireIntent(spec: NativeAlarmSpec): PendingIntent =
PendingIntent.getBroadcast(
appContext,
requestCode(spec.id, 1),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, spec.stationName)
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, spec.stationUrl)
putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, spec.fallbackSound)
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, spec.volume)
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
putExtra(
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun showIntent(spec: NativeAlarmSpec): PendingIntent =
PendingIntent.getActivity(
appContext,
requestCode(spec.id, 2),
Intent(appContext, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
putExtra(
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun pendingFireIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
context,
appContext,
requestCode(id, 1),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
},
flags or PendingIntent.FLAG_IMMUTABLE
@@ -282,9 +505,9 @@ class AlarmScheduler(private val context: Context) {
private fun pendingShowIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getActivity(
context,
appContext,
requestCode(id, 2),
Intent(context, MainActivity::class.java).apply {
Intent(appContext, MainActivity::class.java).apply {
this.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
@@ -294,19 +517,113 @@ class AlarmScheduler(private val context: Context) {
private fun pendingPreNoticeIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
context,
appContext,
requestCode(id, 3),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
},
flags or PendingIntent.FLAG_IMMUTABLE
)
private fun localHour(millis: Long): Int =
Calendar.getInstance().apply { timeInMillis = millis }.get(Calendar.HOUR_OF_DAY)
private fun localMinute(millis: Long): Int =
Calendar.getInstance().apply { timeInMillis = millis }.get(Calendar.MINUTE)
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot
private data class NativeAlarmSpec(
val id: String,
val title: String,
val enabled: Boolean,
val triggerAtMillis: Long,
val preNoticeAtMillis: Long,
val hour: Int,
val minute: Int,
val scheduleType: String,
val weekdays: List<Int>,
val oneShotDateMillis: Long?,
val snoozeUntilMillis: Long?,
val snoozeOriginMillis: Long?,
val lastHandledAtMillis: Long?,
val soundOnVacation: Boolean,
val snoozeMinutes: Int,
val stationName: String?,
val stationUrl: String?,
val fallbackSound: String?,
val volume: Float,
val timezoneId: String
) {
fun toJson(): JSONObject = JSONObject().apply {
put("schemaVersion", 2)
put("id", id)
put("title", title)
put("enabled", enabled)
put("triggerAtMillis", triggerAtMillis)
put("preNoticeAtMillis", preNoticeAtMillis)
put("hour", hour)
put("minute", minute)
put("scheduleType", scheduleType)
put("weekdays", JSONArray(weekdays))
put("oneShotDateMillis", oneShotDateMillis)
put("snoozeUntilMillis", snoozeUntilMillis)
put("snoozeOriginMillis", snoozeOriginMillis)
put("lastHandledAtMillis", lastHandledAtMillis)
put("soundOnVacation", soundOnVacation)
put("snoozeMinutes", snoozeMinutes)
put("stationName", stationName)
put("stationUrl", stationUrl)
put("fallbackSound", fallbackSound)
put("volume", volume)
put("timezoneId", timezoneId)
}
companion object {
fun fromJson(json: JSONObject): NativeAlarmSpec {
val weekdaysJson = json.optJSONArray("weekdays") ?: JSONArray()
return NativeAlarmSpec(
id = json.optString("id"),
title = json.optString("title", "PluriWave"),
enabled = json.optBoolean("enabled", true),
triggerAtMillis = json.optLong("triggerAtMillis", 0L),
preNoticeAtMillis = json.optLong("preNoticeAtMillis", 0L),
hour = json.optInt("hour", 7),
minute = json.optInt("minute", 0),
scheduleType = json.optString("scheduleType", SCHEDULE_UNICA),
weekdays = (0 until weekdaysJson.length()).mapNotNull {
weekdaysJson.optInt(it).takeIf { day -> day in 1..7 }
},
oneShotDateMillis = json.optNullableLong("oneShotDateMillis"),
snoozeUntilMillis = json.optNullableLong("snoozeUntilMillis"),
snoozeOriginMillis = json.optNullableLong("snoozeOriginMillis"),
lastHandledAtMillis = json.optNullableLong("lastHandledAtMillis"),
soundOnVacation = json.optBoolean("soundOnVacation", true),
snoozeMinutes = json.optInt("snoozeMinutes", 5).let {
if (it == 3 || it == 5 || it == 10) it else 5
},
stationName = json.optString("stationName").takeIf { it.isNotBlank() },
stationUrl = json.optString("stationUrl").takeIf { it.isNotBlank() },
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
volume = json.optDouble("volume", 0.85).toFloat(),
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id)
)
}
}
}
companion object {
private const val PREFS = "pluriwave_alarm_scheduler"
private const val KEY_IDS = "scheduled_alarm_ids"
private const val KEY_ALARM_PREFIX = "scheduled_alarm_"
private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L
private const val SCHEDULE_UNICA = "unica"
private const val SCHEDULE_DIAS_SEMANA = "diasSemana"
}
}
private fun JSONObject.optNullableLong(name: String): Long? =
if (has(name) && !isNull(name)) optLong(name) else null
@@ -1,6 +1,7 @@
package es.freetimelab.pluriwave
import android.Manifest
import android.app.NotificationManager
import android.content.ClipData
import android.content.Intent
import android.content.ActivityNotFoundException
@@ -13,7 +14,9 @@ import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
@@ -28,7 +31,8 @@ class MainActivity : AudioServiceActivity() {
private val visualizerChannel = "pluriwave/audio_visualizer"
private val alarmChannel = "pluriwave/alarm_scheduler"
private val fileActionsChannel = "pluriwave/file_actions"
private val permissionRequestCode = 4821
private val visualizerPermissionRequestCode = 4821
private val notificationPermissionRequestCode = 4822
private var visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null
private var pendingArgs: Map<*, *>? = null
@@ -70,6 +74,9 @@ class MainActivity : AudioServiceActivity() {
val stationUrl = call.argument<String>("stationUrl")
val fallbackSound = call.argument<String>("fallbackSound")
val volume = call.argument<Number>("volume")?.toFloat() ?: 0.85f
val weekdays =
(call.argument<List<Int>>("weekdays") ?: emptyList())
.filter { it in 1..7 }
Log.d(tag, "alarm.channel scheduleAlarm id=$id triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis")
if (id == null || triggerAtMillis == null) {
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
@@ -83,7 +90,17 @@ class MainActivity : AudioServiceActivity() {
stationName,
stationUrl,
fallbackSound,
volume
volume,
hour = call.argument<Int>("hour"),
minute = call.argument<Int>("minute"),
scheduleType = call.argument<String>("scheduleType"),
weekdays = weekdays,
oneShotDateMillis = call.argument<Long>("oneShotDateMillis"),
snoozeUntilMillis = call.argument<Long>("snoozeUntilMillis"),
snoozeOriginMillis = call.argument<Long>("snoozeOriginMillis"),
lastHandledAtMillis = call.argument<Long>("lastHandledAtMillis"),
soundOnVacation = call.argument<Boolean>("soundOnVacation") ?: true,
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5
)
result.success(scheduled)
}
@@ -119,12 +136,25 @@ class MainActivity : AudioServiceActivity() {
result.success(null)
}
}
"confirmFlutterAudio" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel confirmFlutterAudio id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
result.success(null)
}
}
"diagnostics" -> {
Log.d(tag, "alarm.channel diagnostics")
result.success(
mapOf(
"canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
"notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(),
"canUseFullScreenIntent" to canUseFullScreenIntent(),
"isIgnoringBatteryOptimizations" to isIgnoringBatteryOptimizations(),
"nativePendingAlarmsCount" to alarmScheduler.pendingAlarmCount(),
"manufacturer" to Build.MANUFACTURER,
"sdkInt" to Build.VERSION.SDK_INT
)
@@ -134,6 +164,14 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel requestExactAlarmPermission")
result.success(requestExactAlarmPermission())
}
"requestPostNotificationsPermission" -> {
Log.d(tag, "alarm.channel requestPostNotificationsPermission")
result.success(requestPostNotificationsPermission())
}
"requestFullScreenIntentPermission" -> {
Log.d(tag, "alarm.channel requestFullScreenIntentPermission")
result.success(requestFullScreenIntentPermission())
}
"getInitialAlarmIntent" -> {
val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
@@ -203,7 +241,10 @@ class MainActivity : AudioServiceActivity() {
return mapOf(
"alarmId" to alarmId,
"alarmTitle" to title,
"alarmAction" to action
"alarmAction" to action,
"triggerAtMillis" to intent.getLongExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, 0L),
"occurrenceAtMillis" to intent.getLongExtra(PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT, 0L),
"snoozeMinutes" to intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
)
}
@@ -224,6 +265,47 @@ class MainActivity : AudioServiceActivity() {
}
}
private fun requestPostNotificationsPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
return true
}
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
notificationPermissionRequestCode
)
return true
}
private fun requestFullScreenIntentPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
if (canUseFullScreenIntent()) return true
return try {
startActivity(
Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply {
data = Uri.parse("package:$packageName")
}
)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel requestFullScreenIntentPermission failed", error)
false
}
}
private fun canUseFullScreenIntent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
return manager.canUseFullScreenIntent()
}
private fun isIgnoringBatteryOptimizations(): Boolean {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(packageName)
}
private fun openDirectory(path: String): Boolean {
val folder = File(path)
if (!folder.exists()) {
@@ -385,7 +467,7 @@ class MainActivity : AudioServiceActivity() {
) {
requestPermissions(
arrayOf(Manifest.permission.RECORD_AUDIO),
permissionRequestCode
visualizerPermissionRequestCode
)
return
}
@@ -474,7 +556,8 @@ class MainActivity : AudioServiceActivity() {
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != permissionRequestCode) return
if (requestCode == notificationPermissionRequestCode) return
if (requestCode != visualizerPermissionRequestCode) return
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
startVisualizer()
@@ -18,18 +18,23 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
return
}
val title = intent.getStringExtra(EXTRA_ALARM_TITLE) ?: "PluriWave"
val snoozeMinutes = sanitizeSnoozeMinutes(intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5))
Log.d(TAG, "alarm.receiver action=${intent.action} id=$alarmId title=$title")
when (intent.action) {
ACTION_FIRE -> {
AlarmScheduler(context).onAlarmFired(alarmId)
PluriWaveAlarmService.start(context, intent)
val launch = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_FIRE)
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
putExtra(EXTRA_OCCURRENCE_AT, intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L))
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
}
showFireNotification(context, alarmId, title, launch)
showFireNotification(context, alarmId, title, launch, snoozeMinutes)
try {
context.startActivity(launch)
Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId")
@@ -38,12 +43,40 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
}
}
ACTION_PRE_NOTICE -> {
showPreNoticeNotification(context, alarmId, title)
showPreNoticeNotification(
context,
alarmId,
title,
snoozeMinutes,
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
)
}
ACTION_POSTPONE_NEXT -> {
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
val occurrenceAt = AlarmScheduler(context).postponeNext(alarmId, snoozeMinutes)
?: intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
val launch = Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_POSTPONE_NEXT)
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAt)
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
}
try {
context.startActivity(launch)
Log.d(TAG, "alarm.receiver postponeNext startActivity OK id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.receiver postponeNext startActivity ERROR id=$alarmId", error)
}
}
ACTION_SKIP_NEXT -> {
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
AlarmScheduler(context).skipNext(alarmId)
val launch = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_SKIP_NEXT)
@@ -63,7 +96,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
context: Context,
alarmId: String,
title: String,
launch: Intent
launch: Intent,
snoozeMinutes: Int
) {
ensureFireChannel(context)
val fullScreenIntent = PendingIntent.getActivity(
@@ -82,6 +116,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
.setAutoCancel(false)
.setContentIntent(fullScreenIntent)
.setFullScreenIntent(fullScreenIntent, true)
.addAction(0, "Posponer $snoozeMinutes min", snoozePendingIntent(context, alarmId, snoozeMinutes))
.build()
try {
@@ -95,14 +130,21 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
}
}
private fun showPreNoticeNotification(context: Context, alarmId: String, title: String) {
private fun showPreNoticeNotification(
context: Context,
alarmId: String,
title: String,
snoozeMinutes: Int,
triggerAtMillis: Long,
occurrenceAtMillis: Long
) {
ensureChannel(context)
val openAppIntent = PendingIntent.getActivity(
context,
requestCode(alarmId, 1),
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_PRE_NOTICE)
@@ -121,6 +163,20 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val postponeNextIntent = PendingIntent.getBroadcast(
context,
requestCode(alarmId, 3),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = ACTION_POSTPONE_NEXT
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
putExtra(EXTRA_TRIGGER_AT, triggerAtMillis)
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAtMillis)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
@@ -130,7 +186,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
.setSilent(true)
.setAutoCancel(true)
.setContentIntent(openAppIntent)
.addAction(0, "Omitir siguiente", skipNextIntent)
.addAction(0, "Posponer $snoozeMinutes min", postponeNextIntent)
.addAction(0, "Omitir esta vez", skipNextIntent)
.build()
try {
@@ -178,6 +235,21 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
private fun snoozePendingIntent(context: Context, alarmId: String, minutes: Int): PendingIntent =
PendingIntent.getService(
context,
requestCode(alarmId, 20 + minutes),
Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmService.ACTION_SNOOZE
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(PluriWaveAlarmService.EXTRA_SNOOZE_MINUTES, minutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
companion object {
const val TAG = "PluriWave"
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
@@ -185,6 +257,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
const val ACTION_POSTPONE_NEXT = "es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"
const val EXTRA_ALARM_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle"
const val EXTRA_ALARM_ACTION = "alarmAction"
@@ -192,6 +265,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
const val EXTRA_STATION_URL = "stationUrl"
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
const val EXTRA_VOLUME = "volume"
const val EXTRA_TRIGGER_AT = "triggerAtMillis"
const val EXTRA_OCCURRENCE_AT = "occurrenceAtMillis"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
@@ -9,7 +9,9 @@ import android.content.Intent
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
@@ -21,6 +23,8 @@ class PluriWaveAlarmService : Service() {
private var player: MediaPlayer? = null
private var wakeLock: PowerManager.WakeLock? = null
private var activeAlarmId: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var stationFallbackRunnable: Runnable? = null
override fun onBind(intent: Intent?): IBinder? = null
@@ -34,6 +38,14 @@ class PluriWaveAlarmService : Service() {
stopAlarm(requestedId)
return START_NOT_STICKY
}
ACTION_SNOOZE -> {
val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5)
if (requestedId != null) {
AlarmScheduler(this).snooze(requestedId, minutes)
}
stopAlarm(requestedId)
return START_NOT_STICKY
}
PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
}
@@ -49,49 +61,164 @@ class PluriWaveAlarmService : Service() {
activeAlarmId = alarmId
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME)
val stationUrl = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL)
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
val snoozeMinutes = sanitizeSnoozeMinutes(
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
)
acquireWakeLock()
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title))
startAudio(alarmId, fallbackSound, volume)
try {
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title, stationName, snoozeMinutes))
} catch (error: Throwable) {
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
releaseWakeLock()
activeAlarmId = null
stopSelf()
return
}
startAudio(alarmId, stationName, stationUrl, fallbackSound, volume)
}
private fun startAudio(alarmId: String, fallbackSound: String?, volume: Float) {
private fun startAudio(
alarmId: String,
stationName: String?,
stationUrl: String?,
fallbackSound: String?,
volume: Float
) {
player?.release()
player = null
if (!stationUrl.isNullOrBlank()) {
startStationAudio(
alarmId,
stationName,
stationUrl.trim(),
fallbackSound,
volume
)
return
}
startFallbackAudio(alarmId, fallbackSound, volume, "station url missing")
}
private fun startStationAudio(
alarmId: String,
stationName: String?,
stationUrl: String,
fallbackSound: String?,
volume: Float
) {
scheduleStationFallback(alarmId, fallbackSound, volume)
try {
player = MediaPlayer().apply {
setAudioAttributes(alarmAudioAttributes())
isLooping = false
setVolume(volume, volume)
setDataSource(stationUrl)
setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
cancelStationFallback()
it.start()
Log.d(
TAG,
"alarm.service station started id=$alarmId station=$stationName url=$stationUrl"
)
}
setOnCompletionListener {
if (activeAlarmId != alarmId) return@setOnCompletionListener
Log.w(TAG, "alarm.service station completed id=$alarmId url=$stationUrl")
startFallbackAudio(alarmId, fallbackSound, volume, "station completed")
}
setOnErrorListener { mp, what, extra ->
Log.e(
TAG,
"alarm.service station error id=$alarmId what=$what extra=$extra url=$stationUrl"
)
runCatching { mp.reset() }
if (activeAlarmId == alarmId) {
startFallbackAudio(alarmId, fallbackSound, volume, "station error")
}
true
}
prepareAsync()
}
Log.d(TAG, "alarm.service station preparing id=$alarmId station=$stationName url=$stationUrl")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service station prepare failed id=$alarmId url=$stationUrl", error)
startFallbackAudio(alarmId, fallbackSound, volume, "station prepare failed")
}
}
private fun startFallbackAudio(
alarmId: String,
fallbackSound: String?,
volume: Float,
reason: String
) {
cancelStationFallback()
player?.release()
player = null
val source = fallbackAssetPath(fallbackSound)
try {
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
setAudioAttributes(alarmAudioAttributes())
isLooping = true
setVolume(volume, volume)
setFallbackAssetDataSource(this, fallbackSound)
setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
it.start()
Log.d(TAG, "alarm.service audio started id=$alarmId source=$source")
Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason")
}
setOnErrorListener { mp, what, extra ->
Log.e(TAG, "alarm.service audio error id=$alarmId what=$what extra=$extra source=$source")
Log.e(TAG, "alarm.service fallback error id=$alarmId what=$what extra=$extra source=$source")
mp.reset()
true
}
prepareAsync()
}
Log.d(TAG, "alarm.service audio preparing id=$alarmId source=$source")
Log.d(TAG, "alarm.service fallback preparing id=$alarmId source=$source reason=$reason")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service audio prepare failed id=$alarmId source=$source", error)
Log.e(TAG, "alarm.service fallback prepare failed id=$alarmId source=$source", error)
}
}
private fun scheduleStationFallback(
alarmId: String,
fallbackSound: String?,
volume: Float
) {
cancelStationFallback()
val runnable = Runnable {
if (activeAlarmId == alarmId) {
Log.w(TAG, "alarm.service station timeout id=$alarmId; using fallback")
startFallbackAudio(alarmId, fallbackSound, volume, "station timeout")
}
}
stationFallbackRunnable = runnable
mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS)
}
private fun cancelStationFallback() {
stationFallbackRunnable?.let { mainHandler.removeCallbacks(it) }
stationFallbackRunnable = null
}
private fun alarmAudioAttributes(): AudioAttributes =
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
private fun stopAlarm(alarmId: String?) {
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
cancelStationFallback()
try {
player?.stop()
} catch (error: Throwable) {
@@ -115,29 +242,42 @@ class PluriWaveAlarmService : Service() {
stopSelf()
}
private fun buildNotification(alarmId: String, title: String) =
private fun buildNotification(
alarmId: String,
title: String,
stationName: String?,
snoozeMinutes: Int
) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave")
.setContentText(title)
.setContentText(
if (stationName.isNullOrBlank()) title else "$title - $stationName"
)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true)
.setAutoCancel(false)
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title), true)
.setContentIntent(openAlarmPendingIntent(alarmId, title))
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
.addAction(0, "Posponer $snoozeMinutes min", snoozePendingIntent(alarmId, snoozeMinutes))
.addAction(0, "Detener", stopPendingIntent(alarmId))
.build()
private fun openAlarmPendingIntent(alarmId: String, title: String): PendingIntent =
private fun openAlarmPendingIntent(
alarmId: String,
title: String,
snoozeMinutes: Int
): PendingIntent =
PendingIntent.getActivity(
this,
requestCode(alarmId, 20),
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, snoozeMinutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -153,6 +293,18 @@ class PluriWaveAlarmService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun snoozePendingIntent(alarmId: String, minutes: Int): PendingIntent =
PendingIntent.getService(
this,
requestCode(alarmId, 30 + minutes),
Intent(this, PluriWaveAlarmService::class.java).apply {
action = ACTION_SNOOZE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_SNOOZE_MINUTES, minutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
@@ -203,6 +355,9 @@ class PluriWaveAlarmService : Service() {
return "flutter_assets/assets/audio/$fileName"
}
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
override fun onDestroy() {
stopAlarm(activeAlarmId)
super.onDestroy()
@@ -213,6 +368,9 @@ class PluriWaveAlarmService : Service() {
private const val CHANNEL_ID = "pluriwave_alarm_native"
private const val NOTIFICATION_ID = 92841
private const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
private const val STATION_START_TIMEOUT_MILLIS = 15_000L
fun start(context: Context, source: Intent) {
ensureChannel(context)
@@ -8,7 +8,9 @@ import android.util.Log
class PluriWaveBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_LOCKED_BOOT_COMPLETED,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_USER_UNLOCKED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
+21 -1
View File
@@ -241,6 +241,27 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
);
return;
}
if (evento.accion.endsWith('.POSTPONE_NEXT')) {
final ejecucion =
evento.occurrenceAtMillis > 0
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
: alarma.proximaEjecucion ?? DateTime.now();
await estado.posponerProximaDesdePreaviso(
alarma,
evento.snoozeMinutes,
ejecucion,
);
if (!mounted) return;
setState(() => _indice = 3);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Alarma pospuesta ${evento.snoozeMinutes} min para esta ejecución.',
),
),
);
return;
}
if (evento.accion.endsWith('.PRE_NOTICE')) {
setState(() => _indice = 3);
return;
@@ -268,7 +289,6 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
_alarmaSonandoId = alarma.id;
try {
await alarmas.android.detenerSonidoNativo(alarma.id);
await _prearrancarAudioAlarma(alarma);
if (!mounted) return;
await Navigator.of(context).push(
+66 -10
View File
@@ -9,7 +9,7 @@ import '../servicios/servicio_alarmas_android.dart';
class EstadoAlarmas extends ChangeNotifier {
EstadoAlarmas({
ServicioAlarmas? servicio,
ServicioAlarmasAndroid? android,
PuertoAlarmasAndroid? android,
bool iniciarAutomaticamente = true,
}) : servicio = servicio ?? ServicioAlarmas(),
android = android ?? ServicioAlarmasAndroid() {
@@ -19,7 +19,7 @@ class EstadoAlarmas extends ChangeNotifier {
}
final ServicioAlarmas servicio;
final ServicioAlarmasAndroid android;
final PuertoAlarmasAndroid android;
List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
@@ -45,8 +45,10 @@ class EstadoAlarmas extends ChangeNotifier {
AlarmaMusical? get proximaAlarma {
final candidatas =
_alarmas.where((a) => a.activa && a.proximaEjecucion != null).toList()
..sort((a, b) => a.proximaEjecucion!.compareTo(b.proximaEjecucion!));
_alarmas.where((a) => a.activa && a.proximaProgramable != null).toList()
..sort(
(a, b) => a.proximaProgramable!.compareTo(b.proximaProgramable!),
);
return candidatas.isEmpty ? null : candidatas.first;
}
@@ -109,7 +111,7 @@ class EstadoAlarmas extends ChangeNotifier {
}
void marcarEjecucionGestionada(AlarmaMusical alarma) {
final proxima = alarma.proximaEjecucion;
final proxima = alarma.proximaProgramable;
if (proxima == null) return;
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
_ejecucionesEmitidas.add(key);
@@ -159,18 +161,62 @@ class EstadoAlarmas extends ChangeNotifier {
}
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
final proxima = DateTime.now().add(Duration(minutes: minutos));
final ejecucion =
alarma.snoozeOrigen ?? alarma.proximaEjecucion ?? DateTime.now();
debugPrint(
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos proxima=${proxima.toIso8601String()}',
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
);
await android.ocultarNotificacionAlarma(alarma.id);
await android.programar(alarma.copyWith(proximaEjecucion: proxima));
final config = await servicio.posponerEjecucion(
alarma.id,
ejecucion,
minutos,
);
_aplicar(config);
final actualizada = _buscarAlarma(alarma.id);
if (actualizada != null) {
await android.programar(actualizada);
}
notifyListeners();
}
Future<void> posponerProximaDesdePreaviso(
AlarmaMusical alarma,
int minutos,
DateTime ejecucion,
) async {
final seguros = _snoozeSeguro(minutos);
final snoozeHasta = ejecucion.add(Duration(minutes: seguros));
debugPrint(
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
);
await android.ocultarNotificacionAlarma(alarma.id);
final config = await servicio.posponerEjecucionHasta(
alarma.id,
ejecucion,
snoozeHasta,
);
_aplicar(config);
final actualizada = _buscarAlarma(alarma.id);
if (actualizada != null) {
await android.programar(actualizada);
}
notifyListeners();
}
Future<void> finalizarEjecucion(String alarmaId) async {
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
final alarma = _buscarAlarma(alarmaId);
final ejecucion =
alarma?.snoozeOrigen ??
alarma?.proximaEjecucion ??
alarma?.snoozeHasta ??
DateTime.now();
await android.ocultarNotificacionAlarma(alarmaId);
await refrescarProgramacion();
final config = await servicio.completarEjecucion(alarmaId, ejecucion);
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
@@ -209,6 +255,16 @@ class EstadoAlarmas extends ChangeNotifier {
}
}
AlarmaMusical? _buscarAlarma(String id) {
for (final alarma in _alarmas) {
if (alarma.id == id) return alarma;
}
return null;
}
int _snoozeSeguro(int minutos) =>
minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
void _aplicar(ConfiguracionAlarmas config) {
_alarmas = config.alarmas;
_vacaciones = config.vacaciones;
@@ -230,7 +286,7 @@ class EstadoAlarmas extends ChangeNotifier {
void _vigilarAlarmasVencidas() {
final ahora = DateTime.now();
for (final alarma in _alarmas) {
final proxima = alarma.proximaEjecucion;
final proxima = alarma.proximaProgramable;
if (!alarma.activa || proxima == null) continue;
if (proxima.isAfter(ahora)) continue;
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
+28
View File
@@ -21,6 +21,9 @@ class AlarmaMusical {
this.volumen = 0.85,
this.sonidoInterno = SonidoInternoAlarma.amanecer,
this.proximaEjecucion,
this.snoozeHasta,
this.snoozeOrigen,
this.ultimaEjecucionGestionada,
this.creadaEn,
this.actualizadaEn,
});
@@ -40,6 +43,9 @@ class AlarmaMusical {
final double volumen;
final SonidoInternoAlarma sonidoInterno;
final DateTime? proximaEjecucion;
final DateTime? snoozeHasta;
final DateTime? snoozeOrigen;
final DateTime? ultimaEjecucionGestionada;
final DateTime? creadaEn;
final DateTime? actualizadaEn;
@@ -61,6 +67,11 @@ class AlarmaMusical {
SonidoInternoAlarma? sonidoInterno,
DateTime? proximaEjecucion,
bool limpiarProximaEjecucion = false,
DateTime? snoozeHasta,
DateTime? snoozeOrigen,
bool limpiarSnooze = false,
DateTime? ultimaEjecucionGestionada,
bool limpiarUltimaEjecucionGestionada = false,
DateTime? creadaEn,
DateTime? actualizadaEn,
}) {
@@ -83,11 +94,20 @@ class AlarmaMusical {
limpiarProximaEjecucion
? proximaEjecucion
: proximaEjecucion ?? this.proximaEjecucion,
snoozeHasta: limpiarSnooze ? snoozeHasta : snoozeHasta ?? this.snoozeHasta,
snoozeOrigen:
limpiarSnooze ? snoozeOrigen : snoozeOrigen ?? this.snoozeOrigen,
ultimaEjecucionGestionada:
limpiarUltimaEjecucionGestionada
? ultimaEjecucionGestionada
: ultimaEjecucionGestionada ?? this.ultimaEjecucionGestionada,
creadaEn: creadaEn ?? this.creadaEn,
actualizadaEn: actualizadaEn ?? this.actualizadaEn,
);
}
DateTime? get proximaProgramable => snoozeHasta ?? proximaEjecucion;
Map<String, dynamic> toJson() => {
'id': id,
'nombre': nombre,
@@ -104,6 +124,9 @@ class AlarmaMusical {
'volumen': volumen,
'sonidoInterno': sonidoInterno.name,
'proximaEjecucion': proximaEjecucion?.toIso8601String(),
'snoozeHasta': snoozeHasta?.toIso8601String(),
'snoozeOrigen': snoozeOrigen?.toIso8601String(),
'ultimaEjecucionGestionada': ultimaEjecucionGestionada?.toIso8601String(),
'creadaEn': creadaEn?.toIso8601String(),
'actualizadaEn': actualizadaEn?.toIso8601String(),
};
@@ -137,6 +160,11 @@ class AlarmaMusical {
SonidoInternoAlarma.amanecer,
),
proximaEjecucion: _dateFromJson(json['proximaEjecucion']),
snoozeHasta: _dateFromJson(json['snoozeHasta']),
snoozeOrigen: _dateFromJson(json['snoozeOrigen']),
ultimaEjecucionGestionada: _dateFromJson(
json['ultimaEjecucionGestionada'],
),
creadaEn: _dateFromJson(json['creadaEn']),
actualizadaEn: _dateFromJson(json['actualizadaEn']),
);
@@ -30,6 +30,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Timer? _fallbackTimer;
bool _fallbackActivo = false;
bool _radioIntentada = false;
bool _audioFlutterConfirmado = false;
@override
void initState() {
@@ -57,6 +58,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_estadoSub = radio.estadoStream.listen((estado) {
if (estado == EstadoReproduccion.reproduciendo && mounted) {
_fallbackTimer?.cancel();
_confirmarAudioFlutterListo();
}
if (estado == EstadoReproduccion.error && mounted) {
_iniciarFallback();
@@ -76,9 +78,18 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_fallbackActivo = true;
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
await _fallbackPlayer.play();
await _confirmarAudioFlutterListo();
if (mounted) setState(() {});
}
Future<void> _confirmarAudioFlutterListo() async {
if (_audioFlutterConfirmado) return;
_audioFlutterConfirmado = true;
await context.read<EstadoAlarmas>().android.confirmarAudioFlutter(
widget.alarma.id,
);
}
Future<void> _detener() async {
final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
+19 -12
View File
@@ -82,8 +82,9 @@ class _PanelProximaAlarma extends StatelessWidget {
final proxima = estado.proximaAlarma;
final activasSinProxima =
estado.alarmas
.where((a) => a.activa && a.proximaEjecucion == null)
.where((a) => a.activa && a.proximaProgramable == null)
.length;
final proximaProgramable = proxima?.proximaProgramable;
return PluriGlassSurface(
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
child: Row(
@@ -110,7 +111,7 @@ class _PanelProximaAlarma extends StatelessWidget {
? activasSinProxima > 0
? 'Hay $activasSinProxima alarma(s) activas, pero ahora mismo no tienen una fecha futura válida. Revisá fecha, días y vacaciones.'
: 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
: '${proxima.nombre} · ${_fechaHora(proxima.proximaEjecucion!)}',
: '${proxima.nombre} · ${_fechaHora(proximaProgramable!)}',
),
],
),
@@ -193,11 +194,11 @@ class _TarjetaAlarma extends StatelessWidget {
],
),
const SizedBox(height: 12),
if (alarma.proximaEjecucion != null)
if (alarma.proximaProgramable != null)
_NoticeLine(
icon: Icons.event_available_rounded,
text:
'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}',
'Siguiente ejecución: ${_fechaHora(alarma.proximaProgramable!)}',
)
else
const _NoticeLine(
@@ -231,7 +232,7 @@ class _TarjetaAlarma extends StatelessWidget {
icon: const Icon(Icons.skip_next_rounded),
label: const Text('Omitir siguiente'),
onPressed:
alarma.proximaEjecucion == null
alarma.proximaProgramable == null
? null
: () async {
await estado.saltarProxima(alarma.id);
@@ -248,9 +249,9 @@ class _TarjetaAlarma extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
actualizada?.proximaEjecucion == null
actualizada?.proximaProgramable == null
? 'Alarma omitida. No queda próxima ejecución.'
: 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.',
: 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaProgramable!)}.',
),
),
);
@@ -281,13 +282,13 @@ class _TarjetaAlarma extends StatelessWidget {
}
}
if (actual != null) {
if (alarma.proximaEjecucion == null) {
if (alarma.proximaProgramable == null) {
return 'Está pausada por vacaciones (${actual.nombre}) y sin próxima ejecución.';
}
return 'Está pausada por vacaciones (${actual.nombre}) y vuelve el ${_fechaHora(alarma.proximaEjecucion!)}.';
return 'Está pausada por vacaciones (${actual.nombre}) y vuelve el ${_fechaHora(alarma.proximaProgramable!)}.';
}
if (alarma.proximaEjecucion != null) {
return 'Con vacaciones activas, volverá a sonar el ${_fechaHora(alarma.proximaEjecucion!)}.';
if (alarma.proximaProgramable != null) {
return 'Con vacaciones activas, volverá a sonar el ${_fechaHora(alarma.proximaProgramable!)}.';
}
return null;
}
@@ -690,12 +691,18 @@ class _AccesoDiagnostico extends StatelessWidget {
label: Text(
diag == null
? 'Revisar fiabilidad Android'
: 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} · notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'}',
: 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} ? notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'} ? pantalla ${diag.puedeUsarPantallaCompleta ? 'OK' : 'pendiente'}',
),
onPressed: () async {
if (diag != null && !diag.puedeProgramarExactas) {
await estado.android.solicitarPermisoAlarmasExactas();
}
if (diag != null && !diag.notificacionesPermitidas) {
await estado.android.solicitarPermisoNotificaciones();
}
if (diag != null && !diag.puedeUsarPantallaCompleta) {
await estado.android.solicitarPermisoPantallaCompleta();
}
await estado.cargarDiagnostico();
},
);
+80 -1
View File
@@ -199,6 +199,81 @@ class ServicioAlarmas {
return nuevo;
}
Future<ConfiguracionAlarmas> posponerEjecucion(
String alarmaId,
DateTime ejecucion,
int minutos,
) async {
final snoozeHasta = _programacion.calcularSnooze(_reloj(), minutos);
return posponerEjecucionHasta(alarmaId, ejecucion, snoozeHasta);
}
Future<ConfiguracionAlarmas> posponerEjecucionHasta(
String alarmaId,
DateTime ejecucion,
DateTime snoozeHasta,
) async {
final config = await cargar();
final ahora = _reloj();
final alarmas =
config.alarmas
.map(
(a) =>
a.id == alarmaId
? a.copyWith(
snoozeHasta: snoozeHasta,
snoozeOrigen: ejecucion,
ultimaEjecucionGestionada: ejecucion,
actualizadaEn: ahora,
)
: a,
)
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: alarmas,
vacaciones: config.vacaciones,
excepciones: config.excepciones,
);
await _guardar(nuevo);
return nuevo;
}
Future<ConfiguracionAlarmas> completarEjecucion(
String alarmaId,
DateTime ejecucion,
) async {
final config = await cargar();
final ahora = _reloj();
final alarmas =
config.alarmas.map((a) {
if (a.id != alarmaId) return a;
final siguiente = _programacion.calcularSiguienteDespuesDeEjecucion(
alarma: a,
ejecucion: ejecucion,
vacaciones: config.vacaciones,
excepciones: config.excepciones,
);
return a.copyWith(
activa:
a.tipoProgramacion == TipoProgramacionAlarma.unica
? false
: a.activa,
proximaEjecucion: siguiente,
limpiarProximaEjecucion: true,
limpiarSnooze: true,
ultimaEjecucionGestionada: ejecucion,
actualizadaEn: ahora,
);
}).toList();
final nuevo = ConfiguracionAlarmas(
alarmas: alarmas,
vacaciones: config.vacaciones,
excepciones: config.excepciones,
);
await _guardar(nuevo);
return nuevo;
}
AlarmaMusical crearAlarma({
required String nombre,
required int hora,
@@ -250,15 +325,19 @@ class ServicioAlarmas {
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
final ahora = _reloj();
final snoozeActivo =
alarma.snoozeHasta != null && alarma.snoozeHasta!.isAfter(ahora);
final proxima = _programacion.calcularProxima(
alarma: alarma,
desde: _reloj(),
desde: ahora,
vacaciones: vacaciones,
excepciones: excepciones,
);
return alarma.copyWith(
proximaEjecucion: proxima,
limpiarProximaEjecucion: true,
limpiarSnooze: !snoozeActivo,
);
}
+79 -3
View File
@@ -10,17 +10,26 @@ class EventoAlarmaAndroid {
required this.alarmaId,
required this.titulo,
required this.accion,
this.triggerAtMillis = 0,
this.occurrenceAtMillis = 0,
this.snoozeMinutes = 5,
});
final String alarmaId;
final String titulo;
final String accion;
final int triggerAtMillis;
final int occurrenceAtMillis;
final int snoozeMinutes;
factory EventoAlarmaAndroid.fromMap(Map<Object?, Object?> map) {
return EventoAlarmaAndroid(
alarmaId: map['alarmId'] as String? ?? '',
titulo: map['alarmTitle'] as String? ?? 'PluriWave',
accion: map['alarmAction'] as String? ?? '',
triggerAtMillis: (map['triggerAtMillis'] as num?)?.toInt() ?? 0,
occurrenceAtMillis: (map['occurrenceAtMillis'] as num?)?.toInt() ?? 0,
snoozeMinutes: (map['snoozeMinutes'] as num?)?.toInt() ?? 5,
);
}
}
@@ -29,12 +38,18 @@ class DiagnosticoAlarmasAndroid {
const DiagnosticoAlarmasAndroid({
required this.puedeProgramarExactas,
required this.notificacionesPermitidas,
required this.puedeUsarPantallaCompleta,
required this.ignoraOptimizacionBateria,
required this.alarmasNativasPendientes,
required this.fabricante,
required this.versionSdk,
});
final bool puedeProgramarExactas;
final bool notificacionesPermitidas;
final bool puedeUsarPantallaCompleta;
final bool ignoraOptimizacionBateria;
final int alarmasNativasPendientes;
final String fabricante;
final int versionSdk;
@@ -42,13 +57,33 @@ class DiagnosticoAlarmasAndroid {
return DiagnosticoAlarmasAndroid(
puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true,
notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true,
puedeUsarPantallaCompleta:
map['canUseFullScreenIntent'] as bool? ?? true,
ignoraOptimizacionBateria:
map['isIgnoringBatteryOptimizations'] as bool? ?? true,
alarmasNativasPendientes: map['nativePendingAlarmsCount'] as int? ?? 0,
fabricante: map['manufacturer'] as String? ?? 'Android',
versionSdk: map['sdkInt'] as int? ?? 0,
);
}
}
class ServicioAlarmasAndroid {
abstract class PuertoAlarmasAndroid {
Stream<EventoAlarmaAndroid> get eventosAlarma;
Future<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
Future<void> ocultarNotificacionAlarma(String alarmaId);
Future<void> detenerSonidoNativo(String alarmaId);
Future<bool> solicitarPermisoAlarmasExactas();
Future<bool> solicitarPermisoNotificaciones();
Future<bool> solicitarPermisoPantallaCompleta();
Future<void> confirmarAudioFlutter(String alarmaId);
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
}
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
ServicioAlarmasAndroid({
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
}) : _channel = channel {
@@ -60,10 +95,12 @@ class ServicioAlarmasAndroid {
StreamController<EventoAlarmaAndroid>.broadcast();
static bool _handlerInstalado = false;
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
@override
Future<void> programar(AlarmaMusical alarma) async {
final proxima = alarma.proximaEjecucion;
final proxima = alarma.proximaProgramable;
if (proxima == null || !alarma.activa) {
debugPrint(
'[PluriWave][alarmas] cancelar por inactiva/sin proxima id=${alarma.id} activa=${alarma.activa} proxima=$proxima',
@@ -79,7 +116,20 @@ class ServicioAlarmasAndroid {
'title': alarma.nombre,
'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis':
proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch,
alarma.snoozeHasta == null
? proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch
: 0,
'hour': alarma.hora,
'minute': alarma.minuto,
'scheduleType': alarma.tipoProgramacion.name,
'weekdays': alarma.diasSemana,
'oneShotDateMillis': alarma.fechaUnica?.millisecondsSinceEpoch,
'snoozeUntilMillis': alarma.snoozeHasta?.millisecondsSinceEpoch,
'snoozeOriginMillis': alarma.snoozeOrigen?.millisecondsSinceEpoch,
'snoozeMinutes': alarma.snoozeMinutos,
'lastHandledAtMillis':
alarma.ultimaEjecucionGestionada?.millisecondsSinceEpoch,
'soundOnVacation': alarma.sonarEnVacaciones,
'stationName': alarma.emisora?.nombre,
'stationUrl': alarma.emisora?.url,
'fallbackSound': alarma.sonidoInterno.name,
@@ -92,15 +142,23 @@ class ServicioAlarmasAndroid {
}
}
@override
Future<void> cancelar(String alarmaId) =>
_logAndInvokeVoid('cancelAlarm', {'id': alarmaId});
@override
Future<void> ocultarNotificacionAlarma(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId});
@override
Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
@override
Future<void> confirmarAudioFlutter(String alarmaId) =>
_logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId});
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
'requestExactAlarmPermission',
@@ -108,6 +166,23 @@ class ServicioAlarmasAndroid {
return abierto ?? false;
}
@override
Future<bool> solicitarPermisoNotificaciones() async {
final abierto = await _channel.invokeMethod<bool>(
'requestPostNotificationsPermission',
);
return abierto ?? false;
}
@override
Future<bool> solicitarPermisoPantallaCompleta() async {
final abierto = await _channel.invokeMethod<bool>(
'requestFullScreenIntentPermission',
);
return abierto ?? false;
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
@@ -120,6 +195,7 @@ class ServicioAlarmasAndroid {
return diag;
}
@override
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'getInitialAlarmIntent',
@@ -58,6 +58,23 @@ class ServicioProgramacionAlarmas {
return desde.add(Duration(minutes: seguro));
}
DateTime? calcularSiguienteDespuesDeEjecucion({
required AlarmaMusical alarma,
required DateTime ejecucion,
List<RangoVacaciones> vacaciones = const [],
List<ExcepcionAlarma> excepciones = const [],
}) {
if (!alarma.activa) return null;
if (alarma.tipoProgramacion == TipoProgramacionAlarma.unica) return null;
return calcularProxima(
alarma: alarma.copyWith(limpiarSnooze: true),
desde: ejecucion.add(const Duration(minutes: 1)),
vacaciones: vacaciones,
excepciones: excepciones,
);
}
bool estaEnVacaciones(DateTime fecha, List<RangoVacaciones> vacaciones) =>
vacaciones.any((rango) => rango.contiene(fecha));
+1 -1
View File
@@ -1,7 +1,7 @@
name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none'
version: 0.1.50+51
version: 0.1.53+54
environment:
sdk: ^3.7.0
+196
View File
@@ -0,0 +1,196 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('posponer persiste snooze y refrescarProgramacion no lo pisa', () async {
var ahora = DateTime(2026, 5, 25, 7, 31);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
final alarma = estado.alarmas.single;
await estado.posponerAlarma(alarma, 5);
expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36));
expect(android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 36));
ahora = DateTime(2026, 5, 25, 7, 32);
await estado.refrescarProgramacion();
expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36));
expect(android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 36));
});
test('posponer desde preaviso mueve esta ejecucion desde la hora original', () async {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(
reloj: () => DateTime(2026, 5, 25, 7),
),
android: android,
iniciarAutomaticamente: false,
);
await estado.guardarAlarma(
AlarmaMusical(
id: 'pre1',
nombre: 'Preaviso',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
proximaEjecucion: DateTime(2026, 5, 25, 7, 30),
),
);
final alarma = estado.alarmas.single;
await estado.posponerProximaDesdePreaviso(
alarma,
10,
DateTime(2026, 5, 25, 7, 30),
);
expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 40));
expect(estado.alarmas.single.snoozeOrigen, DateTime(2026, 5, 25, 7, 30));
expect(android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 40));
});
test('finalizar diaria calcula siguiente dia y limpia snooze', () async {
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(
reloj: () => DateTime(2026, 5, 25, 7, 31),
),
android: FakePuertoAlarmasAndroid(),
iniciarAutomaticamente: false,
);
await estado.guardarAlarma(
AlarmaMusical(
id: 'a2',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
proximaEjecucion: DateTime(2026, 5, 25, 7, 30),
snoozeHasta: DateTime(2026, 5, 25, 7, 36),
snoozeOrigen: DateTime(2026, 5, 25, 7, 30),
),
);
await estado.finalizarEjecucion('a2');
expect(estado.alarmas.single.snoozeHasta, isNull);
expect(estado.alarmas.single.proximaEjecucion, DateTime(2026, 5, 26, 7, 30));
});
test('finalizar unica la desactiva y queda sin proxima ejecucion', () async {
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(
reloj: () => DateTime(2026, 5, 25, 7, 31),
),
android: FakePuertoAlarmasAndroid(),
iniciarAutomaticamente: false,
);
await estado.guardarAlarma(
AlarmaMusical(
id: 'a3',
nombre: 'Unica',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.unica,
diasSemana: const [],
fechaUnica: DateTime(2026, 5, 25),
proximaEjecucion: DateTime(2026, 5, 25, 7, 30),
),
);
await estado.finalizarEjecucion('a3');
expect(estado.alarmas.single.activa, isFalse);
expect(estado.alarmas.single.proximaEjecucion, isNull);
});
}
class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
final programadas = <AlarmaMusical>[];
final canceladas = <String>[];
final detenidas = <String>[];
final ocultadas = <String>[];
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventos.stream;
@override
Future<void> programar(AlarmaMusical alarma) async {
programadas.add(alarma);
}
@override
Future<void> cancelar(String alarmaId) async {
canceladas.add(alarmaId);
}
@override
Future<void> detenerSonidoNativo(String alarmaId) async {
detenidas.add(alarmaId);
}
@override
Future<void> ocultarNotificacionAlarma(String alarmaId) async {
ocultadas.add(alarmaId);
}
@override
Future<void> confirmarAudioFlutter(String alarmaId) async {
detenidas.add(alarmaId);
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
const DiagnosticoAlarmasAndroid(
puedeProgramarExactas: true,
notificacionesPermitidas: true,
puedeUsarPantallaCompleta: true,
ignoraOptimizacionBateria: true,
alarmasNativasPendientes: 0,
fabricante: 'test',
versionSdk: 35,
);
@override
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async => null;
@override
Future<bool> solicitarPermisoAlarmasExactas() async => true;
@override
Future<bool> solicitarPermisoNotificaciones() async => true;
@override
Future<bool> solicitarPermisoPantallaCompleta() async => true;
}
@@ -128,5 +128,63 @@ void main() {
expect(proxima, DateTime(2026, 5, 23, 20, 13, 47));
});
test('calcula siguiente diaria despues de ejecucion completada', () {
final alarma = AlarmaMusical(
id: 'a6',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
proximaEjecucion: DateTime(2026, 5, 25, 7, 30),
);
final siguiente = servicio.calcularSiguienteDespuesDeEjecucion(
alarma: alarma,
ejecucion: DateTime(2026, 5, 25, 7, 30),
);
expect(siguiente, DateTime(2026, 5, 26, 7, 30));
});
test('calcula siguiente por dias de semana despues de ejecucion', () {
final alarma = AlarmaMusical(
id: 'a7',
nombre: 'Laboral',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
diasSemana: const [DateTime.monday, DateTime.wednesday],
proximaEjecucion: DateTime(2026, 5, 25, 8),
);
final siguiente = servicio.calcularSiguienteDespuesDeEjecucion(
alarma: alarma,
ejecucion: DateTime(2026, 5, 25, 8),
);
expect(siguiente, DateTime(2026, 5, 27, 8));
});
test('alarma unica completada no calcula siguiente', () {
final alarma = AlarmaMusical(
id: 'a8',
nombre: 'Unica',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.unica,
diasSemana: const [],
fechaUnica: DateTime(2026, 5, 25),
proximaEjecucion: DateTime(2026, 5, 25, 8),
);
final siguiente = servicio.calcularSiguienteDespuesDeEjecucion(
alarma: alarma,
ejecucion: DateTime(2026, 5, 25, 8),
);
expect(siguiente, isNull);
});
});
}