Files
DataControl/app/src/main/java/com/jttools/datacontrol/remote/FamilyApi.kt
T
tuxgyverandClaude Opus 4.8 c66302a2bd feat: verrouillage parental et désactivation par code du serveur
Le parent verrouille le téléphone à distance sur un écran de blocage plein
écran (ParentalLockActivity) que seul un code à usage unique généré par le
serveur permet de lever. La désactivation du contrôle (couper la protection,
dissocier, quitter le mode propriétaire, retirer l'anti-désinstallation) exige
désormais, sur un téléphone associé, ce code du serveur au lieu du mot de passe
local : l'enfant ne peut plus rien désactiver sans l'accord du parent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 21:26:41 +01:00

121 lines
5.5 KiB
Kotlin

package com.jttools.datacontrol.remote
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/** Erreur renvoyée par le serveur ([code] HTTP) ou réseau ([code] = 0), avec un message lisible. */
class ApiException(val code: Int, message: String) : IOException(message)
/**
* Client de l'API familyd. Appels bloquants : à lancer sur Dispatchers.IO.
* Les routes des appareils passent par le tunnel ([FamilyLink.apiBase], http://10.78.0.1:8100,
* déjà chiffré par WireGuard) ; l'appairage passe par l'adresse publique en HTTPS.
*/
class FamilyApi(private val apiBase: String, private val token: String) {
fun policy(): JSONObject = JSONObject(request("GET", "$apiBase/api/device/policy", token, null))
fun serverTime(): Long = JSONObject(request("GET", "$apiBase/api/device/time", token, null)).getLong("time")
fun state(state: JSONObject) {
request("POST", "$apiBase/api/device/state", token, state)
}
fun usage(date: String, totalMs: Long, apps: Map<String, Long>) {
request("POST", "$apiBase/api/device/usage", token,
JSONObject().put("date", date).put("total_ms", totalMs).put("apps", JSONObject(apps as Map<*, *>)))
}
fun event(type: String, detail: String, at: Long) {
request("POST", "$apiBase/api/device/events", token,
JSONObject().put("type", type).put("detail", detail.take(500)).put("at", at))
}
fun apps(apps: JSONArray) {
request("POST", "$apiBase/api/device/apps", token, JSONObject().put("apps", apps))
}
/** Attente longue des commandes du parent (verrouillage, son, partage d'écran). */
fun commands(waitSeconds: Int): JSONArray =
JSONObject(request("GET", "$apiBase/api/device/commands?wait=$waitSeconds", token, null, waitSeconds * 1000 + 5000))
.optJSONArray("commands") ?: JSONArray()
/** Envoie une image de l'écran (JPEG) pendant un partage. */
fun screen(jpeg: ByteArray) {
request("POST", "$apiBase/api/device/screen", token, jpeg, "image/jpeg")
}
/**
* Vérifie auprès du serveur le code dicté par le parent (désactivation / déverrouillage).
* Renvoie sans erreur si le code est accepté ; lève [ApiException] (404) sinon.
*/
fun unlock(code: String, action: String) {
request("POST", "$apiBase/api/device/unlock", token,
JSONObject().put("code", code).put("action", action))
}
/** Signale au serveur qu'une commande a été exécutée (audit). */
fun commandAck(id: String, status: String, detail: String) {
request("POST", "$apiBase/api/device/command-ack", token,
JSONObject().put("id", id).put("status", status).put("detail", detail.take(300)))
}
companion object {
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000
/** Appairage (public, HTTPS) : renvoie la réponse brute, à lire avec [FamilyLink.fromPairResponse]. */
fun pair(serverUrl: String, code: String, publicKey: String, name: String, model: String, appVersion: String): String {
require(serverUrl.startsWith("https://")) { "L'adresse du serveur doit commencer par https://" }
val body = JSONObject().put("code", code).put("public_key", publicKey).put("name", name)
.put("model", model).put("app_version", appVersion)
return request("POST", "${serverUrl.trimEnd('/')}/api/pair", null, body)
}
fun request(method: String, url: String, token: String?, body: JSONObject?, readTimeoutMs: Int = READ_TIMEOUT_MS): String {
return request(method, url, token, body?.toString()?.toByteArray(Charsets.UTF_8),
"application/json; charset=utf-8", readTimeoutMs)
}
fun request(method: String, url: String, token: String?, body: ByteArray?, contentType: String,
readTimeoutMs: Int = READ_TIMEOUT_MS): String {
val conn = try {
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = readTimeoutMs
setRequestProperty("Accept", "application/json")
token?.let { setRequestProperty("Authorization", "Bearer $it") }
if (body != null) {
doOutput = true
setRequestProperty("Content-Type", contentType)
setFixedLengthStreamingMode(body.size)
}
}
} catch (e: IOException) {
throw ApiException(0, "Serveur injoignable : ${e.message}")
}
try {
if (body != null) conn.outputStream.use { it.write(body) }
val code = conn.responseCode
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
if (code !in 200..299) {
val detail = runCatching { JSONObject(text).optString("detail") }.getOrNull()
throw ApiException(code, detail?.takeIf { it.isNotBlank() } ?: "Erreur du serveur ($code)")
}
return text
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
throw ApiException(0, "Serveur injoignable : ${e.message}")
} finally {
conn.disconnect()
}
}
}
}