> {
+ return readableDatabase.attachedDbs
+ }
+
+ override fun isDatabaseIntegrityOk(): Boolean {
+ return readableDatabase.isDatabaseIntegrityOk
+ }
+}
diff --git a/spinner/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/spinner/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..18c0565852
--- /dev/null
+++ b/spinner/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spinner/app/src/main/res/drawable/ic_launcher_background.xml b/spinner/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..c71b77f4d2
--- /dev/null
+++ b/spinner/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/spinner/app/src/main/res/layout/activity_main.xml b/spinner/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000000..37aea0966a
--- /dev/null
+++ b/spinner/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spinner/app/src/main/res/layout/item.xml b/spinner/app/src/main/res/layout/item.xml
new file mode 100644
index 0000000000..a0cd66dbdd
--- /dev/null
+++ b/spinner/app/src/main/res/layout/item.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spinner/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/spinner/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..9244248264
--- /dev/null
+++ b/spinner/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/spinner/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/spinner/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000000..9244248264
--- /dev/null
+++ b/spinner/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/spinner/app/src/main/res/mipmap-hdpi/ic_launcher.png b/spinner/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..a571e60098
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/spinner/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/spinner/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..61da551c55
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/spinner/app/src/main/res/mipmap-mdpi/ic_launcher.png b/spinner/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..c41dd28531
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/spinner/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/spinner/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..db5080a752
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/spinner/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/spinner/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..6dba46dab1
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/spinner/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/spinner/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..da31a871c8
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/spinner/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/spinner/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..15ac681720
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/spinner/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/spinner/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..b216f2d313
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/spinner/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/spinner/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..f25a419744
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/spinner/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/spinner/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..e96783ccce
Binary files /dev/null and b/spinner/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/spinner/app/src/main/res/values-night/themes.xml b/spinner/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000000..6c4404c52b
--- /dev/null
+++ b/spinner/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/spinner/app/src/main/res/values/colors.xml b/spinner/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..09837df62f
--- /dev/null
+++ b/spinner/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/spinner/app/src/main/res/values/strings.xml b/spinner/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..22c1c3c042
--- /dev/null
+++ b/spinner/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Spinner Test
+
\ No newline at end of file
diff --git a/spinner/app/src/main/res/values/themes.xml b/spinner/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000000..c17f427bda
--- /dev/null
+++ b/spinner/app/src/main/res/values/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/spinner/lib/build.gradle b/spinner/lib/build.gradle
new file mode 100644
index 0000000000..f8e36c79e6
--- /dev/null
+++ b/spinner/lib/build.gradle
@@ -0,0 +1,42 @@
+plugins {
+ id 'com.android.library'
+ id 'kotlin-android'
+ id 'org.jlleitschuh.gradle.ktlint'
+}
+
+android {
+ buildToolsVersion BUILD_TOOL_VERSION
+ compileSdkVersion COMPILE_SDK
+
+ defaultConfig {
+ minSdkVersion MINIMUM_SDK
+ targetSdkVersion TARGET_SDK
+ }
+
+ compileOptions {
+ sourceCompatibility JAVA_VERSION
+ targetCompatibility JAVA_VERSION
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+}
+
+ktlint {
+ // Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
+ version = "0.43.2"
+}
+
+dependencies {
+ // Can't use the newest version because it hits some weird NoClassDefFoundException
+ implementation 'com.github.jknack:handlebars:4.0.7'
+
+ implementation libs.androidx.appcompat
+ implementation libs.material.material
+ implementation libs.androidx.sqlite
+ implementation project(':core-util')
+ testImplementation testLibs.junit.junit
+
+ implementation 'org.nanohttpd:nanohttpd-webserver:2.3.1'
+}
\ No newline at end of file
diff --git a/spinner/lib/src/main/AndroidManifest.xml b/spinner/lib/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..cd8bdc69a9
--- /dev/null
+++ b/spinner/lib/src/main/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/spinner/lib/src/main/assets/browse.hbs b/spinner/lib/src/main/assets/browse.hbs
new file mode 100644
index 0000000000..ae4dedfd18
--- /dev/null
+++ b/spinner/lib/src/main/assets/browse.hbs
@@ -0,0 +1,47 @@
+
+ {{> head title="Browse" }}
+
+
+ {{> prefix isBrowse=true}}
+
+
+
+
+
+ {{#if table}}
+ {{table}}
+ {{else}}
+ Data
+ {{/if}}
+
+ {{#if queryResult}}
+ {{queryResult.rowCount}} row(s).
+
+
+ {{#each queryResult.columns}}
+ {{this}} |
+ {{/each}}
+
+ {{#each queryResult.rows}}
+
+ {{#each this}}
+ {{this}} |
+ {{/each}}
+
+ {{/each}}
+
+ {{else}}
+ Select a table from above and click 'browse'.
+ {{/if}}
+
+ {{> suffix}}
+
+
diff --git a/spinner/lib/src/main/assets/error.hbs b/spinner/lib/src/main/assets/error.hbs
new file mode 100644
index 0000000000..c21e71fe48
--- /dev/null
+++ b/spinner/lib/src/main/assets/error.hbs
@@ -0,0 +1,8 @@
+
+ {{> head title="Error :(" }}
+
+ Hit an exception while trying to serve the page :(
+
+ {{{this}}}
+
+
diff --git a/spinner/lib/src/main/assets/head.hbs b/spinner/lib/src/main/assets/head.hbs
new file mode 100644
index 0000000000..991968a2b8
--- /dev/null
+++ b/spinner/lib/src/main/assets/head.hbs
@@ -0,0 +1,86 @@
+
+ Spinner - {{ title }}
+
+
+
+
diff --git a/spinner/lib/src/main/assets/overview.hbs b/spinner/lib/src/main/assets/overview.hbs
new file mode 100644
index 0000000000..c602b5bed2
--- /dev/null
+++ b/spinner/lib/src/main/assets/overview.hbs
@@ -0,0 +1,52 @@
+
+ {{> head title="Home" }}
+
+
+
+
+
+ {{> prefix isOverview=true}}
+
+
+
+ {{#if tables}}
+ {{#each tables}}
+
+
{{{sql}}}
+ {{/each}}
+ {{else}}
+ None.
+ {{/if}}
+
+
+
+
+ {{#if indices}}
+ {{#each indices}}
+
+
{{{sql}}}
+ {{/each}}
+ {{else}}
+ None.
+ {{/if}}
+
+
+
+
+ {{#if triggers}}
+ {{#each triggers}}
+
+
{{{sql}}}
+ {{/each}}
+ {{else}}
+ None.
+ {{/if}}
+
+
+ {{> suffix }}
+
+
diff --git a/spinner/lib/src/main/assets/prefix.hbs b/spinner/lib/src/main/assets/prefix.hbs
new file mode 100644
index 0000000000..21b9acb1e0
--- /dev/null
+++ b/spinner/lib/src/main/assets/prefix.hbs
@@ -0,0 +1,31 @@
+SPINNER
+
+
+
+ Device |
+ {{deviceInfo.name}} |
+
+
+ Package |
+ {{deviceInfo.packageName}} |
+
+
+ App Version |
+ {{deviceInfo.appVersion}} |
+
+
+
+
+ Database:
+
+
+
+
+ - Overview
+ - Browse
+ - Query
+
\ No newline at end of file
diff --git a/spinner/lib/src/main/assets/query.hbs b/spinner/lib/src/main/assets/query.hbs
new file mode 100644
index 0000000000..dbb37de0e9
--- /dev/null
+++ b/spinner/lib/src/main/assets/query.hbs
@@ -0,0 +1,43 @@
+
+ {{> head title="Query" }}
+
+
+ {{> prefix isQuery=true}}
+
+
+
+
+
+ Data
+ {{#if queryResult}}
+ {{queryResult.rowCount}} row(s).
+ {{queryResult.timeToFirstRow}} ms to read the first row.
+ {{queryResult.timeToReadRows}} ms to read the rest of the rows.
+
+
+
+ {{#each queryResult.columns}}
+ {{this}} |
+ {{/each}}
+
+ {{#each queryResult.rows}}
+
+ {{#each this}}
+ {{this}} |
+ {{/each}}
+
+ {{/each}}
+
+ {{else}}
+ No data.
+ {{/if}}
+
+ {{> suffix}}
+
+
diff --git a/spinner/lib/src/main/assets/suffix.hbs b/spinner/lib/src/main/assets/suffix.hbs
new file mode 100644
index 0000000000..c9157f431f
--- /dev/null
+++ b/spinner/lib/src/main/assets/suffix.hbs
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/spinner/lib/src/main/java/org/signal/spinner/AssetTemplateLoader.kt b/spinner/lib/src/main/java/org/signal/spinner/AssetTemplateLoader.kt
new file mode 100644
index 0000000000..7cc82f0885
--- /dev/null
+++ b/spinner/lib/src/main/java/org/signal/spinner/AssetTemplateLoader.kt
@@ -0,0 +1,47 @@
+package org.signal.spinner
+
+import android.content.Context
+import com.github.jknack.handlebars.io.StringTemplateSource
+import com.github.jknack.handlebars.io.TemplateLoader
+import com.github.jknack.handlebars.io.TemplateSource
+import org.signal.core.util.StreamUtil
+import java.nio.charset.Charset
+
+/**
+ * A loader read handlebars templates from the assets directory.
+ */
+class AssetTemplateLoader(private val context: Context) : TemplateLoader {
+
+ override fun sourceAt(location: String): TemplateSource {
+ val content: String = StreamUtil.readFullyAsString(context.assets.open("$location.hbs"))
+ return StringTemplateSource(location, content)
+ }
+
+ override fun resolve(location: String): String {
+ return location
+ }
+
+ override fun getPrefix(): String {
+ return ""
+ }
+
+ override fun getSuffix(): String {
+ return ""
+ }
+
+ override fun setPrefix(prefix: String) {
+ TODO("Not yet implemented")
+ }
+
+ override fun setSuffix(suffix: String) {
+ TODO("Not yet implemented")
+ }
+
+ override fun setCharset(charset: Charset?) {
+ TODO("Not yet implemented")
+ }
+
+ override fun getCharset(): Charset {
+ return Charset.defaultCharset()
+ }
+}
diff --git a/spinner/lib/src/main/java/org/signal/spinner/DatabaseUtil.kt b/spinner/lib/src/main/java/org/signal/spinner/DatabaseUtil.kt
new file mode 100644
index 0000000000..ed49b8ad3c
--- /dev/null
+++ b/spinner/lib/src/main/java/org/signal/spinner/DatabaseUtil.kt
@@ -0,0 +1,27 @@
+package org.signal.spinner
+
+import android.database.Cursor
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+fun SupportSQLiteDatabase.getTableNames(): List {
+ val out = mutableListOf()
+ this.query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name ASC").use { cursor ->
+ while (cursor.moveToNext()) {
+ out += cursor.getString(0)
+ }
+ }
+
+ return out
+}
+
+fun SupportSQLiteDatabase.getTables(): Cursor {
+ return this.query("SELECT * FROM sqlite_master WHERE type='table' ORDER BY name ASC")
+}
+
+fun SupportSQLiteDatabase.getIndexes(): Cursor {
+ return this.query("SELECT * FROM sqlite_master WHERE type='index' ORDER BY name ASC")
+}
+
+fun SupportSQLiteDatabase.getTriggers(): Cursor {
+ return this.query("SELECT * FROM sqlite_master WHERE type='trigger' ORDER BY name ASC")
+}
diff --git a/spinner/lib/src/main/java/org/signal/spinner/Spinner.kt b/spinner/lib/src/main/java/org/signal/spinner/Spinner.kt
new file mode 100644
index 0000000000..dcc420653d
--- /dev/null
+++ b/spinner/lib/src/main/java/org/signal/spinner/Spinner.kt
@@ -0,0 +1,27 @@
+package org.signal.spinner
+
+import android.content.Context
+import androidx.sqlite.db.SupportSQLiteDatabase
+import org.signal.core.util.logging.Log
+import java.io.IOException
+
+/**
+ * A class to help initialize Spinner, our database debugging interface.
+ */
+object Spinner {
+ val TAG: String = Log.tag(Spinner::class.java)
+
+ fun init(context: Context, deviceInfo: DeviceInfo, databases: Map) {
+ try {
+ SpinnerServer(context, deviceInfo, databases).start()
+ } catch (e: IOException) {
+ Log.w(TAG, "Spinner server hit IO exception! Restarting.", e)
+ }
+ }
+
+ data class DeviceInfo(
+ val name: String,
+ val packageName: String,
+ val appVersion: String
+ )
+}
diff --git a/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt b/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt
new file mode 100644
index 0000000000..fb54f9aacf
--- /dev/null
+++ b/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt
@@ -0,0 +1,311 @@
+package org.signal.spinner
+
+import android.content.Context
+import android.database.Cursor
+import android.util.Base64
+import androidx.sqlite.db.SupportSQLiteDatabase
+import com.github.jknack.handlebars.Handlebars
+import com.github.jknack.handlebars.Template
+import com.github.jknack.handlebars.helper.ConditionalHelpers
+import fi.iki.elonen.NanoHTTPD
+import org.signal.core.util.ExceptionUtil
+import org.signal.core.util.logging.Log
+import java.lang.IllegalArgumentException
+import kotlin.math.max
+
+/**
+ * The workhorse of this lib. Handles all of our our web routing and response generation.
+ *
+ * In general, you add routes in [serve], and then build a response by creating a handlebars template (in the assets folder) and then passing in a data class
+ * to [renderTemplate].
+ */
+internal class SpinnerServer(
+ context: Context,
+ private val deviceInfo: Spinner.DeviceInfo,
+ private val databases: Map
+) : NanoHTTPD(5000) {
+
+ companion object {
+ private val TAG = Log.tag(SpinnerServer::class.java)
+ }
+
+ private val handlebars: Handlebars = Handlebars(AssetTemplateLoader(context)).apply {
+ registerHelper("eq", ConditionalHelpers.eq)
+ }
+
+ override fun serve(session: IHTTPSession): Response {
+ if (session.method == Method.POST) {
+ // Needed to populate session.parameters
+ session.parseBody(mutableMapOf())
+ }
+
+ val dbParam: String = session.queryParam("db") ?: session.parameters["db"]?.toString() ?: databases.keys.first()
+ val db: SupportSQLiteDatabase = databases[dbParam] ?: return internalError(IllegalArgumentException("Invalid db param!"))
+
+ try {
+ return when {
+ session.method == Method.GET && session.uri == "/" -> getIndex(dbParam, db)
+ session.method == Method.GET && session.uri == "/browse" -> getBrowse(dbParam, db)
+ session.method == Method.POST && session.uri == "/browse" -> postBrowse(dbParam, db, session)
+ session.method == Method.GET && session.uri == "/query" -> getQuery(dbParam, db)
+ session.method == Method.POST && session.uri == "/query" -> postQuery(dbParam, db, session)
+ else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "Not found")
+ }
+ } catch (t: Throwable) {
+ Log.e(TAG, t)
+ return internalError(t)
+ }
+ }
+
+ private fun getIndex(dbName: String, db: SupportSQLiteDatabase): Response {
+ return renderTemplate(
+ "overview",
+ OverviewPageModel(
+ deviceInfo = deviceInfo,
+ database = dbName,
+ databases = databases.keys.toList(),
+ tables = db.getTables().toTableInfo(),
+ indices = db.getIndexes().toIndexInfo(),
+ triggers = db.getTriggers().toTriggerInfo(),
+ queryResult = db.getTables().toQueryResult()
+ )
+ )
+ }
+
+ private fun getBrowse(dbName: String, db: SupportSQLiteDatabase): Response {
+ return renderTemplate(
+ "browse",
+ BrowsePageModel(
+ deviceInfo = deviceInfo,
+ database = dbName,
+ databases = databases.keys.toList(),
+ tableNames = db.getTableNames()
+ )
+ )
+ }
+
+ private fun postBrowse(dbName: String, db: SupportSQLiteDatabase, session: IHTTPSession): Response {
+ val table: String = session.parameters["table"]?.get(0).toString()
+ val query = "select * from $table"
+
+ return renderTemplate(
+ "browse",
+ BrowsePageModel(
+ deviceInfo = deviceInfo,
+ database = dbName,
+ databases = databases.keys.toList(),
+ tableNames = db.getTableNames(),
+ table = table,
+ queryResult = db.query(query).toQueryResult()
+ )
+ )
+ }
+
+ private fun getQuery(dbName: String, db: SupportSQLiteDatabase): Response {
+ return renderTemplate(
+ "query",
+ QueryPageModel(
+ deviceInfo = deviceInfo,
+ database = dbName,
+ databases = databases.keys.toList(),
+ query = ""
+ )
+ )
+ }
+
+ private fun postQuery(dbName: String, db: SupportSQLiteDatabase, session: IHTTPSession): Response {
+ val action: String = session.parameters["action"]?.get(0).toString()
+ val rawQuery: String = session.parameters["query"]?.get(0).toString()
+ val query = if (action == "analyze") "EXPLAIN QUERY PLAN $rawQuery" else rawQuery
+ val startTime = System.currentTimeMillis()
+
+ return renderTemplate(
+ "query",
+ QueryPageModel(
+ deviceInfo = deviceInfo,
+ database = dbName,
+ databases = databases.keys.toList(),
+ query = rawQuery,
+ queryResult = db.query(query).toQueryResult(startTime)
+ )
+ )
+ }
+
+ private fun internalError(throwable: Throwable): Response {
+ val stackTrace = ExceptionUtil.convertThrowableToString(throwable)
+ .split("\n")
+ .map { it.trim() }
+ .mapIndexed { index, s -> if (index == 0) s else " $s" }
+ .joinToString("
")
+
+ return renderTemplate("error", stackTrace)
+ }
+
+ private fun renderTemplate(assetName: String, model: Any): Response {
+ val template: Template = handlebars.compile(assetName)
+ val output: String = template.apply(model)
+ return newFixedLengthResponse(output)
+ }
+
+ private fun Cursor.toQueryResult(queryStartTime: Long = 0): QueryResult {
+ val numColumns = this.columnCount
+
+ val columns = mutableListOf()
+ for (i in 0 until numColumns) {
+ columns += getColumnName(i)
+ }
+
+ var timeOfFirstRow = 0L
+ val rows = mutableListOf>()
+ while (moveToNext()) {
+ if (timeOfFirstRow == 0L) {
+ timeOfFirstRow = System.currentTimeMillis()
+ }
+
+ val row = mutableListOf()
+ for (i in 0 until numColumns) {
+ val data: String? = when (getType(i)) {
+ Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(getBlob(i), 0)
+ else -> getString(i)
+ }
+ row += data ?: "null"
+ }
+ rows += row
+ }
+
+ if (timeOfFirstRow == 0L) {
+ timeOfFirstRow = System.currentTimeMillis()
+ }
+
+ return QueryResult(
+ columns = columns,
+ rows = rows,
+ timeToFirstRow = max(timeOfFirstRow - queryStartTime, 0),
+ timeToReadRows = max(System.currentTimeMillis() - timeOfFirstRow, 0)
+ )
+ }
+
+ private fun Cursor.toTableInfo(): List {
+ val tables = mutableListOf()
+
+ while (moveToNext()) {
+ val name = getString(getColumnIndexOrThrow("name"))
+ tables += TableInfo(
+ name = name ?: "null",
+ sql = getString(getColumnIndexOrThrow("sql"))?.formatAsSqlCreationStatement(name) ?: "null"
+ )
+ }
+
+ return tables
+ }
+
+ private fun Cursor.toIndexInfo(): List {
+ val indices = mutableListOf()
+
+ while (moveToNext()) {
+ indices += IndexInfo(
+ name = getString(getColumnIndexOrThrow("name")) ?: "null",
+ sql = getString(getColumnIndexOrThrow("sql")) ?: "null"
+ )
+ }
+
+ return indices
+ }
+
+ private fun Cursor.toTriggerInfo(): List {
+ val indices = mutableListOf()
+
+ while (moveToNext()) {
+ indices += TriggerInfo(
+ name = getString(getColumnIndexOrThrow("name")) ?: "null",
+ sql = getString(getColumnIndexOrThrow("sql")) ?: "null"
+ )
+ }
+
+ return indices
+ }
+
+ /** Takes a SQL table creation statement and formats it using HTML */
+ private fun String.formatAsSqlCreationStatement(name: String): String {
+ val fields = substring(indexOf("(") + 1, this.length - 1).split(",")
+ val fieldStrings = fields.map { s -> " ${s.trim()},
" }.toMutableList()
+
+ if (fieldStrings.isNotEmpty()) {
+ fieldStrings[fieldStrings.lastIndex] = " ${fields.last().trim()}
"
+ }
+
+ return "CREATE TABLE $name (
" +
+ fieldStrings.joinToString("") +
+ ")"
+ }
+
+ private fun IHTTPSession.queryParam(name: String): String? {
+ if (queryParameterString == null) {
+ return null
+ }
+
+ val params: Map = queryParameterString
+ .split("&")
+ .mapNotNull { part ->
+ val parts = part.split("=")
+ if (parts.size == 2) {
+ parts[0] to parts[1]
+ } else {
+ null
+ }
+ }
+ .toMap()
+
+ return params[name]
+ }
+
+ data class OverviewPageModel(
+ val deviceInfo: Spinner.DeviceInfo,
+ val database: String,
+ val databases: List,
+ val tables: List,
+ val indices: List,
+ val triggers: List,
+ val queryResult: QueryResult? = null
+ )
+
+ data class BrowsePageModel(
+ val deviceInfo: Spinner.DeviceInfo,
+ val database: String,
+ val databases: List,
+ val tableNames: List,
+ val table: String? = null,
+ val queryResult: QueryResult? = null
+ )
+
+ data class QueryPageModel(
+ val deviceInfo: Spinner.DeviceInfo,
+ val database: String,
+ val databases: List,
+ val query: String = "",
+ val queryResult: QueryResult? = null
+ )
+
+ data class QueryResult(
+ val columns: List,
+ val rows: List>,
+ val rowCount: Int = rows.size,
+ val timeToFirstRow: Long,
+ val timeToReadRows: Long,
+ )
+
+ data class TableInfo(
+ val name: String,
+ val sql: String
+ )
+
+ data class IndexInfo(
+ val name: String,
+ val sql: String
+ )
+
+ data class TriggerInfo(
+ val name: String,
+ val sql: String
+ )
+}