/*
* HexDroidIRC - An IRC Client for Android
* Copyright (C) 2026 boxlabs
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package com.boxlabs.hexdroid
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
/**
* Simple line-based log writer.
*
* Default: app-private internal storage (Context.filesDir/logs)
* Optional: user-selected folder via SAF (tree URI) for easier access
*
* One file per buffer (eg, #afternet.txt, user.txt)
*/
class LogWriter(private val ctx: Context) {
// Cache resolved SAF file URIs so we don't keep creating duplicates on some providers.
private val safFileCache = ConcurrentHashMap()
private fun internalRoot(): File = File(ctx.filesDir, "logs").apply { mkdirs() }
/**
* Preferred log path:
* //
*
* save log file (example: #afternet.txt, Eck.txt)
*/
fun logFileInternal(networkName: String, buffer: String): File {
val netDir = File(internalRoot(), safeNetworkDirName(networkName)).apply { mkdirs() }
val desired = File(netDir, safeBufferFileName(buffer))
// if we have an older sanitized file and the new file doesn't exist,
// rename it so scrollback works after upgrades.
if (!desired.exists()) {
val legacy = legacyInternalCandidates(netDir, buffer).firstOrNull { it.exists() }
if (legacy != null) {
runCatching {
legacy.renameTo(desired)
}
}
}
return desired
}
fun append(networkName: String, buffer: String, line: String, logFolderUri: String?) {
runCatching {
if (logFolderUri.isNullOrBlank()) {
appendInternal(networkName, buffer, line)
} else {
appendSaf(Uri.parse(logFolderUri), networkName, buffer, line)
}
}
}
private fun appendInternal(networkName: String, buffer: String, line: String) {
val f = logFileInternal(networkName, buffer)
f.parentFile?.mkdirs()
f.appendText(line + "\n", Charsets.UTF_8)
}
/**
* Read the last [maxLines] lines from the most recent log file for [networkName]/[buffer].
* Used to preload scrollback when (re)creating buffers.
*/
fun readTail(networkName: String, buffer: String, maxLines: Int, logFolderUri: String?): List {
val n = maxLines.coerceIn(1, 5000)
return if (logFolderUri.isNullOrBlank()) {
readTailInternal(networkName, buffer, n)
} else {
readTailSaf(Uri.parse(logFolderUri), networkName, buffer, n)
}
}
private fun readTailInternal(networkName: String, buffer: String, maxLines: Int): List {
val dir = File(internalRoot(), safeNetworkDirName(networkName))
if (!dir.exists() || !dir.isDirectory) return emptyList()
// Prefer the new readable filename.
val desired = File(dir, safeBufferFileName(buffer))
// Back-compat: older versions used sanitized names and/or rotation.
val legacyCandidates = legacyInternalCandidates(dir, buffer)
// Choose the most recently modified candidate so scrollback works even if we end up
// writing to a legacy file on some providers.
val candidate: File? = sequenceOf(desired)
.plus(legacyCandidates.asSequence())
.filter { it.exists() && it.isFile }
.maxByOrNull { it.lastModified() }
val f = candidate ?: return emptyList()
return f.inputStream().use { ins -> readTailFromStream(ins, maxLines) }
}
private fun readTailSaf(treeUri: Uri, networkName: String, buffer: String, maxLines: Int): List {
val resolver = ctx.contentResolver
val netDirName = safeNetworkDirName(networkName)
val desiredName = safeBufferFileName(buffer)
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
val net = findChild(resolver, treeUri, rootDocId, netDirName) ?: return emptyList()
val (netDocId, netMime) = net
if (netMime != Document.MIME_TYPE_DIR) return emptyList()
// Prefer the new readable filename, but also handle providers that auto-rename duplicates
// to things like "#channel-1" or "#channel (1)".
val direct = findChild(resolver, treeUri, netDocId, desiredName)?.let { (docId, _) ->
DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
} ?: findLatestChildFileByPrefix(
resolver = resolver,
treeUri = treeUri,
parentDocId = netDocId,
prefix = desiredName,
suffix = ""
)
val legacyNames = legacySafDisplayNames(buffer)
val legacyDirect = direct ?: legacyNames.firstNotNullOfOrNull { nm ->
findChild(resolver, treeUri, netDocId, nm)?.let { (docId, _) ->
DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
} ?: findLatestChildFileByPrefix(
resolver = resolver,
treeUri = treeUri,
parentDocId = netDocId,
prefix = nm,
suffix = ""
)
}
// Also support olkderrotation by prefix if present.
val legacyRotated = legacyDirect ?: findLatestChildFileByPrefix(
resolver = resolver,
treeUri = treeUri,
parentDocId = netDocId,
prefix = sanitize(buffer) + "_",
suffix = ".log"
)
val targetUri = legacyRotated ?: return emptyList()
return runCatching {
resolver.openInputStream(targetUri)?.use { ins ->
readTailFromStream(ins, maxLines)
} ?: emptyList()
}.getOrDefault(emptyList())
}
private fun readTailFromStream(input: java.io.InputStream, maxLines: Int): List {
val dq = java.util.ArrayDeque(maxLines)
input.bufferedReader(Charsets.UTF_8).useLines { seq ->
seq.forEach { line ->
if (dq.size >= maxLines) dq.removeFirst()
dq.addLast(line)
}
}
return dq.toList()
}
fun purgeOlderThan(days: Int, logFolderUri: String?) {
// Retention purge is only implemented for internal storage for now.
if (!logFolderUri.isNullOrBlank()) return
val cutoff = System.currentTimeMillis() - days.coerceIn(1, 365) * 24L * 60L * 60L * 1000L
internalRoot().walkTopDown().forEach { f ->
if (f.isFile && f.lastModified() < cutoff) {
runCatching { f.delete() }
}
}
}
// SAF
private fun appendSaf(treeUri: Uri, networkName: String, buffer: String, line: String) {
val resolver = ctx.contentResolver
val netDirName = safeNetworkDirName(networkName)
val desiredName = safeBufferFileName(buffer)
// Some document providers return child listings inconsistently, which can cause repeated
// createDocument(...) calls and lots of one-line "duplicate" files. Cache the resolved file Uri.
val cacheKey = "${treeUri}|$netDirName|$desiredName"
safFileCache[cacheKey]?.let { cached ->
appendToDocument(resolver, cached, line + "\n")
return
}
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
val rootDocUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId)
val netDirUri = findOrCreateChildDir(resolver, treeUri, rootDocUri, rootDocId, netDirName)
val netDirDocId = DocumentsContract.getDocumentId(netDirUri)
// Prefer the new readable filename, but also accept provider-renamed duplicates.
val desiredUri = findChild(resolver, treeUri, netDirDocId, desiredName)?.let { (docId, _) ->
DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
} ?: findLatestChildFileByPrefix(
resolver = resolver,
treeUri = treeUri,
parentDocId = netDirDocId,
prefix = desiredName,
suffix = ""
)
// If a legacy file exists, try to rename it to the new name so users don't end up
// with "channel-1" style provider renames.
val legacyNames = legacySafDisplayNames(buffer)
val legacyUri = desiredUri ?: legacyNames.firstNotNullOfOrNull { nm ->
findChild(resolver, treeUri, netDirDocId, nm)?.let { (docId, _) ->
DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
} ?: findLatestChildFileByPrefix(
resolver = resolver,
treeUri = treeUri,
parentDocId = netDirDocId,
prefix = nm,
suffix = ""
)
}
var fileUri = if (legacyUri != null && desiredUri == null) {
// Attempt rename. If it fails, fall back to creating a new file with the desired name.
val renamed = runCatching {
DocumentsContract.renameDocument(resolver, legacyUri, desiredName)
}.getOrNull()
renamed ?: findOrCreateChildFile(resolver, treeUri, netDirUri, netDirDocId, desiredName)
} else {
desiredUri ?: findOrCreateChildFile(resolver, treeUri, netDirUri, netDirDocId, desiredName)
}
// If we fell back to the directory, file creation likely failed (some SAF providers reject
// display names that start with symbols like '#'). In that case, try a few safe legacy/sanitized
// names so channel scrollback doesn't silently stop working.
if (fileUri == netDirUri) {
val fallbacks = (legacySafDisplayNames(buffer) + listOf(
"${sanitize(buffer).trimStart('_')}.log",
sanitize(buffer).trimStart('_'),
"${sanitize(buffer)}.log",
sanitize(buffer)
)).distinct()
for (nm in fallbacks) {
val u = findOrCreateChildFile(resolver, treeUri, netDirUri, netDirDocId, nm)
if (u != netDirUri) {
fileUri = u
break
}
}
}
// Only cache if we actually resolved a file (not the directory fallback).
if (fileUri != netDirUri) {
safFileCache[cacheKey] = fileUri
}
appendToDocument(resolver, fileUri, line + "\n")
}
private fun findOrCreateChildDir(
resolver: ContentResolver,
treeUri: Uri,
parentDocUri: Uri,
parentDocId: String,
displayName: String
): Uri {
findChild(resolver, treeUri, parentDocId, displayName)?.let { (docId, _) ->
return DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
}
val created = DocumentsContract.createDocument(resolver, parentDocUri, Document.MIME_TYPE_DIR, displayName)
if (created != null) return created
// If creation failed (provider quirks), fall back to parent.
return parentDocUri
}
private fun findOrCreateChildFile(
resolver: ContentResolver,
treeUri: Uri,
parentDocUri: Uri,
parentDocId: String,
displayName: String
): Uri {
findChild(resolver, treeUri, parentDocId, displayName)?.let { (docId, _) ->
return DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
}
// Some providers insist on "text/plain"; others don't care.
val created = DocumentsContract.createDocument(resolver, parentDocUri, "text/plain", displayName)
if (created != null) return created
// Fallback: at least return the parent (will likely fail when writing, but avoids crashes).
return parentDocUri
}
private fun findChild(
resolver: ContentResolver,
treeUri: Uri,
parentDocId: String,
displayName: String
): Pair? {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
val projection = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE
)
resolver.query(childrenUri, projection, null, null, null)?.use { c ->
val idCol = c.getColumnIndex(Document.COLUMN_DOCUMENT_ID)
val nameCol = c.getColumnIndex(Document.COLUMN_DISPLAY_NAME)
val mimeCol = c.getColumnIndex(Document.COLUMN_MIME_TYPE)
while (c.moveToNext()) {
val name = c.getString(nameCol)
if (name != null && name.trim().equals(displayName, ignoreCase = true)) {
val docId = c.getString(idCol)
val mime = c.getString(mimeCol)
return docId to mime
}
}
}
return null
}
/**
* Find the most-recent child file in [parentDocId] whose display name matches [prefix]...[suffix].
* Uses Document.COLUMN_LAST_MODIFIED when available.
*/
private fun findLatestChildFileByPrefix(
resolver: ContentResolver,
treeUri: Uri,
parentDocId: String,
prefix: String,
suffix: String
): Uri? {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
val projection = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_LAST_MODIFIED
)
var bestDocId: String? = null
var bestLastModified = Long.MIN_VALUE
resolver.query(childrenUri, projection, null, null, null)?.use { c ->
val idCol = c.getColumnIndex(Document.COLUMN_DOCUMENT_ID)
val nameCol = c.getColumnIndex(Document.COLUMN_DISPLAY_NAME)
val mimeCol = c.getColumnIndex(Document.COLUMN_MIME_TYPE)
val lmCol = c.getColumnIndex(Document.COLUMN_LAST_MODIFIED)
while (c.moveToNext()) {
val mime = c.getString(mimeCol)
if (mime == Document.MIME_TYPE_DIR) continue
val name = c.getString(nameCol) ?: continue
if (!name.startsWith(prefix) || !name.endsWith(suffix)) continue
val lm = if (lmCol >= 0) c.getLong(lmCol) else 0L
if (lm >= bestLastModified) {
bestLastModified = lm
bestDocId = c.getString(idCol)
}
}
}
val docId = bestDocId ?: return null
return DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
}
private fun appendToDocument(resolver: ContentResolver, docUri: Uri, text: String) {
val bytes = text.toByteArray(Charsets.UTF_8)
// Try simple append mode first.
runCatching {
resolver.openOutputStream(docUri, "wa")?.use { os ->
os.write(bytes)
os.flush()
return
}
}
// Fallback: open RW and seek to end.
resolver.openFileDescriptor(docUri, "rw")?.use { pfd ->
FileOutputStream(pfd.fileDescriptor).channel.use { ch ->
ch.position(ch.size())
ch.write(ByteBuffer.wrap(bytes))
ch.force(true)
}
}
}
/**
* sanitiser used by earlier versions.
* Keep for back-compat when reading/migrating logs.
*/
private fun sanitize(s: String): String =
s.lowercase()
.replace(Regex("[^a-z0-9._-]+"), "_")
.take(60)
.ifBlank { "x" }
/**
* Only removes characters that would break filesystem paths.
*/
private fun safeNetworkDirName(networkName: String): String {
val trimmed = networkName.trim().ifBlank { "network" }
val cleaned = trimmed
.replace("\\", "_")
.replace("/", "_")
.replace("\u0000", "")
.trim()
.take(80)
return if (cleaned == "." || cleaned == ".." || cleaned.isBlank()) "network" else cleaned
}
/**
* filename for a buffer.
* Example: "#afternet" stays "#afternet" (no suffix), server buffer becomes "server".
*/
private fun safeBufferFileName(buffer: String): String {
val name = when (buffer) {
"*server*" -> "server"
else -> buffer
}
val trimmed = name.trim().ifBlank { "buffer" }
// Avoid path traversal / invalid names.
var cleaned = trimmed
.replace("\\", "_")
.replace("/", "_")
.replace("\u0000", "")
.trim()
.take(120)
// Some SAF providers (e.g. certain OEM file managers, exFAT volumes) silently reject
// display names that START with '#'. Rather than stripping the character (which would
// collapse ##channel and #channel to the same filename), we replace each leading '#'
// with the URL-percent encoding "%23" so the name remains unique and reversible.
cleaned = cleaned.replace(Regex("^#+")) { m -> m.value.replace("#", "%23") }
return if (cleaned == "." || cleaned == ".." || cleaned.isBlank()) "buffer" else cleaned
}
private fun legacyBufferBaseNames(buffer: String): List {
val raw = buffer.trim()
// Some older builds stripped common channel prefix characters.
val noChanPrefix = raw.trimStart('#', '&', '!', '+')
// Keep order from most-likely recent -> older/looser matches.
val out = LinkedHashSet()
// The raw name itself may have been written before the %23 encoding was added.
out += raw
out += sanitize(raw)
out += sanitize(raw).trimStart('_')
if (noChanPrefix.isNotBlank()) {
out += sanitize(noChanPrefix)
out += noChanPrefix
out += noChanPrefix.lowercase()
}
return out.filter { it.isNotBlank() }.take(12)
}
private fun legacyInternalCandidates(netDir: File, buffer: String): List {
val bases = legacyBufferBaseNames(buffer)
val direct = bases.flatMap { b ->
listOf(File(netDir, "$b.log"), File(netDir, b))
}
val rotated = bases.flatMap { b ->
netDir.listFiles { f ->
f.isFile && f.name.startsWith("${b}_") && f.name.endsWith(".log")
}?.toList().orEmpty()
}
return direct + rotated
}
private fun legacySafDisplayNames(buffer: String): List {
val bases = legacyBufferBaseNames(buffer)
// SAF display names are typically exact, but providers sometimes strip extensions.
return bases.flatMap { b -> listOf("$b.log", b) }.distinct().take(24)
}
}