From 634b80003e47f4db1de6c42f7e3be89e6497f9c9 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Wed, 13 May 2026 16:20:27 -0700 Subject: [PATCH 1/4] Handle missing documentation.db LastChange wholedb record by fallback; never crash regardless of LastChange table --- .../TooltipDebugDialogScreenshotTest.kt | 83 ++++++++++++++ app/src/debug/AndroidManifest.xml | 10 ++ .../TooltipScreenshotHostActivity.kt | 9 ++ .../androidide/localWebServer/WebServer.kt | 18 +-- .../utils/DatabaseVersionResolverTest.kt | 107 ++++++++++++++++++ .../utils/DatabaseVersionResolver.kt | 71 ++++++++++++ .../androidide/idetooltips/ToolTipManager.kt | 14 +-- 7 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/idetooltips/TooltipDebugDialogScreenshotTest.kt create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/java/com/itsaky/androidide/idetooltips/TooltipScreenshotHostActivity.kt create mode 100644 common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt create mode 100644 common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/idetooltips/TooltipDebugDialogScreenshotTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/idetooltips/TooltipDebugDialogScreenshotTest.kt new file mode 100644 index 0000000000..a1367f5ca7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/idetooltips/TooltipDebugDialogScreenshotTest.kt @@ -0,0 +1,83 @@ +package com.itsaky.androidide.idetooltips + +import android.widget.Button +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import com.itsaky.androidide.idetooltips.TooltipCategory +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipScreenshotHostActivity +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Drives a tooltip popup → info dialog and captures a screenshot to + * /sdcard/test-screenshots/.png. The scenario name is passed in + * via the instrumentation arg `scenario` (defaults to "default"). + * + * Before invoking, push the test documentation.db onto + * /sdcard/Download/documentation.db and `touch` it so TooltipManager picks it up + * over the bundled DOC_DB (TooltipManager only switches if the debug db is newer). + */ +@RunWith(AndroidJUnit4::class) +class TooltipDebugDialogScreenshotTest { + + private val device: UiDevice + get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + private val scenario: String + get() = InstrumentationRegistry.getArguments().getString("scenario") ?: "default" + + @Test + fun capture_tooltipDebugDialog() { + val scenarioName = scenario + + ActivityScenario.launch(TooltipScreenshotHostActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val anchor = Button(activity).apply { + text = "anchor" + contentDescription = "anchor-button" + } + activity.setContentView(anchor) + TooltipManager.showTooltip( + context = activity, + anchorView = anchor, + category = TooltipCategory.CATEGORY_IDE, + tag = "smoke", + ) + } + + // Wait for the info icon to appear inside the tooltip popup. + val infoIcon = device.wait(Until.findObject(By.desc("Info icon")), 7_000) + assertNotNull("tooltip popup never showed Info icon", infoIcon) + + // Click via accessibility (coordinate clicks are intercepted by the WebView overlay). + infoIcon.click() + + // Wait for the debug dialog to render. + assertTrue( + "Tooltip Debug Info dialog never appeared", + device.wait(Until.hasObject(By.text("Tooltip Debug Info")), 7_000), + ) + + // Let the dialog finish drawing before snapping. + device.waitForIdle(500) + + val outDir = File("/sdcard/test-screenshots").apply { mkdirs() } + val outFile = File(outDir, "$scenarioName.png") + outFile.delete() + val ok = device.takeScreenshot(outFile) + assertTrue("UiDevice.takeScreenshot failed for ${outFile.absolutePath}", ok) + assertTrue( + "screenshot file empty: ${outFile.absolutePath} (${outFile.length()} bytes)", + outFile.length() > 0, + ) + } + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..8b8252c79e --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/debug/java/com/itsaky/androidide/idetooltips/TooltipScreenshotHostActivity.kt b/app/src/debug/java/com/itsaky/androidide/idetooltips/TooltipScreenshotHostActivity.kt new file mode 100644 index 0000000000..3934a730fd --- /dev/null +++ b/app/src/debug/java/com/itsaky/androidide/idetooltips/TooltipScreenshotHostActivity.kt @@ -0,0 +1,9 @@ +package com.itsaky.androidide.idetooltips + +import androidx.appcompat.app.AppCompatActivity + +/** + * Empty host activity used by TooltipDebugDialogScreenshotTest to anchor + * a tooltip popup. Debug-only because it has no production purpose. + */ +class TooltipScreenshotHostActivity : AppCompatActivity() diff --git a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt index b0f9a1d149..40ec753750 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -2,6 +2,7 @@ package org.appdevforall.localwebserver import android.database.sqlite.SQLiteDatabase import com.aayushatharva.brotli4j.decoder.BrotliInputStream +import com.itsaky.androidide.utils.DatabaseVersionResolver import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -75,22 +76,7 @@ class WebServer(private val config: ServerConfig) { fun logDatabaseLastChanged() { try { - val query = """ -SELECT changeTime, who -FROM LastChange -""" - val cursor = database.rawQuery(query, arrayOf()) - - try { - if (cursor.count > 0) { - cursor.moveToFirst() - log.debug("Database last change: {} {}.", cursor.getString(0), cursor.getString(1)) - } - - } finally { - cursor.close() - } - + log.debug("Database last change: {}.", DatabaseVersionResolver.resolveDatabaseVersion(database)) } catch (e: Exception) { log.error("Could not retrieve database last change info: {}", e.message) } diff --git a/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt b/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt new file mode 100644 index 0000000000..1ba7c2eab1 --- /dev/null +++ b/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt @@ -0,0 +1,107 @@ +package com.itsaky.androidide.utils + +import android.database.sqlite.SQLiteDatabase +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DatabaseVersionResolverTest { + + private lateinit var db: SQLiteDatabase + + @Before + fun setUp() { + db = SQLiteDatabase.openOrCreateDatabase(":memory:", null) + } + + @After + fun tearDown() { + db.close() + } + + private fun createTable() { + db.execSQL( + "CREATE TABLE LastChange (" + + "documentationSet TEXT, " + + "changeTime TEXT, " + + "who TEXT)" + ) + } + + private fun insertRow(documentationSet: String, changeTime: String, who: String?) { + db.execSQL( + "INSERT INTO LastChange (documentationSet, changeTime, who) VALUES (?, ?, ?)", + arrayOf(documentationSet, changeTime, who), + ) + } + + @Test + fun returnsWholedbRow_whenPresent() { + createTable() + insertRow("wholedb", "2026-05-09 02:00:20", "hal") + insertRow("tooltips-ide", "2026-05-09 02:00:20", "hal") + + assertEquals( + "2026-05-09 02:00:20 hal", + DatabaseVersionResolver.resolveDatabaseVersion(db), + ) + } + + @Test + fun fallsBackToLatestRow_whenWholedbMissing() { + createTable() + insertRow("tooltips-ide", "2026-05-09 02:00:20", "hal") + insertRow("content-y", "2026-05-01 17:42:29", "hal") + insertRow("tooltips-java", "2026-05-09 01:58:37", "hal") + + assertEquals( + "2026-05-09 02:00:20 (tooltips-ide) hal", + DatabaseVersionResolver.resolveDatabaseVersion(db), + ) + } + + @Test + fun returnsVersionUnknown_whenTableEmpty() { + createTable() + + assertEquals( + DatabaseVersionResolver.VERSION_UNKNOWN, + DatabaseVersionResolver.resolveDatabaseVersion(db), + ) + } + + @Test + fun returnsVersionUnknown_whenTableMissing() { + // Intentionally do not create the LastChange table. + assertEquals( + DatabaseVersionResolver.VERSION_UNKNOWN, + DatabaseVersionResolver.resolveDatabaseVersion(db), + ) + } + + @Test + fun handlesNullWho_onWholedbRow() { + createTable() + insertRow("wholedb", "2026-05-09 02:00:20", null) + + assertEquals( + "2026-05-09 02:00:20", + DatabaseVersionResolver.resolveDatabaseVersion(db), + ) + } + + @Test + fun handlesNullWho_onFallbackRow() { + createTable() + insertRow("tooltips-ide", "2026-05-09 02:00:20", null) + + assertEquals( + "2026-05-09 02:00:20 (tooltips-ide)", + DatabaseVersionResolver.resolveDatabaseVersion(db), + ) + } +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt b/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt new file mode 100644 index 0000000000..711905eadd --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt @@ -0,0 +1,71 @@ +package com.itsaky.androidide.utils + +import android.database.sqlite.SQLiteDatabase +import android.util.Log + +object DatabaseVersionResolver { + + const val VERSION_UNKNOWN = "Version Unknown" + + private const val TAG = "DatabaseVersionResolver" + + private const val QUERY_WHOLEDB = """ + SELECT changeTime, who + FROM LastChange + WHERE documentationSet = 'wholedb' + LIMIT 1 + """ + + private const val QUERY_FALLBACK_LATEST = """ + SELECT changeTime, documentationSet, who + FROM LastChange + ORDER BY changeTime DESC + LIMIT 1 + """ + + fun resolveDatabaseVersion(db: SQLiteDatabase): String { + return try { + db.rawQuery(QUERY_WHOLEDB, arrayOf()).use { c -> + if (c.moveToFirst()) { + return formatVersion( + changeTime = c.getString(0), + who = c.getString(1), + ) + } + } + + db.rawQuery(QUERY_FALLBACK_LATEST, arrayOf()).use { c -> + if (c.moveToFirst()) { + val result = formatVersion( + changeTime = c.getString(0), + who = c.getString(2), + documentationSet = c.getString(1), + ) + Log.e( + TAG, + "Missing 'wholedb' record in LastChange table; falling back to $result", + ) + return result + } + } + + Log.e(TAG, "No versioning information available") + VERSION_UNKNOWN + } catch (e: Exception) { + Log.e(TAG, "No versioning information available", e) + VERSION_UNKNOWN + } + } + + private fun formatVersion( + changeTime: String?, + who: String?, + documentationSet: String? = null, + ): String { + val parts = mutableListOf() + if (!changeTime.isNullOrBlank()) parts += changeTime + if (!documentationSet.isNullOrBlank()) parts += "($documentationSet)" + if (!who.isNullOrBlank()) parts += who + return parts.joinToString(separator = " ") + } +} diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt index b902b131e6..497b2f0086 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt @@ -28,6 +28,7 @@ import android.view.MotionEvent import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat.getColor import com.itsaky.androidide.activities.editor.HelpActivity +import com.itsaky.androidide.utils.DatabaseVersionResolver import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeedbackManager import com.itsaky.androidide.utils.isSystemInDarkMode @@ -66,12 +67,6 @@ object TooltipManager { ORDER BY buttonNumberId """ - private const val QUERY_LAST_CHANGE = """ - SELECT changeTime, who - FROM LastChange - WHERE documentationSet = 'wholedb' - """ - suspend fun getTooltip(context: Context, category: String, tag: String): IDETooltipItem? { Log.d(TAG, "In getTooltip() for category='$category', tag='$tag'.") @@ -98,9 +93,10 @@ object TooltipManager { val db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY) db.use { database -> - database.rawQuery(QUERY_LAST_CHANGE, arrayOf()).use { c -> - c.moveToFirst() - lastChange = "${c.getString(0)} ${c.getString(1)}" + try { + lastChange = DatabaseVersionResolver.resolveDatabaseVersion(database) + } catch (e: Exception) { + Log.e(TAG, "Version resolution failed: ${e.message}") } Log.d(TAG, "last change is '${lastChange}'.") From ad4317c95bf5e862694fb1f3eb987c105518b85b Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Wed, 13 May 2026 16:24:56 -0700 Subject: [PATCH 2/4] Delete app/src/debug/java/com/itsaky/androidide/idetooltips/TooltipScreenshotHostActivity.kt --- .../idetooltips/TooltipScreenshotHostActivity.kt | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 app/src/debug/java/com/itsaky/androidide/idetooltips/TooltipScreenshotHostActivity.kt diff --git a/app/src/debug/java/com/itsaky/androidide/idetooltips/TooltipScreenshotHostActivity.kt b/app/src/debug/java/com/itsaky/androidide/idetooltips/TooltipScreenshotHostActivity.kt deleted file mode 100644 index 3934a730fd..0000000000 --- a/app/src/debug/java/com/itsaky/androidide/idetooltips/TooltipScreenshotHostActivity.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.itsaky.androidide.idetooltips - -import androidx.appcompat.app.AppCompatActivity - -/** - * Empty host activity used by TooltipDebugDialogScreenshotTest to anchor - * a tooltip popup. Debug-only because it has no production purpose. - */ -class TooltipScreenshotHostActivity : AppCompatActivity() From 3d10fc567df1f32959b6e029acb89b6fe549dcdd Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Wed, 13 May 2026 16:25:33 -0700 Subject: [PATCH 3/4] Delete app/src/androidTest/kotlin/com/itsaky/androidide/idetooltips/TooltipDebugDialogScreenshotTest.kt --- .../TooltipDebugDialogScreenshotTest.kt | 83 ------------------- 1 file changed, 83 deletions(-) delete mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/idetooltips/TooltipDebugDialogScreenshotTest.kt diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/idetooltips/TooltipDebugDialogScreenshotTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/idetooltips/TooltipDebugDialogScreenshotTest.kt deleted file mode 100644 index a1367f5ca7..0000000000 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/idetooltips/TooltipDebugDialogScreenshotTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.itsaky.androidide.idetooltips - -import android.widget.Button -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.By -import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.Until -import com.itsaky.androidide.idetooltips.TooltipCategory -import com.itsaky.androidide.idetooltips.TooltipManager -import com.itsaky.androidide.idetooltips.TooltipScreenshotHostActivity -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import java.io.File - -/** - * Drives a tooltip popup → info dialog and captures a screenshot to - * /sdcard/test-screenshots/.png. The scenario name is passed in - * via the instrumentation arg `scenario` (defaults to "default"). - * - * Before invoking, push the test documentation.db onto - * /sdcard/Download/documentation.db and `touch` it so TooltipManager picks it up - * over the bundled DOC_DB (TooltipManager only switches if the debug db is newer). - */ -@RunWith(AndroidJUnit4::class) -class TooltipDebugDialogScreenshotTest { - - private val device: UiDevice - get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - private val scenario: String - get() = InstrumentationRegistry.getArguments().getString("scenario") ?: "default" - - @Test - fun capture_tooltipDebugDialog() { - val scenarioName = scenario - - ActivityScenario.launch(TooltipScreenshotHostActivity::class.java).use { scenario -> - scenario.onActivity { activity -> - val anchor = Button(activity).apply { - text = "anchor" - contentDescription = "anchor-button" - } - activity.setContentView(anchor) - TooltipManager.showTooltip( - context = activity, - anchorView = anchor, - category = TooltipCategory.CATEGORY_IDE, - tag = "smoke", - ) - } - - // Wait for the info icon to appear inside the tooltip popup. - val infoIcon = device.wait(Until.findObject(By.desc("Info icon")), 7_000) - assertNotNull("tooltip popup never showed Info icon", infoIcon) - - // Click via accessibility (coordinate clicks are intercepted by the WebView overlay). - infoIcon.click() - - // Wait for the debug dialog to render. - assertTrue( - "Tooltip Debug Info dialog never appeared", - device.wait(Until.hasObject(By.text("Tooltip Debug Info")), 7_000), - ) - - // Let the dialog finish drawing before snapping. - device.waitForIdle(500) - - val outDir = File("/sdcard/test-screenshots").apply { mkdirs() } - val outFile = File(outDir, "$scenarioName.png") - outFile.delete() - val ok = device.takeScreenshot(outFile) - assertTrue("UiDevice.takeScreenshot failed for ${outFile.absolutePath}", ok) - assertTrue( - "screenshot file empty: ${outFile.absolutePath} (${outFile.length()} bytes)", - outFile.length() > 0, - ) - } - } -} From 4c6fe7d961797ed126770ec1fe76d38407f018fb Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Wed, 13 May 2026 16:26:12 -0700 Subject: [PATCH 4/4] Delete app/src/debug/AndroidManifest.xml --- app/src/debug/AndroidManifest.xml | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 app/src/debug/AndroidManifest.xml diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 8b8252c79e..0000000000 --- a/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - -