From 650d39d598fc475e07f23292539703087b0842fe Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 9 Mar 2026 10:53:40 +0000 Subject: [PATCH] feat(US-08, US-09, US-10): implement foreground service with notification PacerService owns AudioEngine and a PARTIAL_WAKE_LOCK so audio survives screen lock and Doze during long walks. Foreground notification shows current BPM and a Stop action button. PacerViewModel becomes AndroidViewModel and communicates with the service via intents, keeping AudioEngine off the UI process. Service uses START_NOT_STICKY so it does not restart if killed. --- .../kotlin/net/jeena/pacer/PacerService.kt | 145 +++++++++++++++++- .../kotlin/net/jeena/pacer/PacerViewModel.kt | 21 ++- app/src/main/res/drawable/ic_notification.xml | 11 ++ 3 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 app/src/main/res/drawable/ic_notification.xml diff --git a/app/src/main/kotlin/net/jeena/pacer/PacerService.kt b/app/src/main/kotlin/net/jeena/pacer/PacerService.kt index 918552c..59e8459 100644 --- a/app/src/main/kotlin/net/jeena/pacer/PacerService.kt +++ b/app/src/main/kotlin/net/jeena/pacer/PacerService.kt @@ -1,10 +1,153 @@ package net.jeena.pacer +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.content.pm.ServiceInfo +import android.os.Build import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat -// Stub — full implementation in Theme 4 (US-08) class PacerService : Service() { + + companion object { + const val ACTION_START = "net.jeena.pacer.START" + const val ACTION_STOP = "net.jeena.pacer.STOP" + const val ACTION_UPDATE_BPM = "net.jeena.pacer.UPDATE_BPM" + const val ACTION_UPDATE_VOLUME = "net.jeena.pacer.UPDATE_VOLUME" + const val EXTRA_BPM = "bpm" + const val EXTRA_VOLUME = "volume" + private const val NOTIFICATION_ID = 1 + private const val CHANNEL_ID = "pacer" + + // Allows ViewModel to check running state without binding + var isRunning = false + private set + + fun startIntent(context: Context, bpm: Int, volume: Float) = + Intent(context, PacerService::class.java).apply { + action = ACTION_START + putExtra(EXTRA_BPM, bpm) + putExtra(EXTRA_VOLUME, volume) + } + + fun stopIntent(context: Context) = + Intent(context, PacerService::class.java).apply { action = ACTION_STOP } + + fun updateBpmIntent(context: Context, bpm: Int) = + Intent(context, PacerService::class.java).apply { + action = ACTION_UPDATE_BPM + putExtra(EXTRA_BPM, bpm) + } + + fun updateVolumeIntent(context: Context, volume: Float) = + Intent(context, PacerService::class.java).apply { + action = ACTION_UPDATE_VOLUME + putExtra(EXTRA_VOLUME, volume) + } + } + + private val audioEngine = AudioEngine() + private var wakeLock: PowerManager.WakeLock? = null + private var currentBpm = 120 + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + currentBpm = intent.getIntExtra(EXTRA_BPM, 120) + val volume = intent.getFloatExtra(EXTRA_VOLUME, 1f) + startPacing(currentBpm, volume) + } + ACTION_STOP -> stopPacing() + ACTION_UPDATE_BPM -> { + currentBpm = intent.getIntExtra(EXTRA_BPM, 120) + audioEngine.setBpm(currentBpm) + updateNotification(currentBpm) + } + ACTION_UPDATE_VOLUME -> { + audioEngine.setVolume(intent.getFloatExtra(EXTRA_VOLUME, 1f)) + } + } + return START_NOT_STICKY + } + + private fun startPacing(bpm: Int, volume: Float) { + isRunning = true + createNotificationChannel() + val notification = buildNotification(bpm) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + startForeground(NOTIFICATION_ID, notification) + } + acquireWakeLock() + audioEngine.start(bpm, volume) + } + + private fun stopPacing() { + audioEngine.stop() + releaseWakeLock() + isRunning = false + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun acquireWakeLock() { + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Pacer::AudioWakeLock") + wakeLock?.acquire() + } + + private fun releaseWakeLock() { + if (wakeLock?.isHeld == true) wakeLock?.release() + wakeLock = null + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Pacer", + NotificationManager.IMPORTANCE_LOW + ).apply { description = "Pacer pacing audio" } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + + private fun buildNotification(bpm: Int): android.app.Notification { + val stopPendingIntent = PendingIntent.getService( + this, 0, stopIntent(this), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val openAppPendingIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("Pacer") + .setContentText("Pacing at $bpm BPM") + .setContentIntent(openAppPendingIntent) + .addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent) + .setOngoing(true) + .setSilent(true) + .build() + } + + private fun updateNotification(bpm: Int) { + val nm = getSystemService(NotificationManager::class.java) + nm.notify(NOTIFICATION_ID, buildNotification(bpm)) + } + + override fun onDestroy() { + super.onDestroy() + stopPacing() + } } diff --git a/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt b/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt index 1f56513..ac1a1eb 100644 --- a/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt +++ b/app/src/main/kotlin/net/jeena/pacer/PacerViewModel.kt @@ -1,15 +1,16 @@ package net.jeena.pacer +import android.app.Application import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel -class PacerViewModel : ViewModel() { +class PacerViewModel(application: Application) : AndroidViewModel(application) { - private val audioEngine = AudioEngine() + private val ctx = application.applicationContext var bpm by mutableIntStateOf(120) private set @@ -17,30 +18,26 @@ class PacerViewModel : ViewModel() { var volume by mutableFloatStateOf(1f) private set - var isPlaying by mutableStateOf(false) + var isPlaying by mutableStateOf(PacerService.isRunning) private set fun togglePlayback() { if (isPlaying) { - audioEngine.stop() + ctx.startService(PacerService.stopIntent(ctx)) isPlaying = false } else { - audioEngine.start(bpm, volume) + ctx.startService(PacerService.startIntent(ctx, bpm, volume)) isPlaying = true } } fun updateBpm(newBpm: Int) { bpm = newBpm - audioEngine.setBpm(newBpm) + if (isPlaying) ctx.startService(PacerService.updateBpmIntent(ctx, newBpm)) } fun updateVolume(newVolume: Float) { volume = newVolume - audioEngine.setVolume(newVolume) - } - - override fun onCleared() { - audioEngine.stop() + if (isPlaying) ctx.startService(PacerService.updateVolumeIntent(ctx, newVolume)) } } diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..e16189b --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + +