diff --git a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt index 5e401750318..2cd630be72a 100644 --- a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt +++ b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.debug import androidx.compose.ui.test.junit4.createComposeRule import com.wire.android.extensions.waitUntilExists import com.wire.android.ui.WireTestTheme +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -40,6 +41,7 @@ class DebugScreenComposeTest { onDeleteLogs = {}, onDatabaseLoggerEnabledChanged = {}, onShowFeatureFlags = {}, + onFlushLogs = { CompletableDeferred(Unit) }, ) } } diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index 4be311e4590..16c11ccac06 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -40,7 +40,7 @@ import com.wire.android.feature.analytics.model.AnalyticsSettings import com.wire.android.util.AppNameUtil import com.wire.android.util.CurrentScreenManager import com.wire.android.util.DataDogLogger -import com.wire.android.util.LogFileWriter +import com.wire.android.util.logging.LogFileWriter import com.wire.android.util.getGitBuildId import com.wire.android.util.lifecycle.SyncLifecycleManager import com.wire.android.workmanager.WireWorkerFactory @@ -59,8 +59,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltAndroidApp class WireApplication : BaseApp() { @@ -107,6 +109,8 @@ class WireApplication : BaseApp() { enableStrictMode() + setupGlobalExceptionHandler() + startActivityLifecycleCallback() globalAppScope.launch { @@ -160,6 +164,82 @@ class WireApplication : BaseApp() { } } + private fun setupGlobalExceptionHandler() { + setupUncaughtExceptionHandler() + setupHistoricalExitMonitoring() + } + + @Suppress("TooGenericExceptionCaught") + private fun setupUncaughtExceptionHandler() { + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, exception -> + flushLogsBeforeCrash() + defaultHandler?.uncaughtException(thread, exception) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun flushLogsBeforeCrash() { + // Use fire-and-forget approach to avoid blocking the crash handler + // which could lead to ANRs. We attempt a quick flush but don't wait for it. + try { + globalAppScope.launch(Dispatchers.IO) { + try { + // Use a very short timeout to avoid delaying the crash + withTimeout(CRASH_FLUSH_TIMEOUT_MS) { + logFileWriter.get().forceFlush() + } + appLogger.i("Logs flushed before crash") + } catch (e: Exception) { + // Log errors but don't block the crash handler + appLogger.e("Failed to flush logs before crash", e) + } + } + } catch (e: Exception) { + // Ignore any launch failures - we don't want to interfere with crash handling + } + } + + @Suppress("TooGenericExceptionCaught") + private fun setupHistoricalExitMonitoring() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + val activityManager = getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager + activityManager.setProcessStateSummary(ByteArray(0)) + + // This will be called after the app exits, so we can't flush here, + // but we log it for diagnostics + globalAppScope.launch { + activityManager.getHistoricalProcessExitReasons(packageName, 0, MAX_HISTORICAL_EXIT_REASONS) + .forEach { info -> + logPreviousExitReason(info) + } + } + } catch (e: Exception) { + appLogger.e("Failed to setup app exit monitoring", e) + } + } + } + + private fun logPreviousExitReason(info: android.app.ApplicationExitInfo) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + when (info.reason) { + android.app.ApplicationExitInfo.REASON_ANR -> { + appLogger.w("Previous app exit was due to ANR at ${info.timestamp}") + } + android.app.ApplicationExitInfo.REASON_CRASH -> { + appLogger.w("Previous app exit was due to crash at ${info.timestamp}") + } + android.app.ApplicationExitInfo.REASON_LOW_MEMORY -> { + appLogger.w("Previous app exit was due to low memory at ${info.timestamp}") + } + else -> { + appLogger.i("Previous app exit reason: ${info.reason} at ${info.timestamp}") + } + } + } + } + @Suppress("EmptyFunctionBlock") private fun startActivityLifecycleCallback() { registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { @@ -290,7 +370,9 @@ class WireApplication : BaseApp() { override fun onLowMemory() { super.onLowMemory() appLogger.w("onLowMemory called - Stopping logging, buckling the seatbelt and hoping for the best!") - logFileWriter.get().stop() + globalAppScope.launch { + logFileWriter.get().stop() + } } private companion object { @@ -313,5 +395,7 @@ class WireApplication : BaseApp() { } private const val TAG = "WireApplication" + private const val CRASH_FLUSH_TIMEOUT_MS = 1000L + private const val MAX_HISTORICAL_EXIT_REASONS = 5 } } 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 edf61bb8c37..f90c9093484 100644 --- a/app/src/main/kotlin/com/wire/android/di/LogWriterModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/LogWriterModule.kt @@ -19,7 +19,10 @@ package com.wire.android.di import android.content.Context -import com.wire.android.util.LogFileWriter +import com.wire.android.BuildConfig +import com.wire.android.util.logging.LogFileWriterV1Impl + import com.wire.android.util.logging.LogFileWriter +import com.wire.android.util.logging.LogFileWriterV2Impl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -34,7 +37,12 @@ class LogWriterModule { @Singleton @Provides fun provideKaliumFileWriter(@ApplicationContext context: Context): LogFileWriter { - val logsDirectory = LogFileWriter.logsDirectory(context) - return LogFileWriter(logsDirectory) + if (BuildConfig.USE_ASYNC_FLUSH_LOGGING) { + val logsDirectory = LogFileWriter.logsDirectory(context) + return LogFileWriterV2Impl(logsDirectory) + } else { + val logsDirectory = LogFileWriter.logsDirectory(context) + return LogFileWriterV1Impl(logsDirectory) + } } } diff --git a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt index d34644513a8..75fb3f488b9 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt @@ -26,10 +26,10 @@ import com.ramcosta.composedestinations.spec.Direction import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.util.EmailComposer -import com.wire.android.util.LogFileWriter import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.getUrisOfFilesInDirectory +import com.wire.android.util.logging.LogFileWriter import com.wire.android.util.multipleFileSharingIntent import com.wire.android.util.sha256 diff --git a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt index 1d20ebc6bad..70464618b06 100644 --- a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt @@ -28,7 +28,7 @@ import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.lifecycle.SyncLifecycleManager -import com.wire.android.util.logIfEmptyUserName +import com.wire.android.util.logging.logIfEmptyUserName import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.id.ConversationId diff --git a/app/src/main/kotlin/com/wire/android/services/CallServiceManager.kt b/app/src/main/kotlin/com/wire/android/services/CallServiceManager.kt index 06c08fe4937..3df3cb219fc 100644 --- a/app/src/main/kotlin/com/wire/android/services/CallServiceManager.kt +++ b/app/src/main/kotlin/com/wire/android/services/CallServiceManager.kt @@ -21,7 +21,7 @@ import com.wire.android.appLogger import com.wire.android.di.KaliumCoreLogic import com.wire.android.notification.CallNotificationData import com.wire.android.services.CallService.Action -import com.wire.android.util.logIfEmptyUserName +import com.wire.android.util.logging.logIfEmptyUserName import com.wire.kalium.common.functional.Either import com.wire.kalium.common.functional.fold import com.wire.kalium.common.functional.left diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt index f76fd8613af..9b714f700f6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ClipboardManager @@ -61,6 +62,10 @@ import com.wire.android.util.AppNameUtil import com.wire.android.util.getMimeType import com.wire.android.util.getUrisOfFilesInDirectory import com.wire.android.util.multipleFileSharingIntent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.launch import java.io.File @WireDestination @@ -74,6 +79,7 @@ fun DebugScreen( state = userDebugViewModel.state, onLoggingEnabledChange = userDebugViewModel::setLoggingEnabledState, onDeleteLogs = userDebugViewModel::deleteLogs, + onFlushLogs = userDebugViewModel::flushLogs, onDatabaseLoggerEnabledChanged = userDebugViewModel::setDatabaseLoggerEnabledState, onShowFeatureFlags = { navigator.navigate(NavigationCommand(DebugFeatureFlagsScreenDestination)) @@ -88,6 +94,7 @@ internal fun UserDebugContent( onLoggingEnabledChange: (Boolean) -> Unit, onDatabaseLoggerEnabledChanged: (Boolean) -> Unit, onDeleteLogs: () -> Unit, + onFlushLogs: () -> Deferred, onShowFeatureFlags: () -> Unit, ) { val debugContentState: DebugContentState = rememberDebugContentState(state.logPath) @@ -113,7 +120,7 @@ internal fun UserDebugContent( isLoggingEnabled = isLoggingEnabled, onLoggingEnabledChange = onLoggingEnabledChange, onDeleteLogs = onDeleteLogs, - onShareLogs = debugContentState::shareLogs, + onShareLogs = { debugContentState.shareLogs(onFlushLogs) }, isDBLoggerEnabled = state.isDBLoggingEnabled, onDBLoggerEnabledChange = onDatabaseLoggerEnabledChanged, isPrivateBuild = BuildConfig.PRIVATE_BUILD, @@ -181,13 +188,15 @@ fun rememberDebugContentState(logPath: String): DebugContentState { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() return remember { DebugContentState( context, clipboardManager, logPath, - scrollState + scrollState, + coroutineScope ) } } @@ -196,7 +205,8 @@ data class DebugContentState( val context: Context, val clipboardManager: ClipboardManager, val logPath: String, - val scrollState: ScrollState + val scrollState: ScrollState, + val coroutineScope: CoroutineScope ) { fun copyToClipboard(text: String) { clipboardManager.setText(AnnotatedString(text)) @@ -207,18 +217,22 @@ data class DebugContentState( ).show() } - fun shareLogs() { - val dir = File(logPath).parentFile - val fileUris = - if (dir != null && dir.exists()) context.getUrisOfFilesInDirectory(dir) else arrayListOf() - val intent = context.multipleFileSharingIntent(fileUris) - // The first log file is simply text, not compressed. Get its mime type separately - // and set it as the mime type for the intent. - intent.type = fileUris.firstOrNull()?.getMimeType(context) ?: "text/plain" - // Get all other mime types and add them - val mimeTypes = fileUris.drop(1).mapNotNull { it.getMimeType(context) } - intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toSet().toTypedArray()) - context.startActivity(intent) + fun shareLogs(onFlushLogs: () -> Deferred) { + coroutineScope.launch { + // Flush any buffered logs before sharing to ensure completeness + onFlushLogs().await() + val dir = File(logPath).parentFile + val fileUris = + if (dir != null && dir.exists()) context.getUrisOfFilesInDirectory(dir) else arrayListOf() + val intent = context.multipleFileSharingIntent(fileUris) + // The first log file is simply text, not compressed. Get its mime type separately + // and set it as the mime type for the intent. + intent.type = fileUris.firstOrNull()?.getMimeType(context) ?: "text/plain" + // Get all other mime types and add them + val mimeTypes = fileUris.drop(1).mapNotNull { it.getMimeType(context) } + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toSet().toTypedArray()) + context.startActivity(intent) + } } } @@ -233,6 +247,7 @@ internal fun PreviewUserDebugContent() = WireTheme { onNavigationPressed = {}, onLoggingEnabledChange = {}, onDeleteLogs = {}, + onFlushLogs = { CompletableDeferred(Unit) }, onDatabaseLoggerEnabledChanged = {}, onShowFeatureFlags = {}, ) 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 009d36e66ae..7fb4c3b7950 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 @@ -26,7 +26,7 @@ import androidx.lifecycle.viewModelScope import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.CurrentAccount import com.wire.android.util.EMPTY -import com.wire.android.util.LogFileWriter +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 @@ -34,6 +34,8 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.launch import javax.inject.Inject @@ -87,6 +89,12 @@ class UserDebugViewModel logFileWriter.deleteAllLogFiles() } + fun flushLogs(): Deferred { + return viewModelScope.async { + logFileWriter.forceFlush() + } + } + fun setLoggingEnabledState(isEnabled: Boolean) { viewModelScope.launch { globalDataStore.setLoggingEnabled(isEnabled) 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 new file mode 100644 index 00000000000..99af4188b8e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriter.kt @@ -0,0 +1,59 @@ +/* + * 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.content.Context +import java.io.File + +/** + * Common interface for log file writers to enable easy substitution + * between different implementations. + */ +interface LogFileWriter { + + /** + * The active logging file where logs are currently being written + */ + val activeLoggingFile: File + + /** + * Starts the log collection system + */ + suspend fun start() + + /** + * Stops the log collection system + */ + suspend fun stop() + + /** + * Forces a flush of any pending logs to ensure they are written to file + */ + suspend fun forceFlush() + + /** + * Deletes all log files including active and compressed files + * + */ + fun deleteAllLogFiles() + + companion object { + fun logsDirectory(context: Context) = File(context.cacheDir, "logs") + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/LogFileWriter.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Impl.kt similarity index 94% rename from app/src/main/kotlin/com/wire/android/util/LogFileWriter.kt rename to app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Impl.kt index 5a591ca336f..d0634a6a12b 100644 --- a/app/src/main/kotlin/com/wire/android/util/LogFileWriter.kt +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV1Impl.kt @@ -16,9 +16,8 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.util +package com.wire.android.util.logging -import android.content.Context import com.wire.android.appLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -43,11 +42,11 @@ import java.util.Locale import java.util.zip.GZIPOutputStream @Suppress("TooGenericExceptionCaught") -class LogFileWriter(private val logsDirectory: File) { +class LogFileWriterV1Impl(private val logsDirectory: File) : LogFileWriter { private val logFileTimeFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US) - val activeLoggingFile = File(logsDirectory, ACTIVE_LOGGING_FILE_NAME) + override val activeLoggingFile = File(logsDirectory, ACTIVE_LOGGING_FILE_NAME) private val fileWriterCoroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var writingJob: Job? = null @@ -59,7 +58,7 @@ class LogFileWriter(private val logsDirectory: File) { * logger.i("something") // Is guaranteed to be recorded in the log file * ``` */ - suspend fun start() { + override suspend fun start() { appLogger.i("KaliumFileWritter.start called") val isWriting = writingJob?.isActive ?: false if (isWriting) { @@ -112,12 +111,16 @@ class LogFileWriter(private val logsDirectory: File) { /** * Stops processing logs and writing to files */ - fun stop() { + override suspend fun stop() { appLogger.i("KaliumFileWritter.stop called; Stopping log collection.") writingJob?.cancel() clearActiveLoggingFileContent() } + override suspend fun forceFlush() { + /* no-op */ + } + private fun clearActiveLoggingFileContent() { if (activeLoggingFile.exists()) { val writer = PrintWriter(activeLoggingFile) @@ -151,7 +154,7 @@ class LogFileWriter(private val logsDirectory: File) { } } - fun deleteAllLogFiles() { + override fun deleteAllLogFiles() { clearActiveLoggingFileContent() logsDirectory.listFiles()?.filter { it.extension.lowercase(Locale.ROOT) == LOG_COMPRESSED_FILE_EXTENSION @@ -196,7 +199,5 @@ class LogFileWriter(private val logsDirectory: File) { private const val BYTE_ARRAY_SIZE = 1024 private const val LOG_COMPRESSED_FILES_MAX_COUNT = 10 private const val LOG_COMPRESSED_FILE_EXTENSION = "gz" - - fun logsDirectory(context: Context) = File(context.cacheDir, "logs") } } diff --git a/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV2Config.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV2Config.kt new file mode 100644 index 00000000000..55cb5d21e09 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV2Config.kt @@ -0,0 +1,39 @@ +/* + * 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 LogFileWriterV2Config( + 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, + val bufferLockTimeoutMs: Long = DEFAULT_BUFFER_LOCK_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 + private const val DEFAULT_BUFFER_LOCK_TIMEOUT_MS = 3000L // 3 seconds + + fun default() = LogFileWriterV2Config() + } +} 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 new file mode 100644 index 00000000000..2cd5be43660 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogFileWriterV2Impl.kt @@ -0,0 +1,374 @@ +/* + * 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 com.wire.android.appLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +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.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancelAndJoin +import java.io.BufferedWriter +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.zip.GZIPOutputStream + +@Suppress("TooGenericExceptionCaught", "TooManyFunctions") +class LogFileWriterV2Impl( + private val logsDirectory: File, + private val config: LogFileWriterV2Config = LogFileWriterV2Config.default() +) : 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 + private var flushJob: Job? = null + + // Buffering system + private val logBuffer = mutableListOf() + private val bufferMutex = Mutex() + private var lastFlushTime = 0L + private var bufferedWriter: BufferedWriter? = null + + // Process management + private var logcatProcess: Process? = 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 > config.maxFileSize + }.collect { + ensureActive() + // Flush buffer before compression + bufferMutex.withLock { + flushBuffer() + } + launch { compressAsync() } + clearActiveLoggingFileContent() + deleteOldCompressedFiles() + } + } + + // Start periodic flush job + flushJob = fileWriterCoroutineScope.launch { + while (isActive) { + delay(config.flushIntervalMs) + try { + withTimeout(config.bufferLockTimeoutMs) { + bufferMutex.withLock { + if (logBuffer.isNotEmpty()) { + flushBuffer() + lastFlushTime = System.currentTimeMillis() + } + } + } + } catch (e: TimeoutCancellationException) { + appLogger.w("Periodic flush timed out, buffer may be locked by another operation") + } catch (e: Exception) { + appLogger.e("Error during periodic flush", e) + } + } + } + + appLogger.i("KaliumFileWritter.start: Starting log collection.") + waitInitializationJob.join() + } + + /** + * 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") + logcatProcess = Runtime.getRuntime().exec("logcat") + + val reader = logcatProcess!!.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() + stopLogcatProcess() + }.flowOn(Dispatchers.IO) + + private fun stopLogcatProcess() { + logcatProcess?.let { process -> + try { + process.destroy() + if (process.isAlive && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + process.destroyForcibly() + } + } catch (e: Exception) { + appLogger.e("Error stopping logcat process", e) + } + } + logcatProcess = null + } + + /** + * Stops processing logs and writing to files + */ + override suspend fun stop() { + appLogger.i("KaliumFileWritter.stop called; Stopping log collection.") + try { + // Stop logcat process first to prevent new logs + stopLogcatProcess() + // Cancel jobs with timeout to avoid hanging + writingJob?.let { job -> + try { + withTimeout(config.flushTimeoutMs) { + job.cancelAndJoin() + } + } catch (e: TimeoutCancellationException) { + appLogger.w("Writing job cancellation timed out, forcing cancellation") + job.cancel() + } + } + + flushJob?.let { job -> + try { + withTimeout(config.flushTimeoutMs) { + job.cancelAndJoin() + } + } catch (e: TimeoutCancellationException) { + appLogger.w("Flush job cancellation timed out, forcing cancellation") + job.cancel() + } + } + + // Flush any remaining buffered content with timeout + try { + withTimeout(config.flushTimeoutMs) { + bufferMutex.withLock { + flushBuffer() + } + } + } catch (e: TimeoutCancellationException) { + appLogger.w("Final buffer flush timed out, some logs may be lost") + } catch (e: Exception) { + appLogger.e("Error during final buffer flush", e) + } + } finally { + // Ensure resources are cleaned up regardless of exceptions + closeResources() + try { + clearActiveLoggingFileContent() + } catch (e: Exception) { + appLogger.e("Error clearing active logging file content", e) + } + } + } + + private fun closeResources() { + try { + bufferedWriter?.close() + } catch (e: Exception) { + appLogger.e("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() { + try { + withTimeout(config.flushTimeoutMs) { + bufferMutex.withLock { + flushBuffer() + } + } + } 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()) { + val writer = PrintWriter(activeLoggingFile) + writer.print("") + writer.close() + } + } + + /** + * Writes the new [text] and other log entries in logcat to the [activeLoggingFile]. + * @return The length, in bytes, of the log file. + */ + private suspend fun writeLineToFile(text: String): Long = withContext(Dispatchers.IO) { + try { + withTimeout(config.bufferLockTimeoutMs) { + bufferMutex.withLock { + logBuffer.add(text) + + val currentTime = System.currentTimeMillis() + val shouldFlush = logBuffer.size >= config.maxBufferSize || + ((currentTime - lastFlushTime) >= config.flushIntervalMs) + + if (shouldFlush) { + flushBuffer() + lastFlushTime = currentTime + } + + return@withLock activeLoggingFile.length() + } + } + } catch (e: TimeoutCancellationException) { + appLogger.w("Buffer write operation timed out, log line may be lost: $text") + // Return current file length as fallback + return@withContext activeLoggingFile.length() + } catch (e: Exception) { + appLogger.e("Error writing to log buffer", e) + return@withContext activeLoggingFile.length() + } + } + + 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 fun deleteAllLogFiles() { + clearActiveLoggingFileContent() + logsDirectory.listFiles()?.filter { + it.extension.lowercase(Locale.ROOT) == LOG_COMPRESSED_FILE_EXTENSION + }?.forEach { it.delete() } + } + + private fun getCompressedFilesList() = (logsDirectory.listFiles() ?: emptyArray()).filter { it != activeLoggingFile } + + private fun compressedFileName(): String { + val currentDate = logFileTimeFormat.format(Date()) + return "${LOG_FILE_PREFIX}_$currentDate.$LOG_COMPRESSED_FILE_EXTENSION" + } + + private fun deleteOldCompressedFiles() = getCompressedFilesList() + .sortedBy { it.lastModified() } + .dropLast(LOG_COMPRESSED_FILES_MAX_COUNT) + .forEach { + it.delete() + } + + private suspend fun compressAsync() = withContext(Dispatchers.IO) { + try { + val compressedFile = File(logsDirectory, compressedFileName()) + + GZIPOutputStream(compressedFile.outputStream().buffered()).use { gzipOut -> + activeLoggingFile.inputStream().buffered().use { input -> + input.copyTo(gzipOut, config.bufferSizeBytes) + } + } + + appLogger.i("Log file compressed: ${activeLoggingFile.name} -> ${compressedFile.name}") + } catch (e: Exception) { + appLogger.e("Failed to compress log file: ${activeLoggingFile.name}", e) + } + } + + private fun flushBuffer() { + if (logBuffer.isEmpty()) return + val linesToWrite = logBuffer.toList() + logBuffer.clear() + try { + // Use BufferedWriter for efficient writing + val writer = bufferedWriter ?: BufferedWriter( + FileWriter(activeLoggingFile, true), + config.bufferSizeBytes + ).also { bufferedWriter = it } + + linesToWrite.forEach { line -> + writer.appendLine(line) + } + writer.flush() + } catch (e: IOException) { + appLogger.e("Failed to flush log buffer", e) + // Re-add failed lines back to buffer for retry + logBuffer.addAll(0, linesToWrite) + } + } + + 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_COMPRESSED_FILES_MAX_COUNT = 10 + private const val LOG_COMPRESSED_FILE_EXTENSION = "gz" + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/LogUtil.kt b/app/src/main/kotlin/com/wire/android/util/logging/LogUtil.kt similarity index 96% rename from app/src/main/kotlin/com/wire/android/util/LogUtil.kt rename to app/src/main/kotlin/com/wire/android/util/logging/LogUtil.kt index 1907488de09..86e8f9456b8 100644 --- a/app/src/main/kotlin/com/wire/android/util/LogUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/logging/LogUtil.kt @@ -15,7 +15,7 @@ * 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 +package com.wire.android.util.logging import com.wire.android.appLogger import com.wire.kalium.logic.data.user.SelfUser diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index 06c2e88da58..8dad1772279 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -120,4 +120,6 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { ANALYTICS_APP_KEY("analytics_app_key", ConfigType.STRING), ANALYTICS_SERVER_URL("analytics_server_url", ConfigType.STRING), IS_MLS_RESET_ENABLED("is_mls_reset_enabled", ConfigType.BOOLEAN), + + USE_ASYNC_FLUSH_LOGGING("use_async_flush_logging", ConfigType.BOOLEAN), } diff --git a/default.json b/default.json index eaec19a5032..3a680d8e501 100644 --- a/default.json +++ b/default.json @@ -33,7 +33,8 @@ "analytics_app_key": "8ffae535f1836ed5f58fd5c8a11c00eca07c5438", "analytics_server_url": "https://wire.count.ly/", "enable_new_registration": true, - "channels_history_options_enabled": true + "channels_history_options_enabled": true, + "use_async_flush_logging" : true }, "staging": { "application_id": "com.waz.zclient.dev", @@ -78,7 +79,8 @@ "analytics_app_key": "8ffae535f1836ed5f58fd5c8a11c00eca07c5438", "analytics_server_url": "https://wire.count.ly/", "enable_new_registration": true, - "is_mls_reset_enabled": true + "is_mls_reset_enabled": true, + "use_async_flush_logging" : true }, "fdroid": { "application_id": "com.wire", @@ -141,6 +143,8 @@ "paginated_conversation_list_enabled": true, "should_display_release_notes": true, "public_channels_enabled": false, + "use_new_login_for_default_backend": true, + "use_async_flush_logging" : false, "channels_history_options_enabled": false, "use_new_login_for_default_backend": true, "enable_crossplatform_backup": true,