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.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,