fix(alarms): harden native playback and pre-notice actions

This commit is contained in:
Javier Bautista Fernández
2026-05-28 12:03:58 +02:00
parent 41bbd0ea17
commit 659e6da189
16 changed files with 1370 additions and 180 deletions
+8 -2
View File
@@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/> <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_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
@@ -66,19 +67,24 @@
<receiver <receiver
android:name=".PluriWaveAlarmReceiver" android:name=".PluriWaveAlarmReceiver"
android:exported="false"> android:exported="false"
android:directBootAware="true">
<intent-filter> <intent-filter>
<action android:name="es.freetimelab.pluriwave.alarm.FIRE"/> <action android:name="es.freetimelab.pluriwave.alarm.FIRE"/>
<action android:name="es.freetimelab.pluriwave.alarm.PRE_NOTICE"/> <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.SKIP_NEXT"/>
<action android:name="es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver <receiver
android:name=".PluriWaveBootReceiver" android:name=".PluriWaveBootReceiver"
android:exported="true"> android:exported="true"
android:directBootAware="true">
<intent-filter> <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.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.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.TIME_SET"/> <action android:name="android.intent.action.TIME_SET"/>
<action android:name="android.intent.action.TIMEZONE_CHANGED"/> <action android:name="android.intent.action.TIMEZONE_CHANGED"/>
@@ -7,12 +7,16 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.Calendar
import java.util.TimeZone
class AlarmScheduler(private val context: Context) { class AlarmScheduler(private val context: Context) {
private val tag = "PluriWave" private val tag = "PluriWave"
private val appContext = context.applicationContext
private val alarmManager = private val alarmManager =
context.getSystemService(Context.ALARM_SERVICE) as AlarmManager appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
fun scheduleAlarm( fun scheduleAlarm(
id: String, id: String,
@@ -22,95 +26,145 @@ class AlarmScheduler(private val context: Context) {
stationName: String?, stationName: String?,
stationUrl: String?, stationUrl: String?,
fallbackSound: 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 { ): Boolean {
Log.d( val existing = readSpec(id)
tag, val preservedSnooze = preserveNativeSnooze(
"alarm.schedule id=$id title=$title triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis canExact=${canScheduleExactAlarms()}" existing = existing,
requestedTriggerAtMillis = triggerAtMillis,
requestedSnoozeUntilMillis = snoozeUntilMillis
) )
val alarmIntent = PendingIntent.getBroadcast( val spec = NativeAlarmSpec(
context, id = id,
requestCode(id, 1), title = title,
Intent(context, PluriWaveAlarmReceiver::class.java).apply { enabled = true,
action = PluriWaveAlarmReceiver.ACTION_FIRE triggerAtMillis = triggerAtMillis,
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id) preNoticeAtMillis = preNoticeAtMillis,
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title) hour = hour ?: localHour(triggerAtMillis),
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, stationName) minute = minute ?: localMinute(triggerAtMillis),
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, stationUrl) scheduleType = scheduleType ?: SCHEDULE_UNICA,
putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, fallbackSound) weekdays = weekdays,
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, volume) oneShotDateMillis = oneShotDateMillis,
}, snoozeUntilMillis = preservedSnooze?.first ?: snoozeUntilMillis,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 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)
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)
} }
val now = System.currentTimeMillis() private fun scheduleSpec(spec: NativeAlarmSpec, persistOnSuccess: Boolean): Boolean {
val nextTrigger = computeNextTriggerMillis(spec)
Log.d(
tag,
"alarm.schedule id=${spec.id} title=${spec.title} trigger=$nextTrigger type=${spec.scheduleType} snooze=${spec.snoozeUntilMillis} canExact=${canScheduleExactAlarms()}"
)
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 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) { 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 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 { try {
alarmManager.setExactAndAllowWhileIdle( alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, AlarmManager.RTC_WAKEUP,
preNoticeAtMillis, spec.preNoticeAtMillis,
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
context, appContext,
requestCode(id, 3), requestCode(spec.id, 3),
Intent(context, PluriWaveAlarmReceiver::class.java).apply { Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id) putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title) 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 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) { } catch (_: SecurityException) {
// The main alarm is already scheduled with setAlarmClock. Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
Log.w(tag, "alarm.schedule preNotice SecurityException id=$id")
} }
} else if (triggerAtMillis > now) { } else if (spec.triggerAtMillis > now) {
context.sendBroadcast( appContext.sendBroadcast(
Intent(context, PluriWaveAlarmReceiver::class.java).apply { Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id) putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title) 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 { } 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( private fun scheduleMainAlarm(
@@ -141,10 +195,7 @@ class AlarmScheduler(private val context: Context) {
) )
Log.d(tag, "alarm.schedule setExactAndAllowWhileIdle fallback OK id=$id") Log.d(tag, "alarm.schedule setExactAndAllowWhileIdle fallback OK id=$id")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Log.e( Log.e(tag, "alarm.schedule exact permission missing; refusing inexact fallback id=$id")
tag,
"alarm.schedule exact permission missing; refusing inexact fallback id=$id"
)
return false return false
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setAndAllowWhileIdle( 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) { fun cancelAlarm(id: String) {
Log.d(tag, "alarm.cancel id=$id") Log.d(tag, "alarm.cancel id=$id")
removeScheduledAlarm(id) removeScheduledAlarm(id)
cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE)) cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE)) cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE)) cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
NotificationManagerCompat.from(context).cancel( NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id) PluriWaveAlarmReceiver.notificationIdForAlarm(id)
) )
NotificationManagerCompat.from(context).cancel( NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id) PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id)
) )
} }
fun dismissFireNotification(id: String) { fun dismissFireNotification(id: String) {
NotificationManagerCompat.from(context).cancel( NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id) PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id)
) )
} }
@@ -194,27 +311,10 @@ class AlarmScheduler(private val context: Context) {
} }
fun reschedulePersistedAlarms() { fun reschedulePersistedAlarms() {
val now = System.currentTimeMillis()
for (id in prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()) { 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 { try {
val data = JSONObject(raw) scheduleSpec(spec, persistOnSuccess = true)
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()
)
Log.d(tag, "alarm.reschedule OK id=$id") Log.d(tag, "alarm.reschedule OK id=$id")
} catch (error: Throwable) { } catch (error: Throwable) {
Log.e(tag, "alarm.reschedule failed id=$id", error) Log.e(tag, "alarm.reschedule failed id=$id", error)
@@ -222,33 +322,113 @@ class AlarmScheduler(private val context: Context) {
} }
} }
private fun saveScheduledAlarm( fun pendingAlarmCount(): Int =
id: String, prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
title: String,
triggerAtMillis: Long, private fun preserveNativeSnooze(
preNoticeAtMillis: Long, existing: NativeAlarmSpec?,
stationName: String?, requestedTriggerAtMillis: Long,
stationUrl: String?, requestedSnoozeUntilMillis: Long?
fallbackSound: String?, ): Pair<Long, Long>? {
volume: Float if (requestedSnoozeUntilMillis != null || existing == null) return null
) { val snoozeUntil = existing.snoozeUntilMillis ?: return null
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() val snoozeOrigin = existing.snoozeOriginMillis ?: return null
ids.add(id) if (snoozeUntil <= System.currentTimeMillis()) return null
val data = JSONObject().apply { if (snoozeOrigin != requestedTriggerAtMillis) return null
put("title", title) Log.d(
put("triggerAtMillis", triggerAtMillis) tag,
put("preNoticeAtMillis", preNoticeAtMillis) "alarm.schedule preserving native snooze id=${existing.id} origin=$snoozeOrigin until=$snoozeUntil"
put("stationName", stationName) )
put("stationUrl", stationUrl) return snoozeUntil to snoozeOrigin
put("fallbackSound", fallbackSound)
put("volume", volume)
} }
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() prefs().edit()
.putStringSet(KEY_IDS, ids) .putStringSet(KEY_IDS, ids)
.putString("$KEY_ALARM_PREFIX$id", data.toString()) .putString("$KEY_ALARM_PREFIX${spec.id}", spec.toJson().toString())
.apply() .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) { private fun removeScheduledAlarm(id: String) {
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
ids.remove(id) ids.remove(id)
@@ -258,7 +438,9 @@ class AlarmScheduler(private val context: Context) {
.apply() .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?) { private fun cancelPending(name: String, pendingIntent: PendingIntent?) {
if (pendingIntent == null) { if (pendingIntent == null) {
@@ -270,11 +452,52 @@ class AlarmScheduler(private val context: Context) {
Log.d(tag, "alarm.cancel $name OK") 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? = private fun pendingFireIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
context, appContext,
requestCode(id, 1), requestCode(id, 1),
Intent(context, PluriWaveAlarmReceiver::class.java).apply { Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE action = PluriWaveAlarmReceiver.ACTION_FIRE
}, },
flags or PendingIntent.FLAG_IMMUTABLE flags or PendingIntent.FLAG_IMMUTABLE
@@ -282,10 +505,10 @@ class AlarmScheduler(private val context: Context) {
private fun pendingShowIntent(id: String, flags: Int): PendingIntent? = private fun pendingShowIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getActivity( PendingIntent.getActivity(
context, appContext,
requestCode(id, 2), requestCode(id, 2),
Intent(context, MainActivity::class.java).apply { Intent(appContext, 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_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE) putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
}, },
flags or PendingIntent.FLAG_IMMUTABLE flags or PendingIntent.FLAG_IMMUTABLE
@@ -293,19 +516,113 @@ class AlarmScheduler(private val context: Context) {
private fun pendingPreNoticeIntent(id: String, flags: Int): PendingIntent? = private fun pendingPreNoticeIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
context, appContext,
requestCode(id, 3), requestCode(id, 3),
Intent(context, PluriWaveAlarmReceiver::class.java).apply { Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
}, },
flags or PendingIntent.FLAG_IMMUTABLE 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 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 { companion object {
private const val PREFS = "pluriwave_alarm_scheduler" private const val PREFS = "pluriwave_alarm_scheduler"
private const val KEY_IDS = "scheduled_alarm_ids" private const val KEY_IDS = "scheduled_alarm_ids"
private const val KEY_ALARM_PREFIX = "scheduled_alarm_" 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 package es.freetimelab.pluriwave
import android.Manifest import android.Manifest
import android.app.NotificationManager
import android.content.ClipData import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
@@ -13,7 +14,9 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@@ -28,7 +31,8 @@ class MainActivity : AudioServiceActivity() {
private val visualizerChannel = "pluriwave/audio_visualizer" private val visualizerChannel = "pluriwave/audio_visualizer"
private val alarmChannel = "pluriwave/alarm_scheduler" private val alarmChannel = "pluriwave/alarm_scheduler"
private val fileActionsChannel = "pluriwave/file_actions" 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 visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null private var pendingSink: EventChannel.EventSink? = null
private var pendingArgs: Map<*, *>? = null private var pendingArgs: Map<*, *>? = null
@@ -70,6 +74,9 @@ class MainActivity : AudioServiceActivity() {
val stationUrl = call.argument<String>("stationUrl") val stationUrl = call.argument<String>("stationUrl")
val fallbackSound = call.argument<String>("fallbackSound") val fallbackSound = call.argument<String>("fallbackSound")
val volume = call.argument<Number>("volume")?.toFloat() ?: 0.85f 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") Log.d(tag, "alarm.channel scheduleAlarm id=$id triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis")
if (id == null || triggerAtMillis == null) { if (id == null || triggerAtMillis == null) {
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis") Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
@@ -83,7 +90,17 @@ class MainActivity : AudioServiceActivity() {
stationName, stationName,
stationUrl, stationUrl,
fallbackSound, 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) result.success(scheduled)
} }
@@ -119,12 +136,25 @@ class MainActivity : AudioServiceActivity() {
result.success(null) 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" -> { "diagnostics" -> {
Log.d(tag, "alarm.channel diagnostics") Log.d(tag, "alarm.channel diagnostics")
result.success( result.success(
mapOf( mapOf(
"canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(), "canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
"notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(), "notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(),
"canUseFullScreenIntent" to canUseFullScreenIntent(),
"isIgnoringBatteryOptimizations" to isIgnoringBatteryOptimizations(),
"nativePendingAlarmsCount" to alarmScheduler.pendingAlarmCount(),
"manufacturer" to Build.MANUFACTURER, "manufacturer" to Build.MANUFACTURER,
"sdkInt" to Build.VERSION.SDK_INT "sdkInt" to Build.VERSION.SDK_INT
) )
@@ -134,6 +164,14 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel requestExactAlarmPermission") Log.d(tag, "alarm.channel requestExactAlarmPermission")
result.success(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" -> { "getInitialAlarmIntent" -> {
val payload = alarmPayload(intent) val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload") Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
@@ -203,7 +241,10 @@ class MainActivity : AudioServiceActivity() {
return mapOf( return mapOf(
"alarmId" to alarmId, "alarmId" to alarmId,
"alarmTitle" to title, "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 { private fun openDirectory(path: String): Boolean {
val folder = File(path) val folder = File(path)
if (!folder.exists()) { if (!folder.exists()) {
@@ -385,7 +467,7 @@ class MainActivity : AudioServiceActivity() {
) { ) {
requestPermissions( requestPermissions(
arrayOf(Manifest.permission.RECORD_AUDIO), arrayOf(Manifest.permission.RECORD_AUDIO),
permissionRequestCode visualizerPermissionRequestCode
) )
return return
} }
@@ -474,7 +556,8 @@ class MainActivity : AudioServiceActivity() {
grantResults: IntArray grantResults: IntArray
) { ) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != permissionRequestCode) return if (requestCode == notificationPermissionRequestCode) return
if (requestCode != visualizerPermissionRequestCode) return
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
startVisualizer() startVisualizer()
@@ -18,18 +18,23 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
return return
} }
val title = intent.getStringExtra(EXTRA_ALARM_TITLE) ?: "PluriWave" 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") Log.d(TAG, "alarm.receiver action=${intent.action} id=$alarmId title=$title")
when (intent.action) { when (intent.action) {
ACTION_FIRE -> { ACTION_FIRE -> {
AlarmScheduler(context).onAlarmFired(alarmId)
PluriWaveAlarmService.start(context, intent) PluriWaveAlarmService.start(context, intent)
val launch = Intent(context, MainActivity::class.java).apply { 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_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title) putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_FIRE) 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 { try {
context.startActivity(launch) context.startActivity(launch)
Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId") Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId")
@@ -38,12 +43,40 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
} }
} }
ACTION_PRE_NOTICE -> { 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 -> { ACTION_SKIP_NEXT -> {
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId)) NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
AlarmScheduler(context).skipNext(alarmId)
val launch = Intent(context, MainActivity::class.java).apply { 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_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title) putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_SKIP_NEXT) putExtra(EXTRA_ALARM_ACTION, ACTION_SKIP_NEXT)
@@ -63,7 +96,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
context: Context, context: Context,
alarmId: String, alarmId: String,
title: String, title: String,
launch: Intent launch: Intent,
snoozeMinutes: Int
) { ) {
ensureFireChannel(context) ensureFireChannel(context)
val fullScreenIntent = PendingIntent.getActivity( val fullScreenIntent = PendingIntent.getActivity(
@@ -82,6 +116,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
.setAutoCancel(false) .setAutoCancel(false)
.setContentIntent(fullScreenIntent) .setContentIntent(fullScreenIntent)
.setFullScreenIntent(fullScreenIntent, true) .setFullScreenIntent(fullScreenIntent, true)
.addAction(0, "Posponer $snoozeMinutes min", snoozePendingIntent(context, alarmId, snoozeMinutes))
.build() .build()
try { 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) ensureChannel(context)
val openAppIntent = PendingIntent.getActivity( val openAppIntent = PendingIntent.getActivity(
context, context,
requestCode(alarmId, 1), requestCode(alarmId, 1),
Intent(context, MainActivity::class.java).apply { 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_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title) putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_PRE_NOTICE) putExtra(EXTRA_ALARM_ACTION, ACTION_PRE_NOTICE)
@@ -121,6 +163,20 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 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) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title) .setContentTitle(title)
@@ -130,7 +186,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
.setSilent(true) .setSilent(true)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(openAppIntent) .setContentIntent(openAppIntent)
.addAction(0, "Omitir siguiente", skipNextIntent) .addAction(0, "Posponer $snoozeMinutes min", postponeNextIntent)
.addAction(0, "Omitir esta vez", skipNextIntent)
.build() .build()
try { try {
@@ -178,6 +235,21 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot 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 { companion object {
const val TAG = "PluriWave" const val TAG = "PluriWave"
const val CHANNEL_ID = "pluriwave_alarm_pre_notice" 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_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE" 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_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_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle" const val EXTRA_ALARM_TITLE = "alarmTitle"
const val EXTRA_ALARM_ACTION = "alarmAction" const val EXTRA_ALARM_ACTION = "alarmAction"
@@ -192,6 +265,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
const val EXTRA_STATION_URL = "stationUrl" const val EXTRA_STATION_URL = "stationUrl"
const val EXTRA_FALLBACK_SOUND = "fallbackSound" const val EXTRA_FALLBACK_SOUND = "fallbackSound"
const val EXTRA_VOLUME = "volume" 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 notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9 fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
@@ -9,7 +9,9 @@ import android.content.Intent
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@@ -21,6 +23,8 @@ class PluriWaveAlarmService : Service() {
private var player: MediaPlayer? = null private var player: MediaPlayer? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var activeAlarmId: String? = null private var activeAlarmId: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var stationFallbackRunnable: Runnable? = null
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -34,6 +38,14 @@ class PluriWaveAlarmService : Service() {
stopAlarm(requestedId) stopAlarm(requestedId)
return START_NOT_STICKY 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) PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId") else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
} }
@@ -49,49 +61,164 @@ class PluriWaveAlarmService : Service() {
activeAlarmId = alarmId activeAlarmId = alarmId
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave" 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 fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f) val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
val snoozeMinutes = sanitizeSnoozeMinutes(
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
)
acquireWakeLock() acquireWakeLock()
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title)) try {
startAudio(alarmId, fallbackSound, volume) 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?.release()
player = null player = null
val source = fallbackAssetPath(fallbackSound) val source = fallbackAssetPath(fallbackSound)
try { try {
player = MediaPlayer().apply { player = MediaPlayer().apply {
setAudioAttributes( setAudioAttributes(alarmAudioAttributes())
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
isLooping = true isLooping = true
setVolume(volume, volume) setVolume(volume, volume)
setFallbackAssetDataSource(this, fallbackSound) setFallbackAssetDataSource(this, fallbackSound)
setOnPreparedListener { setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
it.start() 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 -> 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() mp.reset()
true true
} }
prepareAsync() 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) { } 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?) { private fun stopAlarm(alarmId: String?) {
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId") Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
cancelStationFallback()
try { try {
player?.stop() player?.stop()
} catch (error: Throwable) { } catch (error: Throwable) {
@@ -115,29 +242,42 @@ class PluriWaveAlarmService : Service() {
stopSelf() stopSelf()
} }
private fun buildNotification(alarmId: String, title: String) = private fun buildNotification(
alarmId: String,
title: String,
stationName: String?,
snoozeMinutes: Int
) =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm) .setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave") .setContentTitle("Alarma PluriWave")
.setContentText(title) .setContentText(
if (stationName.isNullOrBlank()) title else "$title - $stationName"
)
.setCategory(NotificationCompat.CATEGORY_ALARM) .setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX) .setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title), true) .setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
.setContentIntent(openAlarmPendingIntent(alarmId, title)) .setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
.addAction(0, "Posponer $snoozeMinutes min", snoozePendingIntent(alarmId, snoozeMinutes))
.addAction(0, "Detener", stopPendingIntent(alarmId)) .addAction(0, "Detener", stopPendingIntent(alarmId))
.build() .build()
private fun openAlarmPendingIntent(alarmId: String, title: String): PendingIntent = private fun openAlarmPendingIntent(
alarmId: String,
title: String,
snoozeMinutes: Int
): PendingIntent =
PendingIntent.getActivity( PendingIntent.getActivity(
this, this,
requestCode(alarmId, 20), requestCode(alarmId, 20),
Intent(this, MainActivity::class.java).apply { 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_ID, alarmId)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title) putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE) putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, snoozeMinutes)
}, },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
@@ -153,6 +293,18 @@ class PluriWaveAlarmService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 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() { private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return if (wakeLock?.isHeld == true) return
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
@@ -203,6 +355,9 @@ class PluriWaveAlarmService : Service() {
return "flutter_assets/assets/audio/$fileName" 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() { override fun onDestroy() {
stopAlarm(activeAlarmId) stopAlarm(activeAlarmId)
super.onDestroy() super.onDestroy()
@@ -213,6 +368,9 @@ class PluriWaveAlarmService : Service() {
private const val CHANNEL_ID = "pluriwave_alarm_native" private const val CHANNEL_ID = "pluriwave_alarm_native"
private const val NOTIFICATION_ID = 92841 private const val NOTIFICATION_ID = 92841
private const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE" 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) { fun start(context: Context, source: Intent) {
ensureChannel(context) ensureChannel(context)
@@ -8,7 +8,9 @@ import android.util.Log
class PluriWaveBootReceiver : BroadcastReceiver() { class PluriWaveBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
when (intent.action) { when (intent.action) {
Intent.ACTION_LOCKED_BOOT_COMPLETED,
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_USER_UNLOCKED,
Intent.ACTION_MY_PACKAGE_REPLACED, Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_TIMEZONE_CHANGED,
+21 -1
View File
@@ -241,6 +241,27 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
); );
return; 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')) { if (evento.accion.endsWith('.PRE_NOTICE')) {
setState(() => _indice = 3); setState(() => _indice = 3);
return; return;
@@ -268,7 +289,6 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
_alarmaSonandoId = alarma.id; _alarmaSonandoId = alarma.id;
try { try {
await alarmas.android.detenerSonidoNativo(alarma.id);
await _prearrancarAudioAlarma(alarma); await _prearrancarAudioAlarma(alarma);
if (!mounted) return; if (!mounted) return;
await Navigator.of(context).push( await Navigator.of(context).push(
+66 -10
View File
@@ -9,7 +9,7 @@ import '../servicios/servicio_alarmas_android.dart';
class EstadoAlarmas extends ChangeNotifier { class EstadoAlarmas extends ChangeNotifier {
EstadoAlarmas({ EstadoAlarmas({
ServicioAlarmas? servicio, ServicioAlarmas? servicio,
ServicioAlarmasAndroid? android, PuertoAlarmasAndroid? android,
bool iniciarAutomaticamente = true, bool iniciarAutomaticamente = true,
}) : servicio = servicio ?? ServicioAlarmas(), }) : servicio = servicio ?? ServicioAlarmas(),
android = android ?? ServicioAlarmasAndroid() { android = android ?? ServicioAlarmasAndroid() {
@@ -19,7 +19,7 @@ class EstadoAlarmas extends ChangeNotifier {
} }
final ServicioAlarmas servicio; final ServicioAlarmas servicio;
final ServicioAlarmasAndroid android; final PuertoAlarmasAndroid android;
List<AlarmaMusical> _alarmas = []; List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = []; List<RangoVacaciones> _vacaciones = [];
@@ -45,8 +45,10 @@ class EstadoAlarmas extends ChangeNotifier {
AlarmaMusical? get proximaAlarma { AlarmaMusical? get proximaAlarma {
final candidatas = final candidatas =
_alarmas.where((a) => a.activa && a.proximaEjecucion != null).toList() _alarmas.where((a) => a.activa && a.proximaProgramable != null).toList()
..sort((a, b) => a.proximaEjecucion!.compareTo(b.proximaEjecucion!)); ..sort(
(a, b) => a.proximaProgramable!.compareTo(b.proximaProgramable!),
);
return candidatas.isEmpty ? null : candidatas.first; return candidatas.isEmpty ? null : candidatas.first;
} }
@@ -109,7 +111,7 @@ class EstadoAlarmas extends ChangeNotifier {
} }
void marcarEjecucionGestionada(AlarmaMusical alarma) { void marcarEjecucionGestionada(AlarmaMusical alarma) {
final proxima = alarma.proximaEjecucion; final proxima = alarma.proximaProgramable;
if (proxima == null) return; if (proxima == null) return;
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}'; final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
_ejecucionesEmitidas.add(key); _ejecucionesEmitidas.add(key);
@@ -159,18 +161,62 @@ class EstadoAlarmas extends ChangeNotifier {
} }
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async { 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( 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.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 { Future<void> finalizarEjecucion(String alarmaId) async {
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId'); 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 android.ocultarNotificacionAlarma(alarmaId);
await refrescarProgramacion(); final config = await servicio.completarEjecucion(alarmaId, ejecucion);
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
} }
Future<void> crearRangoVacaciones(RangoVacaciones rango) async { 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) { void _aplicar(ConfiguracionAlarmas config) {
_alarmas = config.alarmas; _alarmas = config.alarmas;
_vacaciones = config.vacaciones; _vacaciones = config.vacaciones;
@@ -230,7 +286,7 @@ class EstadoAlarmas extends ChangeNotifier {
void _vigilarAlarmasVencidas() { void _vigilarAlarmasVencidas() {
final ahora = DateTime.now(); final ahora = DateTime.now();
for (final alarma in _alarmas) { for (final alarma in _alarmas) {
final proxima = alarma.proximaEjecucion; final proxima = alarma.proximaProgramable;
if (!alarma.activa || proxima == null) continue; if (!alarma.activa || proxima == null) continue;
if (proxima.isAfter(ahora)) continue; if (proxima.isAfter(ahora)) continue;
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}'; final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
+28
View File
@@ -21,6 +21,9 @@ class AlarmaMusical {
this.volumen = 0.85, this.volumen = 0.85,
this.sonidoInterno = SonidoInternoAlarma.amanecer, this.sonidoInterno = SonidoInternoAlarma.amanecer,
this.proximaEjecucion, this.proximaEjecucion,
this.snoozeHasta,
this.snoozeOrigen,
this.ultimaEjecucionGestionada,
this.creadaEn, this.creadaEn,
this.actualizadaEn, this.actualizadaEn,
}); });
@@ -40,6 +43,9 @@ class AlarmaMusical {
final double volumen; final double volumen;
final SonidoInternoAlarma sonidoInterno; final SonidoInternoAlarma sonidoInterno;
final DateTime? proximaEjecucion; final DateTime? proximaEjecucion;
final DateTime? snoozeHasta;
final DateTime? snoozeOrigen;
final DateTime? ultimaEjecucionGestionada;
final DateTime? creadaEn; final DateTime? creadaEn;
final DateTime? actualizadaEn; final DateTime? actualizadaEn;
@@ -61,6 +67,11 @@ class AlarmaMusical {
SonidoInternoAlarma? sonidoInterno, SonidoInternoAlarma? sonidoInterno,
DateTime? proximaEjecucion, DateTime? proximaEjecucion,
bool limpiarProximaEjecucion = false, bool limpiarProximaEjecucion = false,
DateTime? snoozeHasta,
DateTime? snoozeOrigen,
bool limpiarSnooze = false,
DateTime? ultimaEjecucionGestionada,
bool limpiarUltimaEjecucionGestionada = false,
DateTime? creadaEn, DateTime? creadaEn,
DateTime? actualizadaEn, DateTime? actualizadaEn,
}) { }) {
@@ -83,11 +94,20 @@ class AlarmaMusical {
limpiarProximaEjecucion limpiarProximaEjecucion
? proximaEjecucion ? proximaEjecucion
: proximaEjecucion ?? this.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, creadaEn: creadaEn ?? this.creadaEn,
actualizadaEn: actualizadaEn ?? this.actualizadaEn, actualizadaEn: actualizadaEn ?? this.actualizadaEn,
); );
} }
DateTime? get proximaProgramable => snoozeHasta ?? proximaEjecucion;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'nombre': nombre, 'nombre': nombre,
@@ -104,6 +124,9 @@ class AlarmaMusical {
'volumen': volumen, 'volumen': volumen,
'sonidoInterno': sonidoInterno.name, 'sonidoInterno': sonidoInterno.name,
'proximaEjecucion': proximaEjecucion?.toIso8601String(), 'proximaEjecucion': proximaEjecucion?.toIso8601String(),
'snoozeHasta': snoozeHasta?.toIso8601String(),
'snoozeOrigen': snoozeOrigen?.toIso8601String(),
'ultimaEjecucionGestionada': ultimaEjecucionGestionada?.toIso8601String(),
'creadaEn': creadaEn?.toIso8601String(), 'creadaEn': creadaEn?.toIso8601String(),
'actualizadaEn': actualizadaEn?.toIso8601String(), 'actualizadaEn': actualizadaEn?.toIso8601String(),
}; };
@@ -137,6 +160,11 @@ class AlarmaMusical {
SonidoInternoAlarma.amanecer, SonidoInternoAlarma.amanecer,
), ),
proximaEjecucion: _dateFromJson(json['proximaEjecucion']), proximaEjecucion: _dateFromJson(json['proximaEjecucion']),
snoozeHasta: _dateFromJson(json['snoozeHasta']),
snoozeOrigen: _dateFromJson(json['snoozeOrigen']),
ultimaEjecucionGestionada: _dateFromJson(
json['ultimaEjecucionGestionada'],
),
creadaEn: _dateFromJson(json['creadaEn']), creadaEn: _dateFromJson(json['creadaEn']),
actualizadaEn: _dateFromJson(json['actualizadaEn']), actualizadaEn: _dateFromJson(json['actualizadaEn']),
); );
@@ -30,6 +30,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Timer? _fallbackTimer; Timer? _fallbackTimer;
bool _fallbackActivo = false; bool _fallbackActivo = false;
bool _radioIntentada = false; bool _radioIntentada = false;
bool _audioFlutterConfirmado = false;
@override @override
void initState() { void initState() {
@@ -57,6 +58,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_estadoSub = radio.estadoStream.listen((estado) { _estadoSub = radio.estadoStream.listen((estado) {
if (estado == EstadoReproduccion.reproduciendo && mounted) { if (estado == EstadoReproduccion.reproduciendo && mounted) {
_fallbackTimer?.cancel(); _fallbackTimer?.cancel();
_confirmarAudioFlutterListo();
} }
if (estado == EstadoReproduccion.error && mounted) { if (estado == EstadoReproduccion.error && mounted) {
_iniciarFallback(); _iniciarFallback();
@@ -76,9 +78,18 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_fallbackActivo = true; _fallbackActivo = true;
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno)); await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
await _fallbackPlayer.play(); await _fallbackPlayer.play();
await _confirmarAudioFlutterListo();
if (mounted) setState(() {}); 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 { Future<void> _detener() async {
final radio = context.read<EstadoRadio>(); final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>(); final alarmas = context.read<EstadoAlarmas>();
+19 -12
View File
@@ -82,8 +82,9 @@ class _PanelProximaAlarma extends StatelessWidget {
final proxima = estado.proximaAlarma; final proxima = estado.proximaAlarma;
final activasSinProxima = final activasSinProxima =
estado.alarmas estado.alarmas
.where((a) => a.activa && a.proximaEjecucion == null) .where((a) => a.activa && a.proximaProgramable == null)
.length; .length;
final proximaProgramable = proxima?.proximaProgramable;
return PluriGlassSurface( return PluriGlassSurface(
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28), glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
child: Row( child: Row(
@@ -110,7 +111,7 @@ class _PanelProximaAlarma extends StatelessWidget {
? activasSinProxima > 0 ? activasSinProxima > 0
? 'Hay $activasSinProxima alarma(s) activas, pero ahora mismo no tienen una fecha futura válida. Revisá fecha, días y vacaciones.' ? '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.' : '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), const SizedBox(height: 12),
if (alarma.proximaEjecucion != null) if (alarma.proximaProgramable != null)
_NoticeLine( _NoticeLine(
icon: Icons.event_available_rounded, icon: Icons.event_available_rounded,
text: text:
'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}', 'Siguiente ejecución: ${_fechaHora(alarma.proximaProgramable!)}',
) )
else else
const _NoticeLine( const _NoticeLine(
@@ -231,7 +232,7 @@ class _TarjetaAlarma extends StatelessWidget {
icon: const Icon(Icons.skip_next_rounded), icon: const Icon(Icons.skip_next_rounded),
label: const Text('Omitir siguiente'), label: const Text('Omitir siguiente'),
onPressed: onPressed:
alarma.proximaEjecucion == null alarma.proximaProgramable == null
? null ? null
: () async { : () async {
await estado.saltarProxima(alarma.id); await estado.saltarProxima(alarma.id);
@@ -248,9 +249,9 @@ class _TarjetaAlarma extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
actualizada?.proximaEjecucion == null actualizada?.proximaProgramable == null
? 'Alarma omitida. No queda próxima ejecución.' ? '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 (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 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) { if (alarma.proximaProgramable != null) {
return 'Con vacaciones activas, volverá a sonar el ${_fechaHora(alarma.proximaEjecucion!)}.'; return 'Con vacaciones activas, volverá a sonar el ${_fechaHora(alarma.proximaProgramable!)}.';
} }
return null; return null;
} }
@@ -690,12 +691,18 @@ class _AccesoDiagnostico extends StatelessWidget {
label: Text( label: Text(
diag == null diag == null
? 'Revisar fiabilidad Android' ? '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 { onPressed: () async {
if (diag != null && !diag.puedeProgramarExactas) { if (diag != null && !diag.puedeProgramarExactas) {
await estado.android.solicitarPermisoAlarmasExactas(); 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(); await estado.cargarDiagnostico();
}, },
); );
+80 -1
View File
@@ -199,6 +199,81 @@ class ServicioAlarmas {
return nuevo; 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({ AlarmaMusical crearAlarma({
required String nombre, required String nombre,
required int hora, required int hora,
@@ -250,15 +325,19 @@ class ServicioAlarmas {
List<RangoVacaciones> vacaciones, List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones, List<ExcepcionAlarma> excepciones,
) { ) {
final ahora = _reloj();
final snoozeActivo =
alarma.snoozeHasta != null && alarma.snoozeHasta!.isAfter(ahora);
final proxima = _programacion.calcularProxima( final proxima = _programacion.calcularProxima(
alarma: alarma, alarma: alarma,
desde: _reloj(), desde: ahora,
vacaciones: vacaciones, vacaciones: vacaciones,
excepciones: excepciones, excepciones: excepciones,
); );
return alarma.copyWith( return alarma.copyWith(
proximaEjecucion: proxima, proximaEjecucion: proxima,
limpiarProximaEjecucion: true, limpiarProximaEjecucion: true,
limpiarSnooze: !snoozeActivo,
); );
} }
+79 -3
View File
@@ -10,17 +10,26 @@ class EventoAlarmaAndroid {
required this.alarmaId, required this.alarmaId,
required this.titulo, required this.titulo,
required this.accion, required this.accion,
this.triggerAtMillis = 0,
this.occurrenceAtMillis = 0,
this.snoozeMinutes = 5,
}); });
final String alarmaId; final String alarmaId;
final String titulo; final String titulo;
final String accion; final String accion;
final int triggerAtMillis;
final int occurrenceAtMillis;
final int snoozeMinutes;
factory EventoAlarmaAndroid.fromMap(Map<Object?, Object?> map) { factory EventoAlarmaAndroid.fromMap(Map<Object?, Object?> map) {
return EventoAlarmaAndroid( return EventoAlarmaAndroid(
alarmaId: map['alarmId'] as String? ?? '', alarmaId: map['alarmId'] as String? ?? '',
titulo: map['alarmTitle'] as String? ?? 'PluriWave', titulo: map['alarmTitle'] as String? ?? 'PluriWave',
accion: map['alarmAction'] as String? ?? '', 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({ const DiagnosticoAlarmasAndroid({
required this.puedeProgramarExactas, required this.puedeProgramarExactas,
required this.notificacionesPermitidas, required this.notificacionesPermitidas,
required this.puedeUsarPantallaCompleta,
required this.ignoraOptimizacionBateria,
required this.alarmasNativasPendientes,
required this.fabricante, required this.fabricante,
required this.versionSdk, required this.versionSdk,
}); });
final bool puedeProgramarExactas; final bool puedeProgramarExactas;
final bool notificacionesPermitidas; final bool notificacionesPermitidas;
final bool puedeUsarPantallaCompleta;
final bool ignoraOptimizacionBateria;
final int alarmasNativasPendientes;
final String fabricante; final String fabricante;
final int versionSdk; final int versionSdk;
@@ -42,13 +57,33 @@ class DiagnosticoAlarmasAndroid {
return DiagnosticoAlarmasAndroid( return DiagnosticoAlarmasAndroid(
puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true, puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true,
notificacionesPermitidas: map['notificationsEnabled'] 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', fabricante: map['manufacturer'] as String? ?? 'Android',
versionSdk: map['sdkInt'] as int? ?? 0, 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({ ServicioAlarmasAndroid({
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'), MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
}) : _channel = channel { }) : _channel = channel {
@@ -60,10 +95,12 @@ class ServicioAlarmasAndroid {
StreamController<EventoAlarmaAndroid>.broadcast(); StreamController<EventoAlarmaAndroid>.broadcast();
static bool _handlerInstalado = false; static bool _handlerInstalado = false;
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream; Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
@override
Future<void> programar(AlarmaMusical alarma) async { Future<void> programar(AlarmaMusical alarma) async {
final proxima = alarma.proximaEjecucion; final proxima = alarma.proximaProgramable;
if (proxima == null || !alarma.activa) { if (proxima == null || !alarma.activa) {
debugPrint( debugPrint(
'[PluriWave][alarmas] cancelar por inactiva/sin proxima id=${alarma.id} activa=${alarma.activa} proxima=$proxima', '[PluriWave][alarmas] cancelar por inactiva/sin proxima id=${alarma.id} activa=${alarma.activa} proxima=$proxima',
@@ -79,7 +116,20 @@ class ServicioAlarmasAndroid {
'title': alarma.nombre, 'title': alarma.nombre,
'triggerAtMillis': proxima.millisecondsSinceEpoch, 'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis': '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, 'stationName': alarma.emisora?.nombre,
'stationUrl': alarma.emisora?.url, 'stationUrl': alarma.emisora?.url,
'fallbackSound': alarma.sonidoInterno.name, 'fallbackSound': alarma.sonidoInterno.name,
@@ -92,15 +142,23 @@ class ServicioAlarmasAndroid {
} }
} }
@override
Future<void> cancelar(String alarmaId) => Future<void> cancelar(String alarmaId) =>
_logAndInvokeVoid('cancelAlarm', {'id': alarmaId}); _logAndInvokeVoid('cancelAlarm', {'id': alarmaId});
@override
Future<void> ocultarNotificacionAlarma(String alarmaId) => Future<void> ocultarNotificacionAlarma(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId}); _logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId});
@override
Future<void> detenerSonidoNativo(String alarmaId) => Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId}); _logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
@override
Future<void> confirmarAudioFlutter(String alarmaId) =>
_logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId});
@override
Future<bool> solicitarPermisoAlarmasExactas() async { Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>( final abierto = await _channel.invokeMethod<bool>(
'requestExactAlarmPermission', 'requestExactAlarmPermission',
@@ -108,6 +166,23 @@ class ServicioAlarmasAndroid {
return abierto ?? false; 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 { Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android'); debugPrint('[PluriWave][alarmas] diagnostico android');
final raw = await _channel.invokeMethod<Map<Object?, Object?>>( final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
@@ -120,6 +195,7 @@ class ServicioAlarmasAndroid {
return diag; return diag;
} }
@override
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async { Future<EventoAlarmaAndroid?> obtenerEventoInicial() async {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>( final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'getInitialAlarmIntent', 'getInitialAlarmIntent',
@@ -58,6 +58,23 @@ class ServicioProgramacionAlarmas {
return desde.add(Duration(minutes: seguro)); 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) => bool estaEnVacaciones(DateTime fecha, List<RangoVacaciones> vacaciones) =>
vacaciones.any((rango) => rango.contiene(fecha)); vacaciones.any((rango) => rango.contiene(fecha));
+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)); 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);
});
}); });
} }