1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-22 00:06:48 +02:00

Safeguard LogManager against concurrent file access

This commit is contained in:
oliexdev
2025-08-19 19:11:00 +02:00
parent 5f9d8f95dc
commit f29bd962d2
2 changed files with 120 additions and 71 deletions

View File

@@ -57,10 +57,8 @@ object LogManager {
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
// Tracks foreground Activities to detect when the app goes to background. @Volatile
private val startedActivityCount = AtomicInteger(0) private var isMarkdownBlockOpen = false
@Volatile private var lifecycleCallbacksRegistered = false
@Volatile private var isMarkdownBlockOpen = false
/** /**
* Initializes the LogManager. Must be called once, typically in Application.onCreate(). * Initializes the LogManager. Must be called once, typically in Application.onCreate().
@@ -78,37 +76,14 @@ object LogManager {
logToFileEnabled = enableLoggingToFile logToFileEnabled = enableLoggingToFile
isInitialized = true isInitialized = true
// Register ActivityLifecycleCallbacks to close the markdown block when app goes background.
if (!lifecycleCallbacksRegistered && appContext is Application) {
(appContext as Application).registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityStarted(activity: Activity) {
val count = startedActivityCount.incrementAndGet()
if (count == 1) {
// App went to foreground -> open the diff block to keep Markdown well-formed.
openMarkdownBlockIfNeeded()
}
}
override fun onActivityStopped(activity: Activity) {
if (startedActivityCount.decrementAndGet() == 0) {
// App went to background -> close the diff block to keep Markdown well-formed.
closeMarkdownBlock()
}
}
// Unused callbacks keep empty
override fun onActivityCreated(a: Activity, b: Bundle?) {}
override fun onActivityResumed(a: Activity) {}
override fun onActivityPaused(a: Activity) {}
override fun onActivitySaveInstanceState(a: Activity, outState: Bundle) {}
override fun onActivityDestroyed(a: Activity) {}
})
lifecycleCallbacksRegistered = true
}
// Log initialization status. // Log initialization status.
if (logToFileEnabled) { if (logToFileEnabled) {
coroutineScope.launch { coroutineScope.launch {
resetLogFileOnStartup() resetLogFileOnStartup()
i(TAG, "LogManager initialized. Logging to file: enabled. Log directory: ${getLogDirectory().absolutePath}") i(
TAG,
"LogManager initialized. Logging to file: enabled. Log directory: ${getLogDirectory().absolutePath}"
)
} }
} else { } else {
i(TAG, "LogManager initialized. Logging to file: disabled.") i(TAG, "LogManager initialized. Logging to file: disabled.")
@@ -150,13 +125,16 @@ object LogManager {
closeMarkdownBlock() closeMarkdownBlock()
} }
i(TAG, "File logging preference updated to: $logToFileEnabled (was: $oldState)") i(TAG, "File logging preference updated to: $logToFileEnabled (was: $oldState)")
if (logToFileEnabled) { // Only act if newly enabled if (logToFileEnabled) {
coroutineScope.launch { coroutineScope.launch {
val currentLogFile = File(getLogDirectory(), "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION") try { closeMarkdownBlock() } catch (_: Exception) {}
if (!currentLogFile.exists() || currentLogFile.length() == 0L) { val file = File(getLogDirectory(), "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION")
d(TAG, "Log file missing or empty after enabling file logging. Writing headers.") if (file.exists()) {
writeInitialLogHeaders(currentLogFile) file.delete()
} }
writeInitialLogHeaders(file)
isMarkdownBlockOpen = true
i(TAG, "File logging enabled during runtime started fresh session log.")
} }
} }
} }
@@ -172,7 +150,10 @@ object LogManager {
if (externalLogDir != null) { if (externalLogDir != null) {
if (!externalLogDir.exists()) { if (!externalLogDir.exists()) {
if (!externalLogDir.mkdirs()) { if (!externalLogDir.mkdirs()) {
w(TAG, "Failed to create external log directory: ${externalLogDir.absolutePath}. Attempting internal storage.") w(
TAG,
"Failed to create external log directory: ${externalLogDir.absolutePath}. Attempting internal storage."
)
// Fall through to internal storage if mkdirs fails // Fall through to internal storage if mkdirs fails
} else { } else {
d(TAG, "External log directory created: ${externalLogDir.absolutePath}") d(TAG, "External log directory created: ${externalLogDir.absolutePath}")
@@ -187,28 +168,48 @@ object LogManager {
if (!internalLogDir.exists()) { if (!internalLogDir.exists()) {
if (!internalLogDir.mkdirs()) { if (!internalLogDir.mkdirs()) {
// If internal storage also fails, this is a more serious issue. // If internal storage also fails, this is a more serious issue.
e(TAG, "Failed to create internal log directory: ${internalLogDir.absolutePath}. Logging to file may not work.") e(
TAG,
"Failed to create internal log directory: ${internalLogDir.absolutePath}. Logging to file may not work."
)
} else { } else {
d(TAG, "Internal log directory created: ${internalLogDir.absolutePath}") d(TAG, "Internal log directory created: ${internalLogDir.absolutePath}")
} }
} }
// Log this fallback case if externalLogDir was null initially // Log this fallback case if externalLogDir was null initially
if (externalLogDir == null) { if (externalLogDir == null) {
w(TAG, "External storage not available. Using internal storage for logs: ${internalLogDir.absolutePath}") w(
TAG,
"External storage not available. Using internal storage for logs: ${internalLogDir.absolutePath}"
)
} }
return internalLogDir return internalLogDir
} }
@JvmStatic @JvmStatic
fun v(tag: String?, message: String) { log(Log.VERBOSE, tag, message) } fun v(tag: String?, message: String) {
log(Log.VERBOSE, tag, message)
}
@JvmStatic @JvmStatic
fun d(tag: String?, message: String) { log(Log.DEBUG, tag, message) } fun d(tag: String?, message: String) {
log(Log.DEBUG, tag, message)
}
@JvmStatic @JvmStatic
fun i(tag: String?, message: String) { log(Log.INFO, tag, message) } fun i(tag: String?, message: String) {
log(Log.INFO, tag, message)
}
@JvmStatic @JvmStatic
fun w(tag: String?, message: String, throwable: Throwable? = null) { log(Log.WARN, tag, message, throwable) } fun w(tag: String?, message: String, throwable: Throwable? = null) {
log(Log.WARN, tag, message, throwable)
}
@JvmStatic @JvmStatic
fun e(tag: String?, message: String, throwable: Throwable? = null) { log(Log.ERROR, tag, message, throwable) } fun e(tag: String?, message: String, throwable: Throwable? = null) {
log(Log.ERROR, tag, message, throwable)
}
/** /**
* Core logging function. Logs to Logcat and, if enabled, to a file. * Core logging function. Logs to Logcat and, if enabled, to a file.
@@ -221,7 +222,8 @@ object LogManager {
if (!isInitialized) { if (!isInitialized) {
// Use Android's Log directly if LogManager isn't initialized. // Use Android's Log directly if LogManager isn't initialized.
// This ensures critical early errors or misconfigurations are visible. // This ensures critical early errors or misconfigurations are visible.
val initErrorMsg = "LogManager not initialized! Attempted to log: [${tag ?: TAG}] $message" val initErrorMsg =
"LogManager not initialized! Attempted to log: [${tag ?: TAG}] $message"
Log.e("LogManager_NotInit", initErrorMsg, throwable) Log.e("LogManager_NotInit", initErrorMsg, throwable)
// Optionally, print to System.err as a last resort if Logcat is also problematic // Optionally, print to System.err as a last resort if Logcat is also problematic
// System.err.println("$initErrorMsg ${throwable?.let { Log.getStackTraceString(it) }}") // System.err.println("$initErrorMsg ${throwable?.let { Log.getStackTraceString(it) }}")
@@ -238,15 +240,21 @@ object LogManager {
Log.WARN -> Log.w(currentTag, message, throwable) Log.WARN -> Log.w(currentTag, message, throwable)
Log.ERROR -> Log.e(currentTag, message, throwable) Log.ERROR -> Log.e(currentTag, message, throwable)
// Default case for custom priorities, though less common with this setup. // Default case for custom priorities, though less common with this setup.
else -> Log.println(priority, currentTag, message + if (throwable != null) "\n${Log.getStackTraceString(throwable)}" else "") else -> Log.println(
priority,
currentTag,
message + if (throwable != null) "\n${Log.getStackTraceString(throwable)}" else ""
)
} }
// Log to file if enabled // Log to file if enabled
if (logToFileEnabled) { if (logToFileEnabled) {
val formattedMessageForFile = formatMessageForFile(priority, currentTag, message, throwable) val formattedMessageForFile =
formatMessageForFile(priority, currentTag, message, throwable)
coroutineScope.launch { coroutineScope.launch {
try { try {
val currentLogFile = File(getLogDirectory(), "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION") val currentLogFile =
File(getLogDirectory(), "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION")
// Ensure the log file's parent directory exists. // Ensure the log file's parent directory exists.
// This is a safeguard, though getLogDirectory should handle it. // This is a safeguard, though getLogDirectory should handle it.
@@ -255,7 +263,10 @@ object LogManager {
// Check if file is missing or empty, then write headers. // Check if file is missing or empty, then write headers.
// This can happen if the file was cleared or logging was just enabled. // This can happen if the file was cleared or logging was just enabled.
if (!currentLogFile.exists() || currentLogFile.length() == 0L) { if (!currentLogFile.exists() || currentLogFile.length() == 0L) {
d(TAG, "Log file missing or empty, writing headers: ${currentLogFile.absolutePath}") d(
TAG,
"Log file missing or empty, writing headers: ${currentLogFile.absolutePath}"
)
writeInitialLogHeaders(currentLogFile) // This will create/overwrite with headers writeInitialLogHeaders(currentLogFile) // This will create/overwrite with headers
isMarkdownBlockOpen = true isMarkdownBlockOpen = true
} }
@@ -288,12 +299,18 @@ object LogManager {
try { try {
// Ensure directory exists. // Ensure directory exists.
logFile.parentFile?.mkdirs() logFile.parentFile?.mkdirs()
OutputStreamWriter(FileOutputStream(logFile, false), StandardCharsets.UTF_8).use { writer -> OutputStreamWriter(
FileOutputStream(logFile, false),
StandardCharsets.UTF_8
).use { writer ->
val sessionStartTime = dateFormat.format(Date()) val sessionStartTime = dateFormat.format(Date())
val sessionId = System.currentTimeMillis().toString(16)
// GitHub-friendly Markdown header; copy-pasteable into issues/PRs. // GitHub-friendly Markdown header; copy-pasteable into issues/PRs.
writer.append("| Field | Value |\n") writer.append("| Field | Value |\n")
writer.append("|---|---|\n") writer.append("|---|---|\n")
writer.append("| Time | $sessionStartTime |\n") writer.append("| Time | $sessionStartTime |\n")
writer.append("| Session ID | $sessionId |\n")
writer.append("| App | openScale |\n") writer.append("| App | openScale |\n")
writer.append("| Version | ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) |\n") writer.append("| Version | ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) |\n")
writer.append("| Package | ${BuildConfig.APPLICATION_ID} |\n") writer.append("| Package | ${BuildConfig.APPLICATION_ID} |\n")
@@ -321,7 +338,12 @@ object LogManager {
* @param throwable An optional throwable. * @param throwable An optional throwable.
* @return The formatted log string. * @return The formatted log string.
*/ */
private fun formatMessageForFile(priority: Int, tag: String, message: String, throwable: Throwable?): String { private fun formatMessageForFile(
priority: Int,
tag: String,
message: String,
throwable: Throwable?
): String {
// GitHub diff mapping: + INFO, ! WARN, - ERROR, ' ' DEBUG, ? VERBOSE // GitHub diff mapping: + INFO, ! WARN, - ERROR, ' ' DEBUG, ? VERBOSE
val levelChar = when (priority) { val levelChar = when (priority) {
Log.VERBOSE -> 'V' Log.VERBOSE -> 'V'
@@ -341,7 +363,8 @@ object LogManager {
} }
val timestamp = dateFormat.format(Date()) val timestamp = dateFormat.format(Date())
val base = "$timestamp $levelChar/$tag: $message" val base = "$timestamp $levelChar/$tag: $message"
val withThrowable = if (throwable != null) "$base\n${Log.getStackTraceString(throwable)}" else base val withThrowable =
if (throwable != null) "$base\n${Log.getStackTraceString(throwable)}" else base
return prefix + withThrowable return prefix + withThrowable
} }
@@ -354,23 +377,36 @@ object LogManager {
private fun checkAndRotateLog(currentLogFile: File) { private fun checkAndRotateLog(currentLogFile: File) {
if (currentLogFile.exists() && currentLogFile.length() > MAX_LOG_SIZE_BYTES) { if (currentLogFile.exists() && currentLogFile.length() > MAX_LOG_SIZE_BYTES) {
val oldFileSize = currentLogFile.length() val oldFileSize = currentLogFile.length()
i(TAG, "Log file '${currentLogFile.name}' (size: $oldFileSize bytes) exceeds limit ($MAX_LOG_SIZE_BYTES bytes). Rotating.") i(
TAG,
"Log file '${currentLogFile.name}' (size: $oldFileSize bytes) exceeds limit ($MAX_LOG_SIZE_BYTES bytes). Rotating."
)
try { try {
OutputStreamWriter(FileOutputStream(currentLogFile, true), StandardCharsets.UTF_8).use { OutputStreamWriter(
FileOutputStream(currentLogFile, true),
StandardCharsets.UTF_8
).use {
it.append("\n```").append("\n") it.append("\n```").append("\n")
} }
isMarkdownBlockOpen = false isMarkdownBlockOpen = false
} catch (_: IOException) {} } catch (_: IOException) {
}
if (currentLogFile.delete()) { if (currentLogFile.delete()) {
i(TAG, "Oversized log file deleted: ${currentLogFile.name}. A new log file will be started with headers.") i(
TAG,
"Oversized log file deleted: ${currentLogFile.name}. A new log file will be started with headers."
)
// Immediately open a new header and start a fresh ```diff block. // Immediately open a new header and start a fresh ```diff block.
writeInitialLogHeaders(currentLogFile) writeInitialLogHeaders(currentLogFile)
isMarkdownBlockOpen = true isMarkdownBlockOpen = true
} else { } else {
e(TAG, "Failed to delete oversized log file '${currentLogFile.name}' for rotation. Current log may continue to grow or writes may fail.") e(
TAG,
"Failed to delete oversized log file '${currentLogFile.name}' for rotation. Current log may continue to grow or writes may fail."
)
} }
} }
} }
@@ -411,11 +447,15 @@ object LogManager {
try { try {
if (currentLogFile.exists()) { if (currentLogFile.exists()) {
try { try {
OutputStreamWriter(FileOutputStream(currentLogFile, true), StandardCharsets.UTF_8).use { OutputStreamWriter(
FileOutputStream(currentLogFile, true),
StandardCharsets.UTF_8
).use {
it.append("\n```").append("\n") it.append("\n```").append("\n")
} }
isMarkdownBlockOpen = false isMarkdownBlockOpen = false
} catch (_: Exception) {} } catch (_: Exception) {
}
if (currentLogFile.delete()) { if (currentLogFile.delete()) {
i(TAG, "Log file cleared: ${currentLogFile.absolutePath}") i(TAG, "Log file cleared: ${currentLogFile.absolutePath}")
@@ -425,7 +465,10 @@ object LogManager {
return@launch return@launch
} }
} else { } else {
i(TAG, "Log file already cleared or did not exist: ${currentLogFile.absolutePath}") i(
TAG,
"Log file already cleared or did not exist: ${currentLogFile.absolutePath}"
)
} }
// If file logging is enabled, a new log session effectively starts, so write headers. // If file logging is enabled, a new log session effectively starts, so write headers.
@@ -436,7 +479,11 @@ object LogManager {
} }
} catch (e: Exception) { } catch (e: Exception) {
// Catch any unexpected exception during file operations. // Catch any unexpected exception during file operations.
Log.e(TAG, "Error during clearLogFiles operation for ${currentLogFile.absolutePath}", e) Log.e(
TAG,
"Error during clearLogFiles operation for ${currentLogFile.absolutePath}",
e
)
} }
} }
} }
@@ -461,17 +508,17 @@ object LogManager {
} }
} }
private fun openMarkdownBlockIfNeeded() { @JvmStatic
fun ensureMarkdownBlockOpen() {
if (!isInitialized || !logToFileEnabled || isMarkdownBlockOpen) return if (!isInitialized || !logToFileEnabled || isMarkdownBlockOpen) return
val logFile = File(getLogDirectory(), "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION") val logFile = File(getLogDirectory(), "$CURRENT_LOG_FILE_NAME_BASE$LOG_FILE_EXTENSION")
try { try {
// Falls Datei frisch oder leer ist, wird ohnehin beim nächsten Write der Header erzeugt.
if (!logFile.exists() || logFile.length() == 0L) return if (!logFile.exists() || logFile.length() == 0L) return
OutputStreamWriter(FileOutputStream(logFile, true), StandardCharsets.UTF_8).use { OutputStreamWriter(FileOutputStream(logFile, true), StandardCharsets.UTF_8).use {
it.append("```diff\n") it.append("```diff\n")
} }
isMarkdownBlockOpen = true isMarkdownBlockOpen = true
d(TAG, "Markdown diff block reopened after app foreground.") d(TAG, "Markdown diff block reopened after export.")
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error reopening markdown diff block", e) Log.e(TAG, "Error reopening markdown diff block", e)
} }

View File

@@ -106,25 +106,27 @@ fun GeneralSettingsScreen(
if (logFileToCopy != null && logFileToCopy.exists()) { if (logFileToCopy != null && logFileToCopy.exists()) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
LogManager.closeMarkdownBlock()
context.contentResolver.openOutputStream(uri)?.use { outputStream -> context.contentResolver.openOutputStream(uri)?.use { outputStream ->
logFileToCopy.inputStream().use { inputStream -> logFileToCopy.inputStream().use { inputStream ->
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
} }
} }
LogManager.ensureMarkdownBlockOpen()
scope.launch { scope.launch {
sharedViewModel.showSnackbar(context.getString(R.string.log_export_success)) sharedViewModel.showSnackbar(context.getString(R.string.log_export_success))
} }
} catch (e: Exception) { } catch (e: Exception) {
LogManager.e("GeneralSettingsScreen", "Error exporting log file", e) LogManager.e("GeneralSettingsScreen", "Error exporting log file", e)
LogManager.ensureMarkdownBlockOpen()
scope.launch { scope.launch {
sharedViewModel.showSnackbar(context.getString(R.string.log_export_error)) sharedViewModel.showSnackbar(context.getString(R.string.log_export_error))
} }
} }
} }
} else {
scope.launch {
sharedViewModel.showSnackbar(context.getString(R.string.log_export_no_file))
}
} }
} }
} else { } else {