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
121 lines
5.5 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|
|
}
|