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