From ac25db470323b420bbaac2f50df96d36e31bc825 Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Thu, 15 Oct 2026 09:02:07 +0800 Subject: [PATCH 1/4] Limit log and text editor reads to the file tail. Large logs and config files were loaded whole into the editor and could stall or crash. Read at most 256 KB from the end, append only new log bytes, and keep oversized files read-only so a save cannot drop the unread prefix. --- .../com/bepinex/android/BoundedTextFile.kt | 175 ++++++++++++++++++ .../com/bepinex/android/log/GameLogOverlay.kt | 31 +++- .../bepinex/android/log/LogOverlayActivity.kt | 33 ++-- .../ui/components/ConfigEditorDialog.kt | 53 +++++- .../android/ui/screens/LogViewerScreen.kt | 93 +++++----- .../android/ui/screens/TextViewerScreen.kt | 103 ++++++++--- app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 8 files changed, 402 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/com/bepinex/android/BoundedTextFile.kt diff --git a/app/src/main/java/com/bepinex/android/BoundedTextFile.kt b/app/src/main/java/com/bepinex/android/BoundedTextFile.kt new file mode 100644 index 00000000..ebbf10ff --- /dev/null +++ b/app/src/main/java/com/bepinex/android/BoundedTextFile.kt @@ -0,0 +1,175 @@ +package com.bepinex.android + +import java.io.File +import java.io.RandomAccessFile +import java.util.Locale + +/** + * Reads only a bounded window of a text file. + * + * Logs and previews keep the tail. Editable files at or under [LIMIT_BYTES] + * are read whole. A larger editable file must stay read-only: saving a tail + * would erase the unread prefix. + */ +object BoundedTextFile { + const val LIMIT_BYTES = 256L * 1024L + + data class Snapshot( + val text: String, + val truncated: Boolean, + val fileLength: Long, + val exists: Boolean + ) + + fun read(file: File, limitBytes: Long = LIMIT_BYTES): Snapshot { + if (!file.isFile) return Snapshot("", truncated = false, fileLength = 0L, exists = false) + val length = file.length() + if (length <= limitBytes) { + return Snapshot( + text = if (length == 0L) "" else decodeTail(readRange(file, 0L, length), skipPartialLine = false), + truncated = false, + fileLength = length, + exists = true + ) + } + val start = length - limitBytes + return Snapshot( + text = decodeTail(readRange(file, start, length), skipPartialLine = startsMidLine(file, start)), + truncated = true, + fileLength = length, + exists = true + ) + } + + /** + * Remembers the last file position and reads only the bytes appended since + * the previous call. A shrink or a jump larger than [LIMIT_BYTES] reads the + * tail again. + */ + class Follower(private val limitBytes: Long = LIMIT_BYTES) { + private var path: String? = null + private var consumed = -1L + private var window = ByteArray(0) + private var snapshot = Snapshot("", truncated = false, fileLength = 0L, exists = false) + + fun read(file: File): Snapshot = synchronized(this) { + if (!file.isFile) { + reset() + return snapshot + } + val length = file.length() + val sameFile = path == file.absolutePath && consumed >= 0L + if (!sameFile || length < consumed || !windowMatches(file)) return replaceTail(file, length) + if (length == consumed) return snapshot + val delta = length - consumed + if (delta > limitBytes) return replaceTail(file, length) + val added = readRange(file, consumed, length) + val combined = if (window.isEmpty()) added else window + added + val limit = limitBytes.toInt() + val partial = combined.size > limit && combined[combined.size - limit - 1] != LINE_FEED + return store(file, length, combined, skipPartialLine = partial) + } + + private fun windowMatches(file: File): Boolean { + if (window.isEmpty()) return true + val windowStart = consumed - window.size + if (windowStart < 0L) return false + val probeLength = minOf(64, window.size).toLong() + val probe = readRange(file, windowStart, windowStart + probeLength) + if (probe.size.toLong() != probeLength) return false + for (index in probe.indices) { + if (probe[index] != window[index]) return false + } + return true + } + + private fun replaceTail(file: File, length: Long): Snapshot { + if (length == 0L) { + path = file.absolutePath + consumed = 0L + window = ByteArray(0) + snapshot = Snapshot("", truncated = false, fileLength = 0L, exists = true) + return snapshot + } + val start = if (length > limitBytes) length - limitBytes else 0L + val bytes = readRange(file, start, length) + return store(file, length, bytes, skipPartialLine = startsMidLine(file, start)) + } + + private fun store(file: File, length: Long, bytes: ByteArray, skipPartialLine: Boolean): Snapshot { + val kept = tailBytes(bytes, limitBytes.toInt(), skipPartialLine) + path = file.absolutePath + consumed = length + window = kept + snapshot = Snapshot( + text = decodeTail(kept, skipPartialLine = false), + truncated = length > limitBytes, + fileLength = length, + exists = true + ) + return snapshot + } + + private fun reset() { + path = null + consumed = -1L + window = ByteArray(0) + snapshot = Snapshot("", truncated = false, fileLength = 0L, exists = false) + } + } + + fun readRange(file: File, start: Long, endExclusive: Long): ByteArray { + if (endExclusive <= start) return ByteArray(0) + val count = (endExclusive - start).coerceAtMost(LIMIT_BYTES).toInt() + RandomAccessFile(file, "r").use { raf -> + raf.seek(start.coerceAtLeast(0L)) + val buffer = ByteArray(count) + var offset = 0 + while (offset < count) { + val read = raf.read(buffer, offset, count - offset) + if (read < 0) break + offset += read + } + return if (offset == count) buffer else buffer.copyOf(offset) + } + } + + /** True when [start] cuts through a line instead of landing on a new one. */ + fun startsMidLine(file: File, start: Long): Boolean { + if (start <= 0L) return false + val previous = readRange(file, start - 1, start) + return previous.isEmpty() || previous[0] != LINE_FEED + } + + fun decodeTail(bytes: ByteArray, skipPartialLine: Boolean): String { + val kept = if (skipPartialLine) tailBytes(bytes, bytes.size, skipPartialLine = true) else bytes + if (kept.isEmpty()) return "" + return String(kept, Charsets.UTF_8) + } + + fun formatByteCount(bytes: Long): String { + val safe = bytes.coerceAtLeast(0L) + if (safe < 1024) return "$safe B" + val kb = safe / 1024.0 + if (kb < 1024) return String.format(Locale.US, "%.1f KB", kb) + val mb = kb / 1024.0 + if (mb < 1024) return String.format(Locale.US, "%.1f MB", mb) + return String.format(Locale.US, "%.1f GB", mb / 1024.0) + } + + private fun tailBytes(bytes: ByteArray, limit: Int, skipPartialLine: Boolean): ByteArray { + if (bytes.isEmpty()) return bytes + val slice = if (bytes.size <= limit) bytes else bytes.copyOfRange(bytes.size - limit, bytes.size) + var start = 0 + if (skipPartialLine) { + val newline = slice.indexOf(LINE_FEED) + if (newline >= 0 && newline < slice.lastIndex) start = newline + 1 + } + while (start < slice.size && (slice[start].toInt() and 0xC0) == 0x80) start++ + if (start == 0) return slice + if (start >= slice.size) return ByteArray(0) + return slice.copyOfRange(start, slice.size) + } + + private const val LINE_FEED = 0x0A.toByte() +} diff --git a/app/src/main/java/com/bepinex/android/log/GameLogOverlay.kt b/app/src/main/java/com/bepinex/android/log/GameLogOverlay.kt index e176c780..a27e93dc 100644 --- a/app/src/main/java/com/bepinex/android/log/GameLogOverlay.kt +++ b/app/src/main/java/com/bepinex/android/log/GameLogOverlay.kt @@ -13,8 +13,8 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import com.bepinex.android.BoundedTextFile import java.io.File -import java.io.RandomAccessFile import java.util.ArrayDeque /** @@ -352,14 +352,25 @@ object GameLogOverlay { pollJob = object : Runnable { override fun run() { try { - if (resolvedLogFile.exists() && resolvedLogFile.length() > lastSize) { - RandomAccessFile(resolvedLogFile, "r").use { raf -> - raf.seek(lastSize) - val buf = ByteArray((raf.length() - lastSize).toInt()) - raf.readFully(buf) - val newLines = String(buf, Charsets.UTF_8) - .lines() - .filter { it.isNotBlank() } + if (resolvedLogFile.isFile) { + val length = resolvedLogFile.length() + if (length < lastSize) { + logLines.clear() + lastSize = 0L + logTextView.text = "" + } + if (length > lastSize) { + val start = if (length - lastSize > BoundedTextFile.LIMIT_BYTES) { + logLines.clear() + length - BoundedTextFile.LIMIT_BYTES + } else { + lastSize + } + val text = BoundedTextFile.decodeTail( + BoundedTextFile.readRange(resolvedLogFile, start, length), + skipPartialLine = BoundedTextFile.startsMidLine(resolvedLogFile, start) + ) + val newLines = text.lines().filter { it.isNotBlank() } if (newLines.isNotEmpty()) { for (line in newLines) { if (logLines.size >= MAX_LOG_LINES) logLines.removeFirst() @@ -371,7 +382,7 @@ object GameLogOverlay { ) jumpToBottom() } - lastSize = resolvedLogFile.length() + lastSize = length } } } catch (_: Exception) { } diff --git a/app/src/main/java/com/bepinex/android/log/LogOverlayActivity.kt b/app/src/main/java/com/bepinex/android/log/LogOverlayActivity.kt index 63f4d02a..d5863646 100644 --- a/app/src/main/java/com/bepinex/android/log/LogOverlayActivity.kt +++ b/app/src/main/java/com/bepinex/android/log/LogOverlayActivity.kt @@ -31,10 +31,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bepinex.android.BoundedTextFile import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import java.io.File -import java.io.RandomAccessFile import kotlin.math.roundToInt /** @@ -82,18 +82,29 @@ private fun LogOverlayContent(logFile: File, onClose: () -> Unit) { LaunchedEffect(logFile) { while (isActive) { try { - if (logFile.exists() && logFile.length() > lastSize) { - RandomAccessFile(logFile, "r").use { raf -> - raf.seek(lastSize) - val buf = ByteArray((raf.length() - lastSize).toInt()) - raf.readFully(buf) - val newLines = String(buf, Charsets.UTF_8) - .lines() - .filter { it.isNotBlank() } + if (logFile.isFile) { + val length = logFile.length() + if (length < lastSize) { + lines.clear() + lastSize = 0L + } + if (length > lastSize) { + val start = if (length - lastSize > BoundedTextFile.LIMIT_BYTES) { + lines.clear() + length - BoundedTextFile.LIMIT_BYTES + } else { + lastSize + } + val text = withContext(Dispatchers.IO) { + BoundedTextFile.decodeTail( + BoundedTextFile.readRange(logFile, start, length), + skipPartialLine = BoundedTextFile.startsMidLine(logFile, start) + ) + } + val newLines = text.lines().filter { it.isNotBlank() } lines.addAll(newLines) - // Keep last 300 lines while (lines.size > 300) lines.removeFirstOrNull() - lastSize = logFile.length() + lastSize = length } } } catch (_: Exception) { } diff --git a/app/src/main/java/com/bepinex/android/ui/components/ConfigEditorDialog.kt b/app/src/main/java/com/bepinex/android/ui/components/ConfigEditorDialog.kt index 879d7c73..07956dcd 100644 --- a/app/src/main/java/com/bepinex/android/ui/components/ConfigEditorDialog.kt +++ b/app/src/main/java/com/bepinex/android/ui/components/ConfigEditorDialog.kt @@ -19,11 +19,14 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import com.bepinex.android.BoundedTextFile import com.bepinex.android.R import com.bepinex.android.ui.theme.GlassAlertDialog import com.bepinex.android.ui.theme.glassContainerColor import com.bepinex.android.ui.theme.glassSurface import com.bepinex.android.ui.theme.glassTopBarColors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File /** @@ -36,11 +39,31 @@ fun ConfigEditorDialog( onDismiss: () -> Unit, onSave: (File, String) -> Boolean ) { - val initialContent = remember(configFile) { - runCatching { configFile.readText() }.getOrDefault("") + var initialContent by remember(configFile) { mutableStateOf("") } + var content by remember(configFile) { mutableStateOf("") } + var truncated by remember(configFile) { mutableStateOf(false) } + var fileLength by remember(configFile) { mutableStateOf(0L) } + var loaded by remember(configFile) { mutableStateOf(false) } + var loadError by remember(configFile) { mutableStateOf(null) } + val canEdit = loaded && !truncated && loadError == null + val hasChanges = canEdit && content != initialContent + + LaunchedEffect(configFile) { + val result = withContext(Dispatchers.IO) { + runCatching { BoundedTextFile.read(configFile) } + } + result.onSuccess { snapshot -> + initialContent = snapshot.text + content = snapshot.text + truncated = snapshot.truncated + fileLength = snapshot.fileLength + loadError = null + loaded = true + }.onFailure { error -> + loadError = error.message ?: error.javaClass.simpleName + loaded = false + } } - var content by remember(configFile) { mutableStateOf(initialContent) } - val hasChanges = content != initialContent var showDiscardDialog by remember(configFile) { mutableStateOf(false) } var saveFailed by remember(configFile) { mutableStateOf(false) } val keyboardController = LocalSoftwareKeyboardController.current @@ -89,6 +112,7 @@ fun ConfigEditorDialog( actions = { IconButton( onClick = { + if (!canEdit) return@IconButton val saved = runCatching { onSave(configFile, content) }.getOrDefault(false) if (saved) { saveFailed = false @@ -97,7 +121,7 @@ fun ConfigEditorDialog( saveFailed = true } }, - enabled = hasChanges + enabled = hasChanges && canEdit ) { Icon( Icons.Filled.Save, @@ -127,12 +151,31 @@ fun ConfigEditorDialog( ) } + if (loadError != null) { + Text( + "${stringResource(R.string.error)}: $loadError", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + if (truncated) { + Text( + stringResource(R.string.viewer_truncated, BoundedTextFile.formatByteCount(fileLength)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + OutlinedTextField( value = content, onValueChange = { + if (!canEdit) return@OutlinedTextField content = it saveFailed = false }, + readOnly = !canEdit, modifier = Modifier .fillMaxWidth() .weight(1f), diff --git a/app/src/main/java/com/bepinex/android/ui/screens/LogViewerScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/LogViewerScreen.kt index 92ee1b2b..8eb91d22 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/LogViewerScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/LogViewerScreen.kt @@ -5,10 +5,7 @@ import android.content.Intent import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh @@ -23,6 +20,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider +import com.bepinex.android.BoundedTextFile import com.bepinex.android.R import com.bepinex.android.ui.components.CodeEditor import com.bepinex.android.ui.theme.glassContainerColor @@ -31,35 +29,21 @@ import com.bepinex.android.ui.theme.glassTopBarColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.File private const val LOG_REFRESH_INTERVAL_MS = 1_000L -private const val LOG_SCROLL_BOTTOM_THRESHOLD = 48 private data class LogReadResult( - val content: String, - val exists: Boolean, + val content: String = "", + val exists: Boolean = false, + val truncated: Boolean = false, + val fileLength: Long = 0L, val error: String? = null ) -private suspend fun readLogFile(path: String): LogReadResult = withContext(Dispatchers.IO) { - runCatching { - val file = File(path) - if (!file.exists()) { - LogReadResult(content = "", exists = false) - } else { - LogReadResult(content = file.readText(), exists = true) - } - }.getOrElse { error -> - LogReadResult( - content = "", - exists = true, - error = error.message ?: error.javaClass.simpleName - ) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun LogViewerScreen( @@ -75,25 +59,32 @@ fun LogViewerScreen( ) { val context = LocalContext.current val refreshScope = rememberCoroutineScope() - val verticalScrollState = rememberScrollState() - var readResult by remember(logFilePath) { - mutableStateOf(LogReadResult(content = "", exists = false)) - } + val follower = remember(logFilePath) { BoundedTextFile.Follower() } + val refreshLock = remember(logFilePath) { Mutex() } + var readResult by remember(logFilePath) { mutableStateOf(LogReadResult()) } var isRefreshing by remember { mutableStateOf(false) } suspend fun refreshLog() { - if (isRefreshing) return - isRefreshing = true - val previousContent = readResult.content - val wasAtBottom = verticalScrollState.maxValue == 0 || - verticalScrollState.value >= verticalScrollState.maxValue - LOG_SCROLL_BOTTOM_THRESHOLD - val result = readLogFile(logFilePath) - readResult = result - isRefreshing = false - - if (autoScroll && wasAtBottom && result.content != previousContent) { - withFrameNanos { } - verticalScrollState.animateScrollTo(verticalScrollState.maxValue) + refreshLock.withLock { + val result = withContext(Dispatchers.IO) { + runCatching { follower.read(File(logFilePath)) }.fold( + onSuccess = { snapshot -> + LogReadResult( + content = snapshot.text, + exists = snapshot.exists, + truncated = snapshot.truncated, + fileLength = snapshot.fileLength + ) + }, + onFailure = { error -> + LogReadResult( + exists = true, + error = error.message ?: error.javaClass.simpleName + ) + } + ) + } + if (result != readResult) readResult = result } } @@ -148,7 +139,13 @@ fun LogViewerScreen( }, actions = { IconButton( - onClick = { if (!isRefreshing) refreshScope.launch { refreshLog() } }, + onClick = { + if (!isRefreshing) refreshScope.launch { + isRefreshing = true + refreshLog() + isRefreshing = false + } + }, enabled = !isRefreshing ) { Icon( @@ -174,8 +171,18 @@ fun LogViewerScreen( .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + if (readResult.truncated) { + Text( + text = stringResource( + R.string.log_truncated, + BoundedTextFile.formatByteCount(readResult.fileLength) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Surface( - modifier = Modifier.fillMaxSize().glassSurface(MaterialTheme.shapes.medium), + modifier = Modifier.weight(1f).fillMaxWidth().glassSurface(MaterialTheme.shapes.medium), shape = MaterialTheme.shapes.medium, color = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ) { @@ -206,8 +213,8 @@ fun LogViewerScreen( value = logText, onValueChange = {}, extension = "log", - wordWrap = wordWrap, - showLineNumbers = showLineNumbers, + wordWrap = wordWrap && !readResult.truncated, + showLineNumbers = showLineNumbers && !readResult.truncated, modifier = Modifier.fillMaxSize(), readOnly = true, showStatusBar = false, diff --git a/app/src/main/java/com/bepinex/android/ui/screens/TextViewerScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/TextViewerScreen.kt index dc193d41..c82378f2 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/TextViewerScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/TextViewerScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.bepinex.android.BoundedTextFile import com.bepinex.android.R import com.bepinex.android.ui.theme.GlassAlertDialog import com.bepinex.android.ui.theme.glassContainerColor @@ -26,6 +27,8 @@ import com.bepinex.android.ui.components.CodeEditor import com.bepinex.android.ui.components.SyntaxLanguage import com.bepinex.android.ui.components.syntaxLanguageFor import com.bepinex.android.ui.components.syntaxLanguageLabel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File private val ViewerPreviewSource = """ @@ -47,16 +50,34 @@ fun TextViewerScreen( wordWrap: Boolean = true, showLineNumbers: Boolean = true ) { - val initialContent = remember(file) { - runCatching { file.readText() }.getOrDefault("") - } - var textFieldValue by remember(file) { - mutableStateOf(TextFieldValue(initialContent)) - } - val hasChanges = textFieldValue.text != initialContent + var initialContent by remember(file) { mutableStateOf("") } + var truncated by remember(file) { mutableStateOf(false) } + var fileLength by remember(file) { mutableStateOf(0L) } + var loaded by remember(file) { mutableStateOf(false) } + var loadError by remember(file) { mutableStateOf(null) } + var textFieldValue by remember(file) { mutableStateOf(TextFieldValue("")) } + val canEdit = onSave != null && loaded && !truncated + val hasChanges = canEdit && textFieldValue.text != initialContent var showDiscardDialog by remember(file) { mutableStateOf(false) } var saveFailed by remember(file) { mutableStateOf(false) } val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(file) { + val result = withContext(Dispatchers.IO) { + runCatching { BoundedTextFile.read(file) } + } + result.onSuccess { snapshot -> + initialContent = snapshot.text + truncated = snapshot.truncated + fileLength = snapshot.fileLength + textFieldValue = TextFieldValue(snapshot.text) + loadError = null + loaded = true + }.onFailure { error -> + loadError = error.message ?: error.javaClass.simpleName + loaded = false + } + } val language = remember(file.extension) { syntaxLanguageFor(file.extension) } val languageLabel = remember(language, file.extension) { syntaxLanguageLabel(language, file.extension) @@ -104,9 +125,10 @@ fun TextViewerScreen( } }, actions = { - if (onSave != null) { + if (canEdit) { IconButton( onClick = { + if (truncated) return@IconButton val saved = runCatching { onSave(file, textFieldValue.text) }.getOrDefault(false) if (saved) { saveFailed = false @@ -148,22 +170,57 @@ fun TextViewerScreen( color = MaterialTheme.colorScheme.error ) } + if (truncated) { + Text( + stringResource(R.string.viewer_truncated, BoundedTextFile.formatByteCount(fileLength)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - CodeEditor( - value = textFieldValue, - onValueChange = { - textFieldValue = it - saveFailed = false - }, - extension = file.extension, - wordWrap = wordWrap, - showLineNumbers = showLineNumbers, - readOnly = onSave == null, - placeholder = stringResource(R.string.viewer_empty), - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) + when { + loadError != null -> { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = "${stringResource(R.string.error)}: $loadError", + modifier = Modifier.padding(24.dp), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + } + !loaded -> { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Text( + stringResource(R.string.viewer_loading), + modifier = Modifier.padding(top = 12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + else -> { + CodeEditor( + value = textFieldValue, + onValueChange = { + if (!canEdit) return@CodeEditor + textFieldValue = it + saveFailed = false + }, + extension = file.extension, + wordWrap = wordWrap && !truncated, + showLineNumbers = showLineNumbers && !truncated, + readOnly = !canEdit, + placeholder = stringResource(R.string.viewer_empty), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + } } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9cb24a22..e4faeedf 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -177,6 +177,8 @@ 显示行号 在编辑器左侧显示行号 文件为空 + 正在读取… + 文件有 %1$s,只显示末尾。已关闭编辑,避免写丢未显示的内容。 第 %1$d 行,第 %2$d 列 预览 “每项艺术和每项调查以及同样每项行动和追求都被认为是为了某种好事; 正因为如此,人们正确地宣布,善意是一切事情的目的。 - 亚里士多德,*尼科马丘亚伦理学* @@ -450,6 +452,7 @@ 复制 清除 暂无日志。启动游戏后可查看 BepInEx 日志。 + 日志共 %1$s,正在显示末尾。分享会发送完整文件。 日志已复制到剪贴板 全部 错误 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96fa295c..318f2572 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -185,6 +185,8 @@ Line Numbers Show line numbers on the left side Empty file + Loading… + This file is %1$s. Only the end is shown, and editing is off so the rest is not overwritten. Ln %1$d, Col %2$d Preview "Every art and every inquiry, and similarly every action and pursuit, is thought to aim at some good; and for this reason the good has rightly been declared to be that at which all things aim." — Aristotle, *Nicomachean Ethics* @@ -471,6 +473,7 @@ Copy Clear No log entries yet. Launch a game to see BepInEx logs. + Showing the end of a %1$s log. Share sends the full file. Log copied to clipboard All Errors From cbd5d24e265193d8422b58abb7d8e393754b7d3a Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Thu, 15 Oct 2026 09:05:10 +0800 Subject: [PATCH 2/4] ci: bump version to 584 and drop the for-maliang suffix. --- app/build.gradle.kts | 2 +- ci-version.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 740758f0..99e9b05a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,7 +32,7 @@ android { minSdk = 28 targetSdk = 35 versionCode = ciBuildNumber - versionName = "1.1.2-ci.$ciBuildNumber.for-maliang" + versionName = "1.1.2-ci.$ciBuildNumber" ndk { abiFilters += "arm64-v8a" diff --git a/ci-version.txt b/ci-version.txt index 33f752bd..989ba2b4 100644 --- a/ci-version.txt +++ b/ci-version.txt @@ -1 +1 @@ -583 +584 \ No newline at end of file From 3d4990924a2373849db117e8b4078c60285b07fa Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Thu, 15 Oct 2026 09:05:12 +0800 Subject: [PATCH 3/4] Point the local Android SDK path at D:\Environment\Android\Sdk. --- local.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/local.properties b/local.properties index abb3eb63..9d0c0f81 100644 --- a/local.properties +++ b/local.properties @@ -4,5 +4,5 @@ # Location of the SDK. This is only used by Gradle. # For customization when using a Version Control System, please read the # header note. -#Fri Sep 25 00:01:31 CST 2026 -sdk.dir=E\:\\Android\\Sdk +#Thu Oct 08 11:02:10 CST 2026 +sdk.dir=D\:\\Environment\\Android\\Sdk From e8a7f5465a0311b3321d1e7e7b5387c9878a4931 Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Thu, 15 Oct 2026 09:05:15 +0800 Subject: [PATCH 4/4] Add arm64 libonnxruntime.so. --- app/src/main/jniLibs/arm64-v8a/libonnxruntime.so | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/jniLibs/arm64-v8a/libonnxruntime.so diff --git a/app/src/main/jniLibs/arm64-v8a/libonnxruntime.so b/app/src/main/jniLibs/arm64-v8a/libonnxruntime.so new file mode 100644 index 00000000..1dc2644e --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libonnxruntime.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0c1861fec2e376d866e1ef43de99c0c2b33d532f56f393aeea14675672a1cf4 +size 16037784