Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ dependencies {
testImplementation(libs.tests.kotlinx.coroutines)
testImplementation(libs.tests.google.truth)
testImplementation(libs.tests.mockk)
// Directly, not through projects.testing.unit, which depends on this module.
testImplementation(libs.tests.robolectric)
androidTestImplementation(projects.testing.android)

// brotli4j
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import java.util.concurrent.atomic.AtomicLong
* fails -- falls through to that server unchanged.
*
* Templated pages included: [DocumentationContentSource] renders them, so every documentation path
* a WebView asks for is answered here.
* a WebView asks for is answered here. A single satisfiable `Range` gets a 206; see [RangeResponse].
*/
class DocumentationRequestInterceptor(
private val contentSource: DocumentationContentSource,
Expand All @@ -59,7 +59,7 @@ class DocumentationRequestInterceptor(
*/
fun intercept(request: WebResourceRequest): WebResourceResponse? =
try {
contentFor(request)?.let { response(it) }
replyFor(request)?.let { (content, reply) -> response(content, reply) }
} catch (e: Throwable) {
// Throwable, not Exception. The documented contract is that anything this returns null for
// falls through to the web server, and that only holds for values, not throws: decoding the
Expand Down Expand Up @@ -89,17 +89,33 @@ class DocumentationRequestInterceptor(
val path = url.encodedPath?.removePrefix("/").orEmpty()
if (path.isEmpty() || path.startsWith("pr/")) return null

val content =
when (val lookup = contentSource.lookupRequestPath(path).lookup) {
is DocumentationLookup.Found -> lookup.content
else -> return null
}
return when (val lookup = contentSource.lookupRequestPath(path).lookup) {
is DocumentationLookup.Found -> lookup.content
else -> null
}
}

/**
* The content for [request] and how to answer its `Range`, counted in [servedSummary] by what
* the WebView sends: for a 206 that is the range, not the whole row.
*
* @return The content and its [RangeResponse], or `null` when [contentFor] returns `null`.
*/
internal fun replyFor(request: WebResourceRequest): Pair<DocumentationContent, RangeResponse>? {
val content = contentFor(request) ?: return null
val range = rangeHeader(request.requestHeaders)
val reply = RangeResponse.of(range, content.bytes.size)

servedRequests.incrementAndGet()
servedBytes.addAndGet(content.bytes.size.toLong())
if (log.isDebugEnabled) log.debug("Served '{}' in-process, {} bytes.", path, content.bytes.size)
servedBytes.addAndGet(reply.sentLength.toLong())
if (log.isDebugEnabled) {
log.debug("Served '{}' in-process, {} of {} bytes.", request.url.encodedPath, reply.sentLength, content.bytes.size)
if (range != null) {
log.debug("Answered Range '{}' with {} {}.", range, reply.status, reply.headers["Content-Range"].orEmpty())
}
}

return content
return content to reply
}

/** What this instance has answered without a socket. */
Expand All @@ -117,14 +133,29 @@ class DocumentationRequestInterceptor(
* Creates a WebView resource response from documentation content.
*
* @param content The documentation content to serve.
* @param reply The status, headers and stream length for the request's `Range`.
* @return A response containing the content bytes and parsed MIME metadata.
*/
private fun response(content: DocumentationContent): WebResourceResponse {
private fun response(
content: DocumentationContent,
reply: RangeResponse,
): WebResourceResponse {
val (type, charset) = mimeAndCharset(content.mimeType)
return WebResourceResponse(type, charset, ByteArrayInputStream(content.bytes))
return WebResourceResponse(
type,
charset,
reply.status,
reply.reason,
reply.headers,
ByteArrayInputStream(content.bytes, 0, reply.streamLength),
)
}

companion object {
/** A request's `Range` value, looked up regardless of case, as header names are. */
internal fun rangeHeader(headers: Map<String, String>?): String? =
headers?.entries?.firstOrNull { it.key.equals("Range", ignoreCase = true) }?.value

/**
* Separates a MIME type from its optional charset.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.itsaky.androidide.documentation

import java.util.Locale

/**
* The status, headers and stream length of an in-process response, given the request's `Range`.
*
* The WebView, not this code, cuts the body to the range. For a single range it can satisfy,
* Chromium's `AndroidStreamReaderURLLoader` skips the response stream to the range's first byte,
* sets `Content-Length` to the range's length, applies the response's own status and headers, and
* then reads the stream to its end (`components/embedder_support/android/util`, read at WebView
* 155.0.8059.43). Without a status of ours, a ranged request got "200 OK" with a body that
* began mid-file, which a media player takes for the file from byte 0: looping `<video>` clips
* froze after a re-request during playback (ADFA-3305). So a satisfiable range gets a 206 with
* `Content-Range`, and a stream that stops at the range's last byte, since the WebView reads on
* past it.
*
* @property status The HTTP status code.
* @property reason The reason phrase for [status].
* @property headers The headers to add. Neither `Content-Length` nor `Content-Type` is among them:
* the WebView sets both, and adds these beside its own rather than in place of them.
* @property streamLength How many bytes of the content, from its start, the response stream holds.
* @property sentLength How many bytes the WebView sends: the range's length for a 206, none for a
* range it rejects, since it fails that request, and all of the content otherwise.
*/
internal data class RangeResponse(
val status: Int,
val reason: String,
val headers: Map<String, String>,
val streamLength: Int,
val sentLength: Int,
) {
companion object {
/**
* The response to [range], a request's `Range` value or null, for content of [length] bytes.
*
* Parsed as the WebView parses it (`net::HttpUtil::ParseRangeHeader`, then
* `net::HttpByteRange::ComputeBounds`), so the two agree on which bytes are meant. Anything
* but one satisfiable range gets all the content with a 200: the WebView ignores several
* ranges and sends everything, and fails a malformed or unsatisfiable one itself, whatever
* this returns, so that one counts as nothing sent. Empty content is never cut, by the
* WebView or here.
*/
fun of(
range: String?,
length: Int,
): RangeResponse {
val whole = RangeResponse(200, "OK", mapOf(ACCEPT_RANGES), length, length)
if (range == null || length == 0) return whole

val rejected = whole.copy(sentLength = 0)
val single = (ranges(range) ?: return rejected).singleOrNull() ?: return whole
val bounds = single.boundsWithin(length.toLong()) ?: return rejected

return RangeResponse(
206,
"Partial Content",
mapOf(ACCEPT_RANGES, "Content-Range" to "bytes ${bounds.first}-${bounds.last}/$length"),
(bounds.last + 1).toInt(),
(bounds.last - bounds.first + 1).toInt(),
)
}

private val ACCEPT_RANGES = "Accept-Ranges" to "bytes"
private const val NOT_SPECIFIED = -1L

/** `HttpUtil::ParseRangeHeader`: the ranges [header] names, or null where the WebView rejects it. */
private fun ranges(header: String): List<ByteRange>? {
val equals = header.indexOf('=')
if (equals < 0 || header.substring(0, equals).trimLws().lowercase(Locale.ROOT) != "bytes") return null

val values =
header
.substring(equals + 1)
.split(',')
.map { it.trimLws() }
.filter { it.isNotEmpty() }
return values.map { parseRange(it) ?: return null }.ifEmpty { null }
}

private fun parseRange(value: String): ByteRange? {
val minus = value.indexOf('-')
if (minus < 0) return null

val firstText = value.substring(0, minus).trimLws()
val lastText = value.substring(minus + 1).trimLws()
val first = if (firstText.isEmpty()) NOT_SPECIFIED else firstText.toLongOrNull() ?: return null
val range =
when {
lastText.isNotEmpty() -> {
val last = lastText.toLongOrNull() ?: return null
if (firstText.isEmpty()) ByteRange(suffix = last) else ByteRange(first = first, last = last)
}

firstText.isEmpty() -> {
return null
}

else -> {
ByteRange(first = first)
}
}
return range.takeIf { it.isValid }
}

private fun String.trimLws() = trim(' ', '\t')
}

/**
* One range as `net::HttpByteRange` holds it, -1 standing for "not given" as it does there; so
* `bytes=5--1` reads as `bytes=5-` here too.
*/
private class ByteRange(
val first: Long = NOT_SPECIFIED,
val last: Long = NOT_SPECIFIED,
val suffix: Long = NOT_SPECIFIED,
) {
val isValid: Boolean
get() = suffix > 0 || (first >= 0 && (last == NOT_SPECIFIED || last >= first))

/** `HttpByteRange::ComputeBounds`: the byte positions meant within [size], or null if none are. */
fun boundsWithin(size: Long): LongRange? =
when {
!isValid -> null
suffix != NOT_SPECIFIED -> (size - minOf(size, suffix))..(size - 1)
first < size -> first..(if (last == NOT_SPECIFIED) size - 1 else minOf(size - 1, last))
else -> null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.itsaky.androidide.documentation

import android.net.Uri
import android.webkit.WebResourceRequest
import com.google.common.truth.Truth.assertThat
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.ConscryptMode

/**
* Covers what [DocumentationRequestInterceptor.intercept] hands the WebView, which needs the
* framework's real `WebResourceResponse`: a 206 lost between [RangeResponse] and the response is
* how four looping clips froze on a phone (ADFA-3305). The WebView itself skips to the range's
* first byte, so the stream is expected to hold the content up to the range's last byte.
*
* Conscrypt off: Robolectric otherwise installs it as the JVM's first security provider and leaves
* it there, and SelfSignedCertTest, run later in the same JVM, then parses its certificate with it
* and fails.
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [28])
@ConscryptMode(ConscryptMode.Mode.OFF)
class DocumentationRequestInterceptorResponseTest {
private val source =
mockk<DocumentationContentSource>(relaxed = true) {
every { lookupRequestPath(any()) } answers {
RequestLookup(firstArg(), DocumentationLookup.Found(DocumentationContent("abcdef".toByteArray(), "video/webm")))
}
}

private fun request(headers: Map<String, String>): WebResourceRequest =
mockk {
every { method } returns "GET"
every { url } returns Uri.parse("http://localhost:6174/a/clip.webm")
every { requestHeaders } returns headers
}

@Test
fun `a ranged request gets a 206, its range headers, and a stream that stops at the range`() {
val response = DocumentationRequestInterceptor(source).intercept(request(mapOf("Range" to "bytes=1-2")))!!

assertThat(response.statusCode).isEqualTo(206)
assertThat(response.reasonPhrase).isEqualTo("Partial Content")
assertThat(response.responseHeaders).containsExactly("Accept-Ranges", "bytes", "Content-Range", "bytes 1-2/6")
assertThat(response.mimeType).isEqualTo("video/webm")
assertThat(response.data.readBytes().toString(Charsets.UTF_8)).isEqualTo("abc")
}

@Test
fun `a request with no range gets a 200 and the whole content`() {
val response = DocumentationRequestInterceptor(source).intercept(request(emptyMap()))!!

assertThat(response.statusCode).isEqualTo(200)
assertThat(response.responseHeaders).containsExactly("Accept-Ranges", "bytes")
assertThat(response.data.readBytes().toString(Charsets.UTF_8)).isEqualTo("abcdef")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class DocumentationRequestInterceptorTest {
private fun request(
url: String = "http://localhost:6174/i/index.html",
method: String = "GET",
headers: Map<String, String> = emptyMap(),
): WebResourceRequest {
// Uri is a framework class, so stand in for the three parts the interceptor reads. Only
// encodedPath is stubbed, not path: a call to the decoded form should fail the test.
Expand All @@ -60,6 +61,7 @@ class DocumentationRequestInterceptorTest {
return mockk {
every { this@mockk.method } returns method
every { this@mockk.url } returns uri
every { requestHeaders } returns headers
}
}

Expand Down Expand Up @@ -135,11 +137,22 @@ class DocumentationRequestInterceptorTest {
@Test
fun `reports what it has served`() {
val interceptor = DocumentationRequestInterceptor(source)
repeat(3) { interceptor.contentFor(request()) }
repeat(3) { interceptor.replyFor(request()) }

assertThat(interceptor.servedSummary()).isEqualTo("3 requests, 12 bytes served in-process")
}

// For a 206 the WebView sends the range, not the row: a player's 100-byte probe of a 1.2 MB clip
// once counted as the whole clip.
@Test
fun `counts what a range sends, not the whole row`() {
val interceptor = DocumentationRequestInterceptor(source)
val (_, reply) = interceptor.replyFor(request(headers = mapOf("Range" to "bytes=1-2")))!!

assertThat(reply.status).isEqualTo(206)
assertThat(interceptor.servedSummary()).isEqualTo("1 requests, 2 bytes served in-process")
}

@Test
fun `splits a stored MIME type into what a WebResourceResponse needs`() {
assertThat(DocumentationRequestInterceptor.mimeAndCharset("text/html; charset=utf-8"))
Expand Down
Loading
Loading