diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt index b4689f72e6..49f7c3078b 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt @@ -26,9 +26,7 @@ class SignalInstrumentationApplicationContext : ApplicationContext() { } override fun initializeLogging() { - persistentLogger = PersistentLogger(this) - - Log.initialize({ true }, AndroidLogger(), persistentLogger, inMemoryLogger) + Log.initialize({ true }, AndroidLogger(), PersistentLogger(this), inMemoryLogger) SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index f2c550b636..0e1e20afda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -124,9 +124,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr private static final String TAG = Log.tag(ApplicationContext.class); - @VisibleForTesting - protected PersistentLogger persistentLogger; - public static ApplicationContext getInstance(Context context) { return (ApplicationContext)context.getApplicationContext(); } @@ -265,10 +262,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr MemoryTracker.stop(); } - public PersistentLogger getPersistentLogger() { - return persistentLogger; - } - public void checkBuildExpiration() { if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) { Log.w(TAG, "Build expired!"); @@ -295,8 +288,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr @VisibleForTesting protected void initializeLogging() { - persistentLogger = new PersistentLogger(this); - org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger); + org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), new PersistentLogger(this)); SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger()); diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt index 55de359079..1000f5b0f9 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt @@ -2,8 +2,11 @@ package org.thoughtcrime.securesms import android.content.ContentValues import android.os.Build +import org.signal.core.util.logging.AndroidLogger +import org.signal.core.util.logging.Log import org.signal.spinner.Spinner import org.signal.spinner.Spinner.DatabaseConfig +import org.signal.spinner.SpinnerLogger import org.thoughtcrime.securesms.database.DatabaseMonitor import org.thoughtcrime.securesms.database.GV2Transformer import org.thoughtcrime.securesms.database.GV2UpdateTransformer @@ -22,8 +25,10 @@ import org.thoughtcrime.securesms.database.RecipientTransformer import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.TimestampTransformer import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.logging.PersistentLogger import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.AppSignatureUtil +import org.thoughtcrime.securesms.util.FeatureFlags import java.util.Locale class SpinnerApplicationContext : ApplicationContext() { @@ -69,6 +74,8 @@ class SpinnerApplicationContext : ApplicationContext() { ) ) + Log.initialize({ FeatureFlags.internalUser() }, AndroidLogger(), PersistentLogger(this), SpinnerLogger()) + DatabaseMonitor.initialize(object : QueryMonitor { override fun onSql(sql: String, args: Array?) { Spinner.onSql("signal", sql, args) diff --git a/dependencies.gradle b/dependencies.gradle index c6ab2085aa..a819aeeea3 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -19,6 +19,7 @@ dependencyResolutionManagement { version('mp4parser', '1.9.39') version('android-gradle-plugin', '8.0.2') version('accompanist', '0.28.0') + version('nanohttpd', '2.3.1') // Android Plugins library('android-library', 'com.android.library', 'com.android.library.gradle.plugin').versionRef('android-gradle-plugin') @@ -144,7 +145,8 @@ dependencyResolutionManagement { library('stream', 'com.annimon:stream:1.1.8') library('lottie', 'com.airbnb.android:lottie:5.2.0') library('dnsjava', 'dnsjava:dnsjava:2.1.9') - library('nanohttpd-webserver', 'org.nanohttpd:nanohttpd-webserver:2.3.1') + library('nanohttpd-webserver', 'org.nanohttpd', 'nanohttpd-webserver').versionRef('nanohttpd') + library('nanohttpd-websocket', 'org.nanohttpd', 'nanohttpd-websocket').versionRef('nanohttpd') library('kotlinx-collections-immutable', 'org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5') // Can't use the newest version because it hits some weird NoClassDefFoundException diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a483a67e52..25e7a98035 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -5135,6 +5135,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + diff --git a/spinner/app/src/main/java/org/signal/spinnertest/MainActivity.kt b/spinner/app/src/main/java/org/signal/spinnertest/MainActivity.kt index e0fdcceb8a..981f368009 100644 --- a/spinner/app/src/main/java/org/signal/spinnertest/MainActivity.kt +++ b/spinner/app/src/main/java/org/signal/spinnertest/MainActivity.kt @@ -3,10 +3,16 @@ 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?) { @@ -26,6 +32,25 @@ class MainActivity : AppCompatActivity() { 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 + +
+ + +
+ + + {{> partials/suffix }} + + + + diff --git a/spinner/lib/src/main/assets/partials/prefix.hbs b/spinner/lib/src/main/assets/partials/prefix.hbs index fbc0f2eff2..69c93c49c1 100644 --- a/spinner/lib/src/main/assets/partials/prefix.hbs +++ b/spinner/lib/src/main/assets/partials/prefix.hbs @@ -23,6 +23,7 @@
  • Browse
  • Query
  • Recent
  • +
  • Logs
  • {{#each plugins}}
  • {{name}}
  • {{/each}} diff --git a/spinner/lib/src/main/java/org/signal/spinner/Spinner.kt b/spinner/lib/src/main/java/org/signal/spinner/Spinner.kt index ff7a0f5165..d28fc91e76 100644 --- a/spinner/lib/src/main/java/org/signal/spinner/Spinner.kt +++ b/spinner/lib/src/main/java/org/signal/spinner/Spinner.kt @@ -68,6 +68,10 @@ object Spinner { server.onSql(dbName, queryString) } + internal fun log(item: SpinnerLogItem) { + server.onLog(item) + } + private fun replaceQueryArgs(query: String, args: Array?): String { if (args == null) { return query diff --git a/spinner/lib/src/main/java/org/signal/spinner/SpinnerLogItem.kt b/spinner/lib/src/main/java/org/signal/spinner/SpinnerLogItem.kt new file mode 100644 index 0000000000..e6fa198dd9 --- /dev/null +++ b/spinner/lib/src/main/java/org/signal/spinner/SpinnerLogItem.kt @@ -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) + } +} diff --git a/spinner/lib/src/main/java/org/signal/spinner/SpinnerLogWebSocket.kt b/spinner/lib/src/main/java/org/signal/spinner/SpinnerLogWebSocket.kt new file mode 100644 index 0000000000..b8f5feb67b --- /dev/null +++ b/spinner/lib/src/main/java/org/signal/spinner/SpinnerLogWebSocket.kt @@ -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 = LinkedList() + private val openSockets: MutableList = 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) + } + } + } + } + } +} diff --git a/spinner/lib/src/main/java/org/signal/spinner/SpinnerLogger.kt b/spinner/lib/src/main/java/org/signal/spinner/SpinnerLogger.kt new file mode 100644 index 0000000000..46dec483b4 --- /dev/null +++ b/spinner/lib/src/main/java/org/signal/spinner/SpinnerLogger.kt @@ -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 + +class SpinnerLogger : Logger() { + + private val cachedThreadString: ThreadLocal = 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!! + } +} diff --git a/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt b/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt index 4556a396d9..420d6b41c1 100644 --- a/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt +++ b/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt @@ -7,12 +7,14 @@ 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.spinner.Spinner.DatabaseConfig import java.lang.IllegalArgumentException +import java.security.NoSuchAlgorithmException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -33,7 +35,7 @@ internal class SpinnerServer( deviceInfo: Map String>, private val databases: Map, private val plugins: Map -) : NanoHTTPD(5000) { +) : NanoWSD(5000) { companion object { private val TAG = Log.tag(SpinnerServer::class.java) @@ -69,6 +71,8 @@ internal class SpinnerServer( 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 == "/logs" -> getLogs(dbParam) + isWebsocketRequested(session) && session.uri == "/logs/websocket" -> getLogWebSocket(session) else -> { val plugin = plugins[session.uri] if (plugin != null && session.method == Method.GET) { @@ -84,6 +88,10 @@ internal class SpinnerServer( } } + override fun openWebSocket(handshake: IHTTPSession): WebSocket { + return SpinnerLogWebSocket(handshake) + } + fun onSql(dbName: String, sql: String) { val commands: Queue = recentSql[dbName] ?: ConcurrentLinkedQueue() @@ -95,6 +103,10 @@ internal class SpinnerServer( recentSql[dbName] = commands } + fun onLog(item: SpinnerLogItem) { + SpinnerLogWebSocket.onLog(item) + } + private fun getIndex(dbName: String, db: SupportSQLiteDatabase): Response { return renderTemplate( "overview", @@ -205,6 +217,38 @@ internal class SpinnerServer( ) } + 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() @@ -448,6 +492,14 @@ internal class SpinnerServer( val recentSql: List? ) : PrefixPageData + data class LogsPageModel( + override val environment: String, + override val deviceInfo: Map, + override val database: String, + override val databases: List, + override val plugins: List + ) : PrefixPageData + data class PluginPageModel( override val environment: String, override val deviceInfo: Map,