Coverage Summary for Class: PlayerService (com.vsevolodganin.clicktrack.player)
Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
PlayerService |
0%
(0/18)
|
0%
(0/24)
|
0%
(0/84)
|
0%
(0/671)
|
PlayerService$audioFocusComponent$2 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/1)
|
0%
(0/12)
|
PlayerService$audioFocusComponent$3 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/2)
|
0%
(0/13)
|
PlayerService$Companion |
0%
(0/6)
|
0%
(0/2)
|
0%
(0/21)
|
0%
(0/113)
|
PlayerService$foregroundComponent$2 |
0%
(0/1)
|
12.5%
(1/8)
|
0%
(0/14)
|
0%
(0/128)
|
PlayerService$foregroundComponent$2$2 |
0%
(0/1)
|
|
0%
(0/4)
|
0%
(0/9)
|
PlayerService$foregroundComponent$2$3 |
0%
(0/1)
|
|
0%
(0/7)
|
0%
(0/40)
|
PlayerService$foregroundComponent$2$invokeSuspend$$inlined$map$1 |
0%
(0/2)
|
|
PlayerService$foregroundComponent$2$invokeSuspend$$inlined$map$1$2 |
0%
(0/1)
|
|
PlayerService$foregroundComponent$2$invokeSuspend$$inlined$map$1$2$1 |
|
PlayerService$initializePlayer$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/22)
|
PlayerService$initializePlayer$1$2 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/22)
|
PlayerService$initializePlayer$1$3 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/22)
|
PlayerService$onCreate$1$1 |
0%
(0/4)
|
0%
(0/4)
|
0%
(0/4)
|
0%
(0/45)
|
PlayerService$playbackComponent$2 |
0%
(0/1)
|
|
0%
(0/2)
|
0%
(0/27)
|
PlayerService$playbackComponent$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/24)
|
PlayerService$playbackComponent$2$1$2 |
0%
(0/1)
|
16.7%
(1/6)
|
0%
(0/27)
|
0%
(0/135)
|
PlayerService$playbackComponent$2$1$invokeSuspend$$inlined$map$1 |
0%
(0/2)
|
|
PlayerService$playbackComponent$2$1$invokeSuspend$$inlined$map$1$2 |
0%
(0/1)
|
|
PlayerService$playbackComponent$2$1$invokeSuspend$$inlined$map$1$2$1 |
|
PlayerService$playbackComponent$2$2 |
0%
(0/2)
|
|
0%
(0/2)
|
0%
(0/33)
|
PlayerService$playbackComponent$2$2$2 |
0%
(0/1)
|
0%
(0/4)
|
0%
(0/8)
|
0%
(0/66)
|
PlayerService$playbackComponent$2$2$2$invokeSuspend$$inlined$map$1 |
0%
(0/2)
|
|
PlayerService$playbackComponent$2$2$2$invokeSuspend$$inlined$map$1$2 |
0%
(0/1)
|
|
PlayerService$playbackComponent$2$2$2$invokeSuspend$$inlined$map$1$2$1 |
|
PlayerService$playbackComponent$2$2$invokeSuspend$$inlined$map$1 |
0%
(0/2)
|
|
PlayerService$playbackComponent$2$2$invokeSuspend$$inlined$map$1$2 |
0%
(0/1)
|
|
PlayerService$playbackComponent$2$2$invokeSuspend$$inlined$map$1$2$1 |
|
PlayerService$State |
0%
(0/2)
|
0%
(0/2)
|
0%
(0/6)
|
0%
(0/109)
|
PlayerService$State$Companion |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/2)
|
Total |
0%
(0/57)
|
3.7%
(2/54)
|
0%
(0/187)
|
0%
(0/1493)
|
package com.vsevolodganin.clicktrack.player
import android.Manifest
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.FLAG_FOREGROUND_SERVICE
import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC
import androidx.core.app.ServiceCompat
import androidx.core.content.res.ResourcesCompat
import androidx.media.app.NotificationCompat.MediaStyle
import com.vsevolodganin.clicktrack.R
import com.vsevolodganin.clicktrack.applicationComponent
import com.vsevolodganin.clicktrack.di.component.PlayerServiceComponent
import com.vsevolodganin.clicktrack.di.component.create
import com.vsevolodganin.clicktrack.model.ClickSoundsId
import com.vsevolodganin.clicktrack.model.ClickTrackId
import com.vsevolodganin.clicktrack.model.PlayableId
import com.vsevolodganin.clicktrack.model.TwoLayerPolyrhythmId
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import com.vsevolodganin.clicktrack.multiplatform.R as MR
class PlayerService : Service() {
companion object {
fun start(context: Context, id: PlayableId, atProgress: Double?, soundsId: ClickSoundsId?) {
val arguments = State(id, atProgress, soundsId, isPaused = false)
val intent = serviceIntent(context).apply {
action = ACTION_START
putExtra(EXTRA_START_ARGUMENTS, Json.encodeToString(arguments))
}
context.startService(intent)
}
fun pause(context: Context) {
context.startService(
serviceIntent(context).apply {
action = ACTION_PAUSE
},
)
}
fun resume(context: Context) {
context.startService(
serviceIntent(context).apply {
action = ACTION_RESUME
},
)
}
fun stop(context: Context) {
context.startService(
serviceIntent(context).apply {
action = ACTION_STOP
},
)
}
fun bind(context: Context, serviceConnection: ServiceConnection) {
if (!context.bindService(serviceIntent(context), serviceConnection, BIND_AUTO_CREATE)) {
throw RuntimeException("Wasn't able to connect to player service")
}
}
private fun serviceIntent(context: Context): Intent = Intent(context, PlayerService::class.java)
private const val EXTRA_START_ARGUMENTS = "start_arguments"
private const val ACTION_START = "start"
private const val ACTION_STOP = "stop"
private const val ACTION_PAUSE = "pause"
private const val ACTION_RESUME = "resume"
private const val TAG = "PlayerService"
}
@Serializable
private data class State(
val id: PlayableId,
val startAtProgress: Double?,
val soundsId: ClickSoundsId?,
val isPaused: Boolean,
)
private lateinit var component: PlayerServiceComponent
private lateinit var mediaSession: MediaSessionCompat
private var isNotificationDisplayed = false
private val pendingIntentFlags by lazy(LazyThreadSafetyMode.NONE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
}
private val stopIntent by lazy(LazyThreadSafetyMode.NONE) {
PendingIntent.getService(this, 0, serviceIntent(this).apply { action = ACTION_STOP }, pendingIntentFlags)
}
private val pauseIntent by lazy(LazyThreadSafetyMode.NONE) {
PendingIntent.getService(this, 0, serviceIntent(this).apply { action = ACTION_PAUSE }, pendingIntentFlags)
}
private val resumeIntent by lazy(LazyThreadSafetyMode.NONE) {
PendingIntent.getService(this, 0, serviceIntent(this).apply { action = ACTION_RESUME }, pendingIntentFlags)
}
private val state = MutableStateFlow<State?>(null)
override fun onCreate() {
super.onCreate()
component = PlayerServiceComponent::class.create(applicationComponent)
mediaSession = MediaSessionCompat(this@PlayerService, "ClickTrackMediaSession").apply {
setPlaybackState(mediaSessionPlaybackStateBuilder().build())
setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
state.update { it?.copy(isPaused = false) }
}
override fun onPause() {
state.update { it?.copy(isPaused = true) }
}
override fun onStop() {
state.value = null
}
})
}
initializePlayer()
component.latencyTracker.start()
}
override fun onDestroy() {
super.onDestroy()
mediaSession.release()
disposePlayer()
component.latencyTracker.stop()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> state.value = intent.getStringExtra(EXTRA_START_ARGUMENTS)?.let(Json.Default::decodeFromString)
?: throw RuntimeException("No start arguments were supplied")
ACTION_STOP -> state.value = null
ACTION_PAUSE -> state.update { it?.copy(isPaused = true) }
ACTION_RESUME -> state.update { it?.copy(isPaused = false) }
else -> {
component.logger.logError(TAG, "Undefined intent received = $intent, flags = $flags, startId = $startId")
state.value = null
}
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder = PlayerServiceBinder(component.player.playbackState())
private fun initializePlayer() {
component.scope.apply {
launch { audioFocusComponent() }
launch { foregroundComponent() }
launch { playbackComponent() }
}
}
private fun disposePlayer() = component.scope.cancel()
private suspend fun audioFocusComponent() {
combine(
component.userPreferences.ignoreAudioFocus.flow,
component.audioFocusManager.hasFocus(),
) { ignoreAudioFocus, hasFocus -> ignoreAudioFocus || hasFocus }
.collect { isAllowedToPlay ->
if (!isAllowedToPlay) {
state.emit(null)
}
}
}
private suspend fun foregroundComponent() {
state.collectLatest { args ->
if (args != null) {
when (val id = args.id) {
is ClickTrackId -> {
when (val tapIntent = component.intentFactory.navigate(id)) {
null -> stopForeground()
else -> {
component.playableContentProvider.clickTrackFlow(id)
.filterNotNull()
.map { it.name }
.distinctUntilChanged()
.collectLatest { name ->
startForeground(
contentText = name,
tapIntent = tapIntent,
isPaused = args.isPaused,
)
}
}
}
}
TwoLayerPolyrhythmId -> {
component.playableContentProvider.twoLayerPolyrhythmFlow()
.collectLatest { polyrhythm ->
startForeground(
contentText = getString(
MR.string.player_service_notification_polyrhythm_title,
polyrhythm.layer1,
polyrhythm.layer2,
),
tapIntent = component.intentFactory.navigatePolyrhythms(),
isPaused = args.isPaused,
)
}
}
}
} else {
stopForeground()
}
}
}
private suspend fun playbackComponent() = coroutineScope {
launch {
state.map { it?.isPaused }.distinctUntilChanged().collectLatest { isPaused ->
when (isPaused) {
true -> {
mediaSession.apply {
isActive = true
setPlaybackState(
mediaSessionPlaybackStateBuilder()
.setState(PlaybackStateCompat.STATE_PAUSED, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
.build(),
)
}
component.player.pause()
}
false -> {
mediaSession.apply {
isActive = true
setPlaybackState(
mediaSessionPlaybackStateBuilder()
.setState(PlaybackStateCompat.STATE_PLAYING, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
.build(),
)
}
component.player.resume()
}
null -> {
mediaSession.apply {
isActive = false
setPlaybackState(
mediaSessionPlaybackStateBuilder()
.setState(PlaybackStateCompat.STATE_STOPPED, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
.build(),
)
}
}
}
}
}
launch {
fun State.toPlayerInput() = Player.Input(id, startAtProgress, soundsId)
state.map { it != null }.distinctUntilChanged().collectLatest { startPlay ->
if (startPlay && requestAudioFocus()) {
component.player.play(
state
.filterNotNull()
.map(State::toPlayerInput)
.distinctUntilChanged(),
)
}
component.audioFocusManager.releaseAudioFocus()
state.emit(null)
}
}
}
private fun startForeground(contentText: String, tapIntent: Intent, isPaused: Boolean) {
val launchAppIntent = PendingIntent.getActivity(this, 0, tapIntent, pendingIntentFlags)
val notificationBuilder = NotificationCompat.Builder(this, component.notificationChannels.playingNow)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ResourcesCompat.getColor(resources, MR.color.debug_signature, null))
.setColorized(true)
.setContentTitle(getString(MR.string.player_service_notification_playing_now))
.setContentText(contentText)
.setContentIntent(launchAppIntent)
.setDeleteIntent(stopIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setVisibility(VISIBILITY_PUBLIC)
.setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE)
.setOngoing(true)
.addAction(R.drawable.ic_notification_stop, getString(MR.string.player_service_notification_stop), stopIntent)
.run {
if (isPaused) {
addAction(R.drawable.ic_notification_play, getString(MR.string.player_service_notification_resume), resumeIntent)
} else {
addAction(R.drawable.ic_notification_pause, getString(MR.string.player_service_notification_pause), pauseIntent)
}
}
.setStyle(
MediaStyle()
.setShowCancelButton(true)
.setCancelButtonIntent(stopIntent)
.setShowActionsInCompactView(0, 1),
)
if (isNotificationDisplayed) {
val notification = notificationBuilder
.build()
.apply { flags = flags or FLAG_FOREGROUND_SERVICE }
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
component.notificationManager.notify(R.id.notification_playing_now, notification)
}
} else {
val notification = notificationBuilder.build()
startForeground(R.id.notification_playing_now, notification)
isNotificationDisplayed = true
}
}
private fun stopForeground() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
isNotificationDisplayed = false
}
private fun requestAudioFocus(): Boolean {
return component.userPreferences.ignoreAudioFocus.value || component.audioFocusManager.requestAudioFocus()
}
private fun mediaSessionPlaybackStateBuilder(): PlaybackStateCompat.Builder {
return PlaybackStateCompat.Builder()
.setActions(PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_STOP)
}
}