- VPN retiré : le contrôle reste activé (alerte parent, tunnel remonté après 30 s, autorisation redemandée à l'ouverture) - Horloge de référence : changer date/heure/fuseau ne contourne plus les plages horaires ni le temps d'écran (signalé au parent) - Mot de passe : PBKDF2 + attente croissante après 3 échecs ; conversion automatique des anciens hachages - Écrans protégés : aucun affichage avant chargement des réglages ; effacement du journal protégé - Signature release via keystore.properties (même clé que les versions publiées) - Sauvegarde Google : réglages, appairage et journal exclus - Alerte admin fiable (goAsync), relance refusée signalée, liste d'applis renvoyée à chaque installation, code d'appairage consommé - Journal thread-safe et sans recopie à chaque ligne ; correctifs lint - Tests unitaires (mot de passe, règles, versions, config Firebase) ; README aligné sur le comportement réel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
117 lines
4.6 KiB
Kotlin
117 lines
4.6 KiB
Kotlin
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-ms<TAB>texte 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<Entry>()
|
|
/** Incrémenté à chaque changement : la liste n'est reconstruite que si l'onglet Journal l'observe. */
|
|
private val version = MutableStateFlow(0L)
|
|
val entries: Flow<List<String>> = 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 } }
|
|
}
|