Skip to content
Merged
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
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
175 changes: 175 additions & 0 deletions app/src/main/java/com/bepinex/android/BoundedTextFile.kt
Original file line number Diff line number Diff line change
@@ -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()
}
31 changes: 21 additions & 10 deletions app/src/main/java/com/bepinex/android/log/GameLogOverlay.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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()
Expand All @@ -371,7 +382,7 @@ object GameLogOverlay {
)
jumpToBottom()
}
lastSize = resolvedLogFile.length()
lastSize = length
}
}
} catch (_: Exception) { }
Expand Down
33 changes: 22 additions & 11 deletions app/src/main/java/com/bepinex/android/log/LogOverlayActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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<String?>(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
Expand Down Expand Up @@ -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
Expand All @@ -97,7 +121,7 @@ fun ConfigEditorDialog(
saveFailed = true
}
},
enabled = hasChanges
enabled = hasChanges && canEdit
) {
Icon(
Icons.Filled.Save,
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading