Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -21,6 +21,12 @@ object DatabaseVersionResolver {
// rows are plain Brotli.
const val MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY = 2

// From this MAJOR version on (ADFA-3305), Tooltips is a WITHOUT ROWID table keyed by
// (categoryId, tag) whose text is in TooltipTexts, and TooltipButtons is keyed by (tooltipId,
// buttonNumberId) with its text in TooltipButtonDescriptions and TooltipButtonUris. Below it the
// tooltip tables have the earlier shape, which this build neither reads nor writes.
const val MAJOR_VERSION_WITH_KEYED_TOOLTIPS = 3

private const val QUERY_VERSION_TABLE_EXISTS = """
SELECT 1
FROM sqlite_master
Expand Down Expand Up @@ -135,6 +141,20 @@ object DatabaseVersionResolver {
}
}

/**
* Throws [IllegalStateException] unless [db] declares at least [MAJOR_VERSION_WITH_KEYED_TOOLTIPS],
* naming the version it does declare. Tooltip readers and writers call it first, so a database
* with the earlier tooltip tables fails with that reason rather than on a missing table or
* column partway through.
*/
fun requireKeyedTooltips(db: SQLiteDatabase) {
val major = resolveMajorVersion(db)
check(major != null && major >= MAJOR_VERSION_WITH_KEYED_TOOLTIPS) {
"documentation.db declares documentation version ${major ?: "none"}; its tooltip tables need " +
"$MAJOR_VERSION_WITH_KEYED_TOOLTIPS or later"
}
}

/**
* Formats database change metadata into a readable version string.
*
Expand Down
51 changes: 35 additions & 16 deletions docs/documentation-database.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion idetooltips/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This module provides functionalities such as:

* **Dynamic Tooltip Display:** Show tooltips anchored to specific UI elements or coordinates.
* **Rich Content:** Supports HTML content within tooltips for flexible formatting, including links.
* **Database Integration:** Fetches tooltip content and metadata from the Room database - `IDETooltipDatabase`.
* **Database Integration:** Reads tooltip content read-only from `documentation.db` with raw SQLite (`TooltipQuery.kt`), the database described in [docs/documentation-database.md](../docs/documentation-database.md).
* **"See More" Functionality:** Handles expanding content or navigating to detailed documentation.
* **Custom callbacks:** Customizable action callbacks for link clicks.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,13 @@ object TooltipManager {
private val debugDatabaseFile: File = File(android.os.Environment.getExternalStorageDirectory().toString() +
"/Download/documentation.db")

private const val QUERY_TOOLTIP_BUTTONS = """
SELECT description, uri
FROM TooltipButtons
WHERE tooltipId = ?
ORDER BY buttonNumberId
"""

suspend fun getTooltip(context: Context, category: String, tag: String): IDETooltipItem? {
Log.d(TAG, "In getTooltip() for category='$category', tag='$tag'.")

return withContext(Dispatchers.IO) {
var dbPath = Environment.DOC_DB.absolutePath
// Named in the error log: a refused database could be either file, and only the log says which.
var dbSource = "installed database"

// TODO: The debug database code should only exist in a debug build. --DS, 30-Jul-2025
val debugDatabaseTimestamp =
Expand All @@ -77,6 +72,7 @@ object TooltipManager {
if (debugDatabaseTimestamp > databaseTimestamp) {
// Switch to the debug database.
dbPath = debugDatabaseFile.absolutePath
dbSource = "debug database"
}

try {
Expand All @@ -88,41 +84,7 @@ object TooltipManager {
"n/a"
}

queryTooltip(database, category = category, tag = 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
// shows nothing rather than an "n/a" popup.
0 -> throw NoTooltipFoundException(category, tag)
1 -> { /* Expected case, continue processing */
}

else -> throw DatabaseCorruptionException(
"Multiple tooltips found for category='$category', tag='$tag' (found ${c.count} rows). " +
"Each category/tag combination should be unique."
)
}

c.moveToFirst()
val rowId = c.getInt(0)
val tooltipId = c.getInt(1)
val summary = c.getString(2)
val detail = c.getString(3)

val buttons = ArrayList<Pair<String, String>>()
database.rawQuery(QUERY_TOOLTIP_BUTTONS, arrayOf(tooltipId.toString())).use { bc ->
while (bc.moveToNext()) {
buttons.add(
Pair(
bc.getString(0),
"http://localhost:6174/" + bc.getString(1)
)
)
}
}

IDETooltipItem(rowId, tooltipId, category, tag, summary, detail, buttons, lastChange)
}
readTooltip(database, category = category, tag = tag, lastChange = lastChange)
}
} catch (e: NoTooltipFoundException) {
Log.d(TAG, "No tooltip found for category='$category', tag='$tag'")
Expand All @@ -133,7 +95,7 @@ object TooltipManager {
} catch (e: Exception) {
Log.e(
TAG,
"Error getting tooltip for category='$category', tag='$tag': ${e.message}"
"Error getting tooltip for category='$category', tag='$tag' from the $dbSource '$dbPath': ${e.message}"
)
null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,99 @@ package com.itsaky.androidide.idetooltips
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import androidx.annotation.VisibleForTesting
import com.itsaky.androidide.utils.DatabaseVersionResolver

// 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).
// From documentation version 3 on (ADFA-3305), Tooltips is keyed by (categoryId, tag) and holds
// only the id; the text is in TooltipTexts, one row per id. Looks the category up first, so the
// tag is found by that key. Written as a join, it made SQLite's planner scan all of Tooltips 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.
@VisibleForTesting
internal const val QUERY_TOOLTIP = """
SELECT T.rowid, T.id, T.summary, T.detail
SELECT T.id, X.summary, X.detail
FROM Tooltips AS T
JOIN TooltipTexts AS X ON X.id = T.id
WHERE T.categoryId IN (SELECT id FROM TooltipCategories WHERE category = ?)
AND T.tag = ?
"""

// TooltipButtons is keyed by (tooltipId, buttonNumberId), so a tooltip's buttons are one range of
// that key, already in display order. Each distinct description and uri is stored once.
@VisibleForTesting
internal const val QUERY_TOOLTIP_BUTTONS = """
SELECT D.description, U.uri
FROM TooltipButtons AS B
JOIN TooltipButtonDescriptions AS D ON D.id = B.descriptionId
JOIN TooltipButtonUris AS U ON U.id = B.uriId
WHERE B.tooltipId = ?
ORDER BY B.buttonNumberId
"""

// Outside TooltipManager, so tests can run it without initializing the object, which reads the
// installed app's Environment. Binds the arguments in the order QUERY_TOOLTIP expects them.
// Refuses a database older than version 3 first, rather than fail on whichever old table or
// column the query meets.
internal fun queryTooltip(
database: SQLiteDatabase,
category: String,
tag: String,
): Cursor = database.rawQuery(QUERY_TOOLTIP, arrayOf(category, tag))
): Cursor {
DatabaseVersionResolver.requireKeyedTooltips(database)
return database.rawQuery(QUERY_TOOLTIP, arrayOf(category, tag))
}

/**
* The tooltip for [category] and [tag] as TooltipManager shows it, its buttons pointed at the local
* documentation server, stamped with [lastChange]. Throws [NoTooltipFoundException] when the
* database has none and [DatabaseCorruptionException] when it has more than one.
*/
internal fun readTooltip(
database: SQLiteDatabase,
category: String,
tag: String,
lastChange: String,
): IDETooltipItem =
queryTooltip(database, category = category, tag = 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
// shows nothing rather than an "n/a" popup.
0 -> {
throw NoTooltipFoundException(category, tag)
}

1 -> { // Expected case, continue processing
}

else -> {
throw DatabaseCorruptionException(
"Multiple tooltips found for category='$category', tag='$tag' (found ${c.count} rows). " +
"Each category/tag combination should be unique.",
)
}
}

c.moveToFirst()
val tooltipId = c.getInt(0)
val summary = c.getString(1)
val detail = c.getString(2)

val buttons = ArrayList<Pair<String, String>>()
for ((description, uri) in queryTooltipButtons(database, tooltipId)) {
buttons.add(Pair(description, "http://localhost:6174/$uri"))
}

// Tooltips has no rowid from documentation version 3 on. It always equalled
// id before (id was the INTEGER PRIMARY KEY), so the debug dialog's Row is
// still the id.
IDETooltipItem(tooltipId, tooltipId, category, tag, summary, detail, buttons, lastChange)
}

/** The (description, uri) of each of [tooltipId]'s buttons, in display order. */
internal fun queryTooltipButtons(
database: SQLiteDatabase,
tooltipId: Int,
): List<Pair<String, String>> =
database.rawQuery(QUERY_TOOLTIP_BUTTONS, arrayOf(tooltipId.toString())).use { c ->
buildList { while (c.moveToNext()) add(Pair(c.getString(0), c.getString(1))) }
}
Loading
Loading