|
1 | 1 | package io.github.smiling_pixel.util |
2 | 2 |
|
| 3 | +import android.content.Intent |
| 4 | +import androidx.core.content.FileProvider |
3 | 5 | import android.util.Log |
| 6 | +import io.github.smiling_pixel.preference.AndroidContextProvider |
| 7 | +import java.io.File |
| 8 | +import java.text.SimpleDateFormat |
| 9 | +import java.util.Date |
| 10 | +import java.util.Locale |
4 | 11 |
|
5 | 12 | actual object Logger { |
6 | | - actual fun log(level: LogLevel, tag: String, message: String) { |
| 13 | + private const val MAX_LOG_BYTES = 1_048_576L |
| 14 | + private const val LOG_FILE_NAME = "markday.log" |
| 15 | + private const val PREVIOUS_LOG_FILE_NAME = "markday.previous.log" |
| 16 | + |
| 17 | + @Volatile |
| 18 | + private var minLogLevel: LogLevel = LogLevel.ERROR |
| 19 | + @Volatile |
| 20 | + private var isPersistenceEnabled: Boolean = false |
| 21 | + private val lock = Any() |
| 22 | + |
| 23 | + actual fun setLogLevel(level: LogLevel) { |
| 24 | + minLogLevel = level |
| 25 | + } |
| 26 | + |
| 27 | + actual fun getLogLevel(): LogLevel = minLogLevel |
| 28 | + |
| 29 | + actual fun setPersistenceEnabled(enabled: Boolean) { |
| 30 | + isPersistenceEnabled = enabled |
| 31 | + } |
| 32 | + |
| 33 | + actual fun isPersistenceEnabled(): Boolean = isPersistenceEnabled |
| 34 | + |
| 35 | + actual fun log(level: LogLevel, tag: String, message: String, throwable: Throwable?) { |
| 36 | + if (level.ordinal < minLogLevel.ordinal) return |
7 | 37 | when (level) { |
8 | | - LogLevel.DEBUG -> Log.d(tag, message) |
9 | | - LogLevel.INFO -> Log.i(tag, message) |
10 | | - LogLevel.WARN -> Log.w(tag, message) |
11 | | - LogLevel.ERROR -> Log.e(tag, message) |
| 38 | + LogLevel.DEBUG -> Log.d(tag, message, throwable) |
| 39 | + LogLevel.INFO -> Log.i(tag, message, throwable) |
| 40 | + LogLevel.WARN -> Log.w(tag, message, throwable) |
| 41 | + LogLevel.ERROR -> Log.e(tag, message, throwable) |
| 42 | + } |
| 43 | + if (isPersistenceEnabled) { |
| 44 | + persist(level, tag, message, throwable) |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + actual suspend fun exportPersistedLogs(): LogExportResult { |
| 49 | + return try { |
| 50 | + val context = AndroidContextProvider.context |
| 51 | + val content = synchronized(lock) { readPersistedLogs() } |
| 52 | + if (content.isBlank()) { |
| 53 | + return LogExportResult.NoLogs |
| 54 | + } |
| 55 | + |
| 56 | + val exportDir = File(context.cacheDir, "log_exports").also { it.mkdirs() } |
| 57 | + val exportFile = File(exportDir, timestampedExportFileName()) |
| 58 | + exportFile.writeText(content) |
| 59 | + |
| 60 | + val uri = FileProvider.getUriForFile( |
| 61 | + context, |
| 62 | + "${context.packageName}.fileprovider", |
| 63 | + exportFile |
| 64 | + ) |
| 65 | + val sendIntent = Intent(Intent.ACTION_SEND).apply { |
| 66 | + type = "text/plain" |
| 67 | + putExtra(Intent.EXTRA_STREAM, uri) |
| 68 | + putExtra(Intent.EXTRA_SUBJECT, "MarkDay logs") |
| 69 | + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) |
| 70 | + } |
| 71 | + val chooser = Intent.createChooser(sendIntent, "Export MarkDay logs").apply { |
| 72 | + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
| 73 | + } |
| 74 | + context.startActivity(chooser) |
| 75 | + |
| 76 | + LogExportResult.Success(exportFile.name, "Android share sheet") |
| 77 | + } catch (e: Exception) { |
| 78 | + LogExportResult.Failure(e.message ?: "Unable to export logs.") |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + actual suspend fun clearPersistedLogs() { |
| 83 | + synchronized(lock) { |
| 84 | + logFile().delete() |
| 85 | + previousLogFile().delete() |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + private fun persist(level: LogLevel, tag: String, message: String, throwable: Throwable?) { |
| 90 | + try { |
| 91 | + synchronized(lock) { |
| 92 | + val file = logFile() |
| 93 | + file.parentFile?.mkdirs() |
| 94 | + if (file.length() >= MAX_LOG_BYTES) { |
| 95 | + previousLogFile().delete() |
| 96 | + if (!file.renameTo(previousLogFile())) { |
| 97 | + file.writeText("") |
| 98 | + } |
| 99 | + } |
| 100 | + file.appendText(formatLine(level, tag, message, throwable)) |
| 101 | + } |
| 102 | + } catch (e: Exception) { |
| 103 | + // Logging must never fail the app or recursively log logger-internal errors. |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + private fun readPersistedLogs(): String { |
| 108 | + val previous = previousLogFile().takeIf { it.exists() }?.readText().orEmpty() |
| 109 | + val current = logFile().takeIf { it.exists() }?.readText().orEmpty() |
| 110 | + return previous + current |
| 111 | + } |
| 112 | + |
| 113 | + private fun formatLine(level: LogLevel, tag: String, message: String, throwable: Throwable?): String { |
| 114 | + val fullMessage = if (throwable != null) { |
| 115 | + "$message\n${throwable.stackTraceToString()}" |
| 116 | + } else { |
| 117 | + message |
12 | 118 | } |
| 119 | + return "${timestamp()} [$level] $tag: $fullMessage\n" |
| 120 | + } |
| 121 | + |
| 122 | + private fun timestamp(): String = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US).format(Date()) |
| 123 | + |
| 124 | + private fun timestampedExportFileName(): String { |
| 125 | + val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) |
| 126 | + return "markday-log-$timestamp.txt" |
13 | 127 | } |
| 128 | + |
| 129 | + private fun logsDir(): File = File(AndroidContextProvider.context.filesDir, "logs") |
| 130 | + |
| 131 | + private fun logFile(): File = File(logsDir(), LOG_FILE_NAME) |
| 132 | + |
| 133 | + private fun previousLogFile(): File = File(logsDir(), PREVIOUS_LOG_FILE_NAME) |
14 | 134 | } |
0 commit comments