Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

205
core/logging/README.md Normal file
View file

@ -0,0 +1,205 @@
# Thunderbird Core Logging Module
This module provides a flexible and extensible logging system for Thunderbird for Android.
## Architecture
The logging system is organized into several modules:
- **api**: Core interfaces and classes
- **impl-console**: Console logging implementation
- **impl-composite**: Composite logging (multiple sinks)
- **impl-legacy**: Legacy logging system compatibility
- **testing**: Testing utilities
### Core Components
```mermaid
classDiagram
class Logger {
+verbose(tag, throwable, message: () -> LogMessage)
+debug(tag, throwable, message: () -> LogMessage)
+info(tag, throwable, message: () -> LogMessage)
+warn(tag, throwable, message: () -> LogMessage)
+error(tag, throwable, message: () -> LogMessage)
}
class DefaultLogger {
-sink: LogSink
-clock: Clock
}
class LogSink {
+level: LogLevel
+canLog(level): boolean
+log(event: LogEvent)
}
class LogEvent {
+level: LogLevel
+tag: LogTag?
+message: LogMessage
+throwable: Throwable?
+timestamp: Long
}
class LogLevel {
VERBOSE
DEBUG
INFO
WARN
ERROR
}
Logger <|-- DefaultLogger
DefaultLogger --> LogSink
LogSink --> LogEvent
LogSink --> LogLevel
LogEvent --> LogLevel
```
### Implementation Modules
```mermaid
classDiagram
class LogSink {
+level: LogLevel
+canLog(level): boolean
+log(event: LogEvent)
}
class ConsoleLogSink {
+level: LogLevel
}
class CompositeLogSink {
+level: LogLevel
-manager: LogSinkManager
}
LogSink <|-- ConsoleLogSink
LogSink <|-- CompositeLogSink
CompositeLogSink --> LogSinkManager
class LogSinkManager {
+getAll(): List<LogSink>
+add(sink: LogSink)
+addAll(sinks: List<LogSink>)
+remove(sink: LogSink)
+removeAll()
}
class DefaultLogSinkManager {
-sinks: MutableList<LogSink>
}
LogSinkManager <|-- DefaultLogSinkManager
```
## Getting Started
### Basic Setup
To start using the logging system, you need to:
1. Add the necessary dependencies to your module's build.gradle.kts file
2. Create a LogSink
3. Create a Logger
4. Start logging!
### Basic Logging
```kotlin
// Create a log sink
val sink = ConsoleLogSink(LogLevel.DEBUG)
// Create a logger
val logger = DefaultLogger(sink)
// Log messages
logger.debug(tag = "MyTag") { "Debug message" }
logger.info { "Info message" }
logger.warn { "Warning message" }
logger.error(throwable = exception) { "Error message with exception" }
```
Note that the message parameter is a lambda that returns a String. This allows for lazy evaluation of the message, which can improve performance when the log level is set to filter out certain messages.
### Composite Logging (Multiple Sinks)
If you want to send logs to multiple destinations, use the CompositeLogSink:
```kotlin
// Create log sinks
val consoleSink = ConsoleLogSink(LogLevel.INFO)
val otherSink = YourCustomLogSink(LogLevel.DEBUG)
// Create a composite sink
val compositeSink = CompositeLogSink(
level = LogLevel.DEBUG,
sinks = listOf(
consoleSink,
otherSink
)
)
// Create a logger
val logger = DefaultLogger(compositeSink)
// Log messages (will go to both sinks if level is appropriate)
logger.debug { "This goes only to otherSink if its level is DEBUG or lower" }
logger.info { "This goes to both sinks if their levels are INFO or lower" }
```
## Creating Custom Log Sinks
You can create your own log sink by implementing the LogSink interface:
```kotlin
class MyCustomLogSink(
override val level: LogLevel,
// Add any other parameters you need
) : LogSink {
override fun log(event: LogEvent) {
// Implement your custom logging logic here
// For example, send logs to a remote server, write to a database, etc.
val formattedMessage = "${event.timestamp} [${event.level}] ${event.tag ?: ""}: ${event.message}"
// Handle the throwable if present
event.throwable?.let {
// Process the throwable
}
// Send or store the log message
}
}
```
## Best Practices
### Log Levels
Use appropriate log levels for different types of messages:
- **VERBOSE**: Detailed information, typically useful only for debugging
- **DEBUG**: Debugging information, useful during development
- **INFO**: General information about application operation
- **WARN**: Potential issues that aren't errors but might need attention
- **ERROR**: Errors and exceptions that should be investigated
## Troubleshooting
### Common Issues
1. **No logs appearing**:
- Check that the log level of your sink is appropriate for the messages you're logging
- Verify that your logger is properly initialized
### Debugging the Logging System
To debug issues with the logging system itself:
1. Create a simple ConsoleLogSink with VERBOSE level
2. Log test messages at different levels
3. Check if messages appear as expected

View file

@ -0,0 +1,19 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.core.logging"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.datetime)
}
commonTest.dependencies {
implementation(projects.core.testing)
}
}
}

View file

@ -0,0 +1,106 @@
package net.thunderbird.core.logging
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
/**
* Default implementation of [Logger] that logs messages to a [LogSink].
*
* @param sink The [LogSink] to which log events will be sent.
* @param clock The [Clock] used to get the current time for log events. Defaults to the system clock.
*/
class DefaultLogger
@OptIn(ExperimentalTime::class)
constructor(
private val sink: LogSink,
private val clock: Clock = Clock.System,
) : Logger {
private fun log(
level: LogLevel,
tag: LogTag? = null,
throwable: Throwable? = null,
message: () -> LogMessage,
) {
sink.let { currentSink ->
if (currentSink.canLog(level)) {
@OptIn(ExperimentalTime::class)
val timestamp = clock.now().toEpochMilliseconds()
currentSink.log(
event = LogEvent(
level = level,
tag = tag,
message = message(),
throwable = throwable,
timestamp = timestamp,
),
)
}
}
}
override fun verbose(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
log(
level = LogLevel.VERBOSE,
tag = tag,
throwable = throwable,
message = message,
)
}
override fun debug(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
log(
level = LogLevel.DEBUG,
tag = tag,
throwable = throwable,
message = message,
)
}
override fun info(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
log(
level = LogLevel.INFO,
tag = tag,
throwable = throwable,
message = message,
)
}
override fun warn(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
log(
level = LogLevel.WARN,
tag = tag,
throwable = throwable,
message = message,
)
}
override fun error(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
log(
level = LogLevel.ERROR,
tag = tag,
throwable = throwable,
message = message,
)
}
}

View file

@ -0,0 +1,21 @@
package net.thunderbird.core.logging
typealias LogTag = String
typealias LogMessage = String
/**
* Represents a single log event
*
* @property level The [LogLevel] of the log event.
* @property tag An optional [LogTag] to categorize the log event.
* @property message The [LogMessage] associated with the log event.
* @property throwable An optional [Throwable] associated with the log event.
* @property timestamp The timestamp of the log event in milliseconds.
*/
data class LogEvent(
val level: LogLevel,
val tag: LogTag? = null,
val message: LogMessage,
val throwable: Throwable? = null,
val timestamp: Long,
)

View file

@ -0,0 +1,44 @@
package net.thunderbird.core.logging
/**
* Represents the different levels of logging used to filter log messages.
*
* The log levels are ordered by priority, where a lower number indicates a more verbose level.
* - [VERBOSE]: Most detailed log level, including all messages.
* - [DEBUG]: Detailed information, typically useful for diagnosing problems.
* - [INFO]: General information about the application state.
* - [WARN]: Indicates something unexpected but not necessarily an error.
* - [ERROR]: Indicates a failure or critical issue.
*
* Each log level has a priority, the higher the priority, the more important the log message is.
*
* @param priority The priority of the log level, where a lower priority indicates a more verbose level.
*/
enum class LogLevel(
val priority: Int,
) {
/**
* Verbose log level most detailed log level, including all messages.
*/
VERBOSE(1),
/**
* Debug log level detailed information, typically useful for diagnosing problems.
*/
DEBUG(2),
/**
* Informational log level general information about the application state.
*/
INFO(3),
/**
* Warning log level indicates something unexpected but not necessarily an error.
*/
WARN(4),
/**
* Error log level indicates a failure or critical issue.
*/
ERROR(5),
}

View file

@ -0,0 +1,28 @@
package net.thunderbird.core.logging
/**
* Manages the log level for the application.
*
* This interface provides a way to update the log level dynamically.
* Implementations of this interface are responsible for persisting the log level
* and notifying listeners of changes.
*/
interface LogLevelManager : LogLevelProvider {
/**
* Overrides the current log level.
*
* This function allows for a temporary change in the log level,
* typically for debugging or specific operational needs.
* The original log level can be restored by calling [restoreDefault] function
*
* @param level The new log level to set
*/
fun override(level: LogLevel)
/**
* Restores the log level to its default value.
*
* The default log level is defined by the specific implementation of this interface.
*/
fun restoreDefault()
}

View file

@ -0,0 +1,15 @@
package net.thunderbird.core.logging
/**
* Provides the current [LogLevel].
*
* This can be used to dynamically change the log level during runtime.
*/
fun interface LogLevelProvider {
/**
* Gets the current log level.
*
* @return The current log level.
*/
fun current(): LogLevel
}

View file

@ -0,0 +1,35 @@
package net.thunderbird.core.logging
/**
* A sink that receives and processes log events.
*
* A `LogSink` determines whether to handle a log event based on its log level.
* Log events with a level lower than the sink's configured level will be ignored.
*/
interface LogSink {
/**
* The minimum log level this sink will process.
* Log events with a lower priority than this level will be ignored.
*/
val level: LogLevel
/**
* Checks whether the sink is enabled for the given log level.
*
* @param level The log level to check.
* @return `true` if this sink will process log events at this level or higher.
*/
fun canLog(level: LogLevel): Boolean {
return this.level <= level
}
/**
* Logs a [LogEvent].
*
* @param event The [LogEvent] to log.
*/
fun log(
event: LogEvent,
)
}

View file

@ -0,0 +1,71 @@
package net.thunderbird.core.logging
/**
* A logging interface that provides methods for logging messages at specific log levels.
*/
interface Logger {
/**
* Logs a message at the verbose log level.
*
* @param tag An optional [LogTag] to categorize the log message.
* @param throwable An optional throwable to log.
* @param message Lambda that returns the [LogMessage] to log.
*/
fun verbose(
tag: LogTag? = null,
throwable: Throwable? = null,
message: () -> LogMessage,
)
/**
* Logs a message at the debug log level.
*
* @param tag An optional [LogTag] to categorize the log message.
* @param throwable An optional throwable to log.
* @param message Lambda that returns the [LogMessage] to log.
*/
fun debug(
tag: LogTag? = null,
throwable: Throwable? = null,
message: () -> LogMessage,
)
/**
* Logs a message at the info log level.
*
* @param tag An optional [LogTag] to categorize the log message.
* @param throwable An optional throwable to log.
* @param message Lambda that returns the [LogMessage] to log.
*/
fun info(
tag: LogTag? = null,
throwable: Throwable? = null,
message: () -> LogMessage,
)
/**
* Logs a message at the warn log level.
*
* @param tag An optional [LogTag] to categorize the log message.
* @param throwable An optional throwable to log.
* @param message Lambda that returns the [LogMessage] to log.
*/
fun warn(
tag: LogTag? = null,
throwable: Throwable? = null,
message: () -> LogMessage,
)
/**
* Logs a message at the error log level.
*
* @param tag An optional [LogTag] to categorize the log message.
* @param throwable An optional throwable to log.
* @param message Lambda that returns the [LogMessage] to log.
*/
fun error(
tag: LogTag? = null,
throwable: Throwable? = null,
message: () -> LogMessage,
)
}

View file

@ -0,0 +1,167 @@
package net.thunderbird.core.logging
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import net.thunderbird.core.testing.TestClock
@OptIn(ExperimentalTime::class)
class DefaultLoggerTest {
@Test
fun `log should add all event to the sink`() {
// Arrange
val sink = FakeLogSink(LogLevel.VERBOSE)
val exceptionVerbose = Exception("Verbose exception")
val exceptionDebug = Exception("Debug exception")
val exceptionInfo = Exception("Info exception")
val exceptionWarn = Exception("Warn exception")
val exceptionError = Exception("Error exception")
val clock = TestClock(
currentTime = Instant.fromEpochMilliseconds(0L),
)
val testSubject = DefaultLogger(
sink = sink,
clock = clock,
)
// Act
testSubject.verbose(
tag = "Verbose tag",
throwable = exceptionVerbose,
message = { "Verbose message" },
)
clock.advanceTimeBy(1000.milliseconds)
testSubject.debug(
tag = "Debug tag",
throwable = exceptionDebug,
message = { "Debug message" },
)
clock.advanceTimeBy(1000.milliseconds)
testSubject.info(
tag = "Info tag",
throwable = exceptionInfo,
message = { "Info message" },
)
clock.advanceTimeBy(1000.milliseconds)
testSubject.warn(
tag = "Warn tag",
throwable = exceptionWarn,
message = { "Warn message" },
)
clock.advanceTimeBy(1000.milliseconds)
testSubject.error(
tag = "Error tag",
throwable = exceptionError,
message = { "Error message" },
)
// Assert
val events = sink.events
assertThat(events).hasSize(5)
assertThat(events[0]).isEqualTo(
LogEvent(
level = LogLevel.VERBOSE,
tag = "Verbose tag",
message = "Verbose message",
throwable = exceptionVerbose,
timestamp = 0,
),
)
assertThat(events[1]).isEqualTo(
LogEvent(
level = LogLevel.DEBUG,
tag = "Debug tag",
message = "Debug message",
throwable = exceptionDebug,
timestamp = 1000,
),
)
assertThat(events[2]).isEqualTo(
LogEvent(
level = LogLevel.INFO,
tag = "Info tag",
message = "Info message",
throwable = exceptionInfo,
timestamp = 2000,
),
)
assertThat(events[3]).isEqualTo(
LogEvent(
level = LogLevel.WARN,
tag = "Warn tag",
message = "Warn message",
throwable = exceptionWarn,
timestamp = 3000,
),
)
assertThat(events[4]).isEqualTo(
LogEvent(
level = LogLevel.ERROR,
tag = "Error tag",
message = "Error message",
throwable = exceptionError,
timestamp = 4000,
),
)
}
@Test
fun `log should not add event to the sink if the level is not allowed for the sink`() {
// Arrange
val sink = FakeLogSink(LogLevel.INFO)
val exceptionVerbose = Exception("Verbose exception")
val exceptionDebug = Exception("Debug exception")
val exceptionInfo = Exception("Info exception")
val clock = TestClock(
currentTime = Instant.fromEpochMilliseconds(0L),
)
val testSubject = DefaultLogger(
sink = sink,
clock = clock,
)
// Act
testSubject.verbose(
tag = "Verbose tag",
throwable = exceptionVerbose,
message = { "Verbose message" },
)
clock.advanceTimeBy(1000.milliseconds)
testSubject.debug(
tag = "Debug tag",
throwable = exceptionDebug,
message = { "Debug message" },
)
clock.advanceTimeBy(1000.milliseconds)
testSubject.info(
tag = "Info tag",
throwable = exceptionInfo,
message = { "Info message" },
)
// Assert
assertThat(sink.events).hasSize(1)
assertThat(sink.events[0]).isEqualTo(
LogEvent(
level = LogLevel.INFO,
tag = "Info tag",
message = "Info message",
throwable = exceptionInfo,
timestamp = 2000,
),
)
}
}

View file

@ -0,0 +1,12 @@
package net.thunderbird.core.logging
class FakeLogSink(
override val level: LogLevel = LogLevel.VERBOSE,
) : LogSink {
val events = mutableListOf<LogEvent>()
override fun log(event: LogEvent) {
events.add(event)
}
}

View file

@ -0,0 +1,49 @@
package net.thunderbird.core.logging
class FakeLogger : Logger {
val events = mutableListOf<LogEvent>()
override fun verbose(
tag: String?,
throwable: Throwable?,
message: () -> String,
) {
events.add(LogEvent(LogLevel.VERBOSE, tag, message(), throwable, TIMESTAMP))
}
override fun debug(
tag: String?,
throwable: Throwable?,
message: () -> String,
) {
events.add(LogEvent(LogLevel.DEBUG, tag, message(), throwable, TIMESTAMP))
}
override fun info(
tag: String?,
throwable: Throwable?,
message: () -> String,
) {
events.add(LogEvent(LogLevel.INFO, tag, message(), throwable, TIMESTAMP))
}
override fun warn(
tag: String?,
throwable: Throwable?,
message: () -> String,
) {
events.add(LogEvent(LogLevel.WARN, tag, message(), throwable, TIMESTAMP))
}
override fun error(
tag: String?,
throwable: Throwable?,
message: () -> String,
) {
events.add(LogEvent(LogLevel.ERROR, tag, message(), throwable, TIMESTAMP))
}
private companion object {
const val TIMESTAMP = 0L
}
}

View file

@ -0,0 +1,41 @@
package net.thunderbird.core.logging
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.junit.Test
class LogSinkTest {
@Test
fun `canLog should return true for same level`() {
// Arrange
val testSubject = TestLogSink(LogLevel.INFO)
// Act && Assert
assertTrue { testSubject.canLog(LogLevel.INFO) }
}
@Test
fun `canLog should return false for level below sink level`() {
// Arrange
val testSubject = TestLogSink(LogLevel.INFO)
// Act && Assert
assertFalse { testSubject.canLog(LogLevel.DEBUG) }
}
@Test
fun `canLog should return true for level above sink level`() {
// Arrange
val testSubject = TestLogSink(LogLevel.INFO)
// Act && Assert
assertTrue { testSubject.canLog(LogLevel.WARN) }
}
private class TestLogSink(
override val level: LogLevel,
) : LogSink {
override fun log(event: LogEvent) = Unit
}
}

View file

@ -0,0 +1,15 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.core.logging.composite"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core.logging.api)
}
}
}

View file

@ -0,0 +1,37 @@
package net.thunderbird.core.logging.composite
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.LogLevelProvider
import net.thunderbird.core.logging.LogSink
/**
* A [LogSink] that aggregates multiple [LogSink] and forwards log events to them.
*
* This [CompositeLogSink] is useful when you want to log messages to multiple destinations
* (e.g., console, file, etc.) without having to manage each [LogSink] individually.
*
* It checks the log level of each event against its own level and forwards the event
* to all managed sinks that can handle the event's level.
*
* @param level The minimum log level this sink will process. Log events with a lower priority will be ignored.
* @param manager The [CompositeLogSinkManager] that manages the collection of sinks.
*/
interface CompositeLogSink : LogSink {
val manager: CompositeLogSinkManager
}
/**
* Creates a [CompositeLogSink] with the specified log level and manager.
*
* @param logLevelProvider The minimum [LogLevel] for messages to be logged.
* @param manager The [CompositeLogSinkManager] that manages the collection of sinks.
* @param sinks A list of [LogSink] instances to be managed by this composite sink.
* @return A new instance of [CompositeLogSink].
*/
fun CompositeLogSink(
logLevelProvider: LogLevelProvider,
manager: CompositeLogSinkManager = DefaultLogSinkManager(),
sinks: List<LogSink> = emptyList(),
): CompositeLogSink {
return DefaultCompositeLogSink(logLevelProvider, manager, sinks)
}

View file

@ -0,0 +1,42 @@
package net.thunderbird.core.logging.composite
import net.thunderbird.core.logging.LogSink
/**
* CompositeLogSinkManager is responsible for managing a collection of [LogSink] instances.
*/
interface CompositeLogSinkManager {
/**
* Retrieves all [LogSink] instances managed by this manager.
*
* @return A list of all sinks.
*/
fun getAll(): List<LogSink>
/**
* Adds a [LogSink] to the manager.
*
* @param sink The [LogSink] to add.
*/
fun add(sink: LogSink)
/**
* Adds multiple [LogSink] instances to the manager.
*
* @param sinks The list of [LogSink] to add.
*/
fun addAll(sinks: List<LogSink>)
/**
* Removes a [LogSink] from the manager.
*
* @param sink The [LogSink] to remove.
*/
fun remove(sink: LogSink)
/**
* Removes all [LogSink] instances from the manager.
*/
fun removeAll()
}

View file

@ -0,0 +1,28 @@
package net.thunderbird.core.logging.composite
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.LogLevelProvider
import net.thunderbird.core.logging.LogSink
internal class DefaultCompositeLogSink(
private val logLevelProvider: LogLevelProvider,
override val manager: CompositeLogSinkManager = DefaultLogSinkManager(),
sinks: List<LogSink> = emptyList(),
) : CompositeLogSink {
override val level: LogLevel get() = logLevelProvider.current()
init {
manager.addAll(sinks)
}
override fun log(event: LogEvent) {
if (canLog(event.level)) {
manager.getAll().forEach { sink ->
if (sink.canLog(event.level)) {
sink.log(event)
}
}
}
}
}

View file

@ -0,0 +1,36 @@
package net.thunderbird.core.logging.composite
import net.thunderbird.core.logging.LogSink
/**
* Default implementation of [CompositeLogSinkManager] that manages a collection of [LogSink] instances.
*/
internal class DefaultLogSinkManager : CompositeLogSinkManager {
private val sinks: MutableList<LogSink> = mutableListOf()
override fun getAll(): List<LogSink> {
return sinks.toList()
}
override fun addAll(sinks: List<LogSink>) {
sinks.forEach {
add(it)
}
}
override fun add(sink: LogSink) {
if (sink !in sinks) {
sinks.add(sink)
}
}
override fun remove(sink: LogSink) {
if (sink in sinks) {
sinks.remove(sink)
}
}
override fun removeAll() {
sinks.clear()
}
}

View file

@ -0,0 +1,106 @@
package net.thunderbird.core.logging.composite
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
import org.junit.Test
class DefaultCompositeLogSinkTest {
@Test
fun `init should set initial sinks`() {
// Arrange
val sink1 = FakeLogSink(LogLevel.INFO)
val sink2 = FakeLogSink(LogLevel.INFO)
val sinkManager = FakeCompositeLogSinkManager()
// Act
DefaultCompositeLogSink(
logLevelProvider = { LogLevel.INFO },
manager = sinkManager,
sinks = listOf(sink1, sink2),
)
// Assert
assertThat(sinkManager.sinks).hasSize(2)
assertThat(sinkManager.sinks[0]).isEqualTo(sink1)
assertThat(sinkManager.sinks[1]).isEqualTo(sink2)
}
@Test
fun `log should log to all sinks`() {
// Arrange
val sink1 = FakeLogSink(LogLevel.INFO)
val sink2 = FakeLogSink(LogLevel.INFO)
val sinkManager = FakeCompositeLogSinkManager(mutableListOf(sink1, sink2))
val testSubject = DefaultCompositeLogSink(
logLevelProvider = { LogLevel.INFO },
manager = sinkManager,
)
// Act
testSubject.log(LOG_EVENT)
// Assert
assertThat(sink1.events).hasSize(1)
assertThat(sink2.events).hasSize(1)
assertThat(sink1.events[0]).isEqualTo(LOG_EVENT)
assertThat(sink2.events[0]).isEqualTo(LOG_EVENT)
}
@Test
fun `log should not log if level is below threshold`() {
// Arrange
val sink1 = FakeLogSink(LogLevel.INFO)
val sink2 = FakeLogSink(LogLevel.INFO)
val sinkManager = FakeCompositeLogSinkManager(mutableListOf(sink1, sink2))
val testSubject = DefaultCompositeLogSink(
logLevelProvider = { LogLevel.WARN },
manager = sinkManager,
)
// Act
testSubject.log(LOG_EVENT)
// Assert
assertThat(sink1.events).isEmpty()
assertThat(sink2.events).isEmpty()
}
@Test
fun `log should not log if sink level is below threshold`() {
// Arrange
val sink1 = FakeLogSink(LogLevel.WARN)
val sink2 = FakeLogSink(LogLevel.INFO)
val sinkManager = FakeCompositeLogSinkManager(mutableListOf(sink1, sink2))
val testSubject = DefaultCompositeLogSink(
logLevelProvider = { LogLevel.INFO },
manager = sinkManager,
)
// Act
testSubject.log(LOG_EVENT)
// Assert
assertThat(sink1.events).isEmpty()
assertThat(sink2.events).hasSize(1)
assertThat(sink2.events[0]).isEqualTo(LOG_EVENT)
}
private companion object Companion {
const val TIMESTAMP = 0L
val LOG_EVENT = LogEvent(
level = LogLevel.INFO,
tag = "TestTag",
message = "Test message",
timestamp = TIMESTAMP,
)
}
}

View file

@ -0,0 +1,115 @@
package net.thunderbird.core.logging.composite
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import kotlin.test.Test
import net.thunderbird.core.logging.LogLevel
class DefaultLogSinkManagerTest {
@Test
fun `should have no sinks initially`() {
// Arrange
val sinkManager = DefaultLogSinkManager()
// Act
val sinks = sinkManager.getAll()
// Assert
assertThat(sinks).isEmpty()
}
@Test
fun `should add and retrieve sinks`() {
// Arrange
val sinkManager = DefaultLogSinkManager()
val sink = FakeLogSink(LogLevel.INFO)
sinkManager.add(sink)
// Act
val sinks = sinkManager.getAll()
// Assert
assertThat(sinks.contains(sink))
}
@Test
fun `should add multiple sinks`() {
// Arrange
val sinkManager = DefaultLogSinkManager()
val sink1 = FakeLogSink(LogLevel.INFO)
val sink2 = FakeLogSink(LogLevel.DEBUG)
sinkManager.addAll(listOf(sink1, sink2))
// Act
val sinks = sinkManager.getAll()
// Assert
assertThat(sinks).hasSize(2)
assertThat(sinks).contains(sink1)
assertThat(sinks).contains(sink2)
}
@Test
fun `should remove sink`() {
// Arrange
val sinkManager = DefaultLogSinkManager()
val sink = FakeLogSink(LogLevel.INFO)
sinkManager.add(sink)
// Act
sinkManager.remove(sink)
val sinks = sinkManager.getAll()
// Assert
assertThat(sinks).isEmpty()
}
@Test
fun `should clear all sinks`() {
// Arrange
val sinkManager = DefaultLogSinkManager()
val sink1 = FakeLogSink(LogLevel.INFO)
val sink2 = FakeLogSink(LogLevel.DEBUG)
sinkManager.add(sink1)
sinkManager.add(sink2)
// Act
sinkManager.removeAll()
val sinks = sinkManager.getAll()
// Assert
assertThat(sinks).isEmpty()
}
@Test
fun `should not add duplicate sinks`() {
// Arrange
val sinkManager = DefaultLogSinkManager()
val sink = FakeLogSink(LogLevel.INFO)
sinkManager.add(sink)
// Act
sinkManager.add(sink)
val sinks = sinkManager.getAll()
// Assert
assertThat(sinks).hasSize(1)
}
@Test
fun `should not remove non-existent sinks`() {
// Arrange
val sinkManager = DefaultLogSinkManager()
val sink = FakeLogSink(LogLevel.INFO)
// Act
sinkManager.remove(sink)
val sinks = sinkManager.getAll()
// Assert
assertThat(sinks).isEmpty()
}
}

View file

@ -0,0 +1,20 @@
package net.thunderbird.core.logging.composite
import net.thunderbird.core.logging.LogSink
class FakeCompositeLogSinkManager(
val sinks: MutableList<LogSink> = mutableListOf(),
) : CompositeLogSinkManager {
override fun getAll(): List<LogSink> = sinks
override fun add(sink: LogSink) = Unit
override fun addAll(sinks: List<LogSink>) {
this.sinks.addAll(sinks)
}
override fun remove(sink: LogSink) = Unit
override fun removeAll() = Unit
}

View file

@ -0,0 +1,14 @@
package net.thunderbird.core.logging.composite
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.LogSink
class FakeLogSink(override val level: LogLevel) : LogSink {
val events = mutableListOf<LogEvent>()
override fun log(event: LogEvent) {
events.add(event)
}
}

View file

@ -0,0 +1,32 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.core.logging.console"
}
kotlin {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
applyDefaultHierarchyTemplate {
common {
group("commonJvm") {
withAndroidTarget()
withJvm()
}
}
}
sourceSets {
val commonJvmMain by getting
commonMain.dependencies {
implementation(projects.core.logging.api)
}
androidMain.dependencies {
implementation(libs.timber)
}
}
}

View file

@ -0,0 +1,37 @@
package net.thunderbird.core.logging.console
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
import timber.log.Timber
actual fun ConsoleLogSink(level: LogLevel): ConsoleLogSink = AndroidConsoleLogSink(level)
private class AndroidConsoleLogSink(
override val level: LogLevel,
) : ConsoleLogSink {
override fun log(event: LogEvent) {
val timber = event.tag
?.let { Timber.tag(it) }
?: Timber.tag(event.composeTag(ignoredClasses = IGNORE_CLASSES) ?: this::class.java.name)
when (event.level) {
LogLevel.VERBOSE -> timber.v(event.throwable, event.message)
LogLevel.DEBUG -> timber.d(event.throwable, event.message)
LogLevel.INFO -> timber.i(event.throwable, event.message)
LogLevel.WARN -> timber.w(event.throwable, event.message)
LogLevel.ERROR -> timber.e(event.throwable, event.message)
}
}
companion object {
private val IGNORE_CLASSES = setOf(
Timber::class.java.name,
Timber.Forest::class.java.name,
Timber.Tree::class.java.name,
Timber.DebugTree::class.java.name,
AndroidConsoleLogSink::class.java.name,
// Add other classes to ignore if needed
)
}
}

View file

@ -0,0 +1,101 @@
package net.thunderbird.core.logging.console
import android.util.Log
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import kotlin.test.Test
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
import timber.log.Timber
class ConsoleLogSinkTest {
@Test
fun shouldHaveCorrectLogLevel() {
// Arrange
val testSubject = ConsoleLogSink(LogLevel.INFO)
// Act & Assert
assertThat(testSubject.level).isEqualTo(LogLevel.INFO)
}
@Test
fun shouldLogMessages() {
// Arrange
val testTree = TestTree()
Timber.plant(testTree)
val eventVerbose = LogEvent(
level = LogLevel.VERBOSE,
tag = "TestTag",
message = "This is a verbose message",
throwable = null,
timestamp = 0L,
)
val eventDebug = LogEvent(
level = LogLevel.DEBUG,
tag = "TestTag",
message = "This is a debug message",
throwable = null,
timestamp = 0L,
)
val eventInfo = LogEvent(
level = LogLevel.INFO,
tag = "TestTag",
message = "This is a info message",
throwable = null,
timestamp = 0L,
)
val eventWarn = LogEvent(
level = LogLevel.WARN,
tag = "TestTag",
message = "This is a warning message",
throwable = null,
timestamp = 0L,
)
val eventError = LogEvent(
level = LogLevel.ERROR,
tag = "TestTag",
message = "This is an error message",
throwable = null,
timestamp = 0L,
)
val testSubject = ConsoleLogSink(LogLevel.VERBOSE)
// Act
testSubject.log(eventVerbose)
testSubject.log(eventDebug)
testSubject.log(eventInfo)
testSubject.log(eventWarn)
testSubject.log(eventError)
// Assert
assertThat(testTree.events).hasSize(5)
assertThat(testTree.events[0]).isEqualTo(eventVerbose)
assertThat(testTree.events[1]).isEqualTo(eventDebug)
assertThat(testTree.events[2]).isEqualTo(eventInfo)
assertThat(testTree.events[3]).isEqualTo(eventWarn)
assertThat(testTree.events[4]).isEqualTo(eventError)
}
class TestTree : Timber.DebugTree() {
val events = mutableListOf<LogEvent>()
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
events.add(LogEvent(mapPriorityToLogLevel(priority), tag, message, t, 0L))
}
private fun mapPriorityToLogLevel(priority: Int): LogLevel {
return when (priority) {
Log.VERBOSE -> LogLevel.VERBOSE
Log.DEBUG -> LogLevel.DEBUG
Log.INFO -> LogLevel.INFO
Log.WARN -> LogLevel.WARN
Log.ERROR -> LogLevel.ERROR
else -> throw IllegalArgumentException("Unknown log priority: $priority")
}
}
}
}

View file

@ -0,0 +1,67 @@
package net.thunderbird.core.logging.console
import net.thunderbird.core.logging.DefaultLogger
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.Logger
/**
* Composes a tag for the given [LogEvent].
*
* If the event has a tag, it is used; otherwise, a tag is extracted from the stack trace.
* The tag is processed using the [processTag] method before being returned.
*
* @receiver The [LogEvent] to compose a tag for.
* @param ignoredClasses The set of Class full name to be ignored.
* @param processTag Processes a tag before it is used for logging.
* @return The composed tag, or null if no tag could be determined.
*/
internal fun LogEvent.composeTag(
ignoredClasses: Set<String>,
processTag: (String) -> String? = { it },
): String? {
// If a tag is provided, use it; otherwise, extract it from the stack trace
val rawTag = tag ?: extractTagFromStackTrace(ignoredClasses)
// Process the tag before returning it
return rawTag?.let { processTag(it) }
}
/**
* Extracts a tag from the stack trace.
*
* @return The extracted tag, or null if no suitable tag could be found.
*/
private fun extractTagFromStackTrace(ignoredClasses: Set<String>): String? {
// Some classes are not available to this module, and we don't want
// to add the dependency just for class filtering.
val ignoredClasses = ignoredClasses + setOf(
"net.thunderbird.core.logging.console.ComposeLogTagKt",
"net.thunderbird.core.logging.composite.DefaultCompositeLogSink",
"net.thunderbird.core.logging.legacy.Log",
Logger::class.java.name,
DefaultLogger::class.java.name,
)
@Suppress("ThrowingExceptionsWithoutMessageOrCause")
val stackTrace = Throwable().stackTrace
return stackTrace
.firstOrNull { element ->
ignoredClasses.none { element.className.startsWith(it) }
}
?.let(::createStackElementTag)
}
/**
* Creates a tag from a stack trace element.
*
* @param element The stack trace element to create a tag from.
* @return The created tag.
*/
private fun createStackElementTag(element: StackTraceElement): String {
var tag = element.className.substringAfterLast('.')
val regex = "(\\$\\d+)+$".toRegex()
if (regex.containsMatchIn(input = tag)) {
tag = regex.replace(input = tag, replacement = "")
}
return tag
}

View file

@ -0,0 +1,23 @@
package net.thunderbird.core.logging.console
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.LogSink
/**
* A [LogSink] implementation that logs messages to the console.
*
* This sink uses the platform-specific implementations to handle logging.
*
* @param level The minimum [LogLevel] for messages to be logged.
*/
interface ConsoleLogSink : LogSink
/**
* Creates a [ConsoleLogSink] with the specified log level.
*
* @param level The minimum [LogLevel] for messages to be logged.
* @return A new instance of [ConsoleLogSink].
*/
expect fun ConsoleLogSink(
level: LogLevel = LogLevel.INFO,
): ConsoleLogSink

View file

@ -0,0 +1,32 @@
package net.thunderbird.core.logging.console
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
actual fun ConsoleLogSink(level: LogLevel): ConsoleLogSink = JvmConsoleLogSink(level)
private class JvmConsoleLogSink(
override val level: LogLevel,
) : ConsoleLogSink {
override fun log(event: LogEvent) {
println("[$level] ${composeMessage(event)}")
event.throwable?.printStackTrace()
}
private fun composeMessage(event: LogEvent): String {
val tag = event.tag ?: event.composeTag(ignoredClasses = IGNORE_CLASSES)
return if (tag != null) {
"[$tag] ${event.message}"
} else {
event.message
}
}
companion object {
private val IGNORE_CLASSES = setOf(
JvmConsoleLogSink::class.java.name,
// Add other classes to ignore if needed
)
}
}

View file

@ -0,0 +1,56 @@
package net.thunderbird.core.logging.console
import java.io.ByteArrayOutputStream
import java.io.PrintStream
import kotlin.test.Test
import kotlin.test.assertEquals
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
class ConsoleLogSinkTest {
@Test
fun shouldHaveCorrectLogLevel() {
// Arrange
val testSubject = ConsoleLogSink(LogLevel.INFO)
// Act & Assert
assertEquals(LogLevel.INFO, testSubject.level)
}
@Test
fun shouldLogMessages() {
// Arrange
val originalOut = System.out
val outContent = ByteArrayOutputStream()
System.setOut(PrintStream(outContent))
try {
val eventInfo = LogEvent(
level = LogLevel.INFO,
tag = "TestTag",
message = "This is an info message",
throwable = null,
timestamp = 0L,
)
val testSubject = ConsoleLogSink(LogLevel.VERBOSE)
// Act
testSubject.log(eventInfo)
// Assert
val output = outContent.toString().trim()
println("[DEBUG_LOG] Actual output: '$output'")
// The expected format is: [VERBOSE] [TestTag] This is an info message
// Note: The log level in the output is the sink's level (VERBOSE), not the event's level (INFO)
val expectedOutput = "[VERBOSE] [TestTag] This is an info message"
println("[DEBUG_LOG] Expected output: '$expectedOutput'")
assertEquals(expectedOutput, output)
} finally {
System.setOut(originalOut)
}
}
}

View file

@ -0,0 +1,16 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.core.logging.file"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.io.core)
implementation(projects.core.logging.api)
}
}
}

View file

@ -0,0 +1,140 @@
package net.thunderbird.core.logging.file
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import kotlin.coroutines.CoroutineContext
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.io.Buffer
import kotlinx.io.RawSink
import kotlinx.io.asSink
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
private const val BUFFER_SIZE = 8192 // 8KB buffer size
private const val LOG_BUFFER_COUNT = 4
open class AndroidFileLogSink(
override val level: LogLevel,
fileName: String,
fileLocation: String,
private val fileSystemManager: FileSystemManager,
coroutineContext: CoroutineContext = Dispatchers.IO,
) : FileLogSink {
private val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
private val logFile = File(fileLocation, "$fileName.txt")
private val accumulatedLogs = ArrayList<String>()
private val mutex: Mutex = Mutex()
// Make sure the directory exists
init {
val directory = File(fileLocation)
if (!directory.exists()) {
directory.mkdirs()
}
logFile.createNewFile()
}
override fun log(event: LogEvent) {
coroutineScope.launch {
mutex.withLock {
accumulatedLogs.add(
"${convertLongToTime(event.timestamp)} priority = ${event.level}, ${event.message}",
)
}
if (accumulatedLogs.size > LOG_BUFFER_COUNT) {
writeToLogFile()
}
}
}
@OptIn(ExperimentalTime::class)
private fun convertLongToTime(long: Long): String {
val instant = Instant.fromEpochMilliseconds(long)
val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
return LocalDateTime.Formats.ISO.format(dateTime)
}
private suspend fun writeToLogFile() {
val outputStream = FileOutputStream(logFile, true)
val sink = outputStream.asSink()
var content: String
try {
mutex.withLock {
content = accumulatedLogs.joinToString("\n", postfix = "\n")
accumulatedLogs.clear()
}
val buffer = Buffer()
val contentBytes = content.toByteArray(Charsets.UTF_8)
buffer.write(contentBytes)
sink.write(buffer, buffer.size)
sink.flush()
} finally {
sink.close()
outputStream.close()
}
}
override suspend fun flushAndCloseBuffer() {
if (accumulatedLogs.isNotEmpty()) {
writeToLogFile()
}
}
override suspend fun export(uriString: String) {
if (accumulatedLogs.isNotEmpty()) {
writeToLogFile()
}
val sink = fileSystemManager.openSink(uriString, "wt")
?: error("Error opening contentUri for writing")
copyInternalFileToExternal(sink)
// Clear the log file after export
val outputStream = FileOutputStream(logFile)
val clearSink = outputStream.asSink()
try {
// Write empty string to clear the file
val buffer = Buffer()
clearSink.write(buffer, 0)
clearSink.flush()
} finally {
clearSink.close()
outputStream.close()
}
}
private fun copyInternalFileToExternal(sink: RawSink) {
val inputStream = FileInputStream(logFile)
try {
val buffer = Buffer()
val byteArray = ByteArray(BUFFER_SIZE)
var bytesRead: Int
while (inputStream.read(byteArray).also { bytesRead = it } != -1) {
buffer.write(byteArray, 0, bytesRead)
sink.write(buffer, buffer.size)
buffer.clear()
}
sink.flush()
} finally {
inputStream.close()
sink.close()
}
}
}

View file

@ -0,0 +1,19 @@
package net.thunderbird.core.logging.file
import android.content.ContentResolver
import android.net.Uri
import androidx.core.net.toUri
import kotlinx.io.RawSink
import kotlinx.io.asSink
/**
* Android implementation of [FileSystemManager] that uses [ContentResolver] to perform file operations.
*/
class AndroidFileSystemManager(
private val contentResolver: ContentResolver,
) : FileSystemManager {
override fun openSink(uriString: String, mode: String): RawSink? {
val uri: Uri = uriString.toUri()
return contentResolver.openOutputStream(uri, mode)?.asSink()
}
}

View file

@ -0,0 +1,12 @@
package net.thunderbird.core.logging.file
import net.thunderbird.core.logging.LogLevel
actual fun FileLogSink(
level: LogLevel,
fileName: String,
fileLocation: String,
fileSystemManager: FileSystemManager,
): FileLogSink {
return AndroidFileLogSink(level, fileName, fileLocation, fileSystemManager)
}

View file

@ -0,0 +1,195 @@
package net.thunderbird.core.logging.file
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import java.io.File
import kotlin.test.Test
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TemporaryFolder
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidFileLogSinkTest {
@JvmField
@Rule
val folder = TemporaryFolder()
private val initialTimestamp = 1234567890L
private lateinit var logFile: File
private lateinit var fileLocation: String
private lateinit var fileManager: FakeFileSystemManager
private lateinit var testSubject: AndroidFileLogSink
@Before
fun setUp() {
fileLocation = folder.newFolder().absolutePath
logFile = File(fileLocation, "test_log.txt")
fileManager = FakeFileSystemManager()
testSubject = AndroidFileLogSink(
level = LogLevel.INFO,
fileName = "test_log",
fileLocation = fileLocation,
fileSystemManager = fileManager,
coroutineContext = UnconfinedTestDispatcher(),
)
}
@OptIn(ExperimentalTime::class)
fun timeSetup(timeStamp: Long): String {
val instant = Instant.fromEpochMilliseconds(timeStamp)
val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
return LocalDateTime.Formats.ISO.format(dateTime)
}
@Test
fun shouldHaveCorrectLogLevel() {
assertThat(testSubject.level).isEqualTo(LogLevel.INFO)
}
@Test
fun shouldLogMessageToFile() {
// Arrange
val message = "Test log message"
val event = LogEvent(
timestamp = initialTimestamp,
level = LogLevel.INFO,
tag = "TestTag",
message = message,
throwable = null,
)
// Act
testSubject.log(event)
runBlocking {
testSubject.flushAndCloseBuffer()
}
// Arrange
val logFile = File(fileLocation, "test_log.txt")
assertThat(logFile.exists()).isEqualTo(true)
assertThat(logFile.readText())
.isEqualTo("${timeSetup(initialTimestamp)} priority = INFO, Test log message\n")
}
@Test
fun shouldLogMultipleMessagesToFile() {
// Arrange
val message = "Test log message"
var fiveLogString: String = ""
for (num in 0..3) {
val event = LogEvent(
timestamp = initialTimestamp + num,
level = LogLevel.INFO,
tag = "TestTag",
message = message + num,
throwable = null,
)
testSubject.log(event)
fiveLogString = fiveLogString + "${timeSetup(event.timestamp)} priority = INFO, ${event.message}\n"
}
val logFile = File(fileLocation, "test_log.txt")
assertThat(logFile.exists()).isEqualTo(true)
assertThat(logFile.readText())
.isEqualTo("")
val eventTippingBuffer = LogEvent(
timestamp = initialTimestamp + 6,
level = LogLevel.INFO,
tag = "TestTag",
message = message + "buffered",
throwable = null,
)
fiveLogString =
fiveLogString +
"${timeSetup(eventTippingBuffer.timestamp)} priority = INFO, ${eventTippingBuffer.message}\n"
testSubject.log(eventTippingBuffer)
// Arrange
assertThat(logFile.exists()).isEqualTo(true)
assertThat(logFile.readText())
.isEqualTo(fiveLogString)
}
@Test
fun shouldExportLogFile() {
// Arrange
val event = LogEvent(
timestamp = initialTimestamp,
level = LogLevel.INFO,
tag = "TestTag",
message = "Test log message for export",
throwable = null,
)
testSubject.log(event)
runBlocking {
// Act
testSubject.flushAndCloseBuffer()
val exportUri = "content://test/export.txt"
testSubject.export(exportUri)
}
// Arrange
val exportedContent = fileManager.exportedContent
assertThat(exportedContent).isNotNull()
assertThat(exportedContent!!)
.isEqualTo("${timeSetup(initialTimestamp)} priority = INFO, Test log message for export\n")
}
@Test
fun shouldClearBufferAndExportToFile() {
// Arrange
val message = "Test log message"
var logString1: String = ""
for (num in 0..4) {
val event = LogEvent(
timestamp = initialTimestamp + num,
level = LogLevel.INFO,
tag = "TestTag",
message = message + num,
throwable = null,
)
testSubject.log(event)
logString1 = logString1 + "${timeSetup(event.timestamp)} priority = INFO, ${event.message}\n"
}
val event = LogEvent(
timestamp = initialTimestamp + 5,
level = LogLevel.INFO,
tag = "TestTag",
message = message + 5,
throwable = null,
)
testSubject.log(event)
var logString2: String = logString1 + "${timeSetup(event.timestamp)} priority = INFO, ${event.message}\n"
// Arrange
val logFile = File(fileLocation, "test_log.txt")
assertThat(logFile.exists()).isEqualTo(true)
assertThat(logFile.readText())
.isEqualTo(logString1)
runBlocking {
val exportUri = "content://test/export.txt"
testSubject.export(exportUri)
}
// Arrange
val exportedContent = fileManager.exportedContent
assertThat(exportedContent).isNotNull()
assertThat(exportedContent!!)
.isEqualTo(logString2)
}
}

View file

@ -0,0 +1,32 @@
package net.thunderbird.core.logging.file
import java.io.ByteArrayOutputStream
import java.nio.charset.StandardCharsets
import kotlinx.io.Buffer
import kotlinx.io.RawSink
class FakeFileSystemManager : FileSystemManager {
var exportedContent: String? = null
private val outputStream = ByteArrayOutputStream()
override fun openSink(uriString: String, mode: String): RawSink? {
return object : RawSink {
override fun write(source: Buffer, byteCount: Long) {
val bytes = ByteArray(byteCount.toInt())
for (i in 0 until byteCount.toInt()) {
bytes[i] = source.readByte()
}
outputStream.write(bytes)
exportedContent = String(outputStream.toByteArray(), StandardCharsets.UTF_8)
}
override fun flush() = Unit
override fun close() = Unit
}
}
}

View file

@ -0,0 +1,36 @@
package net.thunderbird.core.logging.file
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.LogSink
interface FileLogSink : LogSink {
/**
* Exports from the logging method to the requested external file
* @param uriString The [String] for the URI to export the log to
*
**/
suspend fun export(uriString: String)
/**
* On a crash or close, flushes buffer to file fo avoid log loss
*
**/
suspend fun flushAndCloseBuffer()
}
/**
* A [LogSink] implementation that logs messages to a specified internal file.
*
* This sink uses the platform-specific implementations to handle logging.
*
* @param level The minimum [LogLevel] for messages to be logged.
* @param fileName The [String] fileName to log to
* @param fileLocation The [String] fileLocation for the log file
* @param fileSystemManager The [FileSystemManager] abstraction for opening the file stream
*/
expect fun FileLogSink(
level: LogLevel,
fileName: String,
fileLocation: String,
fileSystemManager: FileSystemManager,
): FileLogSink

View file

@ -0,0 +1,17 @@
package net.thunderbird.core.logging.file
import kotlinx.io.RawSink
/**
* An interface for file system operations that are platform-specific.
*/
interface FileSystemManager {
/**
* Opens a sink for writing to a URI.
*
* @param uriString The URI string to open a sink for
* @param mode The mode to open the sink in (e.g., "wt" for write text)
* @return A sink for writing to the URI, or null if the URI couldn't be opened
*/
fun openSink(uriString: String, mode: String): RawSink?
}

View file

@ -0,0 +1,20 @@
package net.thunderbird.core.logging.file
import net.thunderbird.core.logging.LogLevel
/**
* A [LogSink] implementation that logs messages to a specified internal file.
*
* This sink uses the platform-specific implementations to handle logging.
*
* @param level The minimum [LogLevel] for messages to be logged.
* @param fileName The [String] fileName to log to
*/
actual fun FileLogSink(
level: LogLevel,
fileName: String,
fileLocation: String,
fileSystemManager: FileSystemManager,
): FileLogSink {
return JvmFileLogSink(level, fileName, fileLocation)
}

View file

@ -0,0 +1,32 @@
package net.thunderbird.core.logging.file
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
internal class JvmFileLogSink(
override val level: LogLevel,
fileName: String,
fileLocation: String,
) : FileLogSink {
override fun log(event: LogEvent) {
println("[$level] ${composeMessage(event)}")
event.throwable?.printStackTrace()
}
override suspend fun export(uriString: String) {
// TODO: Implementation https://github.com/thunderbird/thunderbird-android/issues/9435
}
override suspend fun flushAndCloseBuffer() {
TODO("Not yet implemented")
}
private fun composeMessage(event: LogEvent): String {
return if (event.tag != null) {
"[${event.tag}] ${event.message}"
} else {
event.message
}
}
}

View file

@ -0,0 +1,13 @@
package net.thunderbird.core.logging.file
import kotlinx.io.RawSink
/**
* Android implementation of [FileSystemManager] that uses [ContentResolver] to perform file operations.
*/
class JvmFileSystemManager() : FileSystemManager {
override fun openSink(uriString: String, mode: String): RawSink? {
// TODO: Implementation https://github.com/thunderbird/thunderbird-android/issues/9435
return TODO("Provide the return value")
}
}

View file

@ -0,0 +1,26 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.core.logging.legacy"
}
kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.timber)
implementation(projects.core.logging.implComposite)
implementation(projects.core.logging.implFile)
}
commonMain.dependencies {
api(projects.core.logging.api)
api(libs.androidx.annotation)
}
commonTest.dependencies {
implementation(projects.core.logging.testing)
}
}
}

View file

@ -0,0 +1,27 @@
package net.thunderbird.core.logging.legacy
import net.thunderbird.core.logging.composite.CompositeLogSink
import net.thunderbird.core.logging.file.FileLogSink
import timber.log.Timber
import timber.log.Timber.DebugTree
// TODO: Implementation https://github.com/thunderbird/thunderbird-android/issues/9573
class DebugLogConfigurator(
private val syncDebugCompositeSink: CompositeLogSink,
private val syncDebugFileLogSink: FileLogSink,
) {
fun updateLoggingStatus(isDebugLoggingEnabled: Boolean) {
Timber.uprootAll()
if (isDebugLoggingEnabled) {
Timber.plant(DebugTree())
}
}
fun updateSyncLogging(isSyncLoggingEnabled: Boolean) {
if (isSyncLoggingEnabled) {
syncDebugCompositeSink.manager.add(syncDebugFileLogSink)
} else {
syncDebugCompositeSink.manager.remove(syncDebugFileLogSink)
}
}
}

View file

@ -0,0 +1,159 @@
package net.thunderbird.core.logging.legacy
import androidx.annotation.Discouraged
import net.thunderbird.core.logging.LogMessage
import net.thunderbird.core.logging.LogTag
import net.thunderbird.core.logging.Logger
/**
* A static logging utility that implements [net.thunderbird.core.logging.Logger] and delegates to a [net.thunderbird.core.logging.Logger] implementation.
*
* You can initialize it in your application startup code, for example:
*
* ```kotlin
* import net.thunderbird.core.logging.Log
* import net.thunderbird.core.logging.DefaultLogger // or any other Logger implementation
* fun main() {
* val sink: LogSink = // Your LogSink implementation
* val logger: Logger = DefaultLogger(sink)
*
* Log.logger = logger
* Log.i("Application started")
* // Your application code here
* }
* ```
*/
@Discouraged(
message = "Use a net.thunderbird.core.logging.Logger instance via dependency injection instead. " +
"This class will be removed in a future release.",
)
object Log : Logger {
lateinit var logger: Logger
override fun verbose(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
logger.verbose(
tag = tag,
throwable = throwable,
message = message,
)
}
override fun debug(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
logger.debug(
tag = tag,
throwable = throwable,
message = message,
)
}
override fun info(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
logger.info(
tag = tag,
throwable = throwable,
message = message,
)
}
override fun warn(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
logger.warn(
tag = tag,
throwable = throwable,
message = message,
)
}
override fun error(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
logger.error(
tag = tag,
throwable = throwable,
message = message,
)
}
// Legacy Logger implementation
@JvmStatic
fun v(message: String?, vararg args: Any?) {
logger.verbose(message = { formatMessage(message, args) })
}
@JvmStatic
fun v(t: Throwable?, message: String?, vararg args: Any?) {
logger.verbose(message = { formatMessage(message, args) }, throwable = t)
}
@JvmStatic
fun d(message: String?, vararg args: Any?) {
logger.debug(message = { formatMessage(message, args) })
}
@JvmStatic
fun d(t: Throwable?, message: String?, vararg args: Any?) {
logger.debug(message = { formatMessage(message, args) }, throwable = t)
}
@JvmStatic
fun i(message: String?, vararg args: Any?) {
logger.info(message = { formatMessage(message, args) })
}
@JvmStatic
fun i(t: Throwable?, message: String?, vararg args: Any?) {
logger.info(message = { formatMessage(message, args) }, throwable = t)
}
@JvmStatic
fun w(message: String?, vararg args: Any?) {
logger.warn(message = { formatMessage(message, args) })
}
@JvmStatic
fun w(t: Throwable?, message: String?, vararg args: Any?) {
logger.warn(message = { formatMessage(message, args) }, throwable = t)
}
@JvmStatic
fun e(message: String?, vararg args: Any?) {
logger.error(message = { formatMessage(message, args) })
}
@JvmStatic
fun e(t: Throwable?, message: String?, vararg args: Any?) {
logger.error(message = { formatMessage(message, args) }, throwable = t)
}
private fun formatMessage(message: String?, args: Array<out Any?>): String {
return if (message == null) {
""
} else if (args.isEmpty()) {
message
} else {
try {
String.format(message, *args)
} catch (e: Exception) {
"$message (Error formatting message: $e, args: ${args.joinToString()})"
}
}
}
}

View file

@ -0,0 +1,261 @@
package net.thunderbird.core.logging.legacy
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import kotlin.test.Test
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.testing.TestLogger
import net.thunderbird.core.logging.testing.TestLogger.Companion.TIMESTAMP
class LogTest {
@Test
fun `init should set logger`() {
// Arrange
val logger = TestLogger()
// Act
Log.logger = logger
Log.info(
tag = "Test tag",
message = { "Test message" },
)
// Assert
assertThat(logger.events).hasSize(1)
assertThat(logger.events[0]).isEqualTo(
LogEvent(
level = LogLevel.INFO,
tag = "Test tag",
message = "Test message",
throwable = null,
timestamp = TIMESTAMP,
),
)
}
@Test
fun `log should add all event to the logger`() {
// Arrange
val logger = TestLogger()
val exceptionVerbose = Exception("Verbose exception")
val exceptionDebug = Exception("Debug exception")
val exceptionInfo = Exception("Info exception")
val exceptionWarn = Exception("Warn exception")
val exceptionError = Exception("Error exception")
Log.logger = logger
// Act
Log.verbose(
tag = "Verbose tag",
throwable = exceptionVerbose,
message = { "Verbose message" },
)
Log.debug(
tag = "Debug tag",
throwable = exceptionDebug,
message = { "Debug message" },
)
Log.info(
tag = "Info tag",
throwable = exceptionInfo,
message = { "Info message" },
)
Log.warn(
tag = "Warn tag",
throwable = exceptionWarn,
message = { "Warn message" },
)
Log.error(
tag = "Error tag",
throwable = exceptionError,
message = { "Error message" },
)
// Assert
val events = logger.events
assertThat(events).hasSize(5)
assertThat(events[0]).isEqualTo(
LogEvent(
level = LogLevel.VERBOSE,
tag = "Verbose tag",
message = "Verbose message",
throwable = exceptionVerbose,
timestamp = TIMESTAMP,
),
)
assertThat(events[1]).isEqualTo(
LogEvent(
level = LogLevel.DEBUG,
tag = "Debug tag",
message = "Debug message",
throwable = exceptionDebug,
timestamp = TIMESTAMP,
),
)
assertThat(events[2]).isEqualTo(
LogEvent(
level = LogLevel.INFO,
tag = "Info tag",
message = "Info message",
throwable = exceptionInfo,
timestamp = TIMESTAMP,
),
)
assertThat(events[3]).isEqualTo(
LogEvent(
level = LogLevel.WARN,
tag = "Warn tag",
message = "Warn message",
throwable = exceptionWarn,
timestamp = TIMESTAMP,
),
)
assertThat(events[4]).isEqualTo(
LogEvent(
level = LogLevel.ERROR,
tag = "Error tag",
message = "Error message",
throwable = exceptionError,
timestamp = TIMESTAMP,
),
)
}
@Test
fun `legacy methods should log correctly`() {
// Arrange
val logger = TestLogger()
val exception = Exception("Test exception")
Log.logger = logger
// Act - Test all legacy method signatures for each log level
// Verbose methods
Log.v("Verbose message %s", "arg1")
Log.v(exception, "Verbose message with exception %s", "arg1")
// Debug methods
Log.d("Debug message %s", "arg1")
Log.d(exception, "Debug message with exception %s", "arg1")
// Info methods
Log.i("Info message %s", "arg1")
Log.i(exception, "Info message with exception %s", "arg1")
// Warn methods
Log.w("Warn message %s", "arg1")
Log.w(exception, "Warn message with exception %s", "arg1")
// Error methods
Log.e("Error message %s", "arg1")
Log.e(exception, "Error message with exception %s", "arg1")
// Assert
val events = logger.events
assertThat(events).hasSize(10)
// Verify verbose events
assertThat(events[0]).isEqualTo(
LogEvent(
level = LogLevel.VERBOSE,
tag = null,
message = "Verbose message arg1",
throwable = null,
timestamp = TIMESTAMP,
),
)
assertThat(events[1]).isEqualTo(
LogEvent(
level = LogLevel.VERBOSE,
tag = null,
message = "Verbose message with exception arg1",
throwable = exception,
timestamp = TIMESTAMP,
),
)
// Verify debug events
assertThat(events[2]).isEqualTo(
LogEvent(
level = LogLevel.DEBUG,
tag = null,
message = "Debug message arg1",
throwable = null,
timestamp = TIMESTAMP,
),
)
assertThat(events[3]).isEqualTo(
LogEvent(
level = LogLevel.DEBUG,
tag = null,
message = "Debug message with exception arg1",
throwable = exception,
timestamp = TIMESTAMP,
),
)
// Verify info events
assertThat(events[4]).isEqualTo(
LogEvent(
level = LogLevel.INFO,
tag = null,
message = "Info message arg1",
throwable = null,
timestamp = TIMESTAMP,
),
)
assertThat(events[5]).isEqualTo(
LogEvent(
level = LogLevel.INFO,
tag = null,
message = "Info message with exception arg1",
throwable = exception,
timestamp = TIMESTAMP,
),
)
// Verify warn events
assertThat(events[6]).isEqualTo(
LogEvent(
level = LogLevel.WARN,
tag = null,
message = "Warn message arg1",
throwable = null,
timestamp = TIMESTAMP,
),
)
assertThat(events[7]).isEqualTo(
LogEvent(
level = LogLevel.WARN,
tag = null,
message = "Warn message with exception arg1",
throwable = exception,
timestamp = TIMESTAMP,
),
)
// Verify error events
assertThat(events[8]).isEqualTo(
LogEvent(
level = LogLevel.ERROR,
tag = null,
message = "Error message arg1",
throwable = null,
timestamp = TIMESTAMP,
),
)
assertThat(events[9]).isEqualTo(
LogEvent(
level = LogLevel.ERROR,
tag = null,
message = "Error message with exception arg1",
throwable = exception,
timestamp = TIMESTAMP,
),
)
}
}

View file

@ -0,0 +1,15 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.core.logging.testing"
}
kotlin {
sourceSets {
commonMain.dependencies {
api(projects.core.logging.api)
}
}
}

View file

@ -0,0 +1,17 @@
package net.thunderbird.core.logging.testing
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.LogLevelManager
class TestLogLevelManager : LogLevelManager {
var logLevel = LogLevel.VERBOSE
override fun override(level: LogLevel) {
logLevel = level
}
override fun restoreDefault() {
logLevel = LogLevel.VERBOSE
}
override fun current(): LogLevel = logLevel
}

View file

@ -0,0 +1,99 @@
package net.thunderbird.core.logging.testing
import net.thunderbird.core.logging.LogEvent
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.LogMessage
import net.thunderbird.core.logging.LogTag
import net.thunderbird.core.logging.Logger
/**
* A test logger that captures all log events in a list.
*/
class TestLogger() : Logger {
val events: MutableList<LogEvent> = mutableListOf()
override fun verbose(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
events.add(
LogEvent(
level = LogLevel.VERBOSE,
tag = tag,
message = message(),
throwable = throwable,
timestamp = TIMESTAMP,
),
)
}
override fun debug(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
events.add(
LogEvent(
level = LogLevel.DEBUG,
tag = tag,
message = message(),
throwable = throwable,
timestamp = TIMESTAMP,
),
)
}
override fun info(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
events.add(
LogEvent(
level = LogLevel.INFO,
tag = tag,
message = message(),
throwable = throwable,
timestamp = TIMESTAMP,
),
)
}
override fun warn(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
events.add(
LogEvent(
level = LogLevel.WARN,
tag = tag,
message = message(),
throwable = throwable,
timestamp = TIMESTAMP,
),
)
}
override fun error(
tag: LogTag?,
throwable: Throwable?,
message: () -> LogMessage,
) {
events.add(
LogEvent(
level = LogLevel.ERROR,
tag = tag,
message = message(),
throwable = throwable,
timestamp = TIMESTAMP,
),
)
}
companion object {
const val TIMESTAMP = 1000L
}
}