LogWriter.kt

Java Guest 4 Views Size: 19.30 KB Posted on: Mar 27, 26 @ 1:33 PM
  1. /*
  2. * HexDroidIRC - An IRC Client for Android
  3. * Copyright (C) 2026 boxlabs
  4. *
  5. * This program is free software: you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation, either version 3 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License
  16. * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  17. */
  18.  
  19. package com.boxlabs.hexdroid
  20.  
  21. import android.content.ContentResolver
  22. import android.content.Context
  23. import android.net.Uri
  24. import android.provider.DocumentsContract
  25. import android.provider.DocumentsContract.Document
  26. import java.io.File
  27. import java.io.FileOutputStream
  28. import java.nio.ByteBuffer
  29. import java.util.concurrent.ConcurrentHashMap
  30.  
  31. /**
  32.  * Simple line-based log writer.
  33.  *
  34.  * Default: app-private internal storage (Context.filesDir/logs)
  35.  * Optional: user-selected folder via SAF (tree URI) for easier access
  36.  *
  37.  * One file per buffer (eg, #afternet.txt, user.txt)
  38.  */
  39. class LogWriter(private val ctx: Context) {
  40.  
  41.     // Cache resolved SAF file URIs so we don't keep creating duplicates on some providers.
  42.     private val safFileCache = ConcurrentHashMap<String, Uri>()
  43.  
  44.     private fun internalRoot(): File = File(ctx.filesDir, "logs").apply { mkdirs() }
  45.  
  46.     /**
  47.      * Preferred log path:
  48.      *   <root>/<network>/<buffer>
  49.      *
  50.      * save log file (example: #afternet.txt, Eck.txt)
  51.      */
  52.     fun logFileInternal(networkName: String, buffer: String): File {
  53.         val netDir = File(internalRoot(), safeNetworkDirName(networkName)).apply { mkdirs() }
  54.         val desired = File(netDir, safeBufferFileName(buffer))
  55.  
  56.         // if we have an older sanitized file and the new file doesn't exist,
  57.         // rename it so scrollback works after upgrades.
  58.         if (!desired.exists()) {
  59.             val legacy = legacyInternalCandidates(netDir, buffer).firstOrNull { it.exists() }
  60.             if (legacy != null) {
  61.                 runCatching {
  62.                     legacy.renameTo(desired)
  63.                 }
  64.             }
  65.         }
  66.         return desired
  67.     }
  68.  
  69.     fun append(networkName: String, buffer: String, line: String, logFolderUri: String?) {
  70.         runCatching {
  71.             if (logFolderUri.isNullOrBlank()) {
  72.                 appendInternal(networkName, buffer, line)
  73.             } else {
  74.                 appendSaf(Uri.parse(logFolderUri), networkName, buffer, line)
  75.             }
  76.         }
  77.     }
  78.  
  79.     private fun appendInternal(networkName: String, buffer: String, line: String) {
  80.         val f = logFileInternal(networkName, buffer)
  81.         f.parentFile?.mkdirs()
  82.         f.appendText(line + "\n", Charsets.UTF_8)
  83.     }
  84.  
  85.     /**
  86.      * Read the last [maxLines] lines from the most recent log file for [networkName]/[buffer].
  87.      * Used to preload scrollback when (re)creating buffers.
  88.      */
  89.     fun readTail(networkName: String, buffer: String, maxLines: Int, logFolderUri: String?): List<String> {
  90.         val n = maxLines.coerceIn(1, 5000)
  91.         return if (logFolderUri.isNullOrBlank()) {
  92.             readTailInternal(networkName, buffer, n)
  93.         } else {
  94.             readTailSaf(Uri.parse(logFolderUri), networkName, buffer, n)
  95.         }
  96.     }
  97.  
  98.     private fun readTailInternal(networkName: String, buffer: String, maxLines: Int): List<String> {
  99.         val dir = File(internalRoot(), safeNetworkDirName(networkName))
  100.         if (!dir.exists() || !dir.isDirectory) return emptyList()
  101.  
  102.         // Prefer the new readable filename.
  103.         val desired = File(dir, safeBufferFileName(buffer))
  104.  
  105.         // Back-compat: older versions used sanitized names and/or rotation.
  106.         val legacyCandidates = legacyInternalCandidates(dir, buffer)
  107.  
  108.         // Choose the most recently modified candidate so scrollback works even if we end up
  109.         // writing to a legacy file on some providers.
  110.         val candidate: File? = sequenceOf(desired)
  111.             .plus(legacyCandidates.asSequence())
  112.             .filter { it.exists() && it.isFile }
  113.             .maxByOrNull { it.lastModified() }
  114.  
  115.         val f = candidate ?: return emptyList()
  116.         return f.inputStream().use { ins -> readTailFromStream(ins, maxLines) }
  117.     }
  118.  
  119.     private fun readTailSaf(treeUri: Uri, networkName: String, buffer: String, maxLines: Int): List<String> {
  120.         val resolver = ctx.contentResolver
  121.         val netDirName = safeNetworkDirName(networkName)
  122.         val desiredName = safeBufferFileName(buffer)
  123.  
  124.         val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
  125.  
  126.         val net = findChild(resolver, treeUri, rootDocId, netDirName) ?: return emptyList()
  127.         val (netDocId, netMime) = net
  128.         if (netMime != Document.MIME_TYPE_DIR) return emptyList()
  129.  
  130.         // Prefer the new readable filename, but also handle providers that auto-rename duplicates
  131.         // to things like "#channel-1" or "#channel (1)".
  132.         val direct = findChild(resolver, treeUri, netDocId, desiredName)?.let { (docId, _) ->
  133.             DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
  134.         } ?: findLatestChildFileByPrefix(
  135.             resolver = resolver,
  136.             treeUri = treeUri,
  137.             parentDocId = netDocId,
  138.             prefix = desiredName,
  139.             suffix = ""
  140.         )
  141.  
  142.         val legacyNames = legacySafDisplayNames(buffer)
  143.         val legacyDirect = direct ?: legacyNames.firstNotNullOfOrNull { nm ->
  144.             findChild(resolver, treeUri, netDocId, nm)?.let { (docId, _) ->
  145.                 DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
  146.             } ?: findLatestChildFileByPrefix(
  147.                 resolver = resolver,
  148.                 treeUri = treeUri,
  149.                 parentDocId = netDocId,
  150.                 prefix = nm,
  151.                 suffix = ""
  152.             )
  153.         }
  154.  
  155.         // Also support olkderrotation by prefix if present.
  156.         val legacyRotated = legacyDirect ?: findLatestChildFileByPrefix(
  157.             resolver = resolver,
  158.             treeUri = treeUri,
  159.             parentDocId = netDocId,
  160.             prefix = sanitize(buffer) + "_",
  161.             suffix = ".log"
  162.         )
  163.  
  164.         val targetUri = legacyRotated ?: return emptyList()
  165.  
  166.         return runCatching {
  167.             resolver.openInputStream(targetUri)?.use { ins ->
  168.                 readTailFromStream(ins, maxLines)
  169.             } ?: emptyList()
  170.         }.getOrDefault(emptyList())
  171.     }
  172.  
  173.     private fun readTailFromStream(input: java.io.InputStream, maxLines: Int): List<String> {
  174.         val dq = java.util.ArrayDeque<String>(maxLines)
  175.         input.bufferedReader(Charsets.UTF_8).useLines { seq ->
  176.             seq.forEach { line ->
  177.                 if (dq.size >= maxLines) dq.removeFirst()
  178.                 dq.addLast(line)
  179.             }
  180.         }
  181.         return dq.toList()
  182.     }
  183.  
  184.     fun purgeOlderThan(days: Int, logFolderUri: String?) {
  185.         // Retention purge is only implemented for internal storage for now.
  186.         if (!logFolderUri.isNullOrBlank()) return
  187.  
  188.         val cutoff = System.currentTimeMillis() - days.coerceIn(1, 365) * 24L * 60L * 60L * 1000L
  189.         internalRoot().walkTopDown().forEach { f ->
  190.             if (f.isFile && f.lastModified() < cutoff) {
  191.                 runCatching { f.delete() }
  192.             }
  193.         }
  194.     }
  195.  
  196.     // SAF
  197.  
  198.     private fun appendSaf(treeUri: Uri, networkName: String, buffer: String, line: String) {
  199.         val resolver = ctx.contentResolver
  200.  
  201.         val netDirName = safeNetworkDirName(networkName)
  202.         val desiredName = safeBufferFileName(buffer)
  203.  
  204.         // Some document providers return child listings inconsistently, which can cause repeated
  205.         // createDocument(...) calls and lots of one-line "duplicate" files. Cache the resolved file Uri.
  206.         val cacheKey = "${treeUri}|$netDirName|$desiredName"
  207.         safFileCache[cacheKey]?.let { cached ->
  208.             appendToDocument(resolver, cached, line + "\n")
  209.             return
  210.         }
  211.  
  212.         val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
  213.         val rootDocUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId)
  214.  
  215.         val netDirUri = findOrCreateChildDir(resolver, treeUri, rootDocUri, rootDocId, netDirName)
  216.         val netDirDocId = DocumentsContract.getDocumentId(netDirUri)
  217.  
  218.         // Prefer the new readable filename, but also accept provider-renamed duplicates.
  219.         val desiredUri = findChild(resolver, treeUri, netDirDocId, desiredName)?.let { (docId, _) ->
  220.             DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
  221.         } ?: findLatestChildFileByPrefix(
  222.             resolver = resolver,
  223.             treeUri = treeUri,
  224.             parentDocId = netDirDocId,
  225.             prefix = desiredName,
  226.             suffix = ""
  227.         )
  228.  
  229.         // If a legacy file exists, try to rename it to the new name so users don't end up
  230.         // with "channel-1" style provider renames.
  231.         val legacyNames = legacySafDisplayNames(buffer)
  232.         val legacyUri = desiredUri ?: legacyNames.firstNotNullOfOrNull { nm ->
  233.             findChild(resolver, treeUri, netDirDocId, nm)?.let { (docId, _) ->
  234.                 DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
  235.             } ?: findLatestChildFileByPrefix(
  236.                 resolver = resolver,
  237.                 treeUri = treeUri,
  238.                 parentDocId = netDirDocId,
  239.                 prefix = nm,
  240.                 suffix = ""
  241.             )
  242.         }
  243.  
  244.         var fileUri = if (legacyUri != null && desiredUri == null) {
  245.             // Attempt rename. If it fails, fall back to creating a new file with the desired name.
  246.             val renamed = runCatching {
  247.                 DocumentsContract.renameDocument(resolver, legacyUri, desiredName)
  248.             }.getOrNull()
  249.             renamed ?: findOrCreateChildFile(resolver, treeUri, netDirUri, netDirDocId, desiredName)
  250.         } else {
  251.             desiredUri ?: findOrCreateChildFile(resolver, treeUri, netDirUri, netDirDocId, desiredName)
  252.         }
  253.  
  254.         // If we fell back to the directory, file creation likely failed (some SAF providers reject
  255.         // display names that start with symbols like '#'). In that case, try a few safe legacy/sanitized
  256.         // names so channel scrollback doesn't silently stop working.
  257.         if (fileUri == netDirUri) {
  258.             val fallbacks = (legacySafDisplayNames(buffer) + listOf(
  259.                 "${sanitize(buffer).trimStart('_')}.log",
  260.                 sanitize(buffer).trimStart('_'),
  261.                 "${sanitize(buffer)}.log",
  262.                 sanitize(buffer)
  263.             )).distinct()
  264.  
  265.             for (nm in fallbacks) {
  266.                 val u = findOrCreateChildFile(resolver, treeUri, netDirUri, netDirDocId, nm)
  267.                 if (u != netDirUri) {
  268.                     fileUri = u
  269.                     break
  270.                 }
  271.             }
  272.         }
  273.  
  274.         // Only cache if we actually resolved a file (not the directory fallback).
  275.         if (fileUri != netDirUri) {
  276.             safFileCache[cacheKey] = fileUri
  277.         }
  278.  
  279.         appendToDocument(resolver, fileUri, line + "\n")
  280.     }
  281.  
  282.     private fun findOrCreateChildDir(
  283.         resolver: ContentResolver,
  284.         treeUri: Uri,
  285.         parentDocUri: Uri,
  286.         parentDocId: String,
  287.         displayName: String
  288.     ): Uri {
  289.         findChild(resolver, treeUri, parentDocId, displayName)?.let { (docId, _) ->
  290.             return DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
  291.         }
  292.  
  293.         val created = DocumentsContract.createDocument(resolver, parentDocUri, Document.MIME_TYPE_DIR, displayName)
  294.         if (created != null) return created
  295.  
  296.         // If creation failed (provider quirks), fall back to parent.
  297.         return parentDocUri
  298.     }
  299.  
  300.     private fun findOrCreateChildFile(
  301.         resolver: ContentResolver,
  302.         treeUri: Uri,
  303.         parentDocUri: Uri,
  304.         parentDocId: String,
  305.         displayName: String
  306.     ): Uri {
  307.         findChild(resolver, treeUri, parentDocId, displayName)?.let { (docId, _) ->
  308.             return DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
  309.         }
  310.  
  311.         // Some providers insist on "text/plain"; others don't care.
  312.         val created = DocumentsContract.createDocument(resolver, parentDocUri, "text/plain", displayName)
  313.         if (created != null) return created
  314.  
  315.         // Fallback: at least return the parent (will likely fail when writing, but avoids crashes).
  316.         return parentDocUri
  317.     }
  318.  
  319.     private fun findChild(
  320.         resolver: ContentResolver,
  321.         treeUri: Uri,
  322.         parentDocId: String,
  323.         displayName: String
  324.     ): Pair<String, String /*mime*/>? {
  325.         val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
  326.         val projection = arrayOf(
  327.             Document.COLUMN_DOCUMENT_ID,
  328.             Document.COLUMN_DISPLAY_NAME,
  329.             Document.COLUMN_MIME_TYPE
  330.         )
  331.  
  332.         resolver.query(childrenUri, projection, null, null, null)?.use { c ->
  333.             val idCol = c.getColumnIndex(Document.COLUMN_DOCUMENT_ID)
  334.             val nameCol = c.getColumnIndex(Document.COLUMN_DISPLAY_NAME)
  335.             val mimeCol = c.getColumnIndex(Document.COLUMN_MIME_TYPE)
  336.             while (c.moveToNext()) {
  337.                 val name = c.getString(nameCol)
  338.                 if (name != null && name.trim().equals(displayName, ignoreCase = true)) {
  339.                     val docId = c.getString(idCol)
  340.                     val mime = c.getString(mimeCol)
  341.                     return docId to mime
  342.                 }
  343.             }
  344.         }
  345.         return null
  346.     }
  347.  
  348.     /**
  349.      * Find the most-recent child file in [parentDocId] whose display name matches [prefix]...[suffix].
  350.      * Uses Document.COLUMN_LAST_MODIFIED when available.
  351.      */
  352.     private fun findLatestChildFileByPrefix(
  353.         resolver: ContentResolver,
  354.         treeUri: Uri,
  355.         parentDocId: String,
  356.         prefix: String,
  357.         suffix: String
  358.     ): Uri? {
  359.         val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
  360.         val projection = arrayOf(
  361.             Document.COLUMN_DOCUMENT_ID,
  362.             Document.COLUMN_DISPLAY_NAME,
  363.             Document.COLUMN_MIME_TYPE,
  364.             Document.COLUMN_LAST_MODIFIED
  365.         )
  366.  
  367.         var bestDocId: String? = null
  368.         var bestLastModified = Long.MIN_VALUE
  369.  
  370.         resolver.query(childrenUri, projection, null, null, null)?.use { c ->
  371.             val idCol = c.getColumnIndex(Document.COLUMN_DOCUMENT_ID)
  372.             val nameCol = c.getColumnIndex(Document.COLUMN_DISPLAY_NAME)
  373.             val mimeCol = c.getColumnIndex(Document.COLUMN_MIME_TYPE)
  374.             val lmCol = c.getColumnIndex(Document.COLUMN_LAST_MODIFIED)
  375.  
  376.             while (c.moveToNext()) {
  377.                 val mime = c.getString(mimeCol)
  378.                 if (mime == Document.MIME_TYPE_DIR) continue
  379.  
  380.                 val name = c.getString(nameCol) ?: continue
  381.                 if (!name.startsWith(prefix) || !name.endsWith(suffix)) continue
  382.  
  383.                 val lm = if (lmCol >= 0) c.getLong(lmCol) else 0L
  384.                 if (lm >= bestLastModified) {
  385.                     bestLastModified = lm
  386.                     bestDocId = c.getString(idCol)
  387.                 }
  388.             }
  389.         }
  390.  
  391.         val docId = bestDocId ?: return null
  392.         return DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
  393.     }
  394.  
  395.     private fun appendToDocument(resolver: ContentResolver, docUri: Uri, text: String) {
  396.         val bytes = text.toByteArray(Charsets.UTF_8)
  397.  
  398.         // Try simple append mode first.
  399.         runCatching {
  400.             resolver.openOutputStream(docUri, "wa")?.use { os ->
  401.                 os.write(bytes)
  402.                 os.flush()
  403.                 return
  404.             }
  405.         }
  406.  
  407.         // Fallback: open RW and seek to end.
  408.         resolver.openFileDescriptor(docUri, "rw")?.use { pfd ->
  409.             FileOutputStream(pfd.fileDescriptor).channel.use { ch ->
  410.                 ch.position(ch.size())
  411.                 ch.write(ByteBuffer.wrap(bytes))
  412.                 ch.force(true)
  413.             }
  414.         }
  415.     }
  416.  
  417.     /**
  418.      * sanitiser used by earlier versions.
  419.      * Keep for back-compat when reading/migrating logs.
  420.      */
  421.     private fun sanitize(s: String): String =
  422.         s.lowercase()
  423.             .replace(Regex("[^a-z0-9._-]+"), "_")
  424.             .take(60)
  425.             .ifBlank { "x" }
  426.  
  427.     /**
  428.      * Only removes characters that would break filesystem paths.
  429.      */
  430.     private fun safeNetworkDirName(networkName: String): String {
  431.         val trimmed = networkName.trim().ifBlank { "network" }
  432.         val cleaned = trimmed
  433.             .replace("\\", "_")
  434.             .replace("/", "_")
  435.             .replace("\u0000", "")
  436.             .trim()
  437.             .take(80)
  438.         return if (cleaned == "." || cleaned == ".." || cleaned.isBlank()) "network" else cleaned
  439.     }
  440.  
  441.     /**
  442.      * filename for a buffer.
  443.      * Example: "#afternet" stays "#afternet" (no suffix), server buffer becomes "server".
  444.      */
  445.     private fun safeBufferFileName(buffer: String): String {
  446.         val name = when (buffer) {
  447.             "*server*" -> "server"
  448.             else -> buffer
  449.         }
  450.         val trimmed = name.trim().ifBlank { "buffer" }
  451.         // Avoid path traversal / invalid names.
  452.         val cleaned = trimmed
  453.             .replace("\\", "_")
  454.             .replace("/", "_")
  455.             .replace("\u0000", "")
  456.             .trim()
  457.             .take(120)
  458.         return if (cleaned == "." || cleaned == ".." || cleaned.isBlank()) "buffer" else cleaned
  459.     }
  460.  
  461.     private fun legacyBufferBaseNames(buffer: String): List<String> {
  462.         val raw = buffer.trim()
  463.         // Some older builds stripped common channel prefix characters.
  464.         val noChanPrefix = raw.trimStart('#', '&', '!', '+')
  465.  
  466.         // Keep order from most-likely recent -> older/looser matches.
  467.         val out = LinkedHashSet<String>()
  468.         out += sanitize(raw)
  469.         out += sanitize(raw).trimStart('_')
  470.         if (noChanPrefix.isNotBlank()) {
  471.             out += sanitize(noChanPrefix)
  472.             out += noChanPrefix
  473.             out += noChanPrefix.lowercase()
  474.         }
  475.         // Some older builds used the raw buffer name directly.
  476.         out += raw
  477.         return out.filter { it.isNotBlank() }.take(12)
  478.     }
  479.  
  480.     private fun legacyInternalCandidates(netDir: File, buffer: String): List<File> {
  481.         val bases = legacyBufferBaseNames(buffer)
  482.         val direct = bases.flatMap { b ->
  483.             listOf(File(netDir, "$b.log"), File(netDir, b))
  484.         }
  485.         val rotated = bases.flatMap { b ->
  486.             netDir.listFiles { f ->
  487.                 f.isFile && f.name.startsWith("${b}_") && f.name.endsWith(".log")
  488.             }?.toList().orEmpty()
  489.         }
  490.         return direct + rotated
  491.     }
  492.  
  493.     private fun legacySafDisplayNames(buffer: String): List<String> {
  494.         val bases = legacyBufferBaseNames(buffer)
  495.         // SAF display names are typically exact, but providers sometimes strip extensions.
  496.         return bases.flatMap { b -> listOf("$b.log", b) }.distinct().take(24)
  497.     }
  498. }

Raw Paste

Comments 0
Login to post a comment.
  • No comments yet. Be the first.
Login to post a comment. Login or Register
We use cookies. To comply with GDPR in the EU and the UK we have to show you these.

We use cookies and similar technologies to keep this website functional (including spam protection via Google reCAPTCHA or Cloudflare Turnstile), and — with your consent — to measure usage and show ads. See Privacy.