Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
16
core/logging/impl-file/build.gradle.kts
Normal file
16
core/logging/impl-file/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue