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