From f8fd06afd420e3a3b9a64bb46118e0c7bad61658 Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Wed, 22 Jul 2026 18:04:38 +0200 Subject: [PATCH 1/5] security-check --- .../mg4/control/hardware/VehicleWriteGate.kt | 69 ++++++++++++++----- .../control/service/ProfilePickerOverlay.kt | 7 +- .../com/mg4/control/ui/SettingsFragment.kt | 34 +++++++++ app/src/main/res/layout/fragment_settings.xml | 64 +++++++++++++++++ app/src/main/res/values-de/strings.xml | 5 +- app/src/main/res/values-en/strings.xml | 5 +- app/src/main/res/values-es/strings.xml | 5 +- app/src/main/res/values-it/strings.xml | 5 +- app/src/main/res/values-pt/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- .../control/hardware/VehicleWriteGateTest.kt | 54 ++++++++++----- 11 files changed, 212 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/mg4/control/hardware/VehicleWriteGate.kt b/app/src/main/java/com/mg4/control/hardware/VehicleWriteGate.kt index e4eff95..5a74b53 100644 --- a/app/src/main/java/com/mg4/control/hardware/VehicleWriteGate.kt +++ b/app/src/main/java/com/mg4/control/hardware/VehicleWriteGate.kt @@ -8,12 +8,12 @@ import com.mg4.control.R import com.mg4.control.debug.AppLogger /** - * [T-904] Politique décidée : une écriture de réglage véhicule n'est autorisée QU'À L'ARRÊT. + * [T-904] Verrou d'écriture véhicule configurable (Réglages → « Sécurité conduite »). * - * Changer l'AEB, l'ELK, l'ACC/TJA ou le mode de conduite en roulant modifie le comportement - * du véhicule sous le conducteur. La règle est donc : 0 km/h, sinon refus — et refus AUSSI - * quand la vitesse est illisible (fail closed), parce qu'une vitesse inconnue peut être - * n'importe quelle vitesse. + * OFF par défaut : aucune restriction. Quand l'utilisateur l'active, une écriture de réglage + * de conduite (AEB, ELK, ACC/TJA, mode de conduite…) n'est autorisée que jusqu'à la vitesse + * maximale choisie (bornes incluses) ; au-dessus, refus. La vitesse illisible reste un refus + * (fail closed), une vitesse inconnue pouvant être n'importe quelle vitesse. * * Le confort (sièges/volant chauffants, via CarHvacManager) n'est PAS concerné : ces * écritures ne changent pas le comportement routier. @@ -22,6 +22,15 @@ object VehicleWriteGate { private const val TAG = "MG4_GATE" + /** Store partagé avec SettingsFragment. */ + const val PREFS_NAME = "mg4_settings" + /** Clé bool : sécurité activée. Défaut false (aucune restriction). */ + const val KEY_ENABLED = "safety_speed_gate_enabled" + /** Clé int : vitesse max (km/h) jusqu'à laquelle les écritures passent. Défaut 0. */ + const val KEY_MAX_KMH = "safety_speed_gate_max_kmh" + /** Vitesse max saisissable. */ + const val MAX_SPEED_KMH = 250 + /** Anti-spam sur le message utilisateur : un refus par seconde au plus. */ private const val TOAST_THROTTLE_MS = 1_000L @@ -38,40 +47,62 @@ object VehicleWriteGate { } /** - * Décision pure à partir d'une vitesse en km/h, [speedKmh] à null si illisible. - * - * Une vitesse négative est traitée comme illisible : le VHAL ne produit pas de vitesse - * négative en marche avant, et une valeur aberrante ne doit jamais ouvrir la porte. + * Décision pure. [enabled] false court-circuite tout (aucune restriction). + * Sinon : autorisé jusqu'à [maxKmh] inclus ; vitesse null/NaN/négative = refus + * (fail closed) ; au-dessus du seuil = refus. */ - fun decide(speedKmh: Float?): Decision = when { + fun decide(speedKmh: Float?, enabled: Boolean, maxKmh: Int): Decision = when { + !enabled -> Decision.ALLOWED speedKmh == null || speedKmh.isNaN() -> Decision.REFUSED_UNKNOWN_SPEED speedKmh < 0f -> Decision.REFUSED_UNKNOWN_SPEED - speedKmh == 0f -> Decision.ALLOWED + speedKmh <= maxKmh.toFloat() -> Decision.ALLOWED else -> Decision.REFUSED_MOVING } + /** Clampe une saisie utilisateur dans [0, MAX_SPEED_KMH]. null/vide => 0. */ + fun clampSpeed(raw: Int?): Int = (raw ?: 0).coerceIn(0, MAX_SPEED_KMH) + + /** Décision + seuil courants, lus en direct dans les prefs (sans effet de bord). */ + private data class Eval(val decision: Decision, val maxKmh: Int) + + private fun evaluate(): Eval { + val context = MG4Hardware.appContext() ?: return Eval(Decision.ALLOWED, 0) + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + if (!prefs.getBoolean(KEY_ENABLED, false)) return Eval(Decision.ALLOWED, 0) + val maxKmh = prefs.getInt(KEY_MAX_KMH, 0) + return Eval(decide(MG4Hardware.getVehicleSpeedKmh(), enabled = true, maxKmh = maxKmh), maxKmh) + } + /** - * Vrai si l'écriture [operation] est permise maintenant. En cas de refus, journalise et - * prévient l'utilisateur — un refus silencieux ferait croire que le réglage a été pris. + * Vrai si l'écriture [operation] est permise maintenant. Lit la config en direct dans + * les prefs. Sécurité OFF (défaut) ou contexte indisponible => autorisé. En cas de refus, + * journalise et prévient l'utilisateur. */ fun allow(operation: String): Boolean { - val decision = decide(MG4Hardware.getVehicleSpeedKmh()) + val (decision, maxKmh) = evaluate() if (decision == Decision.ALLOWED) return true - AppLogger.w(TAG, "Écriture refusée ($operation) : $decision") - notifyUser(decision) + AppLogger.w(TAG, "Écriture refusée ($operation) : $decision (max=$maxKmh km/h)") + notifyUser(decision, maxKmh) return false } - private fun notifyUser(decision: Decision) { + /** + * Comme [allow] mais silencieux (ni log ni toast) : pour les appelants qui veulent + * seulement savoir si une écriture passerait maintenant (ex. affichage de l'overlay de + * sélection de profil, qui applique un profil = une écriture). + */ + fun isAllowedNow(): Boolean = evaluate().decision == Decision.ALLOWED + + private fun notifyUser(decision: Decision, maxKmh: Int) { val context: Context = MG4Hardware.appContext() ?: return val now = System.currentTimeMillis() if (now - lastToastMs < TOAST_THROTTLE_MS) return lastToastMs = now val message = when (decision) { - Decision.REFUSED_MOVING -> R.string.write_refused_moving - Decision.REFUSED_UNKNOWN_SPEED -> R.string.write_refused_unknown_speed + Decision.REFUSED_MOVING -> context.getString(R.string.write_refused_moving, maxKmh) + Decision.REFUSED_UNKNOWN_SPEED -> context.getString(R.string.write_refused_unknown_speed) Decision.ALLOWED -> return } Handler(Looper.getMainLooper()).post { diff --git a/app/src/main/java/com/mg4/control/service/ProfilePickerOverlay.kt b/app/src/main/java/com/mg4/control/service/ProfilePickerOverlay.kt index 6de02ea..f97d287 100644 --- a/app/src/main/java/com/mg4/control/service/ProfilePickerOverlay.kt +++ b/app/src/main/java/com/mg4/control/service/ProfilePickerOverlay.kt @@ -87,10 +87,9 @@ object ProfilePickerOverlay { ) { // [T-904] L'overlay ne sert qu'à appliquer un profil, donc à écrire dans le // véhicule : inutile et dangereux de le poser devant le conducteur en roulant. - // Même politique que les écritures — refus aussi si la vitesse est illisible. - if (VehicleWriteGate.decide(MG4Hardware.getVehicleSpeedKmh()) - != VehicleWriteGate.Decision.ALLOWED) { - AppLogger.w(TAG, "Overlay non affiché : véhicule non à l'arrêt") + // Même politique configurable que les écritures (OFF => toujours affiché). + if (!VehicleWriteGate.isAllowedNow()) { + AppLogger.w(TAG, "Overlay non affiché : sécurité conduite active à cette vitesse") onAutoDismiss?.invoke() return } diff --git a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt index fca6019..9a96d5e 100644 --- a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt @@ -19,7 +19,9 @@ import android.os.Environment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo import android.widget.Button +import android.widget.EditText import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView @@ -35,6 +37,7 @@ import com.mg4.control.util.QrCode import com.mg4.control.debug.AppLogger import com.mg4.control.debug.CrashLogger import com.mg4.control.hardware.MG4Hardware +import com.mg4.control.hardware.VehicleWriteGate import com.mg4.control.update.ApkCleanup import com.mg4.control.update.UpdateChecker import com.mg4.control.update.UpdateDialogManager @@ -162,6 +165,36 @@ class SettingsFragment : Fragment() { prefs.edit().putBoolean("auto_apply_profile", checked).apply() } + // ── Sécurité conduite (verrou d'écriture par vitesse) ──────────────── + val switchSpeedGate = view.findViewById(R.id.switch_speed_gate) + val rowSpeedGateMax = view.findViewById(R.id.row_speed_gate_max) + val inputSpeedGateMax = view.findViewById(R.id.input_speed_gate_max) + + val gateEnabled = prefs.getBoolean(VehicleWriteGate.KEY_ENABLED, false) + switchSpeedGate.isChecked = gateEnabled + rowSpeedGateMax.visibility = if (gateEnabled) View.VISIBLE else View.GONE + inputSpeedGateMax.setText(prefs.getInt(VehicleWriteGate.KEY_MAX_KMH, 0).toString()) + + switchSpeedGate.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean(VehicleWriteGate.KEY_ENABLED, checked).apply() + rowSpeedGateMax.visibility = if (checked) View.VISIBLE else View.GONE + } + + // Valide + persiste la vitesse : clamp 0–250, réaffiche la valeur retenue. + fun commitSpeedGateMax() { + val clamped = VehicleWriteGate.clampSpeed(inputSpeedGateMax.text.toString().toIntOrNull()) + prefs.edit().putInt(VehicleWriteGate.KEY_MAX_KMH, clamped).apply() + val clampedText = clamped.toString() + if (inputSpeedGateMax.text.toString() != clampedText) { + inputSpeedGateMax.setText(clampedText) + } + } + inputSpeedGateMax.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) commitSpeedGateMax() } + inputSpeedGateMax.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { commitSpeedGateMax() } + false + } + // ── Vérification auto des mises à jour ─────────────────────────────── // Build offline : pas de réseau → on masque toute l'UI de mise à jour. if (BuildConfig.OFFLINE) { @@ -295,6 +328,7 @@ class SettingsFragment : Fragment() { } } + // ── Feedback "application à jour" sur le bouton ────────────────────────── private fun showUpToDate(btn: MaterialButton, originalText: String) { diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index b785bad..988aec5 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -441,6 +441,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 364b57d..efb1129 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -213,8 +213,11 @@ ✅ Update erfolgreich installiert! Download fehlgeschlagen. Verbindung prüfen. Update abgelehnt: Die heruntergeladene APK ist nicht mit dem erwarteten Schlüssel signiert. Datei gelöscht. - Einstellung abgelehnt: Fahrzeug fährt. Zum Ändern bitte anhalten. + Fahreinstellung über %1$d km/h abgelehnt. Zum Ändern langsamer fahren. Einstellung abgelehnt: Fahrzeuggeschwindigkeit nicht lesbar. + Fahreinstellungen oberhalb einer Geschwindigkeit sperren + Höchstgeschwindigkeit (km/h) + 0–250 Installation fehlgeschlagen. Installationsfehler: %s Manuelle Installation diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0adda5d..2090b2a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -213,8 +213,11 @@ ✅ Update installed successfully! Download failed. Check your connection. Update refused: the downloaded APK is not signed by the expected key. File deleted. - Setting refused: vehicle is moving. Stop the car to change this setting. + Driving setting refused above %1$d km/h. Slow down to change it. Setting refused: vehicle speed could not be read. + Block driving settings above a speed + Maximum speed (km/h) + 0–250 Installation failed. Install error: %s Manual installation diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 60e4794..5800d6e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -213,8 +213,11 @@ ✅ ¡Actualización instalada correctamente! Descarga fallida. Compruebe la conexión. Actualización rechazada: el APK descargado no está firmado con la clave esperada. Archivo eliminado. - Ajuste rechazado: el vehículo está en movimiento. Deténgase para cambiarlo. + Ajuste de conducción rechazado por encima de %1$d km/h. Reduzca la velocidad para cambiarlo. Ajuste rechazado: no se puede leer la velocidad del vehículo. + Bloquear los ajustes de conducción por encima de una velocidad + Velocidad máxima (km/h) + 0–250 Instalación fallida. Error de instalación: %s Instalación manual diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dd2b256..75e3f91 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -213,8 +213,11 @@ ✅ Aggiornamento installato con successo! Download fallito. Controlla la connessione. Aggiornamento rifiutato: l\'APK scaricato non è firmato con la chiave attesa. File eliminato. - Impostazione rifiutata: veicolo in movimento. Fermarsi per modificarla. + Impostazione di guida rifiutata oltre %1$d km/h. Rallenta per modificarla. Impostazione rifiutata: velocità del veicolo non leggibile. + Blocca le impostazioni di guida oltre una velocità + Velocità massima (km/h) + 0–250 Installazione fallita. Errore di installazione: %s Installazione manuale diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 4b5f1bc..0c10fd0 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -213,8 +213,11 @@ ✅ Atualização instalada com sucesso! Transferência falhada. Verifique a ligação. Atualização recusada: o APK transferido não está assinado com a chave esperada. Ficheiro eliminado. - Definição recusada: o veículo está em movimento. Pare para alterar. + Definição de condução recusada acima de %1$d km/h. Abrande para alterar. Definição recusada: não foi possível ler a velocidade do veículo. + Bloquear as definições de condução acima de uma velocidade + Velocidade máxima (km/h) + 0–250 Instalação falhada. Erro de instalação: %s Instalação manual diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94c9bd0..17ab836 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -215,8 +215,11 @@ ✅ Mise à jour installée avec succès ! Erreur lors du téléchargement. Vérifiez la connexion. Mise à jour refusée : la signature de l\'APK téléchargé ne correspond pas. Fichier supprimé. - Réglage refusé : véhicule en mouvement. Arrêtez-vous pour modifier ce paramètre. + Réglage de conduite refusé au-dessus de %1$d km/h. Ralentissez pour modifier. Réglage refusé : vitesse du véhicule illisible. + Bloquer les réglages de conduite au-delà d\'une vitesse + Vitesse maximale (km/h) + 0–250 Erreur lors de l\'installation. Erreur d\'installation : %s Installation manuelle diff --git a/app/src/test/java/com/mg4/control/hardware/VehicleWriteGateTest.kt b/app/src/test/java/com/mg4/control/hardware/VehicleWriteGateTest.kt index 392f5b9..e632141 100644 --- a/app/src/test/java/com/mg4/control/hardware/VehicleWriteGateTest.kt +++ b/app/src/test/java/com/mg4/control/hardware/VehicleWriteGateTest.kt @@ -5,41 +5,61 @@ import org.junit.Assert.assertEquals import org.junit.Test /** - * [T-904] Politique : écriture véhicule autorisée uniquement à 0 km/h, refus si la vitesse - * est illisible. Logique pure — pas de véhicule, pas d'Android. + * Verrou d'écriture configurable : OFF => tout passe ; ON => autorisé jusqu'à maxKmh + * inclus, refus au-dessus, refus si vitesse illisible (fail closed). Logique pure. */ class VehicleWriteGateTest { + // ── Désactivé : jamais de blocage ───────────────────────────────────────── @Test - fun `a l arret l ecriture est autorisee`() { - assertEquals(Decision.ALLOWED, VehicleWriteGate.decide(0f)) + fun `off autorise quelle que soit la vitesse`() { + assertEquals(Decision.ALLOWED, VehicleWriteGate.decide(0f, enabled = false, maxKmh = 0)) + assertEquals(Decision.ALLOWED, VehicleWriteGate.decide(200f, enabled = false, maxKmh = 50)) } @Test - fun `en mouvement l ecriture est refusee`() { - assertEquals(Decision.REFUSED_MOVING, VehicleWriteGate.decide(1f)) - assertEquals(Decision.REFUSED_MOVING, VehicleWriteGate.decide(50f)) - assertEquals(Decision.REFUSED_MOVING, VehicleWriteGate.decide(130f)) + fun `off autorise meme si vitesse illisible`() { + assertEquals(Decision.ALLOWED, VehicleWriteGate.decide(null, enabled = false, maxKmh = 50)) + assertEquals(Decision.ALLOWED, VehicleWriteGate.decide(Float.NaN, enabled = false, maxKmh = 50)) } + // ── Activé, seuil 50 : borne inclusive ──────────────────────────────────── @Test - fun `la moindre vitesse non nulle refuse - pas de tolerance`() { - // Pas de seuil "presque à l'arrêt" : la politique dit 0. - assertEquals(Decision.REFUSED_MOVING, VehicleWriteGate.decide(0.1f)) + fun `on sous ou egal au seuil autorise`() { + assertEquals(Decision.ALLOWED, VehicleWriteGate.decide(0f, enabled = true, maxKmh = 50)) + assertEquals(Decision.ALLOWED, VehicleWriteGate.decide(49f, enabled = true, maxKmh = 50)) + assertEquals(Decision.ALLOWED, VehicleWriteGate.decide(50f, enabled = true, maxKmh = 50)) } @Test - fun `vitesse illisible refuse - fail closed`() { - assertEquals(Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(null)) + fun `on au dessus du seuil refuse`() { + assertEquals(Decision.REFUSED_MOVING, VehicleWriteGate.decide(50.1f, enabled = true, maxKmh = 50)) + assertEquals(Decision.REFUSED_MOVING, VehicleWriteGate.decide(80f, enabled = true, maxKmh = 50)) } + // ── Activé, seuil 0 : comportement d'origine (arrêt seulement) ──────────── @Test - fun `vitesse NaN refuse - fail closed`() { - assertEquals(Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(Float.NaN)) + fun `on seuil zero equivaut a arret seulement`() { + assertEquals(Decision.ALLOWED, VehicleWriteGate.decide(0f, enabled = true, maxKmh = 0)) + assertEquals(Decision.REFUSED_MOVING, VehicleWriteGate.decide(1f, enabled = true, maxKmh = 0)) } + // ── Activé, vitesse illisible : fail closed ─────────────────────────────── @Test - fun `vitesse negative refuse - valeur aberrante, pas une autorisation`() { - assertEquals(Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(-1f)) + fun `on vitesse illisible refuse`() { + assertEquals(Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(null, enabled = true, maxKmh = 50)) + assertEquals(Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(Float.NaN, enabled = true, maxKmh = 50)) + assertEquals(Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(-3f, enabled = true, maxKmh = 50)) + } + + // ── Clamp de la saisie utilisateur ──────────────────────────────────────── + @Test + fun `clampSpeed borne entre 0 et 250`() { + assertEquals(0, VehicleWriteGate.clampSpeed(null)) + assertEquals(0, VehicleWriteGate.clampSpeed(-10)) + assertEquals(0, VehicleWriteGate.clampSpeed(0)) + assertEquals(50, VehicleWriteGate.clampSpeed(50)) + assertEquals(250, VehicleWriteGate.clampSpeed(250)) + assertEquals(250, VehicleWriteGate.clampSpeed(999)) } } From 61ffc1ef8a20045a06a9ea3a57e199184891cb3e Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Thu, 23 Jul 2026 10:38:33 +0200 Subject: [PATCH 2/5] popup improvement --- .../control/service/ProfilePickerOverlay.kt | 14 +++++++++++ .../res/layout/overlay_profile_picker.xml | 25 ++++++++++++++++--- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 8 files changed, 41 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/mg4/control/service/ProfilePickerOverlay.kt b/app/src/main/java/com/mg4/control/service/ProfilePickerOverlay.kt index f97d287..f4ecfa8 100644 --- a/app/src/main/java/com/mg4/control/service/ProfilePickerOverlay.kt +++ b/app/src/main/java/com/mg4/control/service/ProfilePickerOverlay.kt @@ -2,6 +2,7 @@ package com.mg4.control.service import android.app.AlertDialog import android.content.Context +import android.content.Intent import android.content.res.ColorStateList import android.graphics.PixelFormat import android.os.Handler @@ -18,6 +19,7 @@ import android.widget.LinearLayout import android.widget.TextView import com.google.android.material.button.MaterialButton import com.google.android.material.slider.Slider +import com.mg4.control.MainActivity import com.mg4.control.R import com.mg4.control.debug.AppLogger import com.mg4.control.hardware.MG4Hardware @@ -191,6 +193,18 @@ object ProfilePickerOverlay { } } + // ── Bouton « Ouvrir MG4Control » (bas droite, toujours visible) ──── + view.findViewById(R.id.overlay_btn_open_app)?.setOnClickListener { + AppLogger.i(TAG, "Ouverture de MG4Control depuis l'overlay") + runCatching { + context.startActivity( + Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + }.onFailure { AppLogger.w(TAG, "Lancement MainActivity échoué : ${it.message}") } + dismissOnMainThread(context) + } + // ── Tap sur le fond → fermeture ─────────────────────────────────── view.findViewById(R.id.overlay_backdrop)?.setOnClickListener { dismissOnMainThread(context) diff --git a/app/src/main/res/layout/overlay_profile_picker.xml b/app/src/main/res/layout/overlay_profile_picker.xml index 2948c70..5313770 100644 --- a/app/src/main/res/layout/overlay_profile_picker.xml +++ b/app/src/main/res/layout/overlay_profile_picker.xml @@ -224,11 +224,28 @@ app:iconTint="@color/dash_text_lo" app:iconGravity="textStart" /> - - + + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="horizontal" + android:gravity="center_vertical|end"> + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index efb1129..e6df4c7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -43,6 +43,7 @@ Mittel Tag Fahrprofile + MG4Control öffnen Schließt in %1$ds Profil starten: Profil auswählen diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2090b2a..f099d49 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -43,6 +43,7 @@ Medium Day Driving profiles + Open MG4Control Closing in %1$ds Launch profile Choose a profile diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5800d6e..75af55b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -43,6 +43,7 @@ Medio Día Perfiles de conducción + Abrir MG4Control Cierre en %1$ds Iniciar perfil Elegir un perfil diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 75e3f91..a989f74 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -43,6 +43,7 @@ Medio Giorno Profili di guida + Apri MG4Control Chiusura tra %1$ds Avvia profilo Scegli un profilo diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0c10fd0..e2d8bae 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -43,6 +43,7 @@ Médio Dia Perfis de condução + Abrir MG4Control Fechar em %1$ds Iniciar perfil Escolher um perfil diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17ab836..ff24096 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,7 @@ Moyen Jour Profils de conduite + Ouvrir MG4Control Fermeture dans %1$ds Lancer le profil Choisir un profil From 2ed84ce0d9034a711d69e1789a796d4689cf1ebe Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Thu, 23 Jul 2026 13:00:45 +0200 Subject: [PATCH 3/5] get exterior temp --- .../com/mg4/control/hardware/MG4Hardware.kt | 112 ++++++++++++++++++ .../com/mg4/control/ui/SettingsFragment.kt | 2 + 2 files changed, 114 insertions(+) diff --git a/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt b/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt index 3ce8a0d..6f16a62 100644 --- a/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt +++ b/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt @@ -366,6 +366,8 @@ object MG4Hardware { initKatman5Swi69(context) else initKatman5(context) + // Température (sonde Diagnostic) — bind async du service clim SAIC ; no-op si absent. + initAirCondition(context) if (sVehicleBinder != null) AppLogger.i(TAG, " ✓ Katman2: vehiclesetting binder OK") else @@ -3327,6 +3329,116 @@ object MG4Hardware { else sDoorReadLast.entries.joinToString { "0x${it.key.toString(16)}=${it.value}" }) } + // ── Sonde température (bouton Diagnostic) ───────────────────────────────── + private const val TEMP_TAG = "MG4_TEMP" + // Source RÉELLE de la temp (décompilé SystemUI SWI133) : service clim SAIC, PAS une propriété CPM. + private const val AIRCON_CLASS = "com.saicmotor.sdk.vehiclesettings.manager.AirConditionManager" + @Volatile private var sAirCondition: Any? = null + + // Voie CPM (secondaire). ⚠ SAIC a INVERSÉ current/set (0x…502/503) par rapport à l'AAOS. + private const val PROP_ENV_OUTSIDE_TEMP = 0x11600703 // ENV_OUTSIDE_TEMPERATURE (AAOS std — renvoie 0 ici) + private const val PROP_HVAC_TEMP_OUTCAR = 0x15602511 // HVAC_TEMPERATURE_OUTCAR (vendor SAIC = temp extérieure) + private const val PROP_HVAC_AMBIENT_TEMP = 0x1560252a // HVAC_AMBIENT_TEMPERATURE (vendor SAIC) + private const val PROP_HVAC_TEMP_CURRENT = 0x15600502 // HVAC_TEMPERATURE_CURRENT (SAIC — inversé vs AAOS) + private val TEMP_HVAC_AREAS = intArrayOf(0x1, 0x2, 0x4, AREA_HVAC, AREA_GLOBAL, 0) + + private fun fmtTemp(v: Float?): String = when { + v == null || v.isNaN() -> "illisible" + v <= -1000f -> "n/c(${"%.0f".format(v)})" // sentinelle SAIC -10000 = service non connecté + else -> "%.1f".format(v) + } + + /** Lit un getter float sans argument sur le manager clim SAIC (réflexion). */ + private fun acFloat(name: String): Float? { + val ac = sAirCondition ?: return null + return try { ac.javaClass.getMethod(name).invoke(ac) as? Float } catch (_: Exception) { null } + } + /** Lit un getter int sans argument sur le manager clim SAIC (réflexion). */ + private fun acInt(name: String): Int? { + val ac = sAirCondition ?: return null + return try { ac.javaClass.getMethod(name).invoke(ac) as? Int } catch (_: Exception) { null } + } + + /** + * Bind (async) au service clim SAIC — `AirConditionManager`, même SDK que VehicleConditionManager + * (Katman5). C'est la VRAIE source de la temp extérieure (`getOutCarTemp`), pas une propriété CPM. + * No-op silencieux si le SDK est absent (ex. A9, autre package). Idempotent. + */ + private fun initAirCondition(context: Context) { + if (sAirCondition != null) return + val launcherCtx = listOf(LAUNCHER68_PKG, LAUNCHER69_PKG).firstNotNullOfOrNull { pkg -> + try { + context.createPackageContext( + pkg, + android.content.Context.CONTEXT_INCLUDE_CODE or android.content.Context.CONTEXT_IGNORE_SECURITY + ) + } catch (_: Exception) { null } + } ?: return + + val acClass = try { + launcherCtx.classLoader.loadClass(AIRCON_CLASS) + } catch (e: Exception) { + AppLogger.d(TEMP_TAG, "AirConditionManager absent: ${e.message}") + return + } + fun singleton(): Any? = try { acClass.getMethod("getInstance").invoke(null) } catch (_: Exception) { null } + + val initMethod = acClass.methods.firstOrNull { m -> + m.name == "init" && m.parameterCount == 2 && + Context::class.java.isAssignableFrom(m.parameterTypes[0]) + } + if (initMethod != null) { + val listenerType = initMethod.parameterTypes[1] + val listenerArg: Any? = if (listenerType.isInterface) try { + java.lang.reflect.Proxy.newProxyInstance( + listenerType.classLoader, arrayOf(listenerType) + ) { _, method, _ -> + if (method.name == "onServiceConnected") { + AppLogger.i(TEMP_TAG, "AirCondition: onServiceConnected ✓") + sAirCondition = singleton() + } + null + } + } catch (_: Exception) { null } else null + try { + initMethod.invoke(null, context.applicationContext, listenerArg) + AppLogger.i(TEMP_TAG, "AirCondition.init() appelé") + } catch (e: Exception) { + AppLogger.w(TEMP_TAG, "AirCondition.init() erreur: ${e.message}") + } + } + // Handle immédiat ; la valeur sera valide dès que le service est connecté (async). + if (sAirCondition == null) sAirCondition = singleton() + } + + /** + * Sonde du bouton Diagnostic (lecture seule). Voie principale = service clim SAIC + * (`getOutCarTemp`, ce que fait l'OEM). Voie CPM = secondaire, teste les IDs vendor. + */ + fun runTemperatureDiag() { + AppLogger.i(TEMP_TAG, "── DIAG température ──") + sAppContext?.let { initAirCondition(it) } // au cas où l'init au démarrage n'a pas abouti + + // Voie OEM (la bonne). + if (sAirCondition == null) { + AppLogger.i(TEMP_TAG, "AirConditionManager indisponible (SDK non chargé) — voir voie CPM") + } else { + AppLogger.i(TEMP_TAG, "OEM getOutCarTemp=${fmtTemp(acFloat("getOutCarTemp"))} " + + "drvSet=${acInt("getDrvTemp") ?: "?"} psgSet=${acInt("getPsgTemp") ?: "?"}") + } + + // Voie CPM secondaire : IDs vendor SAIC (au cas où certains soient lisibles en direct). + AppLogger.i(TEMP_TAG, "CPM EXTstd(0x11600703)=${fmtTemp(getFloatPropertyCPM(PROP_ENV_OUTSIDE_TEMP, AREA_GLOBAL))} " + + "OUTCAR(0x15602511)=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_TEMP_OUTCAR, AREA_GLOBAL))} " + + "AMBIENT(0x1560252a)=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_AMBIENT_TEMP, AREA_GLOBAL))}") + for (area in TEMP_HVAC_AREAS) { + val a = "0x${Integer.toHexString(area)}" + AppLogger.i(TEMP_TAG, "CPM area=$a OUTCAR=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_TEMP_OUTCAR, area))} " + + "AMBIENT=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_AMBIENT_TEMP, area))} " + + "CURRENT=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_TEMP_CURRENT, area))}") + } + } + /** Connexion (async) à l'API Car AOSP → CarPropertyManager ("property") ET CarDoorLockManager * ("doorlock"). Selon le firmware, la porte est exposée par l'un ou l'autre → on lit via les deux. */ private fun connectCarProperty() { diff --git a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt index 9a96d5e..edd1918 100644 --- a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt @@ -391,6 +391,8 @@ class SettingsFragment : Fragment() { // Sonde diagnostic : logge volume + état des portes AVANT de rendre les logs, // pour que le rapport les contienne (indépendant du toggle / de l'onglet Audio). MG4Hardware.runDoorVolumeDiag() + // Sonde température : tente de lire temp extérieure + habitacle et logge le brut. + MG4Hardware.runTemperatureDiag() val appVersion = try { ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "?" From b15665aab004142428b759330daf7ca0104265d9 Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Thu, 23 Jul 2026 14:04:09 +0200 Subject: [PATCH 4/5] add automate tab + profil based on the ext temp --- .../main/java/com/mg4/control/MainActivity.kt | 11 ++++ .../com/mg4/control/hardware/MG4Hardware.kt | 11 ++++ .../mg4/control/service/MG4ControlService.kt | 55 +++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 16 ++++++ app/src/main/res/navigation/nav_graph.xml | 6 ++ app/src/main/res/values-de/strings.xml | 14 ++++- app/src/main/res/values-en/strings.xml | 14 ++++- app/src/main/res/values-es/strings.xml | 14 ++++- app/src/main/res/values-it/strings.xml | 14 ++++- app/src/main/res/values-pt/strings.xml | 14 ++++- app/src/main/res/values/strings.xml | 14 ++++- 11 files changed, 177 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/mg4/control/MainActivity.kt b/app/src/main/java/com/mg4/control/MainActivity.kt index 1f97135..62b5c03 100644 --- a/app/src/main/java/com/mg4/control/MainActivity.kt +++ b/app/src/main/java/com/mg4/control/MainActivity.kt @@ -291,6 +291,7 @@ class MainActivity : AppCompatActivity() { // ── Boutons de navigation dans la top-bar ───────────────────────────────── private fun setupNavButtons() { + val btnAutomation = findViewById(R.id.btn_nav_automation) val btnAudio = findViewById(R.id.btn_nav_audio) val btnShortcuts = findViewById(R.id.btn_nav_shortcuts) val btnProfiles = findViewById(R.id.btn_nav_profiles) @@ -308,6 +309,13 @@ class MainActivity : AppCompatActivity() { btnAudio.visibility = View.GONE } + btnAutomation.setOnClickListener { + when (navController.currentDestination?.id) { + R.id.automationFragment -> navController.popBackStack(R.id.dashboardFragment, false) + else -> navController.navigate(R.id.automationFragment) + } + } + btnShortcuts.setOnClickListener { when (navController.currentDestination?.id) { R.id.shortcutsFragment -> navController.popBackStack(R.id.dashboardFragment, false) @@ -332,6 +340,9 @@ class MainActivity : AppCompatActivity() { navController.addOnDestinationChangedListener { _, destination, _ -> val accent = getColor(R.color.dash_accent_dim) val inactive = getColor(R.color.dash_btn) + btnAutomation.backgroundTintList = android.content.res.ColorStateList.valueOf( + if (destination.id == R.id.automationFragment) accent else inactive + ) btnAudio.backgroundTintList = android.content.res.ColorStateList.valueOf( if (destination.id == R.id.audioFragment) accent else inactive ) diff --git a/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt b/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt index 6f16a62..51ffd19 100644 --- a/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt +++ b/app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt @@ -3439,6 +3439,17 @@ object MG4Hardware { } } + /** + * Température extérieure en °C, ou null si illisible. Voie OEM (`getOutCarTemp`) puis + * repli CPM (`HVAC_TEMPERATURE_OUTCAR` @ zone 0x75, validé sur SWI133). Sentinelle SAIC + * (-10000) et NaN => null. Lecture seule. + */ + fun getOutsideTempCelsius(): Float? { + acFloat("getOutCarTemp")?.let { if (!it.isNaN() && it > -1000f) return it } + getFloatPropertyCPM(PROP_HVAC_TEMP_OUTCAR, AREA_HVAC)?.let { if (!it.isNaN() && it > -1000f) return it } + return null + } + /** Connexion (async) à l'API Car AOSP → CarPropertyManager ("property") ET CarDoorLockManager * ("doorlock"). Selon le firmware, la porte est exposée par l'un ou l'autre → on lit via les deux. */ private fun connectCarProperty() { diff --git a/app/src/main/java/com/mg4/control/service/MG4ControlService.kt b/app/src/main/java/com/mg4/control/service/MG4ControlService.kt index ea793c6..ca9fbee 100644 --- a/app/src/main/java/com/mg4/control/service/MG4ControlService.kt +++ b/app/src/main/java/com/mg4/control/service/MG4ControlService.kt @@ -20,6 +20,8 @@ import android.view.WindowManager import android.widget.Toast import com.mg4.control.util.LocaleHelper import com.mg4.control.R +import com.mg4.control.automation.AutomationDecision +import com.mg4.control.automation.AutomationSettings import com.mg4.control.bluetooth.BluetoothProfileManager import com.mg4.control.debug.AppLogger import com.mg4.control.hardware.MG4Hardware @@ -344,6 +346,12 @@ class MG4ControlService : Service() { return } + // Automatisation température (précédence : après choix manuel, avant BT/défaut). + tryTemperatureAutomation(onFallback = { resolveBtOrDefaultOnSchedule() }) + } + + /** Résolution BT (+ fallback HFP) → défaut au démarrage service (corps historique, inchangé). */ + private fun resolveBtOrDefaultOnSchedule() { val pm = ProfileManager(applicationContext) // [BT-PROFILES] Cherche tous les profils BT parmi les appareils déjà connus en mémoire @@ -496,6 +504,46 @@ class MG4ControlService : Service() { AppLogger.i(TAG, "[BT] BtAclReceiver enregistré") } + /** + * Automatisation température — précédence : après choix manuel, avant BT/défaut. + * Non applicable (désactivée / temp illisible / < seuil / profil absent) => [onFallback]. + * Applicable => application directe (case cochée) ou popup de confirmation + * (NON/timeout => [onFallback]). + */ + private fun tryTemperatureAutomation(onFallback: () -> Unit) { + val ctx = applicationContext + val cfg = AutomationSettings.read(ctx) + if (!cfg.enabled) { onFallback(); return } + val profile = cfg.profileId.takeIf { it.isNotEmpty() }?.let { ProfileManager(ctx).getById(it) } + + MG4Hardware.whenKatman1Ready { + val temp = MG4Hardware.getOutsideTempCelsius() + val outcome = AutomationDecision.evaluate(cfg.enabled, temp, cfg.threshold, profile != null) + if (outcome != AutomationDecision.Outcome.APPLY || profile == null || temp == null) { + AppLogger.i(TAG, "Auto temp: non applicable (temp=$temp seuil=${cfg.threshold} profil=${profile?.name}) → fallback") + onFallback(); return@whenKatman1Ready + } + if (cfg.autoExecute) { + AppLogger.i(TAG, "Auto temp → application directe '${profile.name}' (temp=$temp ≤ ${cfg.threshold})") + ProfileApplier.apply(profile, autoStart = true) { ok -> AppLogger.i(TAG, "Auto temp appliqué — ok=$ok") } + } else { + AppLogger.i(TAG, "Auto temp → popup confirmation '${profile.name}'") + ProfileConfirmOverlay.show( + context = ctx, + profile = profile, + threshold = cfg.threshold, + currentTemp = temp, + onConfirmed = { + CoroutineScope(Dispatchers.IO).launch { + ProfileApplier.apply(profile, autoStart = true) { ok -> AppLogger.i(TAG, "Auto temp OUI '${profile.name}' — ok=$ok") } + } + }, + onDeclined = { onFallback() } + ) + } + } + } + /** * Applique le profil approprié suite à un événement IGNITION_STATE=RUN. * Priorité : choix manuel récent (popup/app) → profil BT associé → profil par défaut. @@ -529,6 +577,13 @@ class MG4ControlService : Service() { } } + // Automatisation température (précédence : après choix manuel, avant BT/défaut). + tryTemperatureAutomation(onFallback = { resolveBtOrDefaultOnIgnition() }) + } + + /** Résolution BT → défaut au passage RUN (corps historique, inchangé). */ + private fun resolveBtOrDefaultOnIgnition() { + val pm = ProfileManager(applicationContext) // [BT-PROFILES] Cherche tous les profils BT parmi les appareils connectés val btProfiles = BluetoothProfileManager.getConnectedMacs() .mapNotNull { mac -> pm.getProfileForBtDevice(mac) } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 3a2e2e1..8b7cd21 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -151,6 +151,22 @@ + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e6df4c7..39ac9b4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -289,4 +289,16 @@ %d gespeicherte(s) Profil(e) auf diesem Fahrzeug gefunden. Wiederherstellen? Wiederherstellen Ignorieren - %d Profil(e) wiederhergestellt + %d Profil(e) wiederhergestellt + Automatisierung + Ein Profil je nach Außentemperatur anwenden + Schwellentemperatur (°C) + 0–60 + Anzuwendendes Profil + Automatisch anwenden + Kein Profil + Temperatur-Automatisierung + Die Außentemperatur liegt unter %1$d°C\n(%2$s°C aktuelle Temperatur)\nProfil „%3$s“ anwenden? + JA + NEIN + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index f099d49..defbc84 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -289,4 +289,16 @@ %d saved profile(s) were found on this vehicle. Restore them? Restore Ignore - %d profile(s) restored + %d profile(s) restored + Automation + Apply a profile based on the outside temperature + Threshold temperature (°C) + 0–60 + Profile to apply + Apply automatically + No profile + Temperature automation + The outside temperature is below %1$d°C\n(%2$s°C current temperature)\nApply profile “%3$s”? + YES + NO + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 75af55b..80e1943 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -289,4 +289,16 @@ Se encontraron %d perfil(es) guardado(s) en este vehículo. ¿Restaurarlos? Restaurar Ignorar - %d perfil(es) restaurado(s) + %d perfil(es) restaurado(s) + Automatización + Aplicar un perfil según la temperatura exterior + Temperatura umbral (°C) + 0–60 + Perfil a aplicar + Aplicar automáticamente + Ningún perfil + Automatización por temperatura + La temperatura exterior está por debajo de %1$d°C\n(%2$s°C temperatura actual)\n¿Aplicar el perfil «%3$s»? + + NO + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a989f74..fc5ddcb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -289,4 +289,16 @@ Trovati %d profilo/i salvato/i su questo veicolo. Ripristinarli? Ripristina Ignora - %d profilo/i ripristinato/i + %d profilo/i ripristinato/i + Automazione + Applica un profilo in base alla temperatura esterna + Temperatura soglia (°C) + 0–60 + Profilo da applicare + Applica automaticamente + Nessun profilo + Automazione temperatura + La temperatura esterna è sotto i %1$d°C\n(%2$s°C temperatura attuale)\nApplicare il profilo «%3$s»? + + NO + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e2d8bae..0d226c2 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -289,4 +289,16 @@ Foram encontrados %d perfil(is) guardado(s) neste veículo. Restaurar? Restaurar Ignorar - %d perfil(is) restaurado(s) + %d perfil(is) restaurado(s) + Automação + Aplicar um perfil consoante a temperatura exterior + Temperatura limite (°C) + 0–60 + Perfil a aplicar + Aplicar automaticamente + Nenhum perfil + Automação por temperatura + A temperatura exterior está abaixo de %1$d°C\n(%2$s°C temperatura atual)\nAplicar o perfil «%3$s»? + SIM + NÃO + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff24096..0aae610 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -291,4 +291,16 @@ %d profil(s) sauvegardé(s) ont été trouvés sur ce véhicule. Les restaurer ? Restaurer Ignorer - %d profil(s) restauré(s) + %d profil(s) restauré(s) + Automatisation + Appliquer un profil selon la température extérieure + Température seuil (°C) + 0–60 + Profil à appliquer + Exécuter automatiquement + Aucun profil + Automatisation température + La température extérieure est en dessous de %1$d°C\n(%2$s°C température actuelle)\nVoulez-vous appliquer le profil « %3$s » ? + OUI + NON + From d294c069addc15b4c8100cff4c4af43af94e11b7 Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Thu, 23 Jul 2026 14:44:22 +0200 Subject: [PATCH 5/5] add automate tab + profil based on the ext temp --- .../control/automation/AutomationDecision.kt | 20 +++ .../control/automation/AutomationSettings.kt | 37 +++++ .../control/service/ProfileConfirmOverlay.kt | 128 ++++++++++++++++++ .../com/mg4/control/ui/AutomationFragment.kt | 99 ++++++++++++++ .../main/res/drawable/bg_confirm_message.xml | 7 + .../main/res/layout/fragment_automation.xml | 110 +++++++++++++++ .../res/layout/overlay_profile_confirm.xml | 89 ++++++++++++ .../automation/AutomationDecisionTest.kt | 40 ++++++ 8 files changed, 530 insertions(+) create mode 100644 app/src/main/java/com/mg4/control/automation/AutomationDecision.kt create mode 100644 app/src/main/java/com/mg4/control/automation/AutomationSettings.kt create mode 100644 app/src/main/java/com/mg4/control/service/ProfileConfirmOverlay.kt create mode 100644 app/src/main/java/com/mg4/control/ui/AutomationFragment.kt create mode 100644 app/src/main/res/drawable/bg_confirm_message.xml create mode 100644 app/src/main/res/layout/fragment_automation.xml create mode 100644 app/src/main/res/layout/overlay_profile_confirm.xml create mode 100644 app/src/test/java/com/mg4/control/automation/AutomationDecisionTest.kt diff --git a/app/src/main/java/com/mg4/control/automation/AutomationDecision.kt b/app/src/main/java/com/mg4/control/automation/AutomationDecision.kt new file mode 100644 index 0000000..d7c365d --- /dev/null +++ b/app/src/main/java/com/mg4/control/automation/AutomationDecision.kt @@ -0,0 +1,20 @@ +package com.mg4.control.automation + +/** Décision pure de l'automatisation température (testable sans Android). */ +object AutomationDecision { + + enum class Outcome { NOT_APPLICABLE, APPLY } + + /** + * APPLY ssi : [enabled] ET [temp] lisible (non null/NaN) ET [profileExists] + * ET [temp] <= [threshold] (borne incluse — déclenchement quand il fait ≤ seuil). + * Sinon NOT_APPLICABLE. + */ + fun evaluate(enabled: Boolean, temp: Float?, threshold: Int, profileExists: Boolean): Outcome = when { + !enabled -> Outcome.NOT_APPLICABLE + temp == null || temp.isNaN() -> Outcome.NOT_APPLICABLE + !profileExists -> Outcome.NOT_APPLICABLE + temp <= threshold.toFloat() -> Outcome.APPLY + else -> Outcome.NOT_APPLICABLE + } +} diff --git a/app/src/main/java/com/mg4/control/automation/AutomationSettings.kt b/app/src/main/java/com/mg4/control/automation/AutomationSettings.kt new file mode 100644 index 0000000..e475e43 --- /dev/null +++ b/app/src/main/java/com/mg4/control/automation/AutomationSettings.kt @@ -0,0 +1,37 @@ +package com.mg4.control.automation + +import android.content.Context + +/** Clés + defaults de l'automatisation température, partagés entre l'UI et le service. */ +object AutomationSettings { + + const val PREFS = "mg4_settings" + const val KEY_ENABLED = "automation_temp_enabled" + const val KEY_THRESHOLD = "automation_temp_threshold" + const val KEY_PROFILE_ID = "automation_temp_profile_id" + const val KEY_AUTO_EXECUTE = "automation_temp_auto_execute" + + const val DEFAULT_THRESHOLD = 25 + const val MIN_TEMP = 0 + const val MAX_TEMP = 60 + + data class Config( + val enabled: Boolean, + val threshold: Int, + val profileId: String, + val autoExecute: Boolean + ) + + fun read(context: Context): Config { + val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return Config( + enabled = p.getBoolean(KEY_ENABLED, false), + threshold = p.getInt(KEY_THRESHOLD, DEFAULT_THRESHOLD), + profileId = p.getString(KEY_PROFILE_ID, "") ?: "", + autoExecute = p.getBoolean(KEY_AUTO_EXECUTE, false) + ) + } + + /** Clampe une saisie de seuil dans [MIN_TEMP, MAX_TEMP] ; null/vide => défaut. */ + fun clampTemp(raw: Int?): Int = (raw ?: DEFAULT_THRESHOLD).coerceIn(MIN_TEMP, MAX_TEMP) +} diff --git a/app/src/main/java/com/mg4/control/service/ProfileConfirmOverlay.kt b/app/src/main/java/com/mg4/control/service/ProfileConfirmOverlay.kt new file mode 100644 index 0000000..6a5bfbb --- /dev/null +++ b/app/src/main/java/com/mg4/control/service/ProfileConfirmOverlay.kt @@ -0,0 +1,128 @@ +package com.mg4.control.service + +import android.content.Context +import android.graphics.PixelFormat +import android.os.Handler +import android.os.Looper +import android.view.ContextThemeWrapper +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.TextView +import com.google.android.material.button.MaterialButton +import com.mg4.control.R +import com.mg4.control.debug.AppLogger +import com.mg4.control.hardware.VehicleWriteGate +import com.mg4.control.model.DrivingProfile +import com.mg4.control.util.LocaleHelper + +/** + * Popup OUI/NON demandant s'il faut appliquer [profile] car la temp ext dépasse un seuil. + * Calqué sur ProfilePickerOverlay (fenêtre overlay, compte à rebours 8 s, verrou 0 km/h). + * OUI → onConfirmed ; NON ou timeout → onDeclined (une seule fois). + */ +object ProfileConfirmOverlay { + + private const val TAG = "MG4_OVERLAY" + private const val AUTO_DISMISS_MS = 8_000L + + private val handler = Handler(Looper.getMainLooper()) + @Volatile private var overlayView: View? = null + private var dismissRunnable: Runnable? = null + private var countdownRunnable: Runnable? = null + + fun show( + context: Context, + profile: DrivingProfile, + threshold: Int, + currentTemp: Float, + onConfirmed: () -> Unit, + onDeclined: () -> Unit + ) { + handler.post { showOnMain(context, profile, threshold, currentTemp, onConfirmed, onDeclined) } + } + + private fun showOnMain( + context: Context, + profile: DrivingProfile, + threshold: Int, + currentTemp: Float, + onConfirmed: () -> Unit, + onDeclined: () -> Unit + ) { + // En roulant (verrou actif) : pas d'écriture → on décline directement (fallback BT/défaut). + if (!VehicleWriteGate.isAllowedNow()) { + AppLogger.w(TAG, "Confirm non affiché : sécurité conduite active → onDeclined") + onDeclined(); return + } + dismiss(context) + + val localized = LocaleHelper.applyLocale(context) + val themed = ContextThemeWrapper(localized, R.style.Theme_MG4Control) + val view = LayoutInflater.from(themed).inflate(R.layout.overlay_profile_confirm, null) + + val tempStr = String.format(java.util.Locale.getDefault(), "%.1f", currentTemp) + view.findViewById(R.id.confirm_message).text = + localized.getString(R.string.automation_confirm_msg, threshold, tempStr, profile.name) + + // Un seul chemin de sortie : garde-fou pour ne déclencher qu'un callback. + var done = false + fun finish(confirmed: Boolean) { + if (done) return + done = true + dismiss(context) + if (confirmed) onConfirmed() else onDeclined() + } + + view.findViewById(R.id.confirm_btn_yes).setOnClickListener { finish(true) } + view.findViewById(R.id.confirm_btn_no).setOnClickListener { finish(false) } + view.findViewById(R.id.confirm_backdrop).setOnClickListener { finish(false) } + + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT + ).apply { gravity = Gravity.CENTER } + wm.addView(view, params) + overlayView = view + AppLogger.i(TAG, "Confirm affiché pour '${profile.name}'") + + val tvCountdown = view.findViewById(R.id.confirm_countdown) + var remaining = (AUTO_DISMISS_MS / 1_000L).toInt() + val tick = object : Runnable { + override fun run() { + if (overlayView == null) return + tvCountdown.text = localized.getString(R.string.overlay_countdown, remaining) + if (remaining > 0) { remaining--; handler.postDelayed(this, 1_000L) } + } + } + countdownRunnable = tick + handler.post(tick) + + val dr = Runnable { + AppLogger.i(TAG, "Confirm — timeout → onDeclined") + finish(false) + } + dismissRunnable = dr + handler.postDelayed(dr, AUTO_DISMISS_MS) + } + + private fun dismiss(context: Context) { + dismissRunnable?.let { handler.removeCallbacks(it) } + countdownRunnable?.let { handler.removeCallbacks(it) } + dismissRunnable = null + countdownRunnable = null + val v = overlayView ?: return + overlayView = null + try { + (context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).removeView(v) + AppLogger.i(TAG, "Confirm fermé") + } catch (e: Exception) { + AppLogger.i(TAG, "Erreur fermeture confirm : ${e.message}") + } + } +} diff --git a/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt b/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt new file mode 100644 index 0000000..269b8a4 --- /dev/null +++ b/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt @@ -0,0 +1,99 @@ +package com.mg4.control.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.ArrayAdapter +import android.widget.CheckBox +import android.widget.EditText +import android.widget.Spinner +import android.widget.Switch +import androidx.fragment.app.Fragment +import com.mg4.control.R +import com.mg4.control.automation.AutomationSettings +import com.mg4.control.model.DrivingProfile +import com.mg4.control.profile.ProfileManager + +class AutomationFragment : Fragment() { + + private var profiles: List = emptyList() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.fragment_automation, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val prefs = requireContext().getSharedPreferences(AutomationSettings.PREFS, Context.MODE_PRIVATE) + + val switchAuto = view.findViewById(R.id.switch_automation) + val rowConfig = view.findViewById(R.id.row_automation_config) + val inputTemp = view.findViewById(R.id.input_automation_temp) + val spinner = view.findViewById(R.id.spinner_automation_profile) + val checkAuto = view.findViewById(R.id.check_auto_execute) + + val enabled = prefs.getBoolean(AutomationSettings.KEY_ENABLED, false) + switchAuto.isChecked = enabled + rowConfig.visibility = if (enabled) View.VISIBLE else View.GONE + inputTemp.setText(prefs.getInt(AutomationSettings.KEY_THRESHOLD, AutomationSettings.DEFAULT_THRESHOLD).toString()) + checkAuto.isChecked = prefs.getBoolean(AutomationSettings.KEY_AUTO_EXECUTE, false) + + switchAuto.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean(AutomationSettings.KEY_ENABLED, checked).apply() + rowConfig.visibility = if (checked) View.VISIBLE else View.GONE + } + + fun commitTemp() { + val clamped = AutomationSettings.clampTemp(inputTemp.text.toString().toIntOrNull()) + prefs.edit().putInt(AutomationSettings.KEY_THRESHOLD, clamped).apply() + val txt = clamped.toString() + if (inputTemp.text.toString() != txt) inputTemp.setText(txt) + } + inputTemp.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) commitTemp() } + inputTemp.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) commitTemp() + false + } + + checkAuto.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean(AutomationSettings.KEY_AUTO_EXECUTE, checked).apply() + } + + setupSpinner(spinner, prefs) + } + + override fun onResume() { + super.onResume() + // Les profils peuvent avoir changé dans l'onglet Profils → on recharge la liste. + view?.findViewById(R.id.spinner_automation_profile)?.let { sp -> + val prefs = requireContext().getSharedPreferences(AutomationSettings.PREFS, Context.MODE_PRIVATE) + setupSpinner(sp, prefs) + } + } + + private fun setupSpinner(spinner: Spinner, prefs: android.content.SharedPreferences) { + profiles = ProfileManager(requireContext()).getAll() + val labels = if (profiles.isEmpty()) listOf(getString(R.string.automation_no_profile)) + else profiles.map { it.name } + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, labels) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spinner.adapter = adapter + spinner.isEnabled = profiles.isNotEmpty() + + // Positionne sur le profil déjà configuré. + val savedId = prefs.getString(AutomationSettings.KEY_PROFILE_ID, "") ?: "" + val idx = profiles.indexOfFirst { it.id == savedId } + if (idx >= 0) spinner.setSelection(idx) + + spinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: android.widget.AdapterView<*>?, v: View?, position: Int, id: Long) { + if (profiles.isEmpty()) return + prefs.edit().putString(AutomationSettings.KEY_PROFILE_ID, profiles[position].id).apply() + } + override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {} + } + } +} diff --git a/app/src/main/res/drawable/bg_confirm_message.xml b/app/src/main/res/drawable/bg_confirm_message.xml new file mode 100644 index 0000000..f14e22c --- /dev/null +++ b/app/src/main/res/drawable/bg_confirm_message.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_automation.xml b/app/src/main/res/layout/fragment_automation.xml new file mode 100644 index 0000000..89be8a9 --- /dev/null +++ b/app/src/main/res/layout/fragment_automation.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/overlay_profile_confirm.xml b/app/src/main/res/layout/overlay_profile_confirm.xml new file mode 100644 index 0000000..ee2be46 --- /dev/null +++ b/app/src/main/res/layout/overlay_profile_confirm.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/mg4/control/automation/AutomationDecisionTest.kt b/app/src/test/java/com/mg4/control/automation/AutomationDecisionTest.kt new file mode 100644 index 0000000..d36edeb --- /dev/null +++ b/app/src/test/java/com/mg4/control/automation/AutomationDecisionTest.kt @@ -0,0 +1,40 @@ +package com.mg4.control.automation + +import com.mg4.control.automation.AutomationDecision.Outcome +import org.junit.Assert.assertEquals +import org.junit.Test + +class AutomationDecisionTest { + + @Test fun `desactive - non applicable`() { + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(false, 30f, 25, true)) + } + + @Test fun `temp illisible - non applicable`() { + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, null, 25, true)) + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, Float.NaN, 25, true)) + } + + @Test fun `profil absent - non applicable`() { + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, 30f, 25, false)) + } + + @Test fun `sous le seuil - applique`() { + assertEquals(Outcome.APPLY, AutomationDecision.evaluate(true, 24.9f, 25, true)) + } + + @Test fun `au seuil (borne incluse) - applique`() { + assertEquals(Outcome.APPLY, AutomationDecision.evaluate(true, 25f, 25, true)) + } + + @Test fun `au dessus du seuil - non applicable`() { + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, 31.5f, 25, true)) + } + + @Test fun `clampTemp borne 0 a 60, defaut si null`() { + assertEquals(25, AutomationSettings.clampTemp(null)) + assertEquals(0, AutomationSettings.clampTemp(-5)) + assertEquals(60, AutomationSettings.clampTemp(120)) + assertEquals(18, AutomationSettings.clampTemp(18)) + } +}