feat(alarms): native reliability fixes and end-to-end snooze
- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK) - Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed - Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels - Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV - Native fade-in volume ramp honoring fadeInSegundos when the app is killed - Request battery-optimization exemption once, tracked with a persisted asked-once flag - Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze - Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown - Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper) - Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0 - New alarm strings localized across all 13 locales - New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green) - SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
@@ -53,7 +54,7 @@
|
||||
|
||||
<service
|
||||
android:name=".PluriWaveAlarmService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:foregroundServiceType="mediaPlayback|systemExempted"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Receptor de controles de media (auriculares, notificación) -->
|
||||
|
||||
@@ -36,7 +36,10 @@ class AlarmScheduler(private val context: Context) {
|
||||
snoozeOriginMillis: Long? = null,
|
||||
lastHandledAtMillis: Long? = null,
|
||||
soundOnVacation: Boolean = true,
|
||||
snoozeMinutes: Int = 5
|
||||
snoozeMinutes: Int = 5,
|
||||
fallbackStationName: String? = null,
|
||||
fallbackStationUrl: String? = null,
|
||||
fadeInSegundos: Int = 0
|
||||
): Boolean {
|
||||
val existing = readSpec(id)
|
||||
val preservedSnooze = preserveNativeSnooze(
|
||||
@@ -62,8 +65,11 @@ class AlarmScheduler(private val context: Context) {
|
||||
snoozeMinutes = sanitizeSnoozeMinutes(snoozeMinutes),
|
||||
stationName = stationName,
|
||||
stationUrl = stationUrl,
|
||||
fallbackStationName = fallbackStationName,
|
||||
fallbackStationUrl = fallbackStationUrl,
|
||||
fallbackSound = fallbackSound,
|
||||
volume = volume.coerceIn(0f, 1f),
|
||||
fadeInSegundos = fadeInSegundos.coerceIn(0, 60),
|
||||
timezoneId = TimeZone.getDefault().id
|
||||
)
|
||||
return scheduleSpec(spec, persistOnSuccess = true)
|
||||
@@ -251,17 +257,36 @@ class AlarmScheduler(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun snooze(id: String, minutes: Int) {
|
||||
val spec = readSpec(id) ?: return
|
||||
/**
|
||||
* Snoozes using the SAME anchor as [postponeNext] (Design 2.2): the
|
||||
* occurrence time + minutes, clamped to now + minutes when the target is
|
||||
* already past. Returns the resulting snooze so the caller can report it
|
||||
* back to Flutter (single source of truth), or null if the spec is gone.
|
||||
*/
|
||||
fun snooze(id: String, minutes: Int): NativeSnoozeResult? {
|
||||
val spec = readSpec(id) ?: return null
|
||||
val safeMinutes = sanitizeSnoozeMinutes(minutes)
|
||||
val snoozeUntil = System.currentTimeMillis() + safeMinutes * 60_000L
|
||||
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.snooze id=$id minutes=$safeMinutes occurrence=$occurrenceAt until=$snoozeUntil"
|
||||
)
|
||||
scheduleSpec(
|
||||
spec.copy(
|
||||
snoozeUntilMillis = snoozeUntil,
|
||||
snoozeOriginMillis = spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
||||
snoozeOriginMillis = occurrenceAt,
|
||||
snoozeMinutes = safeMinutes
|
||||
),
|
||||
persistOnSuccess = true
|
||||
)
|
||||
return NativeSnoozeResult(
|
||||
snoozeUntilMillis = snoozeUntil,
|
||||
occurrenceAtMillis = occurrenceAt,
|
||||
title = spec.title
|
||||
)
|
||||
}
|
||||
|
||||
fun postponeNext(id: String, minutes: Int): Long? {
|
||||
@@ -339,6 +364,26 @@ class AlarmScheduler(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Active (future) native snoozes from the persisted specs, used by the
|
||||
* Flutter cold-start sync (Decision 2.1, engine-dead case) so a snooze
|
||||
* performed while the engine was down is imported on the next launch.
|
||||
*/
|
||||
fun nativeSnoozeStates(): List<Map<String, Any>> {
|
||||
val now = System.currentTimeMillis()
|
||||
return prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()
|
||||
.mapNotNull { id ->
|
||||
val spec = readSpec(id) ?: return@mapNotNull null
|
||||
val snoozeUntil = spec.snoozeUntilMillis ?: return@mapNotNull null
|
||||
if (snoozeUntil <= now) return@mapNotNull null
|
||||
mapOf(
|
||||
"alarmId" to spec.id,
|
||||
"snoozeUntilMillis" to snoozeUntil,
|
||||
"snoozeOriginMillis" to (spec.snoozeOriginMillis ?: spec.triggerAtMillis)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun preserveNativeSnooze(
|
||||
existing: NativeAlarmSpec?,
|
||||
requestedTriggerAtMillis: Long,
|
||||
@@ -494,8 +539,17 @@ class AlarmScheduler(private val context: Context) {
|
||||
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_STATION_NAME,
|
||||
spec.fallbackStationName
|
||||
)
|
||||
putExtra(
|
||||
PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL,
|
||||
spec.fallbackStationUrl
|
||||
)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, spec.fallbackSound)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, spec.volume)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, spec.fadeInSegundos)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
|
||||
putExtra(
|
||||
@@ -568,6 +622,13 @@ class AlarmScheduler(private val context: Context) {
|
||||
|
||||
private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot
|
||||
|
||||
/** Result of a native snooze, reported back to Flutter via alarmFired. */
|
||||
data class NativeSnoozeResult(
|
||||
val snoozeUntilMillis: Long,
|
||||
val occurrenceAtMillis: Long,
|
||||
val title: String
|
||||
)
|
||||
|
||||
private data class NativeAlarmSpec(
|
||||
val id: String,
|
||||
val title: String,
|
||||
@@ -586,12 +647,15 @@ class AlarmScheduler(private val context: Context) {
|
||||
val snoozeMinutes: Int,
|
||||
val stationName: String?,
|
||||
val stationUrl: String?,
|
||||
val fallbackStationName: String? = null,
|
||||
val fallbackStationUrl: String? = null,
|
||||
val fallbackSound: String?,
|
||||
val volume: Float,
|
||||
val fadeInSegundos: Int = 0,
|
||||
val timezoneId: String
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("schemaVersion", 2)
|
||||
put("schemaVersion", 3)
|
||||
put("id", id)
|
||||
put("title", title)
|
||||
put("enabled", enabled)
|
||||
@@ -609,8 +673,11 @@ class AlarmScheduler(private val context: Context) {
|
||||
put("snoozeMinutes", snoozeMinutes)
|
||||
put("stationName", stationName)
|
||||
put("stationUrl", stationUrl)
|
||||
put("fallbackStationName", fallbackStationName)
|
||||
put("fallbackStationUrl", fallbackStationUrl)
|
||||
put("fallbackSound", fallbackSound)
|
||||
put("volume", volume)
|
||||
put("fadeInSegundos", fadeInSegundos)
|
||||
put("timezoneId", timezoneId)
|
||||
}
|
||||
|
||||
@@ -639,8 +706,15 @@ class AlarmScheduler(private val context: Context) {
|
||||
},
|
||||
stationName = json.optString("stationName").takeIf { it.isNotBlank() },
|
||||
stationUrl = json.optString("stationUrl").takeIf { it.isNotBlank() },
|
||||
// schemaVersion 2 specs lack the v3 fields; default to null/0
|
||||
// so persisted alarms keep working after the upgrade.
|
||||
fallbackStationName = json.optString("fallbackStationName")
|
||||
.takeIf { it.isNotBlank() },
|
||||
fallbackStationUrl = json.optString("fallbackStationUrl")
|
||||
.takeIf { it.isNotBlank() },
|
||||
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
|
||||
volume = json.optDouble("volume", 0.85).toFloat(),
|
||||
fadeInSegundos = json.optInt("fadeInSegundos", 0).coerceIn(0, 60),
|
||||
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,10 @@ class MainActivity : AudioServiceActivity() {
|
||||
snoozeOriginMillis = call.argument<Long>("snoozeOriginMillis"),
|
||||
lastHandledAtMillis = call.argument<Long>("lastHandledAtMillis"),
|
||||
soundOnVacation = call.argument<Boolean>("soundOnVacation") ?: true,
|
||||
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5
|
||||
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5,
|
||||
fallbackStationName = call.argument<String>("fallbackStationName"),
|
||||
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
|
||||
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0
|
||||
)
|
||||
result.success(scheduled)
|
||||
}
|
||||
@@ -172,6 +175,10 @@ class MainActivity : AudioServiceActivity() {
|
||||
Log.d(tag, "alarm.channel requestFullScreenIntentPermission")
|
||||
result.success(requestFullScreenIntentPermission())
|
||||
}
|
||||
"requestIgnoreBatteryOptimizations" -> {
|
||||
Log.d(tag, "alarm.channel requestIgnoreBatteryOptimizations")
|
||||
result.success(requestIgnoreBatteryOptimizations())
|
||||
}
|
||||
"getInitialAlarmIntent" -> {
|
||||
val payload = alarmPayload(intent)
|
||||
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
|
||||
@@ -182,9 +189,14 @@ class MainActivity : AudioServiceActivity() {
|
||||
Log.d(tag, "alarm.channel getHandledAlarmOccurrences")
|
||||
result.success(alarmScheduler.handledOccurrences())
|
||||
}
|
||||
"getNativeSnoozeState" -> {
|
||||
Log.d(tag, "alarm.channel getNativeSnoozeState")
|
||||
result.success(alarmScheduler.nativeSnoozeStates())
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
activeInstance = this
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
@@ -310,6 +322,21 @@ class MainActivity : AudioServiceActivity() {
|
||||
return powerManager.isIgnoringBatteryOptimizations(packageName)
|
||||
}
|
||||
|
||||
private fun requestIgnoreBatteryOptimizations(): Boolean {
|
||||
if (isIgnoringBatteryOptimizations()) return true
|
||||
return try {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
}
|
||||
)
|
||||
true
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "alarm.channel requestIgnoreBatteryOptimizations failed", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDirectory(path: String): Boolean {
|
||||
val folder = File(path)
|
||||
if (!folder.exists()) {
|
||||
@@ -575,7 +602,37 @@ class MainActivity : AudioServiceActivity() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (activeInstance === this) {
|
||||
activeInstance = null
|
||||
}
|
||||
stopVisualizer()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STATIC_TAG = "PluriWave"
|
||||
|
||||
/** alarmAction reported when the native service snoozed by itself. */
|
||||
const val ALARM_ACTION_SNOOZED = "snoozed"
|
||||
|
||||
@Volatile
|
||||
private var activeInstance: MainActivity? = null
|
||||
|
||||
/**
|
||||
* Bridge for components without an activity (PluriWaveAlarmService):
|
||||
* forwards alarm events through the existing alarmFired MethodChannel
|
||||
* when the Flutter engine is alive; no-op when dead — the cold-start
|
||||
* sync (getNativeSnoozeState) covers that case (Decision 2.1).
|
||||
*/
|
||||
fun notifyAlarmEvent(payload: Map<String, Any?>) {
|
||||
val activity = activeInstance
|
||||
if (activity == null) {
|
||||
Log.d(STATIC_TAG, "alarm.channel notifyAlarmEvent skipped (engine dead)")
|
||||
return
|
||||
}
|
||||
activity.mainHandler.post {
|
||||
activity.alarmMethodChannel?.invokeMethod("alarmFired", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
putExtra(EXTRA_OCCURRENCE_AT, intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L))
|
||||
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
||||
}
|
||||
showFireNotification(context, alarmId, title, launch, snoozeMinutes)
|
||||
// The service's startForeground notification (single FSI owner) is
|
||||
// posted by PluriWaveAlarmService.start above; the receiver must NOT
|
||||
// post a duplicate fire notification.
|
||||
try {
|
||||
context.startActivity(launch)
|
||||
Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId")
|
||||
@@ -92,46 +94,6 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFireNotification(
|
||||
context: Context,
|
||||
alarmId: String,
|
||||
title: String,
|
||||
launch: Intent,
|
||||
snoozeMinutes: Int
|
||||
) {
|
||||
ensureFireChannel(context)
|
||||
val fullScreenIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
requestCode(alarmId, 10),
|
||||
launch,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, FIRE_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||
.setContentTitle("Alarma PluriWave")
|
||||
.setContentText(title)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setContentIntent(fullScreenIntent)
|
||||
.setFullScreenIntent(fullScreenIntent, true)
|
||||
.addAction(0, "Posponer", snoozePendingIntent(context, alarmId, snoozeMinutes))
|
||||
.addAction(0, "Detener", stopPendingIntent(context, alarmId))
|
||||
.build()
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(
|
||||
fireNotificationIdForAlarm(alarmId),
|
||||
notification,
|
||||
)
|
||||
Log.d(TAG, "alarm.notification fire shown id=$alarmId")
|
||||
} catch (error: SecurityException) {
|
||||
Log.e(TAG, "alarm.notification fire SecurityException id=$alarmId", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPreNoticeNotification(
|
||||
context: Context,
|
||||
alarmId: String,
|
||||
@@ -200,23 +162,6 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureFireChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val existing = manager.getNotificationChannel(FIRE_CHANNEL_ID)
|
||||
if (existing != null) return
|
||||
|
||||
val channel = NotificationChannel(
|
||||
FIRE_CHANNEL_ID,
|
||||
"Alarmas sonando",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Pantalla urgente cuando una alarma musical debe sonar"
|
||||
enableVibration(true)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -240,33 +185,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
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
|
||||
)
|
||||
|
||||
private fun stopPendingIntent(context: Context, alarmId: String): PendingIntent =
|
||||
PendingIntent.getService(
|
||||
context,
|
||||
requestCode(alarmId, 40),
|
||||
Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = PluriWaveAlarmService.ACTION_STOP
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TAG = "PluriWave"
|
||||
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
|
||||
const val FIRE_CHANNEL_ID = "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_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
|
||||
@@ -276,8 +197,11 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
const val EXTRA_ALARM_ACTION = "alarmAction"
|
||||
const val EXTRA_STATION_NAME = "stationName"
|
||||
const val EXTRA_STATION_URL = "stationUrl"
|
||||
const val EXTRA_FALLBACK_STATION_NAME = "fallbackStationName"
|
||||
const val EXTRA_FALLBACK_STATION_URL = "fallbackStationUrl"
|
||||
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
|
||||
const val EXTRA_VOLUME = "volume"
|
||||
const val EXTRA_FADE_IN_SECONDS = "fadeInSegundos"
|
||||
const val EXTRA_TRIGGER_AT = "triggerAtMillis"
|
||||
const val EXTRA_OCCURRENCE_AT = "occurrenceAtMillis"
|
||||
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
@@ -14,18 +15,31 @@ import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Foreground service that owns native alarm audio and the single ringing
|
||||
* notification (NOTIFICATION_ID, full-screen intent).
|
||||
*
|
||||
* Fade-in ownership boundary: this service ramps volume ONLY for its own
|
||||
* MediaPlayer audio (station stream, fallback station or bundled WAV). The
|
||||
* Flutter ringing screen owns the fade for the audio it starts itself
|
||||
* (radio handler / local fallback player). They never play the same source
|
||||
* simultaneously: the service stops once Flutter confirms its own audio via
|
||||
* confirmFlutterAudio.
|
||||
*/
|
||||
class PluriWaveAlarmService : Service() {
|
||||
private var player: MediaPlayer? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var activeAlarmId: String? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var stationFallbackRunnable: Runnable? = null
|
||||
private var fadeInRunnable: Runnable? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
@@ -42,7 +56,23 @@ class PluriWaveAlarmService : Service() {
|
||||
ACTION_SNOOZE -> {
|
||||
val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5)
|
||||
if (requestedId != null) {
|
||||
AlarmScheduler(this).snooze(requestedId, minutes)
|
||||
val snoozed = AlarmScheduler(this).snooze(requestedId, minutes)
|
||||
if (snoozed != null) {
|
||||
// D1 fix (Decision 2.1): report the native snooze back to
|
||||
// Flutter so the canonical config records it. If the engine
|
||||
// is dead this is a no-op and the cold-start sync
|
||||
// (getNativeSnoozeState) reconciles on next launch.
|
||||
MainActivity.notifyAlarmEvent(
|
||||
mapOf(
|
||||
"alarmId" to requestedId,
|
||||
"alarmTitle" to snoozed.title,
|
||||
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
|
||||
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
|
||||
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
|
||||
"snoozeMinutes" to minutes
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
stopAlarm(requestedId)
|
||||
return START_NOT_STICKY
|
||||
@@ -64,15 +94,33 @@ class PluriWaveAlarmService : Service() {
|
||||
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 fallbackStationName =
|
||||
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_NAME)
|
||||
val fallbackStationUrl =
|
||||
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL)
|
||||
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
|
||||
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
|
||||
val fadeInSegundos =
|
||||
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, 0).coerceIn(0, 60)
|
||||
val snoozeMinutes = sanitizeSnoozeMinutes(
|
||||
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
|
||||
)
|
||||
|
||||
acquireWakeLock()
|
||||
// The FSI notification must be visible BEFORE audio prepares (prepareAsync is
|
||||
// slow); startForeground runs first so the ringing surface never lags audio.
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title, stationName, snoozeMinutes))
|
||||
val notification = buildNotification(alarmId, title, stationName, snoozeMinutes)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
|
||||
releaseWakeLock()
|
||||
@@ -80,46 +128,85 @@ class PluriWaveAlarmService : Service() {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
startAudio(alarmId, stationName, stationUrl, fallbackSound, volume)
|
||||
startAudio(
|
||||
alarmId,
|
||||
stationName,
|
||||
stationUrl,
|
||||
fallbackStationName,
|
||||
fallbackStationUrl,
|
||||
fallbackSound,
|
||||
volume,
|
||||
fadeInSegundos
|
||||
)
|
||||
}
|
||||
|
||||
private fun startAudio(
|
||||
alarmId: String,
|
||||
stationName: String?,
|
||||
stationUrl: String?,
|
||||
fallbackStationName: String?,
|
||||
fallbackStationUrl: String?,
|
||||
fallbackSound: String?,
|
||||
volume: Float
|
||||
volume: Float,
|
||||
fadeInSegundos: Int
|
||||
) {
|
||||
player?.release()
|
||||
player = null
|
||||
|
||||
if (!stationUrl.isNullOrBlank()) {
|
||||
startStationAudio(
|
||||
alarmId,
|
||||
stationName,
|
||||
stationUrl.trim(),
|
||||
fallbackSound,
|
||||
volume
|
||||
)
|
||||
return
|
||||
// Three-stage ordered fallback: primary station -> fallback station -> bundled WAV.
|
||||
// Each stage owns its own 15s timeout window via scheduleStationFallback.
|
||||
val startBundled: (String) -> Unit = { reason ->
|
||||
startFallbackAudio(alarmId, fallbackSound, volume, fadeInSegundos, reason)
|
||||
}
|
||||
val startFallbackStation: (String) -> Unit = { reason ->
|
||||
if (fallbackStationUrl.isNullOrBlank()) {
|
||||
startBundled(reason)
|
||||
} else {
|
||||
startStationAudio(
|
||||
alarmId,
|
||||
fallbackStationName,
|
||||
fallbackStationUrl.trim(),
|
||||
volume,
|
||||
fadeInSegundos,
|
||||
"fallback-station",
|
||||
startBundled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
startFallbackAudio(alarmId, fallbackSound, volume, "station url missing")
|
||||
if (stationUrl.isNullOrBlank()) {
|
||||
startFallbackStation("station url missing")
|
||||
return
|
||||
}
|
||||
startStationAudio(
|
||||
alarmId,
|
||||
stationName,
|
||||
stationUrl.trim(),
|
||||
volume,
|
||||
fadeInSegundos,
|
||||
"station",
|
||||
startFallbackStation
|
||||
)
|
||||
}
|
||||
|
||||
private fun startStationAudio(
|
||||
alarmId: String,
|
||||
stationName: String?,
|
||||
stationUrl: String,
|
||||
fallbackSound: String?,
|
||||
volume: Float
|
||||
volume: Float,
|
||||
fadeInSegundos: Int,
|
||||
stage: String,
|
||||
onStageFailed: (String) -> Unit
|
||||
) {
|
||||
scheduleStationFallback(alarmId, fallbackSound, volume)
|
||||
player?.release()
|
||||
player = null
|
||||
scheduleStationFallback(alarmId, stage, onStageFailed)
|
||||
val startVolume = initialVolume(volume, fadeInSegundos)
|
||||
try {
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(alarmAudioAttributes())
|
||||
isLooping = false
|
||||
setVolume(volume, volume)
|
||||
setVolume(startVolume, startVolume)
|
||||
setDataSource(
|
||||
this@PluriWaveAlarmService,
|
||||
Uri.parse(stationUrl),
|
||||
@@ -129,33 +216,34 @@ class PluriWaveAlarmService : Service() {
|
||||
if (activeAlarmId != alarmId) return@setOnPreparedListener
|
||||
cancelStationFallback()
|
||||
it.start()
|
||||
startFadeIn(alarmId, it, volume, fadeInSegundos)
|
||||
Log.d(
|
||||
TAG,
|
||||
"alarm.service station started id=$alarmId station=$stationName url=$stationUrl"
|
||||
"alarm.service $stage 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")
|
||||
Log.w(TAG, "alarm.service $stage completed id=$alarmId url=$stationUrl")
|
||||
onStageFailed("$stage completed")
|
||||
}
|
||||
setOnErrorListener { mp, what, extra ->
|
||||
Log.e(
|
||||
TAG,
|
||||
"alarm.service station error id=$alarmId what=$what extra=$extra url=$stationUrl"
|
||||
"alarm.service $stage error id=$alarmId what=$what extra=$extra url=$stationUrl"
|
||||
)
|
||||
runCatching { mp.reset() }
|
||||
if (activeAlarmId == alarmId) {
|
||||
startFallbackAudio(alarmId, fallbackSound, volume, "station error")
|
||||
onStageFailed("$stage error")
|
||||
}
|
||||
true
|
||||
}
|
||||
prepareAsync()
|
||||
}
|
||||
Log.d(TAG, "alarm.service station preparing id=$alarmId station=$stationName url=$stationUrl")
|
||||
Log.d(TAG, "alarm.service $stage 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")
|
||||
Log.e(TAG, "alarm.service $stage prepare failed id=$alarmId url=$stationUrl", error)
|
||||
onStageFailed("$stage prepare failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +251,7 @@ class PluriWaveAlarmService : Service() {
|
||||
alarmId: String,
|
||||
fallbackSound: String?,
|
||||
volume: Float,
|
||||
fadeInSegundos: Int,
|
||||
reason: String
|
||||
) {
|
||||
cancelStationFallback()
|
||||
@@ -170,15 +259,17 @@ class PluriWaveAlarmService : Service() {
|
||||
player = null
|
||||
|
||||
val source = fallbackAssetPath(fallbackSound)
|
||||
val startVolume = initialVolume(volume, fadeInSegundos)
|
||||
try {
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(alarmAudioAttributes())
|
||||
isLooping = true
|
||||
setVolume(volume, volume)
|
||||
setVolume(startVolume, startVolume)
|
||||
setFallbackAssetDataSource(this, fallbackSound)
|
||||
setOnPreparedListener {
|
||||
if (activeAlarmId != alarmId) return@setOnPreparedListener
|
||||
it.start()
|
||||
startFadeIn(alarmId, it, volume, fadeInSegundos)
|
||||
Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason")
|
||||
}
|
||||
setOnErrorListener { mp, what, extra ->
|
||||
@@ -196,20 +287,61 @@ class PluriWaveAlarmService : Service() {
|
||||
|
||||
private fun scheduleStationFallback(
|
||||
alarmId: String,
|
||||
fallbackSound: String?,
|
||||
volume: Float
|
||||
stage: String,
|
||||
onStageFailed: (String) -> Unit
|
||||
) {
|
||||
cancelStationFallback()
|
||||
val runnable = Runnable {
|
||||
if (activeAlarmId == alarmId) {
|
||||
Log.w(TAG, "alarm.service station timeout id=$alarmId; using fallback")
|
||||
startFallbackAudio(alarmId, fallbackSound, volume, "station timeout")
|
||||
Log.w(TAG, "alarm.service $stage timeout id=$alarmId; advancing audio chain")
|
||||
onStageFailed("$stage timeout")
|
||||
}
|
||||
}
|
||||
stationFallbackRunnable = runnable
|
||||
mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS)
|
||||
}
|
||||
|
||||
private fun initialVolume(volume: Float, fadeInSegundos: Int): Float =
|
||||
if (fadeInSegundos > 0) {
|
||||
(volume * FADE_IN_START_FRACTION).coerceIn(0f, 1f)
|
||||
} else {
|
||||
volume
|
||||
}
|
||||
|
||||
private fun startFadeIn(
|
||||
alarmId: String,
|
||||
mediaPlayer: MediaPlayer,
|
||||
targetVolume: Float,
|
||||
fadeInSegundos: Int
|
||||
) {
|
||||
cancelFadeIn()
|
||||
if (fadeInSegundos <= 0) return
|
||||
val steps = ((fadeInSegundos * 1000L) / FADE_IN_STEP_MILLIS).toInt().coerceAtLeast(1)
|
||||
val startVolume = initialVolume(targetVolume, fadeInSegundos)
|
||||
var step = 0
|
||||
val runnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (activeAlarmId != alarmId) return
|
||||
step++
|
||||
val fraction = step.toFloat() / steps
|
||||
val current = (startVolume + (targetVolume - startVolume) * fraction)
|
||||
.coerceIn(0f, 1f)
|
||||
runCatching { mediaPlayer.setVolume(current, current) }
|
||||
if (step < steps) {
|
||||
mainHandler.postDelayed(this, FADE_IN_STEP_MILLIS)
|
||||
}
|
||||
}
|
||||
}
|
||||
fadeInRunnable = runnable
|
||||
mainHandler.postDelayed(runnable, FADE_IN_STEP_MILLIS)
|
||||
Log.d(TAG, "alarm.service fade-in started id=$alarmId seconds=$fadeInSegundos steps=$steps")
|
||||
}
|
||||
|
||||
private fun cancelFadeIn() {
|
||||
fadeInRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
fadeInRunnable = null
|
||||
}
|
||||
|
||||
private fun cancelStationFallback() {
|
||||
stationFallbackRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
stationFallbackRunnable = null
|
||||
@@ -224,6 +356,7 @@ class PluriWaveAlarmService : Service() {
|
||||
private fun stopAlarm(alarmId: String?) {
|
||||
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
|
||||
cancelStationFallback()
|
||||
cancelFadeIn()
|
||||
try {
|
||||
player?.stop()
|
||||
} catch (error: Throwable) {
|
||||
@@ -371,12 +504,18 @@ class PluriWaveAlarmService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PluriWave"
|
||||
private const val CHANNEL_ID = "pluriwave_alarm_native"
|
||||
private const val CHANNEL_ID = "pluriwave_alarm_fire_v2"
|
||||
private const val LEGACY_CHANNEL_NATIVE = "pluriwave_alarm_native"
|
||||
private const val LEGACY_CHANNEL_FIRE = "pluriwave_alarm_fire"
|
||||
private const val CHANNELS_PREFS = "pluriwave_alarm_channels"
|
||||
private const val KEY_CHANNELS_MIGRATED_V2 = "channels_migrated_v2"
|
||||
private const val NOTIFICATION_ID = 92841
|
||||
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
|
||||
private const val FADE_IN_STEP_MILLIS = 250L
|
||||
private const val FADE_IN_START_FRACTION = 0.05f
|
||||
|
||||
fun start(context: Context, source: Intent) {
|
||||
ensureChannel(context)
|
||||
@@ -413,18 +552,39 @@ class PluriWaveAlarmService : Service() {
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
migrateLegacyChannels(context, manager)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Alarma musical",
|
||||
"Alarmas sonando",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Sonido de alarma musical con pantalla apagada"
|
||||
description = "Sonido y pantalla urgente cuando una alarma musical debe sonar"
|
||||
enableVibration(true)
|
||||
setSound(
|
||||
Settings.System.DEFAULT_ALARM_ALERT_URI,
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
// Android locks channel sound at creation time; the only way to apply
|
||||
// USAGE_ALARM on existing installs is deleting the legacy channels and
|
||||
// recreating under the versioned id. Runs once, guarded by a flag.
|
||||
private fun migrateLegacyChannels(context: Context, manager: NotificationManager) {
|
||||
val prefs = context.createDeviceProtectedStorageContext()
|
||||
.getSharedPreferences(CHANNELS_PREFS, Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean(KEY_CHANNELS_MIGRATED_V2, false)) return
|
||||
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_NATIVE) }
|
||||
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE) }
|
||||
prefs.edit().putBoolean(KEY_CHANNELS_MIGRATED_V2, true).apply()
|
||||
Log.d(TAG, "alarm.service legacy notification channels migrated to v2")
|
||||
}
|
||||
|
||||
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user