diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 49a6f2a1f..d43e7be5d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -43,4 +43,3 @@ updates: - dependency-name: "com.comscore:*" - dependency-name: "com.google.guava:*" # Guava is updated together with AndroidX Media3 - dependency-name: "com.tagcommander.lib:*" - - dependency-name: "io.ktor:*" # We don't want to update to Ktor 3 yet diff --git a/build-logic/plugins/src/main/java/ch/srgssr/pillarbox/gradle/PillarboxAndroidLibraryPublishingPlugin.kt b/build-logic/plugins/src/main/java/ch/srgssr/pillarbox/gradle/PillarboxAndroidLibraryPublishingPlugin.kt index e2f99e056..6dbc39827 100644 --- a/build-logic/plugins/src/main/java/ch/srgssr/pillarbox/gradle/PillarboxAndroidLibraryPublishingPlugin.kt +++ b/build-logic/plugins/src/main/java/ch/srgssr/pillarbox/gradle/PillarboxAndroidLibraryPublishingPlugin.kt @@ -101,12 +101,6 @@ class PillarboxAndroidLibraryPublishingPlugin : Plugin { packageListUrl.set(URI("https://kotlinlang.org/api/kotlinx.serialization/package-list")) } - // TODO Enable this once the following issue is fixed: https://github.com/Kotlin/dokka/issues/3889 - // externalDocumentationLinks.register("ktor") { - // url.set(URI("https://api.ktor.io")) - // packageListUrl.set(URI("https://api.ktor.io/package-list")) - // } - // This is currently broken in Dokka for Android modules. See: https://github.com/Kotlin/dokka/issues/2876 sourceLink { val version = VersionConfig().versionName(default = name) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 83a2530cf..a0b5b2d8f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,9 +28,9 @@ kotlinx-coroutines = "1.9.0" kotlinx-datetime = "0.6.1" kotlinx-kover = "0.8.3" kotlinx-serialization = "1.7.3" -ktor = "2.3.13" mockk = "1.13.13" okhttp = "4.12.0" +okio = "3.9.1" robolectric = "4.14.1" srg-data-provider = "0.10.1" tag-commander-core = "5.4.3" @@ -78,13 +78,6 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. kotlinx-kover-gradle = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kotlinx-kover" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } -ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } -ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } -ktor-http = { module = "io.ktor:ktor-http", version.ref = "ktor" } -ktor-serialization = { module = "io.ktor:ktor-serialization", version.ref = "ktor" } -ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ktor-utils = { module = "io.ktor:ktor-utils", version.ref = "ktor" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } robolectric-annotations = { module = "org.robolectric:annotations", version.ref = "robolectric" } robolectric-shadows-framework = { module = "org.robolectric:shadows-framework", version.ref = "robolectric" } @@ -115,6 +108,7 @@ androidx-media3-test-utils = { module = "androidx.media3:media3-test-utils", ver androidx-media3-test-utils-robolectric = { module = "androidx.media3:media3-test-utils-robolectric", version.ref = "androidx-media3" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" } tagcommander-core = { group = "com.tagcommander.lib", name = "core", version.ref = "tag-commander-core" } tagcommander-serverside = { group = "com.tagcommander.lib", name = "ServerSide", version.ref = "tag-commander-server-side" } comscore = { group = "com.comscore", name = "android-analytics", version.ref = "comscore" } diff --git a/pillarbox-core-business/build.gradle.kts b/pillarbox-core-business/build.gradle.kts index 149643242..185da6860 100644 --- a/pillarbox-core-business/build.gradle.kts +++ b/pillarbox-core-business/build.gradle.kts @@ -34,12 +34,7 @@ dependencies { api(libs.kotlinx.datetime) api(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) - api(libs.ktor.client.core) - implementation(libs.ktor.client.okhttp) - implementation(libs.ktor.http) - implementation(libs.ktor.serialization.kotlinx.json) - implementation(libs.ktor.utils) - implementation(libs.okhttp) + api(libs.okhttp) api(libs.tagcommander.core) testImplementation(project(":pillarbox-player-testutils")) diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/HttpResultException.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/HttpResultException.kt deleted file mode 100644 index cc88637e7..000000000 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/HttpResultException.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business - -import androidx.media3.common.PlaybackException -import io.ktor.client.plugins.ClientRequestException -import java.io.IOException - -/** - * Represents an exception that occurs during an HTTP request when the server responds with an unsuccessful status code. - * - * @param message A descriptive message about the exception. Used by [PlaybackException] to rebuild this exception - */ -class HttpResultException internal constructor(message: String) : IOException(message) { - /** - * Creates a new instance based on a [ClientRequestException]. - * - * @param throwable The underlying [ClientRequestException] that triggered this exception. - */ - constructor(throwable: ClientRequestException) : this( - "${throwable.response.status.description} (${throwable.response.status.value})" - ) -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProvider.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProvider.kt index 934d9566d..249ac0498 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProvider.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProvider.kt @@ -12,6 +12,7 @@ import androidx.media3.datasource.DataSourceException import ch.srgssr.pillarbox.core.business.exception.BlockReasonException import ch.srgssr.pillarbox.core.business.exception.DataParsingException import ch.srgssr.pillarbox.core.business.exception.ResourceNotFoundException +import ch.srgssr.pillarbox.player.network.HttpResultException import java.io.IOException /** diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenDataSource.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenDataSource.kt index 8309a6300..d2004d071 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenDataSource.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenDataSource.kt @@ -9,7 +9,6 @@ import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DefaultHttpDataSource import ch.srgssr.pillarbox.player.utils.DebugLogger -import kotlinx.coroutines.runBlocking /** * A [DataSource] that injects an Akamai token into URLs containing the query parameter `withToken=true`. @@ -24,9 +23,7 @@ class AkamaiTokenDataSource private constructor( if (hasNeedAkamaiToken(outputUri)) { DebugLogger.debug("Akamai", "open ${dataSpec.uri}") val cleanUri = removeTokenQueryParameter(outputUri) - outputUri = runBlocking { - tokenProvider.tokenizeUri(cleanUri) - } + outputUri = tokenProvider.tokenizeUri(cleanUri) return dataSource.open(dataSpec.buildUpon().setUri(outputUri).build()) } return dataSource.open(dataSpec) diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenProvider.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenProvider.kt index 02fdf9f72..e8f90458f 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenProvider.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenProvider.kt @@ -6,21 +6,19 @@ package ch.srgssr.pillarbox.core.business.akamai import android.net.Uri import android.net.UrlQuerySanitizer -import ch.srgssr.pillarbox.player.network.PillarboxHttpClient -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.http.appendEncodedPathSegments +import ch.srgssr.pillarbox.player.network.PillarboxOkHttp +import ch.srgssr.pillarbox.player.network.RequestSender.send import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import okhttp3.OkHttpClient +import okhttp3.Request /** * The [AkamaiTokenProvider] is responsible for fetching an Akamai token from `TOKEN_SERVICE_URL` and appending it to URIs. * - * @param httpClient The HTTP client used to make requests to the token service. Defaults to a [PillarboxHttpClient] instance. + * @param okHttpClient The OkHttp client used to make requests to the token service. Defaults to a [PillarboxOkHttp] instance. */ -class AkamaiTokenProvider(private val httpClient: HttpClient = PillarboxHttpClient()) { +class AkamaiTokenProvider(private val okHttpClient: OkHttpClient = PillarboxOkHttp()) { /** * Requests and appends an Akamai token to the provided URI. @@ -30,7 +28,7 @@ class AkamaiTokenProvider(private val httpClient: HttpClient = PillarboxHttpClie * @param uri The URI to be tokenized. * @return The tokenized [Uri] if successful, otherwise the original [uri]. */ - suspend fun tokenizeUri(uri: Uri): Uri { + fun tokenizeUri(uri: Uri): Uri { val acl = getAcl(uri) val token = acl?.let { val tokenResult = getToken(it) @@ -39,15 +37,12 @@ class AkamaiTokenProvider(private val httpClient: HttpClient = PillarboxHttpClie return token?.let { appendTokenToUri(uri, it) } ?: uri } - private suspend fun getToken(acl: String): Result { - return runCatching { - httpClient.get(TOKEN_SERVICE_URL) { - url { - appendEncodedPathSegments("akahd/token") - parameter("acl", acl) - } - }.body().token - } + private fun getToken(acl: String): Result { + return Request.Builder() + .url(TOKEN_SERVICE_URL.format(acl)) + .build() + .send(okHttpClient) + .map { it.token } } /** @@ -76,7 +71,7 @@ class AkamaiTokenProvider(private val httpClient: HttpClient = PillarboxHttpClie internal data class TokenResponse(val token: Token) internal companion object { - private const val TOKEN_SERVICE_URL = "https://tp.srgssr.ch/" + private const val TOKEN_SERVICE_URL = "https://tp.srgssr.ch/akahd/token?acl=%s" internal fun getAcl(uri: Uri): String? { val path = uri.path diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingService.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingService.kt index 7e6b696a7..9bf0bb867 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingService.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingService.kt @@ -5,9 +5,8 @@ package ch.srgssr.pillarbox.core.business.integrationlayer import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost -import io.ktor.http.URLBuilder -import io.ktor.http.appendEncodedPathSegments import java.net.URL +import java.net.URLEncoder /** * Service used to get a scaled image URL. This only works for SRG images. @@ -21,14 +20,8 @@ internal class ImageScalingService( fun getScaledImageUrl( imageUrl: String, ): String { - return URLBuilder(baseUrl.toString()) - .appendEncodedPathSegments("images/") - .apply { - parameters.append("imageUrl", imageUrl) - parameters.append("format", "webp") - parameters.append("width", "480") - } - .build() - .toString() + val encodedImageUrl = URLEncoder.encode(imageUrl, Charsets.UTF_8.name()) + + return "${baseUrl}images/?imageUrl=$encodedImageUrl&format=webp&width=480" } } diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/HttpMediaCompositionService.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/HttpMediaCompositionService.kt index 8b134e393..e8c554289 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/HttpMediaCompositionService.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/HttpMediaCompositionService.kt @@ -6,25 +6,24 @@ package ch.srgssr.pillarbox.core.business.integrationlayer.service import android.net.Uri import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition -import ch.srgssr.pillarbox.player.network.PillarboxHttpClient -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import java.net.URL +import ch.srgssr.pillarbox.player.network.PillarboxOkHttp +import ch.srgssr.pillarbox.player.network.RequestSender.send +import okhttp3.OkHttpClient +import okhttp3.Request /** * A service for fetching a [MediaComposition] over HTTP. * - * @param httpClient The Ktor [HttpClient] instance used for making HTTP requests. + * @param okHttpClient The OkHttp client instance used for making HTTP requests. */ class HttpMediaCompositionService( - private val httpClient: HttpClient = PillarboxHttpClient(), + private val okHttpClient: OkHttpClient = PillarboxOkHttp(), ) : MediaCompositionService { override suspend fun fetchMediaComposition(uri: Uri): Result { - return runCatching { - httpClient.get(URL(uri.toString())) - .body() - } + return Request.Builder() + .url(uri.toString()) + .build() + .send(okHttpClient) } } diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoader.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoader.kt index 0078b0234..4e4cc47f9 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoader.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoader.kt @@ -13,7 +13,6 @@ import androidx.media3.common.MimeTypes import androidx.media3.datasource.DataSource.Factory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MergingMediaSource -import ch.srgssr.pillarbox.core.business.HttpResultException import ch.srgssr.pillarbox.core.business.akamai.AkamaiTokenDataSource import ch.srgssr.pillarbox.core.business.akamai.AkamaiTokenProvider import ch.srgssr.pillarbox.core.business.exception.DataParsingException @@ -32,10 +31,10 @@ import ch.srgssr.pillarbox.core.business.tracker.comscore.ComScoreTracker import ch.srgssr.pillarbox.player.PillarboxDsl import ch.srgssr.pillarbox.player.asset.Asset import ch.srgssr.pillarbox.player.asset.AssetLoader +import ch.srgssr.pillarbox.player.network.HttpResultException import ch.srgssr.pillarbox.player.tracker.FactoryData import ch.srgssr.pillarbox.player.tracker.MediaItemTracker import ch.srgssr.pillarbox.player.tracker.MutableMediaItemTrackerData -import io.ktor.client.plugins.ClientRequestException import kotlinx.serialization.SerializationException import java.io.IOException @@ -121,17 +120,9 @@ class SRGAssetLoader internal constructor( checkNotNull(mediaItem.localConfiguration) val result = mediaCompositionService.fetchMediaComposition(mediaItem.localConfiguration!!.uri).getOrElse { when (it) { - is ClientRequestException -> { - throw HttpResultException(it) - } - - is SerializationException -> { - throw DataParsingException(it) - } - - else -> { - throw IOException(it.message) - } + is HttpResultException -> throw it + is SerializationException -> throw DataParsingException(it) + else -> throw IOException(it.message) } } diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoaderConfig.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoaderConfig.kt index 5da310ef0..f328661c8 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoaderConfig.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoaderConfig.kt @@ -26,8 +26,8 @@ import ch.srgssr.pillarbox.player.PillarboxDsl import ch.srgssr.pillarbox.player.network.PillarboxOkHttp import ch.srgssr.pillarbox.player.tracker.MediaItemTracker import ch.srgssr.pillarbox.player.tracker.MutableMediaItemTrackerData -import io.ktor.client.HttpClient import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient /** * Configuration class for [SRGAssetLoader]. @@ -84,15 +84,15 @@ class SRGAssetLoaderConfig internal constructor(context: Context) { } /** - * Sets the HTTP client used by the [MediaCompositionService] and [AkamaiTokenProvider]. + * Sets the OkHttp client used by the [MediaCompositionService] and [AkamaiTokenProvider]. * * Note that this will override any existing [MediaCompositionService] set using [mediaCompositionService]. * - * @param httpClient The HTTP client. + * @param okHttpClient The OkHttp client. */ - fun httpClient(httpClient: HttpClient) { - mediaCompositionService = HttpMediaCompositionService(httpClient) - akamaiTokenProvider = AkamaiTokenProvider(httpClient) + fun httpClient(okHttpClient: OkHttpClient) { + mediaCompositionService = HttpMediaCompositionService(okHttpClient) + akamaiTokenProvider = AkamaiTokenProvider(okHttpClient) } /** diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProviderTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProviderTest.kt index 686399868..9808dfc1c 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProviderTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProviderTest.kt @@ -14,6 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import ch.srgssr.pillarbox.core.business.exception.BlockReasonException import ch.srgssr.pillarbox.core.business.exception.DataParsingException import ch.srgssr.pillarbox.core.business.exception.ResourceNotFoundException +import ch.srgssr.pillarbox.player.network.HttpResultException import org.junit.runner.RunWith import java.io.IOException import kotlin.test.BeforeTest @@ -60,7 +61,7 @@ class SRGErrorMessageProviderTest { @Test fun `getErrorMessage HttpResultException`() { - val exception = HttpResultException("HTTP request failed") + val exception = HttpResultException(503, "HTTP request failed") val (errorCode, errorMessage) = errorMessageProvider.getErrorMessage(playbackException(exception)) assertEquals(0, errorCode) diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/TestJsonSerialization.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/TestJsonSerialization.kt index 3452c743a..542ab3d27 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/TestJsonSerialization.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/TestJsonSerialization.kt @@ -7,7 +7,7 @@ package ch.srgssr.pillarbox.core.business import ch.srgssr.pillarbox.core.business.integrationlayer.data.BlockReason import ch.srgssr.pillarbox.core.business.integrationlayer.data.Chapter import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition -import ch.srgssr.pillarbox.player.network.PillarboxHttpClient.jsonSerializer +import ch.srgssr.pillarbox.player.network.jsonSerializer import kotlinx.serialization.SerializationException import kotlin.test.Test import kotlin.test.assertEquals diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/utils/LocalMediaCompositionWithFallbackService.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/utils/LocalMediaCompositionWithFallbackService.kt index 687927267..89885e3f0 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/utils/LocalMediaCompositionWithFallbackService.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/utils/LocalMediaCompositionWithFallbackService.kt @@ -9,7 +9,7 @@ import android.net.Uri import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition import ch.srgssr.pillarbox.core.business.integrationlayer.service.HttpMediaCompositionService import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionService -import ch.srgssr.pillarbox.player.network.PillarboxHttpClient +import ch.srgssr.pillarbox.player.network.jsonSerializer internal class LocalMediaCompositionWithFallbackService( context: Context, @@ -19,7 +19,7 @@ internal class LocalMediaCompositionWithFallbackService( init { val json = context.assets.open("media-compositions.json").bufferedReader().use { it.readText() } - mediaCompositions = PillarboxHttpClient.jsonSerializer.decodeFromString(json) + mediaCompositions = jsonSerializer.decodeFromString(json) } override suspend fun fetchMediaComposition(uri: Uri): Result { diff --git a/pillarbox-demo/build.gradle.kts b/pillarbox-demo/build.gradle.kts index 4acc0fe82..e8e4fc7de 100644 --- a/pillarbox-demo/build.gradle.kts +++ b/pillarbox-demo/build.gradle.kts @@ -94,7 +94,6 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.core) - implementation(libs.ktor.client.okhttp) implementation(libs.okhttp) implementation(libs.srg.data) implementation(libs.srg.dataprovider.retrofit) diff --git a/pillarbox-player/build.gradle.kts b/pillarbox-player/build.gradle.kts index e36341d8c..218ee7504 100644 --- a/pillarbox-player/build.gradle.kts +++ b/pillarbox-player/build.gradle.kts @@ -49,13 +49,6 @@ dependencies { api(libs.kotlinx.coroutines.core) api(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) - implementation(libs.ktor.client.content.negotiation) - api(libs.ktor.client.core) - implementation(libs.ktor.client.okhttp) - implementation(libs.ktor.http) - implementation(libs.ktor.serialization) - implementation(libs.ktor.serialization.kotlinx.json) - implementation(libs.ktor.utils) api(libs.okhttp) implementation(libs.okhttp.logging.interceptor) @@ -70,6 +63,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) testImplementation(libs.mockk.dsl) + testImplementation(libs.okio) testRuntimeOnly(libs.robolectric) testImplementation(libs.robolectric.annotations) testImplementation(libs.robolectric.shadows.framework) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBuilder.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBuilder.kt index 8dbb4f843..8222c557a 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBuilder.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxBuilder.kt @@ -27,9 +27,9 @@ import ch.srgssr.pillarbox.player.monitoring.NoOp import ch.srgssr.pillarbox.player.monitoring.Remote import ch.srgssr.pillarbox.player.monitoring.Remote.config import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory -import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO @@ -147,16 +147,16 @@ abstract class PillarboxBuilder { * Configures the monitoring to send all events to a remote server. * * @param endpointUrl The URL of the endpoint responsible for receiving monitoring messages. - * @param httpClient The [HttpClient] instance used for transmitting events to the endpoint. + * @param okHttpClient The [OkHttpClient] instance used for transmitting events to the endpoint. * @param coroutineScope The [CoroutineScope] which manages the coroutine responsible for sending monitoring messages. */ fun monitoring( endpointUrl: String, - httpClient: HttpClient? = null, + okHttpClient: OkHttpClient? = null, coroutineScope: CoroutineScope? = null, ) { monitoring(Remote) { - config(endpointUrl = endpointUrl, httpClient = httpClient, coroutineScope = coroutineScope) + config(endpointUrl = endpointUrl, okHttpClient = okHttpClient, coroutineScope = coroutineScope) } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/monitoring/MonitoringMessageHandler.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/monitoring/MonitoringMessageHandler.kt index 7836a6046..4abcf5e69 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/monitoring/MonitoringMessageHandler.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/monitoring/MonitoringMessageHandler.kt @@ -7,15 +7,14 @@ package ch.srgssr.pillarbox.player.monitoring import android.util.Log import ch.srgssr.pillarbox.player.PillarboxDsl import ch.srgssr.pillarbox.player.monitoring.models.Message -import ch.srgssr.pillarbox.player.network.PillarboxHttpClient -import io.ktor.client.HttpClient -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.contentType +import ch.srgssr.pillarbox.player.network.PillarboxOkHttp +import ch.srgssr.pillarbox.player.network.RequestSender.send +import ch.srgssr.pillarbox.player.network.RequestSender.toJsonRequestBody import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request import java.net.URL /** @@ -171,12 +170,12 @@ object Remote : MonitoringMessageHandlerType() { * Configuration class for the [Remote] handler type. * * @property endpointUrl The URL of the endpoint responsible for receiving monitoring messages. - * @property httpClient The [HttpClient] instance used for transmitting events to the endpoint. + * @property okHttpClient The [OkHttpClient] instance used for transmitting events to the endpoint. * @property coroutineScope The [CoroutineScope] which manages the coroutine responsible for sending monitoring messages. */ class Config internal constructor( val endpointUrl: URL, - val httpClient: HttpClient, + val okHttpClient: OkHttpClient, val coroutineScope: CoroutineScope, ) @@ -184,7 +183,7 @@ object Remote : MonitoringMessageHandlerType() { * Creates a new [Config] instance for the [MonitoringConfigFactory]. * * @param endpointUrl The URL of the endpoint responsible for receiving monitoring messages. - * @param httpClient The [HttpClient] instance used for transmitting events to the endpoint. + * @param okHttpClient The [OkHttpClient] instance used for transmitting events to the endpoint. * @param coroutineScope The [CoroutineScope] which manages the coroutine responsible for sending monitoring messages. * * @return A new [Config] instance with the specified configuration. @@ -192,12 +191,12 @@ object Remote : MonitoringMessageHandlerType() { @Suppress("UnusedReceiverParameter") fun MonitoringConfigFactory.config( endpointUrl: String, - httpClient: HttpClient? = null, + okHttpClient: OkHttpClient? = null, coroutineScope: CoroutineScope? = null, ): Config { return Config( endpointUrl = URL(endpointUrl), - httpClient = httpClient ?: PillarboxHttpClient(), + okHttpClient = okHttpClient ?: PillarboxOkHttp(), coroutineScope = coroutineScope ?: CoroutineScope(Dispatchers.IO), ) } @@ -208,7 +207,7 @@ object Remote : MonitoringMessageHandlerType() { object Factory : MonitoringMessageHandlerFactory { override fun createMessageHandler(config: Config): MonitoringMessageHandler { return MessageHandler( - httpClient = config.httpClient, + okHttpClient = config.okHttpClient, endpointUrl = config.endpointUrl, coroutineScope = config.coroutineScope, ) @@ -216,18 +215,17 @@ object Remote : MonitoringMessageHandlerType() { } private class MessageHandler( - private val httpClient: HttpClient, + private val okHttpClient: OkHttpClient, private val endpointUrl: URL, private val coroutineScope: CoroutineScope, ) : MonitoringMessageHandler { override fun sendEvent(event: Message) { coroutineScope.launch { - runCatching { - httpClient.post(endpointUrl) { - contentType(ContentType.Application.Json) - setBody(event) - } - } + Request.Builder() + .url(endpointUrl) + .post(event.toJsonRequestBody()) + .build() + .send(okHttpClient) } } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/HttpResultException.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/HttpResultException.kt new file mode 100644 index 000000000..b955a0327 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/HttpResultException.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.network + +import androidx.media3.common.PlaybackException +import java.io.IOException + +/** + * Represents an exception that occurs during an HTTP request when the server responds with an unsuccessful status code. + * + * @param message A descriptive message about the exception. Used by [PlaybackException] to rebuild this exception + */ +class HttpResultException private constructor(message: String) : IOException(message) { + /** + * Creates a new instance based on a HTTP status code and message. + * + * @param statusCode The HTTP code received by the server. + * @param statusMessage The message received by the server. + */ + constructor(statusCode: Int, statusMessage: String) : this("$statusMessage ($statusCode)") +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/JsonSerializer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/JsonSerializer.kt new file mode 100644 index 000000000..cf15d4536 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/JsonSerializer.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.network + +import androidx.annotation.RestrictTo +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.ClassDiscriminatorMode +import kotlinx.serialization.json.Json + +/** + * The [Json] serializer used for Pillarbox network requests. + */ +@OptIn(ExperimentalSerializationApi::class) +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +val jsonSerializer = Json { + classDiscriminatorMode = ClassDiscriminatorMode.NONE + encodeDefaults = true + explicitNulls = false + ignoreUnknownKeys = true + isLenient = true +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/PillarboxHttpClient.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/PillarboxHttpClient.kt deleted file mode 100644 index 8f3b97bf9..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/PillarboxHttpClient.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.network - -import androidx.annotation.VisibleForTesting -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.cache.HttpCache -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.ClassDiscriminatorMode -import kotlinx.serialization.json.Json - -/** - * Provides a pre-configured Ktor [HttpClient] instance tailored for Pillarbox's specific needs. - */ -object PillarboxHttpClient { - /** - * The [Json] serializer used by this [HttpClient]. - */ - @OptIn(ExperimentalSerializationApi::class) - @VisibleForTesting - val jsonSerializer = Json { - classDiscriminatorMode = ClassDiscriminatorMode.NONE - encodeDefaults = true - explicitNulls = false - ignoreUnknownKeys = true - isLenient = true - } - - private val httpClient by lazy { - HttpClient(OkHttp) { - expectSuccess = true - - engine { - preconfigured = PillarboxOkHttp() - } - - install(HttpCache) - install(ContentNegotiation) { - json(jsonSerializer) - } - } - } - - /** - * Provides access to the underlying [HttpClient] instance configured for Pillarbox. - * - * @return The [HttpClient] instance used by Pillarbox. - */ - operator fun invoke(): HttpClient { - return httpClient - } -} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/RequestSender.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/RequestSender.kt new file mode 100644 index 000000000..ad4d85db5 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/network/RequestSender.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.network + +import androidx.annotation.RestrictTo +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.decodeFromStream +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * A helper object responsible for sending HTTP requests using OkHttp and handling JSON serialization. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +object RequestSender { + /** + * Represents the MIME type for JSON data. + */ + val MIME_TYPE_JSON = "application/json; charset=utf-8".toMediaType() + + /** + * Converts an object of type [T] to a [RequestBody] with JSON content type. + * + * @receiver The object to be converted to a [RequestBody]. + * @return A [RequestBody] containing the JSON representation of the receiver object. + */ + inline fun T.toJsonRequestBody(): RequestBody { + return jsonSerializer.encodeToString(this) + .toRequestBody(MIME_TYPE_JSON) + } + + /** + * Sends the current request and attempts to decode the response body into an object of type [T]. + * + * @param T The type of object to decode the response body into. + * @param okHttpClient The OkHttp client used to make requests to the token service. Defaults to a [PillarboxOkHttp] instance. + * + * @return A [Result] object containing either the successfully decoded object of type [T] or a [Throwable] representing the error that occurred. + */ + @OptIn(ExperimentalSerializationApi::class) + inline fun Request.send(okHttpClient: OkHttpClient = PillarboxOkHttp()): Result { + return runCatching { + okHttpClient.newCall(this) + .execute() + .use { response -> + if (response.isSuccessful) { + val bodyStream = checkNotNull(response.body).byteStream() + + jsonSerializer.decodeFromStream(bodyStream) + } else { + throw HttpResultException(response.code, response.message) + } + } + } + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/network/RequestSenderTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/network/RequestSenderTest.kt new file mode 100644 index 000000000..63b84f180 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/network/RequestSenderTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.network + +import ch.srgssr.pillarbox.player.network.RequestSender.send +import ch.srgssr.pillarbox.player.network.RequestSender.toJsonRequestBody +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import okhttp3.Request +import okio.Buffer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RequestSenderTest { + private lateinit var buffer: Buffer + + @BeforeTest + fun setUp() { + buffer = Buffer() + } + + @AfterTest + fun tearDown() { + buffer.close() + } + + @Test + fun `to JSON request body, Int`() { + validateRequestBodyConversion(42) + } + + @Test + fun `to JSON request body, String`() { + validateRequestBodyConversion("Hello, World!") + } + + @Test + fun `to JSON request body, serializable Model`() { + validateRequestBodyConversion(SerializableModel(name = "Bruce Wayne", place = "Gotham City")) + } + + @Test(expected = SerializationException::class) + fun `to JSON request body, non serializable Model`() { + NonSerializableModel(name = "The Joker", place = "Arkham City").toJsonRequestBody() + } + + @Test + fun `send request, 20x`() { + val result = Request.Builder() + .url("https://httpbin.org/get") + .build() + .send() + + assertTrue(result.isSuccess) + assertNotNull(result.getOrNull()) + assertNull(result.exceptionOrNull()) + } + + @Test + fun `send request, 40x`() { + val result = Request.Builder() + .url("https://httpbin.org/status/404") + .build() + .send() + val exception = result.exceptionOrNull() + + assertFalse(result.isSuccess) + assertNull(result.getOrNull()) + assertNotNull(exception) + assertIs(exception) + } + + private inline fun validateRequestBodyConversion(data: T) { + val requestBody = data.toJsonRequestBody() + requestBody.writeTo(buffer) + + assertEquals(RequestSender.MIME_TYPE_JSON, requestBody.contentType()) + assertEquals(jsonSerializer.encodeToString(data), buffer.readUtf8()) + } + + @Serializable + private data class SerializableModel( + val name: String, + val place: String, + ) + + private data class NonSerializableModel( + val name: String, + val place: String, + ) +}