diff --git a/constants.gradle b/constants.gradle
index 40513baf94d..c37b4d9a35c 100644
--- a/constants.gradle
+++ b/constants.gradle
@@ -65,6 +65,7 @@ project.ext {
desugarJdkLibsVersion = '2.1.5'
lottieVersion = '6.6.0'
truthVersion = '1.4.0'
+ ktorVersion = '3.0.3'
okhttpVersion = '4.12.0'
testParameterInjectorVersion = '1.18'
modulePrefix = ':'
diff --git a/core_settings.gradle b/core_settings.gradle
index 73f3c542f30..b4108e0216b 100644
--- a/core_settings.gradle
+++ b/core_settings.gradle
@@ -68,6 +68,8 @@ include modulePrefix + 'lib-datasource-rtmp'
project(modulePrefix + 'lib-datasource-rtmp').projectDir = new File(rootDir, 'libraries/datasource_rtmp')
include modulePrefix + 'lib-datasource-okhttp'
project(modulePrefix + 'lib-datasource-okhttp').projectDir = new File(rootDir, 'libraries/datasource_okhttp')
+include modulePrefix + 'lib-datasource-ktor'
+project(modulePrefix + 'lib-datasource-ktor').projectDir = new File(rootDir, 'libraries/datasource_ktor')
include modulePrefix + 'lib-decoder'
project(modulePrefix + 'lib-decoder').projectDir = new File(rootDir, 'libraries/decoder')
diff --git a/libraries/datasource_ktor/README.md b/libraries/datasource_ktor/README.md
new file mode 100644
index 00000000000..f85da67ea51
--- /dev/null
+++ b/libraries/datasource_ktor/README.md
@@ -0,0 +1,47 @@
+# Ktor DataSource module
+
+This module provides an [HttpDataSource][] implementation that uses [Ktor][].
+
+Ktor is a multiplatform HTTP client developed by JetBrains. It supports HTTP/2,
+WebSocket, and Kotlin coroutines for asynchronous operations.
+
+[HttpDataSource]: ../datasource/src/main/java/androidx/media3/datasource/HttpDataSource.java
+[Ktor]: https://ktor.io/
+
+## Getting the module
+
+The easiest way to get the module is to add it as a gradle dependency:
+
+```groovy
+implementation 'androidx.media3:media3-datasource-ktor:1.X.X'
+```
+
+where `1.X.X` is the version, which must match the version of the other media
+modules being used.
+
+Alternatively, you can clone this GitHub project and depend on the module
+locally. Instructions for doing this can be found in the [top level README][].
+
+[top level README]: ../../README.md
+
+## Using the module
+
+Media components request data through `DataSource` instances. These instances
+are obtained from instances of `DataSource.Factory`, which are instantiated and
+injected from application code.
+
+If your application only needs to play http(s) content, using the Ktor
+extension is as simple as updating any `DataSource.Factory` instantiations in
+your application code to use `KtorDataSource.Factory`. If your application
+also needs to play non-http(s) content such as local files, use:
+```
+new DefaultDataSourceFactory(
+ ...
+ /* baseDataSourceFactory= */ new KtorDataSource.Factory(...));
+```
+
+## Links
+
+* [Javadoc][]
+
+[Javadoc]: https://developer.android.com/reference/androidx/media3/datasource/ktor/package-summary
diff --git a/libraries/datasource_ktor/build.gradle b/libraries/datasource_ktor/build.gradle
new file mode 100644
index 00000000000..7f061fb52da
--- /dev/null
+++ b/libraries/datasource_ktor/build.gradle
@@ -0,0 +1,55 @@
+// Copyright 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle"
+
+apply plugin: 'kotlin-android'
+
+android {
+ namespace 'androidx.media3.datasource.ktor'
+
+ defaultConfig.minSdkVersion project.ext.minSdkVersion
+
+ publishing {
+ singleVariant('release') {
+ withSourcesJar()
+ }
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+}
+
+dependencies {
+ api 'io.ktor:ktor-client-core:' + ktorVersion
+ api 'io.ktor:ktor-client-android:' + ktorVersion
+ api project(modulePrefix + 'lib-common')
+ api project(modulePrefix + 'lib-datasource')
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ compileOnly 'com.google.errorprone:error_prone_annotations:' + errorProneVersion
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:' + kotlinxCoroutinesVersion
+ androidTestImplementation project(modulePrefix + 'test-utils')
+ androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
+ androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:' + dexmakerVersion
+ androidTestImplementation 'com.squareup.okhttp3:mockwebserver:' + okhttpVersion
+}
+
+ext {
+ releaseArtifactId = 'media3-datasource-ktor'
+ releaseName = 'Media3 Ktor DataSource module'
+
+}
+apply from: '../../publish.gradle'
diff --git a/libraries/datasource_ktor/proguard-rules.txt b/libraries/datasource_ktor/proguard-rules.txt
new file mode 100644
index 00000000000..a905fc418c7
--- /dev/null
+++ b/libraries/datasource_ktor/proguard-rules.txt
@@ -0,0 +1,7 @@
+# Proguard rules specific to the Ktor extension.
+
+# Options for Ktor and Okio
+-dontwarn io.ktor.**
+-dontwarn okio.**
+-dontwarn javax.annotation.**
+-dontwarn org.conscrypt.**
diff --git a/libraries/datasource_ktor/src/androidTest/AndroidManifest.xml b/libraries/datasource_ktor/src/androidTest/AndroidManifest.xml
new file mode 100644
index 00000000000..c2c578aa852
--- /dev/null
+++ b/libraries/datasource_ktor/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libraries/datasource_ktor/src/androidTest/java/androidx/media3/datasource/ktor/KtorDataSourceContractTest.kt b/libraries/datasource_ktor/src/androidTest/java/androidx/media3/datasource/ktor/KtorDataSourceContractTest.kt
new file mode 100644
index 00000000000..a179bd004e5
--- /dev/null
+++ b/libraries/datasource_ktor/src/androidTest/java/androidx/media3/datasource/ktor/KtorDataSourceContractTest.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.media3.datasource.ktor
+
+import androidx.media3.datasource.DataSource
+import androidx.media3.test.utils.DataSourceContractTest
+import androidx.media3.test.utils.HttpDataSourceTestEnv
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.collect.ImmutableList
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.HttpTimeout
+import org.junit.Rule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class KtorDataSourceContractTest : DataSourceContractTest() {
+
+ @JvmField @Rule var httpDataSourceTestEnv = HttpDataSourceTestEnv()
+ val httpClient =
+ HttpClient() {
+ install(HttpTimeout) {
+ requestTimeoutMillis = 400
+ connectTimeoutMillis = 400
+ socketTimeoutMillis = 400
+ }
+ }
+
+ override fun createDataSource(): DataSource {
+ return KtorDataSource.Factory(httpClient).createDataSource()
+ }
+
+ override fun getTestResources(): ImmutableList {
+ return httpDataSourceTestEnv.servedResources
+ }
+
+ override fun getNotFoundResources(): MutableList {
+ return httpDataSourceTestEnv.notFoundResources
+ }
+}
diff --git a/libraries/datasource_ktor/src/androidTest/java/androidx/media3/datasource/ktor/KtorDataSourceTest.kt b/libraries/datasource_ktor/src/androidTest/java/androidx/media3/datasource/ktor/KtorDataSourceTest.kt
new file mode 100644
index 00000000000..8aad11a8417
--- /dev/null
+++ b/libraries/datasource_ktor/src/androidTest/java/androidx/media3/datasource/ktor/KtorDataSourceTest.kt
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.media3.datasource.ktor
+
+import androidx.media3.datasource.DataSpec
+import androidx.media3.datasource.HttpDataSource
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import io.ktor.client.HttpClient
+import java.nio.charset.StandardCharsets
+import java.util.HashMap
+import java.util.concurrent.TimeUnit
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class KtorDataSourceTest {
+
+ val httpClient = HttpClient()
+
+ @Test
+ @Throws(Exception::class)
+ fun open_setsCorrectHeaders() {
+ val mockWebServer = MockWebServer()
+ mockWebServer.enqueue(MockResponse())
+
+ val propertyFromFactory = "fromFactory"
+ val defaultRequestProperties = HashMap()
+ defaultRequestProperties["0"] = propertyFromFactory
+ defaultRequestProperties["1"] = propertyFromFactory
+ defaultRequestProperties["2"] = propertyFromFactory
+ defaultRequestProperties["4"] = propertyFromFactory
+
+ val dataSource =
+ KtorDataSource.Factory(httpClient)
+ .setDefaultRequestProperties(defaultRequestProperties)
+ .createDataSource()
+
+ val propertyFromSetter = "fromSetter"
+ dataSource.setRequestProperty("1", propertyFromSetter)
+ dataSource.setRequestProperty("2", propertyFromSetter)
+ dataSource.setRequestProperty("3", propertyFromSetter)
+ dataSource.setRequestProperty("5", propertyFromSetter)
+
+ val propertyFromDataSpec = "fromDataSpec"
+ val dataSpecRequestProperties = HashMap()
+ dataSpecRequestProperties["2"] = propertyFromDataSpec
+ dataSpecRequestProperties["3"] = propertyFromDataSpec
+ dataSpecRequestProperties["4"] = propertyFromDataSpec
+ dataSpecRequestProperties["6"] = propertyFromDataSpec
+
+ val dataSpec =
+ DataSpec.Builder()
+ .setUri(mockWebServer.url("/test-path").toString())
+ .setHttpRequestHeaders(dataSpecRequestProperties)
+ .build()
+
+ dataSource.open(dataSpec)
+
+ val request = mockWebServer.takeRequest(10, TimeUnit.SECONDS)
+ assertThat(request).isNotNull()
+ val headers = request!!.headers
+ assertThat(headers["0"]).isEqualTo(propertyFromFactory)
+ assertThat(headers["1"]).isEqualTo(propertyFromSetter)
+ assertThat(headers["2"]).isEqualTo(propertyFromDataSpec)
+ assertThat(headers["3"]).isEqualTo(propertyFromDataSpec)
+ assertThat(headers["4"]).isEqualTo(propertyFromDataSpec)
+ assertThat(headers["5"]).isEqualTo(propertyFromSetter)
+ assertThat(headers["6"]).isEqualTo(propertyFromDataSpec)
+ }
+
+ @Test
+ fun open_invalidResponseCode() {
+ val mockWebServer = MockWebServer()
+ mockWebServer.enqueue(MockResponse().setResponseCode(404).setBody("failure msg"))
+
+ val dataSource = KtorDataSource.Factory(httpClient).createDataSource()
+
+ val dataSpec = DataSpec.Builder().setUri(mockWebServer.url("/test-path").toString()).build()
+
+ val exception =
+ assertThrows(HttpDataSource.InvalidResponseCodeException::class.java) {
+ dataSource.open(dataSpec)
+ }
+
+ assertThat(exception.responseCode).isEqualTo(404)
+ assertThat(exception.responseBody).isEqualTo("failure msg".toByteArray(StandardCharsets.UTF_8))
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun factory_setRequestPropertyAfterCreation_setsCorrectHeaders() {
+ val mockWebServer = MockWebServer()
+ mockWebServer.enqueue(MockResponse())
+ val dataSpec = DataSpec.Builder().setUri(mockWebServer.url("/test-path").toString()).build()
+
+ val factory = KtorDataSource.Factory(httpClient)
+ val dataSource = factory.createDataSource()
+
+ val defaultRequestProperties = HashMap()
+ defaultRequestProperties["0"] = "afterCreation"
+ factory.setDefaultRequestProperties(defaultRequestProperties)
+ dataSource.open(dataSpec)
+
+ val request = mockWebServer.takeRequest(10, TimeUnit.SECONDS)
+ assertThat(request).isNotNull()
+ val headers = request!!.headers
+ assertThat(headers["0"]).isEqualTo("afterCreation")
+ }
+
+ @Test
+ fun open_malformedUrl_throwsException() {
+ val dataSource = KtorDataSource.Factory(httpClient).createDataSource()
+
+ val dataSpec = DataSpec.Builder().setUri("not-a-valid-url").build()
+
+ val exception =
+ assertThrows(HttpDataSource.HttpDataSourceException::class.java) { dataSource.open(dataSpec) }
+
+ assertThat(exception.message).contains("Malformed URL")
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun open_httpPost_sendsPostRequest() {
+ val mockWebServer = MockWebServer()
+ mockWebServer.enqueue(MockResponse())
+
+ val dataSource = KtorDataSource.Factory(httpClient).createDataSource()
+
+ val dataSpec =
+ DataSpec.Builder()
+ .setUri(mockWebServer.url("/test-path").toString())
+ .setHttpMethod(DataSpec.HTTP_METHOD_POST)
+ .setHttpBody("test body".toByteArray(StandardCharsets.UTF_8))
+ .build()
+
+ dataSource.open(dataSpec)
+
+ val request = mockWebServer.takeRequest(10, TimeUnit.SECONDS)
+ assertThat(request).isNotNull()
+ assertThat(request!!.method).isEqualTo("POST")
+ assertThat(request.body.readUtf8()).isEqualTo("test body")
+ }
+}
diff --git a/libraries/datasource_ktor/src/main/AndroidManifest.xml b/libraries/datasource_ktor/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..f5f8b47a4b1
--- /dev/null
+++ b/libraries/datasource_ktor/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/libraries/datasource_ktor/src/main/java/androidx/media3/datasource/ktor/KtorDataSource.kt b/libraries/datasource_ktor/src/main/java/androidx/media3/datasource/ktor/KtorDataSource.kt
new file mode 100644
index 00000000000..78676fc7e70
--- /dev/null
+++ b/libraries/datasource_ktor/src/main/java/androidx/media3/datasource/ktor/KtorDataSource.kt
@@ -0,0 +1,496 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.media3.datasource.ktor
+
+import android.net.Uri
+import androidx.media3.common.C
+import androidx.media3.common.MediaLibraryInfo
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.common.util.Util
+import androidx.media3.datasource.BaseDataSource
+import androidx.media3.datasource.DataSourceException
+import androidx.media3.datasource.DataSpec
+import androidx.media3.datasource.HttpDataSource
+import androidx.media3.datasource.HttpUtil
+import androidx.media3.datasource.TransferListener
+import com.google.common.base.Predicate
+import com.google.common.net.HttpHeaders
+import io.ktor.client.HttpClient
+import io.ktor.client.request.headers
+import io.ktor.client.request.prepareRequest
+import io.ktor.client.request.setBody
+import io.ktor.client.request.url
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.client.statement.request
+import io.ktor.http.HttpMethod
+import io.ktor.http.contentLength
+import io.ktor.http.contentType
+import io.ktor.utils.io.ByteReadChannel
+import io.ktor.utils.io.availableForRead
+import io.ktor.utils.io.readAvailable
+import java.io.IOException
+import java.io.InterruptedIOException
+import java.util.TreeMap
+import kotlin.math.min
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.runBlocking
+
+/**
+ * An [HttpDataSource] that delegates to Ktor's [HttpClient].
+ *
+ * Note: HTTP request headers will be set using all parameters passed via (in order of decreasing
+ * priority) the `dataSpec`, [setRequestProperty] and the default parameters used to construct the
+ * instance.
+ */
+@UnstableApi
+class KtorDataSource
+private constructor(
+ private val httpClient: HttpClient,
+ private val userAgent: String?,
+ private val cacheControl: String?,
+ private val defaultRequestProperties: HttpDataSource.RequestProperties?,
+ private val contentTypePredicate: Predicate?,
+ private val requestProperties: HttpDataSource.RequestProperties,
+) : BaseDataSource(true), HttpDataSource {
+
+ companion object {
+ private const val TAG = "KtorDataSource"
+
+ init {
+ MediaLibraryInfo.registerModule("media3.datasource.ktor")
+ }
+ }
+
+ /**
+ * [androidx.media3.datasource.DataSource.Factory] for [KtorDataSource] instances.
+ *
+ * @param httpClient A [HttpClient] for use by the sources created by the factory.
+ */
+ class Factory(private val httpClient: HttpClient) : HttpDataSource.Factory {
+
+ private val defaultRequestProperties = HttpDataSource.RequestProperties()
+
+ private var userAgent: String? = null
+
+ private var cacheControl: String? = null
+
+ private var transferListener: TransferListener? = null
+
+ private var contentTypePredicate: Predicate? = null
+
+ override fun setDefaultRequestProperties(
+ defaultRequestProperties: Map
+ ): Factory {
+ this.defaultRequestProperties.clearAndSet(defaultRequestProperties)
+ return this
+ }
+
+ /**
+ * Sets the user agent that will be used.
+ *
+ * The default is `null`, which causes the default user agent of the underlying [HttpClient] to
+ * be used.
+ *
+ * @param userAgent The user agent that will be used, or `null` to use the default user agent of
+ * the underlying [HttpClient].
+ * @return This factory.
+ */
+ fun setUserAgent(userAgent: String?): Factory {
+ this.userAgent = userAgent
+ return this
+ }
+
+ /**
+ * Sets the Cache-Control header that will be used.
+ *
+ * The default is `null`.
+ *
+ * @param cacheControl The cache control header value that will be used, or `null` to clear a
+ * previously set value.
+ * @return This factory.
+ */
+ fun setCacheControl(cacheControl: String?): Factory {
+ this.cacheControl = cacheControl
+ return this
+ }
+
+ /**
+ * Sets a content type [Predicate]. If a content type is rejected by the predicate then a
+ * [HttpDataSource.InvalidContentTypeException] is thrown from [KtorDataSource.open].
+ *
+ * The default is `null`.
+ *
+ * @param contentTypePredicate The content type [Predicate], or `null` to clear a predicate that
+ * was previously set.
+ * @return This factory.
+ */
+ fun setContentTypePredicate(contentTypePredicate: Predicate?): Factory {
+ this.contentTypePredicate = contentTypePredicate
+ return this
+ }
+
+ /**
+ * Sets the [TransferListener] that will be used.
+ *
+ * The default is `null`.
+ *
+ * See [androidx.media3.datasource.DataSource.addTransferListener].
+ *
+ * @param transferListener The listener that will be used.
+ * @return This factory.
+ */
+ fun setTransferListener(transferListener: TransferListener?): Factory {
+ this.transferListener = transferListener
+ return this
+ }
+
+ override fun createDataSource(): KtorDataSource {
+ val client = httpClient
+ val dataSource =
+ KtorDataSource(
+ client,
+ userAgent,
+ cacheControl,
+ defaultRequestProperties,
+ contentTypePredicate,
+ HttpDataSource.RequestProperties(),
+ )
+ transferListener?.let { dataSource.addTransferListener(it) }
+ return dataSource
+ }
+ }
+
+ private var dataSpec: DataSpec? = null
+
+ private var response: HttpResponse? = null
+
+ private var responseChannel: ByteReadChannel? = null
+
+ private var connectionEstablished = false
+ private var bytesToRead: Long = 0
+ private var bytesRead: Long = 0
+
+ override fun getUri(): Uri? {
+ return if (response != null) {
+ Uri.parse(response!!.request.url.toString())
+ } else if (dataSpec != null) {
+ dataSpec!!.uri
+ } else {
+ null
+ }
+ }
+
+ override fun getResponseCode(): Int {
+ return response?.status?.value ?: -1
+ }
+
+ override fun getResponseHeaders(): Map> {
+ val httpResponse = response ?: return emptyMap()
+ // ordered map use case-insensitive comparator to support case-insensitive lookup
+ val result = TreeMap>(String.CASE_INSENSITIVE_ORDER)
+ for (name in httpResponse.headers.names()) {
+ result[name] = httpResponse.headers.getAll(name) ?: emptyList()
+ }
+ return result
+ }
+
+ override fun setRequestProperty(name: String, value: String) {
+ requestProperties.set(name, value)
+ }
+
+ override fun clearRequestProperty(name: String) {
+ requestProperties.remove(name)
+ }
+
+ override fun clearAllRequestProperties() {
+ requestProperties.clear()
+ }
+
+ @Throws(HttpDataSource.HttpDataSourceException::class)
+ override fun open(dataSpec: DataSpec): Long {
+ this.dataSpec = dataSpec
+ bytesRead = 0
+ bytesToRead = 0
+ transferInitializing(dataSpec)
+
+ val urlString = dataSpec.uri.toString()
+ val uri = Uri.parse(urlString)
+ val scheme = uri.scheme
+ if (scheme == null || !scheme.lowercase().startsWith("http")) {
+ throw HttpDataSource.HttpDataSourceException(
+ "Malformed URL",
+ dataSpec,
+ PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK,
+ HttpDataSource.HttpDataSourceException.TYPE_OPEN,
+ )
+ }
+
+ val mergedHeaders = HashMap()
+ defaultRequestProperties?.snapshot?.forEach { (key, value) -> mergedHeaders[key] = value }
+ requestProperties.snapshot.forEach { (key, value) -> mergedHeaders[key] = value }
+ dataSpec.httpRequestHeaders.forEach { (key, value) -> mergedHeaders[key] = value }
+
+ val httpResponse: HttpResponse
+ val channel: ByteReadChannel
+ try {
+ runBlocking {
+ httpResponse =
+ httpClient
+ .prepareRequest {
+ url(urlString)
+
+ headers {
+ mergedHeaders.forEach { (key, value) -> append(key, value) }
+
+ val rangeHeader =
+ HttpUtil.buildRangeRequestHeader(dataSpec.position, dataSpec.length)
+ if (rangeHeader != null) {
+ append(HttpHeaders.RANGE, rangeHeader)
+ }
+
+ if (userAgent != null) {
+ append(HttpHeaders.USER_AGENT, userAgent)
+ }
+
+ if (cacheControl != null) {
+ append(HttpHeaders.CACHE_CONTROL, cacheControl)
+ }
+
+ if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) {
+ append(HttpHeaders.ACCEPT_ENCODING, "identity")
+ }
+ }
+
+ method =
+ when (dataSpec.httpMethod) {
+ DataSpec.HTTP_METHOD_GET -> HttpMethod.Get
+ DataSpec.HTTP_METHOD_POST -> HttpMethod.Post
+ DataSpec.HTTP_METHOD_HEAD -> HttpMethod.Head
+ else -> HttpMethod.Get
+ }
+
+ if (dataSpec.httpBody != null) {
+ setBody(dataSpec.httpBody!!)
+ } else if (dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) {
+ setBody(ByteArray(0))
+ }
+ }
+ .execute()
+ channel = httpResponse.bodyAsChannel()
+ }
+ this.response = httpResponse
+ this.responseChannel = channel
+ } catch (_: CancellationException) {
+ throw InterruptedIOException()
+ } catch (e: IOException) {
+ if (e is HttpDataSource.HttpDataSourceException) throw e
+ throw HttpDataSource.HttpDataSourceException.createForIOException(
+ e,
+ dataSpec,
+ HttpDataSource.HttpDataSourceException.TYPE_OPEN,
+ )
+ } catch (e: Exception) {
+ throw HttpDataSource.HttpDataSourceException(
+ e.message ?: "Unknown error",
+ null,
+ dataSpec,
+ PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
+ HttpDataSource.HttpDataSourceException.TYPE_OPEN,
+ )
+ }
+
+ val responseCode = httpResponse.status.value
+
+ if (responseCode !in 200..299) {
+ if (responseCode == 416) {
+ val contentRange = httpResponse.headers[HttpHeaders.CONTENT_RANGE]
+ val documentSize = HttpUtil.getDocumentSize(contentRange)
+ if (dataSpec.position == documentSize) {
+ connectionEstablished = true
+ transferStarted(dataSpec)
+ return if (dataSpec.length != C.LENGTH_UNSET.toLong()) dataSpec.length else 0
+ }
+ }
+
+ val errorResponseBody: ByteArray =
+ try {
+ runBlocking {
+ val ch = responseChannel ?: return@runBlocking Util.EMPTY_BYTE_ARRAY
+ val buffer = ByteArray(ch.availableForRead.coerceAtMost(1024 * 1024))
+ val read = ch.readAvailable(buffer)
+ if (read > 0) buffer.copyOf(read) else Util.EMPTY_BYTE_ARRAY
+ }
+ } catch (_: Exception) {
+ Util.EMPTY_BYTE_ARRAY
+ }
+
+ val headers = getResponseHeaders()
+ closeConnectionQuietly()
+
+ val cause: IOException? =
+ if (responseCode == 416) {
+ DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE)
+ } else {
+ null
+ }
+
+ throw HttpDataSource.InvalidResponseCodeException(
+ responseCode,
+ httpResponse.status.description,
+ cause,
+ headers,
+ dataSpec,
+ errorResponseBody,
+ )
+ }
+
+ val contentType = httpResponse.contentType()?.toString() ?: ""
+ if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) {
+ closeConnectionQuietly()
+ throw HttpDataSource.InvalidContentTypeException(contentType, dataSpec)
+ }
+
+ val bytesToSkip = if (responseCode == 200 && dataSpec.position != 0L) dataSpec.position else 0L
+
+ if (dataSpec.length != C.LENGTH_UNSET.toLong()) {
+ bytesToRead = dataSpec.length
+ } else {
+ val contentLength = httpResponse.contentLength() ?: -1L
+ bytesToRead = if (contentLength >= 0) contentLength - bytesToSkip else C.LENGTH_UNSET.toLong()
+ }
+
+ connectionEstablished = true
+ transferStarted(dataSpec)
+
+ try {
+ runBlocking { skipFully(bytesToSkip, dataSpec) }
+ } catch (e: HttpDataSource.HttpDataSourceException) {
+ closeConnectionQuietly()
+ throw e
+ } catch (_: CancellationException) {
+ closeConnectionQuietly()
+ throw InterruptedIOException()
+ }
+
+ return bytesToRead
+ }
+
+ @Throws(HttpDataSource.HttpDataSourceException::class)
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
+ return try {
+ runBlocking { readInternal(buffer, offset, length) }
+ } catch (_: CancellationException) {
+ throw HttpDataSource.HttpDataSourceException(
+ InterruptedIOException(),
+ dataSpec!!,
+ PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
+ HttpDataSource.HttpDataSourceException.TYPE_READ,
+ )
+ } catch (e: IOException) {
+ throw HttpDataSource.HttpDataSourceException.createForIOException(
+ e,
+ dataSpec!!,
+ HttpDataSource.HttpDataSourceException.TYPE_READ,
+ )
+ } catch (e: Exception) {
+ throw HttpDataSource.HttpDataSourceException(
+ e.message ?: "Unknown error",
+ null,
+ dataSpec!!,
+ PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
+ HttpDataSource.HttpDataSourceException.TYPE_READ,
+ )
+ }
+ }
+
+ override fun close() {
+ if (connectionEstablished) {
+ connectionEstablished = false
+ transferEnded()
+ closeConnectionQuietly()
+ }
+ response = null
+ dataSpec = null
+ }
+
+ @Throws(HttpDataSource.HttpDataSourceException::class)
+ private suspend fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) {
+ if (bytesToSkip == 0L) return
+
+ val skipBuffer = ByteArray(4096)
+ var remaining = bytesToSkip
+
+ try {
+ val channel = responseChannel ?: throw IOException("Channel closed")
+ while (remaining > 0) {
+ val readLength = min(remaining.toInt(), skipBuffer.size)
+ val read = channel.readAvailable(skipBuffer, 0, readLength)
+
+ if (read < 0) {
+ throw HttpDataSource.HttpDataSourceException(
+ dataSpec,
+ PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
+ HttpDataSource.HttpDataSourceException.TYPE_OPEN,
+ )
+ }
+
+ remaining -= read
+ bytesTransferred(read)
+ }
+ } catch (e: IOException) {
+ if (e is HttpDataSource.HttpDataSourceException) throw e
+ throw HttpDataSource.HttpDataSourceException(
+ dataSpec,
+ PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
+ HttpDataSource.HttpDataSourceException.TYPE_OPEN,
+ )
+ }
+ }
+
+ @Throws(IOException::class)
+ private suspend fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int {
+ if (readLength == 0) return 0
+
+ if (bytesToRead != C.LENGTH_UNSET.toLong()) {
+ val bytesRemaining = bytesToRead - bytesRead
+ if (bytesRemaining == 0L) return C.RESULT_END_OF_INPUT
+
+ val actualReadLength = min(readLength.toLong(), bytesRemaining).toInt()
+ return readFromChannel(buffer, offset, actualReadLength)
+ }
+
+ return readFromChannel(buffer, offset, readLength)
+ }
+
+ @Throws(IOException::class)
+ private suspend fun readFromChannel(buffer: ByteArray, offset: Int, readLength: Int): Int {
+ val channel = responseChannel ?: return C.RESULT_END_OF_INPUT
+ val read = channel.readAvailable(buffer, offset, readLength)
+
+ if (read < 0) return C.RESULT_END_OF_INPUT
+
+ bytesRead += read
+ bytesTransferred(read)
+ return read
+ }
+
+ private fun closeConnectionQuietly() {
+ responseChannel?.cancel(null)
+ responseChannel = null
+ }
+}
diff --git a/libraries/datasource_ktor/src/main/java/androidx/media3/datasource/ktor/package-info.java b/libraries/datasource_ktor/src/main/java/androidx/media3/datasource/ktor/package-info.java
new file mode 100644
index 00000000000..c60143af1f0
--- /dev/null
+++ b/libraries/datasource_ktor/src/main/java/androidx/media3/datasource/ktor/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@NonNullApi
+package androidx.media3.datasource.ktor;
+
+import androidx.media3.common.util.NonNullApi;