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:
parent
a9bde4ffc7
commit
650d39d598
3 changed files with 164 additions and 13 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
app/src/main/res/drawable/ic_notification.xml
Normal file
11
app/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue