diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7c7b3f206e..7d4399019a2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,8 @@ if (!project.hasProperty("skip.aboutlibraries")) { } dependencies { + implementation(libs.kermit.io) + implementation("com.wire.kalium:kalium-logic") implementation("com.wire.kalium:kalium-util") implementation("com.wire.kalium:kalium-cells") diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index fd875b93207..4492189f72a 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -26,6 +26,7 @@ import android.os.StrictMode import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Configuration import androidx.work.WorkManager +import co.touchlab.kermit.LogWriter import co.touchlab.kermit.platformLogWriter import com.wire.android.analytics.ObserveCurrentSessionAnalyticsUseCase import com.wire.android.datastore.GlobalDataStore @@ -303,21 +304,15 @@ class WireApplication : BaseApp() { private suspend fun initializeApplicationLoggingFrameworks() { // 1. Datadog should be initialized first ExternalLoggerManager.initDatadogLogger(applicationContext) - // 2. Initialize our internal logging framework val isLoggingEnabled = globalDataStore.get().isLoggingEnabled().first() - val config = if (isLoggingEnabled) { - KaliumLogger.Config( - KaliumLogLevel.VERBOSE, - listOf(DataDogLogger, platformLogWriter()) - ) - } else { - KaliumLogger.Config.DISABLED - } - // 2. Initialize our internal logging framework + val fileWriter = logFileWriter.get() + val config = fullLoggerConfig(isLoggingEnabled, fileWriter.logWriter) + + // 2. Initialize the application and Kalium logging framework. AppLogger.init(config) CoreLogger.init(config) - // 3. Initialize our internal FILE logging framework - logFileWriter.get().start() + // 3. Initialize direct file logging after its directory exists. + fileWriter.start() // 4. Everything ready, now we can log device info appLogger.i("Logger enabled") logDeviceInformation() @@ -416,7 +411,19 @@ class WireApplication : BaseApp() { } } - private companion object { + internal companion object { + fun fullLoggerConfig(isLoggingEnabled: Boolean, fileLogWriter: LogWriter?) = if (isLoggingEnabled) { + KaliumLogger.Config( + KaliumLogLevel.VERBOSE, + listOfNotNull(DataDogLogger, platformLogWriter(), fileLogWriter) + ) + } else { + KaliumLogger.Config( + KaliumLogLevel.WARN, + listOfNotNull(platformLogWriter(), fileLogWriter) + ) + } + enum class MemoryLevel(val level: Int) { TRIM_MEMORY_BACKGROUND(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND), TRIM_MEMORY_COMPLETE(ComponentCallbacks2.TRIM_MEMORY_COMPLETE), diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementViewModel.kt index 69fe1bf7c8f..f7b5f37b5a3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementViewModel.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.wire.android.AppLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.util.logging.LogFileWriter import com.wire.kalium.common.logger.CoreLogger @@ -56,16 +57,18 @@ class LogManagementViewModel @Inject constructor( globalDataStore.setLoggingEnabled(isEnabled) if (isEnabled) { logFileWriter.start() + AppLogger.setLogLevel(level = KaliumLogLevel.VERBOSE) CoreLogger.setLoggingLevel(level = KaliumLogLevel.VERBOSE) } else { logFileWriter.stop() + AppLogger.setLogLevel(level = KaliumLogLevel.WARN) CoreLogger.setLoggingLevel(level = KaliumLogLevel.DISABLED) } } } fun deleteLogs() { - logFileWriter.deleteAllLogFiles() + viewModelScope.launch { logFileWriter.deleteAllLogFiles() } } fun flushLogs(): Deferred { diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/UserDebugViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/UserDebugViewModel.kt index 7fb4c3b7950..430ccdb3d1a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/UserDebugViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/UserDebugViewModel.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.wire.android.AppLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.CurrentAccount import com.wire.android.util.EMPTY @@ -86,7 +87,7 @@ class UserDebugViewModel } fun deleteLogs() { - logFileWriter.deleteAllLogFiles() + viewModelScope.launch { logFileWriter.deleteAllLogFiles() } } fun flushLogs(): Deferred { @@ -100,9 +101,11 @@ class UserDebugViewModel globalDataStore.setLoggingEnabled(isEnabled) if (isEnabled) { logFileWriter.start() + AppLogger.setLogLevel(level = KaliumLogLevel.VERBOSE) CoreLogger.setLoggingLevel(level = KaliumLogLevel.VERBOSE) } else { logFileWriter.stop() + AppLogger.setLogLevel(level = KaliumLogLevel.WARN) CoreLogger.setLoggingLevel(level = KaliumLogLevel.DISABLED) } } diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriter.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriter.kt index 99af4188b8e..b4e7d9dfe4b 100644 --- a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriter.kt +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriter.kt @@ -19,6 +19,7 @@ package com.wire.android.util.logging import android.content.Context +import co.touchlab.kermit.LogWriter import java.io.File /** @@ -27,6 +28,10 @@ import java.io.File */ interface LogFileWriter { + /** The Kermit sink used by the app and Kalium logging configuration. */ + val logWriter: LogWriter? + get() = null + /** * The active logging file where logs are currently being written */ @@ -51,7 +56,7 @@ interface LogFileWriter { * Deletes all log files including active and compressed files * */ - fun deleteAllLogFiles() + suspend fun deleteAllLogFiles() companion object { fun logsDirectory(context: Context) = File(context.cacheDir, "logs") diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Config.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Config.kt new file mode 100644 index 00000000000..68d51ec7078 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Config.kt @@ -0,0 +1,27 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.util.logging + +data class LogFileWriterV1Config( + val rollOnSizeBytes: Long = DEFAULT_ROLL_ON_SIZE_BYTES, + val maxLogFiles: Int = DEFAULT_MAX_LOG_FILES, + val flushTimeoutMs: Long = DEFAULT_FLUSH_TIMEOUT_MS, +) { + companion object { + private const val DEFAULT_ROLL_ON_SIZE_BYTES = 25 * 1024 * 1024L + + // RollingFileLogWriter counts the active file, so this retains ten rolls. + private const val DEFAULT_MAX_LOG_FILES = 11 + private const val DEFAULT_FLUSH_TIMEOUT_MS = 5000L + + fun default() = LogFileWriterV1Config() + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Impl.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Impl.kt index d0634a6a12b..7574feac9e9 100644 --- a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Impl.kt +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Impl.kt @@ -1,203 +1,185 @@ /* * Wire - * Copyright (C) 2024 Wire Swiss GmbH + * Copyright (C) 2026 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. */ package com.wire.android.util.logging -import com.wire.android.appLogger -import kotlinx.coroutines.CoroutineScope +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Severity +import co.touchlab.kermit.io.RollingFileLogWriter +import co.touchlab.kermit.io.RollingFileLogWriterConfig import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.io.files.Path import java.io.File -import java.io.FileWriter import java.io.IOException -import java.io.PrintWriter -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import java.util.UUID +import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream -@Suppress("TooGenericExceptionCaught") -class LogFileWriterV1Impl(private val logsDirectory: File) : LogFileWriter { - - private val logFileTimeFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US) - - override val activeLoggingFile = File(logsDirectory, ACTIVE_LOGGING_FILE_NAME) - - private val fileWriterCoroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var writingJob: Job? = null - - /** - * Initializes logging, waiting until the logger is actually initialized before returning. - * ```kotlin - * logFileWriter.start() - * logger.i("something") // Is guaranteed to be recorded in the log file - * ``` - */ - override suspend fun start() { - appLogger.i("KaliumFileWritter.start called") - val isWriting = writingJob?.isActive ?: false - if (isWriting) { - appLogger.d("KaliumFileWriter.init called but job was already active. Ignoring call") - return - } - ensureLogDirectoryAndFileExistence() - val waitInitializationJob = Job() - - writingJob = fileWriterCoroutineScope.launch { - observeLogCatWritingToLoggingFile().catch { - appLogger.e("Write to file failed :$it", it) - }.onEach { - waitInitializationJob.complete() - }.filter { - it > LOG_FILE_MAX_SIZE_THRESHOLD - }.collect { - ensureActive() - compress() - clearActiveLoggingFileContent() - deleteOldCompressedFiles() - } +/** Persists diagnostics directly from Kermit instead of reading device logcat. */ +class LogFileWriterV1Impl( + private val logsDirectory: File, + private val config: LogFileWriterV1Config = LogFileWriterV1Config.default(), +) : LogFileWriter { + + override val activeLoggingFile = File(logsDirectory, "$LOG_FILE_NAME.log") + + private var rollingWriter: RollingFileLogWriter? = null + private val gatedWriter = GatedLogWriter { rollingWriter } + + override val logWriter: LogWriter = gatedWriter + + override suspend fun start() = withContext(Dispatchers.IO) { + ensureLogDirectoryExists() + migrateLegacyLogs() + if (rollingWriter == null) { + rollingWriter = RollingFileLogWriter( + RollingFileLogWriterConfig( + logFileName = LOG_FILE_NAME, + logFilePath = Path(logsDirectory.absolutePath), + rollOnSize = config.rollOnSizeBytes, + maxLogFiles = config.maxLogFiles, + ) + ) } - appLogger.i("KaliumFileWritter.start: Starting log collection.") - waitInitializationJob.join() + gatedWriter.enable() } - /** - * Observes logcat text, writing to the [activeLoggingFile] as it reads. - * @return A Flow that tells the current length, in bytes, of the log file. - */ - private fun CoroutineScope.observeLogCatWritingToLoggingFile(): Flow = flow { - Runtime.getRuntime().exec("logcat -c") - val process = Runtime.getRuntime().exec("logcat") - - val reader = process.inputStream.bufferedReader() - - appLogger.i("Starting to write log files, grabbing from logcat") - while (isActive) { - val text = reader.readLine() - if (!text.isNullOrBlank()) { - val fileSize = writeLineToFile(text) - emit(fileSize) - } - } - reader.close() - process.destroy() - }.flowOn(Dispatchers.IO) - - /** - * Stops processing logs and writing to files - */ override suspend fun stop() { - appLogger.i("KaliumFileWritter.stop called; Stopping log collection.") - writingJob?.cancel() - clearActiveLoggingFileContent() + gatedWriter.disable() } override suspend fun forceFlush() { - /* no-op */ + gatedWriter.flushBarrier()?.let { waitForBarrier(it) } } - private fun clearActiveLoggingFileContent() { - if (activeLoggingFile.exists()) { - val writer = PrintWriter(activeLoggingFile) - writer.print("") - writer.close() + override suspend fun deleteAllLogFiles() { + val marker = gatedWriter.flushBarrier(disableAfterWrite = true) + marker?.let { waitForBarrier(it) } + + withContext(Dispatchers.IO) { + logsDirectory.listFiles() + ?.filter { ROLLED_LOG_FILE_REGEX.matches(it.name) } + ?.forEach(File::delete) + deleteLegacyLogFiles() + ensureLogDirectoryExists() + activeLoggingFile.outputStream().use { } } + + if (marker != null) gatedWriter.enable() } - /** - * Writes the new [text] and other log entries in logcat to the [activeLoggingFile]. - * @return The length, in bytes, of the log file. - */ - private fun writeLineToFile(text: String): Long { - FileWriter(activeLoggingFile, true).use { fw -> - fw.appendLine(text) - fw.flush() + private suspend fun waitForBarrier(marker: String) = withContext(Dispatchers.IO) { + withTimeout(config.flushTimeoutMs) { + while (!containsBarrier(marker)) delay(FLUSH_POLL_INTERVAL_MS) } - return activeLoggingFile.length() } - private fun ensureLogDirectoryAndFileExistence() { + private fun containsBarrier(marker: String): Boolean = logsDirectory.listFiles() + ?.filter { it.name == activeLoggingFile.name || ROLLED_LOG_FILE_REGEX.matches(it.name) } + ?.any { file -> runCatching { file.useLines { lines -> lines.any { marker in it } } }.getOrDefault(false) } + ?: false + + private fun ensureLogDirectoryExists() { if (!logsDirectory.exists() && !logsDirectory.mkdirs()) { - appLogger.e("Unable to create logs directory") + throw IOException("Unable to create log directory: ${logsDirectory.absolutePath}") } + } - if (!activeLoggingFile.exists() && !activeLoggingFile.createNewFile()) { - appLogger.e("KaliumFileWriter: Failure to create new file for logging", IOException("Unable to load log file")) + /** + * Transfers the only legacy active log to a deterministic gzip snapshot. The active source + * remains untouched until the snapshot has been fully written, validated and renamed. + */ + private fun migrateLegacyLogs() { + val legacyActiveFile = File(logsDirectory, LEGACY_ACTIVE_FILE_NAME) + val legacySnapshot = File(logsDirectory, LEGACY_SNAPSHOT_FILE_NAME) + val legacySnapshotTemp = File(logsDirectory, "$LEGACY_SNAPSHOT_FILE_NAME.tmp") + + when { + !legacyActiveFile.exists() && legacySnapshot.isFile && isReadableGzip(legacySnapshot) -> { + deleteLegacyLogFiles(keepSnapshot = true) + } + !legacyActiveFile.exists() -> legacySnapshotTemp.delete() + legacySnapshot.isFile && isReadableGzip(legacySnapshot) -> { + deleteLegacyLogFiles(keepSnapshot = true) + } + legacySnapshot.exists() && !legacySnapshot.delete() -> Unit + legacySnapshotTemp.exists() && !legacySnapshotTemp.delete() -> Unit + createLegacySnapshot(legacyActiveFile, legacySnapshotTemp, legacySnapshot) -> { + deleteLegacyLogFiles(keepSnapshot = true) + } } - if (!activeLoggingFile.canWrite()) { - appLogger.e("KaliumFileWriter: Logging file is not writable", IOException("Log file not writable")) + } + + private fun createLegacySnapshot(source: File, temporarySnapshot: File, finalSnapshot: File): Boolean = runCatching { + source.inputStream().buffered().use { input -> + GZIPOutputStream(temporarySnapshot.outputStream().buffered()).use { output -> input.copyTo(output) } } + temporarySnapshot.renameTo(finalSnapshot) && isReadableGzip(finalSnapshot) + }.getOrDefault(false).also { created -> + if (!created) temporarySnapshot.delete() } - override fun deleteAllLogFiles() { - clearActiveLoggingFileContent() - logsDirectory.listFiles()?.filter { - it.extension.lowercase(Locale.ROOT) == LOG_COMPRESSED_FILE_EXTENSION - }?.forEach { it.delete() } + private fun isReadableGzip(file: File): Boolean = runCatching { + GZIPInputStream(file.inputStream().buffered()).use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (input.read(buffer) != -1) Unit + } + }.isSuccess + + private fun deleteLegacyLogFiles(keepSnapshot: Boolean = false) { + logsDirectory.listFiles() + ?.filter { file -> + file.name == LEGACY_ACTIVE_FILE_NAME || + LEGACY_ARCHIVE_FILE_REGEX.matches(file.name) || + LEGACY_TEMP_FILE_REGEX.matches(file.name) || + (!keepSnapshot && file.name == LEGACY_SNAPSHOT_FILE_NAME) + } + ?.forEach(File::delete) } - private fun getCompressedFilesList() = (logsDirectory.listFiles() ?: emptyArray()).filter { it != activeLoggingFile } + private class GatedLogWriter(private val delegate: () -> LogWriter?) : LogWriter() { + private val lock = Any() + private var enabled = false - private fun compressedFileName(): String { - val currentDate = logFileTimeFormat.format(Date()) - return "${LOG_FILE_PREFIX}_$currentDate.$LOG_COMPRESSED_FILE_EXTENSION" - } + fun enable() = synchronized(lock) { enabled = true } - private fun deleteOldCompressedFiles() = getCompressedFilesList() - .sortedBy { it.lastModified() } - .dropLast(LOG_COMPRESSED_FILES_MAX_COUNT) - .forEach { - it.delete() - } + fun disable() = synchronized(lock) { enabled = false } + + fun flushBarrier(disableAfterWrite: Boolean = false): String? = synchronized(lock) { + if (!enabled) return null - private fun compress(): Boolean { - try { - val compressed = File(logsDirectory, compressedFileName()) - val zippedOutputStream = GZIPOutputStream(compressed.outputStream()) - val inputStream = activeLoggingFile.inputStream() - inputStream.copyTo(zippedOutputStream, BYTE_ARRAY_SIZE) - clearActiveLoggingFileContent() - inputStream.close() - zippedOutputStream.close() - } catch (e: IOException) { - appLogger.d("$e") - return false + val marker = "$FLUSH_MARKER_PREFIX${UUID.randomUUID()}" + delegate()?.log(Severity.Verbose, marker, LOG_TAG, null) + if (disableAfterWrite) enabled = false + marker } - return true + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + synchronized(lock) { + if (enabled) delegate()?.log(severity, message, tag, throwable) + } + } } - companion object { - private const val LOG_FILE_PREFIX = "wire" - private const val ACTIVE_LOGGING_FILE_NAME = "${LOG_FILE_PREFIX}_logs.txt" - private const val LOG_FILE_MAX_SIZE_THRESHOLD = 25 * 1024 * 1024 - private const val BYTE_ARRAY_SIZE = 1024 - private const val LOG_COMPRESSED_FILES_MAX_COUNT = 10 - private const val LOG_COMPRESSED_FILE_EXTENSION = "gz" + private companion object { + const val LOG_FILE_NAME = "wire_logs" + const val LEGACY_ACTIVE_FILE_NAME = "$LOG_FILE_NAME.txt" + const val LEGACY_SNAPSHOT_FILE_NAME = "wire_legacy_active.gz" + const val LOG_TAG = "LogFileWriter" + const val FLUSH_MARKER_PREFIX = "wire-log-flush:" + const val FLUSH_POLL_INTERVAL_MS = 10L + val ROLLED_LOG_FILE_REGEX = Regex("$LOG_FILE_NAME-[0-9]+\\.log") + val LEGACY_ARCHIVE_FILE_REGEX = Regex("wire_\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}\\.gz") + val LEGACY_TEMP_FILE_REGEX = Regex("(?:wire_\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}\\.gz|$LEGACY_SNAPSHOT_FILE_NAME)\\.tmp") } } diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV2Impl.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV2Impl.kt index 214abb9e4cf..2fe41b866e8 100644 --- a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV2Impl.kt +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV2Impl.kt @@ -316,7 +316,7 @@ class LogFileWriterV2Impl( } } - override fun deleteAllLogFiles() { + override suspend fun deleteAllLogFiles() { clearActiveLoggingFileContent() logsDirectory.listFiles()?.filter { it.extension.lowercase(Locale.ROOT) == LOG_COMPRESSED_FILE_EXTENSION diff --git a/app/src/test/kotlin/com/wire/android/util/logging/LogFileWriterV1ImplTest.kt b/app/src/test/kotlin/com/wire/android/util/logging/LogFileWriterV1ImplTest.kt new file mode 100644 index 00000000000..402c1387623 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/logging/LogFileWriterV1ImplTest.kt @@ -0,0 +1,198 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.util.logging + +import com.wire.android.AppLogger +import com.wire.android.appLogger +import com.wire.kalium.common.logger.CoreLogger +import com.wire.kalium.common.logger.kaliumLogger +import com.wire.kalium.logger.KaliumLogLevel +import com.wire.kalium.logger.KaliumLogger +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +class LogFileWriterV1ImplTest { + + @TempDir + lateinit var temporaryDirectory: File + + @AfterEach + fun resetLoggers() { + AppLogger.init(KaliumLogger.Config.DISABLED) + CoreLogger.init(KaliumLogger.Config.DISABLED) + } + + @Test + fun `given app and Kalium loggers when enabled then both events reach the active file`() = runTest { + val writer = writer() + writer.start() + val config = KaliumLogger.Config(KaliumLogLevel.VERBOSE, listOf(writer.logWriter)) + AppLogger.init(config) + CoreLogger.init(config) + + appLogger.i("app-log-event") + kaliumLogger.i("kalium-log-event") + writer.forceFlush() + + val contents = writer.activeLoggingFile.readText() + assertTrue(contents.contains("app-log-event")) + assertTrue(contents.contains("kalium-log-event")) + } + + @Test + fun `given stopped writer when events are logged then they are not persisted`() = runTest { + val writer = writer() + writer.start() + writer.stop() + + writer.logWriter.log(co.touchlab.kermit.Severity.Debug, "disabled-event", "test", null) + + assertFalse(writer.activeLoggingFile.exists() && writer.activeLoggingFile.readText().contains("disabled-event")) + } + + @Test + fun `given small rolling limit when logging then retained files are bounded and shareable`() = runTest { + val writer = writer(rollOnSizeBytes = 1, maxLogFiles = 3) + writer.start() + + repeat(10) { writer.logWriter.log(co.touchlab.kermit.Severity.Info, "event-$it", "test", null) } + writer.forceFlush() + + val files = temporaryDirectory.listFiles().orEmpty() + assertTrue(files.any { it == writer.activeLoggingFile }) + assertTrue(files.all { it.name == "wire_logs.log" || it.name.matches(Regex("wire_logs-[0-9]+\\.log")) }) + assertTrue(files.size <= 3) + } + + @Test + fun `given existing logs when deleting then active file is cleared rolls are removed and logging resumes`() = runTest { + val writer = writer() + writer.start() + repeat(4) { writer.logWriter.log(co.touchlab.kermit.Severity.Info, "before-delete-$it", "test", null) } + writer.forceFlush() + + writer.deleteAllLogFiles() + + assertEquals("", writer.activeLoggingFile.readText()) + assertTrue(temporaryDirectory.listFiles().orEmpty().none { it.name.matches(Regex("wire_logs-[0-9]+\\.log")) }) + + writer.logWriter.log(co.touchlab.kermit.Severity.Info, "after-delete", "test", null) + writer.forceFlush() + assertTrue(writer.activeLoggingFile.readText().contains("after-delete")) + } + + @Test + fun `given legacy log when starting then it is retained in one gzip snapshot`() = runTest { + val legacyActive = File(temporaryDirectory, "wire_logs.txt").apply { writeText("legacy diagnostic") } + File(temporaryDirectory, "wire_2026-08-27_15-09-01.gz").writeText("old archive") + File(temporaryDirectory, "wire_2026-08-27_15-09-01.gz.tmp").writeText("old temp") + + writer().start() + + val snapshot = File(temporaryDirectory, "wire_legacy_active.gz") + assertEquals("legacy diagnostic", snapshot.gzipText()) + assertFalse(legacyActive.exists()) + assertTrue(temporaryDirectory.listFiles().orEmpty().none { it.name.startsWith("wire_2026-") }) + assertTrue(File(temporaryDirectory, "wire_logs.log").exists()) + } + + @Test + fun `given finalized legacy snapshot and source when starting then source is removed without a duplicate`() = runTest { + File(temporaryDirectory, "wire_logs.txt").writeText("legacy diagnostic") + File(temporaryDirectory, "wire_legacy_active.gz").writeGzip("legacy diagnostic") + File(temporaryDirectory, "wire_legacy_active.gz.tmp").writeText("stale temp") + val legacyArchive = File(temporaryDirectory, "wire_2026-08-27_15-09-01.gz").apply { writeText("old archive") } + + writer().start() + + assertFalse(File(temporaryDirectory, "wire_logs.txt").exists()) + assertFalse(File(temporaryDirectory, "wire_legacy_active.gz.tmp").exists()) + assertFalse(legacyArchive.exists()) + assertEquals(1, temporaryDirectory.listFiles().orEmpty().count { it.name == "wire_legacy_active.gz" }) + assertEquals("legacy diagnostic", File(temporaryDirectory, "wire_legacy_active.gz").gzipText()) + } + + @Test + fun `given finalized snapshot without source when starting then remaining legacy archives are cleaned`() = runTest { + File(temporaryDirectory, "wire_legacy_active.gz").writeGzip("legacy diagnostic") + val legacyArchive = File(temporaryDirectory, "wire_2026-08-27_15-09-01.gz").apply { writeText("old archive") } + + writer().start() + + assertFalse(legacyArchive.exists()) + assertEquals("legacy diagnostic", File(temporaryDirectory, "wire_legacy_active.gz").gzipText()) + } + + @Test + fun `given snapshot failure when starting then legacy files are retained and direct logging starts`() = runTest { + val legacyActive = File(temporaryDirectory, "wire_logs.txt").apply { writeText("legacy diagnostic") } + val legacyArchive = File(temporaryDirectory, "wire_2026-08-27_15-09-01.gz").apply { writeText("old archive") } + File(temporaryDirectory, "wire_legacy_active.gz").apply { + mkdir() + File(this, "blocker").writeText("cannot delete") + } + + val writer = writer() + writer.start() + writer.logWriter.log(co.touchlab.kermit.Severity.Info, "direct after failed migration", "test", null) + writer.forceFlush() + + assertTrue(legacyActive.exists()) + assertTrue(legacyArchive.exists()) + assertTrue(writer.activeLoggingFile.readText().contains("direct after failed migration")) + } + + @Test + fun `given legacy and direct logs when deleting then recognized logs are removed and unrelated files remain`() = runTest { + val writer = writer() + writer.start() + writer.logWriter.log(co.touchlab.kermit.Severity.Info, "direct", "test", null) + writer.forceFlush() + File(temporaryDirectory, "wire_logs.txt").writeText("legacy active") + File(temporaryDirectory, "wire_2026-08-27_15-09-01.gz").writeText("legacy archive") + File(temporaryDirectory, "wire_legacy_active.gz").writeGzip("legacy snapshot") + val unrelatedFile = File(temporaryDirectory, "unrelated.gz").apply { writeText("keep") } + + writer.deleteAllLogFiles() + + assertEquals("", writer.activeLoggingFile.readText()) + assertFalse(File(temporaryDirectory, "wire_logs.txt").exists()) + assertFalse(File(temporaryDirectory, "wire_2026-08-27_15-09-01.gz").exists()) + assertFalse(File(temporaryDirectory, "wire_legacy_active.gz").exists()) + assertTrue(unrelatedFile.exists()) + } + + private fun writer( + rollOnSizeBytes: Long = 25L * 1024 * 1024, + maxLogFiles: Int = 11, + ) = LogFileWriterV1Impl( + logsDirectory = temporaryDirectory, + config = LogFileWriterV1Config( + rollOnSizeBytes = rollOnSizeBytes, + maxLogFiles = maxLogFiles, + flushTimeoutMs = 5_000, + ), + ) + + private fun File.writeGzip(contents: String) { + GZIPOutputStream(outputStream()).bufferedWriter().use { it.write(contents) } + } + + private fun File.gzipText(): String = GZIPInputStream(inputStream()).bufferedReader().use { it.readText() } +} diff --git a/default.json b/default.json index 71f4bb3e601..1488de9afbd 100644 --- a/default.json +++ b/default.json @@ -71,7 +71,7 @@ "analytics_app_key": "8ffae535f1836ed5f58fd5c8a11c00eca07c5438", "analytics_server_url": "https://wire.count.ly/", "enable_new_registration": true, - "use_async_flush_logging": true, + "use_async_flush_logging": true }, "internal": { "application_id": "com.wire.internal", @@ -86,7 +86,7 @@ "use_strict_mls_filter": false, "use_async_flush_logging": true, "conversation_feeder_enabled": true, - "db_invalidation_control_enabled": false, + "db_invalidation_control_enabled": false }, "fdroid": { "application_id": "com.wire", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ea59960f47..addbdd3f805 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -90,6 +90,7 @@ aboutLibraries = "14.0.0-b03" cyclonedx = "3.1.0" leakCanary = "2.14" ksp = "2.3.4" +kermitIo = "2.0.5" # Benchmark benchmark-macro-junit4 = "1.3.3" @@ -162,6 +163,7 @@ ktx-immutableCollections = { module = "org.jetbrains.kotlinx:kotlinx-collections ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } ksp-symbol-processing-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } +kermit-io = { module = "co.touchlab:kermit-io", version.ref = "kermitIo" } allure-kotlin-model = { module = "io.qameta.allure:allure-kotlin-model", version.ref = "allureKotlin" } allure-kotlin-commons = { module = "io.qameta.allure:allure-kotlin-commons", version.ref = "allureKotlin" }