From 12e4057a926b899310c6482c2568feed28f559be Mon Sep 17 00:00:00 2001 From: malys Date: Wed, 22 Jul 2026 14:40:52 +0200 Subject: [PATCH 01/13] build: add emulator tasks to mise emulator-setup / emulator-car / emulator-screen / emulator-stop / run. No single emulator matches the MG4: the car runs AAOS 9 (API 28) and Google publishes no Automotive system image below API 33 (checked with sdkmanager --list). So two AVDs, each faithful on one axis -- emulator-car is API 33 Automotive (has CarPropertyManager, wrong OS), emulator-screen is API 28 at the MG4's 1920x1080 @ 160dpi panel (right OS and screen, no car service). Neither exposes the SAIC vendor properties, so those reads fail on both, as they should. `run` builds, installs with -g to pre-grant runtime permissions, and launches the activity on whatever device is attached -- emulator or the car over ADB. setup aborts if /dev/kvm is missing and warns when the user is not in the kvm group, rather than handing over an unusably slow emulator. This is a local dev convenience only; nothing here runs in CI or changes the build. Co-Authored-By: Claude Opus 4.8 --- mise.toml | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/mise.toml b/mise.toml index ba60511..a3d6ced 100644 --- a/mise.toml +++ b/mise.toml @@ -31,6 +31,32 @@ BUILD_TOOLS = "34.0.0" # Gradle doit tourner sur le JDK de mise. Sans -Dorg.gradle.java.home, Gradle réutilise # un daemon déjà lancé par l'extension Java de VS Code, qui embarque un JRE 21 sans # jlink : le build échoue alors sur JdkImageTransform, sans rapport avec le code. +# ── Émulateur ──────────────────────────────────────────────────────────────────── +# +# ATTENTION sur la fidélité : le véhicule tourne en AAOS 9 (API 28). Google ne publie +# AUCUNE image système Automotive en dessous d'API 33 — vérifié avec sdkmanager --list. +# Il n'existe donc pas d'émulateur qui reproduise à la fois l'OS et l'API voiture du MG4. +# D'où deux profils, chacun fidèle sur un axe : +# +# emulator-car API 33 Automotive — a bien un CarPropertyManager, mauvaise version +# d'OS. Pour tester le cycle de vie du service et le chemin voiture. +# Les propriétés VENDEUR SAIC (0x216xxxxx : SOC, autonomie) n'existent +# pas dans l'émulateur : ces lectures échoueront, c'est normal. +# emulator-screen API 28 tablette — bonne version d'OS et bon écran (1920x1080 @ 160dpi, +# 12,8" HD comme le MG4), mais AUCUN service voiture. +# Pour l'UI, les layouts et la densité. +# +# Rien de tout cela ne remplace un essai sur le véhicule. +# 12,8" HD : 1920x1080 ≈ 172 dpi réels, arrondi au bucket 160 (mdpi). + +EMU_CAR_AVD = "mg4control-car" +EMU_CAR_IMAGE = "system-images;android-33;android-automotive;x86_64" +EMU_SCREEN_AVD = "mg4control-screen" +EMU_SCREEN_IMAGE = "system-images;android-28;google_apis;x86_64" +EMU_WIDTH = "1920" +EMU_HEIGHT = "1080" +EMU_DENSITY = "160" + [tasks.bootstrap] description = "Installe le SDK Android (platform + build-tools) s'il manque" run = """ @@ -83,3 +109,90 @@ run = './gradlew -Dorg.gradle.java.home=$JAVA_HOME assembleOnlineRelease assembl [tasks.clean] run = './gradlew -Dorg.gradle.java.home=$JAVA_HOME clean' + +[tasks.emulator-setup] +description = "Installe l'émulateur + les images système et crée les deux AVD" +run = ''' +set -e +SDK="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" +AVDM="$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" + +if [ ! -e /dev/kvm ]; then + echo "ERREUR : /dev/kvm absent — pas d'accélération matérielle, l'émulateur sera inutilisable." >&2 + echo "Sous WSL2 : activer la virtualisation imbriquée côté Windows." >&2 + exit 1 +fi +if ! id -nG | tr ' ' '\n' | grep -qx kvm; then + echo "ATTENTION : utilisateur absent du groupe kvm. Corriger avec :" >&2 + echo " sudo usermod -aG kvm $USER # puis se reconnecter" >&2 +fi + +yes | "$SDK" --sdk_root="$ANDROID_HOME" --licenses > /dev/null +"$SDK" --sdk_root="$ANDROID_HOME" "emulator" "platform-tools" "$EMU_CAR_IMAGE" "$EMU_SCREEN_IMAGE" + +create_avd() { + name="$1"; image="$2"; device="$3" + if "$AVDM" list avd 2>/dev/null | grep -q "Name: $name"; then + echo "AVD $name déjà présent" + else + echo no | "$AVDM" create avd -n "$name" -k "$image" -d "$device" --force + fi + cfg="$HOME/.android/avd/$name.avd/config.ini" + # Écran du MG4. Sans ça l'AVD garde la densité du profil générique et les layouts + # ne ressemblent pas à ce que voit le conducteur. + sed -i '/^hw.lcd/d;/^disk.dataPartition.size/d' "$cfg" + { + echo "hw.lcd.width=$EMU_WIDTH" + echo "hw.lcd.height=$EMU_HEIGHT" + echo "hw.lcd.density=$EMU_DENSITY" + echo "disk.dataPartition.size=4G" + } >> "$cfg" + echo "AVD $name configuré en ${EMU_WIDTH}x${EMU_HEIGHT} @ ${EMU_DENSITY}dpi" +} + +create_avd "$EMU_CAR_AVD" "$EMU_CAR_IMAGE" "automotive_1024p_landscape" +create_avd "$EMU_SCREEN_AVD" "$EMU_SCREEN_IMAGE" "pixel_c" +''' + +[tasks.emulator-car] +description = "Lance l'émulateur Automotive (API 33 — a un CarPropertyManager)" +run = ''' +set -e +# swiftshader_indirect : le GPU hôte n'est pas exposé de façon fiable sous WSL2. +"$ANDROID_HOME/emulator/emulator" -avd "$EMU_CAR_AVD" \ + -gpu swiftshader_indirect -no-snapshot-save -no-boot-anim & +"$ANDROID_HOME/platform-tools/adb" wait-for-device +echo "Émulateur voiture prêt." +''' + +[tasks.emulator-screen] +description = "Lance l'émulateur écran MG4 (API 28, 1920x1080 @ 160dpi — pas de service voiture)" +run = ''' +set -e +"$ANDROID_HOME/emulator/emulator" -avd "$EMU_SCREEN_AVD" \ + -gpu swiftshader_indirect -no-snapshot-save -no-boot-anim & +"$ANDROID_HOME/platform-tools/adb" wait-for-device +echo "Émulateur écran prêt." +''' + +[tasks.emulator-stop] +description = "Arrête l'émulateur en cours" +run = '"$ANDROID_HOME/platform-tools/adb" emu kill || true' + +[tasks.run] +description = "Compile, installe et lance l'APK sur l'émulateur ou le véhicule connecté" +depends = ["build"] +run = ''' +set -e +ADB="$ANDROID_HOME/platform-tools/adb" +if [ -z "$($ADB devices | sed '1d' | grep -w device || true)" ]; then + echo "Aucun appareil connecté. Lancer d'abord 'mise run emulator-car' ou 'mise run emulator-screen'," >&2 + echo "ou brancher le véhicule en ADB." >&2 + exit 1 +fi +APK=$(ls -t app/build/outputs/apk/online/debug/*.apk | head -1) +echo "Installation de $APK" +$ADB install -r -g "$APK" +$ADB shell am start -n "com.mg4.control/com.mg4.control.MainActivity" +echo "Lancé. Logs : mise run logs" +''' From 7fdf819515356d51ec7c1f0709e34f14a8b56337 Mon Sep 17 00:00:00 2001 From: Malys Date: Wed, 22 Jul 2026 15:04:46 +0200 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20TaskerBridgeService=20=E2=80=94?= =?UTF-8?q?=20narrow=20IPC=20surface=20for=20MG4Tasker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes a 4-method AIDL bridge (ITaskerBridge) that lets a separately signed companion app (MG4Tasker) read a vehicle snapshot and request named actions, without granting it any vehicle access of its own. Design: - Closed action catalogue, no raw property-write method. A caller can only request what the user could already do from MG4Control's UI. VEHICLE_POWER_OFF is intentionally excluded. - Every write still runs in this process, so VehicleWriteGate (0 km/h) applies in one place and the per-firmware routing is not duplicated. - Guarded by a new signature-level permission (com.mg4.control.permission. TASKER_BRIDGE); the exported service and the ignition broadcast both require it. - Ignition broadcast to MG4Tasker is delayed ~8s so the default profile finishes applying first, avoiding interleaved write sequences. Also adds read-only outside-temperature and climate/window reads (HVAC AC/AUTO/recirc/fan/temperature, window position). These use standard AOSP property ids and are UNVERIFIED on MG4 firmware — all return null when unreadable, for the MG4Tasker diagnostic screen to check exposure before any write path is added. No write counterparts. Permission added to the allowlist; ProGuard keeps the AIDL stub. Co-Authored-By: Claude Opus 4.8 --- .github/security/permission-allowlist.txt | 4 + app/build.gradle.kts | 2 + app/proguard-rules.pro | 5 + app/src/main/AndroidManifest.xml | 18 ++ .../com/mg4/control/tasker/ITaskerBridge.aidl | 37 +++ .../com/mg4/control/hardware/MG4Hardware.kt | 84 ++++++ .../mg4/control/service/MG4ControlService.kt | 34 +++ .../mg4/control/tasker/TaskerBridgeService.kt | 281 ++++++++++++++++++ .../control/tasker/TaskerBridgeVerdictTest.kt | 48 +++ 9 files changed, 513 insertions(+) create mode 100644 app/src/main/aidl/com/mg4/control/tasker/ITaskerBridge.aidl create mode 100644 app/src/main/java/com/mg4/control/tasker/TaskerBridgeService.kt create mode 100644 app/src/test/java/com/mg4/control/tasker/TaskerBridgeVerdictTest.kt diff --git a/.github/security/permission-allowlist.txt b/.github/security/permission-allowlist.txt index c55a502..23f507a 100644 --- a/.github/security/permission-allowlist.txt +++ b/.github/security/permission-allowlist.txt @@ -23,6 +23,10 @@ android.car.permission.CONTROL_CAR_CLIMATE # seule une app signée avec la clé plateforme peut l'obtenir. Elle restreint un accès, # elle n'en accorde pas. com.mg4.control.permission.RECEIVE_HARDKEY +# Pont MG4Tasker : ferme l'accès au TaskerBridgeService et au broadcast d'allumage. +# protectionLevel="signature" → réservée aux apps signées avec la clé plateforme. +# Déclarée ET demandée par MG4Control : demandée pour pouvoir émettre le broadcast. +com.mg4.control.permission.TASKER_BRIDGE # --- network / auto-update (online variant only) --- android.permission.INTERNET android.permission.ACCESS_NETWORK_STATE diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 63d61d6..b780bd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,6 +70,8 @@ android { buildFeatures { viewBinding = true buildConfig = true + // ITaskerBridge : contrat IPC partagé avec MG4Tasker (même package AIDL des deux côtés). + aidl = true } // Tests unitaires JVM (pas de véhicule, pas d'émulateur) : Robolectric a besoin des diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 603e3c7..770003e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -9,6 +9,11 @@ # ServiceManager, which was already listed. -keep class android.os.SystemProperties { *; } +# Contrat IPC avec MG4Tasker. Le Stub/Proxy AIDL est résolu par nom côté client : +# renommer ces classes casserait le bind au lieu d'échouer à la compilation. +-keep interface com.mg4.control.tasker.ITaskerBridge { *; } +-keep class com.mg4.control.tasker.ITaskerBridge$* { *; } + # Gson (profils + sauvegarde). Sans Signature, le type générique de # TypeToken> est effacé et la désérialisation rend une liste de # LinkedTreeMap : les profils disparaîtraient silencieusement au premier lancement d'une diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ce3abb3..a63cd47 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,15 @@ android:name="com.mg4.control.permission.RECEIVE_HARDKEY" android:protectionLevel="signature" /> + + + + @@ -65,6 +74,15 @@ android:directBootAware="true" android:foregroundServiceType="connectedDevice" /> + + + @@ -55,6 +57,7 @@ From 023c8a35394d6b3c7225eae690795251cf093ecb Mon Sep 17 00:00:00 2001 From: Malys Date: Thu, 23 Jul 2026 16:42:11 +0200 Subject: [PATCH 10/13] chore: remove the dead MG4Tasker ignition broadcast MG4Tasker is now independent and listens for ignition itself, so MG4Control's ignition notification broadcast had no receiver. Remove notifyTaskerOnIgnition(), the ACTION_IGNITION_ON constant, the TASKER_* constants, and MG4Control's own use of the TASKER_BRIDGE permission (it still declares the permission to protect the profile bridge service). Build + tests green. Co-Authored-By: Claude Opus 4.8 --- app/src/main/AndroidManifest.xml | 7 ++-- .../mg4/control/service/MG4ControlService.kt | 32 ------------------- .../mg4/control/tasker/TaskerBridgeService.kt | 2 -- 3 files changed, 3 insertions(+), 38 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b762edc..96f3bb9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,13 +30,12 @@ android:protectionLevel="signature" /> + clé plateforme peut se lier au TaskerBridgeService. MG4Control la déclare pour + protéger ce service ; MG4Tasker (signé avec la même clé) l'obtient et l'utilise + uniquement pour l'action « appliquer un profil ». --> - 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 1d5e8cf..d66452a 100644 --- a/app/src/main/java/com/mg4/control/service/MG4ControlService.kt +++ b/app/src/main/java/com/mg4/control/service/MG4ControlService.kt @@ -29,7 +29,6 @@ import com.mg4.hardware.model.RegenLevel import com.mg4.control.profile.ProfileApplier import com.mg4.control.profile.ProfileManager import com.mg4.control.shortcut.ShortcutAction -import com.mg4.control.tasker.TaskerBridgeService import com.mg4.hardware.FirmwareInfo import com.mg4.hardware.VehicleWriteGate import com.mg4.control.util.ThemeHelper @@ -45,11 +44,7 @@ class MG4ControlService : Service() { private const val NOTIF_ID = 1 private const val PREFS_SHORTCUTS = "mg4_shortcuts" - /** Destinataire du broadcast d'allumage. Absent = broadcast sans effet, pas d'erreur. */ - private const val TASKER_PACKAGE = "com.mg4.tasker" - /** Marge laissée à l'application du profil par défaut (HVAC scrute jusqu'à ~7 s). */ - private const val TASKER_NOTIFY_DELAY_MS = 8_000L // Intent action broadcast par le système SAIC pour les touches physiques private const val HARDKEY_ACTION = "com.saic.keyevent.hardkey.report" @@ -464,7 +459,6 @@ class MG4ControlService : Service() { Handler(Looper.getMainLooper()).postDelayed({ applyDefaultProfileOnIgnition() }, 500L) - notifyTaskerOnIgnition() } MG4Hardware.CarIgnitionItem.OFF -> { // Extinction → on oublie le choix manuel : le prochain cycle repart sur le défaut/BT @@ -480,32 +474,6 @@ class MG4ControlService : Service() { AppLogger.i(TAG, "Listener Katman5 enregistré") } - /** - * Prévient MG4Tasker qu'un cycle d'allumage vient de commencer. - * - * Le délai n'est pas cosmétique : [applyDefaultProfileOnIgnition] démarre à +500 ms et - * ses écritures HVAC scrutent l'état jusqu'à ~7 s. Réveiller le tasker plus tôt le - * ferait écrire pendant qu'un profil est en cours d'application — ces écritures - * unitaires ne passent pas par le mutex de ProfileApplier et s'entrelaceraient avec - * les séquences ADAS multi-étapes. On laisse donc le profil se poser d'abord. - * - * Broadcast explicite (package ciblé) + permission signature : aucune app tierce ne - * peut ni le recevoir ni s'y substituer. - */ - private fun notifyTaskerOnIgnition() { - Handler(Looper.getMainLooper()).postDelayed({ - val intent = Intent(TaskerBridgeService.ACTION_IGNITION_ON).apply { - setPackage(TASKER_PACKAGE) - } - try { - sendBroadcast(intent, TaskerBridgeService.PERMISSION_BRIDGE) - AppLogger.i(TAG, "IGNITION → notification MG4Tasker envoyée") - } catch (e: Exception) { - AppLogger.w(TAG, "IGNITION → notification MG4Tasker échouée : ${e.message}") - } - }, TASKER_NOTIFY_DELAY_MS) - } - /** * [BT-PROFILES] Enregistre les receivers ACL Bluetooth pour maintenir * la liste des appareils connectés dans BluetoothProfileManager. diff --git a/app/src/main/java/com/mg4/control/tasker/TaskerBridgeService.kt b/app/src/main/java/com/mg4/control/tasker/TaskerBridgeService.kt index e48eeeb..fd42816 100644 --- a/app/src/main/java/com/mg4/control/tasker/TaskerBridgeService.kt +++ b/app/src/main/java/com/mg4/control/tasker/TaskerBridgeService.kt @@ -37,8 +37,6 @@ class TaskerBridgeService : Service() { companion object { private const val TAG = "MG4_TASKER_BRIDGE" - /** Broadcast émis vers MG4Tasker quand l'allumage passe à ON. */ - const val ACTION_IGNITION_ON = "com.mg4.control.tasker.IGNITION_ON" /** Permission signature exigée du récepteur du broadcast ci-dessus. */ const val PERMISSION_BRIDGE = "com.mg4.control.permission.TASKER_BRIDGE" From 2bb82243b9727cf30fd611cfe28fd70fbf144e99 Mon Sep 17 00:00:00 2001 From: Malys Date: Thu, 23 Jul 2026 21:17:50 +0200 Subject: [PATCH 11/13] refactor: trim the Tasker bridge to profile-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MG4Tasker is now independent and reads/writes the vehicle itself, so the bridge's readSnapshot() and applyAction() had no caller. Reduce the AIDL contract and the service to what MG4Tasker actually uses — listProfiles() and applyProfile() — and correct the now-false docstring (it claimed MG4Tasker never touches the vehicle and needs no privileges). Removes the snapshot builder, the action dispatch, the snapshot keys and the PARAM_VALUE constant. Build + tests green. Co-Authored-By: Claude Opus 4.8 --- .../com/mg4/control/tasker/ITaskerBridge.aidl | 35 +-- .../mg4/control/tasker/TaskerBridgeService.kt | 224 ++---------------- 2 files changed, 28 insertions(+), 231 deletions(-) diff --git a/app/src/main/aidl/com/mg4/control/tasker/ITaskerBridge.aidl b/app/src/main/aidl/com/mg4/control/tasker/ITaskerBridge.aidl index a2b3b5e..11434cc 100644 --- a/app/src/main/aidl/com/mg4/control/tasker/ITaskerBridge.aidl +++ b/app/src/main/aidl/com/mg4/control/tasker/ITaskerBridge.aidl @@ -1,37 +1,24 @@ package com.mg4.control.tasker; /** - * Pont IPC exposé par MG4Control à MG4Tasker. + * Narrow IPC surface exposed by MG4Control to MG4Tasker — profiles only. * - * Contrat volontairement ÉTROIT : 4 méthodes, jamais une par réglage véhicule. - * Ajouter une action au catalogue ne change pas cette interface, seulement le - * dispatch interne de applyAction(). Toute écriture véhicule reste exécutée - * dans le processus MG4Control, donc soumise à VehicleWriteGate. + * MG4Tasker is an independent system app: it reads and writes the vehicle itself through + * the shared MG4Hardware layer. The one thing it cannot do on its own is apply an + * MG4Control *profile* (those live in MG4Control), so this bridge exposes exactly that and + * nothing else. There is no vehicle read or raw property write here. * - * Protégé par la permission signature com.mg4.control.permission.TASKER_BRIDGE. + * Guarded by the signature permission com.mg4.control.permission.TASKER_BRIDGE. */ interface ITaskerBridge { - /** - * Instantané de l'état véhicule pour l'évaluation des conditions. - * Toutes les clés sont optionnelles : une valeur absente = donnée illisible. - * Voir TaskerBridgeService.KEY_* pour la liste. - */ - Bundle readSnapshot(); - - /** Profils MG4Control. Bundle : "ids" String[], "names" String[], "defaultId" String. */ + /** MG4Control profiles. Bundle: "ids" String[], "names" String[], "defaultId" String. */ Bundle listProfiles(); - /** Applique un profil complet. Bundle résultat : voir applyAction. */ - Bundle applyProfile(String profileId); - /** - * Exécute une action unitaire du catalogue. - * @param actionType identifiant stable, ex. "SET_MEDIA_VOLUME" - * @param params arguments typés ("int", "bool", "string" selon l'action) - * @return Bundle : "ok" boolean, "verdict" String - * (ALLOWED | REFUSED_MOVING | REFUSED_UNKNOWN_SPEED | UNSUPPORTED | ERROR), - * "detail" String optionnel. + * Applies a whole profile. Bundle result: "ok" boolean, "verdict" String + * (ALLOWED | REFUSED_MOVING | REFUSED_UNKNOWN_SPEED | UNSUPPORTED | ERROR), + * "detail" String optional. */ - Bundle applyAction(String actionType, in Bundle params); + Bundle applyProfile(String profileId); } diff --git a/app/src/main/java/com/mg4/control/tasker/TaskerBridgeService.kt b/app/src/main/java/com/mg4/control/tasker/TaskerBridgeService.kt index fd42816..638d320 100644 --- a/app/src/main/java/com/mg4/control/tasker/TaskerBridgeService.kt +++ b/app/src/main/java/com/mg4/control/tasker/TaskerBridgeService.kt @@ -7,81 +7,29 @@ import android.os.IBinder import com.mg4.hardware.AppLogger import com.mg4.hardware.MG4Hardware import com.mg4.hardware.VehicleWriteGate -import com.mg4.hardware.model.DriveMode -import com.mg4.hardware.model.RegenLevel import com.mg4.control.profile.ProfileApplier import com.mg4.control.profile.ProfileManager -import com.mg4.hardware.FirmwareInfo -import com.mg4.control.bluetooth.BluetoothProfileManager /** - * Pont IPC pour MG4Tasker (voir [ITaskerBridge]). + * Profile bridge for MG4Tasker (see [ITaskerBridge]). * - * Principe : MG4Tasker ne touche JAMAIS le véhicule. Il lit un instantané et demande des - * actions nommées ; l'écriture réelle se fait ici, dans le processus qui détient - * android.uid.system et [VehicleWriteGate]. Conséquences voulues : - * • un seul processus écrit → pas d'entrelacement de commandes ADAS - * • le verrou 0 km/h reste appliqué à un seul endroit - * • MG4Tasker n'a pas besoin de privilèges véhicule + * MG4Tasker is an independent system app: it reads and writes the vehicle itself through + * the shared MG4Hardware layer. The one thing it cannot do alone is apply an MG4Control + * *profile* — those live in MG4Control — so this service exposes exactly that: list the + * profiles and apply one. There is no vehicle read and no raw property write here. * - * Le catalogue d'actions est une liste fermée. Il n'existe volontairement aucune méthode - * « écris la propriété 0xNNNN » : un appelant compromis ne peut solliciter que ce que - * l'utilisateur peut déjà faire depuis l'UI de MG4Control. - * - * [VEHICLE_POWER_OFF] est délibérément ABSENT du catalogue. Couper le véhicule est une - * action irréversible pour le conducteur ; elle reste réservée à un geste humain explicite - * (raccourci volant), jamais à une règle automatique. + * Guarded by the signature permission com.mg4.control.permission.TASKER_BRIDGE: only an app + * signed with the same platform key can bind. */ class TaskerBridgeService : Service() { companion object { private const val TAG = "MG4_TASKER_BRIDGE" - - /** Permission signature exigée du récepteur du broadcast ci-dessus. */ + /** Signature permission required to bind this service. */ const val PERMISSION_BRIDGE = "com.mg4.control.permission.TASKER_BRIDGE" - // ── Clés de l'instantané ──────────────────────────────────────────── - // Une clé ABSENTE signifie « illisible ». Ne jamais écrire de valeur - // sentinelle (-1) : le tasker la prendrait pour une vraie mesure. - const val KEY_SPEED_KMH = "speedKmh" // float - const val KEY_SPEED_READABLE = "speedReadable" // boolean (toujours présent) - const val KEY_IGNITION = "ignition" // int - const val KEY_IN_PARK = "inPark" // boolean - const val KEY_OUTSIDE_TEMP = "outsideTempC" // float - const val KEY_DRIVE_MODE = "driveMode" // int (DriveMode.value) - const val KEY_REGEN_LEVEL = "regenLevel" // int (RegenLevel.value) - const val KEY_SEAT_HEAT_L = "seatHeatLeft" // int - const val KEY_SEAT_HEAT_R = "seatHeatRight" // int - const val KEY_STEERING_HEAT = "steeringHeat" // boolean - const val KEY_MEDIA_VOLUME = "mediaVolume" // int - const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax" // int - const val KEY_BRIGHTNESS = "brightnessPct" // int - const val KEY_OVERSPEED_ALARM = "overspeedAlarm" // boolean - const val KEY_SPEED_LIMIT_TONE = "speedLimitTone" // boolean - const val KEY_SOUND_WARNING = "soundWarning" // boolean - const val KEY_AEB_ENABLED = "aebEnabled" // boolean - const val KEY_AEB_MODE = "aebMode" // int - const val KEY_AEB_SENSITIVITY = "aebSensitivity" // int - const val KEY_ELK_MODE = "elkMode" // int - const val KEY_ELK_SENSITIVITY = "elkSensitivity" // int - const val KEY_TSR = "tsr" // boolean - const val KEY_ENERGY_SAVING = "energySaving" // boolean - const val KEY_ACC_TJA_MODE = "accTjaMode" // int - const val KEY_LIMITER_MODE = "limiterMode" // int - // Climate + windows — read only, unverified (see MG4Hardware). Absent = unreadable. - const val KEY_AC_ON = "acOn" // boolean - const val KEY_HVAC_AUTO = "hvacAuto" // boolean - const val KEY_RECIRC = "recirc" // boolean - const val KEY_FAN_SPEED = "fanSpeed" // int - const val KEY_TEMPERATURE_SET = "temperatureSetC" // float - const val KEY_WINDOW_OPEN = "windowOpen" // boolean - const val KEY_FIRMWARE_GEN = "firmwareGen" // String - const val KEY_BT_MACS = "btConnectedMacs" // String[] - const val KEY_HAS_AUDIO = "hasAudioControl" // boolean - const val KEY_HAS_BRIGHTNESS = "hasBrightness" // boolean - - // ── Clés du résultat ──────────────────────────────────────────────── + // Result keys (shared with MG4Tasker's BridgeContract). const val KEY_OK = "ok" const val KEY_VERDICT = "verdict" const val KEY_DETAIL = "detail" @@ -92,15 +40,10 @@ class TaskerBridgeService : Service() { const val VERDICT_UNSUPPORTED = "UNSUPPORTED" const val VERDICT_ERROR = "ERROR" - /** Clé unique des paramètres d'action (int, boolean ou String selon l'action). */ - const val PARAM_VALUE = "value" - /** - * Traduit la décision du verrou en verdict transmissible au tasker. - * - * Fonction pure et exhaustive : si [VehicleWriteGate.Decision] gagne un cas, la - * compilation casse ici plutôt que d'envoyer un verdict inventé au tasker, qui - * l'afficherait à l'utilisateur comme un motif de refus. + * Translates the gate decision into a verdict string. Pure and exhaustive: if + * [VehicleWriteGate.Decision] gains a case, compilation breaks here rather than + * sending an invented verdict to the caller. */ fun verdictOf(decision: VehicleWriteGate.Decision): String = when (decision) { VehicleWriteGate.Decision.ALLOWED -> VERDICT_ALLOWED @@ -115,55 +58,6 @@ class TaskerBridgeService : Service() { private val binder = object : ITaskerBridge.Stub() { - override fun readSnapshot(): Bundle = Bundle().apply { - val speed = MG4Hardware.getVehicleSpeedKmh() - putBoolean(KEY_SPEED_READABLE, speed != null) - speed?.let { putFloat(KEY_SPEED_KMH, it) } - - MG4Hardware.getCurrentIgnitionState().takeIf { it > 0 }?.let { putInt(KEY_IGNITION, it) } - MG4Hardware.isVehicleInPark()?.let { putBoolean(KEY_IN_PARK, it) } - MG4Hardware.getOutsideTempCelsius()?.let { putFloat(KEY_OUTSIDE_TEMP, it) } - - MG4Hardware.getDriveMode()?.let { putInt(KEY_DRIVE_MODE, it.value) } - MG4Hardware.getRegenLevel()?.let { putInt(KEY_REGEN_LEVEL, it.value) } - - putIfReadable(KEY_SEAT_HEAT_L, MG4Hardware.getSeatHeatLeft()) - putIfReadable(KEY_SEAT_HEAT_R, MG4Hardware.getSeatHeatRight()) - putBoolean(KEY_STEERING_HEAT, MG4Hardware.isSteeringHeatOn()) - - putIfReadable(KEY_MEDIA_VOLUME, MG4Hardware.getMediaVolume()) - putIfReadable(KEY_MEDIA_VOLUME_MAX, MG4Hardware.getMediaVolumeMax()) - - putBoolean(KEY_HAS_BRIGHTNESS, MG4Hardware.hasBrightnessControl()) - if (MG4Hardware.hasBrightnessControl()) { - putIfReadable(KEY_BRIGHTNESS, MG4Hardware.getScreenBrightnessPercent()) - } - - putBoolean(KEY_OVERSPEED_ALARM, MG4Hardware.isOverspeedAlarmOn()) - putBoolean(KEY_SPEED_LIMIT_TONE, MG4Hardware.isSpeedLimitToneOn()) - putBoolean(KEY_SOUND_WARNING, MG4Hardware.isSoundWarningOn()) - putBoolean(KEY_AEB_ENABLED, MG4Hardware.isAebEnabled()) - putIfReadable(KEY_AEB_MODE, MG4Hardware.getAebMode()) - putIfReadable(KEY_AEB_SENSITIVITY, MG4Hardware.getAebSensitivity()) - putIfReadable(KEY_ELK_MODE, MG4Hardware.getElkMode()) - putIfReadable(KEY_ELK_SENSITIVITY, MG4Hardware.getElkSensitivity()) - putBoolean(KEY_TSR, MG4Hardware.isTsrOn()) - putBoolean(KEY_ENERGY_SAVING, MG4Hardware.isEnergySavingOn()) - putIfReadable(KEY_ACC_TJA_MODE, MG4Hardware.getAccTjaMode()) - putIfReadable(KEY_LIMITER_MODE, MG4Hardware.getSpeedLimiterMode()) - - MG4Hardware.getAcOn()?.let { putBoolean(KEY_AC_ON, it) } - MG4Hardware.getHvacAutoOn()?.let { putBoolean(KEY_HVAC_AUTO, it) } - MG4Hardware.getRecircOn()?.let { putBoolean(KEY_RECIRC, it) } - MG4Hardware.getFanSpeed()?.let { putInt(KEY_FAN_SPEED, it) } - MG4Hardware.getTemperatureSetCelsius()?.let { putFloat(KEY_TEMPERATURE_SET, it) } - MG4Hardware.isAnyWindowOpen()?.let { putBoolean(KEY_WINDOW_OPEN, it) } - - putString(KEY_FIRMWARE_GEN, FirmwareInfo.getGeneration().name) - putBoolean(KEY_HAS_AUDIO, MG4Hardware.hasAudioControl()) - putStringArray(KEY_BT_MACS, BluetoothProfileManager.getConnectedMacs().toTypedArray()) - } - override fun listProfiles(): Bundle { val profiles = profileManager.getAll() return Bundle().apply { @@ -175,105 +69,21 @@ class TaskerBridgeService : Service() { override fun applyProfile(profileId: String?): Bundle { val profile = profileId?.let { profileManager.getById(it) } - ?: return result(false, VERDICT_UNSUPPORTED, "profil introuvable: $profileId") + ?: return result(false, VERDICT_UNSUPPORTED, "profile not found: $profileId") - // Le gate est ré-évalué réglage par réglage dans ProfileApplier ; on renvoie - // ici le verdict courant pour que le tasker sache s'il faut s'attendre à des - // refus partiels. L'application elle-même est asynchrone. - val verdict = gateVerdict() + // The gate is re-evaluated setting-by-setting inside ProfileApplier; the current + // verdict is returned here so the caller knows whether to expect partial + // refusals. Application itself is asynchronous. + val verdict = verdictOf(VehicleWriteGate.decide(MG4Hardware.getVehicleSpeedKmh())) ProfileApplier.apply(profile, autoStart = true) AppLogger.i(TAG, "applyProfile(${profile.name}) verdict=$verdict") return result(true, verdict, profile.name) } - - override fun applyAction(actionType: String?, params: Bundle?): Bundle { - val type = actionType ?: return result(false, VERDICT_UNSUPPORTED, "action nulle") - val args = params ?: Bundle() - return try { - dispatch(type, args) - } catch (e: Exception) { - AppLogger.w(TAG, "applyAction($type) exception: ${e.message}") - result(false, VERDICT_ERROR, e.message) - } - } - } - - // ------------------------------------------------------------------------- - // Catalogue d'actions - // ------------------------------------------------------------------------- - - /** - * [gated] = écriture qui change le comportement routier → soumise au verrou 0 km/h. - * On calcule le verdict AVANT d'appeler MG4Hardware, uniquement pour pouvoir le - * rapporter au tasker. Le refus effectif reste celui de MG4Hardware/VehicleWriteGate : - * cette pré-lecture n'est pas la garde, elle l'observe. - */ - private fun dispatch(type: String, args: Bundle): Bundle { - val i = { args.getInt(PARAM_VALUE) } - val b = { args.getBoolean(PARAM_VALUE) } - - return when (type) { - // ── Confort : pas de gate (n'altère pas le comportement routier) ── - "SET_SEAT_HEAT_LEFT" -> ungated { MG4Hardware.setSeatHeatLeft(i()) } - "SET_SEAT_HEAT_RIGHT" -> ungated { MG4Hardware.setSeatHeatRight(i()) } - "SET_STEERING_HEAT" -> ungated { MG4Hardware.setSteeringHeat(b()) } - "SET_MEDIA_VOLUME" -> ungated { MG4Hardware.setMediaVolume(i()) } - "SET_SCREEN_BRIGHTNESS" -> ungated { MG4Hardware.setScreenBrightnessPercent(i()) } - "SET_AUDIO_BALANCE" -> ungated { MG4Hardware.setAudioBalance(i()) } - "SET_AUDIO_FADER" -> ungated { MG4Hardware.setAudioFader(i()) } - "SET_BOSE_SOUND_TYPE" -> ungated { MG4Hardware.setBoseSoundType(i()) } - "SET_3D_EFFECT" -> ungated { MG4Hardware.set3dEffectType(i()) } - "SET_TONE_CONTROL" -> ungated { MG4Hardware.setToneControl(i()) } - "SET_SOUND_FIELD" -> ungated { MG4Hardware.setSoundFieldType(i()) } - "SET_SPEED_VOLUME" -> ungated { MG4Hardware.setSpeedVolumeLevel(i()) } - "SET_LAS_WARNING_SOUND" -> ungated { MG4Hardware.setLasWarningSound(b()) } - "SET_LAS_WARNING_VIBRATION"-> ungated { MG4Hardware.setLasWarningVibration(b()) } - - // ── Comportement routier : gated ────────────────────────────────── - "SET_DRIVE_MODE" -> gated { MG4Hardware.setDriveMode(DriveMode.fromValue(i())) } - "SET_REGEN_LEVEL" -> gated { MG4Hardware.setRegenLevel(RegenLevel.fromValue(i())) } - "SET_ONE_PEDAL" -> gated { MG4Hardware.setOnePedal(b()) } - "SET_OVERSPEED_ALARM" -> gated { MG4Hardware.setOverspeedAlarm(b()) } - "SET_SPEED_LIMIT_TONE"-> gated { MG4Hardware.setSpeedLimitTone(b()) } - "SET_SOUND_WARNING" -> gated { MG4Hardware.setSoundWarning(b()) } - "SET_AEB_ENABLED" -> gated { MG4Hardware.setAebEnabled(b()) } - "SET_AEB_MODE" -> gated { MG4Hardware.setAebMode(i()) } - "SET_AEB_SENSITIVITY" -> gated { MG4Hardware.setAebSensitivity(i()) } - "SET_ELK_MODE" -> gated { MG4Hardware.setElkMode(i()) } - "SET_ELK_SENSITIVITY" -> gated { MG4Hardware.setElkSensitivity(i()) } - "SET_TSR" -> gated { MG4Hardware.setTsrMode(b()) } - "SET_ENERGY_SAVING" -> gated { MG4Hardware.setEnergySavingMode(b()) } - "SET_ACC_TJA_MODE" -> gated { MG4Hardware.setAccTjaMode(i()) } - "SET_LIMITER_MODE" -> gated { MG4Hardware.setSpeedLimiterMode(i()) } - "SET_INTELLIGENT_DRIVE"-> gated { MG4Hardware.setMixedIntelligentDrive(i()) } - - else -> result(false, VERDICT_UNSUPPORTED, "action inconnue: $type") - } - } - - private inline fun ungated(write: () -> Boolean): Bundle { - val ok = write() - return result(ok, if (ok) VERDICT_ALLOWED else VERDICT_ERROR) - } - - private inline fun gated(write: () -> Boolean): Bundle { - val verdict = gateVerdict() - if (verdict != VERDICT_ALLOWED) return result(false, verdict) - val ok = write() - return result(ok, if (ok) VERDICT_ALLOWED else VERDICT_ERROR) } - private fun gateVerdict(): String = - verdictOf(VehicleWriteGate.decide(MG4Hardware.getVehicleSpeedKmh())) - private fun result(ok: Boolean, verdict: String, detail: String? = null) = Bundle().apply { putBoolean(KEY_OK, ok) putString(KEY_VERDICT, verdict) detail?.let { putString(KEY_DETAIL, it) } } - - /** Les getters MG4Hardware renvoient -1 quand la couche n'est pas prête : on n'écrit rien. */ - private fun Bundle.putIfReadable(key: String, value: Int) { - if (value >= 0) putInt(key, value) - } } From 69f7fe5c15e63420172c62a2985b87a618963274 Mon Sep 17 00:00:00 2001 From: Malys Date: Thu, 23 Jul 2026 21:34:33 +0200 Subject: [PATCH 12/13] refactor: generalize the Tasker bridge into a caller-agnostic profile API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IPC surface was named for one caller (MG4Tasker), but it is just MG4Control's external control API — list and apply driving profiles, nothing else. Rename it so any app signed with the platform key can use it without the naming implying a single client: - com.mg4.control.tasker.ITaskerBridge -> com.mg4.control.api.IProfileControl - TaskerBridgeService -> ProfileControlService - permission …TASKER_BRIDGE -> …CONTROL_PROFILES No behaviour change: same two methods, same signature-level protection, same 0 km/h gate verdicts. Manifest, proguard keep rules, permission allowlist and the verdict test move with it; French bridge comments translated to English. Co-Authored-By: Claude Opus 4.8 --- .github/security/permission-allowlist.txt | 4 +- app/proguard-rules.pro | 8 ++-- app/src/main/AndroidManifest.xml | 21 ++++---- .../com/mg4/control/api/IProfileControl.aidl | 23 +++++++++ .../com/mg4/control/tasker/ITaskerBridge.aidl | 24 ---------- .../ProfileControlService.kt} | 25 +++++----- .../control/api/ProfileControlVerdictTest.kt | 48 +++++++++++++++++++ .../control/tasker/TaskerBridgeVerdictTest.kt | 48 ------------------- 8 files changed, 99 insertions(+), 102 deletions(-) create mode 100644 app/src/main/aidl/com/mg4/control/api/IProfileControl.aidl delete mode 100644 app/src/main/aidl/com/mg4/control/tasker/ITaskerBridge.aidl rename app/src/main/java/com/mg4/control/{tasker/TaskerBridgeService.kt => api/ProfileControlService.kt} (78%) create mode 100644 app/src/test/java/com/mg4/control/api/ProfileControlVerdictTest.kt delete mode 100644 app/src/test/java/com/mg4/control/tasker/TaskerBridgeVerdictTest.kt diff --git a/.github/security/permission-allowlist.txt b/.github/security/permission-allowlist.txt index 23f507a..2e0061d 100644 --- a/.github/security/permission-allowlist.txt +++ b/.github/security/permission-allowlist.txt @@ -23,10 +23,10 @@ android.car.permission.CONTROL_CAR_CLIMATE # seule une app signée avec la clé plateforme peut l'obtenir. Elle restreint un accès, # elle n'en accorde pas. com.mg4.control.permission.RECEIVE_HARDKEY -# Pont MG4Tasker : ferme l'accès au TaskerBridgeService et au broadcast d'allumage. +# External control API: signature-level permission protecting ProfileControlService. # protectionLevel="signature" → réservée aux apps signées avec la clé plateforme. # Déclarée ET demandée par MG4Control : demandée pour pouvoir émettre le broadcast. -com.mg4.control.permission.TASKER_BRIDGE +com.mg4.control.permission.CONTROL_PROFILES # --- network / auto-update (online variant only) --- android.permission.INTERNET android.permission.ACCESS_NETWORK_STATE diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 07e699a..908bb41 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -9,10 +9,10 @@ # ServiceManager, which was already listed. -keep class android.os.SystemProperties { *; } -# Contrat IPC avec MG4Tasker. Le Stub/Proxy AIDL est résolu par nom côté client : -# renommer ces classes casserait le bind au lieu d'échouer à la compilation. --keep interface com.mg4.control.tasker.ITaskerBridge { *; } --keep class com.mg4.control.tasker.ITaskerBridge$* { *; } +# External control IPC contract. The AIDL Stub/Proxy is resolved by name on the client +# side: renaming these classes would break the bind instead of failing at compile time. +-keep interface com.mg4.control.api.IProfileControl { *; } +-keep class com.mg4.control.api.IProfileControl$* { *; } # Gson (profils + sauvegarde). Sans Signature, le type générique de # TypeToken> est effacé et la désérialisation rend une liste de diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 96f3bb9..d29a3d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,12 +29,11 @@ android:name="com.mg4.control.permission.RECEIVE_HARDKEY" android:protectionLevel="signature" /> - + + + android:permission="com.mg4.control.permission.CONTROL_PROFILES" /> + diff --git a/mise.toml b/mise.toml index b6848d4..34d93ee 100644 --- a/mise.toml +++ b/mise.toml @@ -58,6 +58,7 @@ EMU_WIDTH = "1920" EMU_HEIGHT = "1080" EMU_DENSITY = "160" + [tasks.bootstrap] description = "Installe le SDK Android (platform + build-tools) s'il manque" run = """ @@ -187,10 +188,12 @@ run = ''' set -e ADB="$ANDROID_HOME/platform-tools/adb" if [ -z "$($ADB devices | sed '1d' | grep -w device || true)" ]; then - echo "Aucun appareil connecté. Lancer d'abord 'mise run emulator-car' ou 'mise run emulator-screen'," >&2 - echo "ou brancher le véhicule en ADB." >&2 + echo "Aucun appareil. Lancer 'mise run emulator-car'/'emulator-screen' ou brancher le véhicule." >&2 exit 1 fi +# Les builds debug retirent sharedUserId (overlay src/debug/AndroidManifest.xml), donc la +# clé debug suffit pour installer sur un émulateur. La release garde sharedUserId + signature +# plateforme pour le véhicule. APK=$(ls -t app/build/outputs/apk/online/debug/*.apk | head -1) echo "Installation de $APK" $ADB install -r -g "$APK"