feat(alarms): add native ringing service
This commit is contained in:
@@ -49,6 +49,11 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".PluriWaveAlarmService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Receptor de controles de media (auriculares, notificación) -->
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
|
||||
@@ -13,7 +13,16 @@ class AlarmScheduler(private val context: Context) {
|
||||
private val alarmManager =
|
||||
context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
fun scheduleAlarm(id: String, title: String, triggerAtMillis: Long, preNoticeAtMillis: Long) {
|
||||
fun scheduleAlarm(
|
||||
id: String,
|
||||
title: String,
|
||||
triggerAtMillis: Long,
|
||||
preNoticeAtMillis: Long,
|
||||
stationName: String?,
|
||||
stationUrl: String?,
|
||||
fallbackSound: String?,
|
||||
volume: Float
|
||||
) {
|
||||
Log.d(
|
||||
tag,
|
||||
"alarm.schedule id=$id title=$title triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis canExact=${canScheduleExactAlarms()}"
|
||||
@@ -25,6 +34,10 @@ class AlarmScheduler(private val context: Context) {
|
||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, stationName)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, stationUrl)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, fallbackSound)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, volume)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
@@ -64,12 +64,25 @@ class MainActivity : AudioServiceActivity() {
|
||||
val title = call.argument<String>("title") ?: "PluriWave"
|
||||
val triggerAtMillis = call.argument<Long>("triggerAtMillis")
|
||||
val preNoticeAtMillis = call.argument<Long>("preNoticeAtMillis") ?: 0L
|
||||
val stationName = call.argument<String>("stationName")
|
||||
val stationUrl = call.argument<String>("stationUrl")
|
||||
val fallbackSound = call.argument<String>("fallbackSound")
|
||||
val volume = call.argument<Number>("volume")?.toFloat() ?: 0.85f
|
||||
Log.d(tag, "alarm.channel scheduleAlarm id=$id triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis")
|
||||
if (id == null || triggerAtMillis == null) {
|
||||
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
|
||||
result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
|
||||
} else {
|
||||
alarmScheduler.scheduleAlarm(id, title, triggerAtMillis, preNoticeAtMillis)
|
||||
alarmScheduler.scheduleAlarm(
|
||||
id,
|
||||
title,
|
||||
triggerAtMillis,
|
||||
preNoticeAtMillis,
|
||||
stationName,
|
||||
stationUrl,
|
||||
fallbackSound,
|
||||
volume
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +92,7 @@ class MainActivity : AudioServiceActivity() {
|
||||
if (id == null) {
|
||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
||||
} else {
|
||||
PluriWaveAlarmService.stop(this, id)
|
||||
alarmScheduler.cancelAlarm(id)
|
||||
result.success(null)
|
||||
}
|
||||
@@ -89,10 +103,21 @@ class MainActivity : AudioServiceActivity() {
|
||||
if (id == null) {
|
||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
||||
} else {
|
||||
PluriWaveAlarmService.stop(this, id)
|
||||
alarmScheduler.dismissFireNotification(id)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
"stopNativeAlarmSound" -> {
|
||||
val id = call.argument<String>("id")
|
||||
Log.d(tag, "alarm.channel stopNativeAlarmSound id=$id")
|
||||
if (id == null) {
|
||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
||||
} else {
|
||||
PluriWaveAlarmService.stop(this, id)
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
"diagnostics" -> {
|
||||
Log.d(tag, "alarm.channel diagnostics")
|
||||
result.success(
|
||||
|
||||
@@ -22,6 +22,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_FIRE -> {
|
||||
PluriWaveAlarmService.start(context, intent)
|
||||
val launch = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
@@ -187,6 +188,10 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
const val EXTRA_ALARM_ID = "alarmId"
|
||||
const val EXTRA_ALARM_TITLE = "alarmTitle"
|
||||
const val EXTRA_ALARM_ACTION = "alarmAction"
|
||||
const val EXTRA_STATION_NAME = "stationName"
|
||||
const val EXTRA_STATION_URL = "stationUrl"
|
||||
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
|
||||
const val EXTRA_VOLUME = "volume"
|
||||
|
||||
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
|
||||
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.File
|
||||
|
||||
class PluriWaveAlarmService : Service() {
|
||||
private var player: MediaPlayer? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var activeAlarmId: String? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action
|
||||
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
||||
Log.d(TAG, "alarm.service onStartCommand action=$action id=$requestedId active=$activeAlarmId")
|
||||
|
||||
when (action) {
|
||||
ACTION_STOP -> {
|
||||
stopAlarm(requestedId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
|
||||
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startAlarm(intent: Intent?) {
|
||||
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
|
||||
if (activeAlarmId != null) {
|
||||
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
|
||||
return
|
||||
}
|
||||
activeAlarmId = alarmId
|
||||
|
||||
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
|
||||
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
|
||||
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
|
||||
|
||||
acquireWakeLock()
|
||||
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title))
|
||||
startAudio(alarmId, fallbackSound, volume)
|
||||
}
|
||||
|
||||
private fun startAudio(alarmId: String, fallbackSound: String?, volume: Float) {
|
||||
player?.release()
|
||||
player = null
|
||||
|
||||
val source = fallbackAssetPath(fallbackSound)
|
||||
try {
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
)
|
||||
isLooping = true
|
||||
setVolume(volume, volume)
|
||||
setFallbackAssetDataSource(this, fallbackSound)
|
||||
setOnPreparedListener {
|
||||
it.start()
|
||||
Log.d(TAG, "alarm.service audio started id=$alarmId source=$source")
|
||||
}
|
||||
setOnErrorListener { mp, what, extra ->
|
||||
Log.e(TAG, "alarm.service audio error id=$alarmId what=$what extra=$extra source=$source")
|
||||
mp.reset()
|
||||
true
|
||||
}
|
||||
prepareAsync()
|
||||
}
|
||||
Log.d(TAG, "alarm.service audio preparing id=$alarmId source=$source")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service audio prepare failed id=$alarmId source=$source", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAlarm(alarmId: String?) {
|
||||
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
|
||||
try {
|
||||
player?.stop()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "alarm.service stop player failed", error)
|
||||
}
|
||||
player?.release()
|
||||
player = null
|
||||
activeAlarmId = null
|
||||
releaseWakeLock()
|
||||
if (alarmId != null) {
|
||||
NotificationManagerCompat.from(this).cancel(
|
||||
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun buildNotification(alarmId: String, title: String) =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||
.setContentTitle("Alarma PluriWave")
|
||||
.setContentText(title)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title), true)
|
||||
.setContentIntent(openAlarmPendingIntent(alarmId, title))
|
||||
.addAction(0, "Detener", stopPendingIntent(alarmId))
|
||||
.build()
|
||||
|
||||
private fun openAlarmPendingIntent(alarmId: String, title: String): PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
requestCode(alarmId, 20),
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
private fun stopPendingIntent(alarmId: String): PendingIntent =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
requestCode(alarmId, 21),
|
||||
Intent(this, PluriWaveAlarmService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"PluriWave:AlarmWakeLock"
|
||||
).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(10 * 60 * 1000L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
try {
|
||||
if (wakeLock?.isHeld == true) wakeLock?.release()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "alarm.service wakeLock release failed", error)
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun setFallbackAssetDataSource(mediaPlayer: MediaPlayer, sound: String?) {
|
||||
val path = fallbackAssetPath(sound)
|
||||
try {
|
||||
val descriptor = assets.openFd(path)
|
||||
mediaPlayer.setDataSource(
|
||||
descriptor.fileDescriptor,
|
||||
descriptor.startOffset,
|
||||
descriptor.length
|
||||
)
|
||||
descriptor.close()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "alarm.service asset descriptor failed path=$path; copying to cache", error)
|
||||
val cached = File(cacheDir, path.substringAfterLast('/'))
|
||||
assets.open(path).use { input ->
|
||||
cached.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
mediaPlayer.setDataSource(cached.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallbackAssetPath(sound: String?): String {
|
||||
val fileName = when (sound) {
|
||||
"campanaSuave" -> "alarm_campana_suave.wav"
|
||||
"pulsoDigital" -> "alarm_pulso_digital.wav"
|
||||
else -> "alarm_amanecer.wav"
|
||||
}
|
||||
return "flutter_assets/assets/audio/$fileName"
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopAlarm(activeAlarmId)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PluriWave"
|
||||
private const val CHANNEL_ID = "pluriwave_alarm_native"
|
||||
private const val NOTIFICATION_ID = 92841
|
||||
private const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
|
||||
|
||||
fun start(context: Context, source: Intent) {
|
||||
ensureChannel(context)
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
||||
putExtras(source)
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
Log.d(TAG, "alarm.service start requested")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service start failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context, alarmId: String) {
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
}
|
||||
try {
|
||||
context.startService(intent)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Alarma musical",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Sonido de alarma musical con pantalla apagada"
|
||||
enableVibration(true)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user