diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index a53ddb057d4..762262c4573 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -28,6 +28,7 @@ import android.util.Log 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 @@ -178,7 +179,7 @@ class WireApplication : BaseApp() { } private fun initializeMinimalLogging() { - val config = minimalLoggerConfig() + val config = minimalLoggerConfig(listOf(logFileWriter.value.logWriter)) AppLogger.init(config) CoreLogger.init(config) } @@ -346,15 +347,19 @@ class WireApplication : BaseApp() { ExternalLoggerManager.initDatadogLogger(applicationContext) val isLoggingEnabled = globalDataStore.value.isLoggingEnabled().firstOrNull() == true - val config = fullLoggerConfig(isLoggingEnabled) - - AppLogger.init(config) - CoreLogger.init(config) + val fileWriter = logFileWriter.value if (isLoggingEnabled) { - logFileWriter.value.start() + fileWriter.start() } + val config = fullLoggerConfig( + isLoggingEnabled = isLoggingEnabled, + additionalLogWriters = listOf(fileWriter.logWriter) + ) + AppLogger.init(config) + CoreLogger.init(config) + appLogger.i("Logger initialized after splash") logDeviceInformation() initializeAnonymousAnalytics() @@ -450,25 +455,42 @@ class WireApplication : BaseApp() { override fun onLowMemory() { super.onLowMemory() - appLogger.w("onLowMemory called - Stopping logging, buckling the seatbelt and hoping for the best!") + appLogger.w("onLowMemory called - Flushing pending logs") globalAppScope.launch { - logFileWriter.value.stop() + flushLogsAfterLowMemory(logFileWriter.value) { error -> + Log.e(TAG, "Failed to flush logs after onLowMemory", error) + } } } internal companion object { - fun minimalLoggerConfig() = KaliumLogger.Config( + @Suppress("TooGenericExceptionCaught") + suspend fun flushLogsAfterLowMemory( + logFileWriter: LogFileWriter, + reportFailure: (Exception) -> Unit + ) { + try { + logFileWriter.forceFlush() + } catch (e: Exception) { + reportFailure(e) + } + } + + fun minimalLoggerConfig(additionalLogWriters: List = emptyList()) = KaliumLogger.Config( KaliumLogLevel.WARN, - listOf(platformLogWriter()) + listOf(platformLogWriter()) + additionalLogWriters ) - fun fullLoggerConfig(isLoggingEnabled: Boolean) = if (isLoggingEnabled) { + fun fullLoggerConfig( + isLoggingEnabled: Boolean, + additionalLogWriters: List = emptyList() + ) = if (isLoggingEnabled) { KaliumLogger.Config( KaliumLogLevel.VERBOSE, - listOf(DataDogLogger, platformLogWriter()) + listOf(DataDogLogger, platformLogWriter()) + additionalLogWriters ) } else { - minimalLoggerConfig() + minimalLoggerConfig(additionalLogWriters) } enum class MemoryLevel(val level: Int) { diff --git a/app/src/main/kotlin/com/wire/android/di/LogWriterModule.kt b/app/src/main/kotlin/com/wire/android/di/LogWriterModule.kt index b316084624c..ef3af275f6b 100644 --- a/app/src/main/kotlin/com/wire/android/di/LogWriterModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/LogWriterModule.kt @@ -19,11 +19,12 @@ package com.wire.android.di import android.content.Context +import com.wire.android.BuildConfig import com.wire.android.util.logging.LogFileWriter -import com.wire.android.util.logging.LogFileWriterImpl +import com.wire.android.util.logging.LogFileWriterFactory +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.Provides -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.SingleIn @BindingContainer @@ -33,6 +34,9 @@ class LogWriterModule { @Provides fun provideKaliumFileWriter(@ApplicationContext context: Context): LogFileWriter { val logsDirectory = LogFileWriter.logsDirectory(context) - return LogFileWriterImpl(logsDirectory) + return LogFileWriterFactory.create( + logsDirectory = logsDirectory, + usePlatformIndependentFileLogger = BuildConfig.USE_PLATFORM_INDEPENDENT_FILE_LOGGER + ) } } 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 1b211c60aa0..878999b9a0c 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 @@ -62,7 +62,9 @@ class LogManagementViewModel( } 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 7a28d0c0192..6203a5c9630 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 @@ -81,7 +81,9 @@ class UserDebugViewModel( } fun deleteLogs() { - logFileWriter.deleteAllLogFiles() + viewModelScope.launch { + logFileWriter.deleteAllLogFiles() + } } fun flushLogs(): Deferred { 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..bdd6161a1b6 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 /** @@ -32,6 +33,11 @@ interface LogFileWriter { */ val activeLoggingFile: File + /** + * Kermit sink that writes log entries into [activeLoggingFile] while this writer is started. + */ + val logWriter: LogWriter + /** * Starts the log collection system */ @@ -51,7 +57,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/LogFileWriterFactory.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterFactory.kt new file mode 100644 index 00000000000..fc596f2e4f3 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterFactory.kt @@ -0,0 +1,30 @@ +/* + * 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. + * + * 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 java.io.File + +object LogFileWriterFactory { + fun create(logsDirectory: File, usePlatformIndependentFileLogger: Boolean): LogFileWriter = + if (usePlatformIndependentFileLogger) { + PlatformIndependentLogFileWriter(logsDirectory) + } else { + LogFileWriterImpl(logsDirectory) + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterImpl.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterImpl.kt index 410043a91c6..bd640b68e98 100644 --- a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterImpl.kt +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterImpl.kt @@ -19,6 +19,8 @@ package com.wire.android.util.logging import android.util.Log +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Severity import com.wire.android.appLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -73,6 +75,11 @@ class LogFileWriterImpl( // Process management private var logcatProcess: Process? = null + override val logWriter: LogWriter = object : LogWriter() { + @Suppress("UnusedParameter") + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) = Unit + } + /** * Initializes logging, waiting until the logger is actually initialized before returning. * ```kotlin @@ -324,11 +331,13 @@ class LogFileWriterImpl( } } - override fun deleteAllLogFiles() { - clearActiveLoggingFileContent() - logsDirectory.listFiles()?.filter { - it.extension.lowercase(Locale.ROOT) == LOG_COMPRESSED_FILE_EXTENSION - }?.forEach { it.delete() } + override suspend fun deleteAllLogFiles() { + withContext(Dispatchers.IO) { + clearActiveLoggingFileContent() + logsDirectory.listFiles()?.filter { + it.extension.lowercase(Locale.ROOT) == LOG_COMPRESSED_FILE_EXTENSION + }?.forEach { it.delete() } + } } private fun getCompressedFilesList() = (logsDirectory.listFiles() ?: emptyArray()).filter { diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogLineTimestampFormatter.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogLineTimestampFormatter.kt new file mode 100644 index 00000000000..ca1b107d266 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogLineTimestampFormatter.kt @@ -0,0 +1,63 @@ +/* + * 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. + * + * 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 java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +internal class LogLineTimestampFormatter( + private val currentTimeMillis: () -> Long = System::currentTimeMillis +) { + private val dateFormat = SimpleDateFormat(TIMESTAMP_PATTERN, Locale.US) + private val date = Date(0L) + private var cachedEpochSecond = Long.MIN_VALUE + private var cachedPrefix = "" + + fun now(): String = format(currentTimeMillis()) + + fun format(timestampMillis: Long): String { + val epochSecond = timestampMillis / MILLIS_IN_SECOND + if (epochSecond != cachedEpochSecond) { + date.time = epochSecond * MILLIS_IN_SECOND + cachedPrefix = dateFormat.format(date) + cachedEpochSecond = epochSecond + } + + val millis = (timestampMillis - epochSecond * MILLIS_IN_SECOND).toInt() + return buildString(capacity = cachedPrefix.length + MILLIS_SUFFIX_LENGTH) { + append(cachedPrefix) + append('.') + appendPaddedMillis(millis) + } + } + + private fun StringBuilder.appendPaddedMillis(millis: Int) { + if (millis < HUNDRED_MILLIS) append('0') + if (millis < TEN_MILLIS) append('0') + append(millis) + } + + private companion object { + const val TIMESTAMP_PATTERN = "yyyy-MM-dd HH:mm:ss" + const val MILLIS_IN_SECOND = 1000L + const val MILLIS_SUFFIX_LENGTH = 4 + const val HUNDRED_MILLIS = 100 + const val TEN_MILLIS = 10 + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/logging/PlatformIndependentLogFileWriter.kt b/app/src/main/kotlin/com/wire/android/util/logging/PlatformIndependentLogFileWriter.kt new file mode 100644 index 00000000000..e1408d76790 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/logging/PlatformIndependentLogFileWriter.kt @@ -0,0 +1,539 @@ +/* + * Wire + * Copyright (C) 2025 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 android.util.Log +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Severity +import com.wire.android.appLogger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.zip.GZIPOutputStream + +@Suppress("TooGenericExceptionCaught", "TooManyFunctions") +class PlatformIndependentLogFileWriter internal constructor( + private val logsDirectory: File, + private val config: PlatformIndependentLogFileWriterConfig, + private val fileCompressor: (sourceFile: File, targetFile: File) -> Unit, + private val rotationTimestampProvider: () -> String +) : LogFileWriter { + + constructor( + logsDirectory: File, + config: PlatformIndependentLogFileWriterConfig = PlatformIndependentLogFileWriterConfig.default() + ) : this( + logsDirectory = logsDirectory, + config = config, + fileCompressor = { sourceFile, targetFile -> + compressFileToGzip(sourceFile, targetFile, config.bufferSizeBytes) + }, + rotationTimestampProvider = { + SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date()) + } + ) + + private val timestampFormatter: ThreadLocal = ThreadLocal.withInitial { + LogLineTimestampFormatter() + } + + override val activeLoggingFile = File(logsDirectory, ACTIVE_LOGGING_FILE_NAME) + + private val fileWriterCoroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val lifecycleMutex = Mutex() + + @Volatile + private var writerJob: Job? = null + + @Volatile + private var logCommandChannel: Channel? = null + + @Volatile + private var deleteGeneration = 0L + + private val logBuffer = mutableListOf() + private var bufferedWriter: BufferedWriter? = null + private var currentFileSize = 0L + + private val isStarted = AtomicBoolean(false) + private val rotationSequence = AtomicLong(0L) + + override val logWriter: LogWriter = object : LogWriter() { + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + writeLogEntry(severity, message, tag, throwable) + } + } + + /** + * 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() { + lifecycleMutex.withLock { + appLogger.i("KaliumFileWritter.start called") + if (isStarted.get()) { + appLogger.d("KaliumFileWriter.init called but job was already active. Ignoring call") + return + } + + try { + withContext(Dispatchers.IO) { + ensureLogDirectoryAndFileExistence() + cleanupOrphanedTempFiles() + } + currentFileSize = activeLoggingFile.length() + val channel = Channel(LOG_COMMAND_CHANNEL_CAPACITY) + val job = fileWriterCoroutineScope.launch { + processLogCommands(channel) + } + logCommandChannel = channel + writerJob = job + isStarted.set(true) + } catch (e: Exception) { + isStarted.set(false) + logCommandChannel = null + writerJob?.cancel() + writerJob = null + throw e + } + + appLogger.i("KaliumFileWritter.start: Starting log collection.") + } + } + + /** + * Stops processing logs and writing to files + */ + override suspend fun stop() { + lifecycleMutex.withLock { + appLogger.i("KaliumFileWritter.stop called; Stopping log collection.") + if (!isStarted.getAndSet(false)) return + + val channel = logCommandChannel + val job = writerJob + try { + if (channel != null) { + val completion = CompletableDeferred() + withTimeout(config.flushTimeoutMs) { + channel.send(LogCommand.Stop(completion)) + completion.await() + job?.cancelAndJoin() + } + } + } catch (e: TimeoutCancellationException) { + appLogger.w("Logger stop timed out, forcing cancellation") + job?.cancel() + } catch (e: Exception) { + appLogger.e("Error stopping log file writer", e) + } finally { + logCommandChannel = null + writerJob = null + } + } + } + + private fun closeResources() { + try { + bufferedWriter?.close() + } catch (e: Exception) { + Log.e(LOG_TAG, "Error closing buffered writer", e) + } finally { + bufferedWriter = null + } + } + + /** + * Manually flushes any buffered log entries to the file. + * This is useful before sharing logs to ensure all recent entries are included. + */ + override suspend fun forceFlush() { + val channel = logCommandChannel.takeIf { isStarted.get() } + val completion = CompletableDeferred() + if (channel != null) { + try { + withTimeout(config.flushTimeoutMs) { + channel.send(LogCommand.Flush(completion)) + completion.await() + } + } catch (e: TimeoutCancellationException) { + appLogger.w("Force flush operation timed out after ${config.flushTimeoutMs}ms") + throw e + } catch (e: Exception) { + appLogger.e("Error during force flush", e) + throw e + } + } + } + + private fun clearActiveLoggingFileContent() { + if (activeLoggingFile.exists()) { + activeLoggingFile.outputStream().use { } + } + } + + private fun writeLogEntry(severity: Severity, message: String, tag: String, throwable: Throwable?) { + if (!isStarted.get()) return + val channel = logCommandChannel ?: return + + val lines = buildList { + add("${timestampFormatter.get()!!.now()} $severity: ($tag) $message") + } + channel.trySend(LogCommand.Entry(lines, throwable)) + } + + private fun appendLogLine(text: String) { + logBuffer.add(text) + currentFileSize += text.length + LINE_SEPARATOR_LENGTH + } + + private suspend fun processLogCommands(channel: Channel) { + var lastFlushTime = System.currentTimeMillis() + try { + while (currentCoroutineContext().isActive) { + val command = receiveLogCommand(channel, lastFlushTime) + + if (command == null) { + lastFlushTime = tryFlushBufferAndGetTime(lastFlushTime) + continue + } + + val result = handleLogCommand(command, lastFlushTime) + lastFlushTime = result.lastFlushTime + if (result.shouldStop) return + } + } finally { + closeResources() + } + } + + private suspend fun receiveLogCommand(channel: Channel, lastFlushTime: Long): LogCommand? { + val flushTimeout = (config.flushIntervalMs - (System.currentTimeMillis() - lastFlushTime)) + .coerceAtLeast(0L) + return withTimeoutOrNull(flushTimeout) { channel.receive() } + } + + private suspend fun handleLogCommand(command: LogCommand, lastFlushTime: Long): LogCommandResult = when (command) { + is LogCommand.Entry -> LogCommandResult(handleEntryCommand(command, lastFlushTime)) + is LogCommand.Flush -> LogCommandResult(completeFlushCommand(command, lastFlushTime)) + is LogCommand.DeleteAll -> LogCommandResult(completeDeleteAllCommand(command, lastFlushTime)) + is LogCommand.Stop -> { + completeStopCommand(command) + LogCommandResult(lastFlushTime = lastFlushTime, shouldStop = true) + } + } + + private suspend fun handleEntryCommand(command: LogCommand.Entry, lastFlushTime: Long): Long { + var updatedFlushTime = lastFlushTime + try { + command.lines.forEach(::appendLogLine) + command.throwable?.let { appendLogLine(it.stackTraceToString()) } + if (logBuffer.size >= config.maxBufferSize) { + updatedFlushTime = flushBufferAndGetTime() + } + if (currentFileSize > config.maxFileSize) { + rotateActiveLogFileIfNeeded() + updatedFlushTime = System.currentTimeMillis() + } + } catch (e: IOException) { + Log.e(LOG_TAG, "Failed to write log entry", e) + } + return updatedFlushTime + } + + private fun completeFlushCommand(command: LogCommand.Flush, lastFlushTime: Long): Long { + var updatedFlushTime = lastFlushTime + completeCommand(command.completion) { + updatedFlushTime = flushBufferAndGetTime() + } + return updatedFlushTime + } + + private fun completeDeleteAllCommand(command: LogCommand.DeleteAll, lastFlushTime: Long): Long { + var updatedFlushTime = lastFlushTime + completeCommand(command.completion) { + deleteAllLogFilesInternal() + updatedFlushTime = System.currentTimeMillis() + } + return updatedFlushTime + } + + private fun completeStopCommand(command: LogCommand.Stop) { + completeCommand(command.completion) { + logBuffer.clear() + closeResources() + clearActiveLoggingFileContent() + currentFileSize = 0L + } + } + + private fun flushBufferAndGetTime(): Long { + flushBuffer() + return System.currentTimeMillis() + } + + private fun tryFlushBufferAndGetTime(lastFlushTime: Long): Long = try { + flushBufferAndGetTime() + } catch (e: IOException) { + Log.e(LOG_TAG, "Failed to flush log buffer", e) + lastFlushTime + } + + private inline fun completeCommand(completion: CompletableDeferred, block: () -> Unit) { + try { + block() + completion.complete(Unit) + } catch (e: Exception) { + completion.completeExceptionally(e) + } + } + + private suspend fun rotateActiveLogFileIfNeeded() { + if (currentFileSize <= config.maxFileSize) return + + val compressedFileName = compressedFileName() + flushBuffer() + closeResources() + val tempFile = moveActiveLogFileToTemp(compressedFileName) + currentFileSize = 0L + val rotationDeleteGeneration = deleteGeneration + + fileWriterCoroutineScope.launch { + val compressedFile = compressFileAsync(tempFile, compressedFileName) + if (compressedFile != null) { + if (deleteGeneration != rotationDeleteGeneration) { + compressedFile.delete() + tempFile.delete() + } else { + tempFile.delete() + deleteOldCompressedFiles() + } + } + } + } + + private fun ensureLogDirectoryAndFileExistence() { + if (!logsDirectory.exists() && !logsDirectory.mkdirs()) { + appLogger.e("Unable to create logs directory") + } + + if (!activeLoggingFile.exists() && !activeLoggingFile.createNewFile()) { + appLogger.e("KaliumFileWriter: Failure to create new file for logging", IOException("Unable to load log file")) + } + if (!activeLoggingFile.canWrite()) { + appLogger.e("KaliumFileWriter: Logging file is not writable", IOException("Log file not writable")) + } + } + + override suspend fun deleteAllLogFiles() { + val channel = logCommandChannel + if (isStarted.get() && channel != null) { + val completion = CompletableDeferred() + withTimeout(config.flushTimeoutMs) { + channel.send(LogCommand.DeleteAll(completion)) + completion.await() + } + return + } + + withContext(Dispatchers.IO) { + deleteAllLogFilesInternal() + } + } + + private fun deleteAllLogFilesInternal() { + deleteGeneration += 1 + logBuffer.clear() + closeResources() + clearActiveLoggingFileContent() + currentFileSize = 0L + logsDirectory.listFiles()?.filter { + it.extension.lowercase(Locale.ROOT) == LOG_COMPRESSED_FILE_EXTENSION || + it.name.endsWith(TEMP_FILE_EXTENSION) || + it.name.endsWith(PARTIAL_FILE_EXTENSION) + }?.forEach { it.delete() } + } + + private fun getCompressedFilesList() = (logsDirectory.listFiles() ?: emptyArray()).filter { + it.extension.lowercase(Locale.ROOT) == LOG_COMPRESSED_FILE_EXTENSION + } + + private fun compressedFileName(): String { + val currentDate = rotationTimestampProvider() + while (true) { + val sequence = rotationSequence.incrementAndGet() + val candidate = "${LOG_FILE_PREFIX}_${currentDate}_$sequence.$LOG_COMPRESSED_FILE_EXTENSION" + if (rotationFiles(candidate).none(File::exists)) return candidate + } + } + + private fun rotationFiles(compressedFileName: String): List = listOf( + File(logsDirectory, compressedFileName), + File(logsDirectory, "$compressedFileName$TEMP_FILE_EXTENSION"), + File(logsDirectory, "$compressedFileName$PARTIAL_FILE_EXTENSION") + ) + + private fun deleteOldCompressedFiles() = getCompressedFilesList() + .sortedBy { it.lastModified() } + .dropLast(LOG_COMPRESSED_FILES_MAX_COUNT) + .forEach { + it.delete() + } + + private fun cleanupOrphanedTempFiles() { + getListOfOrphanedTempFiles().forEach { tempFile -> + appLogger.i("Found orphaned temp file: ${tempFile.name}, attempting to compress before cleanup") + try { + val compressedFileName = tempFile.name.removeSuffix(TEMP_FILE_EXTENSION) + val compressedFile = File(logsDirectory, compressedFileName) + if (!compressedFile.exists()) { + compressAndPublish(tempFile, compressedFileName) + } + tempFile.delete() + appLogger.i("Successfully salvaged orphaned temp file: ${tempFile.name} -> ${compressedFile.name}") + } catch (e: Exception) { + appLogger.w("Failed to compress orphaned temp file: ${tempFile.name}, preserving it for retry", e) + } + } + } + + private fun getListOfOrphanedTempFiles(): List { + return try { + logsDirectory.listFiles()?.filter { it.name.endsWith(TEMP_FILE_EXTENSION) } ?: emptyList() + } catch (e: SecurityException) { + appLogger.e("Error cleaning up orphaned temp files", e) + emptyList() + } + } + + private fun moveActiveLogFileToTemp(compressedFileName: String): File { + val tempFile = File(logsDirectory, "$compressedFileName$TEMP_FILE_EXTENSION") + if (activeLoggingFile.renameTo(tempFile)) { + if (activeLoggingFile.createNewFile()) return tempFile + + tempFile.renameTo(activeLoggingFile) + throw IOException("Unable to create a new active logging file after rotation") + } + + activeLoggingFile.copyTo(tempFile, overwrite = false) + clearActiveLoggingFileContent() + return tempFile + } + + private suspend fun compressFileAsync(sourceFile: File, compressedFileName: String): File? = withContext(Dispatchers.IO) { + try { + val compressedFile = compressAndPublish(sourceFile, compressedFileName) + + appLogger.i("Log file compressed: ${sourceFile.name} -> ${compressedFile.name}") + compressedFile + } catch (e: Exception) { + appLogger.e("Failed to compress log file: ${sourceFile.name}", e) + null + } + } + + private fun compressAndPublish(sourceFile: File, compressedFileName: String): File { + val compressedFile = File(logsDirectory, compressedFileName) + val partialFile = File(logsDirectory, "$compressedFileName$PARTIAL_FILE_EXTENSION") + try { + fileCompressor(sourceFile, partialFile) + if (!partialFile.renameTo(compressedFile)) { + throw IOException("Unable to publish compressed log file ${compressedFile.name}") + } + return compressedFile + } catch (e: Exception) { + partialFile.delete() + throw e + } + } + + @Throws(IOException::class) + private fun flushBuffer() { + if (logBuffer.isEmpty()) return + + // Use BufferedWriter for efficient writing + val writer = bufferedWriter ?: BufferedWriter( + FileWriter(activeLoggingFile, true), + config.bufferSizeBytes + ).also { bufferedWriter = it } + + logBuffer.forEach { line -> + writer.appendLine(line) + } + writer.flush() + + logBuffer.clear() + } + + private sealed interface LogCommand { + data class Entry(val lines: List, val throwable: Throwable?) : LogCommand + data class Flush(val completion: CompletableDeferred) : LogCommand + data class DeleteAll(val completion: CompletableDeferred) : LogCommand + data class Stop(val completion: CompletableDeferred) : LogCommand + } + + private data class LogCommandResult( + val lastFlushTime: Long, + val shouldStop: Boolean = false + ) + + companion object { + private const val LOG_TAG = "LogFileWriter" + private const val LOG_FILE_PREFIX = "wire" + private const val ACTIVE_LOGGING_FILE_NAME = "${LOG_FILE_PREFIX}_logs.txt" + private const val LOG_COMPRESSED_FILES_MAX_COUNT = 10 + private const val LOG_COMPRESSED_FILE_EXTENSION = "gz" + private const val TEMP_FILE_EXTENSION = ".tmp" + private const val PARTIAL_FILE_EXTENSION = ".partial" + private const val LOG_COMMAND_CHANNEL_CAPACITY = 10_000 + private val LINE_SEPARATOR_LENGTH = System.lineSeparator().length + + private fun compressFileToGzip(sourceFile: File, targetGzipFile: File, bufferSizeBytes: Int) { + GZIPOutputStream(targetGzipFile.outputStream().buffered()).use { gzipOut -> + sourceFile.inputStream().buffered().use { input -> + input.copyTo(gzipOut, bufferSizeBytes) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/logging/PlatformIndependentLogFileWriterConfig.kt b/app/src/main/kotlin/com/wire/android/util/logging/PlatformIndependentLogFileWriterConfig.kt new file mode 100644 index 00000000000..78c2c2d2747 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/logging/PlatformIndependentLogFileWriterConfig.kt @@ -0,0 +1,37 @@ +/* + * Wire + * Copyright (C) 2025 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 + +data class PlatformIndependentLogFileWriterConfig( + val flushIntervalMs: Long = DEFAULT_FLUSH_INTERVAL_MS, + val maxBufferSize: Int = DEFAULT_MAX_BUFFER_SIZE, + val bufferSizeBytes: Int = DEFAULT_BUFFER_SIZE_BYTES, + val maxFileSize: Long = DEFAULT_MAX_FILE_SIZE_BYTES, + val flushTimeoutMs: Long = DEFAULT_FLUSH_TIMEOUT_MS, +) { + companion object { + private const val DEFAULT_FLUSH_INTERVAL_MS = 5000L + private const val DEFAULT_MAX_BUFFER_SIZE = 100 + private const val DEFAULT_BUFFER_SIZE_BYTES = 64 * 1024 + private const val DEFAULT_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024L // 25MB + private const val DEFAULT_FLUSH_TIMEOUT_MS = 5000L // 5 seconds + + fun default() = PlatformIndependentLogFileWriterConfig() + } +} diff --git a/app/src/test/kotlin/com/wire/android/WireApplicationLoggingConfigTest.kt b/app/src/test/kotlin/com/wire/android/WireApplicationLoggingConfigTest.kt index a6595be9087..d3e3e79fc38 100644 --- a/app/src/test/kotlin/com/wire/android/WireApplicationLoggingConfigTest.kt +++ b/app/src/test/kotlin/com/wire/android/WireApplicationLoggingConfigTest.kt @@ -18,9 +18,17 @@ package com.wire.android +import com.wire.android.util.logging.LogFileWriter import com.wire.kalium.logger.KaliumLogLevel +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import java.io.IOException class WireApplicationLoggingConfigTest { @@ -47,4 +55,19 @@ class WireApplicationLoggingConfigTest { assertEquals(KaliumLogLevel.VERBOSE, config.logLevel) assertEquals(2, config.initialLogWriterList.size) } + + @Test + fun givenLogFlushFails_whenFlushingAfterLowMemory_thenFailureIsReportedWithoutStopping() = runTest { + val logFileWriter = mockk(relaxUnitFun = true) + val reportFailure = mockk<(Exception) -> Unit>(relaxed = true) + val failure = IOException("flush failed") + coEvery { logFileWriter.forceFlush() } throws failure + every { reportFailure(failure) } returns Unit + + WireApplication.flushLogsAfterLowMemory(logFileWriter, reportFailure) + + coVerify(exactly = 1) { logFileWriter.forceFlush() } + coVerify(exactly = 0) { logFileWriter.stop() } + verify(exactly = 1) { reportFailure(failure) } + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/debug/UserDebugViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/debug/UserDebugViewModelTest.kt new file mode 100644 index 00000000000..07dfbd6dca3 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/debug/UserDebugViewModelTest.kt @@ -0,0 +1,124 @@ +/* + * 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. + * + * 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.ui.debug + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.util.logging.LogFileWriter +import com.wire.kalium.common.logger.CoreLogger +import com.wire.kalium.logger.KaliumLogLevel +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase +import com.wire.kalium.logic.feature.debug.ChangeProfilingUseCase +import com.wire.kalium.logic.feature.debug.ObserveDatabaseLoggerStateUseCase +import com.wire.kalium.util.DebugKaliumApi +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class, DebugKaliumApi::class) +@ExtendWith(CoroutineTestExtension::class) +class UserDebugViewModelTest { + + @BeforeEach + fun setUp() { + mockkObject(CoreLogger) + every { CoreLogger.setLoggingLevel(any()) } just runs + } + + @AfterEach + fun tearDown() { + unmockkObject(CoreLogger) + } + + @Test + fun givenLoggingIsDisabled_whenEnablingLogging_thenEnableSharedLoggerLevel() = runTest { + val (arrangement, viewModel) = Arrangement().arrange() + + viewModel.setLoggingEnabledState(true) + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.globalDataStore.setLoggingEnabled(true) } + coVerify(exactly = 1) { arrangement.logFileWriter.start() } + verify(exactly = 1) { CoreLogger.setLoggingLevel(KaliumLogLevel.VERBOSE) } + } + + @Test + fun givenLoggingIsEnabled_whenDisablingLogging_thenDisableSharedLoggerLevel() = runTest { + val (arrangement, viewModel) = Arrangement().arrange() + + viewModel.setLoggingEnabledState(false) + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.globalDataStore.setLoggingEnabled(false) } + coVerify(exactly = 1) { arrangement.logFileWriter.stop() } + verify(exactly = 1) { CoreLogger.setLoggingLevel(KaliumLogLevel.DISABLED) } + } + + private class Arrangement { + + @MockK + lateinit var logFileWriter: LogFileWriter + + @MockK + lateinit var currentClientIdUseCase: ObserveCurrentClientIdUseCase + + @MockK + lateinit var globalDataStore: GlobalDataStore + + @MockK + lateinit var changeProfilingUseCase: ChangeProfilingUseCase + + @MockK + lateinit var observeDatabaseLoggerState: ObserveDatabaseLoggerStateUseCase + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { logFileWriter.activeLoggingFile } returns File("active.log") + every { globalDataStore.isLoggingEnabled() } returns emptyFlow() + coEvery { currentClientIdUseCase() } returns emptyFlow() + coEvery { observeDatabaseLoggerState() } returns emptyFlow() + } + + fun arrange() = this to UserDebugViewModel( + currentAccount = UserId("user", "domain"), + logFileWriter = logFileWriter, + currentClientIdUseCase = currentClientIdUseCase, + globalDataStore = globalDataStore, + changeProfilingUseCase = changeProfilingUseCase, + observeDatabaseLoggerState = observeDatabaseLoggerState + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/util/logging/LogFileWriterFactoryTest.kt b/app/src/test/kotlin/com/wire/android/util/logging/LogFileWriterFactoryTest.kt new file mode 100644 index 00000000000..4b869945d66 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/logging/LogFileWriterFactoryTest.kt @@ -0,0 +1,49 @@ +/* + * 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. + * + * 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 org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class LogFileWriterFactoryTest { + + @TempDir + lateinit var tempDir: File + + @Test + fun givenPlatformIndependentFlagIsDisabled_whenCreatingLogFileWriter_thenDefaultWriterIsReturned() { + val writer = LogFileWriterFactory.create( + logsDirectory = tempDir, + usePlatformIndependentFileLogger = false + ) + + assertInstanceOf(LogFileWriterImpl::class.java, writer) + } + + @Test + fun givenPlatformIndependentFlagIsEnabled_whenCreatingLogFileWriter_thenPlatformIndependentWriterIsReturned() { + val writer = LogFileWriterFactory.create( + logsDirectory = tempDir, + usePlatformIndependentFileLogger = true + ) + + assertInstanceOf(PlatformIndependentLogFileWriter::class.java, writer) + } +} diff --git a/app/src/test/kotlin/com/wire/android/util/logging/LogFileWriterImplTest.kt b/app/src/test/kotlin/com/wire/android/util/logging/LogFileWriterImplTest.kt new file mode 100644 index 00000000000..e2c69b2b676 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/logging/LogFileWriterImplTest.kt @@ -0,0 +1,57 @@ +/* + * 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. + * + * 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 co.touchlab.kermit.Severity +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class LogFileWriterImplTest { + + @TempDir + lateinit var tempDir: File + + @Test + fun givenFileWriterIsStopped_whenKermitEmitsLog_thenActiveFileIsNotCreated() { + val writer = LogFileWriterImpl(logsDirectory = logsDirectory()) + + writer.logWriter.log(Severity.Info, "ignored", "TestTag", null) + + assertFalse(writer.activeLoggingFile.exists()) + } + + @Test + fun givenFileWriterIsNotStarted_whenDeletingAllLogs_thenActiveAndCompressedLogsAreDeleted() = runBlocking { + val writer = LogFileWriterImpl(logsDirectory = logsDirectory()) + val compressedFile = logsDirectory().resolve("old-log.gz") + logsDirectory().mkdirs() + writer.activeLoggingFile.writeText("active") + compressedFile.writeText("compressed") + + writer.deleteAllLogFiles() + + assertEquals("", writer.activeLoggingFile.readText()) + assertFalse(compressedFile.exists()) + } + + private fun logsDirectory() = tempDir.resolve("logs") +} diff --git a/app/src/test/kotlin/com/wire/android/util/logging/PlatformIndependentLogFileWriterTest.kt b/app/src/test/kotlin/com/wire/android/util/logging/PlatformIndependentLogFileWriterTest.kt new file mode 100644 index 00000000000..9c483653bb4 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/logging/PlatformIndependentLogFileWriterTest.kt @@ -0,0 +1,360 @@ +/* + * 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. + * + * 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 co.touchlab.kermit.Severity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +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.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +class PlatformIndependentLogFileWriterTest { + + @TempDir + lateinit var tempDir: File + + @Test + fun givenFileWriterIsStarted_whenKermitEmitsLog_thenLogIsWrittenToActiveFile() = runBlocking { + val writer = PlatformIndependentLogFileWriter( + logsDirectory = logsDirectory(), + config = PlatformIndependentLogFileWriterConfig.default().copy(flushIntervalMs = ONE_MINUTE_MS) + ) + + writer.start() + writer.logWriter.log(Severity.Info, "hello from kermit", "TestTag", null) + writer.forceFlush() + + val logText = writer.activeLoggingFile.readText() + assertTrue(logText.contains(Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} Info: \(TestTag\)"""))) + assertTrue(logText.contains("TestTag")) + assertTrue(logText.contains("hello from kermit")) + writer.stop() + } + + @Test + fun givenFileWriterIsStarted_whenKermitEmitsLog_thenLogIsNotSynchronouslyFlushed() = runBlocking { + val writer = PlatformIndependentLogFileWriter( + logsDirectory = logsDirectory(), + config = PlatformIndependentLogFileWriterConfig.default().copy( + flushIntervalMs = ONE_MINUTE_MS, + maxBufferSize = TWO_LOG_LINES + ) + ) + + writer.start() + writer.logWriter.log(Severity.Info, "buffered only", "TestTag", null) + waitForWorker() + + assertFalse(writer.activeLoggingFile.readText().contains("buffered only")) + writer.forceFlush() + assertTrue(writer.activeLoggingFile.readText().contains("buffered only")) + writer.stop() + } + + @Test + fun givenBufferReachesMaxSize_whenKermitEmitsLogs_thenWorkerFlushesBuffer() = runBlocking { + val writer = PlatformIndependentLogFileWriter( + logsDirectory = logsDirectory(), + config = PlatformIndependentLogFileWriterConfig.default().copy( + flushIntervalMs = ONE_MINUTE_MS, + maxBufferSize = TWO_LOG_LINES + ) + ) + + writer.start() + writer.logWriter.log(Severity.Info, "first", "TestTag", null) + writer.logWriter.log(Severity.Info, "second", "TestTag", null) + + eventually { + val logText = writer.activeLoggingFile.readText() + logText.contains("first") && logText.contains("second") + } + writer.stop() + } + + @Test + fun givenFileWriterIsStopped_whenKermitEmitsLog_thenActiveFileIsNotCreated() { + val writer = PlatformIndependentLogFileWriter(logsDirectory = logsDirectory()) + + writer.logWriter.log(Severity.Info, "ignored", "TestTag", null) + + assertFalse(writer.activeLoggingFile.exists()) + } + + @Test + fun givenFileWriterIsStopped_whenStartedAgain_thenPreviousSessionLogsAreCleared() = runBlocking { + val writer = PlatformIndependentLogFileWriter( + logsDirectory = logsDirectory(), + config = PlatformIndependentLogFileWriterConfig.default().copy(flushIntervalMs = ONE_MINUTE_MS) + ) + + writer.start() + writer.logWriter.log(Severity.Info, "previous session", "TestTag", null) + writer.forceFlush() + assertTrue(writer.activeLoggingFile.readText().contains("previous session")) + + writer.stop() + assertEquals("", writer.activeLoggingFile.readText()) + + writer.start() + writer.logWriter.log(Severity.Info, "next session", "TestTag", null) + writer.forceFlush() + + val logText = writer.activeLoggingFile.readText() + assertFalse(logText.contains("previous session")) + assertTrue(logText.contains("next session")) + writer.stop() + } + + @Test + fun givenActiveFileExceedsMaxSize_whenKermitEmitsLog_thenWorkerRotatesActiveFile() = runBlocking { + val writer = PlatformIndependentLogFileWriter( + logsDirectory = logsDirectory(), + config = PlatformIndependentLogFileWriterConfig.default().copy( + flushIntervalMs = ONE_MINUTE_MS, + maxBufferSize = ONE_LOG_LINE, + maxFileSize = SMALL_FILE_SIZE_BYTES + ) + ) + + writer.start() + writer.logWriter.log(Severity.Info, "large-message-${"x".repeat(LARGE_MESSAGE_SIZE)}", "TestTag", null) + writer.forceFlush() + + eventually { compressedLogFiles().size == 1 } + val compressedFile = compressedLogFiles().single() + val compressedText = GZIPInputStream(compressedFile.inputStream()).bufferedReader().use { it.readText() } + assertTrue(compressedText.contains("large-message")) + assertFalse(writer.activeLoggingFile.readText().contains("large-message")) + writer.stop() + } + + @Test + fun givenTwoRotationsHaveTheSameTimestamp_whenLogsAreCompressed_thenDistinctArchivesArePublished() = runBlocking { + val writer = testWriter(rotationTimestamp = FIXED_ROTATION_TIMESTAMP) + + writer.start() + writer.logWriter.log(Severity.Info, "first-${"x".repeat(LARGE_MESSAGE_SIZE)}", "TestTag", null) + eventually { compressedLogFiles().size == 1 } + writer.logWriter.log(Severity.Info, "second-${"x".repeat(LARGE_MESSAGE_SIZE)}", "TestTag", null) + eventually { compressedLogFiles().size == 2 } + + assertEquals( + setOf("wire_${FIXED_ROTATION_TIMESTAMP}_1.gz", "wire_${FIXED_ROTATION_TIMESTAMP}_2.gz"), + compressedLogFiles().map(File::getName).toSet() + ) + writer.stop() + } + + @Test + fun givenCompressionFails_whenActiveFileRotates_thenSourceIsPreservedAndPartialOutputIsDeleted() = runBlocking { + val writer = testWriter( + rotationTimestamp = FIXED_ROTATION_TIMESTAMP, + fileCompressor = { _, targetFile -> + targetFile.writeText("incomplete gzip") + throw IOException("compression failed") + } + ) + + writer.start() + writer.logWriter.log(Severity.Info, "preserve-${"x".repeat(LARGE_MESSAGE_SIZE)}", "TestTag", null) + + eventually { + temporaryLogFiles().singleOrNull()?.readText()?.contains("preserve-") == true && + partialLogFiles().isEmpty() + } + assertTrue(compressedLogFiles().isEmpty()) + writer.stop() + } + + @Test + fun givenDeleteAllLogsWhileCompressionIsInFlight_whenCompressionFinishes_thenNoRotationFilesRemain() = runBlocking { + val compressionStarted = CountDownLatch(1) + val releaseCompression = CountDownLatch(1) + val compressionFinished = CountDownLatch(1) + val writer = testWriter( + rotationTimestamp = FIXED_ROTATION_TIMESTAMP, + fileCompressor = { _, targetFile -> + compressionStarted.countDown() + try { + check(releaseCompression.await(COMPRESSION_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + targetFile.writeText("compressed") + } finally { + compressionFinished.countDown() + } + } + ) + + writer.start() + writer.logWriter.log(Severity.Info, "delete-${"x".repeat(LARGE_MESSAGE_SIZE)}", "TestTag", null) + eventually { compressionStarted.count == 0L } + + writer.deleteAllLogFiles() + releaseCompression.countDown() + + eventually { compressionFinished.count == 0L } + eventually { + compressedLogFiles().isEmpty() && temporaryLogFiles().isEmpty() && partialLogFiles().isEmpty() + } + writer.stop() + } + + @Test + fun givenOrphanCompressionInitiallyFails_whenLaterStartupRetries_thenArchiveIsPublishedAndOrphanIsRemoved() = runBlocking { + logsDirectory().mkdirs() + val orphan = logsDirectory().resolve("wire_orphan.gz.tmp").apply { writeText("diagnostic logs") } + val failingWriter = testWriter( + rotationTimestamp = FIXED_ROTATION_TIMESTAMP, + fileCompressor = { _, targetFile -> + targetFile.writeText("incomplete gzip") + throw IOException("compression failed") + } + ) + + failingWriter.start() + + assertTrue(orphan.exists()) + assertEquals("diagnostic logs", orphan.readText()) + assertFalse(logsDirectory().resolve("wire_orphan.gz").exists()) + assertTrue(partialLogFiles().isEmpty()) + failingWriter.stop() + + val retryingWriter = testWriter(rotationTimestamp = FIXED_ROTATION_TIMESTAMP) + retryingWriter.start() + + val recoveredArchive = logsDirectory().resolve("wire_orphan.gz") + assertTrue(recoveredArchive.exists()) + assertFalse(orphan.exists()) + assertTrue(partialLogFiles().isEmpty()) + val recoveredText = GZIPInputStream(recoveredArchive.inputStream()).bufferedReader().use { it.readText() } + assertEquals("diagnostic logs", recoveredText) + retryingWriter.stop() + } + + @Test + fun givenFileWriterIsStarted_whenDeletingAllLogs_thenActiveAndCompressedLogsAreDeleted() = runBlocking { + val writer = PlatformIndependentLogFileWriter( + logsDirectory = logsDirectory(), + config = PlatformIndependentLogFileWriterConfig.default().copy(flushIntervalMs = ONE_MINUTE_MS) + ) + val compressedFile = logsDirectory().resolve("old-log.gz") + + writer.start() + writer.logWriter.log(Severity.Info, "to be deleted", "TestTag", null) + writer.forceFlush() + compressedFile.writeText("compressed") + + writer.deleteAllLogFiles() + + assertEquals("", writer.activeLoggingFile.readText()) + assertFalse(compressedFile.exists()) + writer.stop() + } + + @Test + fun givenFileWriterIsNotStarted_whenDeletingAllLogs_thenActiveAndCompressedLogsAreDeleted() = runBlocking { + val writer = PlatformIndependentLogFileWriter(logsDirectory = logsDirectory()) + val compressedFile = logsDirectory().resolve("old-log.gz") + logsDirectory().mkdirs() + writer.activeLoggingFile.writeText("active") + compressedFile.writeText("compressed") + + writer.deleteAllLogFiles() + + assertEquals("", writer.activeLoggingFile.readText()) + assertFalse(compressedFile.exists()) + } + + @Test + fun givenTimestampFormatter_whenFormattingMillis_thenMillisecondsArePadded() { + val formatter = LogLineTimestampFormatter() + + assertTrue(formatter.format(1_005L).endsWith(".005")) + assertTrue(formatter.format(1_040L).endsWith(".040")) + assertTrue(formatter.format(1_400L).endsWith(".400")) + } + + private fun logsDirectory() = tempDir.resolve("logs") + + private fun compressedLogFiles() = logsDirectory().listFiles().orEmpty() + .filter { it.extension == "gz" } + + private fun temporaryLogFiles() = logsDirectory().listFiles().orEmpty() + .filter { it.name.endsWith(".tmp") } + + private fun partialLogFiles() = logsDirectory().listFiles().orEmpty() + .filter { it.name.endsWith(".partial") } + + private fun testWriter( + rotationTimestamp: String, + fileCompressor: (File, File) -> Unit = ::compressToGzip + ) = PlatformIndependentLogFileWriter( + logsDirectory = logsDirectory(), + config = PlatformIndependentLogFileWriterConfig.default().copy( + flushIntervalMs = ONE_MINUTE_MS, + maxBufferSize = ONE_LOG_LINE, + maxFileSize = SMALL_FILE_SIZE_BYTES + ), + fileCompressor = fileCompressor, + rotationTimestampProvider = { rotationTimestamp } + ) + + private fun compressToGzip(sourceFile: File, targetFile: File) { + GZIPOutputStream(targetFile.outputStream()).use { output -> + sourceFile.inputStream().use { input -> input.copyTo(output) } + } + } + + private suspend fun eventually(assertion: () -> Boolean) { + repeat(EVENTUALLY_RETRIES) { + if (assertion()) return + waitForWorker(EVENTUALLY_DELAY_MS) + } + assertTrue(assertion()) + } + + private suspend fun waitForWorker(delayMillis: Long = WORKER_SETTLE_MS) { + withContext(Dispatchers.IO) { + delay(delayMillis) + } + } + + private companion object { + const val ONE_MINUTE_MS = 60_000L + const val ONE_LOG_LINE = 1 + const val TWO_LOG_LINES = 2 + const val SMALL_FILE_SIZE_BYTES = 64L + const val LARGE_MESSAGE_SIZE = 64 + const val WORKER_SETTLE_MS = 100L + const val EVENTUALLY_RETRIES = 20 + const val EVENTUALLY_DELAY_MS = 50L + const val FIXED_ROTATION_TIMESTAMP = "2026-01-02_03-04-05" + const val COMPRESSION_TIMEOUT_SECONDS = 5L + } +} diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index ca22cd97fd3..1258b2453a3 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -79,6 +79,7 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { * Development/Logging stuff */ LOGGING_ENABLED("logging_enabled", ConfigType.BOOLEAN), + USE_PLATFORM_INDEPENDENT_FILE_LOGGER("use_platform_independent_file_logger", ConfigType.BOOLEAN), DEBUG_SCREEN_ENABLED("debug_screen_enabled", ConfigType.BOOLEAN), DEVELOPER_FEATURES_ENABLED("developer_features_enabled", ConfigType.BOOLEAN), DEVELOPMENT_API_ENABLED("development_api_enabled", ConfigType.BOOLEAN), diff --git a/default.json b/default.json index 8701bd1c2aa..327e3e48fb6 100644 --- a/default.json +++ b/default.json @@ -4,6 +4,7 @@ "application_id": "com.wire", "developer_features_enabled": false, "logging_enabled": false, + "use_platform_independent_file_logger": false, "application_is_private_build": false, "development_api_enabled": false, "analytics_enabled": true, @@ -133,6 +134,7 @@ "firebase_app_id": "1:782078216207:android:d3db2443512d2055", "google_api_key": "AIzaSyBXtNKuX6GCKv2jDtsFImUaxCRL21DTLEQ", "fcm_project_id": "w966768976", + "use_platform_independent_file_logger": true, "report_bug_menu_item_enabled": true, "debug_screen_enabled": true, "update_app_url": "https://wire.com/en/download/",