())
-
- /**
- * Register a consumer to receive log messages.
- * The consumer will receive both new messages and all buffered messages.
- */
- fun registerConsumer(consumer: Consumer) {
- consumers.add(consumer)
- buffer.forEach { message ->
- dispatchTo(
- consumer = consumer,
- level = message.level,
- message = message.message,
- )
- }
- }
-
- /**
- * Unregister a consumer.
- */
- fun unregisterConsumer(consumer: Consumer) {
- consumers.remove(consumer)
- }
-
- private fun dispatch(
- level: Level,
- message: String,
- ) {
- consumers.forEach { consumer ->
- dispatchTo(consumer, level, message)
- }
- }
-
- private fun dispatchTo(
- consumer: Consumer,
- level: Level,
- message: String,
- ) {
- if (level.levelInt < consumer.logLevel.levelInt) return
- try {
- consumer.consume(level, message)
- } catch (e: Exception) {
- Log.e("GlobalBufferAppender", "Log consumer failed", e)
- }
- }
- }
-
- override fun start() {
- this.logLayout.start()
- super.start()
- }
-
- override fun stop() {
- super.stop()
- this.logLayout.stop()
- }
-
- override fun setContext(context: Context?) {
- super.setContext(context)
- this.logLayout.context = context
- }
-
- override fun append(eventObject: ILoggingEvent?) {
- if (eventObject == null || !isStarted) {
- return
- }
-
- // Format the log message
- val formattedMessage = logLayout.doLayout(eventObject).trim()
-
- // Add to buffer
- buffer.offer(LogEvent(eventObject.level, formattedMessage))
-
- // Maintain buffer size
- if (bufferSize.incrementAndGet() > MAX_BUFFER_SIZE) {
- buffer.poll() // Remove oldest entry
- bufferSize.decrementAndGet()
- }
-
- dispatch(eventObject.level, formattedMessage)
- }
-}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/IDELoggingConfigurator.kt b/logger/src/main/java/com/itsaky/androidide/logging/IDELoggingConfigurator.kt
deleted file mode 100644
index ddbcbc7290..0000000000
--- a/logger/src/main/java/com/itsaky/androidide/logging/IDELoggingConfigurator.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.logging
-
-import ch.qos.logback.classic.Logger
-import ch.qos.logback.classic.LoggerContext
-import ch.qos.logback.classic.spi.Configurator
-import ch.qos.logback.classic.spi.ConfiguratorRank
-import ch.qos.logback.core.spi.ContextAwareBase
-import com.google.auto.service.AutoService
-
-/**
- * Default IDE logging configurator.
- *
- * @author Akash Yadav
- */
-@ConfiguratorRank(ConfiguratorRank.CUSTOM_TOP_PRIORITY)
-@AutoService(Configurator::class)
-@Suppress("UNUSED")
-class IDELoggingConfigurator : ContextAwareBase(), Configurator {
-
- override fun configure(context: LoggerContext): Configurator.ExecutionStatus {
- addInfo("Setting up logging configuration")
-
- val appender = LogcatAppender()
- appender.context = context
- appender.start()
-
- val globalBufferAppender = GlobalBufferAppender()
- globalBufferAppender.context = context
- globalBufferAppender.start()
-
- val rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME)
- rootLogger.addAppender(appender)
- rootLogger.addAppender(globalBufferAppender)
-
- return Configurator.ExecutionStatus.DO_NOT_INVOKE_NEXT_IF_ANY
- }
-}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/JvmStdErrAppender.java b/logger/src/main/java/com/itsaky/androidide/logging/JvmStdErrAppender.java
deleted file mode 100644
index 13f503e0d0..0000000000
--- a/logger/src/main/java/com/itsaky/androidide/logging/JvmStdErrAppender.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.logging;
-
-import ch.qos.logback.classic.spi.ILoggingEvent;
-import com.itsaky.androidide.logging.utils.LogUtils;
-
-/**
- * @author Akash Yadav
- */
-public class JvmStdErrAppender extends StdErrAppender {
-
- public static final String PROP_JVM_STDERR_APPENDER_ENABLED = "ide.logging.jvmStdErrAppenderEnabled";
-
- private boolean jvmStdErrAppenderEnabled = true;
-
- @Override
- public void start() {
- jvmStdErrAppenderEnabled = Boolean.parseBoolean(
- System.getProperty(PROP_JVM_STDERR_APPENDER_ENABLED, "true"));
- jvmStdErrAppenderEnabled &= LogUtils.isJvm();
- super.start();
- }
-
- @Override
- protected void append(ILoggingEvent eventObject) {
- if (!jvmStdErrAppenderEnabled)
- return;
- super.append(eventObject);
- }
-}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/LogcatAppender.java b/logger/src/main/java/com/itsaky/androidide/logging/LogcatAppender.java
deleted file mode 100644
index ca3eaf0fc4..0000000000
--- a/logger/src/main/java/com/itsaky/androidide/logging/LogcatAppender.java
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * Copyright 2019 Anthony Trinh
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the License
- * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
- * or implied. See the License for the specific language governing permissions and limitations under
- * the License.
- */
-package com.itsaky.androidide.logging;
-
-import android.util.Log;
-
-import com.itsaky.androidide.logging.utils.LogUtils;
-
-import ch.qos.logback.classic.Level;
-import ch.qos.logback.classic.PatternLayout;
-import ch.qos.logback.classic.pattern.Abbreviator;
-import ch.qos.logback.classic.pattern.ClassNameOnlyAbbreviator;
-import ch.qos.logback.classic.spi.ILoggingEvent;
-import ch.qos.logback.core.Context;
-import ch.qos.logback.core.UnsynchronizedAppenderBase;
-
-/**
- * An appender that wraps the native Android logging mechanism (logcat); redirects all
- * logging requests to logcat
- *
- * Note:
- * By default, this appender pushes all messages to logcat regardless of logcat's own
- * filter settings (i.e., everything is printed). To disable this behavior and enable
- * filter-checking, use {@link #setCheckLoggable(boolean)}. See the Android Developer Guide for
- * details on adjusting the logcat filter.
- *
- *
- * @author Fred Eisele
- * @author Anthony Trinh
- * @see ADB
- * Filtering Output
- */
-public class LogcatAppender extends UnsynchronizedAppenderBase {
-
- private final Abbreviator classNameAbbreviator = new ClassNameOnlyAbbreviator();
- private final PatternLayout messageLayout = new PatternLayout();
- private boolean checkLoggable = false;
-
- // AndroidIDE Changed: Appender is enabled only when running on Android.
- private boolean isAndroid = false;
- // AndroidIDE Changed
-
- public LogcatAppender() {
- this.messageLayout.setPattern(LogUtils.PATTERN_LAYOUT_MESSAGE_PATTERN);
- }
-
- /**
- * Checks that required parameters are set, and if everything is in order, activates this
- * appender.
- */
- @Override
- public void start() {
- this.isAndroid = !LogUtils.isJvm();
-
- if (!isAndroid) {
- addInfo("Appender [" + name + "] is not running on Android. Skipping init.");
- return;
- }
-
- super.start();
- this.messageLayout.start();
- }
-
- @Override
- public void setContext(Context context) {
- super.setContext(context);
- this.messageLayout.setContext(context);
- }
-
- @Override
- public void stop() {
- super.stop();
- this.messageLayout.stop();
- }
-
- @Override
- public boolean isStarted() {
- return super.isStarted() && isAndroid;
- }
-
- /**
- * Writes an event to Android's logging mechanism (logcat)
- *
- * @param event the event to be logged
- */
- @Override
- public void append(ILoggingEvent event) {
- if (!isStarted()) {
- return;
- }
-
- String tag = this.classNameAbbreviator.abbreviate(event.getLoggerName());
-
- switch (event.getLevel().levelInt) {
- case Level.ALL_INT:
- case Level.TRACE_INT:
- if (!checkLoggable || Log.isLoggable(tag, Log.VERBOSE)) {
- Log.v(tag, this.messageLayout.doLayout(event));
- }
- break;
-
- case Level.DEBUG_INT:
- if (!checkLoggable || Log.isLoggable(tag, Log.DEBUG)) {
- Log.d(tag, this.messageLayout.doLayout(event));
- }
- break;
-
- case Level.INFO_INT:
- if (!checkLoggable || Log.isLoggable(tag, Log.INFO)) {
- Log.i(tag, this.messageLayout.doLayout(event));
- }
- break;
-
- case Level.WARN_INT:
- if (!checkLoggable || Log.isLoggable(tag, Log.WARN)) {
- Log.w(tag, this.messageLayout.doLayout(event));
- }
- break;
-
- case Level.ERROR_INT:
- if (!checkLoggable || Log.isLoggable(tag, Log.ERROR)) {
- Log.e(tag, this.messageLayout.doLayout(event));
- }
- break;
-
- case Level.OFF_INT:
- default:
- break;
- }
- }
-
- /**
- * Sets whether to ask Android before logging a message with a specific tag and priority (i.e.,
- * calls {@code android.util.Log.html#isLoggable}).
- *
- * See Log#isLoggable(java.lang.String,
- * int)
- *
- * @param enable {@code true} to enable; {@code false} to disable
- */
- public void setCheckLoggable(boolean enable) {
- this.checkLoggable = enable;
- }
-
- /**
- * Gets the enable status of the isLoggable()-check that is called before logging
- *
- * Log#isLoggable(java.lang.String,
- * int)
- *
- * @return {@code true} if enabled; {@code false} otherwise
- */
- public boolean getCheckLoggable() {
- return this.checkLoggable;
- }
-
-}
\ No newline at end of file
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/StdErrAppender.java b/logger/src/main/java/com/itsaky/androidide/logging/StdErrAppender.java
deleted file mode 100644
index 77d346495a..0000000000
--- a/logger/src/main/java/com/itsaky/androidide/logging/StdErrAppender.java
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.logging;
-
-import com.itsaky.androidide.logging.encoder.IDELogFormatLayout;
-
-import java.io.IOException;
-
-import ch.qos.logback.classic.PatternLayout;
-import ch.qos.logback.classic.spi.ILoggingEvent;
-import ch.qos.logback.core.Context;
-import ch.qos.logback.core.UnsynchronizedAppenderBase;
-
-/**
- * @author Akash Yadav
- */
-public class StdErrAppender extends UnsynchronizedAppenderBase {
-
- private final PatternLayout layout = new IDELogFormatLayout(false);
-
- @Override
- public void start() {
- super.start();
- layout.start();
- }
-
- @Override
- public void setContext(Context context) {
- super.setContext(context);
- layout.setContext(context);
- }
-
- @Override
- public void stop() {
- super.stop();
- layout.stop();
- }
-
- @Override
- protected void append(ILoggingEvent eventObject) {
- if (!isStarted()) {
- return;
- }
-
- final var bytes = layout.doLayout(eventObject).getBytes();
- try {
- System.err.write(bytes);
- } catch (IOException e) {
- addError("Failed to write to stderr", e);
- }
- }
-}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/encoder/IDELogFormatEncoder.java b/logger/src/main/java/com/itsaky/androidide/logging/encoder/IDELogFormatEncoder.java
deleted file mode 100644
index bfa377aa99..0000000000
--- a/logger/src/main/java/com/itsaky/androidide/logging/encoder/IDELogFormatEncoder.java
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.logging.encoder;
-
-import com.itsaky.androidide.logging.utils.LogUtils;
-
-import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
-
-/**
- * Encoder to format the log events in AndroidIDE.
- *
- * @author Akash Yadav
- */
-public class IDELogFormatEncoder extends PatternLayoutEncoder {
-
- public IDELogFormatEncoder() {
- this(false);
- }
-
- public IDELogFormatEncoder(boolean omitMessage) {
- super();
- setPattern(LogUtils.getPatternLayoutVerbosePattern(omitMessage));
- }
-}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/encoder/IDELogFormatLayout.java b/logger/src/main/java/com/itsaky/androidide/logging/encoder/IDELogFormatLayout.java
deleted file mode 100644
index d4e790872e..0000000000
--- a/logger/src/main/java/com/itsaky/androidide/logging/encoder/IDELogFormatLayout.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.logging.encoder;
-
-import com.itsaky.androidide.logging.utils.LogUtils;
-
-import ch.qos.logback.classic.PatternLayout;
-
-/**
- * Log layout for AndroidIDE.
- *
- * @author Akash Yadav
- */
-public class IDELogFormatLayout extends PatternLayout {
- public IDELogFormatLayout(boolean omitMessage) {
- super();
- setPattern(LogUtils.getPatternLayoutVerbosePattern(omitMessage));
- }
-}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeGlobalLogBuffer.kt b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeGlobalLogBuffer.kt
new file mode 100644
index 0000000000..f4434c4eaf
--- /dev/null
+++ b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeGlobalLogBuffer.kt
@@ -0,0 +1,99 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.logging.provider
+
+import org.slf4j.event.Level
+import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Port of the Logback-backed `GlobalBufferAppender`: a ring buffer of formatted log lines
+ * that makes recent logs available to the IDE Logs tab, independent of any particular
+ * SLF4J backend.
+ *
+ * @author Akash Yadav
+ */
+object IdeGlobalLogBuffer {
+ interface Consumer {
+ val logLevel: Level
+
+ fun consume(
+ level: Level,
+ message: String,
+ )
+ }
+
+ private data class LogEvent(
+ val level: Level,
+ val message: String,
+ )
+
+ private const val MAX_BUFFER_SIZE = 1000
+ private val buffer = ConcurrentLinkedQueue()
+ private val bufferSize = AtomicInteger(0)
+ private val consumers = CopyOnWriteArrayList()
+
+ /**
+ * Register a consumer to receive log messages.
+ * The consumer will receive both new messages and all buffered messages.
+ */
+ fun registerConsumer(consumer: Consumer) {
+ consumers.add(consumer)
+ buffer.forEach { event -> dispatchTo(consumer, event.level, event.message) }
+ }
+
+ /**
+ * Unregister a consumer.
+ */
+ fun unregisterConsumer(consumer: Consumer) {
+ consumers.remove(consumer)
+ }
+
+ fun append(
+ level: Level,
+ formattedMessage: String,
+ ) {
+ val trimmed = formattedMessage.trim()
+
+ buffer.offer(LogEvent(level, trimmed))
+ if (bufferSize.incrementAndGet() > MAX_BUFFER_SIZE) {
+ buffer.poll()
+ bufferSize.decrementAndGet()
+ }
+
+ dispatch(level, trimmed)
+ }
+
+ private fun dispatch(
+ level: Level,
+ message: String,
+ ) {
+ consumers.forEach { consumer -> dispatchTo(consumer, level, message) }
+ }
+
+ private fun dispatchTo(
+ consumer: Consumer,
+ level: Level,
+ message: String,
+ ) {
+ if (level.toInt() < consumer.logLevel.toInt()) return
+ runCatching { consumer.consume(level, message) }
+ .onFailure { error -> System.err.println("IdeGlobalLogBuffer: consumer $consumer failed: $error") }
+ }
+}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLogFormatter.kt b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLogFormatter.kt
new file mode 100644
index 0000000000..acd47543d2
--- /dev/null
+++ b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLogFormatter.kt
@@ -0,0 +1,72 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.logging.provider
+
+import org.slf4j.event.Level
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+/**
+ * Ports the message format previously produced by Logback's `IDELogFormatLayout`
+ * (pattern `%d{dd-MM HH:mm:ss.SS} %5level [%thread] %logger{0}: %msg%n`) without
+ * depending on Logback's `PatternLayout` engine.
+ *
+ * @author Akash Yadav
+ */
+object IdeLogFormatter {
+ private val TIMESTAMP_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("dd-MM HH:mm:ss")
+
+ /**
+ * Abbreviates a fully-qualified logger name to its last segment, matching Logback's
+ * `%logger{0}` behavior.
+ */
+ fun abbreviateLoggerName(loggerName: String): String {
+ val lastDot = loggerName.lastIndexOf('.')
+ return if (lastDot == -1) loggerName else loggerName.substring(lastDot + 1)
+ }
+
+ /** Flattens a throwable's stack trace onto the message, matching Logback's PatternLayout. */
+ fun appendThrowable(
+ message: String,
+ throwable: Throwable?,
+ ): String = if (throwable == null) message else "$message\n${throwable.stackTraceToString()}"
+
+ /**
+ * Formats a single log line as `dd-MM HH:mm:ss.SS LEVEL [thread] tag: message`, followed
+ * by a trailing newline. Timestamps use the JVM's local time zone (not UTC).
+ *
+ * @param omitMessage when `true`, the `message` argument is dropped and the line ends
+ * right after `tag:` (still newline-terminated) — for callers that only need the
+ * line's header.
+ */
+ fun format(
+ level: Level,
+ loggerName: String,
+ message: String,
+ omitMessage: Boolean = false,
+ ): String {
+ val now = LocalDateTime.now()
+ val centiseconds = now.nano / 10_000_000
+ val threadName = Thread.currentThread().name
+ val tag = abbreviateLoggerName(loggerName)
+
+ val prefix = "${TIMESTAMP_FORMAT.format(now)}.%02d %5s [%s] %s:".format(centiseconds, level.name, threadName, tag)
+
+ return if (omitMessage) "$prefix\n" else "$prefix $message\n"
+ }
+}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLogSink.kt b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLogSink.kt
new file mode 100644
index 0000000000..6181190e58
--- /dev/null
+++ b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLogSink.kt
@@ -0,0 +1,101 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.logging.provider
+
+import com.itsaky.androidide.logging.utils.LogUtils
+import org.slf4j.event.Level
+import java.util.concurrent.CopyOnWriteArrayList
+
+/**
+ * Routes a formatted log line to the appropriate physical sink: `android.util.Log` when
+ * running on Android, `System.err` when running in a plain JVM (e.g. the standalone
+ * tooling-api process). Mirrors the existing `LogcatAppender`/`StdErrAppender` split, but
+ * without any Logback appender machinery.
+ *
+ * `android.util.Log` is referenced directly rather than through Timber: this module is a
+ * plain `java-library` compiled only against the Android SDK stub jar
+ * (`compileOnly(projects.subprojects.frameworkStubs)`), which cannot resolve a real Android
+ * AAR dependency like Timber.
+ *
+ * @author Akash Yadav
+ */
+object IdeLogRouter {
+
+ /** A side channel for log events, e.g. forwarding over RPC or into crash reporting. */
+ fun interface ExternalSink {
+ fun onLog(level: Level, loggerName: String, message: String, throwable: Throwable?)
+ }
+
+ /**
+ * System property that toggles [System.err] output in a plain JVM process (default `true`).
+ * Used by [com.itsaky.androidide.services.builder.ToolingServerRunner] to pass the app's
+ * debug-logging preference into the standalone tooling-api JVM process it launches.
+ */
+ const val PROP_JVM_STDERR_ENABLED = "ide.logging.jvmStdErrAppenderEnabled"
+
+ private val isJvm: Boolean by lazy { LogUtils.isJvm() }
+ private val jvmStdErrEnabled: Boolean by lazy { System.getProperty(PROP_JVM_STDERR_ENABLED, "true").toBoolean() }
+ private val externalSinks = CopyOnWriteArrayList()
+
+ fun addSink(sink: ExternalSink) {
+ externalSinks.add(sink)
+ }
+
+ fun removeSink(sink: ExternalSink) {
+ externalSinks.remove(sink)
+ }
+
+ fun dispatch(level: Level, loggerName: String, message: String, throwable: Throwable?) {
+ val fullMessage = IdeLogFormatter.appendThrowable(message, throwable)
+
+ // A log call must never throw into the caller: contain failures the way Logback's
+ // AppenderBase.doAppend used to, falling back to a raw stderr line so the message
+ // isn't lost entirely.
+ runCatching {
+ val formatted = IdeLogFormatter.format(level, loggerName, fullMessage)
+
+ if (isJvm) {
+ if (jvmStdErrEnabled) {
+ System.err.print(formatted)
+ }
+ } else {
+ logToLogcat(level, loggerName, fullMessage)
+ }
+
+ IdeGlobalLogBuffer.append(level, formatted)
+ }.onFailure { error ->
+ System.err.println("IdeLogRouter: failed to dispatch log line: $fullMessage ($error)")
+ }
+
+ externalSinks.forEach { sink ->
+ runCatching { sink.onLog(level, loggerName, message, throwable) }
+ .onFailure { error -> System.err.println("IdeLogRouter: sink $sink failed: $error") }
+ }
+ }
+
+ private fun logToLogcat(level: Level, loggerName: String, message: String) {
+ val tag = LogUtils.processLogTag(IdeLogFormatter.abbreviateLoggerName(loggerName))
+ when (level) {
+ Level.ERROR -> android.util.Log.e(tag, message)
+ Level.WARN -> android.util.Log.w(tag, message)
+ Level.INFO -> android.util.Log.i(tag, message)
+ Level.DEBUG -> android.util.Log.d(tag, message)
+ Level.TRACE -> android.util.Log.v(tag, message)
+ }
+ }
+}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLogger.kt b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLogger.kt
new file mode 100644
index 0000000000..6517b340cc
--- /dev/null
+++ b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLogger.kt
@@ -0,0 +1,69 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.logging.provider
+
+import org.slf4j.Marker
+import org.slf4j.event.Level
+import org.slf4j.helpers.LegacyAbstractLogger
+import org.slf4j.helpers.MessageFormatter
+
+/**
+ * SLF4J [org.slf4j.Logger] implementation backed by [IdeLogRouter] instead of Logback.
+ *
+ * DEBUG and above are always enabled: the old Logback setup never called `.setLevel()`
+ * anywhere in this codebase, leaving the root logger at Logback's default of DEBUG. TRACE
+ * is disabled to match that same default (Logback's root logger is never TRACE unless
+ * explicitly configured, which this codebase never did). The only other level filtering
+ * (`IDELogFragment`'s in-app log tab) happens downstream, per-consumer, in [IdeGlobalLogBuffer].
+ *
+ * @author Akash Yadav
+ */
+class IdeLogger(
+ private val loggerName: String,
+) : LegacyAbstractLogger() {
+ override fun getName(): String = loggerName
+
+ override fun isTraceEnabled(): Boolean = false
+
+ override fun isDebugEnabled(): Boolean = true
+
+ override fun isInfoEnabled(): Boolean = true
+
+ override fun isWarnEnabled(): Boolean = true
+
+ override fun isErrorEnabled(): Boolean = true
+
+ override fun getFullyQualifiedCallerName(): String = IdeLogger::class.java.name
+
+ override fun handleNormalizedLoggingCall(
+ level: Level,
+ marker: Marker?,
+ messagePattern: String,
+ arguments: Array?,
+ throwable: Throwable?,
+ ) {
+ val message =
+ if (arguments.isNullOrEmpty()) {
+ messagePattern
+ } else {
+ MessageFormatter.arrayFormat(messagePattern, arguments).message
+ }
+
+ IdeLogRouter.dispatch(level, loggerName, message, throwable)
+ }
+}
diff --git a/composite-builds/build-deps-common/desugaring-core/src/main/java/com/itsaky/androidide/desugaring/ch/qos/logback/core/util/DesugarEnvUtil.java b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLoggerFactory.kt
similarity index 67%
rename from composite-builds/build-deps-common/desugaring-core/src/main/java/com/itsaky/androidide/desugaring/ch/qos/logback/core/util/DesugarEnvUtil.java
rename to logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLoggerFactory.kt
index 72168394c1..359d6487d6 100644
--- a/composite-builds/build-deps-common/desugaring-core/src/main/java/com/itsaky/androidide/desugaring/ch/qos/logback/core/util/DesugarEnvUtil.java
+++ b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeLoggerFactory.kt
@@ -15,22 +15,17 @@
* along with AndroidIDE. If not, see .
*/
-package com.itsaky.androidide.desugaring.ch.qos.logback.core.util;
+package com.itsaky.androidide.logging.provider
+
+import org.slf4j.ILoggerFactory
+import org.slf4j.Logger
+import java.util.concurrent.ConcurrentHashMap
/**
* @author Akash Yadav
*/
-public class DesugarEnvUtil {
+class IdeLoggerFactory : ILoggerFactory {
+ private val loggers = ConcurrentHashMap()
- /**
- * Returns the current version of logback, or null if data is not
- * available.
- *
- *
- * @since 1.3.0
- * @return current version or null if missing version data
- */
- static public String logbackVersion() {
- return null;
- }
+ override fun getLogger(name: String): Logger = loggers.computeIfAbsent(name) { IdeLogger(it) }
}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeSlf4jServiceProvider.kt b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeSlf4jServiceProvider.kt
new file mode 100644
index 0000000000..24e5173f38
--- /dev/null
+++ b/logger/src/main/java/com/itsaky/androidide/logging/provider/IdeSlf4jServiceProvider.kt
@@ -0,0 +1,49 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.logging.provider
+
+import com.google.auto.service.AutoService
+import org.slf4j.IMarkerFactory
+import org.slf4j.helpers.BasicMarkerFactory
+import org.slf4j.helpers.NOPMDCAdapter
+import org.slf4j.spi.MDCAdapter
+import org.slf4j.spi.SLF4JServiceProvider
+
+/**
+ * Logback-free SLF4J 2.x provider backed by [IdeLogRouter]/[IdeLogger].
+ *
+ * @author Akash Yadav
+ */
+@AutoService(SLF4JServiceProvider::class)
+class IdeSlf4jServiceProvider : SLF4JServiceProvider {
+ private val loggerFactory = IdeLoggerFactory()
+ private val markerFactory: IMarkerFactory = BasicMarkerFactory()
+ private val mdcAdapter: MDCAdapter = NOPMDCAdapter()
+
+ override fun getLoggerFactory() = loggerFactory
+
+ override fun getMarkerFactory() = markerFactory
+
+ override fun getMDCAdapter() = mdcAdapter
+
+ override fun getRequestedApiVersion() = "2.0.99"
+
+ override fun initialize() {
+ // No-op: nothing needs eager setup.
+ }
+}
diff --git a/logger/src/main/java/com/itsaky/androidide/logging/utils/LogUtils.java b/logger/src/main/java/com/itsaky/androidide/logging/utils/LogUtils.java
index ceb427e721..ccd94235d7 100644
--- a/logger/src/main/java/com/itsaky/androidide/logging/utils/LogUtils.java
+++ b/logger/src/main/java/com/itsaky/androidide/logging/utils/LogUtils.java
@@ -26,40 +26,35 @@
*/
public class LogUtils {
- public static final int MAX_TAG_LENGTH = 23;
- public static final String PATTERN_LAYOUT_MESSAGE_PATTERN = "[%thread] %msg%n";
-
- public static boolean isJvm() {
- try {
- // If we're in a testing environment
- Class.forName("org.junit.runners.JUnit4");
- return true;
- } catch (ClassNotFoundException e) {
- // ignored
- }
-
- try {
- Class.forName("android.content.Context");
- return false;
- } catch (ClassNotFoundException e) {
- return true;
- }
- }
-
- public static String processLogTag(String tag) {
- if (tag == null) {
- return null;
- }
-
- final var regex = "[^a-z-A-Z0-9_.]";
- if (Pattern.compile(regex).matcher(tag).find()) {
- tag = tag.replaceAll(regex, "_");
- }
-
- return LogTagUtils.trimTagIfNeeded(tag, MAX_TAG_LENGTH);
- }
-
- public static String getPatternLayoutVerbosePattern(boolean omitMessage) {
- return "%d{dd-MM HH:mm:ss.SS} %5level [%thread] %logger{0}:" + (omitMessage ? "" : " %msg") + "%n";
- }
+ public static final int MAX_TAG_LENGTH = 23;
+
+ public static boolean isJvm() {
+ try {
+ // If we're in a testing environment
+ Class.forName("org.junit.runners.JUnit4");
+ return true;
+ } catch (ClassNotFoundException e) {
+ // ignored
+ }
+
+ try {
+ Class.forName("android.content.Context");
+ return false;
+ } catch (ClassNotFoundException e) {
+ return true;
+ }
+ }
+
+ public static String processLogTag(String tag) {
+ if (tag == null) {
+ return null;
+ }
+
+ final var regex = "[^a-z-A-Z0-9_.]";
+ if (Pattern.compile(regex).matcher(tag).find()) {
+ tag = tag.replaceAll(regex, "_");
+ }
+
+ return LogTagUtils.trimTagIfNeeded(tag, MAX_TAG_LENGTH);
+ }
}
diff --git a/logger/src/test/java/com/itsaky/androidide/logging/provider/IdeGlobalLogBufferTest.kt b/logger/src/test/java/com/itsaky/androidide/logging/provider/IdeGlobalLogBufferTest.kt
new file mode 100644
index 0000000000..20819545c1
--- /dev/null
+++ b/logger/src/test/java/com/itsaky/androidide/logging/provider/IdeGlobalLogBufferTest.kt
@@ -0,0 +1,131 @@
+package com.itsaky.androidide.logging.provider
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.slf4j.event.Level
+import java.util.Collections
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * [IdeGlobalLogBuffer] is a process-wide singleton whose buffer is never cleared between
+ * tests, so every test uses a unique marker string and only asserts on lines containing it.
+ */
+@RunWith(JUnit4::class)
+class IdeGlobalLogBufferTest {
+ private class RecordingConsumer(
+ override val logLevel: Level,
+ ) : IdeGlobalLogBuffer.Consumer {
+ val received = Collections.synchronizedList(mutableListOf())
+
+ override fun consume(
+ level: Level,
+ message: String,
+ ) {
+ received.add(message)
+ }
+ }
+
+ private fun withConsumer(
+ consumer: IdeGlobalLogBuffer.Consumer,
+ block: () -> Unit,
+ ) {
+ IdeGlobalLogBuffer.registerConsumer(consumer)
+ try {
+ block()
+ } finally {
+ IdeGlobalLogBuffer.unregisterConsumer(consumer)
+ }
+ }
+
+ @Test
+ fun `registerConsumer replays previously buffered messages`() {
+ val marker = "replay-marker-${System.nanoTime()}"
+ IdeGlobalLogBuffer.append(Level.INFO, marker)
+
+ val consumer = RecordingConsumer(Level.INFO)
+ withConsumer(consumer) {
+ assertThat(consumer.received.any { it.contains(marker) }).isTrue()
+ }
+ }
+
+ @Test
+ fun `consumer only receives messages at or above its own level`() {
+ val marker = "level-filter-marker-${System.nanoTime()}"
+ val consumer = RecordingConsumer(Level.WARN)
+
+ withConsumer(consumer) {
+ IdeGlobalLogBuffer.append(Level.INFO, marker)
+ IdeGlobalLogBuffer.append(Level.ERROR, marker)
+
+ assertThat(consumer.received.count { it.contains(marker) }).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `unregisterConsumer stops further delivery`() {
+ val marker = "unregister-marker-${System.nanoTime()}"
+ val consumer = RecordingConsumer(Level.INFO)
+
+ IdeGlobalLogBuffer.registerConsumer(consumer)
+ IdeGlobalLogBuffer.unregisterConsumer(consumer)
+ IdeGlobalLogBuffer.append(Level.INFO, marker)
+
+ assertThat(consumer.received.any { it.contains(marker) }).isFalse()
+ }
+
+ @Test
+ fun `buffer keeps only the most recent 1000 entries`() {
+ val marker = "capacity-marker-${System.nanoTime()}"
+ val oldestLine = "$marker-oldest"
+ val newestLine = "$marker-newest"
+
+ IdeGlobalLogBuffer.append(Level.INFO, oldestLine)
+ repeat(1000) { IdeGlobalLogBuffer.append(Level.INFO, "$marker-filler-$it") }
+ IdeGlobalLogBuffer.append(Level.INFO, newestLine)
+
+ val consumer = RecordingConsumer(Level.INFO)
+ withConsumer(consumer) {
+ assertThat(consumer.received.any { it.contains(oldestLine) }).isFalse()
+ assertThat(consumer.received.any { it.contains(newestLine) }).isTrue()
+ }
+ }
+
+ @Test
+ fun `concurrent register-unregister during dispatch never throws`() {
+ val stop = AtomicBoolean(false)
+ val failure =
+ java.util.concurrent.atomic
+ .AtomicReference(null)
+ val ready = CountDownLatch(1)
+ val churned = CountDownLatch(1)
+
+ val churner =
+ Thread {
+ ready.await()
+ while (!stop.get()) {
+ val consumer = RecordingConsumer(Level.INFO)
+ IdeGlobalLogBuffer.registerConsumer(consumer)
+ IdeGlobalLogBuffer.unregisterConsumer(consumer)
+ churned.countDown()
+ }
+ }
+ churner.setUncaughtExceptionHandler { _, error -> failure.set(error) }
+ churner.start()
+
+ ready.countDown()
+ // Make sure the churner has actually registered/unregistered at least once before
+ // dispatching, so this test can't silently pass without exercising the race at all.
+ assertThat(churned.await(10, TimeUnit.SECONDS)).isTrue()
+
+ repeat(2000) { IdeGlobalLogBuffer.append(Level.INFO, "concurrent-marker-$it") }
+ stop.set(true)
+ churner.join(TimeUnit.SECONDS.toMillis(10))
+
+ assertThat(churner.isAlive).isFalse()
+ assertThat(failure.get()).isNull()
+ }
+}
diff --git a/logger/src/test/java/com/itsaky/androidide/logging/provider/IdeLogFormatterTest.kt b/logger/src/test/java/com/itsaky/androidide/logging/provider/IdeLogFormatterTest.kt
new file mode 100644
index 0000000000..859b0f88f7
--- /dev/null
+++ b/logger/src/test/java/com/itsaky/androidide/logging/provider/IdeLogFormatterTest.kt
@@ -0,0 +1,67 @@
+package com.itsaky.androidide.logging.provider
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.slf4j.event.Level
+
+@RunWith(JUnit4::class)
+class IdeLogFormatterTest {
+ @Test
+ fun `abbreviateLoggerName returns last segment of a qualified name`() {
+ assertThat(IdeLogFormatter.abbreviateLoggerName("com.itsaky.androidide.Foo")).isEqualTo("Foo")
+ }
+
+ @Test
+ fun `abbreviateLoggerName returns the name unchanged when there is no dot`() {
+ assertThat(IdeLogFormatter.abbreviateLoggerName("Foo")).isEqualTo("Foo")
+ }
+
+ @Test
+ fun `format includes the level, thread, tag and message`() {
+ val formatted = IdeLogFormatter.format(Level.WARN, "com.itsaky.androidide.Foo", "hello world")
+
+ assertThat(formatted).contains("WARN")
+ assertThat(formatted).contains(Thread.currentThread().name)
+ assertThat(formatted).contains("Foo:")
+ assertThat(formatted).contains("hello world")
+ }
+
+ @Test
+ fun `format ends with a trailing newline`() {
+ assertThat(IdeLogFormatter.format(Level.INFO, "Foo", "msg")).endsWith("\n")
+ }
+
+ @Test
+ fun `format with omitMessage drops the message but keeps the header`() {
+ val formatted = IdeLogFormatter.format(Level.ERROR, "com.itsaky.androidide.Foo", "should not appear", omitMessage = true)
+
+ assertThat(formatted).doesNotContain("should not appear")
+ assertThat(formatted).contains("ERROR")
+ assertThat(formatted).contains("Foo:")
+ assertThat(formatted).endsWith("\n")
+ }
+
+ @Test
+ fun `format timestamp matches the expected dd-MM HH-mm-ss-SS shape`() {
+ val formatted = IdeLogFormatter.format(Level.DEBUG, "Foo", "msg")
+ val timestampPattern = Regex("""^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{2} """)
+
+ assertThat(timestampPattern.containsMatchIn(formatted)).isTrue()
+ }
+
+ @Test
+ fun `appendThrowable leaves the message unchanged when there is no throwable`() {
+ assertThat(IdeLogFormatter.appendThrowable("hello", null)).isEqualTo("hello")
+ }
+
+ @Test
+ fun `appendThrowable appends the stack trace when a throwable is present`() {
+ val result = IdeLogFormatter.appendThrowable("hello", RuntimeException("boom"))
+
+ assertThat(result).startsWith("hello\n")
+ assertThat(result).contains("RuntimeException")
+ assertThat(result).contains("boom")
+ }
+}
diff --git a/logger/src/test/java/com/itsaky/androidide/logging/provider/IdeLogRouterTest.kt b/logger/src/test/java/com/itsaky/androidide/logging/provider/IdeLogRouterTest.kt
new file mode 100644
index 0000000000..86586f0609
--- /dev/null
+++ b/logger/src/test/java/com/itsaky/androidide/logging/provider/IdeLogRouterTest.kt
@@ -0,0 +1,85 @@
+package com.itsaky.androidide.logging.provider
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.slf4j.event.Level
+import java.util.Collections
+
+/** [IdeLogRouter] is a process-wide singleton; every sink registered here is removed again. */
+@RunWith(JUnit4::class)
+class IdeLogRouterTest {
+ private data class Received(
+ val level: Level,
+ val loggerName: String,
+ val message: String,
+ val throwable: Throwable?,
+ )
+
+ @Test
+ fun `a registered sink receives dispatched events`() {
+ val received = Collections.synchronizedList(mutableListOf())
+ val sink =
+ IdeLogRouter.ExternalSink { level, loggerName, message, throwable ->
+ received.add(Received(level, loggerName, message, throwable))
+ }
+
+ IdeLogRouter.addSink(sink)
+ try {
+ IdeLogRouter.dispatch(Level.INFO, "com.itsaky.androidide.Foo", "hello", null)
+ } finally {
+ IdeLogRouter.removeSink(sink)
+ }
+
+ assertThat(received).contains(Received(Level.INFO, "com.itsaky.androidide.Foo", "hello", null))
+ }
+
+ @Test
+ fun `removeSink stops further delivery to that sink`() {
+ val received = Collections.synchronizedList(mutableListOf())
+ val sink =
+ IdeLogRouter.ExternalSink { level, loggerName, message, throwable ->
+ received.add(Received(level, loggerName, message, throwable))
+ }
+
+ IdeLogRouter.addSink(sink)
+ IdeLogRouter.removeSink(sink)
+ IdeLogRouter.dispatch(Level.INFO, "Foo", "should not arrive", null)
+
+ assertThat(received).isEmpty()
+ }
+
+ @Test
+ fun `a throwing sink does not stop other sinks from receiving the event`() {
+ val received = Collections.synchronizedList(mutableListOf())
+ val brokenSink = IdeLogRouter.ExternalSink { _, _, _, _ -> throw RuntimeException("boom") }
+ val healthySink =
+ IdeLogRouter.ExternalSink { level, loggerName, message, throwable ->
+ received.add(Received(level, loggerName, message, throwable))
+ }
+
+ IdeLogRouter.addSink(brokenSink)
+ IdeLogRouter.addSink(healthySink)
+ try {
+ IdeLogRouter.dispatch(Level.ERROR, "Foo", "still delivered", null)
+ } finally {
+ IdeLogRouter.removeSink(brokenSink)
+ IdeLogRouter.removeSink(healthySink)
+ }
+
+ assertThat(received).contains(Received(Level.ERROR, "Foo", "still delivered", null))
+ }
+
+ @Test
+ fun `dispatch never throws even when every sink is broken`() {
+ val brokenSink = IdeLogRouter.ExternalSink { _, _, _, _ -> throw IllegalStateException("boom") }
+
+ IdeLogRouter.addSink(brokenSink)
+ try {
+ IdeLogRouter.dispatch(Level.WARN, "Foo", "message", RuntimeException("cause"))
+ } finally {
+ IdeLogRouter.removeSink(brokenSink)
+ }
+ }
+}
diff --git a/subprojects/tooling-api-impl/build.gradle.kts b/subprojects/tooling-api-impl/build.gradle.kts
index 3b1215a138..83100e0bc2 100644
--- a/subprojects/tooling-api-impl/build.gradle.kts
+++ b/subprojects/tooling-api-impl/build.gradle.kts
@@ -69,6 +69,7 @@ dependencies {
implementation(projects.shared)
implementation(projects.subprojects.projectModels)
+ implementation(libs.androidx.annotation)
implementation(libs.common.jkotlin)
implementation(libs.google.auto.service.annotations)
implementation(libs.xml.xercesImpl)
diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/Main.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/Main.kt
index 7b1f621e1d..a9734c31fc 100644
--- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/Main.kt
+++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/Main.kt
@@ -16,9 +16,12 @@
*/
package com.itsaky.androidide.tooling.impl
+import androidx.annotation.VisibleForTesting
+import com.itsaky.androidide.logging.provider.IdeLogRouter
import com.itsaky.androidide.tooling.api.IToolingApiClient
import com.itsaky.androidide.tooling.api.util.ToolingApiLauncher.newServerLauncher
import com.itsaky.androidide.tooling.api.util.ToolingProps
+import com.itsaky.androidide.tooling.impl.logging.ToolingApiAppender
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.gradle.tooling.events.OperationType
@@ -43,6 +46,12 @@ object Main {
val client: IToolingApiClient?
get() = _client
+ /** Test-only seam for [_client]; the property itself stays private. */
+ @VisibleForTesting
+ internal fun setClientForTesting(client: IToolingApiClient?) {
+ _client = client
+ }
+
val future: Future?
get() = _future
@@ -64,6 +73,8 @@ object Main {
@JvmStatic
fun main(args: Array): Unit =
runBlocking {
+ IdeLogRouter.addSink(ToolingApiAppender)
+
logger.debug("Starting Tooling API server...")
val server = ToolingApiServerImpl()
diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/logging/ToolingApiAppender.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/logging/ToolingApiAppender.kt
index aeea0c2488..a9e279a076 100644
--- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/logging/ToolingApiAppender.kt
+++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/logging/ToolingApiAppender.kt
@@ -17,53 +17,27 @@
package com.itsaky.androidide.tooling.impl.logging
-import ch.qos.logback.classic.PatternLayout
-import ch.qos.logback.classic.spi.ILoggingEvent
-import ch.qos.logback.core.AppenderBase
-import ch.qos.logback.core.Context
-import com.itsaky.androidide.logging.utils.LogUtils
+import com.itsaky.androidide.logging.provider.IdeLogFormatter
+import com.itsaky.androidide.logging.provider.IdeLogRouter
import com.itsaky.androidide.tooling.api.messages.LogMessageParams
import com.itsaky.androidide.tooling.impl.Main
+import org.slf4j.event.Level
/**
- * [AppenderBase] implementation which forwards all logs to the tooling API client.
+ * [IdeLogRouter.ExternalSink] which forwards all logs to the tooling API client.
*
* @author Akash Yadav
*/
-class ToolingApiAppender : AppenderBase() {
-
- private val layout = PatternLayout()
-
- init {
- layout.pattern = LogUtils.PATTERN_LAYOUT_MESSAGE_PATTERN
- }
-
- override fun start() {
- super.start()
- layout.start()
- }
-
- override fun stop() {
- super.stop()
- layout.stop()
- }
-
- override fun setContext(context: Context?) {
- super.setContext(context)
- layout.context = context
- }
-
- override fun append(eventObject: ILoggingEvent?) {
- if (eventObject == null || !isStarted) {
- return
- }
-
- Main.client?.logMessage(
- LogMessageParams(
- eventObject.level.levelStr[0],
- eventObject.loggerName,
- layout.doLayout(eventObject)
- )
- )
- }
-}
\ No newline at end of file
+object ToolingApiAppender : IdeLogRouter.ExternalSink {
+ override fun onLog(
+ level: Level,
+ loggerName: String,
+ message: String,
+ throwable: Throwable?,
+ ) {
+ // Send the raw message, not a pre-formatted line: the client re-logs this through its
+ // own SLF4J logger (see GradleBuildService.logMessage), which formats it once already.
+ val fullMessage = IdeLogFormatter.appendThrowable(message, throwable)
+ Main.client?.logMessage(LogMessageParams(level.name.first(), loggerName, fullMessage))
+ }
+}
diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/logging/ToolingLoggingConfigurator.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/logging/ToolingLoggingConfigurator.kt
deleted file mode 100644
index c038951252..0000000000
--- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/logging/ToolingLoggingConfigurator.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.tooling.impl.logging
-
-import ch.qos.logback.classic.Logger
-import ch.qos.logback.classic.LoggerContext
-import ch.qos.logback.classic.spi.Configurator
-import ch.qos.logback.classic.spi.ConfiguratorRank
-import ch.qos.logback.core.spi.ContextAwareBase
-import com.google.auto.service.AutoService
-import com.itsaky.androidide.logging.JvmStdErrAppender
-
-/**
- * Default logging configurator for the Tooling API Runtime.
- *
- * @author Akash Yadav
- */
-@ConfiguratorRank(ConfiguratorRank.CUSTOM_TOP_PRIORITY)
-@AutoService(Configurator::class)
-@Suppress("UNUSED")
-class ToolingLoggingConfigurator :
- ContextAwareBase(),
- Configurator {
- override fun configure(context: LoggerContext): Configurator.ExecutionStatus {
- addInfo("Setting up logging configuration for tooling API")
-
- val stdErrAppender = JvmStdErrAppender()
- stdErrAppender.context = context
- stdErrAppender.start()
-
- val toolingApiAppender = ToolingApiAppender()
- toolingApiAppender.context = context
- toolingApiAppender.start()
-
- val rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME)
- rootLogger.addAppender(stdErrAppender)
- rootLogger.addAppender(toolingApiAppender)
-
- return Configurator.ExecutionStatus.DO_NOT_INVOKE_NEXT_IF_ANY
- }
-}
diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/LogbackStatusListener.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/LogbackStatusListener.kt
deleted file mode 100644
index afa106fa5e..0000000000
--- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/util/LogbackStatusListener.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.tooling.impl.util
-
-import ch.qos.logback.core.status.Status
-import ch.qos.logback.core.status.StatusListener
-import ch.qos.logback.core.util.StatusPrinter
-import com.itsaky.androidide.tooling.api.messages.LogMessageParams
-import com.itsaky.androidide.tooling.impl.Main
-
-/**
- * @author Akash Yadav
- */
-class LogbackStatusListener : StatusListener {
-
- companion object {
-
- private fun levelChar(level: Int): Char {
- return when (level) {
- Status.ERROR -> 'E'
- Status.WARN -> 'W'
- Status.INFO -> 'I'
- else -> 'D'
- }
- }
- }
-
- override fun addStatusEvent(status: Status?) {
- status ?: return
- val sb = StringBuilder(256)
- val client = Main.client
- StatusPrinter.buildStr(sb, "", status)
-
- client?.logMessage(
- LogMessageParams(
- levelChar(status.level),
- status.origin.javaClass.simpleName,
- sb.toString()
- )
- )
- }
-}
\ No newline at end of file
diff --git a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/logging/ToolingApiAppenderTest.kt b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/logging/ToolingApiAppenderTest.kt
new file mode 100644
index 0000000000..fbc49f934a
--- /dev/null
+++ b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/logging/ToolingApiAppenderTest.kt
@@ -0,0 +1,65 @@
+package com.itsaky.androidide.tooling.impl.logging
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.tooling.api.IToolingApiClient
+import com.itsaky.androidide.tooling.api.messages.LogMessageParams
+import com.itsaky.androidide.tooling.impl.Main
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.slf4j.event.Level
+
+/**
+ * [Main.client] has no production setter (only ever assigned once, when the tooling API
+ * server actually connects), so this test uses [Main.setClientForTesting] to install a mock
+ * for the duration of each test, and always clears it afterward.
+ *
+ * @author Akash Yadav
+ */
+@RunWith(JUnit4::class)
+class ToolingApiAppenderTest {
+ @After
+ fun clearMainClient() {
+ Main.setClientForTesting(null)
+ }
+
+ @Test
+ fun `onLog forwards level, tag and message`() {
+ val client = mockk(relaxed = true)
+ Main.setClientForTesting(client)
+
+ ToolingApiAppender.onLog(Level.WARN, "com.itsaky.androidide.Foo", "hello world", null)
+
+ val captured = slot()
+ verify { client.logMessage(capture(captured)) }
+ assertThat(captured.captured.level).isEqualTo('W')
+ assertThat(captured.captured.tag).isEqualTo("com.itsaky.androidide.Foo")
+ assertThat(captured.captured.message).contains("hello world")
+ }
+
+ @Test
+ fun `onLog appends the throwable's stack trace to the message`() {
+ val client = mockk(relaxed = true)
+ Main.setClientForTesting(client)
+
+ ToolingApiAppender.onLog(Level.ERROR, "Foo", "boom happened", RuntimeException("cause"))
+
+ val captured = slot()
+ verify { client.logMessage(capture(captured)) }
+ assertThat(captured.captured.level).isEqualTo('E')
+ assertThat(captured.captured.message).contains("boom happened")
+ assertThat(captured.captured.message).contains("RuntimeException")
+ assertThat(captured.captured.message).contains("cause")
+ }
+
+ @Test
+ fun `onLog does not throw when no client is connected`() {
+ Main.setClientForTesting(null)
+
+ ToolingApiAppender.onLog(Level.INFO, "Foo", "no client yet", null)
+ }
+}
diff --git a/testing/common/src/main/java/com/itsaky/androidide/testing/common/logging/TestingLoggingConfigurator.kt b/testing/common/src/main/java/com/itsaky/androidide/testing/common/logging/TestingLoggingConfigurator.kt
deleted file mode 100644
index 982d9d5cc8..0000000000
--- a/testing/common/src/main/java/com/itsaky/androidide/testing/common/logging/TestingLoggingConfigurator.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * This file is part of AndroidIDE.
- *
- * AndroidIDE is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * AndroidIDE is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with AndroidIDE. If not, see .
- */
-
-package com.itsaky.androidide.testing.common.logging
-
-import ch.qos.logback.classic.Logger
-import ch.qos.logback.classic.LoggerContext
-import ch.qos.logback.classic.spi.Configurator
-import ch.qos.logback.classic.spi.ConfiguratorRank
-import ch.qos.logback.core.spi.ContextAwareBase
-import com.google.auto.service.AutoService
-import com.itsaky.androidide.logging.JvmStdErrAppender
-
-@ConfiguratorRank(ConfiguratorRank.CUSTOM_HIGH_PRIORITY)
-@AutoService(Configurator::class)
-@Suppress("UNUSED")
-open class TestingLoggingConfigurator :
- ContextAwareBase(),
- Configurator {
- override fun configure(context: LoggerContext): Configurator.ExecutionStatus {
- addInfo("Setting up logging configuration for test runtime")
-
- val stdErrAppender = JvmStdErrAppender()
- stdErrAppender.context = context
- stdErrAppender.start()
-
- val rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME)
- rootLogger.addAppender(stdErrAppender)
-
- return Configurator.ExecutionStatus.DO_NOT_INVOKE_NEXT_IF_ANY
- }
-}
diff --git a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt b/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt
index 82d740942e..916987e40a 100644
--- a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt
+++ b/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt
@@ -17,7 +17,6 @@
package com.itsaky.androidide.testing.tooling
-import ch.qos.logback.core.CoreConstants
import com.itsaky.androidide.testing.tooling.models.ToolingApiTestLauncherParams
import com.itsaky.androidide.testing.tooling.models.ToolingApiTestScope
import com.itsaky.androidide.tooling.api.IToolingApiClient
@@ -246,10 +245,6 @@ object ToolingApiTestLauncher {
cmd.add("-D$key=$value")
}
- cmd.add(
- "-D${CoreConstants.STATUS_LISTENER_CLASS_KEY}=com.itsaky.androidide.tooling.impl.util.LogbackStatusListener",
- )
-
Collections.addAll(cmd, "-jar", jar)
println(
@@ -299,7 +294,6 @@ object ToolingApiTestLauncher {
ILogger.Level.WARNING -> logger.warn(message)
ILogger.Level.ERROR -> logger.error(message)
ILogger.Level.INFO -> logger.info(message)
-
else -> logger.trace(message)
}
}