Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
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 `Range` request 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, reads the stream to its end, and only then applies
* the response's own status and headers (`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, all of the
* content otherwise. (For a range it rejects, it sends none, which this cannot tell.)
*/
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 fails a malformed or unsatisfiable one itself, whatever this returns. Empty
* content is never cut, by the WebView or here.
*/
fun of(
range: String?,
length: Int,
): RangeResponse {
val bounds =
range
?.takeIf { length > 0 }
?.let { singleRange(it) }
?.boundsWithin(length.toLong())
?: return RangeResponse(200, "OK", mapOf(ACCEPT_RANGES), length, length)

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`, kept only when it yields exactly one range. */
private fun singleRange(header: String): 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() }
val ranges = values.map { parseRange(it) ?: return null }
return ranges.singleOrNull()
}

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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.itsaky.androidide.documentation

import com.google.common.truth.Truth.assertThat
import org.junit.Test
import java.io.ByteArrayInputStream

/**
* Covers the answer to a `Range` request (ADFA-3305). [delivered] plays the WebView's part, as
* Chromium's `InputStreamReader` does it: skip to the range's first byte, then read to the end.
*/
class RangeResponseTest {
private val content = ByteArray(1000) { (it * 7).toByte() }

private fun delivered(
reply: RangeResponse,
first: Int,
): ByteArray = ByteArrayInputStream(content, 0, reply.streamLength).apply { skip(first.toLong()) }.readBytes()

private fun assertWhole(reply: RangeResponse) {
assertThat(reply.status).isEqualTo(200)
assertThat(reply.headers).containsExactly("Accept-Ranges", "bytes")
assertThat(reply.streamLength).isEqualTo(content.size)
assertThat(reply.sentLength).isEqualTo(content.size)
}

private fun assertPartial(
range: String,
first: Int,
last: Int,
) {
val reply = RangeResponse.of(range, content.size)

assertThat(reply.status).isEqualTo(206)
assertThat(reply.reason).isEqualTo("Partial Content")
assertThat(reply.headers).containsExactly("Accept-Ranges", "bytes", "Content-Range", "bytes $first-$last/1000")
assertThat(delivered(reply, first)).isEqualTo(content.copyOfRange(first, last + 1))
assertThat(reply.sentLength).isEqualTo(last - first + 1)
}

@Test
fun `a request with no range gets the whole content, and is told ranges work`() {
assertWhole(RangeResponse.of(null, content.size))
}

// The case behind the frozen clips: a player's re-request from partway through the file.
@Test
fun `an open range from partway through gets a 206 from that byte`() {
assertPartial("bytes=600-", 600, 999)
}

// The WebView reads the stream to its end, so the stream itself has to stop at the last byte.
@Test
fun `a closed range delivers no bytes past its end`() {
assertPartial("bytes=0-99", 0, 99)
assertPartial("bytes=250-260", 250, 260)
}

@Test
fun `a suffix range delivers the last bytes`() {
assertPartial("bytes=-300", 700, 999)
}

@Test
fun `a range running past the end is cut at the end`() {
assertPartial("bytes=900-5000", 900, 999)
assertPartial("bytes=-5000", 0, 999)
}

@Test
fun `the unit is matched regardless of case, and blanks and empty items are skipped`() {
assertPartial(" Bytes = 10 - 19 ", 10, 19)
assertPartial("bytes=10-19,", 10, 19)
}

// Chromium holds "not given" as -1, so a last position of -1 leaves the range open.
@Test
fun `a last position of -1 reads as an open range, as it does in the WebView`() {
assertPartial("bytes=5--1", 5, 999)
}

@Test
fun `several ranges get the whole content, which is what the WebView sends for them`() {
assertWhole(RangeResponse.of("bytes=0-9,20-29", content.size))
}

@Test
fun `a range the WebView rejects gets the whole content`() {
for (range in listOf("bytes=1000-", "bytes=9-0", "bytes=-0", "bytes=abc", "items=0-9", "bytes 0-9", "bytes=")) {
assertWhole(RangeResponse.of(range, content.size))
}
}

@Test
fun `empty content is never cut`() {
for (range in listOf("bytes=0-", "bytes=-5")) {
val reply = RangeResponse.of(range, 0)

assertThat(reply.status).isEqualTo(200)
assertThat(reply.streamLength).isEqualTo(0)
}
}

@Test
fun `finds the range header whatever the case of its name`() {
assertThat(DocumentationRequestInterceptor.rangeHeader(mapOf("range" to "bytes=0-"))).isEqualTo("bytes=0-")
assertThat(DocumentationRequestInterceptor.rangeHeader(mapOf("Range" to "bytes=5-"))).isEqualTo("bytes=5-")
assertThat(DocumentationRequestInterceptor.rangeHeader(mapOf("Accept" to "*/*"))).isNull()
assertThat(DocumentationRequestInterceptor.rangeHeader(null)).isNull()
}
}
2 changes: 1 addition & 1 deletion docs/documentation-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ CREATE TABLE Tooltips (
Of the five sites below, only `DocumentationContentSource` and `ToolTipManager` open the file, with `SQLiteDatabase.openDatabase(..., OPEN_READONLY)` — `WebServer` and the interceptor read through the content source, and `PluginDocumentationManager` is the one write path, opening `OPEN_READWRITE` to install plugin content (see ADR 0001 for why raw SQLite is justified here instead of Room).

- **`common/.../documentation/DocumentationContentSource.kt`** — the one Tier 3 pipeline that reads this database: row lookup (a request target is matched raw first, since stored `Content.path` values are percent-encoded, with a percent-decoded fallback on a miss — shared by both transports so they agree on which pages exist), chunked-row reassembly, dictionary-aware Brotli decode gated on the declared documentation version, and the database swaps — to a newer sdcard debug database, and reopening the installed file when an asset install rewrites it in place under the cached handle — under a read/write lock so several threads can read while a swap cannot close the handle under them. It also renders the rows that are Pebble template contexts (`templateId > 0`, the Kotlin doc set's pages) — resolving each template's references to other templates against `Templates.name` as it goes — so both transports below serve finished pages and neither needs the template engine itself. All of that logic exists once (ADFA-5176).
- **`common/.../documentation/DocumentationRequestInterceptor.kt`** — serves Tier 3 *in-process* for the app's WebViews (`HelpActivity`, the tooltip fragment, `FAQActivity`), through `WebViewClient.shouldInterceptRequest`, so a page's assets cost a database read instead of a TCP connection each (ADFA-5176). It matches the same `http://localhost:6174/...` URL space, so the strings.xml entries, `ToolTipManager`'s link builder and the `DocumentationExtension` contract need no changes; anything it declines — a `/pr/` endpoint, an unknown path, a failed read — falls through to `WebServer` unchanged. Both transports get their `Content-Type` charset from the same place, `ContentTypeHeaders` (ADFA-5241), so a row does not describe itself differently depending on which one served it. Set `/sdcard/Download/CodeOnTheGo.nointercept` to force documentation back onto the server.
- **`common/.../documentation/DocumentationRequestInterceptor.kt`** — serves Tier 3 *in-process* for the app's WebViews (`HelpActivity`, the tooltip fragment, `FAQActivity`), through `WebViewClient.shouldInterceptRequest`, so a page's assets cost a database read instead of a TCP connection each (ADFA-5176). It matches the same `http://localhost:6174/...` URL space, so the strings.xml entries, `ToolTipManager`'s link builder and the `DocumentationExtension` contract need no changes; anything it declines — a `/pr/` endpoint, an unknown path, a failed read — falls through to `WebServer` unchanged. Both transports get their `Content-Type` charset from the same place, `ContentTypeHeaders` (ADFA-5241), so a row does not describe itself differently depending on which one served it. They answer `Range` differently, both validly: the interceptor answers one satisfiable range with a 206 and `Content-Range` (`RangeResponse`, ADFA-3305), because the WebView cuts an in-process body to the range itself and a 200 would then start mid-file; `WebServer` ignores `Range` and sends the whole row with a 200. Set `/sdcard/Download/CodeOnTheGo.nointercept` to force documentation back onto the server.
- **`app/.../localWebServer/WebServer.kt`** — serves Tier 3 over HTTP on port 6174, for WebViews that are not wired to the interceptor above and for the `/pr/` developer endpoints. It reads through `DocumentationContentSource`, so it holds no database, template engine or decode logic of its own; what remains here is HTTP: request parsing, the `/pr/` pages, error responses, and the CSS/asset shortcuts. Also serves a Dynamic Bookshelf JSON payload (joining `Content`/`Bookshelf`/`BookCategories`, rendered through the `bookshelf` template) and debug-only HTML dumps at `/pr/db` (`LastChange`, last 20 rows) and `/pr/pr` (recent projects, from a *different* database).
- **`idetooltips/.../ToolTipManager.kt`** — serves Tier 1/2. Looks up `Tooltips` joined to `TooltipCategories` by `(category, tag)`, then `TooltipButtons` for the Tier 3 links shown at the bottom.
- **`plugin-manager/.../documentation/PluginDocumentationManager.kt`** (with `Tier3AssetWalker.kt`, and the `DocumentationExtension` contract in `plugin-api`) — lets plugins contribute their own help content into the same lookup paths, under the reserved `plugin/<pluginId>/...` prefix. Brotli assets are compressed against the same `CompressionDictionary` the rest of the table uses, read from the database being written. When that dictionary can't be read the install is abandoned rather than written plain, and retried on the plugin's next activation.
Expand Down
Loading