Add a 'Recent' tab to Spinner.

This commit is contained in:
Greyson Parrelli 2022-02-21 12:39:07 -05:00
parent acecd5f013
commit 9594be8fcf
20 changed files with 404 additions and 131 deletions

View file

@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
object DatabaseMonitor {
private var queryMonitor: QueryMonitor? = null
fun initialize(queryMonitor: QueryMonitor?) {
DatabaseMonitor.queryMonitor = queryMonitor
}
@JvmStatic
fun onSql(sql: String, args: Array<Any>?) {
queryMonitor?.onSql(sql, args)
}
@JvmStatic
fun onQuery(distinct: Boolean, table: String, projection: Array<String>?, selection: String?, args: Array<Any>?, groupBy: String?, having: String?, orderBy: String?, limit: String?) {
queryMonitor?.onQuery(distinct, table, projection, selection, args, groupBy, having, orderBy, limit)
}
@JvmStatic
fun onDelete(table: String, selection: String?, args: Array<Any>?) {
queryMonitor?.onDelete(table, selection, args)
}
@JvmStatic
fun onUpdate(table: String, values: ContentValues, selection: String?, args: Array<Any>?) {
queryMonitor?.onUpdate(table, values, selection, args)
}
}

View file

@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
interface QueryMonitor {
fun onSql(sql: String, args: Array<Any>?)
fun onQuery(distinct: Boolean, table: String, projection: Array<String>?, selection: String?, args: Array<Any>?, groupBy: String?, having: String?, orderBy: String?, limit: String?)
fun onDelete(table: String, selection: String?, args: Array<Any>?)
fun onUpdate(table: String, values: ContentValues, selection: String?, args: Array<Any>?)
}

View file

@ -226,34 +226,42 @@ public class SQLiteDatabase {
} }
public Cursor query(boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) { public Cursor query(boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) {
DatabaseMonitor.onQuery(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
return traceSql("query(9)", table, selection, false, () -> wrapped.query(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit)); return traceSql("query(9)", table, selection, false, () -> wrapped.query(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit));
} }
public Cursor queryWithFactory(net.zetetic.database.sqlcipher.SQLiteDatabase.CursorFactory cursorFactory, boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) { public Cursor queryWithFactory(net.zetetic.database.sqlcipher.SQLiteDatabase.CursorFactory cursorFactory, boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) {
DatabaseMonitor.onQuery(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
return traceSql("queryWithFactory()", table, selection, false, () -> wrapped.queryWithFactory(cursorFactory, distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit)); return traceSql("queryWithFactory()", table, selection, false, () -> wrapped.queryWithFactory(cursorFactory, distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit));
} }
public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy) { public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy) {
DatabaseMonitor.onQuery(false, table, columns, selection, selectionArgs, groupBy, having, orderBy, null);
return traceSql("query(7)", table, selection, false, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy)); return traceSql("query(7)", table, selection, false, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy));
} }
public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) { public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) {
DatabaseMonitor.onQuery(false, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
return traceSql("query(8)", table, selection, false, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy, limit)); return traceSql("query(8)", table, selection, false, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy, limit));
} }
public Cursor rawQuery(String sql, String[] selectionArgs) { public Cursor rawQuery(String sql, String[] selectionArgs) {
DatabaseMonitor.onSql(sql, selectionArgs);
return traceSql("rawQuery(2a)", sql, false, () -> wrapped.rawQuery(sql, selectionArgs)); return traceSql("rawQuery(2a)", sql, false, () -> wrapped.rawQuery(sql, selectionArgs));
} }
public Cursor rawQuery(String sql, Object[] args) { public Cursor rawQuery(String sql, Object[] args) {
DatabaseMonitor.onSql(sql, args);
return traceSql("rawQuery(2b)", sql, false,() -> wrapped.rawQuery(sql, args)); return traceSql("rawQuery(2b)", sql, false,() -> wrapped.rawQuery(sql, args));
} }
public Cursor rawQueryWithFactory(net.zetetic.database.sqlcipher.SQLiteDatabase.CursorFactory cursorFactory, String sql, String[] selectionArgs, String editTable) { public Cursor rawQueryWithFactory(net.zetetic.database.sqlcipher.SQLiteDatabase.CursorFactory cursorFactory, String sql, String[] selectionArgs, String editTable) {
DatabaseMonitor.onSql(sql, selectionArgs);
return traceSql("rawQueryWithFactory()", sql, false, () -> wrapped.rawQueryWithFactory(cursorFactory, sql, selectionArgs, editTable)); return traceSql("rawQueryWithFactory()", sql, false, () -> wrapped.rawQueryWithFactory(cursorFactory, sql, selectionArgs, editTable));
} }
public Cursor rawQuery(String sql, String[] selectionArgs, int initialRead, int maxRead) { public Cursor rawQuery(String sql, String[] selectionArgs, int initialRead, int maxRead) {
DatabaseMonitor.onSql(sql, selectionArgs);
return traceSql("rawQuery(4)", sql, false, () -> rawQuery(sql, selectionArgs, initialRead, maxRead)); return traceSql("rawQuery(4)", sql, false, () -> rawQuery(sql, selectionArgs, initialRead, maxRead));
} }
@ -278,10 +286,12 @@ public class SQLiteDatabase {
} }
public int delete(String table, String whereClause, String[] whereArgs) { public int delete(String table, String whereClause, String[] whereArgs) {
DatabaseMonitor.onDelete(table, whereClause, whereArgs);
return traceSql("delete()", table, whereClause, true, () -> wrapped.delete(table, whereClause, whereArgs)); return traceSql("delete()", table, whereClause, true, () -> wrapped.delete(table, whereClause, whereArgs));
} }
public int update(String table, ContentValues values, String whereClause, String[] whereArgs) { public int update(String table, ContentValues values, String whereClause, String[] whereArgs) {
DatabaseMonitor.onUpdate(table, values, whereClause, whereArgs);
return traceSql("update()", table, whereClause, true, () -> wrapped.update(table, values, whereClause, whereArgs)); return traceSql("update()", table, whereClause, true, () -> wrapped.update(table, values, whereClause, whereArgs));
} }
@ -290,14 +300,17 @@ public class SQLiteDatabase {
} }
public void execSQL(String sql) throws SQLException { public void execSQL(String sql) throws SQLException {
DatabaseMonitor.onSql(sql, null);
traceSql("execSQL(1)", sql, true, () -> wrapped.execSQL(sql)); traceSql("execSQL(1)", sql, true, () -> wrapped.execSQL(sql));
} }
public void rawExecSQL(String sql) { public void rawExecSQL(String sql) {
DatabaseMonitor.onSql(sql, null);
traceSql("rawExecSQL()", sql, true, () -> wrapped.rawExecSQL(sql)); traceSql("rawExecSQL()", sql, true, () -> wrapped.rawExecSQL(sql));
} }
public void execSQL(String sql, Object[] bindArgs) throws SQLException { public void execSQL(String sql, Object[] bindArgs) throws SQLException {
DatabaseMonitor.onSql(sql, null);
traceSql("execSQL(2)", sql, true, () -> wrapped.execSQL(sql, bindArgs)); traceSql("execSQL(2)", sql, true, () -> wrapped.execSQL(sql, bindArgs));
} }

View file

@ -1,13 +1,16 @@
package org.thoughtcrime.securesms package org.thoughtcrime.securesms
import android.content.ContentValues
import android.os.Build import android.os.Build
import leakcanary.LeakCanary import leakcanary.LeakCanary
import org.signal.spinner.Spinner import org.signal.spinner.Spinner
import org.thoughtcrime.securesms.database.DatabaseMonitor
import org.thoughtcrime.securesms.database.JobDatabase import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.database.KeyValueDatabase import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.database.LocalMetricsDatabase import org.thoughtcrime.securesms.database.LocalMetricsDatabase
import org.thoughtcrime.securesms.database.LogDatabase import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.database.MegaphoneDatabase import org.thoughtcrime.securesms.database.MegaphoneDatabase
import org.thoughtcrime.securesms.database.QueryMonitor
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.util.AppSignatureUtil import org.thoughtcrime.securesms.util.AppSignatureUtil
import shark.AndroidReferenceMatchers import shark.AndroidReferenceMatchers
@ -33,6 +36,24 @@ class SpinnerApplicationContext : ApplicationContext() {
) )
) )
DatabaseMonitor.initialize(object : QueryMonitor {
override fun onSql(sql: String, args: Array<Any>?) {
Spinner.onSql("signal", sql, args)
}
override fun onQuery(distinct: Boolean, table: String, projection: Array<String>?, selection: String?, args: Array<Any>?, groupBy: String?, having: String?, orderBy: String?, limit: String?) {
Spinner.onQuery("signal", distinct, table, projection, selection, args, groupBy, having, orderBy, limit)
}
override fun onDelete(table: String, selection: String?, args: Array<Any>?) {
Spinner.onDelete("signal", table, selection, args)
}
override fun onUpdate(table: String, values: ContentValues, selection: String?, args: Array<Any>?) {
Spinner.onUpdate("signal", table, values, selection, args)
}
})
LeakCanary.config = LeakCanary.config.copy( LeakCanary.config = LeakCanary.config.copy(
referenceMatchers = AndroidReferenceMatchers.appDefaults + referenceMatchers = AndroidReferenceMatchers.appDefaults +
AndroidReferenceMatchers.ignoredInstanceField( AndroidReferenceMatchers.ignoredInstanceField(

View file

@ -45,7 +45,7 @@ import java.util.concurrent.atomic.AtomicInteger;
* and we want to create as little overhead as possible. The idea being that it's ok if we don't, * and we want to create as little overhead as possible. The idea being that it's ok if we don't,
* for example, keep a perfect circular buffer size if it allows us to reduce overhead. The only * for example, keep a perfect circular buffer size if it allows us to reduce overhead. The only
* cost of screwing up would be dropping a trace packet or something, which, while sad, won't affect * cost of screwing up would be dropping a trace packet or something, which, while sad, won't affect
* how the app functions. * how the app functions
*/ */
public final class Tracer { public final class Tracer {

View file

@ -1,8 +1,8 @@
<html> <html>
{{> head title="Browse" }} {{> partials/head title="Browse" }}
<body> <body>
{{> prefix isBrowse=true}} {{> partials/prefix isBrowse=true}}
<!-- Table Selector --> <!-- Table Selector -->
<form action="browse" method="post"> <form action="browse" method="post">
@ -70,6 +70,6 @@
<input type="submit" name="action" value="last" {{#if pagingData.lastPage}}disabled{{/if}} /> <input type="submit" name="action" value="last" {{#if pagingData.lastPage}}disabled{{/if}} />
</form> </form>
{{> suffix}} {{> partials/suffix}}
</body> </body>
</html> </html>

View file

@ -0,0 +1,89 @@
html, body {
font-family: 'Roboto Mono', monospace;
font-variant-ligatures: none;
font-size: 12px;
width: 100%;
}
select, input {
font-family: 'Roboto Mono', monospace;
font-variant-ligatures: none;
font-size: 1rem;
}
table, th, td {
border: 1px solid black;
font-size: 1rem;
}
th, td {
padding: 8px;
}
.query-input {
width: 100%;
height: 10rem;
margin-bottom: 8px;
}
li.active {
font-weight: bold;
}
ol.tabs {
margin: 16px 0px 8px 0px;
padding: 0px;
font-size: 0px;
}
.tabs li {
list-style-type: none;
display: inline-block;
padding: 8px;
border-bottom: 1px solid black;
font-size: 1rem;
}
.tabs li.active {
border: 1px solid black;
border-bottom: 0;
}
.tabs a {
text-decoration: none;
color: black;
}
.collapse-header {
cursor: pointer;
}
.collapse-header:before {
content: "⯈ ";
font-size: 1rem;
}
.collapse-header.active:before {
content: "⯆ ";
font-size: 1rem;
}
h2.collapse-header, h2.collapse-header+div {
margin-left: 16px;
}
.hidden {
display: none;
}
table.device-info {
margin-bottom: 16px;
}
table.device-info, table.device-info tr, table.device-info td {
border: 0;
padding: 2px;
font-size: 0.75rem;
font-style: italic;
}

View file

@ -1,5 +1,5 @@
<html> <html>
{{> head title="Error :(" }} {{> partials/head title="Error :(" }}
<body> <body>
Hit an exception while trying to serve the page :( Hit an exception while trying to serve the page :(
<hr/> <hr/>

View file

@ -1,99 +0,0 @@
<head>
<title>Spinner - {{ title }}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌀</text></svg>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap" rel="stylesheet">
<style type="text/css">
html, body {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
select, input {
font-family: 'JetBrains Mono', monospace;
font-size: 1rem;
}
table, th, td {
border: 1px solid black;
font-size: 1rem;
}
th, td {
padding: 8px;
}
.query-input {
width: 100%;
height: 10rem;
margin-bottom: 8px;
}
li.active {
font-weight: bold;
}
ol.tabs {
margin: 16px 0px 8px 0px;
padding: 0px;
font-size: 0px;
}
.tabs li {
list-style-type: none;
display: inline-block;
padding: 8px;
border-bottom: 1px solid black;
font-size: 1rem;
}
.tabs li.active {
border: 1px solid black;
border-bottom: 0;
}
.tabs a {
text-decoration: none;
color: black;
}
.collapse-header {
cursor: pointer;
}
.collapse-header:before {
content: "⯈ ";
font-size: 1rem;
}
.collapse-header.active:before {
content: "⯆ ";
font-size: 1rem;
}
h2.collapse-header, h2.collapse-header+div {
margin-left: 16px;
}
.hidden {
display: none;
}
table.device-info {
margin-bottom: 16px;
}
table.device-info, table.device-info tr, table.device-info td {
border: 0;
padding: 2px;
font-size: 0.75rem;
font-style: italic;
}
</style>
</head>

View file

@ -0,0 +1,15 @@
function init() {
document.querySelectorAll('.collapse-header').forEach(elem => {
elem.onclick = () => {
console.log('clicked');
elem.classList.toggle('active');
document.getElementById(elem.dataset.for).classList.toggle('hidden');
}
});
document.querySelector('#database-selector').onchange = (e) => {
window.location.href = window.location.href.split('?')[0] + '?db=' + e.target.value;
}
}
init();

View file

@ -1,5 +1,5 @@
<html> <html>
{{> head title="Home" }} {{> partials/head title="Home" }}
<style type="text/css"> <style type="text/css">
h1.collapse-header { h1.collapse-header {
@ -12,7 +12,7 @@
<body> <body>
{{> prefix isOverview=true}} {{> partials/prefix isOverview=true}}
<h1 class="collapse-header active" data-for="table-creates">Tables</h1> <h1 class="collapse-header active" data-for="table-creates">Tables</h1>
<div id="table-creates"> <div id="table-creates">
@ -50,6 +50,6 @@
{{/if}} {{/if}}
</div> </div>
{{> suffix }} {{> partials/suffix }}
</body> </body>
</html> </html>

View file

@ -0,0 +1,14 @@
<head>
<title>Spinner - {{ title }}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌀</text></svg>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">
<link href="/css/main.css" rel="stylesheet">
<style type="text/css">
</style>
</head>

View file

@ -28,4 +28,5 @@
<li {{#if isOverview}}class="active"{{/if}}><a href="/?db={{database}}">Overview</a></li> <li {{#if isOverview}}class="active"{{/if}}><a href="/?db={{database}}">Overview</a></li>
<li {{#if isBrowse}}class="active"{{/if}}><a href="/browse?db={{database}}">Browse</a></li> <li {{#if isBrowse}}class="active"{{/if}}><a href="/browse?db={{database}}">Browse</a></li>
<li {{#if isQuery}}class="active"{{/if}}><a href="/query?db={{database}}">Query</a></li> <li {{#if isQuery}}class="active"{{/if}}><a href="/query?db={{database}}">Query</a></li>
<li {{#if isRecent}}class="active"{{/if}}><a href="/recent?db={{database}}">Recent</a></li>
</ol> </ol>

View file

@ -0,0 +1 @@
<script src="/js/main.js" type="text/javascript"></script>

View file

@ -1,8 +1,8 @@
<html> <html>
{{> head title="Query" }} {{> partials/head title="Query" }}
<body> <body>
{{> prefix isQuery=true}} {{> partials/prefix isQuery=true}}
<!-- Query Input --> <!-- Query Input -->
<form action="query" method="post"> <form action="query" method="post">
@ -11,6 +11,8 @@
<input type="submit" name="action" value="run" /> <input type="submit" name="action" value="run" />
or or
<input type="submit" name="action" value="analyze" /> <input type="submit" name="action" value="analyze" />
or
<button onclick="onFormatClicked(event)">format</button>
</form> </form>
<!-- Query Result --> <!-- Query Result -->
@ -38,6 +40,15 @@
No data. No data.
{{/if}} {{/if}}
{{> suffix}} {{> partials/suffix}}
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql-formatter/4.0.2/sql-formatter.min.js" integrity="sha512-JPoVzOHQvXbB4+lOX6GOBM3xOZhwAMKRYn2G0VpfPcwIixAAvPL+HKuaFuevm+i6Q4GktSKY/CxlcB/1BaV/6Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script type="text/javascript">
function onFormatClicked(e) {
e.preventDefault();
const queryInput = document.querySelector('.query-input')
queryInput.value = sqlFormatter.format(queryInput.value).replaceAll("! =", "!=").replaceAll("| |", "||");
}
</script>
</body> </body>
</html> </html>

View file

@ -0,0 +1,50 @@
<html>
{{> partials/head title="Home" }}
<style type="text/css">
h1.collapse-header {
font-size: 1.35rem;
}
h2.collapse-header {
font-size: 1.15rem;
}
table.recent {
width: 100%;
}
</style>
<body>
{{> partials/prefix isRecent=true}}
{{#if recentSql}}
<table class="recent">
{{#each recentSql}}
<tr>
<td>
{{formattedTime}}
</td>
<td>
<form action="query" method="post">
<input type="hidden" name="db" value="{{database}}" />
<input type="hidden" name="query" value="{{query}}" />
<input type="submit" name="action" value="run" />
<input type="submit" name="action" value="analyze" />
</form>
</td>
<td>{{query}}</td>
</tr>
{{/each}}
</table>
{{else}}
No recent queries.
{{/if}}
{{> partials/suffix }}
<script>
function onAnalyzeClicked(id) {
document.getElementById
}
</script>
</body>
</html>

View file

@ -1,17 +0,0 @@
<script type="text/javascript">
function init() {
document.querySelectorAll('.collapse-header').forEach(elem => {
elem.onclick = () => {
console.log('clicked');
elem.classList.toggle('active');
document.getElementById(elem.dataset.for).classList.toggle('hidden');
}
});
document.querySelector('#database-selector').onchange = (e) => {
window.location.href = window.location.href.split('?')[0] + '?db=' + e.target.value;
}
}
init();
</script>

View file

@ -34,4 +34,4 @@ fun SupportSQLiteDatabase.getTableRowCount(table: String): Int {
0 0
} }
} }
} }

View file

@ -1,6 +1,8 @@
package org.signal.spinner package org.signal.spinner
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteQueryBuilder
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import java.io.IOException import java.io.IOException
@ -11,14 +13,80 @@ import java.io.IOException
object Spinner { object Spinner {
val TAG: String = Log.tag(Spinner::class.java) val TAG: String = Log.tag(Spinner::class.java)
private lateinit var server: SpinnerServer
fun init(context: Context, deviceInfo: DeviceInfo, databases: Map<String, SupportSQLiteDatabase>) { fun init(context: Context, deviceInfo: DeviceInfo, databases: Map<String, SupportSQLiteDatabase>) {
try { try {
SpinnerServer(context, deviceInfo, databases).start() server = SpinnerServer(context, deviceInfo, databases)
server.start()
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, "Spinner server hit IO exception! Restarting.", e) Log.w(TAG, "Spinner server hit IO exception!", e)
} }
} }
fun onSql(dbName: String, sql: String, args: Array<Any>?) {
server.onSql(dbName, replaceQueryArgs(sql, args))
}
fun onQuery(dbName: String, distinct: Boolean, table: String, projection: Array<String>?, selection: String?, args: Array<Any>?, groupBy: String?, having: String?, orderBy: String?, limit: String?) {
val queryString = SQLiteQueryBuilder.buildQueryString(distinct, table, projection, selection, groupBy, having, orderBy, limit)
server.onSql(dbName, replaceQueryArgs(queryString, args))
}
fun onDelete(dbName: String, table: String, selection: String?, args: Array<Any>?) {
var query = "DELETE FROM $table"
if (selection != null) {
query += " WHERE $selection"
query = replaceQueryArgs(query, args)
}
server.onSql(dbName, query)
}
fun onUpdate(dbName: String, table: String, values: ContentValues, selection: String?, args: Array<Any>?) {
val query = StringBuilder("UPDATE $table SET ")
for (key in values.keySet()) {
query.append("$key = ${values.get(key)}, ")
}
query.delete(query.length - 2, query.length)
if (selection != null) {
query.append(" WHERE ").append(selection)
}
var queryString = query.toString()
if (args != null) {
queryString = replaceQueryArgs(queryString, args)
}
server.onSql(dbName, queryString)
}
private fun replaceQueryArgs(query: String, args: Array<Any>?): String {
if (args == null) {
return query
}
val builder = StringBuilder()
var i = 0
var argIndex = 0
while (i < query.length) {
if (query[i] == '?' && argIndex < args.size) {
builder.append("'").append(args[argIndex]).append("'")
argIndex++
} else {
builder.append(query[i])
}
i++
}
return builder.toString()
}
data class DeviceInfo( data class DeviceInfo(
val name: String, val name: String,
val packageName: String, val packageName: String,

View file

@ -11,6 +11,9 @@ import fi.iki.elonen.NanoHTTPD
import org.signal.core.util.ExceptionUtil import org.signal.core.util.ExceptionUtil
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import java.lang.IllegalArgumentException import java.lang.IllegalArgumentException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -22,7 +25,7 @@ import kotlin.math.min
* to [renderTemplate]. * to [renderTemplate].
*/ */
internal class SpinnerServer( internal class SpinnerServer(
context: Context, private val context: Context,
private val deviceInfo: Spinner.DeviceInfo, private val deviceInfo: Spinner.DeviceInfo,
private val databases: Map<String, SupportSQLiteDatabase> private val databases: Map<String, SupportSQLiteDatabase>
) : NanoHTTPD(5000) { ) : NanoHTTPD(5000) {
@ -36,6 +39,9 @@ internal class SpinnerServer(
registerHelper("neq", ConditionalHelpers.neq) registerHelper("neq", ConditionalHelpers.neq)
} }
private val recentSql: MutableMap<String, MutableList<QueryItem>> = mutableMapOf()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz", Locale.US)
override fun serve(session: IHTTPSession): Response { override fun serve(session: IHTTPSession): Response {
if (session.method == Method.POST) { if (session.method == Method.POST) {
// Needed to populate session.parameters // Needed to populate session.parameters
@ -47,11 +53,14 @@ internal class SpinnerServer(
try { try {
return when { return when {
session.method == Method.GET && session.uri == "/css/main.css" -> newFileResponse("css/main.css", "text/css")
session.method == Method.GET && session.uri == "/js/main.js" -> newFileResponse("js/main.js", "text/javascript")
session.method == Method.GET && session.uri == "/" -> getIndex(dbParam, db) session.method == Method.GET && session.uri == "/" -> getIndex(dbParam, db)
session.method == Method.GET && session.uri == "/browse" -> getBrowse(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.POST && session.uri == "/browse" -> postBrowse(dbParam, db, session)
session.method == Method.GET && session.uri == "/query" -> getQuery(dbParam, db) session.method == Method.GET && session.uri == "/query" -> getQuery(dbParam, db)
session.method == Method.POST && session.uri == "/query" -> postQuery(dbParam, db, session) session.method == Method.POST && session.uri == "/query" -> postQuery(dbParam, db, session)
session.method == Method.GET && session.uri == "/recent" -> getRecent(dbParam, db)
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "Not found") else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "Not found")
} }
} catch (t: Throwable) { } catch (t: Throwable) {
@ -60,6 +69,17 @@ internal class SpinnerServer(
} }
} }
fun onSql(dbName: String, sql: String) {
val commands: MutableList<QueryItem> = recentSql[dbName] ?: mutableListOf()
commands += QueryItem(System.currentTimeMillis(), sql)
if (commands.size > 100) {
commands.removeAt(0)
}
recentSql[dbName] = commands
}
private fun getIndex(dbName: String, db: SupportSQLiteDatabase): Response { private fun getIndex(dbName: String, db: SupportSQLiteDatabase): Response {
return renderTemplate( return renderTemplate(
"overview", "overview",
@ -139,6 +159,26 @@ internal class SpinnerServer(
) )
} }
private fun getRecent(dbName: String, db: SupportSQLiteDatabase): Response {
val queries: List<RecentQuery>? = recentSql[dbName]
?.map { it ->
RecentQuery(
formattedTime = dateFormat.format(Date(it.time)),
query = it.query
)
}
return renderTemplate(
"recent",
RecentPageModel(
deviceInfo = deviceInfo,
database = dbName,
databases = databases.keys.toList(),
recentSql = queries?.reversed()
)
)
}
private fun postQuery(dbName: String, db: SupportSQLiteDatabase, session: IHTTPSession): Response { private fun postQuery(dbName: String, db: SupportSQLiteDatabase, session: IHTTPSession): Response {
val action: String = session.parameters["action"]?.get(0).toString() val action: String = session.parameters["action"]?.get(0).toString()
val rawQuery: String = session.parameters["query"]?.get(0).toString() val rawQuery: String = session.parameters["query"]?.get(0).toString()
@ -173,6 +213,14 @@ internal class SpinnerServer(
return newFixedLengthResponse(output) return newFixedLengthResponse(output)
} }
private fun newFileResponse(assetPath: String, mimeType: String): Response {
return newChunkedResponse(
Response.Status.OK,
mimeType,
context.assets.open(assetPath)
)
}
private fun Cursor.toQueryResult(queryStartTime: Long = 0): QueryResult { private fun Cursor.toQueryResult(queryStartTime: Long = 0): QueryResult {
val numColumns = this.columnCount val numColumns = this.columnCount
@ -313,6 +361,13 @@ internal class SpinnerServer(
val queryResult: QueryResult? = null val queryResult: QueryResult? = null
) )
data class RecentPageModel(
val deviceInfo: Spinner.DeviceInfo,
val database: String,
val databases: List<String>,
val recentSql: List<RecentQuery>?
)
data class QueryResult( data class QueryResult(
val columns: List<String>, val columns: List<String>,
val rows: List<List<String>>, val rows: List<List<String>>,
@ -346,4 +401,14 @@ internal class SpinnerServer(
val startRow: Int, val startRow: Int,
val endRow: Int val endRow: Int
) )
data class QueryItem(
val time: Long,
val query: String
)
data class RecentQuery(
val formattedTime: String,
val query: String
)
} }