From 65013cf940e0ff07eec251070da50c546fed09be Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 26 May 2026 01:04:00 +0200 Subject: [PATCH 1/4] refactor: polish logs helpers for #963 --- .../java/to/bitkit/repositories/LogsRepo.kt | 180 ++++++++++-------- app/src/main/java/to/bitkit/utils/Logger.kt | 170 ++++++++++------- .../to/bitkit/repositories/LogsRepoTest.kt | 130 +++++++++++++ .../java/to/bitkit/utils/LogSaverImplTest.kt | 85 +++++++++ changelog.d/next/947.fixed.md | 1 + changelog.d/next/support-logs.fixed.md | 1 - 6 files changed, 418 insertions(+), 149 deletions(-) create mode 100644 app/src/test/java/to/bitkit/repositories/LogsRepoTest.kt create mode 100644 app/src/test/java/to/bitkit/utils/LogSaverImplTest.kt create mode 100644 changelog.d/next/947.fixed.md delete mode 100644 changelog.d/next/support-logs.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt index 621ee9bd40..7ff4814820 100644 --- a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt @@ -44,7 +44,7 @@ class LogsRepo @Inject constructor( suspend fun postQuestion(email: String, message: String): Result = withContext(bgDispatcher) { runCatching { val logsBase64 = zipLogs(maxEncodedBytes = MAX_SUPPORT_UPLOAD_BASE64_BYTES).getOrDefault("") - val logsFileName = createLogsArchiveFileName(SUPPORT_LOGS_ARCHIVE_PREFIX) + val logsArchiveBaseName = currentLogsArchiveName(SUPPORT_LOGS_ARCHIVE_PREFIX).baseName chatwootHttpClient.postQuestion( message = ChatwootMessage( @@ -53,7 +53,7 @@ class LogsRepo @Inject constructor( platform = Env.platform, version = Env.version, logs = logsBase64, - logsFileName = logsFileName, + logsFileName = logsArchiveBaseName, ) ) }.onFailure { @@ -108,7 +108,7 @@ class LogsRepo @Inject constructor( val file = withContext(ioDispatcher) { val tempDir = context.cacheDir.resolve("logs").apply { mkdirs() } - val zipFileName = createLogsArchiveFileName() + val zipFileName = currentLogsArchiveName().fileName val tempFile = File(tempDir, zipFileName) // Convert base64 back to bytes and write to file @@ -143,74 +143,12 @@ class LogsRepo @Inject constructor( allLogs.take(limit) } - return@runCatching createZipBase64(logsToZip, maxEncodedBytes) + return@runCatching createZipBase64(logsToZip, maxEncodedBytes, ::createSupportSnapshot) }.onFailure { Logger.error("Failed to zip logs", it, context = TAG) } } - @Suppress("NestedBlockDepth") - private fun createZipBase64(logFiles: List, maxEncodedBytes: Int?): String { - val selectedLogFiles = logFiles.toMutableList() - - while (true) { - val encoded = createZipBytes(selectedLogFiles).toBase64() - if (maxEncodedBytes == null || encoded.length <= maxEncodedBytes || selectedLogFiles.isEmpty()) { - Logger.info("Created support logs archive with '${selectedLogFiles.size}' log file(s)", context = TAG) - return encoded - } - - selectedLogFiles.removeAt(selectedLogFiles.lastIndex) - } - } - - @Suppress("NestedBlockDepth") - private fun createZipBytes(logFiles: List): ByteArray { - return ByteArrayOutputStream().use { byteArrayOut -> - ZipOutputStream(byteArrayOut).use { zipOut -> - zipOut.putNextEntry(ZipEntry(SUPPORT_SNAPSHOT_FILE_NAME)) - zipOut.write(createSupportSnapshot().toByteArray()) - zipOut.closeEntry() - - logFiles.forEach { logFile -> - if (logFile.file.exists()) { - val zipEntry = ZipEntry("${logFile.source.name.lowercase()}/${logFile.fileName}") - zipOut.putNextEntry(zipEntry) - - FileInputStream(logFile.file).use { fileIn -> - fileIn.copyTo(zipOut) - } - zipOut.closeEntry() - } - } - } - byteArrayOut.toByteArray() - } - } - - private fun File.toLogFile(): LogFile { - val match = LOG_FILE_NAME_REGEX.matchEntire(name) - val serviceName = match - ?.groupValues - ?.getOrNull(1) - ?.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } - ?: LogSource.Unknown.name - val timestamp = match?.groupValues?.getOrNull(2)?.replace("_", " ") - val part = match?.groupValues?.getOrNull(3)?.ifBlank { null } - val partSuffix = part?.let { " part $it" }.orEmpty() - val displayName = if (timestamp != null) { - "$serviceName Log: $timestamp$partSuffix" - } else { - "$serviceName Log: $name" - } - - return LogFile( - displayName = displayName, - file = this, - source = getEnumValueOf(serviceName).getOrDefault(LogSource.Unknown), - ) - } - private fun createSupportSnapshot(): String { val state = lightningRepo.lightningState.value val snapshot = SupportSnapshot( @@ -262,26 +200,86 @@ class LogsRepo @Inject constructor( return appJson.encodeToString(snapshot) } - private fun createLogsArchiveFileName(prefix: String = LOGS_ARCHIVE_PREFIX): String { - return "${prefix}_${currentLogTimestamp()}.zip" + private fun currentLogsArchiveName(prefix: String = LOGS_ARCHIVE_PREFIX): LogsArchiveName { + return createLogsArchiveName(prefix, currentLogTimestamp()) } private fun currentLogTimestamp(): String { return utcDateFormatterOf(DatePattern.LOG_FILE).format(Date()) } +} - private companion object { - const val TAG = "SupportRepo" - const val LOGS_ARCHIVE_PREFIX = "bitkit_logs" - const val MAX_SUPPORT_UPLOAD_BASE64_BYTES = 900 * 1024 - const val SUPPORT_LOGS_ARCHIVE_PREFIX = "bitkit_support_logs" - const val SUPPORT_SNAPSHOT_FILE_NAME = "support_snapshot.json" - val LOG_FILE_NAME_REGEX = Regex( - "^([A-Za-z]+)_(\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2})(?:\\.part_(\\d{3}))?\\.log$" - ) +@Suppress("NestedBlockDepth") +internal fun createZipBase64( + logFiles: List, + maxEncodedBytes: Int?, + supportSnapshot: () -> String, +): String { + val selectedLogFiles = logFiles.toMutableList() + + while (true) { + val encoded = createZipBytes(selectedLogFiles, supportSnapshot).toBase64() + if (maxEncodedBytes == null || encoded.length <= maxEncodedBytes || selectedLogFiles.isEmpty()) { + Logger.info("Created support logs archive with '${selectedLogFiles.size}' log file(s)", context = TAG) + return encoded + } + + selectedLogFiles.removeAt(selectedLogFiles.lastIndex) } } +internal fun createZipBytes( + logFiles: List, + supportSnapshot: () -> String, +): ByteArray { + return ByteArrayOutputStream().use { byteArrayOut -> + ZipOutputStream(byteArrayOut).use { zipOut -> + zipOut.writeSupportSnapshot(supportSnapshot()) + logFiles.filter { it.file.exists() }.forEach { logFile -> + zipOut.writeLogFile(logFile) + } + } + byteArrayOut.toByteArray() + } +} + +private fun ZipOutputStream.writeSupportSnapshot(supportSnapshot: String) { + putNextEntry(ZipEntry(SUPPORT_SNAPSHOT_FILE_NAME)) + write(supportSnapshot.toByteArray()) + closeEntry() +} + +private fun ZipOutputStream.writeLogFile(logFile: LogFile) { + putNextEntry(ZipEntry("${logFile.source.name.lowercase()}/${logFile.fileName}")) + FileInputStream(logFile.file).use { fileIn -> + fileIn.copyTo(this) + } + closeEntry() +} + +internal fun File.toLogFile(): LogFile { + val match = LOG_FILE_NAME_REGEX.matchEntire(name) + val serviceName = match + ?.groupValues + ?.getOrNull(1) + ?.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + ?: LogSource.Unknown.name + val timestamp = match?.groupValues?.getOrNull(2)?.replace("_", " ") + val part = match?.groupValues?.getOrNull(3)?.ifBlank { null } + val partSuffix = part?.let { " part $it" }.orEmpty() + val displayName = if (timestamp != null) { + "$serviceName Log: $timestamp$partSuffix" + } else { + "$serviceName Log: $name" + } + + return LogFile( + displayName = displayName, + file = this, + source = getEnumValueOf(serviceName).getOrDefault(LogSource.Unknown), + ) +} + data class LogFile( val displayName: String, val file: File, @@ -290,6 +288,24 @@ data class LogFile( val fileName: String get() = file.name } +internal data class LogsArchiveName( + val baseName: String, +) { + val fileName: String get() = "$baseName$ZIP_EXTENSION" +} + +internal fun createLogsArchiveName(prefix: String, timestamp: String): LogsArchiveName { + return LogsArchiveName("${prefix}_$timestamp".withoutZipExtension()) +} + +internal fun String.withoutZipExtension(): String { + var name = this + while (name.endsWith(ZIP_EXTENSION, ignoreCase = true)) { + name = name.dropLast(ZIP_EXTENSION.length) + } + return name +} + private fun NodeLifecycleState.supportName(): String = when (this) { is NodeLifecycleState.Stopped -> "Stopped" is NodeLifecycleState.Starting -> "Starting" @@ -348,3 +364,13 @@ private data class SupportBalanceSnapshot( val lightningBalancesCount: Int, val pendingChannelClosureBalancesCount: Int, ) + +private const val TAG = "SupportRepo" +private const val LOGS_ARCHIVE_PREFIX = "bitkit_logs" +private const val MAX_SUPPORT_UPLOAD_BASE64_BYTES = 900 * 1024 +private const val SUPPORT_LOGS_ARCHIVE_PREFIX = "bitkit_support_logs" +private const val SUPPORT_SNAPSHOT_FILE_NAME = "support_snapshot.json" +private const val ZIP_EXTENSION = ".zip" +private val LOG_FILE_NAME_REGEX = Regex( + "^([A-Za-z]+)_(\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2})(?:\\.part_(\\d{3}))?\\.log$" +) diff --git a/app/src/main/java/to/bitkit/utils/Logger.kt b/app/src/main/java/to/bitkit/utils/Logger.kt index f117942b02..de02afaf49 100644 --- a/app/src/main/java/to/bitkit/utils/Logger.kt +++ b/app/src/main/java/to/bitkit/utils/Logger.kt @@ -239,7 +239,11 @@ class LogSaverImpl( runCatching { val sanitized = message.replace("\n", " ") val bytes = "$sanitized\n".toByteArray() - val file = getWritableLogFile(bytes.size) + val file = getWritableLogFile( + sessionFilePath = sessionFilePath, + currentLogFilePath = currentLogFilePath, + nextWriteBytes = bytes.size, + ) val previousLogFilePath = currentLogFilePath currentLogFilePath = file.absolutePath @@ -258,43 +262,6 @@ class LogSaverImpl( } } - private fun getWritableLogFile(nextWriteBytes: Int): File { - val sessionFile = File(sessionFilePath) - val sessionDir = sessionFile.parentFile ?: Env.logDir - val sessionName = sessionFile.nameWithoutExtension - - sessionDir.mkdirs() - - val currentFile = currentLogFilePath - ?.let(::File) - ?.takeIf { - it.parentFile?.absolutePath == sessionDir.absolutePath && - it.nameWithoutExtension.startsWith(sessionName) - } - - if (currentFile != null && currentFile.hasRoomFor(nextWriteBytes)) { - return currentFile - } - - var file = currentFile ?: sessionFile - var part = file.logPartNumber(sessionFile) - while (file.exists() && file.length() + nextWriteBytes > MAX_LOG_FILE_BYTES) { - part += 1 - file = sessionDir.resolve("$sessionName.part_${part.toString().padStart(3, '0')}.$LOG_FILE_EXTENSION") - } - - return file - } - - private fun File.hasRoomFor(nextWriteBytes: Int): Boolean { - return !exists() || length() + nextWriteBytes <= MAX_LOG_FILE_BYTES - } - - private fun File.logPartNumber(sessionFile: File): Int { - if (absolutePath == sessionFile.absolutePath) return 1 - return nameWithoutExtension.substringAfter(".part_", "1").toIntOrNull() ?: 1 - } - private fun log( message: String, level: LogLevel = LogLevel.INFO, @@ -313,39 +280,13 @@ class LogSaverImpl( log("Enforcing log retention limits…", LogLevel.VERBOSE, Log::v) val logDir = runCatching { Env.logDir }.getOrNull() ?: return - val logFiles = logDir - .listFiles { file -> file.extension == LOG_FILE_EXTENSION } - ?: return - - val activeLogFilePath = activeLogFile?.absolutePath - val removableFiles = logFiles.filterNot { it.absolutePath == activeLogFilePath } - val oldestAllowedModifiedAt = System.currentTimeMillis() - maxAgeDays * MILLIS_IN_DAY - - removableFiles - .sortedBy { it.lastModified() } - .forEach { file -> - if (file.lastModified() < oldestAllowedModifiedAt) { - deleteLogFile(file) - } - } - - var totalSize = logDir - .listFiles { file -> file.extension == LOG_FILE_EXTENSION } - ?.sumOf { it.length() } - ?: return - - logDir - .listFiles { file -> file.extension == LOG_FILE_EXTENSION } - ?.filterNot { it.absolutePath == activeLogFilePath } - ?.sortedBy { it.lastModified() } - ?.forEach { file -> - if (totalSize <= maxTotalSizeBytes) return@forEach - - val fileSize = file.length() - if (deleteLogFile(file)) { - totalSize -= fileSize - } - } + enforceLogRetentionLimits( + logDir = logDir, + maxTotalSizeBytes = maxTotalSizeBytes, + maxAgeDays = maxAgeDays, + activeLogFile = activeLogFile, + deleteLogFile = ::deleteLogFile, + ) log("Enforced log retention limits.", LogLevel.VERBOSE, Log::v) } @@ -364,6 +305,93 @@ class LogSaverImpl( } } +internal fun getWritableLogFile( + sessionFilePath: String, + currentLogFilePath: String?, + nextWriteBytes: Int, + logDir: File? = null, + maxLogFileBytes: Long = MAX_LOG_FILE_BYTES, +): File { + val sessionFile = File(sessionFilePath) + val sessionDir = sessionFile.parentFile ?: logDir ?: Env.logDir + val sessionName = sessionFile.nameWithoutExtension + + sessionDir.mkdirs() + + val currentFile = currentLogFilePath + ?.let(::File) + ?.takeIf { + it.parentFile?.absolutePath == sessionDir.absolutePath && + it.nameWithoutExtension.startsWith(sessionName) + } + + if (currentFile != null && currentFile.hasRoomFor(nextWriteBytes, maxLogFileBytes)) { + return currentFile + } + + var file = currentFile ?: sessionFile + var part = file.logPartNumber(sessionFile) + while (file.exists() && file.length() + nextWriteBytes > maxLogFileBytes) { + part += 1 + file = sessionDir.resolve("$sessionName.part_${part.toString().padStart(3, '0')}.$LOG_FILE_EXTENSION") + } + + return file +} + +@Suppress("LongParameterList") +internal fun enforceLogRetentionLimits( + logDir: File, + maxTotalSizeBytes: Long = MAX_LOG_RETENTION_BYTES, + maxAgeDays: Long = MAX_LOG_RETENTION_DAYS, + activeLogFile: File? = null, + nowMillis: Long = System.currentTimeMillis(), + deleteLogFile: (File) -> Boolean = { it.delete() }, +) { + val logFiles = logDir + .listFiles { file -> file.extension == LOG_FILE_EXTENSION } + ?: return + + val activeLogFilePath = activeLogFile?.absolutePath + val removableFiles = logFiles.filterNot { it.absolutePath == activeLogFilePath } + val oldestAllowedModifiedAt = nowMillis - maxAgeDays * MILLIS_IN_DAY + + removableFiles + .sortedBy { it.lastModified() } + .forEach { file -> + if (file.lastModified() < oldestAllowedModifiedAt) { + deleteLogFile(file) + } + } + + var totalSize = logDir + .listFiles { file -> file.extension == LOG_FILE_EXTENSION } + ?.sumOf { it.length() } + ?: return + + logDir + .listFiles { file -> file.extension == LOG_FILE_EXTENSION } + ?.filterNot { it.absolutePath == activeLogFilePath } + ?.sortedBy { it.lastModified() } + ?.forEach { file -> + if (totalSize <= maxTotalSizeBytes) return@forEach + + val fileSize = file.length() + if (deleteLogFile(file)) { + totalSize -= fileSize + } + } +} + +private fun File.hasRoomFor(nextWriteBytes: Int, maxLogFileBytes: Long): Boolean { + return !exists() || length() + nextWriteBytes <= maxLogFileBytes +} + +private fun File.logPartNumber(sessionFile: File): Int { + if (absolutePath == sessionFile.absolutePath) return 1 + return nameWithoutExtension.substringAfter(".part_", "1").toIntOrNull() ?: 1 +} + private fun buildSessionLogFilePath(): String { val logDir = Env.logDir val timestamp = utcDateFormatterOf(DatePattern.LOG_FILE).format(Date()) diff --git a/app/src/test/java/to/bitkit/repositories/LogsRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LogsRepoTest.kt new file mode 100644 index 0000000000..620d1d7824 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/LogsRepoTest.kt @@ -0,0 +1,130 @@ +package to.bitkit.repositories + +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import to.bitkit.ext.fromBase64 +import to.bitkit.utils.LogSource +import java.io.ByteArrayInputStream +import java.io.File +import java.util.zip.ZipInputStream +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class LogsRepoTest { + private companion object { + const val SUPPORT_SNAPSHOT = """{"generatedAt":"test"}""" + } + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `toLogFile parses display name source and part suffix`() { + val file = tempFolder.newFile("bitkit_2026-05-22_12-15-00.part_002.log") + + val logFile = file.toLogFile() + + assertEquals("Bitkit Log: 2026-05-22 12-15-00 part 002", logFile.displayName) + assertEquals(LogSource.Bitkit, logFile.source) + assertEquals(file, logFile.file) + } + + @Test + fun `toLogFile falls back to unknown for unmatched file name`() { + val file = tempFolder.newFile("notes.txt") + + val logFile = file.toLogFile() + + assertEquals("Unknown Log: notes.txt", logFile.displayName) + assertEquals(LogSource.Unknown, logFile.source) + } + + @Test + fun `archive name exposes upload base name and local file name`() { + val archiveName = createLogsArchiveName( + prefix = "bitkit_logs", + timestamp = "1779326719473", + ) + + assertEquals("bitkit_logs_1779326719473", archiveName.baseName) + assertEquals("bitkit_logs_1779326719473.zip", archiveName.fileName) + } + + @Test + fun `archive name collapses repeated zip suffixes`() { + val archiveName = LogsArchiveName("bitkit_logs_1779326719473.zip.zip".withoutZipExtension()) + + assertEquals("bitkit_logs_1779326719473", archiveName.baseName) + assertEquals("bitkit_logs_1779326719473.zip", archiveName.fileName) + } + + @Test + fun `archive name collapses mixed case zip suffixes`() { + val archiveName = LogsArchiveName("bitkit_support_logs_2026-05-22_12-15-00.ZIP.zip".withoutZipExtension()) + + assertEquals("bitkit_support_logs_2026-05-22_12-15-00", archiveName.baseName) + assertEquals("bitkit_support_logs_2026-05-22_12-15-00.zip", archiveName.fileName) + } + + @Test + fun `createZipBase64 drops oldest logs first to fit limit`() { + val newestFile = createLogFile("bitkit_2026-05-22_12-15-00.log", seed = 1) + val middleFile = createLogFile("bitkit_2026-05-22_12-14-00.log", seed = 2) + val oldestFile = createLogFile("bitkit_2026-05-22_12-13-00.log", seed = 3) + val newestLog = newestFile.bitkitLogFile() + val middleLog = middleFile.bitkitLogFile() + val oldestLog = oldestFile.bitkitLogFile() + val maxEncodedBytes = createZipBase64(listOf(newestLog), null) { SUPPORT_SNAPSHOT }.length + + val encoded = createZipBase64( + logFiles = listOf(newestLog, middleLog, oldestLog), + maxEncodedBytes = maxEncodedBytes, + supportSnapshot = { SUPPORT_SNAPSHOT }, + ) + + assertEquals( + listOf( + "support_snapshot.json", + "bitkit/bitkit_2026-05-22_12-15-00.log", + ), + encoded.zipEntryNames(), + ) + } + + @Test + fun `createZipBase64 keeps snapshot when logs are empty and over limit`() { + val encoded = createZipBase64( + logFiles = emptyList(), + maxEncodedBytes = 1, + supportSnapshot = { SUPPORT_SNAPSHOT }, + ) + + assertEquals(listOf("support_snapshot.json"), encoded.zipEntryNames()) + } + + private fun createLogFile(name: String, seed: Int): File { + return tempFolder.newFile(name).apply { + writeBytes(Random(seed).nextBytes(16 * 1024)) + } + } + + private fun File.bitkitLogFile() = LogFile( + displayName = name, + file = this, + source = LogSource.Bitkit, + ) + + private fun String.zipEntryNames(): List { + val names = mutableListOf() + ZipInputStream(ByteArrayInputStream(fromBase64())).use { zipIn -> + var entry = zipIn.nextEntry + while (entry != null) { + names += entry.name + zipIn.closeEntry() + entry = zipIn.nextEntry + } + } + return names + } +} diff --git a/app/src/test/java/to/bitkit/utils/LogSaverImplTest.kt b/app/src/test/java/to/bitkit/utils/LogSaverImplTest.kt new file mode 100644 index 0000000000..7f1c800b35 --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/LogSaverImplTest.kt @@ -0,0 +1,85 @@ +package to.bitkit.utils + +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LogSaverImplTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `getWritableLogFile increments from full current part`() { + val logDir = tempFolder.newFolder() + val sessionFile = logDir.logFile("bitkit_2026-05-22_12-15-00.log", size = 10) + val currentPart = logDir.logFile("bitkit_2026-05-22_12-15-00.part_002.log", size = 10) + + val file = getWritableLogFile( + sessionFilePath = sessionFile.absolutePath, + currentLogFilePath = currentPart.absolutePath, + nextWriteBytes = 1, + logDir = logDir, + maxLogFileBytes = 10, + ) + + assertEquals("bitkit_2026-05-22_12-15-00.part_003.log", file.name) + } + + @Test + fun `getWritableLogFile recovers from stale current path`() { + val logDir = tempFolder.newFolder() + val staleDir = tempFolder.newFolder() + val sessionFile = logDir.logFile("bitkit_2026-05-22_12-15-00.log", size = 10) + val stalePart = staleDir.resolve("bitkit_2026-05-22_12-15-00.part_009.log") + + val file = getWritableLogFile( + sessionFilePath = sessionFile.absolutePath, + currentLogFilePath = stalePart.absolutePath, + nextWriteBytes = 1, + logDir = logDir, + maxLogFileBytes = 10, + ) + + assertEquals("bitkit_2026-05-22_12-15-00.part_002.log", file.name) + } + + @Test + fun `enforceLogRetentionLimits deletes old and oversized logs without deleting active file`() { + val nowMillis = 1_000_000_000L + val logDir = tempFolder.newFolder() + val activeFile = logDir.logFile("active.log", size = 80, lastModifiedAt = nowMillis - 3.daysInMillis()) + val expiredFile = logDir.logFile("expired.log", size = 5, lastModifiedAt = nowMillis - 3.daysInMillis()) + val oldestFile = logDir.logFile("oldest.log", size = 50, lastModifiedAt = nowMillis - 1_000) + val newestFile = logDir.logFile("newest.log", size = 20, lastModifiedAt = nowMillis) + + enforceLogRetentionLimits( + logDir = logDir, + maxTotalSizeBytes = 100, + maxAgeDays = 1, + activeLogFile = activeFile, + nowMillis = nowMillis, + ) + + assertTrue(activeFile.exists()) + assertFalse(expiredFile.exists()) + assertFalse(oldestFile.exists()) + assertTrue(newestFile.exists()) + } + + private fun File.logFile( + name: String, + size: Int, + lastModifiedAt: Long = 1_000, + ): File { + return resolve(name).apply { + writeBytes(ByteArray(size) { it.toByte() }) + assertTrue(setLastModified(lastModifiedAt)) + } + } + + private fun Int.daysInMillis(): Long = this * 24L * 60L * 60L * 1000L +} diff --git a/changelog.d/next/947.fixed.md b/changelog.d/next/947.fixed.md new file mode 100644 index 0000000000..bc4177f00d --- /dev/null +++ b/changelog.d/next/947.fixed.md @@ -0,0 +1 @@ +Improved logs, support diagnostics and channel peer recovery after wallet restore. diff --git a/changelog.d/next/support-logs.fixed.md b/changelog.d/next/support-logs.fixed.md deleted file mode 100644 index 6284c60158..0000000000 --- a/changelog.d/next/support-logs.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Improved support log handling so diagnostics stay smaller and include more useful Lightning connection details. From d7960105955c72eb5a5006fc76e611c6f2d6a174 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 26 May 2026 01:27:47 +0200 Subject: [PATCH 2/4] chore: fix detekt import order --- .../java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt | 2 +- app/src/main/java/to/bitkit/services/MigrationService.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt index 56e5635227..093621a8c8 100644 --- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt @@ -26,12 +26,12 @@ import to.bitkit.data.dto.price.TradingPair import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlocksPreferences import to.bitkit.models.widget.BlocksWidgetField -import to.bitkit.models.widget.toggleField import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.models.widget.PricePreferences import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences import to.bitkit.models.widget.toArticleModel +import to.bitkit.models.widget.toggleField import to.bitkit.repositories.CurrencyRepo import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.screens.widgets.blocks.toWeatherModel diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 416fb60266..ba2b1ae635 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -58,11 +58,11 @@ import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.toSettingsString import to.bitkit.models.widget.BlocksPreferences -import to.bitkit.models.widget.limitedToMax import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.models.widget.PricePreferences import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences +import to.bitkit.models.widget.limitedToMax import to.bitkit.repositories.ActivityRepo import to.bitkit.services.core.Bip39Service import to.bitkit.utils.AppError From 79718a13f453b98145e452b3ef693b4dbbb023aa Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 26 May 2026 01:37:26 +0200 Subject: [PATCH 3/4] chore: rename changelog fragment --- changelog.d/next/{947.fixed.md => 969.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{947.fixed.md => 969.fixed.md} (100%) diff --git a/changelog.d/next/947.fixed.md b/changelog.d/next/969.fixed.md similarity index 100% rename from changelog.d/next/947.fixed.md rename to changelog.d/next/969.fixed.md From 729c1c3a12c84c07b972a637213e89f003ab9d5b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 26 May 2026 19:18:17 +0200 Subject: [PATCH 4/4] fix: address logs review comments --- app/src/main/java/to/bitkit/repositories/LogsRepo.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt index 7ff4814820..113e6f771b 100644 --- a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt @@ -209,7 +209,6 @@ class LogsRepo @Inject constructor( } } -@Suppress("NestedBlockDepth") internal fun createZipBase64( logFiles: List, maxEncodedBytes: Int?, @@ -365,7 +364,7 @@ private data class SupportBalanceSnapshot( val pendingChannelClosureBalancesCount: Int, ) -private const val TAG = "SupportRepo" +private const val TAG = "LogsRepo" private const val LOGS_ARCHIVE_PREFIX = "bitkit_logs" private const val MAX_SUPPORT_UPLOAD_BASE64_BYTES = 900 * 1024 private const val SUPPORT_LOGS_ARCHIVE_PREFIX = "bitkit_support_logs"