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 map) { return EventoAlarmaAndroid( alarmaId: map['alarmId'] as String? ?? '', titulo: map['alarmTitle'] as String? ?? 'PluriWave', accion: map['alarmAction'] as String? ?? '', + triggerAtMillis: (map['triggerAtMillis'] as num?)?.toInt() ?? 0, + occurrenceAtMillis: (map['occurrenceAtMillis'] as num?)?.toInt() ?? 0, + snoozeMinutes: (map['snoozeMinutes'] as num?)?.toInt() ?? 5, ); } } @@ -29,12 +38,18 @@ class DiagnosticoAlarmasAndroid { const DiagnosticoAlarmasAndroid({ required this.puedeProgramarExactas, required this.notificacionesPermitidas, + required this.puedeUsarPantallaCompleta, + required this.ignoraOptimizacionBateria, + required this.alarmasNativasPendientes, required this.fabricante, required this.versionSdk, }); final bool puedeProgramarExactas; final bool notificacionesPermitidas; + final bool puedeUsarPantallaCompleta; + final bool ignoraOptimizacionBateria; + final int alarmasNativasPendientes; final String fabricante; final int versionSdk; @@ -42,13 +57,33 @@ class DiagnosticoAlarmasAndroid { return DiagnosticoAlarmasAndroid( puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true, notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true, + puedeUsarPantallaCompleta: + map['canUseFullScreenIntent'] as bool? ?? true, + ignoraOptimizacionBateria: + map['isIgnoringBatteryOptimizations'] as bool? ?? true, + alarmasNativasPendientes: map['nativePendingAlarmsCount'] as int? ?? 0, fabricante: map['manufacturer'] as String? ?? 'Android', versionSdk: map['sdkInt'] as int? ?? 0, ); } } -class ServicioAlarmasAndroid { +abstract class PuertoAlarmasAndroid { + Stream get eventosAlarma; + + Future programar(AlarmaMusical alarma); + Future cancelar(String alarmaId); + Future ocultarNotificacionAlarma(String alarmaId); + Future detenerSonidoNativo(String alarmaId); + Future solicitarPermisoAlarmasExactas(); + Future solicitarPermisoNotificaciones(); + Future solicitarPermisoPantallaCompleta(); + Future confirmarAudioFlutter(String alarmaId); + Future diagnostico(); + Future obtenerEventoInicial(); +} + +class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { ServicioAlarmasAndroid({ MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'), }) : _channel = channel { @@ -60,10 +95,12 @@ class ServicioAlarmasAndroid { StreamController.broadcast(); static bool _handlerInstalado = false; + @override Stream get eventosAlarma => _eventosController.stream; + @override Future programar(AlarmaMusical alarma) async { - final proxima = alarma.proximaEjecucion; + final proxima = alarma.proximaProgramable; if (proxima == null || !alarma.activa) { debugPrint( '[PluriWave][alarmas] cancelar por inactiva/sin proxima id=${alarma.id} activa=${alarma.activa} proxima=$proxima', @@ -79,7 +116,20 @@ class ServicioAlarmasAndroid { 'title': alarma.nombre, 'triggerAtMillis': proxima.millisecondsSinceEpoch, 'preNoticeAtMillis': - proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch, + alarma.snoozeHasta == null + ? proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch + : 0, + 'hour': alarma.hora, + 'minute': alarma.minuto, + 'scheduleType': alarma.tipoProgramacion.name, + 'weekdays': alarma.diasSemana, + 'oneShotDateMillis': alarma.fechaUnica?.millisecondsSinceEpoch, + 'snoozeUntilMillis': alarma.snoozeHasta?.millisecondsSinceEpoch, + 'snoozeOriginMillis': alarma.snoozeOrigen?.millisecondsSinceEpoch, + 'snoozeMinutes': alarma.snoozeMinutos, + 'lastHandledAtMillis': + alarma.ultimaEjecucionGestionada?.millisecondsSinceEpoch, + 'soundOnVacation': alarma.sonarEnVacaciones, 'stationName': alarma.emisora?.nombre, 'stationUrl': alarma.emisora?.url, 'fallbackSound': alarma.sonidoInterno.name, @@ -92,15 +142,23 @@ class ServicioAlarmasAndroid { } } + @override Future cancelar(String alarmaId) => _logAndInvokeVoid('cancelAlarm', {'id': alarmaId}); + @override Future ocultarNotificacionAlarma(String alarmaId) => _logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId}); + @override Future detenerSonidoNativo(String alarmaId) => _logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId}); + @override + Future confirmarAudioFlutter(String alarmaId) => + _logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId}); + + @override Future solicitarPermisoAlarmasExactas() async { final abierto = await _channel.invokeMethod( 'requestExactAlarmPermission', @@ -108,6 +166,23 @@ class ServicioAlarmasAndroid { return abierto ?? false; } + @override + Future solicitarPermisoNotificaciones() async { + final abierto = await _channel.invokeMethod( + 'requestPostNotificationsPermission', + ); + return abierto ?? false; + } + + @override + Future solicitarPermisoPantallaCompleta() async { + final abierto = await _channel.invokeMethod( + 'requestFullScreenIntentPermission', + ); + return abierto ?? false; + } + + @override Future diagnostico() async { debugPrint('[PluriWave][alarmas] diagnostico android'); final raw = await _channel.invokeMethod>( @@ -120,6 +195,7 @@ class ServicioAlarmasAndroid { return diag; } + @override Future obtenerEventoInicial() async { final raw = await _channel.invokeMethod>( 'getInitialAlarmIntent', diff --git a/lib/servicios/servicio_programacion_alarmas.dart b/lib/servicios/servicio_programacion_alarmas.dart index e5b833c..6aac66d 100644 --- a/lib/servicios/servicio_programacion_alarmas.dart +++ b/lib/servicios/servicio_programacion_alarmas.dart @@ -58,6 +58,23 @@ class ServicioProgramacionAlarmas { return desde.add(Duration(minutes: seguro)); } + DateTime? calcularSiguienteDespuesDeEjecucion({ + required AlarmaMusical alarma, + required DateTime ejecucion, + List vacaciones = const [], + List excepciones = const [], + }) { + if (!alarma.activa) return null; + if (alarma.tipoProgramacion == TipoProgramacionAlarma.unica) return null; + + return calcularProxima( + alarma: alarma.copyWith(limpiarSnooze: true), + desde: ejecucion.add(const Duration(minutes: 1)), + vacaciones: vacaciones, + excepciones: excepciones, + ); + } + bool estaEnVacaciones(DateTime fecha, List vacaciones) => vacaciones.any((rango) => rango.contiene(fecha)); diff --git a/pubspec.yaml b/pubspec.yaml index 8b69860..75157e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pluriwave description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium" publish_to: 'none' -version: 0.1.50+51 +version: 0.1.53+54 environment: sdk: ^3.7.0 diff --git a/test/estado/estado_alarmas_test.dart b/test/estado/estado_alarmas_test.dart new file mode 100644 index 0000000..7c9c82b --- /dev/null +++ b/test/estado/estado_alarmas_test.dart @@ -0,0 +1,196 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('posponer persiste snooze y refrescarProgramacion no lo pisa', () async { + var ahora = DateTime(2026, 5, 25, 7, 31); + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => ahora), + android: android, + iniciarAutomaticamente: false, + ); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + final alarma = estado.alarmas.single; + await estado.posponerAlarma(alarma, 5); + + expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36)); + expect(android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 36)); + + ahora = DateTime(2026, 5, 25, 7, 32); + await estado.refrescarProgramacion(); + + expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36)); + expect(android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 36)); + }); + + test('posponer desde preaviso mueve esta ejecucion desde la hora original', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas( + reloj: () => DateTime(2026, 5, 25, 7), + ), + android: android, + iniciarAutomaticamente: false, + ); + await estado.guardarAlarma( + AlarmaMusical( + id: 'pre1', + nombre: 'Preaviso', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2026, 5, 25, 7, 30), + ), + ); + + final alarma = estado.alarmas.single; + await estado.posponerProximaDesdePreaviso( + alarma, + 10, + DateTime(2026, 5, 25, 7, 30), + ); + + expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 40)); + expect(estado.alarmas.single.snoozeOrigen, DateTime(2026, 5, 25, 7, 30)); + expect(android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 40)); + }); + + test('finalizar diaria calcula siguiente dia y limpia snooze', () async { + final estado = EstadoAlarmas( + servicio: ServicioAlarmas( + reloj: () => DateTime(2026, 5, 25, 7, 31), + ), + android: FakePuertoAlarmasAndroid(), + iniciarAutomaticamente: false, + ); + await estado.guardarAlarma( + AlarmaMusical( + id: 'a2', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2026, 5, 25, 7, 30), + snoozeHasta: DateTime(2026, 5, 25, 7, 36), + snoozeOrigen: DateTime(2026, 5, 25, 7, 30), + ), + ); + + await estado.finalizarEjecucion('a2'); + + expect(estado.alarmas.single.snoozeHasta, isNull); + expect(estado.alarmas.single.proximaEjecucion, DateTime(2026, 5, 26, 7, 30)); + }); + + test('finalizar unica la desactiva y queda sin proxima ejecucion', () async { + final estado = EstadoAlarmas( + servicio: ServicioAlarmas( + reloj: () => DateTime(2026, 5, 25, 7, 31), + ), + android: FakePuertoAlarmasAndroid(), + iniciarAutomaticamente: false, + ); + await estado.guardarAlarma( + AlarmaMusical( + id: 'a3', + nombre: 'Unica', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.unica, + diasSemana: const [], + fechaUnica: DateTime(2026, 5, 25), + proximaEjecucion: DateTime(2026, 5, 25, 7, 30), + ), + ); + + await estado.finalizarEjecucion('a3'); + + expect(estado.alarmas.single.activa, isFalse); + expect(estado.alarmas.single.proximaEjecucion, isNull); + }); +} + +class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { + final programadas = []; + final canceladas = []; + final detenidas = []; + final ocultadas = []; + final _eventos = StreamController.broadcast(); + + @override + Stream get eventosAlarma => _eventos.stream; + + @override + Future programar(AlarmaMusical alarma) async { + programadas.add(alarma); + } + + @override + Future cancelar(String alarmaId) async { + canceladas.add(alarmaId); + } + + @override + Future detenerSonidoNativo(String alarmaId) async { + detenidas.add(alarmaId); + } + + @override + Future ocultarNotificacionAlarma(String alarmaId) async { + ocultadas.add(alarmaId); + } + + @override + Future confirmarAudioFlutter(String alarmaId) async { + detenidas.add(alarmaId); + } + + @override + Future diagnostico() async => + const DiagnosticoAlarmasAndroid( + puedeProgramarExactas: true, + notificacionesPermitidas: true, + puedeUsarPantallaCompleta: true, + ignoraOptimizacionBateria: true, + alarmasNativasPendientes: 0, + fabricante: 'test', + versionSdk: 35, + ); + + @override + Future obtenerEventoInicial() async => null; + + @override + Future solicitarPermisoAlarmasExactas() async => true; + + @override + Future solicitarPermisoNotificaciones() async => true; + + @override + Future solicitarPermisoPantallaCompleta() async => true; +} diff --git a/test/servicios/servicio_programacion_alarmas_test.dart b/test/servicios/servicio_programacion_alarmas_test.dart index 9b94372..30954ea 100644 --- a/test/servicios/servicio_programacion_alarmas_test.dart +++ b/test/servicios/servicio_programacion_alarmas_test.dart @@ -128,5 +128,63 @@ void main() { expect(proxima, DateTime(2026, 5, 23, 20, 13, 47)); }); + + test('calcula siguiente diaria despues de ejecucion completada', () { + final alarma = AlarmaMusical( + id: 'a6', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2026, 5, 25, 7, 30), + ); + + final siguiente = servicio.calcularSiguienteDespuesDeEjecucion( + alarma: alarma, + ejecucion: DateTime(2026, 5, 25, 7, 30), + ); + + expect(siguiente, DateTime(2026, 5, 26, 7, 30)); + }); + + test('calcula siguiente por dias de semana despues de ejecucion', () { + final alarma = AlarmaMusical( + id: 'a7', + nombre: 'Laboral', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diasSemana, + diasSemana: const [DateTime.monday, DateTime.wednesday], + proximaEjecucion: DateTime(2026, 5, 25, 8), + ); + + final siguiente = servicio.calcularSiguienteDespuesDeEjecucion( + alarma: alarma, + ejecucion: DateTime(2026, 5, 25, 8), + ); + + expect(siguiente, DateTime(2026, 5, 27, 8)); + }); + + test('alarma unica completada no calcula siguiente', () { + final alarma = AlarmaMusical( + id: 'a8', + nombre: 'Unica', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.unica, + diasSemana: const [], + fechaUnica: DateTime(2026, 5, 25), + proximaEjecucion: DateTime(2026, 5, 25, 8), + ); + + final siguiente = servicio.calcularSiguienteDespuesDeEjecucion( + alarma: alarma, + ejecucion: DateTime(2026, 5, 25, 8), + ); + + expect(siguiente, isNull); + }); }); }