package com.jttools.datacontrol.log import android.content.Context import android.util.Log import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.Executors /** * Journal de diagnostic : conservé [RETENTION_DAYS] jours (avec un plafond de [MAX_LINES] lignes), * en mémoire et dans un fichier qui survit au redémarrage de l'app. Consultable dans l'onglet « Journal ». * * Format du fichier : une ligne par entrée, `horodatage-mstexte affiché`. Le fichier est complété * au fil de l'eau puis réécrit (purgé) de temps en temps, pour ne pas le réécrire à chaque ligne. */ object AppLog { const val RETENTION_DAYS = 15 private const val RETENTION_MS = RETENTION_DAYS * 24L * 3600 * 1000 private const val MAX_LINES = 20_000 private const val COMPACT_EVERY_PRUNED = 200 private const val COMPACT_MIN_INTERVAL_MS = 3600_000L private const val FILE_NAME = "journal.txt" private class Entry(val ts: Long, val text: String) private val io = Executors.newSingleThreadExecutor() private val fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.FRANCE) private val lines = ArrayDeque() /** Incrémenté à chaque changement : la liste n'est reconstruite que si l'onglet Journal l'observe. */ private val version = MutableStateFlow(0L) val entries: Flow> = version.map { synchronized(lines) { lines.map { it.text } } } @Volatile private var file: File? = null private var prunedSinceCompact = 0 private var lastCompactAt = 0L fun init(context: Context) { if (file != null) return val f = File(context.filesDir, FILE_NAME) file = f synchronized(lines) { runCatching { if (f.exists()) f.readLines().forEach { raw -> val tab = raw.indexOf('\t') val ts = if (tab > 0) raw.substring(0, tab).toLongOrNull() else null if (ts != null) lines.addLast(Entry(ts, raw.substring(tab + 1))) } } prune(System.currentTimeMillis()) publish() compactLocked() } i("App", "Journal ouvert (conservation : $RETENTION_DAYS jours)") } fun i(tag: String, message: String) = add("I", tag, message) fun w(tag: String, message: String) = add("W", tag, message) fun e(tag: String, message: String, t: Throwable? = null) = add("E", tag, if (t == null) message else "$message — ${t.javaClass.simpleName}: ${t.message}") private fun add(level: String, tag: String, message: String) { Log.println(when (level) { "E" -> Log.ERROR; "W" -> Log.WARN; else -> Log.INFO }, "DataControl/$tag", message) val now = System.currentTimeMillis() synchronized(lines) { // SimpleDateFormat n'est pas sûr entre threads : formaté sous le verrou. val entry = Entry(now, "${fmt.format(Date(now))} $level $tag: $message") lines.addLast(entry) prune(now) publish() if (prunedSinceCompact >= COMPACT_EVERY_PRUNED && now - lastCompactAt > COMPACT_MIN_INTERVAL_MS) { compactLocked() } else { io.execute { runCatching { file?.appendText("${entry.ts}\t${entry.text}\n") } } } } } /** Retire les entrées plus vieilles que la rétention et au-delà du plafond. À appeler sous verrou. */ private fun prune(now: Long) { while (lines.isNotEmpty() && (now - lines.first().ts > RETENTION_MS || lines.size > MAX_LINES)) { lines.removeFirst() prunedSinceCompact++ } } private fun publish() { version.value++ } /** Réécrit le fichier avec les seules entrées conservées. À appeler sous verrou. */ private fun compactLocked() { prunedSinceCompact = 0 lastCompactAt = System.currentTimeMillis() val snapshot = lines.toList() io.execute { runCatching { file?.writeText(snapshot.joinToString("") { "${it.ts}\t${it.text}\n" }) } } } fun clear() { synchronized(lines) { lines.clear() publish() compactLocked() } } /** Texte à copier/partager : les [maxLines] dernières lignes (le presse-papiers et le partage ont une taille limite). */ fun asText(maxLines: Int = 3000): String = synchronized(lines) { lines.takeLast(maxLines).joinToString("\n") { it.text } } }