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