Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 103 additions & 77 deletions app/src/main/java/to/bitkit/repositories/LogsRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class LogsRepo @Inject constructor(
suspend fun postQuestion(email: String, message: String): Result<Unit> = 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(
Expand All @@ -53,7 +53,7 @@ class LogsRepo @Inject constructor(
platform = Env.platform,
version = Env.version,
logs = logsBase64,
logsFileName = logsFileName,
logsFileName = logsArchiveBaseName,
)
)
}.onFailure {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<LogFile>, 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<LogFile>): 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<LogSource>(serviceName).getOrDefault(LogSource.Unknown),
)
}

private fun createSupportSnapshot(): String {
val state = lightningRepo.lightningState.value
val snapshot = SupportSnapshot(
Expand Down Expand Up @@ -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<LogFile>,
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<LogFile>,
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<LogSource>(serviceName).getOrDefault(LogSource.Unknown),
)
}

data class LogFile(
val displayName: String,
val file: File,
Expand All @@ -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"
Expand Down Expand Up @@ -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$"
)
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/services/MigrationService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading