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

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")
}
}