Skip to content

Commit e841349

Browse files
StaehliJMGaetan89
andauthored
Don't crash when spritesheet can't be downloaded. (#893)
Co-authored-by: Gaëtan Muller <[email protected]>
1 parent 474e793 commit e841349

File tree

8 files changed

+236
-34
lines changed

8 files changed

+236
-34
lines changed

pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoader.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import ch.srgssr.pillarbox.player.tracker.MediaItemTracker
3737
import ch.srgssr.pillarbox.player.tracker.MutableMediaItemTrackerData
3838
import kotlinx.serialization.SerializationException
3939
import java.io.IOException
40+
import kotlin.coroutines.CoroutineContext
4041

4142
/**
4243
* Creates an [SRGAssetLoader] instance using a DSL-style configuration.
@@ -100,6 +101,7 @@ class SRGAssetLoader internal constructor(
100101
private val customMediaMetadata: (suspend MediaMetadata.Builder.(MediaMetadata, Chapter, MediaComposition) -> Unit)?,
101102
private val resourceSelector: ResourceSelector,
102103
private val spriteSheetLoader: SpriteSheetLoader,
104+
private val spriteSheetLoaderCoroutineContext: CoroutineContext,
103105
) : AssetLoader(
104106
mediaSourceFactory = DefaultMediaSourceFactory(AkamaiTokenDataSource.Factory(akamaiTokenProvider, dataSourceFactory))
105107
) {
@@ -153,7 +155,7 @@ class SRGAssetLoader internal constructor(
153155
.build()
154156
val contentMediaSource = mediaSourceFactory.createMediaSource(loadingMediaItem)
155157
val mediaSource = chapter.spriteSheet?.let {
156-
MergingMediaSource(contentMediaSource, SpriteSheetMediaSource(it, loadingMediaItem, spriteSheetLoader))
158+
MergingMediaSource(contentMediaSource, SpriteSheetMediaSource(it, loadingMediaItem, spriteSheetLoader, spriteSheetLoaderCoroutineContext))
157159
} ?: contentMediaSource
158160
return Asset(
159161
mediaSource = mediaSource,

pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoaderConfig.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import ch.srgssr.pillarbox.player.tracker.MediaItemTracker
2828
import ch.srgssr.pillarbox.player.tracker.MutableMediaItemTrackerData
2929
import kotlinx.coroutines.Dispatchers
3030
import okhttp3.OkHttpClient
31+
import kotlin.coroutines.CoroutineContext
3132

3233
/**
3334
* Configuration class for [SRGAssetLoader].
@@ -53,7 +54,8 @@ class SRGAssetLoaderConfig internal constructor(context: Context) {
5354
private var commanderActTrackerFactory: MediaItemTracker.Factory<CommandersActTracker.Data> =
5455
CommandersActTracker.Factory(SRGAnalytics.commandersAct, Dispatchers.Default)
5556
private var comscoreTrackerFactory: MediaItemTracker.Factory<ComScoreTracker.Data> = ComScoreTracker.Factory()
56-
private var spriteSheetLoader: SpriteSheetLoader = SpriteSheetLoader.Default()
57+
private var spriteSheetLoader: SpriteSheetLoader = SpriteSheetLoader.Default
58+
private var spriteSheetLoaderCoroutineContext: CoroutineContext = Dispatchers.IO
5759

5860
@VisibleForTesting
5961
internal fun commanderActTrackerFactory(commanderActTrackerFactory: MediaItemTracker.Factory<CommandersActTracker.Data>) {
@@ -165,6 +167,12 @@ class SRGAssetLoaderConfig internal constructor(context: Context) {
165167
this.spriteSheetLoader = spriteSheetLoader
166168
}
167169

170+
@VisibleForTesting
171+
internal fun spriteSheetLoader(spriteSheetLoader: SpriteSheetLoader, coroutineContext: CoroutineContext) {
172+
spriteSheetLoader(spriteSheetLoader)
173+
this.spriteSheetLoaderCoroutineContext = coroutineContext
174+
}
175+
168176
internal fun create(): SRGAssetLoader {
169177
return SRGAssetLoader(
170178
akamaiTokenProvider = akamaiTokenProvider,
@@ -175,7 +183,8 @@ class SRGAssetLoaderConfig internal constructor(context: Context) {
175183
comscoreTrackerFactory = comscoreTrackerFactory,
176184
mediaCompositionService = mediaCompositionService,
177185
resourceSelector = ResourceSelector(),
178-
spriteSheetLoader = spriteSheetLoader
186+
spriteSheetLoader = spriteSheetLoader,
187+
spriteSheetLoaderCoroutineContext = spriteSheetLoaderCoroutineContext
179188
)
180189
}
181190
}

pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SpriteSheetLoader.kt

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ package ch.srgssr.pillarbox.core.business.source
77
import android.graphics.Bitmap
88
import android.graphics.BitmapFactory
99
import ch.srgssr.pillarbox.core.business.integrationlayer.data.SpriteSheet
10-
import kotlinx.coroutines.CoroutineDispatcher
11-
import kotlinx.coroutines.Dispatchers
12-
import kotlinx.coroutines.MainScope
13-
import kotlinx.coroutines.launch
1410
import java.net.URL
1511

1612
/**
@@ -24,20 +20,18 @@ fun interface SpriteSheetLoader {
2420
* Load sprite sheet
2521
*
2622
* @param spriteSheet The [SpriteSheet] to load the [Bitmap] from.
27-
* @param onComplete The callback to call when the [Bitmap] has been loaded. Passing `null` means that the [Bitmap] could not be loaded.
23+
* @return The [Result] of the loading operation.
2824
*/
29-
fun loadSpriteSheet(spriteSheet: SpriteSheet, onComplete: (Bitmap?) -> Unit)
25+
suspend fun loadSpriteSheet(spriteSheet: SpriteSheet): Result<Bitmap>
3026

3127
/**
3228
* Default
33-
*
34-
* @param dispatcher The [CoroutineDispatcher] to use for loading the sprite sheet. Should not be on the main thread.
35-
*/
36-
class Default(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) : SpriteSheetLoader {
37-
override fun loadSpriteSheet(spriteSheet: SpriteSheet, onComplete: (Bitmap?) -> Unit) {
38-
MainScope().launch(dispatcher) {
29+
**/
30+
object Default : SpriteSheetLoader {
31+
override suspend fun loadSpriteSheet(spriteSheet: SpriteSheet): Result<Bitmap> {
32+
return runCatching {
3933
URL(spriteSheet.url).openStream().use {
40-
onComplete(BitmapFactory.decodeStream(it))
34+
BitmapFactory.decodeStream(it)
4135
}
4236
}
4337
}

pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SpriteSheetMediaPeriod.kt

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package ch.srgssr.pillarbox.core.business.source
66

77
import android.graphics.Bitmap
8+
import androidx.annotation.VisibleForTesting
89
import androidx.media3.common.C
910
import androidx.media3.common.Format
1011
import androidx.media3.common.MimeTypes
@@ -18,9 +19,12 @@ import androidx.media3.exoplayer.source.SampleStream
1819
import androidx.media3.exoplayer.source.TrackGroupArray
1920
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
2021
import ch.srgssr.pillarbox.core.business.integrationlayer.data.SpriteSheet
22+
import kotlinx.coroutines.CoroutineScope
23+
import kotlinx.coroutines.Job
24+
import kotlinx.coroutines.launch
2125
import java.io.ByteArrayOutputStream
22-
import java.io.IOException
2326
import java.util.concurrent.atomic.AtomicBoolean
27+
import kotlin.coroutines.CoroutineContext
2428
import kotlin.math.max
2529
import kotlin.time.Duration.Companion.milliseconds
2630

@@ -30,8 +34,10 @@ import kotlin.time.Duration.Companion.milliseconds
3034
internal class SpriteSheetMediaPeriod(
3135
private val spriteSheet: SpriteSheet,
3236
private val spriteSheetLoader: SpriteSheetLoader,
37+
private val coroutineContext: CoroutineContext,
3338
) : MediaPeriod {
34-
private var bitmap: Bitmap? = null
39+
@VisibleForTesting
40+
internal var bitmap: Bitmap? = null
3541
private val isLoading = AtomicBoolean(true)
3642
private val format = Format.Builder()
3743
.setId("SpriteSheet")
@@ -43,20 +49,25 @@ internal class SpriteSheetMediaPeriod(
4349
.build()
4450
private val tracks = TrackGroupArray(TrackGroup("sprite-sheet-srg", format))
4551
private var positionUs = 0L
46-
52+
private var currentLoadingJob: Job? = null
4753
override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) {
48-
callback.onPrepared(this)
4954
this.positionUs = positionUs
55+
currentLoadingJob?.cancel()
5056
isLoading.set(true)
51-
spriteSheetLoader.loadSpriteSheet(spriteSheet) { bitmap ->
52-
this.bitmap = bitmap
57+
bitmap = null
58+
callback.onPrepared(this)
59+
currentLoadingJob = CoroutineScope(coroutineContext).launch {
60+
val result = spriteSheetLoader.loadSpriteSheet(spriteSheet)
61+
bitmap = result.getOrNull()
5362
isLoading.set(false)
5463
}
5564
}
5665

5766
fun releasePeriod() {
58-
bitmap?.recycle()
67+
currentLoadingJob?.cancel()
68+
currentLoadingJob = null
5969
bitmap = null
70+
isLoading.set(false)
6071
}
6172

6273
override fun selectTracks(
@@ -127,9 +138,7 @@ internal class SpriteSheetMediaPeriod(
127138
}
128139

129140
override fun maybeThrowError() {
130-
if (bitmap == null && !isLoading.get()) {
131-
throw IOException("Can't decode ${spriteSheet.url}")
132-
}
141+
// Nothing if no image, there will be no image
133142
}
134143

135144
@Suppress("ReturnCount")

pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SpriteSheetMediaSource.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.media3.exoplayer.source.MediaSource
1313
import androidx.media3.exoplayer.source.SinglePeriodTimeline
1414
import androidx.media3.exoplayer.upstream.Allocator
1515
import ch.srgssr.pillarbox.core.business.integrationlayer.data.SpriteSheet
16+
import kotlin.coroutines.CoroutineContext
1617
import kotlin.time.Duration.Companion.milliseconds
1718

1819
/**
@@ -21,11 +22,13 @@ import kotlin.time.Duration.Companion.milliseconds
2122
* @param spriteSheet The [SpriteSheet] to build thumbnails.
2223
* @param mediaItem The [MediaItem].
2324
* @param spriteSheetLoader The [SpriteSheetLoader] to use to load a [Bitmap] from a [SpriteSheet].
25+
* @param coroutineContext The [CoroutineContext] to use for loading the [Bitmap].
2426
*/
2527
internal class SpriteSheetMediaSource(
2628
private val spriteSheet: SpriteSheet,
2729
private val mediaItem: MediaItem,
2830
private val spriteSheetLoader: SpriteSheetLoader,
31+
private val coroutineContext: CoroutineContext,
2932
) : BaseMediaSource() {
3033

3134
override fun getMediaItem(): MediaItem {
@@ -35,7 +38,7 @@ internal class SpriteSheetMediaSource(
3538
override fun maybeThrowSourceInfoRefreshError() = Unit
3639

3740
override fun createPeriod(id: MediaSource.MediaPeriodId, allocator: Allocator, startPositionUs: Long): MediaPeriod {
38-
return SpriteSheetMediaPeriod(spriteSheet, spriteSheetLoader)
41+
return SpriteSheetMediaPeriod(spriteSheet, spriteSheetLoader, coroutineContext)
3942
}
4043

4144
override fun releasePeriod(mediaPeriod: MediaPeriod) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright (c) SRG SSR. All rights reserved.
3+
* License information is available from the LICENSE file.
4+
*/
5+
package ch.srgssr.pillarbox.core.business.source
6+
7+
import androidx.test.ext.junit.runners.AndroidJUnit4
8+
import ch.srgssr.pillarbox.core.business.integrationlayer.data.SpriteSheet
9+
import junit.framework.TestCase.assertTrue
10+
import kotlinx.coroutines.ExperimentalCoroutinesApi
11+
import kotlinx.coroutines.test.runTest
12+
import org.junit.runner.RunWith
13+
import kotlin.test.Test
14+
import kotlin.test.assertNotNull
15+
16+
@OptIn(ExperimentalCoroutinesApi::class)
17+
@RunWith(AndroidJUnit4::class)
18+
class DefaultSpriteSheetLoaderTest {
19+
20+
@Test
21+
fun `malformed image url`() = runTest {
22+
val spriteSheet = SpriteSheet(
23+
urn = "urn:123",
24+
rows = 10,
25+
columns = 10,
26+
thumbnailHeight = 10,
27+
thumbnailWidth = 10,
28+
interval = 10,
29+
url = "not_valid url"
30+
)
31+
32+
val result = SpriteSheetLoader.Default.loadSpriteSheet(spriteSheet)
33+
assertTrue(result.isFailure)
34+
}
35+
36+
@Test
37+
fun `image url returns http 404`() = runTest {
38+
val spriteSheet = SpriteSheet(
39+
urn = "urn:123",
40+
rows = 10,
41+
columns = 10,
42+
thumbnailHeight = 10,
43+
thumbnailWidth = 10,
44+
interval = 10,
45+
url = "https://www.server.com/noimage.png"
46+
)
47+
48+
val result = SpriteSheetLoader.Default.loadSpriteSheet(spriteSheet)
49+
assertTrue(result.isFailure)
50+
}
51+
52+
@Suppress("MaxLineLength")
53+
@Test
54+
fun `valid image url`() = runTest {
55+
val spriteSheet = SpriteSheet(
56+
urn = "urn:123",
57+
rows = 10,
58+
columns = 10,
59+
thumbnailHeight = 10,
60+
thumbnailWidth = 10,
61+
interval = 10,
62+
url = "https://il.srgssr.ch/spritesheet/urn/srf/video/881be9c2-65ec-4fa9-ba4a-926d15d046ef/sprite-881be9c2-65ec-4fa9-ba4a-926d15d046ef.jpeg"
63+
)
64+
65+
val result = SpriteSheetLoader.Default.loadSpriteSheet(spriteSheet)
66+
assertTrue(result.isSuccess)
67+
assertNotNull(result.getOrNull())
68+
}
69+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright (c) SRG SSR. All rights reserved.
3+
* License information is available from the LICENSE file.
4+
*/
5+
package ch.srgssr.pillarbox.core.business.source
6+
7+
import android.os.Looper
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import ch.srgssr.pillarbox.core.business.integrationlayer.data.SpriteSheet
10+
import io.mockk.clearAllMocks
11+
import io.mockk.mockk
12+
import kotlinx.coroutines.Dispatchers
13+
import kotlinx.coroutines.ExperimentalCoroutinesApi
14+
import kotlinx.coroutines.test.TestDispatcher
15+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
16+
import kotlinx.coroutines.test.advanceUntilIdle
17+
import kotlinx.coroutines.test.resetMain
18+
import kotlinx.coroutines.test.runTest
19+
import kotlinx.coroutines.test.setMain
20+
import org.junit.runner.RunWith
21+
import org.robolectric.Shadows.shadowOf
22+
import kotlin.test.AfterTest
23+
import kotlin.test.BeforeTest
24+
import kotlin.test.Test
25+
import kotlin.test.assertFalse
26+
import kotlin.test.assertNotNull
27+
import kotlin.test.assertNull
28+
29+
@OptIn(ExperimentalCoroutinesApi::class)
30+
@RunWith(AndroidJUnit4::class)
31+
class SpriteSheetMediaPeriodTest {
32+
private val spriteSheetLoader = SpriteSheetLoader.Default
33+
private lateinit var testDispatcher: TestDispatcher
34+
35+
@BeforeTest
36+
fun init() {
37+
testDispatcher = UnconfinedTestDispatcher()
38+
Dispatchers.setMain(testDispatcher)
39+
}
40+
41+
@AfterTest
42+
fun release() {
43+
Dispatchers.resetMain()
44+
shadowOf(Looper.getMainLooper()).idle()
45+
clearAllMocks()
46+
}
47+
48+
@Test
49+
fun `prepare doesn't crash with malformed url`() = runTest(testDispatcher) {
50+
val spriteSheet = SpriteSheet(
51+
urn = "urn:123",
52+
rows = 10,
53+
columns = 10,
54+
thumbnailHeight = 10,
55+
thumbnailWidth = 10,
56+
interval = 10,
57+
url = "not_valid url"
58+
)
59+
60+
val mediaPeriod = SpriteSheetMediaPeriod(spriteSheet = spriteSheet, spriteSheetLoader = spriteSheetLoader, testDispatcher)
61+
mediaPeriod.prepare(mockk(relaxed = true), 1L)
62+
advanceUntilIdle()
63+
64+
assertFalse(mediaPeriod.isLoading)
65+
assertNull(mediaPeriod.bitmap)
66+
}
67+
68+
@Test
69+
fun `prepare doesn't crash with image url producing 404 error`() = runTest(testDispatcher) {
70+
val spriteSheet = SpriteSheet(
71+
urn = "urn:123",
72+
rows = 10,
73+
columns = 10,
74+
thumbnailHeight = 10,
75+
thumbnailWidth = 10,
76+
interval = 10,
77+
url = "https://www.server.com/noimage.png"
78+
)
79+
80+
val mediaPeriod = SpriteSheetMediaPeriod(spriteSheet = spriteSheet, spriteSheetLoader = spriteSheetLoader, testDispatcher)
81+
mediaPeriod.prepare(mockk(relaxed = true), 1L)
82+
advanceUntilIdle()
83+
84+
assertFalse(mediaPeriod.isLoading)
85+
assertNull(mediaPeriod.bitmap)
86+
}
87+
88+
@Suppress("MaxLineLength")
89+
@Test
90+
fun `prepare loads image`() = runTest(testDispatcher) {
91+
val spriteSheet = SpriteSheet(
92+
urn = "urn:123",
93+
rows = 10,
94+
columns = 10,
95+
thumbnailHeight = 10,
96+
thumbnailWidth = 10,
97+
interval = 10,
98+
url = "https://il.srgssr.ch/spritesheet/urn/srf/video/881be9c2-65ec-4fa9-ba4a-926d15d046ef/sprite-881be9c2-65ec-4fa9-ba4a-926d15d046ef.jpeg"
99+
)
100+
101+
val mediaPeriod = SpriteSheetMediaPeriod(spriteSheet = spriteSheet, spriteSheetLoader = spriteSheetLoader, testDispatcher)
102+
mediaPeriod.prepare(mockk(relaxed = true), 1L)
103+
advanceUntilIdle()
104+
105+
assertFalse(mediaPeriod.isLoading)
106+
assertNotNull(mediaPeriod.bitmap)
107+
}
108+
}

0 commit comments

Comments
 (0)