LogManager.kt (9150B) - raw
1 package me.rhunk.snapenhance 2 3 import android.util.Log 4 import com.google.gson.GsonBuilder 5 import me.rhunk.snapenhance.common.data.FileType 6 import me.rhunk.snapenhance.common.logger.AbstractLogger 7 import me.rhunk.snapenhance.common.logger.LogChannel 8 import me.rhunk.snapenhance.common.logger.LogLevel 9 import java.io.File 10 import java.io.OutputStream 11 import java.io.RandomAccessFile 12 import java.time.format.DateTimeFormatter 13 import java.util.zip.ZipEntry 14 import java.util.zip.ZipOutputStream 15 import kotlin.time.Duration.Companion.hours 16 17 class LogLine( 18 val logLevel: LogLevel, 19 val dateTime: String, 20 val tag: String, 21 val message: String 22 ) { 23 companion object { 24 fun fromString(line: String) = runCatching { 25 val parts = line.trimEnd().split("/") 26 if (parts.size != 4) return@runCatching null 27 LogLine( 28 LogLevel.fromLetter(parts[0]) ?: return@runCatching null, 29 parts[1], 30 parts[2], 31 parts[3] 32 ) 33 }.getOrNull() 34 } 35 36 override fun toString(): String { 37 return "${logLevel.letter}/$dateTime/$tag/$message" 38 } 39 } 40 41 42 class LogReader( 43 logFile: File 44 ) { 45 private val randomAccessFile = RandomAccessFile(logFile, "r") 46 private var startLineIndexes = mutableListOf<Long>() 47 var lineCount = queryLineCount() 48 49 private fun readLogLine(): LogLine? { 50 val lines = StringBuilder() 51 val lastPointer = randomAccessFile.filePointer 52 var lastChar: Int = -1 53 var bufferLength = 0 54 while (true) { 55 val char = randomAccessFile.read() 56 if (char == -1) { 57 randomAccessFile.seek(lastPointer) 58 return null 59 } 60 if ((char == '|'.code && lastChar == '\n'.code) || bufferLength > 4096) { 61 break 62 } 63 lines.append(char.toChar()) 64 bufferLength++ 65 lastChar = char 66 } 67 68 return LogLine.fromString(lines.trimEnd().toString()) 69 ?: LogLine(LogLevel.ERROR, "1970-01-01 00:00:00", "LogReader", "Failed to parse log line: $lines") 70 } 71 72 fun incrementLineCount() { 73 synchronized(randomAccessFile) { 74 randomAccessFile.seek(randomAccessFile.length()) 75 startLineIndexes.add(randomAccessFile.filePointer + 1) 76 lineCount++ 77 } 78 } 79 80 private fun queryLineCount(): Int { 81 val buffer = ByteArray(1024 * 1024) 82 83 synchronized(randomAccessFile) { 84 randomAccessFile.seek(0) 85 var lineCount = 0 86 var read: Int 87 var lastPointer: Long = 0 88 var line: StringBuilder? = null 89 90 while (randomAccessFile.read(buffer).also { read = it } != -1) { 91 for (i in 0 until read) { 92 val char = buffer[i].toInt().toChar() 93 if (line == null) { 94 line = StringBuilder() 95 lastPointer = randomAccessFile.filePointer - read + i 96 } 97 line.append(char) 98 if (char == '\n') { 99 if (line.startsWith('|')) { 100 lineCount++ 101 startLineIndexes.add(lastPointer + 1) 102 } 103 line = null 104 } 105 } 106 } 107 108 return lineCount 109 } 110 } 111 112 private fun getLine(index: Int): String? { 113 if (index <= 0 || index > lineCount) return null 114 synchronized(randomAccessFile) { 115 randomAccessFile.seek(startLineIndexes.getOrNull(index) ?: return null) 116 return readLogLine()?.toString() 117 } 118 } 119 120 fun getLogLine(index: Int): LogLine? { 121 return getLine(index)?.let { LogLine.fromString(it) } 122 } 123 } 124 125 126 class LogManager( 127 private val remoteSideContext: RemoteSideContext 128 ): AbstractLogger(LogChannel.MANAGER) { 129 companion object { 130 private val LOG_LIFETIME = 24.hours 131 } 132 133 private val printLogLock = Any() 134 private val anonymizeLogs by lazy { !remoteSideContext.config.root.scripting.disableLogAnonymization.get() } 135 136 var lineAddListener = { _: LogLine -> } 137 138 private val logFolder = File(remoteSideContext.androidContext.cacheDir, "logs") 139 private var logFile: File? = null 140 141 private val uuidRegex by lazy { Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", RegexOption.MULTILINE) } 142 private val contentUriRegex by lazy { Regex("content://[a-zA-Z0-9_\\-./]+") } 143 private val filePathRegex by lazy { Regex("([a-zA-Z0-9_\\-./]+)\\.(${FileType.entries.joinToString("|") { file -> file.fileExtension.toString() }})") } 144 145 fun init() { 146 if (!logFolder.exists()) { 147 logFolder.mkdirs() 148 } 149 logFile = remoteSideContext.sharedPreferences.getString("log_file", null)?.let { File(it) }?.takeIf { it.exists() } ?: run { 150 newLogFile() 151 logFile 152 } 153 154 if (System.currentTimeMillis() - remoteSideContext.sharedPreferences.getLong("last_created", 0) > LOG_LIFETIME.inWholeMilliseconds) { 155 newLogFile() 156 } 157 } 158 159 fun internalLog(tag: String, logLevel: LogLevel, message: Any?) { 160 synchronized(printLogLock) { 161 runCatching { 162 val anonymizedMessage = message.toString().let { 163 if (remoteSideContext.config.isInitialized() && anonymizeLogs) 164 it.replace(uuidRegex, "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") 165 .replace(contentUriRegex, "content://xxx") 166 .replace(filePathRegex, "xxxxxxxx.$2") 167 else it 168 } 169 val line = LogLine( 170 logLevel = logLevel, 171 dateTime = getCurrentDateTime(), 172 tag = tag, 173 message = anonymizedMessage 174 ) 175 logFile?.appendText("|$line\n", Charsets.UTF_8) 176 lineAddListener(line) 177 Log.println(logLevel.priority, tag, anonymizedMessage) 178 }.onFailure { 179 Log.println(Log.ERROR, tag, "Failed to log message: $message") 180 Log.println(Log.ERROR, tag, it.stackTraceToString()) 181 } 182 } 183 } 184 185 private fun getCurrentDateTime(pathSafe: Boolean = false): String { 186 return DateTimeFormatter.ofPattern(if (pathSafe) "yyyy-MM-dd_HH-mm-ss" else "yyyy-MM-dd HH:mm:ss").format( 187 java.time.LocalDateTime.now() 188 ) 189 } 190 191 private fun newLogFile() { 192 val currentTime = System.currentTimeMillis() 193 logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also { 194 it.createNewFile() 195 remoteSideContext.sharedPreferences.edit().putString("log_file", it.absolutePath).putLong("last_created", currentTime).apply() 196 } 197 } 198 199 fun clearLogs() { 200 logFolder.listFiles()?.forEach { it.delete() } 201 newLogFile() 202 } 203 204 fun exportLogsToZip(outputStream: OutputStream) { 205 val zipOutputStream = ZipOutputStream(outputStream).apply { 206 setMethod(ZipOutputStream.DEFLATED) 207 } 208 209 // add device info to zip 210 zipOutputStream.putNextEntry(ZipEntry("device_info.json")) 211 val gson = GsonBuilder().setPrettyPrinting().create() 212 zipOutputStream.write(gson.toJson(remoteSideContext.installationSummary).toByteArray()) 213 zipOutputStream.closeEntry() 214 215 // add config 216 zipOutputStream.putNextEntry(ZipEntry("config.json")) 217 zipOutputStream.write(remoteSideContext.config.exportToString(exportSensitiveData = false).toByteArray()) 218 zipOutputStream.closeEntry() 219 220 //add logFolder to zip 221 logFolder.walk().forEach { 222 if (it.isFile) { 223 zipOutputStream.putNextEntry(ZipEntry(it.name)) 224 it.inputStream().copyTo(zipOutputStream) 225 zipOutputStream.closeEntry() 226 } 227 } 228 229 zipOutputStream.close() 230 } 231 232 fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile!!).also { 233 lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) } 234 } 235 236 override fun debug(message: Any?, tag: String) { 237 internalLog(tag, LogLevel.DEBUG, message) 238 } 239 240 override fun error(message: Any?, tag: String) { 241 internalLog(tag, LogLevel.ERROR, message) 242 } 243 244 override fun error(message: Any?, throwable: Throwable, tag: String) { 245 internalLog(tag, LogLevel.ERROR, message) 246 internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString()) 247 } 248 249 override fun info(message: Any?, tag: String) { 250 internalLog(tag, LogLevel.INFO, message) 251 } 252 253 override fun verbose(message: Any?, tag: String) { 254 internalLog(tag, LogLevel.VERBOSE, message) 255 } 256 257 override fun warn(message: Any?, tag: String) { 258 internalLog(tag, LogLevel.WARN, message) 259 } 260 261 override fun assert(message: Any?, tag: String) { 262 internalLog(tag, LogLevel.ASSERT, message) 263 } 264 }