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 package net.jeena.pacer
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
// Stub — full implementation in Theme 4 (US-08)
class PacerService : Service() { 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 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 package net.jeena.pacer
import android.app.Application
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue 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) var bpm by mutableIntStateOf(120)
private set private set
@ -17,30 +18,26 @@ class PacerViewModel : ViewModel() {
var volume by mutableFloatStateOf(1f) var volume by mutableFloatStateOf(1f)
private set private set
var isPlaying by mutableStateOf(false) var isPlaying by mutableStateOf(PacerService.isRunning)
private set private set
fun togglePlayback() { fun togglePlayback() {
if (isPlaying) { if (isPlaying) {
audioEngine.stop() ctx.startService(PacerService.stopIntent(ctx))
isPlaying = false isPlaying = false
} else { } else {
audioEngine.start(bpm, volume) ctx.startService(PacerService.startIntent(ctx, bpm, volume))
isPlaying = true isPlaying = true
} }
} }
fun updateBpm(newBpm: Int) { fun updateBpm(newBpm: Int) {
bpm = newBpm bpm = newBpm
audioEngine.setBpm(newBpm) if (isPlaying) ctx.startService(PacerService.updateBpmIntent(ctx, newBpm))
} }
fun updateVolume(newVolume: Float) { fun updateVolume(newVolume: Float) {
volume = newVolume volume = newVolume
audioEngine.setVolume(newVolume) if (isPlaying) ctx.startService(PacerService.updateVolumeIntent(ctx, newVolume))
}
override fun onCleared() {
audioEngine.stop()
} }
} }

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>