Source added
21
spinner/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Spinner
|
||||
Spinner is a development tool that lets you inspect and run queries against an app's database(s) in a convenient web interface.
|
||||
|
||||
## Getting Started
|
||||
Install one of the spinner build variants (e.g. `./gradlew installPlayProdSpinner`) and run the following adb command:
|
||||
|
||||
```bash
|
||||
adb forward tcp:5000 tcp:5000
|
||||
```
|
||||
|
||||
Then, navigate to `localhost:5000` in your web browser.
|
||||
|
||||
Magic!
|
||||
|
||||
## How does it work?
|
||||
Spinner is just a [NanoHttpd](https://github.com/NanoHttpd/nanohttpd) server that runs a little webapp in the background.
|
||||
You initialize Spinner in `Application.onCreate` with a list of databases you wish to let it run queries against.
|
||||
Then, you can use the `adb forward` command to route the Android device's port to a port on your local machine.
|
||||
|
||||
## What's with the name?
|
||||
It's a riff on Flipper, a development tool we used to use. It was very useful, but also wildly unstable (at least on Linux).
|
||||
19
spinner/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
plugins {
|
||||
id("signal-sample-app")
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.spinnertest"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.signal.spinnertest"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":spinner"))
|
||||
|
||||
implementation(libs.androidx.sqlite)
|
||||
implementation(libs.signal.android.database.sqlcipher)
|
||||
}
|
||||
24
spinner/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.PagingTest">
|
||||
<activity android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package org.signal.spinnertest
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.logging.AndroidLogger
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.spinner.Spinner
|
||||
import org.signal.spinner.SpinnerLogger
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
val db = SpinnerTestSqliteOpenHelper(applicationContext)
|
||||
|
||||
// insertMockData(db.writableDatabase)
|
||||
|
||||
Spinner.init(
|
||||
application,
|
||||
mapOf(
|
||||
"Name" to { "${Build.MODEL} (API ${Build.VERSION.SDK_INT})" },
|
||||
"Package" to { packageName }
|
||||
),
|
||||
mapOf("main" to Spinner.DatabaseConfig(db = { db })),
|
||||
emptyMap()
|
||||
)
|
||||
|
||||
Log.initialize(AndroidLogger, SpinnerLogger)
|
||||
|
||||
object : Thread() {
|
||||
override fun run() {
|
||||
while (true) {
|
||||
when (Random.nextInt(0, 5)) {
|
||||
0 -> Log.v("MyTag", "Message: ${System.currentTimeMillis()}")
|
||||
1 -> Log.d("MyTag", "Message: ${System.currentTimeMillis()}")
|
||||
2 -> Log.i("MyTag", "Message: ${System.currentTimeMillis()}")
|
||||
3 -> Log.w("MyTag", "Message: ${System.currentTimeMillis()}")
|
||||
4 -> Log.e("MyTag", "Message: ${System.currentTimeMillis()}")
|
||||
}
|
||||
ThreadUtil.sleep(Random.nextLong(0, 200))
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
|
||||
findViewById<Button>(R.id.log_throwable_button).setOnClickListener { Log.e("MyTag", "Message: ${System.currentTimeMillis()}", Throwable()) }
|
||||
}
|
||||
|
||||
private fun insertMockData(db: SQLiteDatabase) {
|
||||
for (i in 1..10000) {
|
||||
db.insert("test", null, contentValuesOf("col1" to UUID.randomUUID().toString(), "col2" to UUID.randomUUID().toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package org.signal.spinnertest
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.database.sqlite.SQLiteTransactionListener
|
||||
import android.os.CancellationSignal
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteStatement
|
||||
import java.util.Locale
|
||||
|
||||
class SpinnerTestSqliteOpenHelper(context: Context) : SQLiteOpenHelper(context, "test", null, 2), SupportSQLiteDatabase {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE test (id INTEGER PRIMARY KEY, col1 TEXT, col2 TEXT)")
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (oldVersion < 2) {
|
||||
db.execSQL("CREATE INDEX test_col1_index ON test (col1)")
|
||||
}
|
||||
}
|
||||
|
||||
override var version: Int
|
||||
get() = readableDatabase.version
|
||||
set(value) {
|
||||
writableDatabase.version = value
|
||||
}
|
||||
|
||||
override val maximumSize: Long
|
||||
get() = readableDatabase.maximumSize
|
||||
|
||||
override val isDbLockedByCurrentThread: Boolean
|
||||
get() = readableDatabase.isDbLockedByCurrentThread
|
||||
|
||||
override var pageSize: Long
|
||||
get() = readableDatabase.pageSize
|
||||
set(value) {
|
||||
writableDatabase.pageSize = value
|
||||
}
|
||||
|
||||
override fun compileStatement(sql: String): SupportSQLiteStatement {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun beginTransaction() {
|
||||
writableDatabase.beginTransaction()
|
||||
}
|
||||
|
||||
override fun beginTransactionNonExclusive() {
|
||||
writableDatabase.beginTransactionNonExclusive()
|
||||
}
|
||||
|
||||
override fun beginTransactionWithListener(transactionListener: SQLiteTransactionListener) {
|
||||
writableDatabase.beginTransactionWithListener(transactionListener)
|
||||
}
|
||||
|
||||
override fun beginTransactionWithListenerNonExclusive(transactionListener: SQLiteTransactionListener) {
|
||||
writableDatabase.beginTransactionWithListenerNonExclusive(transactionListener)
|
||||
}
|
||||
|
||||
override fun endTransaction() {
|
||||
writableDatabase.endTransaction()
|
||||
}
|
||||
|
||||
override fun setTransactionSuccessful() {
|
||||
writableDatabase.setTransactionSuccessful()
|
||||
}
|
||||
|
||||
override fun inTransaction(): Boolean {
|
||||
return writableDatabase.inTransaction()
|
||||
}
|
||||
|
||||
override fun yieldIfContendedSafely(): Boolean {
|
||||
return writableDatabase.yieldIfContendedSafely()
|
||||
}
|
||||
|
||||
override fun yieldIfContendedSafely(sleepAfterYieldDelay: Long): Boolean {
|
||||
return writableDatabase.yieldIfContendedSafely(sleepAfterYieldDelay)
|
||||
}
|
||||
|
||||
override fun setMaximumSize(numBytes: Long): Long {
|
||||
writableDatabase.maximumSize = numBytes
|
||||
return writableDatabase.maximumSize
|
||||
}
|
||||
|
||||
override fun query(query: String): Cursor {
|
||||
return readableDatabase.rawQuery(query, null)
|
||||
}
|
||||
|
||||
override fun query(query: String, bindArgs: Array<out Any?>): Cursor {
|
||||
return readableDatabase.rawQuery(query, bindArgs.map { it.toString() }?.toTypedArray())
|
||||
}
|
||||
|
||||
override fun query(query: SupportSQLiteQuery): Cursor {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun query(query: SupportSQLiteQuery, cancellationSignal: CancellationSignal?): Cursor {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun insert(table: String, conflictAlgorithm: Int, values: ContentValues): Long {
|
||||
return writableDatabase.insertWithOnConflict(table, null, values, conflictAlgorithm)
|
||||
}
|
||||
|
||||
override fun delete(table: String, whereClause: String?, whereArgs: Array<out Any?>?): Int {
|
||||
return writableDatabase.delete(table, whereClause, whereArgs?.map { it.toString() }?.toTypedArray())
|
||||
}
|
||||
|
||||
override fun update(table: String, conflictAlgorithm: Int, values: ContentValues, whereClause: String?, whereArgs: Array<out Any?>?): Int {
|
||||
return writableDatabase.updateWithOnConflict(table, values, whereClause, whereArgs?.map { it.toString() }?.toTypedArray(), conflictAlgorithm)
|
||||
}
|
||||
|
||||
override fun execSQL(sql: String) {
|
||||
writableDatabase.execSQL(sql)
|
||||
}
|
||||
|
||||
override fun execSQL(sql: String, bindArgs: Array<out Any?>) {
|
||||
writableDatabase.execSQL(sql, bindArgs.map { it.toString() }.toTypedArray())
|
||||
}
|
||||
|
||||
override val isReadOnly: Boolean
|
||||
get() = readableDatabase.isReadOnly
|
||||
|
||||
override val isOpen: Boolean
|
||||
get() = readableDatabase.isOpen
|
||||
|
||||
override fun needUpgrade(newVersion: Int): Boolean {
|
||||
return readableDatabase.needUpgrade(newVersion)
|
||||
}
|
||||
|
||||
override val path: String
|
||||
get() = readableDatabase.path
|
||||
|
||||
override fun setLocale(locale: Locale) {
|
||||
writableDatabase.setLocale(locale)
|
||||
}
|
||||
|
||||
override fun setMaxSqlCacheSize(cacheSize: Int) {
|
||||
writableDatabase.setMaxSqlCacheSize(cacheSize)
|
||||
}
|
||||
|
||||
override fun setForeignKeyConstraintsEnabled(enable: Boolean) {
|
||||
writableDatabase.setForeignKeyConstraintsEnabled(enable)
|
||||
}
|
||||
|
||||
override fun enableWriteAheadLogging(): Boolean {
|
||||
return writableDatabase.enableWriteAheadLogging()
|
||||
}
|
||||
|
||||
override fun disableWriteAheadLogging() {
|
||||
writableDatabase.disableWriteAheadLogging()
|
||||
}
|
||||
|
||||
override val isWriteAheadLoggingEnabled: Boolean
|
||||
get() = readableDatabase.isWriteAheadLoggingEnabled
|
||||
|
||||
override val attachedDbs: List<android.util.Pair<String, String>>?
|
||||
get() = readableDatabase.attachedDbs
|
||||
|
||||
override val isDatabaseIntegrityOk: Boolean
|
||||
get() = readableDatabase.isDatabaseIntegrityOk
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
171
spinner/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
36
spinner/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:text="To use, enter the command:" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:fontFamily="monospace"
|
||||
android:text="adb forward tcp:5000 tcp:5000" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:text="Then go to localhost:5000 in your browser." />
|
||||
|
||||
<Button
|
||||
android:id="@+id/log_throwable_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Log Throwable" />
|
||||
|
||||
</LinearLayout>
|
||||
30
spinner/app/src/main/res/layout/item.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="5dp"
|
||||
android:clipToPadding="false"
|
||||
android:clipChildren="false">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardElevation="5dp"
|
||||
app:cardCornerRadius="5dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:fontFamily="monospace"
|
||||
tools:text="Spider-Man"/>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
spinner/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
spinner/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
spinner/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
spinner/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
spinner/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
spinner/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
spinner/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
spinner/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
spinner/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
spinner/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
16
spinner/app/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.PagingTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
10
spinner/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
spinner/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Spinner Test</string>
|
||||
</resources>
|
||||
16
spinner/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.PagingTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
18
spinner/lib/build.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
plugins {
|
||||
id("signal-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.spinner"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core-util"))
|
||||
|
||||
implementation(libs.jknack.handlebars)
|
||||
implementation(libs.nanohttpd.webserver)
|
||||
implementation(libs.nanohttpd.websocket)
|
||||
implementation(libs.androidx.sqlite)
|
||||
|
||||
testImplementation(testLibs.junit.junit)
|
||||
}
|
||||
5
spinner/lib/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
75
spinner/lib/src/main/assets/browse.hbs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<html>
|
||||
{{> partials/head title="Browse" }}
|
||||
<body>
|
||||
|
||||
{{> partials/prefix isBrowse=true}}
|
||||
|
||||
<!-- Table Selector -->
|
||||
<form action="browse" method="post">
|
||||
<select name="table">
|
||||
{{#each tableNames}}
|
||||
<option value="{{this}}" {{eq table this yes="selected" no=""}}>{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
<input type="hidden" name="db" value="{{database}}" />
|
||||
<input type="submit" value="browse" />
|
||||
</form>
|
||||
|
||||
<!-- Data -->
|
||||
{{#if table}}
|
||||
<h1>{{table}}</h1>
|
||||
{{else}}
|
||||
<h1>Data</h1>
|
||||
{{/if}}
|
||||
|
||||
{{#if queryResult}}
|
||||
<p>Viewing rows {{pagingData.startRow}}-{{pagingData.endRow}} of {{pagingData.rowCount}}.</p>
|
||||
|
||||
<!-- Paging Controls -->
|
||||
<form action="browse" method="post">
|
||||
<input type="hidden" name="table" value="{{table}}" />
|
||||
<input type="hidden" name="pageSize" value="{{pagingData.pageSize}}" />
|
||||
<input type="hidden" name="pageIndex" value="{{pagingData.pageIndex}}" />
|
||||
|
||||
<input type="submit" name="action" value="first" {{#if pagingData.firstPage}}disabled{{/if}} />
|
||||
<input type="submit" name="action" value="previous" {{#if pagingData.firstPage}}disabled{{/if}} />
|
||||
<input type="submit" name="action" value="next" {{#if pagingData.lastPage}}disabled{{/if}} />
|
||||
<input type="submit" name="action" value="last" {{#if pagingData.lastPage}}disabled{{/if}} />
|
||||
</form>
|
||||
|
||||
<!-- Data Rows -->
|
||||
<table>
|
||||
<tr>
|
||||
{{#each queryResult.columns}}
|
||||
<th>{{this}}</th>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{#each queryResult.rows}}
|
||||
<tr>
|
||||
{{#each this}}
|
||||
<td><pre>{{#if (eq this null)}}<em class="null">null</em>{{else}}{{{this}}}{{/if}}</pre></td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
{{else}}
|
||||
<div>Select a table from above and click 'browse'.</div>
|
||||
{{/if}}
|
||||
|
||||
<br />
|
||||
|
||||
<!-- Paging Controls -->
|
||||
<form action="browse" method="post">
|
||||
<input type="hidden" name="table" value="{{table}}" />
|
||||
<input type="hidden" name="pageSize" value="{{pagingData.pageSize}}" />
|
||||
<input type="hidden" name="pageIndex" value="{{pagingData.pageIndex}}" />
|
||||
|
||||
<input type="submit" name="action" value="first" {{#if pagingData.firstPage}}disabled{{/if}} />
|
||||
<input type="submit" name="action" value="previous" {{#if pagingData.firstPage}}disabled{{/if}} />
|
||||
<input type="submit" name="action" value="next" {{#if pagingData.lastPage}}disabled{{/if}} />
|
||||
<input type="submit" name="action" value="last" {{#if pagingData.lastPage}}disabled{{/if}} />
|
||||
</form>
|
||||
|
||||
{{> partials/suffix}}
|
||||
</body>
|
||||
</html>
|
||||
165
spinner/lib/src/main/assets/css/main.css
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
:root {
|
||||
--background-color: #fff;
|
||||
--table-header-background-color: #f0f0f0;
|
||||
--text-color: #000;
|
||||
--border-color: #000;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background-color: #333;
|
||||
--table-header-background-color: #444;
|
||||
--text-color: #fff;
|
||||
--border-color: #888;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #aaf;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--background-color: #333;
|
||||
--table-header-background-color: #444;
|
||||
--text-color: #fff;
|
||||
--border-color: #888;
|
||||
|
||||
a {
|
||||
color: #aaf;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-variant-ligatures: none;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
background: var(--background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
select, input, button {
|
||||
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;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
.handsontable th {
|
||||
color: var(--text-color);
|
||||
background: var(--table-header-background-color);
|
||||
}
|
||||
|
||||
.handsontable thead th.ht__highlight {
|
||||
color: var(--text-color);
|
||||
background: var(--table-header-background-color);
|
||||
}
|
||||
|
||||
.handsontable td {
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
.query-input {
|
||||
width: calc(100% - 18px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
height: 200px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
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 var(--border-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tabs li.active {
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.tabs a {
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.null {
|
||||
color: #666
|
||||
}
|
||||
|
||||
#grow-button {
|
||||
width: calc(100% - 18px);
|
||||
height: 0.75rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#theme-toggle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
8
spinner/lib/src/main/assets/error.hbs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
{{> partials/head title="Error :(" }}
|
||||
<body>
|
||||
Hit an exception while trying to serve the page :(
|
||||
<hr/>
|
||||
{{{this}}}
|
||||
</body>
|
||||
</html>
|
||||
33
spinner/lib/src/main/assets/js/main.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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.dispatchEvent(new CustomEvent('header-toggle', {
|
||||
detail: document.getElementById(elem.dataset.for)
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#database-selector').onchange = (e) => {
|
||||
window.location.href = window.location.href.split('?')[0] + '?db=' + e.target.value;
|
||||
}
|
||||
|
||||
document.querySelector('#theme-toggle').onclick = function() {
|
||||
if (document.body.getAttribute('data-theme') === 'dark') {
|
||||
document.body.removeAttribute('data-theme');
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
document.body.setAttribute('data-theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
document.body.setAttribute('data-theme', savedTheme);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
290
spinner/lib/src/main/assets/logs.hbs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<html>
|
||||
{{> partials/head title="Home" }}
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap" rel="stylesheet">
|
||||
|
||||
<style type="text/css">
|
||||
html, body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1.collapse-header {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
h2.collapse-header {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
#log-container {
|
||||
width: calc(100% - 32px);
|
||||
height: calc(100% - 325px);
|
||||
overflow-y: scroll;
|
||||
overflow-x: auto;
|
||||
border: 1px solid black;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background-color: #2b2b2b;
|
||||
margin-top: 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid black;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#logs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-verbose {
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
.log-debug {
|
||||
color: #5ca72b;
|
||||
}
|
||||
|
||||
.log-info{
|
||||
color: #46bbb9;
|
||||
}
|
||||
|
||||
.log-warn{
|
||||
color: #d6cb37;
|
||||
}
|
||||
|
||||
.log-error{
|
||||
color: #ff6b68;
|
||||
}
|
||||
|
||||
#follow-button.enabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
width: calc(100% - 14px);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#toolbar #filter-text {
|
||||
flex-grow: 1;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
#socket-status {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 16px;
|
||||
margin: 3px 0 3px 3px;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
#socket-status.connected {
|
||||
background-color: #5ca72b;
|
||||
}
|
||||
|
||||
#socket-status.connecting {
|
||||
background-color: #d6cb37;
|
||||
}
|
||||
|
||||
#socket-status.disconnected {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<body>
|
||||
{{> partials/prefix isLogs=true}}
|
||||
|
||||
<div id="toolbar">
|
||||
<button onclick="onFollowClicked()" id="follow-button">Follow</button>
|
||||
<input type="text" id="filter-text" placeholder="Filter..." />
|
||||
<div id="socket-status"></div>
|
||||
</div>
|
||||
|
||||
<div id="log-container"></div>
|
||||
|
||||
|
||||
{{> partials/suffix }}
|
||||
|
||||
<script>
|
||||
const logs = []
|
||||
const logTable = document.getElementById('logs')
|
||||
const logContainer = document.getElementById('log-container')
|
||||
const followButton = document.getElementById('follow-button')
|
||||
const filterText = document.getElementById('filter-text')
|
||||
const statusOrb = document.getElementById('socket-status')
|
||||
|
||||
let followLogs = false
|
||||
let programaticScroll = false
|
||||
|
||||
let filter = null
|
||||
|
||||
function main() {
|
||||
initWebSocket()
|
||||
setFollowState(true)
|
||||
|
||||
logContainer.innerHTML = ''
|
||||
|
||||
filterText.addEventListener('input', onFilterChanged)
|
||||
|
||||
logContainer.addEventListener('scroll', () => {
|
||||
if (programaticScroll) {
|
||||
programaticScroll = false
|
||||
} else {
|
||||
setFollowState(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onFollowClicked() {
|
||||
setFollowState(true)
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
function onFilterChanged(event) {
|
||||
filter = event.target.value
|
||||
|
||||
logContainer.innerHTML = ''
|
||||
|
||||
logs
|
||||
.filter(it => logMatches(it, filter))
|
||||
.map(it => logToDiv(it))
|
||||
.forEach(it => logContainer.appendChild(it))
|
||||
|
||||
setFollowState(true)
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
function initWebSocket() {
|
||||
const websocket = new WebSocket(`ws://${window.location.host}/logs/websocket`)
|
||||
let keepAliveTimer = null
|
||||
statusOrb.className = 'connecting'
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log("[open] Connection established");
|
||||
console.log("Sending to server");
|
||||
|
||||
statusOrb.className = 'connected'
|
||||
|
||||
keepAliveTimer = setInterval(() => websocket.send('keepalive'), 1000)
|
||||
}
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.log('[close] Closed!')
|
||||
statusOrb.className = 'disconnected'
|
||||
|
||||
if (keepAliveTimer != null) {
|
||||
clearInterval(keepAliveTimer)
|
||||
keepAliveTimer = null
|
||||
}
|
||||
|
||||
setTimeout(() => initWebSocket(), 1000)
|
||||
}
|
||||
|
||||
websocket.onmessage = onWebSocketMessage
|
||||
}
|
||||
|
||||
function onWebSocketMessage(event) {
|
||||
const log = JSON.parse(event.data)
|
||||
logs.push(log)
|
||||
if (logs.length > 5_000) {
|
||||
logs.shift()
|
||||
}
|
||||
|
||||
if (filter == null || logMatches(log, filter)) {
|
||||
logContainer.appendChild(logToDiv(log))
|
||||
}
|
||||
|
||||
if (followLogs) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function logToDiv(log) {
|
||||
const div = document.createElement('div')
|
||||
|
||||
const linePrefix = `[${log.thread}] ${log.time} ${log.tag} ${log.level} `
|
||||
|
||||
let stackTraceString = log.stackTrace
|
||||
if (stackTraceString != null) {
|
||||
stackTraceString = ' \n' + stackTraceString
|
||||
}
|
||||
|
||||
let textContent = `${linePrefix}${log.message || ''}${stackTraceString || ''}`
|
||||
textContent = indentOverflowLines(textContent, linePrefix.length)
|
||||
|
||||
div.textContent = textContent
|
||||
div.classList.add(levelToClass(log.level))
|
||||
|
||||
return div
|
||||
}
|
||||
|
||||
function levelToClass(level) {
|
||||
switch (level) {
|
||||
case 'V': return 'log-verbose'
|
||||
case 'D': return 'log-debug'
|
||||
case 'I': return 'log-info'
|
||||
case 'W': return 'log-warn'
|
||||
case 'E': return 'log-error'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function setFollowState(value) {
|
||||
if (followLogs === value) {
|
||||
return
|
||||
}
|
||||
|
||||
followLogs = value
|
||||
|
||||
if (followLogs) {
|
||||
followButton.classList.add('enabled')
|
||||
followButton.disabled = true
|
||||
} else {
|
||||
followButton.classList.remove('enabled')
|
||||
followButton.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
programaticScroll = true
|
||||
logContainer.scrollTop = logContainer.scrollHeight
|
||||
}
|
||||
|
||||
function indentOverflowLines(text, indent) {
|
||||
const lines = text.split('\n')
|
||||
|
||||
if (lines.length > 1) {
|
||||
const spaces = ' '.repeat(indent)
|
||||
const overflow = lines.slice(1)
|
||||
const indented = overflow.map(it => spaces + it).join('\n')
|
||||
return lines[0] + '\n' + indented
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function logMatches(log, filter) {
|
||||
if (log.tag != null && log.tag.indexOf(filter) >= 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (log.message != null && log.message.indexOf(filter) >= 0) {
|
||||
return true
|
||||
|
||||
}
|
||||
|
||||
if (log.stackTrace != null && log.stackTrace.indexOf(filter) >= 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
main()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
107
spinner/lib/src/main/assets/overview.hbs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<html>
|
||||
{{> partials/head title="Home" }}
|
||||
|
||||
<style type="text/css">
|
||||
h1.collapse-header {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
h2.collapse-header {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
|
||||
{{> partials/prefix isOverview=true}}
|
||||
|
||||
<h1 class="collapse-header" data-for="table-creates">Tables</h1>
|
||||
<div id="table-creates" class="hidden">
|
||||
{{#if tables}}
|
||||
{{#each tables}}
|
||||
<h2 class="collapse-header" data-for="table-create-{{@index}}">{{name}}</h2>
|
||||
<div id="table-create-{{@index}}" class="hidden">{{{sql}}}</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
None.
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<h1 class="collapse-header" data-for="index-creates">Indices</h1>
|
||||
<div id="index-creates" class="hidden">
|
||||
{{#if indices}}
|
||||
{{#each indices}}
|
||||
<h2 class="collapse-header active" data-for="index-create-{{@index}}">{{name}}</h2>
|
||||
<div id="index-create-{{@index}}">{{{sql}}}</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
None.
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<h1 class="collapse-header" data-for="trigger-creates">Triggers</h1>
|
||||
<div id="trigger-creates" class="hidden">
|
||||
{{#if triggers}}
|
||||
{{#each triggers}}
|
||||
<h2 class="collapse-header active" data-for="trigger-create-{{@index}}">{{name}}</h2>
|
||||
<div id="trigger-create-{{@index}}">{{{sql}}}</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
None.
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
|
||||
<h1 class="collapse-header" data-for="foreign-key-creates">Foreign Keys</h1>
|
||||
<div id="foreign-key-creates" class="hidden">
|
||||
{{#if foreignKeys}}
|
||||
<table>
|
||||
<tr>
|
||||
<th>Column</th>
|
||||
<th>Depends On</th>
|
||||
<th>On Delete</th>
|
||||
</tr>
|
||||
{{#each foreignKeys}}
|
||||
<tr>
|
||||
<td>{{table}}.{{column}}</td>
|
||||
<td>{{dependsOnTable}}.{{dependsOnColumn}}</td>
|
||||
<td>{{onDelete}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
|
||||
<h2>Without Labels</h2>
|
||||
<pre class="mermaid">
|
||||
flowchart LR
|
||||
{{#each foreignKeys}}
|
||||
id_{{table}}[{{table}}] --> id_{{dependsOnTable}}[{{dependsOnTable}}]
|
||||
{{/each}}
|
||||
</pre>
|
||||
|
||||
<h2>With Labels</h2>
|
||||
<pre class="mermaid">
|
||||
flowchart LR
|
||||
{{#each foreignKeys}}
|
||||
id_{{table}}[{{table}}] -- "{{column}} 🠖 {{dependsOnColumn}}" --> id_{{dependsOnTable}}[{{dependsOnTable}}]
|
||||
{{/each}}
|
||||
</pre>
|
||||
|
||||
{{else}}
|
||||
None.
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@9/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({ startOnLoad: false });
|
||||
|
||||
document.addEventListener('header-toggle', (e) => {
|
||||
if (e.detail.id === 'foreign-key-creates') {
|
||||
mermaid.init('.mermaid')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{{> partials/suffix }}
|
||||
</body>
|
||||
</html>
|
||||
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>
|
||||
38
spinner/lib/src/main/assets/partials/prefix.hbs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<h1>SPINNER - {{environment}}</h1>
|
||||
|
||||
<table class="device-info">
|
||||
{{#each deviceInfo}}
|
||||
<tr>
|
||||
<td>{{@key}}</td>
|
||||
<td>{{this}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
|
||||
<div>
|
||||
Download Trace: <a href="/trace">Link</a>
|
||||
</div>
|
||||
|
||||
<button id="theme-toggle">Toggle theme</button>
|
||||
|
||||
<br />
|
||||
|
||||
<div>
|
||||
Database:
|
||||
<select id="database-selector">
|
||||
{{#each databases}}
|
||||
<option value="{{this}}" {{eq database this yes="selected" no=""}}>{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ol class="tabs">
|
||||
<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>
|
||||
<li {{#if isLogs}}class="active"{{/if}}><a href="/logs?db={{database}}">Logs</a></li>
|
||||
{{#each plugins}}
|
||||
<li {{#if (eq name activePlugin.name)}}class="active"{{/if}}><a href="{{path}}">{{name}}</a></li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
1
spinner/lib/src/main/assets/partials/suffix.hbs
Normal file
|
|
@ -0,0 +1 @@
|
|||
<script src="/js/main.js" type="text/javascript"></script>
|
||||
31
spinner/lib/src/main/assets/plugin.hbs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<html>
|
||||
{{> partials/head title=activePlugin.name }}
|
||||
|
||||
<body>
|
||||
{{> partials/prefix}}
|
||||
|
||||
{{#if (eq "table" pluginResult.type)}}
|
||||
<h1>Data</h1>
|
||||
{{pluginResult.rowCount}} row(s). <br />
|
||||
<br />
|
||||
<table>
|
||||
<tr>
|
||||
{{#each pluginResult.columns}}
|
||||
<th>{{this}}</th>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{#each pluginResult.rows}}
|
||||
<tr>
|
||||
{{#each this}}
|
||||
<td><pre>{{{this}}}</pre></td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
{{/if}}
|
||||
{{#if (eq "string" pluginResult.type)}}
|
||||
<p>{{pluginResult.text}}</p>
|
||||
{{/if}}
|
||||
{{> partials/suffix }}
|
||||
</body>
|
||||
</html>
|
||||
173
spinner/lib/src/main/assets/query.hbs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<html>
|
||||
{{> partials/head title="Query" }}
|
||||
<body>
|
||||
|
||||
{{> partials/prefix isQuery=true}}
|
||||
|
||||
<!-- Query Input -->
|
||||
<form action="query" method="post" id="query-form">
|
||||
<div class="query-input">{{query}}</div>
|
||||
<button id="grow-button" onclick="onGrowClicked(event)"> </button>
|
||||
|
||||
<input type="hidden" name="query" id="query" />
|
||||
<input type="hidden" name="db" value="{{database}}" />
|
||||
<input type="submit" name="action" value="run" />
|
||||
or
|
||||
<input type="submit" name="action" value="analyze" />
|
||||
or
|
||||
<button onclick="onFormatClicked(event)">format</button>
|
||||
</form>
|
||||
|
||||
<!-- Container for previous queries -->
|
||||
<h1 class="collapse-header" data-for="history-container">Query History</h1>
|
||||
<table id="history-container" class="hidden"></table>
|
||||
|
||||
<!-- Query Result -->
|
||||
<h1>Data</h1>
|
||||
{{#if queryResult}}
|
||||
{{queryResult.rowCount}} row(s). <br />
|
||||
{{queryResult.timeToFirstRow}} ms to read the first row. <br />
|
||||
{{queryResult.timeToReadRows}} ms to read the rest of the rows. <br />
|
||||
<br />
|
||||
<table>
|
||||
<tr>
|
||||
{{#each queryResult.columns}}
|
||||
<th>{{this}}</th>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{#each queryResult.rows}}
|
||||
<tr>
|
||||
{{#each this}}
|
||||
<td><pre>{{#if (eq this null)}}<em class="null">null</em>{{else}}{{{this}}}{{/if}}</pre></td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
{{else}}
|
||||
No data.
|
||||
{{/if}}
|
||||
|
||||
{{> partials/suffix}}
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql-formatter/4.0.2/sql-formatter.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/ace.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/mode-sql.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/theme-github.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.5/theme-github_dark.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script type="text/javascript">
|
||||
let editor;
|
||||
|
||||
function main() {
|
||||
//document.querySelector('.query-input').addEventListener('keypress', submitOnEnter);
|
||||
document.getElementById('query-form').addEventListener('submit', onQuerySubmitted, false);
|
||||
renderQueryHistory();
|
||||
|
||||
editor = ace.edit(document.querySelector('.query-input'), {
|
||||
mode: 'ace/mode/sql',
|
||||
theme: isDarkTheme() ? 'ace/theme/github_dark' : 'ace/theme/github',
|
||||
selectionStyle: 'text',
|
||||
showPrintMargin: false
|
||||
});
|
||||
|
||||
editor.setFontSize(13);
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: "Run",
|
||||
bindKey: {win: "Ctrl-Return", mac: "Command-Return"},
|
||||
exec: triggerSubmit
|
||||
});
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: "Run (Cody)",
|
||||
bindKey: {win: "Shift-Return", mac: "Shift-Return"},
|
||||
exec: triggerSubmit
|
||||
});
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: "Format",
|
||||
bindKey: {win: "Ctrl-Shift-F", mac: "Command-Shift-F"},
|
||||
exec: formatSql
|
||||
});
|
||||
}
|
||||
|
||||
function onFormatClicked(e) {
|
||||
formatSql();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function formatSql() {
|
||||
let formatted = sqlFormatter.format(editor.getValue()).replaceAll("! =", "!=").replaceAll("| |", "||");
|
||||
editor.setValue(formatted, formatted.length);
|
||||
}
|
||||
|
||||
function triggerSubmit() {
|
||||
onQuerySubmitted();
|
||||
document.getElementById('query-form').submit();
|
||||
}
|
||||
|
||||
function onGrowClicked(e) {
|
||||
let element = document.querySelector('.query-input');
|
||||
let currentHeight = parseInt(element.style.height) || 200;
|
||||
element.style.height = currentHeight + 100;
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onQuerySubmitted() {
|
||||
const query = editor.getValue();
|
||||
|
||||
document.getElementById('query').value = query;
|
||||
|
||||
let history = getQueryHistory();
|
||||
|
||||
if (history.length > 0 && history[0] === query) {
|
||||
console.log('Query already at the top of the history, not saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
history.unshift(query);
|
||||
history = history.slice(0, 25);
|
||||
|
||||
localStorage.setItem('query-history', JSON.stringify(history));
|
||||
}
|
||||
|
||||
function renderQueryHistory() {
|
||||
const container = document.getElementById('history-container');
|
||||
|
||||
let history = getQueryHistory();
|
||||
|
||||
if (history.length > 0) {
|
||||
let i = 0;
|
||||
for (let item of history) {
|
||||
container.innerHTML += `
|
||||
<tr>
|
||||
<td><button onclick="onHistoryItemClicked(${i})">^</button></td>
|
||||
<td id="history-item-${i}">${item}</td>
|
||||
</tr>
|
||||
`;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
container.innerHTML = '<em>None</em>';
|
||||
}
|
||||
}
|
||||
|
||||
function onHistoryItemClicked(i) {
|
||||
let item = document.getElementById(`history-item-${i}`).innerText;
|
||||
editor.setValue(item, item.length);
|
||||
}
|
||||
|
||||
function getQueryHistory() {
|
||||
const historyRaw = localStorage.getItem('query-history') || "[]";
|
||||
return JSON.parse(historyRaw);
|
||||
}
|
||||
|
||||
function isDarkTheme() {
|
||||
return document.body.getAttribute('data-theme') === 'dark'
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package org.signal.spinner
|
||||
|
||||
import android.database.Cursor
|
||||
|
||||
/**
|
||||
* An interface to transform on column value into another. Useful for making certain data fields (like bitmasks) more readable.
|
||||
*/
|
||||
interface ColumnTransformer {
|
||||
/**
|
||||
* In certain circumstances (like some queries), the table name may not be guaranteed.
|
||||
*/
|
||||
fun matches(tableName: String?, columnName: String): Boolean
|
||||
|
||||
/**
|
||||
* In certain circumstances (like some queries), the table name may not be guaranteed.
|
||||
*/
|
||||
fun transform(tableName: String?, columnName: String, cursor: Cursor): String?
|
||||
}
|
||||
37
spinner/lib/src/main/java/org/signal/spinner/DatabaseUtil.kt
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package org.signal.spinner
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
fun SupportSQLiteDatabase.getTableNames(): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
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")
|
||||
}
|
||||
|
||||
fun SupportSQLiteDatabase.getTableRowCount(table: String): Int {
|
||||
return this.query("SELECT COUNT(*) FROM $table").use {
|
||||
if (it.moveToFirst()) {
|
||||
it.getInt(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package org.signal.spinner
|
||||
|
||||
import android.database.Cursor
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Hex
|
||||
|
||||
object DefaultColumnTransformer : ColumnTransformer {
|
||||
override fun matches(tableName: String?, columnName: String): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun transform(tableName: String?, columnName: String, cursor: Cursor): String? {
|
||||
val index = cursor.getColumnIndex(columnName)
|
||||
return when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_BLOB -> "Base64 with padding:<br>${Base64.encodeWithPadding(cursor.getBlob(index))}<br><br>Hex string:<br>${Hex.toStringCondensed(cursor.getBlob(index))}"
|
||||
else -> cursor.getString(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
7
spinner/lib/src/main/java/org/signal/spinner/Plugin.kt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package org.signal.spinner
|
||||
|
||||
interface Plugin {
|
||||
fun get(): PluginResult
|
||||
val name: String
|
||||
val path: String
|
||||
}
|
||||
13
spinner/lib/src/main/java/org/signal/spinner/PluginResult.kt
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package org.signal.spinner
|
||||
|
||||
sealed class PluginResult(val type: String) {
|
||||
data class TableResult(
|
||||
val columns: List<String>,
|
||||
val rows: List<List<String>>,
|
||||
val rowCount: Int = rows.size
|
||||
) : PluginResult("table")
|
||||
|
||||
data class StringResult(
|
||||
val text: String
|
||||
) : PluginResult("string")
|
||||
}
|
||||
101
spinner/lib/src/main/java/org/signal/spinner/Spinner.kt
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package org.signal.spinner
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
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 {
|
||||
internal const val KEY_PREFIX = "spinner"
|
||||
const val KEY_ENVIRONMENT = "$KEY_PREFIX:environment"
|
||||
|
||||
private val TAG: String = Log.tag(Spinner::class.java)
|
||||
|
||||
private lateinit var server: SpinnerServer
|
||||
|
||||
fun init(application: Application, deviceInfo: Map<String, () -> String>, databases: Map<String, DatabaseConfig>, plugins: Map<String, Plugin>) {
|
||||
try {
|
||||
server = SpinnerServer(application, deviceInfo, databases, plugins)
|
||||
server.start()
|
||||
} catch (e: IOException) {
|
||||
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)
|
||||
}
|
||||
|
||||
internal fun log(item: SpinnerLogItem) {
|
||||
server.onLog(item)
|
||||
}
|
||||
|
||||
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 DatabaseConfig(
|
||||
val db: () -> SupportSQLiteDatabase,
|
||||
val columnTransformers: List<ColumnTransformer> = emptyList()
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.spinner
|
||||
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import org.signal.core.util.ExceptionUtil
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
internal data class SpinnerLogItem(
|
||||
val level: Int,
|
||||
val time: Long,
|
||||
val thread: String,
|
||||
val tag: String,
|
||||
val message: String?,
|
||||
val throwable: Throwable?
|
||||
) {
|
||||
fun serialize(): String {
|
||||
val stackTrace: String? = throwable?.let { ExceptionUtil.convertThrowableToString(throwable) }
|
||||
val formattedTime = dateFormat.format(Date(time))
|
||||
val paddedTag: String = when {
|
||||
tag.length > 23 -> tag.substring(0, 23)
|
||||
tag.length < 23 -> tag.padEnd(23)
|
||||
else -> tag
|
||||
}
|
||||
|
||||
val levelString = when (level) {
|
||||
Log.VERBOSE -> "V"
|
||||
Log.DEBUG -> "D"
|
||||
Log.INFO -> "I"
|
||||
Log.WARN -> "W"
|
||||
Log.ERROR -> "E"
|
||||
else -> "?"
|
||||
}
|
||||
|
||||
val out = JSONObject()
|
||||
out.put("level", levelString)
|
||||
out.put("time", formattedTime)
|
||||
out.put("thread", thread)
|
||||
out.put("tag", paddedTag)
|
||||
message?.let { out.put("message", it) }
|
||||
stackTrace?.let { out.put("stackTrace", it) }
|
||||
|
||||
return out.toString(0)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.spinner
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import fi.iki.elonen.NanoWSD
|
||||
import fi.iki.elonen.NanoWSD.WebSocket
|
||||
import java.io.IOException
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
@SuppressLint("LogNotSignal")
|
||||
internal class SpinnerLogWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : WebSocket(handshakeRequest) {
|
||||
|
||||
companion object {
|
||||
private val TAG = "SpinnerLogWebSocket"
|
||||
|
||||
private const val MAX_LOGS = 5_000
|
||||
|
||||
private val logs: Queue<SpinnerLogItem> = LinkedList()
|
||||
private val openSockets: MutableList<SpinnerLogWebSocket> = mutableListOf()
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
private val condition = lock.newCondition()
|
||||
|
||||
private val logThread: LogThread = LogThread().also { it.start() }
|
||||
|
||||
fun onLog(item: SpinnerLogItem) {
|
||||
lock.withLock {
|
||||
logs += item
|
||||
if (logs.size > MAX_LOGS) {
|
||||
logs.remove()
|
||||
}
|
||||
condition.signal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpen() {
|
||||
Log.d(TAG, "onOpen()")
|
||||
|
||||
lock.withLock {
|
||||
openSockets += this
|
||||
condition.signal()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClose(code: NanoWSD.WebSocketFrame.CloseCode, reason: String?, initiatedByRemote: Boolean) {
|
||||
Log.d(TAG, "onClose()")
|
||||
|
||||
lock.withLock {
|
||||
openSockets -= this
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(message: NanoWSD.WebSocketFrame) {
|
||||
Log.d(TAG, "onMessage()")
|
||||
}
|
||||
|
||||
override fun onPong(pong: NanoWSD.WebSocketFrame) {
|
||||
Log.d(TAG, "onPong()")
|
||||
}
|
||||
|
||||
override fun onException(exception: IOException) {
|
||||
Log.d(TAG, "onException()", exception)
|
||||
}
|
||||
|
||||
private class LogThread : Thread("SpinnerLog") {
|
||||
override fun run() {
|
||||
while (true) {
|
||||
val (sockets, log) = lock.withLock {
|
||||
while (logs.isEmpty() || openSockets.isEmpty()) {
|
||||
condition.await()
|
||||
}
|
||||
|
||||
openSockets.toList() to logs.remove()
|
||||
}
|
||||
|
||||
sockets.forEach { socket ->
|
||||
try {
|
||||
socket.send(log.serialize())
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to send a log to the socket!", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.spinner
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import org.signal.core.util.logging.Log.Logger
|
||||
|
||||
object SpinnerLogger : Logger() {
|
||||
|
||||
private val cachedThreadString: ThreadLocal<String> = ThreadLocal()
|
||||
|
||||
override fun v(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
|
||||
Spinner.log(
|
||||
SpinnerLogItem(
|
||||
level = Log.VERBOSE,
|
||||
time = System.currentTimeMillis(),
|
||||
thread = getThreadString(),
|
||||
tag = tag,
|
||||
message = message,
|
||||
throwable = t
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun d(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
|
||||
Spinner.log(
|
||||
SpinnerLogItem(
|
||||
level = Log.DEBUG,
|
||||
time = System.currentTimeMillis(),
|
||||
thread = getThreadString(),
|
||||
tag = tag,
|
||||
message = message,
|
||||
throwable = t
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun i(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
|
||||
Spinner.log(
|
||||
SpinnerLogItem(
|
||||
level = Log.INFO,
|
||||
time = System.currentTimeMillis(),
|
||||
thread = getThreadString(),
|
||||
tag = tag,
|
||||
message = message,
|
||||
throwable = t
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun w(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
|
||||
Spinner.log(
|
||||
SpinnerLogItem(
|
||||
level = Log.WARN,
|
||||
time = System.currentTimeMillis(),
|
||||
thread = getThreadString(),
|
||||
tag = tag,
|
||||
message = message,
|
||||
throwable = t
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun e(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) {
|
||||
Spinner.log(
|
||||
SpinnerLogItem(
|
||||
level = Log.ERROR,
|
||||
time = System.currentTimeMillis(),
|
||||
thread = getThreadString(),
|
||||
tag = tag,
|
||||
message = message,
|
||||
throwable = t
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun flush() = Unit
|
||||
|
||||
fun getThreadString(): String {
|
||||
var threadString = cachedThreadString.get()
|
||||
|
||||
if (cachedThreadString.get() == null) {
|
||||
threadString = if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
"main "
|
||||
} else {
|
||||
String.format("%-5s", Thread.currentThread().id)
|
||||
}
|
||||
|
||||
cachedThreadString.set(threadString)
|
||||
}
|
||||
|
||||
return threadString!!
|
||||
}
|
||||
}
|
||||
568
spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
package org.signal.spinner
|
||||
|
||||
import android.app.Application
|
||||
import android.database.Cursor
|
||||
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 fi.iki.elonen.NanoWSD
|
||||
import org.signal.core.util.ExceptionUtil
|
||||
import org.signal.core.util.ForeignKeyConstraint
|
||||
import org.signal.core.util.getForeignKeys
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.tracing.Tracer
|
||||
import org.signal.spinner.Spinner.DatabaseConfig
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.Queue
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 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(
|
||||
private val application: Application,
|
||||
deviceInfo: Map<String, () -> String>,
|
||||
private val databases: Map<String, DatabaseConfig>,
|
||||
private val plugins: Map<String, Plugin>
|
||||
) : NanoWSD(5000) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SpinnerServer::class.java)
|
||||
}
|
||||
|
||||
private val deviceInfo: Map<String, () -> String> = deviceInfo.filterKeys { !it.startsWith(Spinner.KEY_PREFIX) }
|
||||
private val environment: String = deviceInfo[Spinner.KEY_ENVIRONMENT]?.let { it() } ?: "UNKNOWN"
|
||||
|
||||
private val handlebars: Handlebars = Handlebars(AssetTemplateLoader(application)).apply {
|
||||
registerHelper("eq", ConditionalHelpers.eq)
|
||||
registerHelper("neq", ConditionalHelpers.neq)
|
||||
}
|
||||
|
||||
private val recentSql: MutableMap<String, Queue<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
|
||||
session.parseBody(mutableMapOf())
|
||||
}
|
||||
|
||||
val dbParam: String = session.queryParam("db") ?: session.parameters["db"]?.toString() ?: databases.keys.first()
|
||||
val dbConfig: DatabaseConfig = databases[dbParam] ?: return internalError(IllegalArgumentException("Invalid db param!"))
|
||||
|
||||
try {
|
||||
return when {
|
||||
session.method == Method.GET && session.uri.startsWith("/css/") -> newFileResponse(session.uri.substring(1), "text/css")
|
||||
session.method == Method.GET && session.uri.startsWith("/js/") -> newFileResponse(session.uri.substring(1), "text/javascript")
|
||||
session.method == Method.GET && session.uri == "/" -> getIndex(dbParam, dbConfig.db())
|
||||
session.method == Method.GET && session.uri == "/browse" -> getBrowse(dbParam, dbConfig.db())
|
||||
session.method == Method.POST && session.uri == "/browse" -> postBrowse(dbParam, dbConfig, session)
|
||||
session.method == Method.GET && session.uri == "/query" -> getQuery(dbParam)
|
||||
session.method == Method.POST && session.uri == "/query" -> postQuery(dbParam, dbConfig, session)
|
||||
session.method == Method.GET && session.uri == "/recent" -> getRecent(dbParam)
|
||||
session.method == Method.GET && session.uri == "/trace" -> getTrace()
|
||||
session.method == Method.GET && session.uri == "/logs" -> getLogs(dbParam)
|
||||
isWebsocketRequested(session) && session.uri == "/logs/websocket" -> getLogWebSocket(session)
|
||||
else -> {
|
||||
val plugin = plugins[session.uri]
|
||||
if (plugin != null && session.method == Method.GET) {
|
||||
getPlugin(dbParam, plugin)
|
||||
} else {
|
||||
newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "Not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, t)
|
||||
return internalError(t)
|
||||
}
|
||||
}
|
||||
|
||||
override fun openWebSocket(handshake: IHTTPSession): WebSocket {
|
||||
return SpinnerLogWebSocket(handshake)
|
||||
}
|
||||
|
||||
fun onSql(dbName: String, sql: String) {
|
||||
val commands: Queue<QueryItem> = recentSql[dbName] ?: ConcurrentLinkedQueue()
|
||||
|
||||
commands += QueryItem(System.currentTimeMillis(), sql)
|
||||
if (commands.size > 500) {
|
||||
commands.remove()
|
||||
}
|
||||
|
||||
recentSql[dbName] = commands
|
||||
}
|
||||
|
||||
fun onLog(item: SpinnerLogItem) {
|
||||
SpinnerLogWebSocket.onLog(item)
|
||||
}
|
||||
|
||||
private fun getIndex(dbName: String, db: SupportSQLiteDatabase): Response {
|
||||
return renderTemplate(
|
||||
"overview",
|
||||
OverviewPageModel(
|
||||
environment = environment,
|
||||
deviceInfo = deviceInfo.resolve(),
|
||||
database = dbName,
|
||||
databases = databases.keys.toList(),
|
||||
plugins = plugins.values.toList(),
|
||||
tables = db.getTables().use { it.toTableInfo() },
|
||||
indices = db.getIndexes().use { it.toIndexInfo() },
|
||||
triggers = db.getTriggers().use { it.toTriggerInfo() },
|
||||
foreignKeys = db.getForeignKeys(),
|
||||
queryResult = db.getTables().use { it.toQueryResult() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getBrowse(dbName: String, db: SupportSQLiteDatabase): Response {
|
||||
return renderTemplate(
|
||||
"browse",
|
||||
BrowsePageModel(
|
||||
environment = environment,
|
||||
deviceInfo = deviceInfo.resolve(),
|
||||
database = dbName,
|
||||
databases = databases.keys.toList(),
|
||||
plugins = plugins.values.toList(),
|
||||
tableNames = db.getTableNames()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun postBrowse(dbName: String, dbConfig: DatabaseConfig, session: IHTTPSession): Response {
|
||||
val table: String = session.parameters["table"]?.get(0).toString()
|
||||
val pageSize: Int = session.parameters["pageSize"]?.get(0)?.toInt() ?: 1000
|
||||
var pageIndex: Int = session.parameters["pageIndex"]?.get(0)?.toInt() ?: 0
|
||||
val action: String? = session.parameters["action"]?.get(0)
|
||||
|
||||
val rowCount = dbConfig.db().getTableRowCount(table)
|
||||
val pageCount = ceil(rowCount.toFloat() / pageSize.toFloat()).toInt()
|
||||
|
||||
when (action) {
|
||||
"first" -> pageIndex = 0
|
||||
"next" -> pageIndex = min(pageIndex + 1, pageCount - 1)
|
||||
"previous" -> pageIndex = max(pageIndex - 1, 0)
|
||||
"last" -> pageIndex = pageCount - 1
|
||||
}
|
||||
|
||||
val query = "select * from $table limit $pageSize offset ${pageSize * pageIndex}"
|
||||
val queryResult = dbConfig.db().query(query).use { it.toQueryResult(columnTransformers = dbConfig.columnTransformers, table = table) }
|
||||
|
||||
return renderTemplate(
|
||||
"browse",
|
||||
BrowsePageModel(
|
||||
environment = environment,
|
||||
deviceInfo = deviceInfo.resolve(),
|
||||
database = dbName,
|
||||
databases = databases.keys.toList(),
|
||||
plugins = plugins.values.toList(),
|
||||
tableNames = dbConfig.db().getTableNames(),
|
||||
table = table,
|
||||
queryResult = queryResult,
|
||||
pagingData = PagingData(
|
||||
rowCount = rowCount,
|
||||
pageSize = pageSize,
|
||||
pageIndex = pageIndex,
|
||||
pageCount = pageCount,
|
||||
startRow = pageSize * pageIndex,
|
||||
endRow = min(pageSize * (pageIndex + 1), rowCount)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getQuery(dbName: String): Response {
|
||||
return renderTemplate(
|
||||
"query",
|
||||
QueryPageModel(
|
||||
environment = environment,
|
||||
deviceInfo = deviceInfo.resolve(),
|
||||
database = dbName,
|
||||
databases = databases.keys.toList(),
|
||||
plugins = plugins.values.toList(),
|
||||
query = ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getRecent(dbName: String): Response {
|
||||
val queries: List<RecentQuery>? = recentSql[dbName]
|
||||
?.map { it ->
|
||||
RecentQuery(
|
||||
formattedTime = dateFormat.format(Date(it.time)),
|
||||
query = it.query
|
||||
)
|
||||
}
|
||||
|
||||
return renderTemplate(
|
||||
"recent",
|
||||
RecentPageModel(
|
||||
environment = environment,
|
||||
deviceInfo = deviceInfo.resolve(),
|
||||
database = dbName,
|
||||
databases = databases.keys.toList(),
|
||||
plugins = plugins.values.toList(),
|
||||
recentSql = queries?.reversed()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getTrace(): Response {
|
||||
return newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
"application/octet-stream",
|
||||
ByteArrayInputStream(Tracer.getInstance().serialize())
|
||||
)
|
||||
}
|
||||
|
||||
private fun getLogs(dbName: String): Response {
|
||||
return renderTemplate(
|
||||
"logs",
|
||||
LogsPageModel(
|
||||
environment = environment,
|
||||
deviceInfo = deviceInfo.resolve(),
|
||||
database = dbName,
|
||||
databases = databases.keys.toList(),
|
||||
plugins = plugins.values.toList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getLogWebSocket(session: IHTTPSession): Response {
|
||||
val headers = session.headers
|
||||
val webSocket = openWebSocket(session)
|
||||
|
||||
val handshakeResponse = webSocket.handshakeResponse
|
||||
|
||||
try {
|
||||
handshakeResponse.addHeader(HEADER_WEBSOCKET_ACCEPT, makeAcceptKey(headers[HEADER_WEBSOCKET_KEY]))
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "The SHA-1 Algorithm required for websockets is not available on the server.")
|
||||
}
|
||||
|
||||
if (headers.containsKey(HEADER_WEBSOCKET_PROTOCOL)) {
|
||||
handshakeResponse.addHeader(HEADER_WEBSOCKET_PROTOCOL, headers[HEADER_WEBSOCKET_PROTOCOL]!!.split(",")[0])
|
||||
}
|
||||
|
||||
return webSocket.handshakeResponse
|
||||
}
|
||||
|
||||
private fun postQuery(dbName: String, dbConfig: DatabaseConfig, 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 startTimeNanos = System.nanoTime()
|
||||
|
||||
return renderTemplate(
|
||||
"query",
|
||||
QueryPageModel(
|
||||
environment = environment,
|
||||
deviceInfo = deviceInfo.resolve(),
|
||||
database = dbName,
|
||||
databases = databases.keys.toList(),
|
||||
plugins = plugins.values.toList(),
|
||||
query = rawQuery,
|
||||
queryResult = dbConfig.db().query(query).use { it.toQueryResult(queryStartTimeNanos = startTimeNanos, columnTransformers = dbConfig.columnTransformers) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPlugin(dbName: String, plugin: Plugin): Response {
|
||||
return renderTemplate(
|
||||
"plugin",
|
||||
PluginPageModel(
|
||||
environment = environment,
|
||||
deviceInfo = deviceInfo.resolve(),
|
||||
database = dbName,
|
||||
databases = databases.keys.toList(),
|
||||
plugins = plugins.values.toList(),
|
||||
activePlugin = plugin,
|
||||
pluginResult = plugin.get()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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("<br />")
|
||||
|
||||
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 newFileResponse(assetPath: String, mimeType: String): Response {
|
||||
return newChunkedResponse(
|
||||
Response.Status.OK,
|
||||
mimeType,
|
||||
application.assets.open(assetPath)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Cursor.toQueryResult(queryStartTimeNanos: Long = 0, columnTransformers: List<ColumnTransformer> = emptyList(), table: String? = null): QueryResult {
|
||||
val numColumns = this.columnCount
|
||||
val columns = mutableListOf<String>()
|
||||
val transformers = mutableListOf<ColumnTransformer>()
|
||||
|
||||
for (i in 0 until numColumns) {
|
||||
val columnName = getColumnName(i)
|
||||
val customTransformer: ColumnTransformer? = columnTransformers.find { it.matches(table, columnName) }
|
||||
|
||||
columns += if (customTransformer != null) {
|
||||
"$columnName *"
|
||||
} else {
|
||||
columnName
|
||||
}
|
||||
|
||||
transformers += customTransformer ?: DefaultColumnTransformer
|
||||
}
|
||||
|
||||
var timeOfFirstRowNanos = 0L
|
||||
val rows = mutableListOf<List<String?>>()
|
||||
while (moveToNext()) {
|
||||
if (timeOfFirstRowNanos == 0L) {
|
||||
timeOfFirstRowNanos = System.nanoTime()
|
||||
}
|
||||
|
||||
val row = mutableListOf<String?>()
|
||||
for (i in 0 until numColumns) {
|
||||
val columnName: String = getColumnName(i)
|
||||
try {
|
||||
row += transformers[i].transform(null, columnName, this)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to transform", e)
|
||||
row += "*Failed to Transform*\n\n${DefaultColumnTransformer.transform(null, columnName, this)}"
|
||||
}
|
||||
}
|
||||
|
||||
rows += row
|
||||
}
|
||||
|
||||
if (timeOfFirstRowNanos == 0L) {
|
||||
timeOfFirstRowNanos = System.nanoTime()
|
||||
}
|
||||
|
||||
return QueryResult(
|
||||
columns = columns,
|
||||
rows = rows,
|
||||
timeToFirstRow = (max(timeOfFirstRowNanos - queryStartTimeNanos, 0) / 1_000_000.0f).roundForDisplay(3),
|
||||
timeToReadRows = (max(System.nanoTime() - timeOfFirstRowNanos, 0) / 1_000_000.0f).roundForDisplay(3)
|
||||
)
|
||||
}
|
||||
|
||||
fun Float.roundForDisplay(decimals: Int = 2): String {
|
||||
return "%.${decimals}f".format(this)
|
||||
}
|
||||
|
||||
private fun Cursor.toTableInfo(): List<TableInfo> {
|
||||
val tables = mutableListOf<TableInfo>()
|
||||
|
||||
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<IndexInfo> {
|
||||
val indices = mutableListOf<IndexInfo>()
|
||||
|
||||
while (moveToNext()) {
|
||||
indices += IndexInfo(
|
||||
name = getString(getColumnIndexOrThrow("name")) ?: "null",
|
||||
sql = getString(getColumnIndexOrThrow("sql")) ?: "null"
|
||||
)
|
||||
}
|
||||
|
||||
return indices
|
||||
}
|
||||
|
||||
private fun Cursor.toTriggerInfo(): List<TriggerInfo> {
|
||||
val indices = mutableListOf<TriggerInfo>()
|
||||
|
||||
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()},<br>" }.toMutableList()
|
||||
|
||||
if (fieldStrings.isNotEmpty()) {
|
||||
fieldStrings[fieldStrings.lastIndex] = " ${fields.last().trim()}<br>"
|
||||
}
|
||||
|
||||
return "CREATE TABLE $name (<br/>" +
|
||||
fieldStrings.joinToString("") +
|
||||
")"
|
||||
}
|
||||
|
||||
private fun IHTTPSession.queryParam(name: String): String? {
|
||||
if (queryParameterString == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val params: Map<String, String> = queryParameterString
|
||||
.split("&")
|
||||
.mapNotNull { part ->
|
||||
val parts = part.split("=")
|
||||
if (parts.size == 2) {
|
||||
parts[0] to parts[1]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.toMap()
|
||||
|
||||
return params[name]
|
||||
}
|
||||
|
||||
private fun Map<String, () -> String>.resolve(): Map<String, String> {
|
||||
return this.mapValues { entry -> entry.value() }.toMap()
|
||||
}
|
||||
|
||||
interface PrefixPageData {
|
||||
val environment: String
|
||||
val deviceInfo: Map<String, String>
|
||||
val database: String
|
||||
val databases: List<String>
|
||||
val plugins: List<Plugin>
|
||||
}
|
||||
|
||||
data class OverviewPageModel(
|
||||
override val environment: String,
|
||||
override val deviceInfo: Map<String, String>,
|
||||
override val database: String,
|
||||
override val databases: List<String>,
|
||||
override val plugins: List<Plugin>,
|
||||
val tables: List<TableInfo>,
|
||||
val indices: List<IndexInfo>,
|
||||
val triggers: List<TriggerInfo>,
|
||||
val foreignKeys: List<ForeignKeyConstraint>,
|
||||
val queryResult: QueryResult? = null
|
||||
) : PrefixPageData
|
||||
|
||||
data class BrowsePageModel(
|
||||
override val environment: String,
|
||||
override val deviceInfo: Map<String, String>,
|
||||
override val database: String,
|
||||
override val databases: List<String>,
|
||||
override val plugins: List<Plugin>,
|
||||
val tableNames: List<String>,
|
||||
val table: String? = null,
|
||||
val queryResult: QueryResult? = null,
|
||||
val pagingData: PagingData? = null
|
||||
) : PrefixPageData
|
||||
|
||||
data class QueryPageModel(
|
||||
override val environment: String,
|
||||
override val deviceInfo: Map<String, String>,
|
||||
override val database: String,
|
||||
override val databases: List<String>,
|
||||
override val plugins: List<Plugin>,
|
||||
val query: String = "",
|
||||
val queryResult: QueryResult? = null
|
||||
) : PrefixPageData
|
||||
|
||||
data class RecentPageModel(
|
||||
override val environment: String,
|
||||
override val deviceInfo: Map<String, String>,
|
||||
override val database: String,
|
||||
override val databases: List<String>,
|
||||
override val plugins: List<Plugin>,
|
||||
val recentSql: List<RecentQuery>?
|
||||
) : PrefixPageData
|
||||
|
||||
data class LogsPageModel(
|
||||
override val environment: String,
|
||||
override val deviceInfo: Map<String, String>,
|
||||
override val database: String,
|
||||
override val databases: List<String>,
|
||||
override val plugins: List<Plugin>
|
||||
) : PrefixPageData
|
||||
|
||||
data class PluginPageModel(
|
||||
override val environment: String,
|
||||
override val deviceInfo: Map<String, String>,
|
||||
override val database: String,
|
||||
override val databases: List<String>,
|
||||
override val plugins: List<Plugin>,
|
||||
val activePlugin: Plugin,
|
||||
val pluginResult: PluginResult
|
||||
) : PrefixPageData
|
||||
|
||||
data class QueryResult(
|
||||
val columns: List<String>,
|
||||
val rows: List<List<String?>>,
|
||||
val rowCount: Int = rows.size,
|
||||
val timeToFirstRow: String,
|
||||
val timeToReadRows: String
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
data class PagingData(
|
||||
val rowCount: Int,
|
||||
val pageSize: Int,
|
||||
val pageIndex: Int,
|
||||
val pageCount: Int,
|
||||
val firstPage: Boolean = pageIndex == 0,
|
||||
val lastPage: Boolean = pageIndex == pageCount - 1,
|
||||
val startRow: Int,
|
||||
val endRow: Int
|
||||
)
|
||||
|
||||
data class QueryItem(
|
||||
val time: Long,
|
||||
val query: String
|
||||
)
|
||||
|
||||
data class RecentQuery(
|
||||
val formattedTime: String,
|
||||
val query: String
|
||||
)
|
||||
}
|
||||