diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index 330a5a3..33094f4 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -94,7 +94,7 @@ jobs:
- name: Build AAB release
run: flutter build appbundle --release
- - name: Verificar firma del AAB
+ - name: Verificar firma
env:
KEYSTORE_PASSWORD: ${{ secrets.PLURIWAVE_KEYSTORE_PASSWORD }}
run: |
@@ -103,10 +103,10 @@ jobs:
echo "ERROR: no se pudo leer el keystore de firma"
exit 1
}
- echo "$KEYSTORE_INFO" | grep -E "SHA1:|SHA256:" || true
- echo ""
- echo "=== Huellas del AAB (desde ZIP) ==="
- unzip -p build/app/outputs/bundle/release/app-release.aab META-INF/CERT.RSA | keytool -printcert 2>/dev/null | grep -E "SHA1:|SHA256:" || echo "(huellas no extraídas, build generado)"
+ echo "$KEYSTORE_INFO" | grep -E "SHA1:|SHA256:" || {
+ echo "ERROR: no se encontraron huellas SHA1/SHA256 en el keystore"
+ exit 1
+ }
- name: Publicar en ftl-builds (Zimaboard)
run: |
@@ -171,4 +171,4 @@ jobs:
MSG="❌ *PluriWave* build FAILED · rama ${BRANCH} · ${COMMIT}"
fi
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
- -d "chat_id=221721467" -d "parse_mode=Markdown" -d "text=${MSG}" || true
+ -d "chat_id=221721467" -d "parse_mode=Markdown" -d "text=${MSG}" || true
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 04a81ea..d1cc818 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -10,6 +10,7 @@
+
@@ -66,19 +67,24 @@
+ android:exported="false"
+ android:directBootAware="true">
+
+ android:exported="true"
+ android:directBootAware="true">
+
+
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt
index 5fb6253..4dfa6d0 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt
@@ -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 = 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? {
+ if (requestedSnoozeUntilMillis != null || existing == null) return null
+ val snoozeUntil = existing.snoozeUntilMillis ?: return null
+ val snoozeOrigin = existing.snoozeOriginMillis ?: return null
+ if (snoozeUntil <= System.currentTimeMillis()) return null
+ if (snoozeOrigin != requestedTriggerAtMillis) return null
+ Log.d(
+ tag,
+ "alarm.schedule preserving native snooze id=${existing.id} origin=$snoozeOrigin until=$snoozeUntil"
+ )
+ return snoozeUntil to snoozeOrigin
+ }
+
+ private fun computeNextTriggerMillis(spec: NativeAlarmSpec): Long? {
+ val now = System.currentTimeMillis()
+ spec.snoozeUntilMillis?.let { if (it > now) return it }
+ if (!spec.enabled) return null
+ val base = maxOf(now, (spec.lastHandledAtMillis ?: 0L) + 60_000L)
+ return when (spec.scheduleType) {
+ SCHEDULE_UNICA -> computeOneShot(spec, base)
+ SCHEDULE_DIAS_SEMANA -> computeWeekday(spec, base)
+ else -> computeDaily(spec, base)
}
+ }
+
+ private fun computeOneShot(spec: NativeAlarmSpec, baseMillis: Long): Long? {
+ val candidate = Calendar.getInstance().apply {
+ timeInMillis = spec.oneShotDateMillis ?: spec.triggerAtMillis
+ set(Calendar.HOUR_OF_DAY, spec.hour)
+ set(Calendar.MINUTE, spec.minute)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ return candidate.timeInMillis.takeIf { it > baseMillis }
+ }
+
+ private fun computeDaily(spec: NativeAlarmSpec, baseMillis: Long): Long? {
+ val candidate = Calendar.getInstance().apply {
+ timeInMillis = baseMillis
+ set(Calendar.HOUR_OF_DAY, spec.hour)
+ set(Calendar.MINUTE, spec.minute)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ if (candidate.timeInMillis <= baseMillis) {
+ candidate.add(Calendar.DAY_OF_YEAR, 1)
+ }
+ return candidate.timeInMillis
+ }
+
+ private fun computeWeekday(spec: NativeAlarmSpec, baseMillis: Long): Long? {
+ if (spec.weekdays.isEmpty()) return null
+ val candidate = Calendar.getInstance().apply {
+ timeInMillis = baseMillis
+ set(Calendar.HOUR_OF_DAY, spec.hour)
+ set(Calendar.MINUTE, spec.minute)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ for (i in 0 until 370) {
+ if (candidate.timeInMillis > baseMillis &&
+ spec.weekdays.contains(dartWeekday(candidate))
+ ) {
+ return candidate.timeInMillis
+ }
+ candidate.add(Calendar.DAY_OF_YEAR, 1)
+ }
+ return null
+ }
+
+ private fun dartWeekday(calendar: Calendar): Int =
+ when (calendar.get(Calendar.DAY_OF_WEEK)) {
+ Calendar.MONDAY -> 1
+ Calendar.TUESDAY -> 2
+ Calendar.WEDNESDAY -> 3
+ Calendar.THURSDAY -> 4
+ Calendar.FRIDAY -> 5
+ Calendar.SATURDAY -> 6
+ else -> 7
+ }
+
+ private fun saveScheduledAlarm(spec: NativeAlarmSpec) {
+ val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
+ ids.add(spec.id)
prefs().edit()
.putStringSet(KEY_IDS, ids)
- .putString("$KEY_ALARM_PREFIX$id", data.toString())
+ .putString("$KEY_ALARM_PREFIX${spec.id}", spec.toJson().toString())
.apply()
}
+ private fun readSpec(id: String): NativeAlarmSpec? {
+ val raw = prefs().getString("$KEY_ALARM_PREFIX$id", null) ?: return null
+ return try {
+ NativeAlarmSpec.fromJson(JSONObject(raw))
+ } catch (error: Throwable) {
+ Log.e(tag, "alarm.readSpec failed id=$id", error)
+ null
+ }
+ }
+
private fun removeScheduledAlarm(id: String) {
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
ids.remove(id)
@@ -258,7 +438,9 @@ class AlarmScheduler(private val context: Context) {
.apply()
}
- private fun prefs() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ private fun prefs() =
+ appContext.createDeviceProtectedStorageContext()
+ .getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun cancelPending(name: String, pendingIntent: PendingIntent?) {
if (pendingIntent == null) {
@@ -270,11 +452,52 @@ class AlarmScheduler(private val context: Context) {
Log.d(tag, "alarm.cancel $name OK")
}
+ private fun fireIntent(spec: NativeAlarmSpec): PendingIntent =
+ PendingIntent.getBroadcast(
+ appContext,
+ requestCode(spec.id, 1),
+ Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
+ action = PluriWaveAlarmReceiver.ACTION_FIRE
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, spec.stationName)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, spec.stationUrl)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, spec.fallbackSound)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, spec.volume)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
+ putExtra(
+ PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
+ spec.snoozeOriginMillis ?: spec.triggerAtMillis
+ )
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ private fun showIntent(spec: NativeAlarmSpec): PendingIntent =
+ PendingIntent.getActivity(
+ appContext,
+ requestCode(spec.id, 2),
+ Intent(appContext, MainActivity::class.java).apply {
+ this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
+ putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
+ putExtra(
+ PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
+ spec.snoozeOriginMillis ?: spec.triggerAtMillis
+ )
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
private fun pendingFireIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
- context,
+ appContext,
requestCode(id, 1),
- Intent(context, PluriWaveAlarmReceiver::class.java).apply {
+ Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
},
flags or PendingIntent.FLAG_IMMUTABLE
@@ -282,9 +505,9 @@ class AlarmScheduler(private val context: Context) {
private fun pendingShowIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getActivity(
- context,
+ appContext,
requestCode(id, 2),
- Intent(context, MainActivity::class.java).apply {
+ Intent(appContext, MainActivity::class.java).apply {
this.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
@@ -294,19 +517,113 @@ class AlarmScheduler(private val context: Context) {
private fun pendingPreNoticeIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
- context,
+ appContext,
requestCode(id, 3),
- Intent(context, PluriWaveAlarmReceiver::class.java).apply {
+ Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
},
flags or PendingIntent.FLAG_IMMUTABLE
)
+ private fun localHour(millis: Long): Int =
+ Calendar.getInstance().apply { timeInMillis = millis }.get(Calendar.HOUR_OF_DAY)
+
+ private fun localMinute(millis: Long): Int =
+ Calendar.getInstance().apply { timeInMillis = millis }.get(Calendar.MINUTE)
+
+ private fun sanitizeSnoozeMinutes(minutes: Int): Int =
+ if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
+
private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot
+ private data class NativeAlarmSpec(
+ val id: String,
+ val title: String,
+ val enabled: Boolean,
+ val triggerAtMillis: Long,
+ val preNoticeAtMillis: Long,
+ val hour: Int,
+ val minute: Int,
+ val scheduleType: String,
+ val weekdays: List,
+ 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
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
index 4182d75..5d0ac6f 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
@@ -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("stationUrl")
val fallbackSound = call.argument("fallbackSound")
val volume = call.argument("volume")?.toFloat() ?: 0.85f
+ val weekdays =
+ (call.argument>("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("hour"),
+ minute = call.argument("minute"),
+ scheduleType = call.argument("scheduleType"),
+ weekdays = weekdays,
+ oneShotDateMillis = call.argument("oneShotDateMillis"),
+ snoozeUntilMillis = call.argument("snoozeUntilMillis"),
+ snoozeOriginMillis = call.argument("snoozeOriginMillis"),
+ lastHandledAtMillis = call.argument("lastHandledAtMillis"),
+ soundOnVacation = call.argument("soundOnVacation") ?: true,
+ snoozeMinutes = call.argument("snoozeMinutes") ?: 5
)
result.success(scheduled)
}
@@ -119,12 +136,25 @@ class MainActivity : AudioServiceActivity() {
result.success(null)
}
}
+ "confirmFlutterAudio" -> {
+ val id = call.argument("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()
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt
index a22d6ea..4cd8196 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt
@@ -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
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt
index 899b983..25be508 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt
@@ -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)
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt
index 5a82ca4..ed273bb 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt
@@ -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,
diff --git a/lib/app.dart b/lib/app.dart
index ba1d082..4252c6e 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -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(
diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart
index 4f9e7ec..f969804 100644
--- a/lib/estado/estado_alarmas.dart
+++ b/lib/estado/estado_alarmas.dart
@@ -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 _alarmas = [];
List _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 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 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 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 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}';
diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart
index facf063..ba2fb0a 100644
--- a/lib/modelos/alarma_musical.dart
+++ b/lib/modelos/alarma_musical.dart
@@ -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 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']),
);
diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart
index 87a099b..b5c6a32 100644
--- a/lib/pantallas/pantalla_alarma_sonando.dart
+++ b/lib/pantallas/pantalla_alarma_sonando.dart
@@ -30,6 +30,7 @@ class _PantallaAlarmaSonandoState extends State {
Timer? _fallbackTimer;
bool _fallbackActivo = false;
bool _radioIntentada = false;
+ bool _audioFlutterConfirmado = false;
@override
void initState() {
@@ -57,6 +58,7 @@ class _PantallaAlarmaSonandoState extends State {
_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 {
_fallbackActivo = true;
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
await _fallbackPlayer.play();
+ await _confirmarAudioFlutterListo();
if (mounted) setState(() {});
}
+ Future _confirmarAudioFlutterListo() async {
+ if (_audioFlutterConfirmado) return;
+ _audioFlutterConfirmado = true;
+ await context.read().android.confirmarAudioFlutter(
+ widget.alarma.id,
+ );
+ }
+
Future _detener() async {
final radio = context.read();
final alarmas = context.read();
diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart
index 14653f7..a1a33a1 100644
--- a/lib/pantallas/pantalla_alarmas.dart
+++ b/lib/pantallas/pantalla_alarmas.dart
@@ -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();
},
);
diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart
index 814b982..03e1fd2 100644
--- a/lib/servicios/servicio_alarmas.dart
+++ b/lib/servicios/servicio_alarmas.dart
@@ -199,6 +199,81 @@ class ServicioAlarmas {
return nuevo;
}
+ Future posponerEjecucion(
+ String alarmaId,
+ DateTime ejecucion,
+ int minutos,
+ ) async {
+ final snoozeHasta = _programacion.calcularSnooze(_reloj(), minutos);
+ return posponerEjecucionHasta(alarmaId, ejecucion, snoozeHasta);
+ }
+
+ Future 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 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 vacaciones,
List 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,
);
}
diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart
index 56eb6d3..0b7a951 100644
--- a/lib/servicios/servicio_alarmas_android.dart
+++ b/lib/servicios/servicio_alarmas_android.dart
@@ -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