Skip to content

Commit 053cff9

Browse files
authored
Merge pull request #1630 from theovilardo/fix/service-foreground-crash
Enhance media playback reliability and service lifecycle management
2 parents 2919310 + 31a9639 commit 053cff9

5 files changed

Lines changed: 171 additions & 41 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@
193193

194194
<!-- Media Button Receiver for external media controls (headphones, car, etc.) -->
195195
<receiver
196-
android:name="androidx.media.session.MediaButtonReceiver"
196+
android:name=".data.service.PixelPlayMediaButtonReceiver"
197197
android:exported="true">
198198
<intent-filter>
199199
<action android:name="android.intent.action.MEDIA_BUTTON" />

app/src/main/java/com/theveloper/pixelplay/data/service/CoilBitmapLoader.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.media3.common.util.UnstableApi
1010
import coil.imageLoader
1111
import coil.request.CachePolicy
1212
import coil.request.ImageRequest
13+
import coil.size.Precision
1314
import coil.size.Size
1415
import com.google.common.util.concurrent.ListenableFuture
1516
import com.google.common.util.concurrent.SettableFuture
@@ -19,6 +20,12 @@ import kotlinx.coroutines.launch
1920
@OptIn(UnstableApi::class)
2021
class CoilBitmapLoader(private val context: Context, private val scope: CoroutineScope) : BitmapLoader {
2122

23+
companion object {
24+
// Large enough for lock screen / media surfaces, but bounded so we never hand
25+
// unbounded original artwork to MediaSession/SystemUI IPC.
26+
private const val MAX_NOTIFICATION_ARTWORK_SIZE_PX = 1024
27+
}
28+
2229
override fun loadBitmap(uri: Uri): ListenableFuture<Bitmap> {
2330
return loadBitmapInternal(uri)
2431
}
@@ -34,9 +41,10 @@ class CoilBitmapLoader(private val context: Context, private val scope: Coroutin
3441
try {
3542
val request = ImageRequest.Builder(context)
3643
.data(data)
37-
// Let Media3 and System UI downscale from the real artwork instead of
38-
// forcing a 256 px thumbnail into the notification pipeline.
39-
.size(Size.ORIGINAL)
44+
// Preserve enough resolution for media surfaces while preventing huge
45+
// album art from destabilizing notification/SystemUI rendering.
46+
.size(MAX_NOTIFICATION_ARTWORK_SIZE_PX, MAX_NOTIFICATION_ARTWORK_SIZE_PX)
47+
.precision(Precision.INEXACT)
4048
.allowHardware(false) // Bitmap must not be hardware for MediaSession
4149
// Disable memory cache so Coil does not hold a second reference to this
4250
// bitmap. Without this, Coil may recycle the cached copy while Media3

app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import android.net.Uri
1313
import android.os.Build
1414
import android.os.Bundle
1515
import android.os.SystemClock
16-
import android.util.Log
1716
import androidx.core.app.NotificationCompat
1817
import androidx.glance.appwidget.GlanceAppWidgetManager
1918
import androidx.glance.appwidget.state.updateAppWidgetState
@@ -90,8 +89,10 @@ import kotlin.math.abs
9089
import java.io.ByteArrayOutputStream
9190
import java.net.HttpURLConnection
9291
import java.net.URL
92+
import java.util.concurrent.atomic.AtomicInteger
9393

9494
import javax.inject.Inject
95+
import androidx.core.net.toUri
9596

9697
// Acciones personalizadas para compatibilidad con el widget existente
9798

@@ -163,6 +164,7 @@ class MusicService : MediaLibraryService() {
163164
private var shouldResumeAfterHeadsetReconnect = false
164165
private var lastNoisyPauseRealtimeMs = 0L
165166
private var resumeOnHeadsetReconnectEnabled = false
167+
private var temporaryForegroundStartedInOnCreate = false
166168

167169
companion object {
168170
private const val TAG = "MusicService_PixelPlay"
@@ -171,6 +173,7 @@ class MusicService : MediaLibraryService() {
171173
const val EXTRA_FORCE_FOREGROUND_ON_START =
172174
"com.theveloper.pixelplay.extra.FORCE_FOREGROUND_ON_START"
173175
private const val PLAYBACK_SNAPSHOT_DEBOUNCE_MS = 350L
176+
private val pendingMediaButtonForegroundStarts = AtomicInteger(0)
174177

175178
private const val APP_PACKAGE_PREFIX = "com.theveloper.pixelplay"
176179
private val BLOCKED_WEAR_CONTROLLER_PREFIXES = listOf(
@@ -199,6 +202,30 @@ class MusicService : MediaLibraryService() {
199202
private const val DEFAULT_STREAM_BUFFER_SIZE = 8 * 1024
200203
private const val WIDGET_ART_FAILURE_RETRY_MS = 30_000L
201204
private const val HEADSET_RECONNECT_RESUME_WINDOW_MS = 15_000L
205+
206+
fun markPendingMediaButtonForegroundStart() {
207+
pendingMediaButtonForegroundStarts.incrementAndGet()
208+
}
209+
210+
fun unmarkPendingMediaButtonForegroundStart() {
211+
while (true) {
212+
val currentCount = pendingMediaButtonForegroundStarts.get()
213+
if (currentCount <= 0) return
214+
if (pendingMediaButtonForegroundStarts.compareAndSet(currentCount, currentCount - 1)) {
215+
return
216+
}
217+
}
218+
}
219+
220+
private fun consumePendingMediaButtonForegroundStart(): Boolean {
221+
while (true) {
222+
val currentCount = pendingMediaButtonForegroundStarts.get()
223+
if (currentCount <= 0) return false
224+
if (pendingMediaButtonForegroundStarts.compareAndSet(currentCount, currentCount - 1)) {
225+
return true
226+
}
227+
}
228+
}
202229
}
203230

204231
private val playerSwapListener: (Player) -> Unit = { newPlayer ->
@@ -239,6 +266,14 @@ class MusicService : MediaLibraryService() {
239266
}
240267

241268
super.onCreate()
269+
270+
// A MEDIA_BUTTON broadcast starts the foreground-service timeout before
271+
// MusicService reaches onStartCommand(). Promote as early as possible so
272+
// cold-start initialization cannot consume the whole timeout window.
273+
temporaryForegroundStartedInOnCreate = consumePendingMediaButtonForegroundStart()
274+
if (temporaryForegroundStartedInOnCreate) {
275+
startTemporaryForegroundForCommand()
276+
}
242277

243278
// Ensure engine is ready (re-initialize if service was restarted)
244279
engine.initialize()
@@ -747,19 +782,12 @@ class MusicService : MediaLibraryService() {
747782
pendingIntent,
748783
)
749784
}
750-
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
785+
} else
751786
alarmManager.setExactAndAllowWhileIdle(
752787
AlarmManager.RTC_WAKEUP,
753788
triggerAtMillis,
754789
pendingIntent,
755790
)
756-
} else {
757-
alarmManager.setExact(
758-
AlarmManager.RTC_WAKEUP,
759-
triggerAtMillis,
760-
pendingIntent,
761-
)
762-
}
763791
Timber.tag(TAG).d("Sleep timer set from Wear for %d minutes", minutes)
764792
} catch (e: SecurityException) {
765793
Timber.tag(TAG).w(e, "Exact alarm denied; using inexact sleep timer")
@@ -791,7 +819,6 @@ class MusicService : MediaLibraryService() {
791819
}
792820

793821
private fun startTemporaryForegroundForCommand() {
794-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
795822
val notification = NotificationCompat.Builder(
796823
this,
797824
PixelPlayApplication.NOTIFICATION_CHANNEL_ID
@@ -820,12 +847,18 @@ class MusicService : MediaLibraryService() {
820847
}
821848

822849
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
850+
val startedTemporaryForegroundInOnCreate = temporaryForegroundStartedInOnCreate
851+
temporaryForegroundStartedInOnCreate = false
852+
val pendingMediaButtonForegroundStart = consumePendingMediaButtonForegroundStart()
823853
val forcedForegroundStart =
824854
intent?.getBooleanExtra(EXTRA_FORCE_FOREGROUND_ON_START, false) == true
825855
val isMediaButtonIntent = intent?.action == Intent.ACTION_MEDIA_BUTTON
826856
val needsTemporaryForeground = forcedForegroundStart ||
827-
(isMediaButtonIntent && !isServiceAlreadyForeground())
828-
if (needsTemporaryForeground) {
857+
pendingMediaButtonForegroundStart ||
858+
(isMediaButtonIntent &&
859+
!startedTemporaryForegroundInOnCreate &&
860+
!isServiceAlreadyForeground())
861+
if (needsTemporaryForeground && !startedTemporaryForegroundInOnCreate) {
829862
startTemporaryForegroundForCommand()
830863
}
831864

@@ -855,7 +888,7 @@ class MusicService : MediaLibraryService() {
855888
if (songId != -1L) {
856889
val timeline = player.currentTimeline
857890
if (!timeline.isEmpty) {
858-
val window = androidx.media3.common.Timeline.Window()
891+
val window = Timeline.Window()
859892
for (i in 0 until timeline.windowCount) {
860893
timeline.getWindow(i, window)
861894
if (window.mediaItem.mediaId.toLongOrNull() == songId) {
@@ -1041,8 +1074,8 @@ class MusicService : MediaLibraryService() {
10411074
}
10421075

10431076
val mediaId = mediaItem.mediaId
1044-
val filePath = mediaItem.mediaMetadata?.extras
1045-
?.getString(com.theveloper.pixelplay.utils.MediaItemBuilder.EXTERNAL_EXTRA_FILE_PATH)
1077+
val filePath = mediaItem.mediaMetadata.extras
1078+
?.getString(MediaItemBuilder.EXTERNAL_EXTRA_FILE_PATH)
10461079

10471080
if (filePath.isNullOrBlank()) {
10481081
Timber.tag(TAG).d("ReplayGain: No file path for track, keeping user-selected volume")
@@ -1055,7 +1088,7 @@ class MusicService : MediaLibraryService() {
10551088
val useAlbumGain = replayGainUseAlbumGain
10561089
// Read ReplayGain tags on IO thread to avoid blocking main
10571090
replayGainJob = serviceScope.launch {
1058-
val rgValues = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
1091+
val rgValues = withContext(Dispatchers.IO) {
10591092
replayGainManager.readReplayGain(filePath)
10601093
}
10611094

@@ -1078,12 +1111,14 @@ class MusicService : MediaLibraryService() {
10781111
// Store for application after transition completes
10791112
pendingReplayGainVolume = volume
10801113
Timber.tag(TAG).d("ReplayGain: Stored pending volume=%.2f for %s (transition running)",
1081-
volume, mediaItem.mediaMetadata?.title)
1114+
volume, mediaItem.mediaMetadata.title
1115+
)
10821116
} else {
10831117
pendingReplayGainVolume = null
10841118
setPlayerVolume(player, volume)
10851119
Timber.tag(TAG).d("ReplayGain: Applied volume=%.2f for %s",
1086-
volume, mediaItem.mediaMetadata?.title)
1120+
volume, mediaItem.mediaMetadata.title
1121+
)
10871122
}
10881123
}
10891124
}
@@ -1614,12 +1649,11 @@ class MusicService : MediaLibraryService() {
16141649
val streamDurationMs = remoteClient.streamDuration.takeIf { it > 0L }
16151650
val effectiveDurationMs = (streamDurationMs ?: durationHintMs ?: 0L).coerceAtLeast(0L)
16161651
val imageUri = metadata
1617-
?.images
1618-
?.firstOrNull()
1619-
?.url
1620-
?.toString()
1621-
?.takeIf { it.isNotBlank() }
1622-
?.let { Uri.parse(it) }
1652+
?.images
1653+
?.firstOrNull()
1654+
?.url
1655+
?.toString()
1656+
?.takeIf { it.isNotBlank() }?.toUri()
16231657

16241658
val mappedRepeatMode = when (mediaStatus.queueRepeatMode) {
16251659
MediaStatus.REPEAT_MODE_REPEAT_SINGLE -> Player.REPEAT_MODE_ONE
@@ -1761,7 +1795,7 @@ class MusicService : MediaLibraryService() {
17611795
var currentPosition = 0L
17621796
var totalDuration = 0L
17631797
var snapshotWindowIndex = 0
1764-
var snapshotTimeline: androidx.media3.common.Timeline = androidx.media3.common.Timeline.EMPTY
1798+
var snapshotTimeline: Timeline = Timeline.EMPTY
17651799

17661800
withContext(Dispatchers.Main) {
17671801
currentItem = player.currentMediaItem
@@ -1782,10 +1816,10 @@ class MusicService : MediaLibraryService() {
17821816
var artworkData = currentItem?.mediaMetadata?.artworkData
17831817

17841818
resolveCastRemoteSnapshot()?.let { remote ->
1785-
if (!remote.title.isNullOrBlank()) {
1819+
if (remote.title.isNotBlank()) {
17861820
title = remote.title
17871821
}
1788-
if (!remote.artist.isNullOrBlank()) {
1822+
if (remote.artist.isNotBlank()) {
17891823
artist = remote.artist
17901824
}
17911825
if (!remote.songId.isNullOrBlank()) {
@@ -1873,7 +1907,7 @@ class MusicService : MediaLibraryService() {
18731907
val queueItems = mutableListOf<com.theveloper.pixelplay.data.model.QueueItem>()
18741908
// Reuse snapshotTimeline / snapshotWindowIndex captured at the top — no extra main-thread hop
18751909
if (!snapshotTimeline.isEmpty) {
1876-
val window = androidx.media3.common.Timeline.Window()
1910+
val window = Timeline.Window()
18771911

18781912
// Empezar desde la siguiente canción en la cola
18791913
val startIndex = if (snapshotWindowIndex + 1 < snapshotTimeline.windowCount) snapshotWindowIndex + 1 else 0
@@ -1936,7 +1970,6 @@ class MusicService : MediaLibraryService() {
19361970
if (artUriString.isNullOrBlank()) {
19371971
return@withContext null to artUriString
19381972
}
1939-
val safeArtUri = artUri ?: return@withContext null to artUriString
19401973

19411974
if (artUriString == cachedWidgetArtUri && cachedWidgetArtBytes != null) {
19421975
return@withContext cachedWidgetArtBytes to artUriString
@@ -1948,7 +1981,7 @@ class MusicService : MediaLibraryService() {
19481981
}
19491982
}
19501983

1951-
val loadedBytes = loadArtworkBytesForWidget(safeArtUri)
1984+
val loadedBytes = loadArtworkBytesForWidget(artUri)
19521985
if (loadedBytes != null) {
19531986
cachedWidgetArtUri = artUriString
19541987
cachedWidgetArtBytes = loadedBytes
@@ -1969,7 +2002,7 @@ class MusicService : MediaLibraryService() {
19692002
?.getString(MediaItemBuilder.EXTERNAL_EXTRA_ALBUM_ART)
19702003
?.takeIf { it.isNotBlank() }
19712004
?: return null
1972-
return runCatching { Uri.parse(extrasUri) }.getOrNull()
2005+
return runCatching { extrasUri.toUri() }.getOrNull()
19732006
}
19742007

19752008
private fun loadArtworkBytesForWidget(uri: Uri): ByteArray? {
@@ -2051,12 +2084,13 @@ class MusicService : MediaLibraryService() {
20512084
}
20522085

20532086
if (glanceIds.isNotEmpty() || barGlanceIds.isNotEmpty() || controlGlanceIds.isNotEmpty() || gridGlanceIds.isNotEmpty()) {
2054-
Log.d(TAG, "Widgets actualizados: ${playerInfo.songTitle} (Original: ${glanceIds.size}, Bar: ${barGlanceIds.size}, Control: ${controlGlanceIds.size})")
2087+
Timber.tag(TAG)
2088+
.d("Widgets actualizados: ${playerInfo.songTitle} (Original: ${glanceIds.size}, Bar: ${barGlanceIds.size}, Control: ${controlGlanceIds.size})")
20552089
} else {
2056-
Log.w(TAG, "No se encontraron widgets para actualizar")
2090+
Timber.tag(TAG).w("No se encontraron widgets para actualizar")
20572091
}
20582092
} catch (e: Exception) {
2059-
Log.e(TAG, "Error al actualizar el widget", e)
2093+
Timber.tag(TAG).e(e, "Error al actualizar el widget")
20602094
}
20612095
}
20622096

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.theveloper.pixelplay.data.service
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import androidx.annotation.OptIn
6+
import androidx.media.session.MediaButtonReceiver
7+
import androidx.media3.common.util.UnstableApi
8+
import timber.log.Timber
9+
10+
class PixelPlayMediaButtonReceiver : MediaButtonReceiver() {
11+
12+
@OptIn(UnstableApi::class)
13+
override fun onReceive(context: Context, intent: Intent) {
14+
if (intent.action != Intent.ACTION_MEDIA_BUTTON) {
15+
super.onReceive(context, intent)
16+
return
17+
}
18+
19+
MusicService.markPendingMediaButtonForegroundStart()
20+
try {
21+
super.onReceive(context, intent)
22+
} catch (throwable: Throwable) {
23+
MusicService.unmarkPendingMediaButtonForegroundStart()
24+
Timber.tag(TAG).w(throwable, "Media button dispatch failed before MusicService start")
25+
throw throwable
26+
}
27+
}
28+
29+
companion object {
30+
private const val TAG = "MediaButtonReceiver"
31+
}
32+
}

0 commit comments

Comments
 (0)