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.
This commit is contained in:
Jeena 2026-03-09 10:53:40 +00:00
parent a9bde4ffc7
commit 650d39d598
3 changed files with 164 additions and 13 deletions

View file

@ -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()
}
}

View file

@ -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))
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Simple filled circle — white silhouette required for notification icons -->
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z" />
</vector>