mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 21:15:48 +00:00
Add a 'Recent' tab to Spinner.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<html>
|
||||
{{> head title="Browse" }}
|
||||
{{> partials/head title="Browse" }}
|
||||
<body>
|
||||
|
||||
{{> prefix isBrowse=true}}
|
||||
{{> partials/prefix isBrowse=true}}
|
||||
|
||||
<!-- Table Selector -->
|
||||
<form action="browse" method="post">
|
||||
@@ -70,6 +70,6 @@
|
||||
<input type="submit" name="action" value="last" {{#if pagingData.lastPage}}disabled{{/if}} />
|
||||
</form>
|
||||
|
||||
{{> suffix}}
|
||||
{{> partials/suffix}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
89
spinner/lib/src/main/assets/css/main.css
Normal file
89
spinner/lib/src/main/assets/css/main.css
Normal 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<html>
|
||||
{{> head title="Error :(" }}
|
||||
{{> partials/head title="Error :(" }}
|
||||
<body>
|
||||
Hit an exception while trying to serve the page :(
|
||||
<hr/>
|
||||
|
||||
@@ -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>
|
||||
15
spinner/lib/src/main/assets/js/main.js
Normal file
15
spinner/lib/src/main/assets/js/main.js
Normal 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();
|
||||
@@ -1,5 +1,5 @@
|
||||
<html>
|
||||
{{> head title="Home" }}
|
||||
{{> partials/head title="Home" }}
|
||||
|
||||
<style type="text/css">
|
||||
h1.collapse-header {
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<body>
|
||||
|
||||
{{> prefix isOverview=true}}
|
||||
{{> partials/prefix isOverview=true}}
|
||||
|
||||
<h1 class="collapse-header active" data-for="table-creates">Tables</h1>
|
||||
<div id="table-creates">
|
||||
@@ -50,6 +50,6 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{> suffix }}
|
||||
{{> partials/suffix }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
14
spinner/lib/src/main/assets/partials/head.hbs
Normal file
14
spinner/lib/src/main/assets/partials/head.hbs
Normal 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>
|
||||
@@ -28,4 +28,5 @@
|
||||
<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 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>
|
||||
1
spinner/lib/src/main/assets/partials/suffix.hbs
Normal file
1
spinner/lib/src/main/assets/partials/suffix.hbs
Normal file
@@ -0,0 +1 @@
|
||||
<script src="/js/main.js" type="text/javascript"></script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<html>
|
||||
{{> head title="Query" }}
|
||||
{{> partials/head title="Query" }}
|
||||
<body>
|
||||
|
||||
{{> prefix isQuery=true}}
|
||||
{{> partials/prefix isQuery=true}}
|
||||
|
||||
<!-- Query Input -->
|
||||
<form action="query" method="post">
|
||||
@@ -11,6 +11,8 @@
|
||||
<input type="submit" name="action" value="run" />
|
||||
or
|
||||
<input type="submit" name="action" value="analyze" />
|
||||
or
|
||||
<button onclick="onFormatClicked(event)">format</button>
|
||||
</form>
|
||||
|
||||
<!-- Query Result -->
|
||||
@@ -38,6 +40,15 @@
|
||||
No data.
|
||||
{{/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>
|
||||
</html>
|
||||
|
||||
50
spinner/lib/src/main/assets/recent.hbs
Normal file
50
spinner/lib/src/main/assets/recent.hbs
Normal 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>
|
||||
@@ -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>
|
||||
@@ -34,4 +34,4 @@ fun SupportSQLiteDatabase.getTableRowCount(table: String): Int {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.signal.spinner
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import org.signal.core.util.logging.Log
|
||||
import java.io.IOException
|
||||
@@ -11,14 +13,80 @@ import java.io.IOException
|
||||
object Spinner {
|
||||
val TAG: String = Log.tag(Spinner::class.java)
|
||||
|
||||
private lateinit var server: SpinnerServer
|
||||
|
||||
fun init(context: Context, deviceInfo: DeviceInfo, databases: Map<String, SupportSQLiteDatabase>) {
|
||||
try {
|
||||
SpinnerServer(context, deviceInfo, databases).start()
|
||||
server = SpinnerServer(context, deviceInfo, databases)
|
||||
server.start()
|
||||
} 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(
|
||||
val name: String,
|
||||
val packageName: String,
|
||||
|
||||
@@ -11,6 +11,9 @@ import fi.iki.elonen.NanoHTTPD
|
||||
import org.signal.core.util.ExceptionUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -22,7 +25,7 @@ import kotlin.math.min
|
||||
* to [renderTemplate].
|
||||
*/
|
||||
internal class SpinnerServer(
|
||||
context: Context,
|
||||
private val context: Context,
|
||||
private val deviceInfo: Spinner.DeviceInfo,
|
||||
private val databases: Map<String, SupportSQLiteDatabase>
|
||||
) : NanoHTTPD(5000) {
|
||||
@@ -36,6 +39,9 @@ internal class SpinnerServer(
|
||||
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 {
|
||||
if (session.method == Method.POST) {
|
||||
// Needed to populate session.parameters
|
||||
@@ -47,11 +53,14 @@ internal class SpinnerServer(
|
||||
|
||||
try {
|
||||
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 == "/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)
|
||||
session.method == Method.GET && session.uri == "/recent" -> getRecent(dbParam, db)
|
||||
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "Not found")
|
||||
}
|
||||
} 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 {
|
||||
return renderTemplate(
|
||||
"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 {
|
||||
val action: String = session.parameters["action"]?.get(0).toString()
|
||||
val rawQuery: String = session.parameters["query"]?.get(0).toString()
|
||||
@@ -173,6 +213,14 @@ internal class SpinnerServer(
|
||||
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 {
|
||||
val numColumns = this.columnCount
|
||||
|
||||
@@ -313,6 +361,13 @@ internal class SpinnerServer(
|
||||
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(
|
||||
val columns: List<String>,
|
||||
val rows: List<List<String>>,
|
||||
@@ -346,4 +401,14 @@ internal class SpinnerServer(
|
||||
val startRow: Int,
|
||||
val endRow: Int
|
||||
)
|
||||
|
||||
data class QueryItem(
|
||||
val time: Long,
|
||||
val query: String
|
||||
)
|
||||
|
||||
data class RecentQuery(
|
||||
val formattedTime: String,
|
||||
val query: String
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user