Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 1 addition & 1 deletion docs/documentation-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Of the five sites below, only `DocumentationContentSource` and `ToolTipManager`
- **`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.
- **`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.
- **`idetooltips/.../ToolTipManager.kt`** — serves Tier 1/2. Looks up a tooltip by `(category, tag)`: the category's id from `TooltipCategories` first, so the row is found through `Tooltips`' `UNIQUE (categoryId, tag)` index rather than a scan of the whole table (ADFA-6597). Then `TooltipButtons` for the Tier 3 links shown at the bottom, which still scans that table: it has no index on `tooltipId`.
- **`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.

## Editing the database
Expand Down
1 change: 1 addition & 0 deletions idetooltips/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ dependencies {
implementation(projects.common)

testImplementation(libs.tests.junit)
testImplementation(libs.tests.robolectric)
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,15 @@ object TooltipManager {
private val debugDatabaseFile: File = File(android.os.Environment.getExternalStorageDirectory().toString() +
"/Download/documentation.db")

private const val QUERY_TOOLTIP = """
// Looks the category up first, so the tag is found through Tooltips' UNIQUE (categoryId, tag)
// index. Written as a join, it made SQLite's planner scan all of Tooltips (46,128 rows, 13 MB)
// for every tooltip shown, because documentation.db carries no ANALYZE statistics (ADFA-6597).
// IN rather than =, so two categories sharing a name still come back as duplicates, as before.
internal const val QUERY_TOOLTIP = """
SELECT T.rowid, T.id, T.summary, T.detail
FROM Tooltips AS T, TooltipCategories AS TC
WHERE T.categoryId = TC.id
FROM Tooltips AS T
WHERE T.categoryId IN (SELECT id FROM TooltipCategories WHERE category = ?)
AND T.tag = ?
AND TC.category = ?
"""


Expand Down Expand Up @@ -93,7 +96,7 @@ object TooltipManager {
"n/a"
}

database.rawQuery(QUERY_TOOLTIP, arrayOf(tag, category)).use { c ->
database.rawQuery(QUERY_TOOLTIP, arrayOf(category, tag)).use { c ->
when (c.count) {
// A missing tag is a normal outcome (e.g. a Gradle task with no
// authored tooltip). Honor the nullable contract so the caller
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.itsaky.androidide.idetooltips

import android.database.sqlite.SQLiteDatabase
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.ConscryptMode

/**
* Runs [TooltipManager.QUERY_TOOLTIP] against documentation.db's own Tooltips schema. The query
* plan matters as much as the rows: the join it replaced scanned the whole 13 MB table for every
* tooltip shown (ADFA-6597).
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [28])
@ConscryptMode(ConscryptMode.Mode.OFF)
class TooltipQueryTest {
private lateinit var db: SQLiteDatabase

@Before
fun setUp() {
db = SQLiteDatabase.create(null)
db.execSQL("CREATE TABLE TooltipCategories (id INTEGER PRIMARY KEY, category TEXT NOT NULL)")
db.execSQL(
"CREATE TABLE Tooltips (id INTEGER PRIMARY KEY AUTOINCREMENT, categoryId INTEGER NOT NULL, " +
"tag TEXT NOT NULL, summary TEXT NOT NULL, detail TEXT NOT NULL, UNIQUE (categoryId, tag), " +
"FOREIGN KEY(categoryId) REFERENCES TooltipCategories(id))",
)
listOf("ide", "xml", "java", "kotlin").forEachIndexed { i, category ->
db.execSQL("INSERT INTO TooltipCategories VALUES (?, ?)", arrayOf(i + 1, category))
}
db.beginTransaction()
for (categoryId in 1..4) {
for (n in 0 until 500) {
db.execSQL(
"INSERT INTO Tooltips (categoryId, tag, summary, detail) VALUES (?, ?, ?, ?)",
arrayOf(categoryId, "tag$n", "summary $categoryId/$n", "detail"),
)
}
}
db.setTransactionSuccessful()
db.endTransaction()
}

@After
fun tearDown() {
db.close()
}

@Test
fun `finds the one tooltip for a category and tag`() {
db.rawQuery(TooltipManager.QUERY_TOOLTIP, arrayOf("java", "tag7")).use { c ->
assertEquals(1, c.count)
c.moveToFirst()
assertEquals("summary 3/7", c.getString(2))
}
db.rawQuery(TooltipManager.QUERY_TOOLTIP, arrayOf("java", "no-such-tag")).use { assertEquals(0, it.count) }
db.rawQuery(TooltipManager.QUERY_TOOLTIP, arrayOf("no-such-category", "tag7")).use { assertEquals(0, it.count) }
}

@Test
fun `two categories with the same name still come back as two rows`() {
db.execSQL("INSERT INTO TooltipCategories VALUES (5, 'java')")
db.execSQL("INSERT INTO Tooltips (categoryId, tag, summary, detail) VALUES (5, 'tag7', 'duplicate', 'detail')")

db.rawQuery(TooltipManager.QUERY_TOOLTIP, arrayOf("java", "tag7")).use { assertEquals(2, it.count) }
}

@Test
fun `finds the tag through the (categoryId, tag) index, not a scan of Tooltips`() {
val plan =
db.rawQuery("EXPLAIN QUERY PLAN " + TooltipManager.QUERY_TOOLTIP, arrayOf("java", "tag7")).use { c ->
buildList { while (c.moveToNext()) add(c.getString(3)) }
}

assertTrue(plan.toString(), plan.any { "sqlite_autoindex_Tooltips_1" in it })
assertFalse(plan.toString(), plan.any { Regex("""\bSCAN (TABLE )?(T|Tooltips)\b""").containsMatchIn(it) })
}
}
Loading