diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml
index 19a45622..df00fb84 100644
--- a/.github/workflows/beta.yml
+++ b/.github/workflows/beta.yml
@@ -116,7 +116,11 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.ver.outputs.full }}
- name: Beta ${{ steps.ver.outputs.full }}
+ # Sans target_commitish, GitHub cree le tag sur la branche par defaut : la beta 2.6.6-beta.2
+ # a ainsi ete taguee sur main alors qu'elle etait construite depuis beta-OTA. L'APK etait
+ # bonne, mais le tag pointait vers un code qui n'etait pas celui livre.
+ target_commitish: ${{ github.sha }}
+ name: MG4Control ${{ steps.ver.outputs.full }}
prerelease: true
files: dist/*.apk
body: |
diff --git a/README.md b/README.md
index 88d2a5c1..1f4c9a5e 100644
--- a/README.md
+++ b/README.md
@@ -24,8 +24,9 @@ You enjoy MG4Control and want to support its development ?
6. [Couches matérielles](#couches-matérielles)
7. [Système de profils](#système-de-profils)
8. [Interface utilisateur](#interface-utilisateur)
-9. [Compilation et installation](#compilation-et-installation)
-10. [Permissions requises](#permissions-requises)
+9. [API externe](#api-externe-keymapper-tasker)
+10. [Compilation et installation](#compilation-et-installation)
+11. [Permissions requises](#permissions-requises)
---
@@ -49,9 +50,11 @@ L'application communique avec le véhicule via le SDK propriétaire SAIC, en acc
- **Mode de conduite** : ECO / NORMAL / SPORT / SNOW / CUSTOM
- **Régénération** : Off / Faible / Moyen / Fort / Adaptatif / 1 Pédale
-### Climatisation
+### Confort
- **Volant chauffant** : On / Off
- **Sièges chauffants gauche et droit** : Off / Niveau 1 / 2 / 3
+- **Climatisation** : consigne de température, ventilation, marche/arrêt, A/C, AUTO,
+ recirculation (intérieur / extérieur / auto), dégivrage avant et arrière
### ADAS (Assistance à la conduite)
- **SWI133** : Off / Limiteur / Auto / ACC / ICA + alertes excès de vitesse / changement de limite
@@ -60,30 +63,49 @@ L'application communique avec le véhicule via le SDK propriétaire SAIC, en acc
- **SWI165** : Désactiver / ACC / TJA + Anti-collision avant (AEB) On/Off + mode Alerte / Alerte+Freinage + avertissement sonore
### Raccourcis volant
-- Configuration des **4 boutons du volant** (boutons latéraux gauche/droit)
-- Actions disponibles : Mode de conduite / Régénération / ADAS / **Ouvrir l'application**
+- Configuration des **boutons ★ gauche et droit**, en appui **simple** ou **long**
+- Actions disponibles : 1 Pédale, cycle ADAS, cycle anticollision, alertes sonores,
+ reconnaissance des panneaux, économie d'énergie, lancer un profil, sélecteur de profil,
+ ouvrir MG4Control, lancer une application, éteindre le véhicule
+- Réglages associés affichés **uniquement si l'action correspondante est attribuée**
+ (niveau de repli du mode 1 Pédale, crans du cycle ADAS)
- Activation / désactivation des raccourcis avec **dialog d'avertissement**
+### Automatisation
+- **Application d'un profil selon la température extérieure** : seuil, sens
+ (inférieure/supérieure), profil à appliquer, exécution directe ou popup de confirmation
+- **Déclenchement A/C via la température** : deux règles indépendantes (température supérieure /
+ inférieure), chacune avec son seuil, sa consigne, sa ventilation et ses dégivrages
+- Chaque automatisation est dépliable indépendamment de son interrupteur d'activation
+
### Gestion de profils
- Sauvegarde jusqu'à **5 profils** personnalisés
- Application instantanée d'un profil en un clic
- Application automatique du profil par défaut **au démarrage du véhicule**
### Réglages
-- Choix de la langue (Français / English)
-- Activation/désactivation de l'application automatique du profil
-- **Mise à jour automatique** : vérification GitHub + téléchargement APK vers le dossier Téléchargements
-- **Nettoyage APK** : suppression des anciens fichiers `MGControl*.apk` du dossier Téléchargements
-- Dialog "À propos" avec version de l'app, version firmware et QR code GitHub
-- Bouton "Fermer" pour revenir directement au dashboard
+Écran organisé en **quatre onglets** :
+- **Langues** : français, anglais, allemand, espagnol, italien, portugais
+- **Interface** : écran affiché au démarrage, apparence (auto / sombre / clair)
+- **Réglages avancés** : application automatique du profil, vérification des mises à jour au
+ lancement, extinction du véhicule écran allumé, blocage des réglages de conduite au-delà d'une
+ vitesse donnée, **API externe** (cf. section dédiée)
+- **Infos** : vérification des mises à jour, nettoyage des APK, dialog « À propos » (version de
+ l'app, firmware, QR codes), indicateur de firmware, et bouton Diagnostic révélé par 5 clics
+ sur le logo
### Profils
-- Bouton "Fermer" pour revenir directement au dashboard
+- Liste des profils avec application, définition par défaut, modification, suppression
+- **Éditeur en plein écran** organisé en trois catégories : Conduite, Sécurité, Confort
+- Le nom du profil et le réglage « profil par défaut » restent visibles sur les trois onglets
+- Volant et sièges chauffants disposent d'un interrupteur de **prise en compte** : décoché, le
+ profil ne touche pas au réglage au lieu de l'éteindre
### Compatibilité firmware inconnue (UNKNOWN)
- Dialog d'avertissement au démarrage si le firmware n'est ni SWI133 ni SWI68
- L'utilisateur peut fermer l'application ou continuer
-- En mode "Continuer", les chips SWI133 / SWI68 / SWI69 / SWI131 deviennent cliquables pour forcer un mode de compatibilité
+- En mode "Continuer", les pastilles de firmware (*Réglages → Infos*) deviennent cliquables pour
+ forcer un mode de compatibilité
- Le choix forcé est persisté en SharedPreferences et survit aux redémarrages de l'app
---
@@ -182,10 +204,25 @@ MG4Control/
│ │ ├── hardware/
│ │ │ └── MG4Hardware.kt # Abstraction matérielle (4 couches)
│ │ │
+│ │ ├── api/
+│ │ │ ├── ExternalApi.kt # Contrat de l'API externe (actions, clés, verrous)
+│ │ │ ├── ExternalApiReceiver.kt # Réception des intents tiers
+│ │ │ └── StateProvider.kt # Lecture de l'état (ContentProvider)
+│ │ │
+│ │ ├── automation/
+│ │ │ ├── AutomationSettings.kt # Profil selon la température
+│ │ │ ├── AutomationDecision.kt # Décision pure (testable hors Android)
+│ │ │ ├── ClimateAutomationSettings.kt # Déclenchement A/C
+│ │ │ └── ClimateAutomationDecision.kt
+│ │ │
│ │ ├── ui/
-│ │ │ ├── DashboardFragment.kt # Écran principal unifié
-│ │ │ ├── ProfileFragment.kt # Gestion des profils
-│ │ │ ├── SettingsFragment.kt # Réglages & À propos
+│ │ │ ├── DashboardFragment.kt # Écran principal (rail 3 catégories)
+│ │ │ ├── ProfileFragment.kt # Liste des profils
+│ │ │ ├── ProfileEditFragment.kt # Éditeur plein écran (rail 3 catégories)
+│ │ │ ├── SettingsFragment.kt # Réglages (rail 4 onglets)
+│ │ │ ├── ShortcutsFragment.kt # Raccourcis volant (rail 2 onglets)
+│ │ │ ├── AutomationFragment.kt # Automatisations
+│ │ │ ├── AudioFragment.kt # Audio (A9 uniquement)
│ │ │ ├── ProfileAdapter.kt # Adaptateur RecyclerView profils
│ │ │ ├── ConsoleFragment.kt # Journal de debug en temps réel
│ │ │ ├── DriveRegenFragment.kt # Héritage (non utilisé en v2)
@@ -317,33 +354,177 @@ Les profils sont sérialisés en JSON via **Gson** et stockés dans `SharedPrefe
## Interface utilisateur
### Navigation
-L'application utilise un **NavController** avec **3 destinations** :
+L'application utilise un **NavController** avec **7 destinations** :
```
DashboardFragment (départ)
- ├──► ProfileFragment (bouton PROFILS — toggle)
- └──► SettingsFragment (bouton RÉGLAGES — toggle)
+ ├──► ProfileFragment ──► ProfileEditFragment (création / édition, plein écran)
+ ├──► SettingsFragment
+ ├──► ShortcutsFragment
+ ├──► AudioFragment (A9 uniquement)
+ └──► AutomationFragment
+```
+
+Les boutons de la barre du haut fonctionnent en bascule : un second appui revient au dashboard.
+
+### Rail de catégories
+Quatre écrans partagent le même motif : un **rail vertical à gauche** sélectionne une catégorie,
+le contenu défile à droite, et ce qui n'appartient à aucune catégorie reste dans un bandeau
+persistant (nom du profil, interrupteur maître) ou en pied de page (Annuler / Enregistrer / Fermer).
+
+| Écran | Onglets |
+|---|---|
+| Dashboard | Conduite · Sécurité · Confort |
+| Éditeur de profil | Conduite · Sécurité · Confort |
+| Réglages | Langues · Interface · Réglages avancés · Infos |
+| Raccourcis | Boutons · Actions |
+
+Un onglet dont la page n'a plus aucune section visible sur le firmware courant est **masqué** —
+mieux vaut pas d'onglet qu'un onglet qui ouvre une page vide.
+
+### Dimensionnement
+Valeurs communes aux écrans refondus, calées sur la lisibilité au volant : titres **20sp**,
+en-têtes de section **13sp**, libellés et boutons **16sp**, hauteur de bouton **52dp**, onglets du
+rail **64dp**, rail **180dp**, padding de carte **14dp**.
+
+### Palette de couleurs
+
+L'application suit le thème clair ou sombre. Les valeurs claires sont dans
+`res/values/colors.xml`, les sombres dans `res/values-night/colors.xml` — **mêmes noms de token
+des deux côtés**, c'est la seule règle à respecter en ajoutant une couleur.
+
+| Token | Clair | Sombre | Usage |
+|-------|-------|--------|-------|
+| `dash_bg` | `#F2F2F7` | `#0C0C0E` | Fond général |
+| `dash_card` | `#FFFFFF` | `#141416` | Cartes |
+| `dash_section` | `#F2F2F7` | `#1C1C1F` | Sections internes |
+| `dash_border` | `#D1D1D6` | `#2A2A2E` | Bordures et séparateurs |
+| `dash_btn` | `#E5E5EA` | `#222226` | Fond de bouton inactif |
+| `dash_text_lo` | `#8E8E93` | `#52525B` | En-têtes de section |
+| `dash_accent` | `#0284C7` | `#38BDF8` | Sélection active (bleu) |
+| `dash_accent_dim` | `#E0F2FE` | `#0C4A6E` | Fond de la sélection active |
+| `dash_eco` | `#16A34A` | `#22C55E` | Mode ECO (vert) |
+| `dash_warn` | `#D97706` | `#F59E0B` | Avertissement (orange) |
+| `dash_danger` | `#E11D48` | `#F43F5E` | Suppression / danger |
+| `text_primary` | `#1C1C1E` | `#FFFFFF` | Texte principal |
+| `text_secondary` | `#6C6C70` | `#B0B0B0` | Texte secondaire |
+
+Chaque couleur `*_dim` est le fond associé à sa couleur vive : `dash_eco_dim`, `dash_warn_dim` et
+`dash_danger_dim` suivent le même principe que `dash_accent_dim`.
+
+> **Piège de nommage :** `bg_dark` vaut `#FFFFFF` en thème clair. Le nom date d'une époque où
+> l'application n'avait qu'un thème sombre ; il désigne le fond général, pas une couleur foncée.
+
+---
+
+## API externe (KeyMapper, Tasker…)
+
+Permet à une application tierce de déclencher les fonctions de MG4Control (issue #79).
+
+> **Désactivée par défaut.** Elle s'active dans *Réglages → Réglages avancés → « API externe »*,
+> avec une confirmation explicite. Tant qu'elle est désactivée, toute commande reçue est refusée
+> et journalisée. Une fois activée, **n'importe quelle application installée** peut envoyer ces
+> intents : ils ne sont protégés par aucune permission, car KeyMapper et Tasker viennent du Play
+> Store et ne peuvent pas en détenir une de niveau `signature`.
+
+### Actions directes — une action d'intent par commande
+
+Aucun extra requis : c'est la forme utilisable depuis **KeyMapper**, dont l'éditeur d'intent ne
+propose que le type (*Broadcast receiver*) et la chaîne d'action.
+
+| Action | Effet |
+|---|---|
+| `com.mg4.control.action.ONE_PEDAL` | Bascule 1 pédale ↔ niveau de repli |
+| `com.mg4.control.action.ENERGY_SAVING_TOGGLE` | Économie d'énergie |
+| `com.mg4.control.action.PROFILE_PICKER` | Ouvre le sélecteur de profil à l'écran |
+| `com.mg4.control.action.OPEN_APP` | Ouvre MG4Control |
+
+Ce sont des **bascules** : chaque envoi inverse l'état, il n'existe pas de « mettre à ON ».
+
+> **Commandes volontairement hors API.** `VEHICLE_POWER_OFF`, `ADAS_CYCLE`, `AEB_CYCLE`,
+> `TSR_TOGGLE`, `OVERSPEED_ALARM`, `SPEED_LIMIT_TONE` et `SOUND_WARNING` ne sont **pas** exposées :
+> elles touchent à la sécurité active ou coupent le véhicule. Le refus s'applique aussi à
+> `EXECUTE` — les retirer des seules actions directes n'aurait rien protégé. Elles restent
+> pilotables depuis l'application et les raccourcis volant.
+
+**Dans KeyMapper** : ajouter une action → *Intent* (version 2.3.0 minimum) → type
+**Broadcast receiver** → coller la chaîne dans le champ *Action*.
+
+### `EXECUTE` — pour Tasker, adb, scripts
+
+`com.mg4.control.action.EXECUTE` avec un extra texte `action` valant l'un des noms ci-dessus, plus
+deux commandes que les actions directes ne peuvent pas couvrir :
+
+- `APPLY_PROFILE` — exige un extra `profile` : le nom du profil, insensible à la casse
+- `OPEN_CUSTOM_APP` — ouvre l'application configurée dans les raccourcis
+
+```bash
+adb shell am broadcast -a com.mg4.control.action.EXECUTE \
+ --es action APPLY_PROFILE --es profile "Trajet domicile"
+```
+
+### `SET` — écriture directe d'une valeur
+
+`com.mg4.control.action.SET` avec les extras `key` et `value` :
+
+| `key` | `value` accepté |
+|---|---|
+| `drive_mode` | `ECO` `NORMAL` `SPORT` `SNOW` `CUSTOM` |
+| `regen` | `OFF` `LOW` `MEDIUM` `HIGH` `ADAPTIVE` `ONE_PEDAL` |
+| `seat_heat_left` | `0` à `3` |
+| `seat_heat_right` | `0` à `3` |
+| `steering_heat` | `0`/`1` ou `false`/`true` |
+| `profile` | nom du profil |
+| `hvac_power` | `0`/`1` — marche/arrêt de la clim |
+| `ac` | `0`/`1` — compresseur A/C |
+| `hvac_auto` | `0`/`1` — mode automatique |
+| `hvac_temp` | °C, clampé aux bornes réelles du véhicule |
+| `hvac_fan` | niveau de ventilation, clampé aux bornes réelles |
+| `hvac_recirc` | `INNER` `OUTSIDE` `AUTO` (ou `0` `1` `2`) |
+| `defrost_front` | `0`/`1` |
+| `defrost_rear` | `0`/`1` |
+
+Les clés `hvac_*` et `defrost_*` sont ignorées si le firmware n'expose pas la climatisation.
+Consigne et ventilation sont clampées aux bornes **lues sur le véhicule**, qui diffèrent d'un
+firmware à l'autre. Ces commandes sont des bascules matérielles qui avancent d'un cran à la fois :
+comptez quelques secondes avant que l'état final soit atteint.
+
+```bash
+adb shell am broadcast -a com.mg4.control.action.SET --es key drive_mode --es value SPORT
```
-Un second appui sur PROFILS ou RÉGLAGES ferme la vue et revient au dashboard.
+### Lecture de l'état — ContentProvider
+
+`content://com.mg4.control.state/state` (ou `com.mg4.control.offline.state` pour la variante
+offline — l'authority suit l'applicationId). Un curseur d'**une** ligne :
+
+`drive_mode`, `regen`, `seat_heat_left`, `seat_heat_right`, `steering_heat`, `speed_kmh`,
+`outside_temp_c`, `tsr`, `energy_saving`, `aeb_enabled`, `firmware`, `profiles` (noms séparés
+par `|`), `default_profile`.
+
+Une valeur illisible vaut `null`, jamais `0` — un zéro se confondrait avec « siège éteint » ou
+« véhicule à l'arrêt ». Tasker sait interroger un ContentProvider, KeyMapper non.
-### Dashboard (écran principal)
-Disposition en **2 rangées** (ratio 2:1) optimisée pour 1280×480 :
-- **Rangée haute (2/3)** : Mode de conduite | Régénération | ADAS
-- **Rangée basse (1/3)** : Climatisation (volant + sièges) | Alertes
+Contrairement aux broadcasts, un provider connaît son appelant : chaque lecture est journalisée
+nominativement, et la préférence `external_api_allowlist` (liste de paquets séparés par des
+virgules, vide = tous acceptés) est réellement appliquée.
-### Dark theme — palette de couleurs
+### Sécurité et diagnostic
-| Token | Hex | Usage |
-|-------|-----|-------|
-| `dash_bg` | `#0C0C0E` | Fond général |
-| `dash_card` | `#141416` | Cartes |
-| `dash_section` | `#1C1C1F` | Sections internes |
-| `dash_border` | `#2A2A2E` | Bordures |
-| `dash_accent` | `#38BDF8` | Sélection active (bleu) |
-| `dash_eco` | `#22C55E` | Mode ECO (vert) |
-| `dash_warn` | `#F59E0B` | Mode SPORT (orange) |
-| `dash_danger` | `#F43F5E` | Suppression / danger |
+Le **verrou de vitesse** (*Réglages → « Bloquer les réglages de conduite au-delà d'une certaine
+vitesse »*) s'applique aussi à l'API, puisqu'il est posé dans les primitives d'écriture. Attention :
+il est lui-même **désactivé par défaut** — s'il ne l'est pas, aucune limite de vitesse ne
+s'applique aux commandes externes. Le confort (sièges, volant chauffants) n'est jamais concerné.
+
+Toute commande, acceptée ou refusée, est tracée au tag **`MG4_API`** (visible via le bouton
+Diagnostic). Pour tester l'application indépendamment de KeyMapper :
+
+```bash
+adb shell am broadcast -a com.mg4.control.action.PROFILE_PICKER
+```
+
+Silence complet = APK pas à jour ou service arrêté. `REFUS … API externe désactivée` =
+l'interrupteur des Réglages n'a pas été confirmé.
---
@@ -416,8 +597,9 @@ adb shell pm install -r --system /sdcard/app-debug.apk
6. [Hardware Layers](#hardware-layers)
7. [Profile System](#profile-system)
8. [User Interface](#user-interface)
-9. [Build & Installation](#build--installation)
-10. [Required Permissions](#required-permissions)
+9. [External API](#external-api-keymapper-tasker)
+10. [Build & Installation](#build--installation)
+11. [Required Permissions](#required-permissions)
---
@@ -441,9 +623,11 @@ The app communicates with the vehicle through the proprietary SAIC SDK, accessin
- **Drive mode**: ECO / NORMAL / SPORT / SNOW / CUSTOM
- **Regenerative braking**: Off / Low / Medium / High / Adaptive / One Pedal
-### Climate Control
+### Comfort
- **Heated steering wheel**: On / Off
- **Heated seats (left & right)**: Off / Level 1 / 2 / 3
+- **Climate control**: temperature setpoint, fan speed, power, A/C, AUTO, recirculation
+ (inner / outside / auto), front and rear defrost
### ADAS (Advanced Driver Assistance)
- **SWI133**: Off / Speed Limiter / Auto / ACC / ICA + overspeed alert / speed limit change alert
@@ -686,33 +870,176 @@ Profiles are serialized to JSON via **Gson** and stored in `SharedPreferences`.
## User Interface
### Navigation
-The app uses a **NavController** with **3 destinations**:
+The app uses a **NavController** with **7 destinations**:
```
DashboardFragment (start)
- ├──► ProfileFragment (PROFILS button — toggle)
- └──► SettingsFragment (RÉGLAGES button — toggle)
+ ├──► ProfileFragment ──► ProfileEditFragment (create / edit, full screen)
+ ├──► SettingsFragment
+ ├──► ShortcutsFragment
+ ├──► AudioFragment (A9 only)
+ └──► AutomationFragment
```
-A second press on PROFILS or RÉGLAGES closes the view and returns to the dashboard.
+Top-bar buttons act as toggles: a second press returns to the dashboard.
+
+### Category rail
+Four screens share the same pattern: a **vertical rail on the left** selects a category, the
+content scrolls on the right, and whatever belongs to no category stays in a persistent header
+(profile name, master switch) or footer (Cancel / Save / Close).
+
+| Screen | Tabs |
+|---|---|
+| Dashboard | Driving · Safety · Comfort |
+| Profile editor | Driving · Safety · Comfort |
+| Settings | Languages · Interface · Advanced · Info |
+| Shortcuts | Buttons · Actions |
+
+A tab whose page has no visible section left on the current firmware is **hidden** — better no tab
+than a tab opening an empty page.
+
+### Sizing
+Values shared by the reworked screens, tuned for readability while driving: titles **20sp**,
+section headers **13sp**, labels and buttons **16sp**, button height **52dp**, rail tabs **64dp**,
+rail width **180dp**, card padding **14dp**.
+
+### Color Palette
+
+The app follows the light or dark theme. Light values live in `res/values/colors.xml`, dark ones in
+`res/values-night/colors.xml` — **same token names on both sides**, which is the only rule to
+follow when adding a colour.
+
+| Token | Light | Dark | Usage |
+|-------|-------|------|-------|
+| `dash_bg` | `#F2F2F7` | `#0C0C0E` | App background |
+| `dash_card` | `#FFFFFF` | `#141416` | Cards |
+| `dash_section` | `#F2F2F7` | `#1C1C1F` | Inner sections |
+| `dash_border` | `#D1D1D6` | `#2A2A2E` | Borders and dividers |
+| `dash_btn` | `#E5E5EA` | `#222226` | Inactive button background |
+| `dash_text_lo` | `#8E8E93` | `#52525B` | Section headers |
+| `dash_accent` | `#0284C7` | `#38BDF8` | Active selection (blue) |
+| `dash_accent_dim` | `#E0F2FE` | `#0C4A6E` | Active selection background |
+| `dash_eco` | `#16A34A` | `#22C55E` | ECO mode (green) |
+| `dash_warn` | `#D97706` | `#F59E0B` | Warning (amber) |
+| `dash_danger` | `#E11D48` | `#F43F5E` | Delete / danger actions |
+| `text_primary` | `#1C1C1E` | `#FFFFFF` | Primary text |
+| `text_secondary` | `#6C6C70` | `#B0B0B0` | Secondary text |
+
+Every `*_dim` colour is the background paired with its vivid counterpart: `dash_eco_dim`,
+`dash_warn_dim` and `dash_danger_dim` follow the same principle as `dash_accent_dim`.
+
+> **Naming pitfall:** `bg_dark` is `#FFFFFF` in the light theme. The name dates back to when the
+> app only had a dark theme; it means the general background, not a dark colour.
+
+---
+
+## External API (KeyMapper, Tasker…)
+
+Lets a third-party app trigger MG4Control functions (issue #79).
+
+> **Disabled by default.** Turn it on in *Settings → Advanced settings → "External API"*, with an
+> explicit confirmation. While disabled, every incoming command is refused and logged. Once
+> enabled, **any installed app** can send these intents: they are protected by no permission,
+> because KeyMapper and Tasker ship from the Play Store and can never hold a `signature` one.
+
+### Direct actions — one intent action per command
+
+No extras required. This is the form usable from **KeyMapper**, whose intent editor only offers
+the type (*Broadcast receiver*) and the action string.
-### Dashboard (main screen)
-**2-row layout** (2:1 weight ratio) optimized for 1280×480:
-- **Top row (2/3 height)**: Drive mode | Regeneration | ADAS
-- **Bottom row (1/3 height)**: Climate (steering + seats) | Alerts
+| Action | Effect |
+|---|---|
+| `com.mg4.control.action.ONE_PEDAL` | Toggle 1-pedal ↔ fallback regen level |
+| `com.mg4.control.action.ENERGY_SAVING_TOGGLE` | Energy saving |
+| `com.mg4.control.action.PROFILE_PICKER` | Show the on-screen profile picker |
+| `com.mg4.control.action.OPEN_APP` | Open MG4Control |
-### Dark Theme — Color Palette
+These are **toggles**: each send flips the state, there is no "set to ON".
+
+> **Deliberately out of the API.** `VEHICLE_POWER_OFF`, `ADAS_CYCLE`, `AEB_CYCLE`, `TSR_TOGGLE`,
+> `OVERSPEED_ALARM`, `SPEED_LIMIT_TONE` and `SOUND_WARNING` are **not** exposed: they affect active
+> safety or shut the vehicle down. The refusal also covers `EXECUTE` — removing them from the direct
+> actions alone would have protected nothing. They remain available from the app and the steering
+> wheel shortcuts.
+
+**In KeyMapper**: add an action → *Intent* (version 2.3.0 minimum) → type **Broadcast receiver** →
+paste the string into the *Action* field.
+
+### `EXECUTE` — for Tasker, adb, scripts
+
+`com.mg4.control.action.EXECUTE` with a string extra `action` holding one of the names above, plus
+two commands the direct actions cannot cover:
+
+- `APPLY_PROFILE` — requires a `profile` extra: the profile name, case-insensitive
+- `OPEN_CUSTOM_APP` — opens the app configured in the shortcuts screen
+
+```bash
+adb shell am broadcast -a com.mg4.control.action.EXECUTE \
+ --es action APPLY_PROFILE --es profile "Home commute"
+```
+
+### `SET` — write a value directly
+
+`com.mg4.control.action.SET` with the `key` and `value` extras:
+
+| `key` | accepted `value` |
+|---|---|
+| `drive_mode` | `ECO` `NORMAL` `SPORT` `SNOW` `CUSTOM` |
+| `regen` | `OFF` `LOW` `MEDIUM` `HIGH` `ADAPTIVE` `ONE_PEDAL` |
+| `seat_heat_left` | `0` to `3` |
+| `seat_heat_right` | `0` to `3` |
+| `steering_heat` | `0`/`1` or `false`/`true` |
+| `profile` | profile name |
+| `hvac_power` | `0`/`1` — climate on/off |
+| `ac` | `0`/`1` — A/C compressor |
+| `hvac_auto` | `0`/`1` — automatic mode |
+| `hvac_temp` | °C, clamped to the vehicle's real bounds |
+| `hvac_fan` | fan level, clamped to the real bounds |
+| `hvac_recirc` | `INNER` `OUTSIDE` `AUTO` (or `0` `1` `2`) |
+| `defrost_front` | `0`/`1` |
+| `defrost_rear` | `0`/`1` |
+
+The `hvac_*` and `defrost_*` keys are ignored when the firmware exposes no climate control.
+Setpoint and fan are clamped to bounds **read from the vehicle**, which differ across firmwares.
+These are hardware toggles that step one notch at a time: expect a few seconds before the final
+state is reached.
+
+```bash
+adb shell am broadcast -a com.mg4.control.action.SET --es key drive_mode --es value SPORT
+```
+
+### Reading state — ContentProvider
+
+`content://com.mg4.control.state/state` (or `com.mg4.control.offline.state` for the offline
+variant — the authority follows the applicationId). A **single**-row cursor:
+
+`drive_mode`, `regen`, `seat_heat_left`, `seat_heat_right`, `steering_heat`, `speed_kmh`,
+`outside_temp_c`, `tsr`, `energy_saving`, `aeb_enabled`, `firmware`, `profiles` (names separated
+by `|`), `default_profile`.
+
+An unreadable value is `null`, never `0` — a zero would be indistinguishable from "seat off" or
+"vehicle stopped". Tasker can query a ContentProvider, KeyMapper cannot.
+
+Unlike broadcasts, a provider knows its caller: every read is logged by package name, and the
+`external_api_allowlist` preference (comma-separated packages, empty = all allowed) is actually
+enforced.
+
+### Security and diagnostics
+
+The **speed lock** (*Settings → "Block driving settings above a given speed"*) also covers the API,
+since it sits in the write primitives. Note that it is itself **disabled by default** — if you have
+not enabled it, no speed limit applies to external commands. Comfort settings (seats, steering
+wheel heating) are never affected.
+
+Every command, accepted or refused, is traced under the **`MG4_API`** tag (visible via the
+Diagnostic button). To test the app independently of KeyMapper:
+
+```bash
+adb shell am broadcast -a com.mg4.control.action.PROFILE_PICKER
+```
-| Token | Hex | Usage |
-|-------|-----|-------|
-| `dash_bg` | `#0C0C0E` | App background |
-| `dash_card` | `#141416` | Cards |
-| `dash_section` | `#1C1C1F` | Inner sections |
-| `dash_border` | `#2A2A2E` | Borders |
-| `dash_accent` | `#38BDF8` | Active selection (blue) |
-| `dash_eco` | `#22C55E` | ECO mode (green) |
-| `dash_warn` | `#F59E0B` | SPORT mode (amber) |
-| `dash_danger` | `#F43F5E` | Delete / danger actions |
+Complete silence = stale APK or service not running. `REFUS … API externe désactivée` = the
+Settings toggle was never confirmed.
---
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c991c153..5ded59e0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,8 +11,11 @@ android {
applicationId = "com.mg4.control"
minSdk = 28
targetSdk = 34
- versionCode = 17
- versionName = "2.6.5"
+ versionCode = 18
+ // Suffixe injecte par la CI beta : -Pmg4.versionSuffix=-beta42 produit "2.6.6-beta42".
+ // L'APK installe annonce alors EXACTEMENT ce que dit le tag de la release, sans quoi
+ // l'OTA reproposerait la meme mise a jour indefiniment.
+ versionName = "2.6.6" + (project.findProperty("mg4.versionSuffix") as String? ?: "")
}
// Signature avec la clé plateforme de la ROM (requise par sharedUserId=android.uid.system).
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ce3abb31..9833286e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -70,6 +70,52 @@
hors du manifest de base évite aussi un conflit d'authority qui empêcherait
d'installer les APK online et offline côte à côte. -->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
.state/state` → une ligne, une colonne par valeur.
+ *
+ * ⚠️ L'authority suit l'applicationId, elle n'est donc PAS une constante : la variante
+ * offline s'installe à côté de l'online et deux paquets ne peuvent pas déclarer la même
+ * (INSTALL_FAILED_CONFLICTING_PROVIDER). Les intégrateurs doivent viser
+ * `com.mg4.control.state` ou `com.mg4.control.offline.state` selon la variante installée.
+ */
+ fun authority(context: Context): String = context.packageName + ".state"
+
+ const val PATH_STATE = "state"
+
+ fun isEnabled(context: Context): Boolean =
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_ENABLED, false)
+
+ /**
+ * Vrai si [caller] est autorisé. Liste vide = pas de filtrage (l'interrupteur maître reste le
+ * verrou). Un appelant inconnu de la plateforme (`null`) est refusé dès que la liste est posée.
+ */
+ fun isCallerAllowed(context: Context, caller: String?): Boolean {
+ val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getString(KEY_ALLOWLIST, "").orEmpty().trim()
+ if (raw.isEmpty()) return true
+ val allowed = raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ return caller != null && allowed.any { it.equals(caller, ignoreCase = true) }
+ }
+}
diff --git a/app/src/main/java/com/mg4/control/api/ExternalApiReceiver.kt b/app/src/main/java/com/mg4/control/api/ExternalApiReceiver.kt
new file mode 100644
index 00000000..1bf38249
--- /dev/null
+++ b/app/src/main/java/com/mg4/control/api/ExternalApiReceiver.kt
@@ -0,0 +1,55 @@
+package com.mg4.control.api
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.mg4.control.debug.AppLogger
+import com.mg4.control.service.MG4ControlService
+
+/**
+ * Point d'entrée des applications tierces (KeyMapper, Tasker…) — issue #79.
+ *
+ * Déclaré dans le Manifest, donc joignable application fermée : on ne fait ici QUE le contrôle
+ * d'accès et le relais. Le travail réel part dans [MG4ControlService], pour deux raisons — un
+ * receiver ne dispose que de dix secondes, et le service détient déjà l'état des bascules et le
+ * répartiteur d'actions.
+ *
+ * Voir [ExternalApi] pour la discussion sécurité : le verrou est l'interrupteur des Réglages,
+ * désactivé par défaut.
+ */
+class ExternalApiReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action ?: return
+ val direct = ExternalApi.directActionName(action)
+ if (direct == null &&
+ action != ExternalApi.ACTION_EXECUTE && action != ExternalApi.ACTION_SET) return
+
+ // On journalise AUSSI les refus : « l'API ne répond pas » doit être diagnosticable sans
+ // avoir à deviner si c'est l'interrupteur ou l'intent qui est en cause.
+ if (!ExternalApi.isEnabled(context)) {
+ AppLogger.i(ExternalApi.LOG_TAG, "REFUS $action — API externe désactivée dans Réglages")
+ return
+ }
+
+ AppLogger.i(ExternalApi.LOG_TAG, "REÇU $action " +
+ "action=${intent.getStringExtra(ExternalApi.EXTRA_ACTION)} " +
+ "key=${intent.getStringExtra(ExternalApi.EXTRA_KEY)} " +
+ "value=${intent.extras?.get(ExternalApi.EXTRA_VALUE)} " +
+ "profile=${intent.getStringExtra(ExternalApi.EXTRA_PROFILE)}")
+
+ val relay = Intent(context, MG4ControlService::class.java).apply {
+ // Une action directe est convertie en forme riche : le service n'a ainsi qu'un seul
+ // chemin de traitement, quel que soit le vocabulaire employé par l'appelant.
+ setAction(if (direct != null) ExternalApi.ACTION_EXECUTE else action)
+ putExtra(ExternalApi.EXTRA_ACTION, direct ?: intent.getStringExtra(ExternalApi.EXTRA_ACTION))
+ putExtra(ExternalApi.EXTRA_KEY, intent.getStringExtra(ExternalApi.EXTRA_KEY))
+ putExtra(ExternalApi.EXTRA_PROFILE, intent.getStringExtra(ExternalApi.EXTRA_PROFILE))
+ // La valeur arrive en texte (adb, Tasker) ou en entier (KeyMapper) : on relaie la forme
+ // texte, seule commune aux deux, et le service se charge de l'interpréter.
+ putExtra(ExternalApi.EXTRA_VALUE, intent.extras?.get(ExternalApi.EXTRA_VALUE)?.toString())
+ }
+ runCatching { context.startForegroundService(relay) }
+ .onFailure { AppLogger.w(ExternalApi.LOG_TAG, "relais vers le service impossible : ${it.message}") }
+ }
+}
diff --git a/app/src/main/java/com/mg4/control/api/StateProvider.kt b/app/src/main/java/com/mg4/control/api/StateProvider.kt
new file mode 100644
index 00000000..51a63061
--- /dev/null
+++ b/app/src/main/java/com/mg4/control/api/StateProvider.kt
@@ -0,0 +1,87 @@
+package com.mg4.control.api
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import com.mg4.control.debug.AppLogger
+import com.mg4.control.hardware.MG4Hardware
+import com.mg4.control.profile.ProfileManager
+import com.mg4.control.util.FirmwareInfo
+
+/**
+ * Lecture de l'état véhicule par les applications tierces — `content://com.mg4.control.state/state`.
+ *
+ * Pourquoi un ContentProvider et pas un broadcast : un broadcast ne sait pas retourner de valeur,
+ * et surtout il ne porte AUCUNE identité d'émetteur. Ici `callingPackage` est fiable (fourni par la
+ * plateforme), ce qui permet à la fois de journaliser qui lit et d'appliquer une liste blanche.
+ *
+ * Lecture seule — aucune écriture véhicule ne passe par ce composant, [insert]/[update]/[delete]
+ * sont volontairement inertes.
+ *
+ * Le format est un curseur d'UNE ligne, une colonne par valeur : c'est ce que savent consommer
+ * Tasker et les outils d'automatisation. Une valeur illisible sort à `null` plutôt qu'à zéro — un
+ * zéro se confondrait avec « siège éteint » ou « à l'arrêt ».
+ */
+class StateProvider : ContentProvider() {
+
+ private companion object {
+ val COLUMNS = arrayOf(
+ "drive_mode", "regen", "seat_heat_left", "seat_heat_right", "steering_heat",
+ "speed_kmh", "outside_temp_c", "tsr", "energy_saving", "aeb_enabled",
+ "firmware", "profiles", "default_profile"
+ )
+ }
+
+ override fun onCreate(): Boolean = true
+
+ override fun query(
+ uri: Uri, projection: Array?, selection: String?,
+ selectionArgs: Array?, sortOrder: String?
+ ): Cursor? {
+ val ctx = context ?: return null
+ val caller = callingPackage
+
+ if (!ExternalApi.isEnabled(ctx)) {
+ AppLogger.i(ExternalApi.LOG_TAG, "LECTURE refusée (appelant=$caller) — API désactivée")
+ return null
+ }
+ if (!ExternalApi.isCallerAllowed(ctx, caller)) {
+ AppLogger.w(ExternalApi.LOG_TAG, "LECTURE refusée — $caller hors liste blanche")
+ return null
+ }
+
+ val pm = ProfileManager(ctx)
+ val profiles = pm.getAll()
+ val cursor = MatrixCursor(COLUMNS)
+ cursor.addRow(arrayOf(
+ MG4Hardware.getDriveMode()?.name,
+ MG4Hardware.getRegenLevel()?.name,
+ MG4Hardware.getSeatHeatLeft().takeIf { it >= 0 },
+ MG4Hardware.getSeatHeatRight().takeIf { it >= 0 },
+ if (MG4Hardware.isSteeringHeatOn()) 1 else 0,
+ MG4Hardware.getVehicleSpeedKmh(),
+ MG4Hardware.getOutsideTempCelsius(),
+ if (MG4Hardware.isTsrOn()) 1 else 0,
+ if (MG4Hardware.isEnergySavingOn()) 1 else 0,
+ if (MG4Hardware.isAebEnabled()) 1 else 0,
+ FirmwareInfo.getGeneration().name,
+ profiles.joinToString("|") { it.name },
+ profiles.firstOrNull { it.id == pm.getDefaultId() }?.name
+ ))
+ AppLogger.i(ExternalApi.LOG_TAG, "LECTURE par $caller")
+ return cursor
+ }
+
+ override fun getType(uri: Uri): String =
+ "vnd.android.cursor.item/vnd.${context?.packageName}.${ExternalApi.PATH_STATE}"
+
+ // Lecture seule, par conception : aucune écriture véhicule ne doit passer par un provider
+ // exporté. Les écritures ont leur propre chemin (ExternalApiReceiver), journalisé et
+ // soumis au verrou de vitesse.
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun update(uri: Uri, values: ContentValues?, selection: String?,
+ selectionArgs: Array?): Int = 0
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+}
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 31500ba0..626bd05d 100644
--- a/app/src/main/java/com/mg4/control/service/MG4ControlService.kt
+++ b/app/src/main/java/com/mg4/control/service/MG4ControlService.kt
@@ -24,6 +24,9 @@ import com.mg4.control.automation.AutomationDecision
import com.mg4.control.automation.AutomationSettings
import com.mg4.control.automation.ClimateAutomationDecision
import com.mg4.control.automation.ClimateAutomationSettings
+import com.mg4.control.api.ExternalApi
+import com.mg4.control.api.ExternalApiReceiver
+import com.mg4.control.model.DriveMode
import com.mg4.control.bluetooth.BluetoothProfileManager
import com.mg4.control.debug.AppLogger
import com.mg4.control.hardware.MG4Hardware
@@ -87,6 +90,9 @@ class MG4ControlService : Service() {
// ── Receiver sync thème launcher ─────────────────────────────────────────
private var skinChangeReceiver: BroadcastReceiver? = null
+ // ── Receiver API externe (issue #79) ─────────────────────────────────────
+ private var externalApiReceiver: BroadcastReceiver? = null
+
// ── Listener de cycle d'allumage (Katman5) ──────────────────────────────
private var vehicleConditionListener: ((Int) -> Unit)? = null
@@ -106,9 +112,36 @@ class MG4ControlService : Service() {
registerHardkeyReceiver()
registerBtAclReceiver() // [BT-PROFILES]
registerSkinChangeReceiver() // [THEME-AUTO]
+ registerExternalApiReceiver() // issue #79
registerIgnitionListener()
}
+ /**
+ * Deuxième enregistrement du receiver d'API externe, en plus de celui du Manifest.
+ *
+ * ⚠️ INDISPENSABLE, ne pas supprimer en croyant faire un doublon : depuis Android 8, un
+ * receiver déclaré au Manifest ne reçoit plus les broadcasts **implicites**, et une action
+ * personnalisée en est un. KeyMapper ou Tasker enverraient l'intent sans que rien n'arrive,
+ * silencieusement. Un receiver enregistré par code n'a pas cette limite.
+ *
+ * Le Manifest reste utile pour les émetteurs qui ciblent explicitement le paquet
+ * (`setPackage`), y compris quand le service n'est pas encore démarré.
+ */
+ private fun registerExternalApiReceiver() {
+ externalApiReceiver = ExternalApiReceiver()
+ val filter = IntentFilter().apply {
+ addAction(ExternalApi.ACTION_EXECUTE)
+ addAction(ExternalApi.ACTION_SET)
+ // Les actions directes sont construites depuis la même liste que le Manifest :
+ // en ajouter une ne doit se faire qu'à un seul endroit côté code.
+ ExternalApi.DIRECT_ACTIONS.forEach { addAction(ExternalApi.ACTION_PREFIX + it) }
+ }
+ // Émetteurs tiers par nature : l'export est explicite. Le contrôle d'accès est
+ // l'interrupteur des Réglages, vérifié dans le receiver ET dans le service.
+ ContextCompat.registerReceiver(this, externalApiReceiver, filter, ContextCompat.RECEIVER_EXPORTED)
+ AppLogger.i(ExternalApi.LOG_TAG, "receiver API externe enregistré (dynamique + Manifest)")
+ }
+
override fun onDestroy() {
super.onDestroy()
vehicleConditionListener?.let { MG4Hardware.unregisterVehicleConditionListener(it) }
@@ -119,15 +152,170 @@ class MG4ControlService : Service() {
btAclReceiver = null
skinChangeReceiver?.let { unregisterReceiver(it) } // [THEME-AUTO]
skinChangeReceiver = null
+ externalApiReceiver?.let { unregisterReceiver(it) } // issue #79
+ externalApiReceiver = null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
AppLogger.i(TAG, "onStartCommand")
+ // Relais de l'API externe (issue #79) : traité AVANT la routine de démarrage, sinon un
+ // simple appel tiers relancerait l'application du profil par défaut à chaque commande.
+ if (handleExternalApiIntent(intent)) return START_STICKY
tryClimateAutomation("démarrage service")
scheduleDefaultProfileOnce()
return START_STICKY
}
+ /**
+ * Exécute une commande venue d'[ExternalApiReceiver]. Retourne vrai si l'intent en était une.
+ *
+ * Le contrôle d'accès a déjà eu lieu dans le receiver ; on le revérifie quand même, parce que
+ * ce service est aussi démarrable autrement et qu'un interrupteur de sécurité ne doit pas
+ * dépendre d'un seul point de passage.
+ */
+ private fun handleExternalApiIntent(intent: Intent?): Boolean {
+ val action = intent?.action ?: return false
+ if (action != ExternalApi.ACTION_EXECUTE && action != ExternalApi.ACTION_SET) return false
+ if (!ExternalApi.isEnabled(this)) {
+ AppLogger.i(ExternalApi.LOG_TAG, "REFUS $action — API externe désactivée")
+ return true
+ }
+
+ if (action == ExternalApi.ACTION_EXECUTE) {
+ val name = intent.getStringExtra(ExternalApi.EXTRA_ACTION).orEmpty()
+ // Filtre appliqué ICI, donc pour les deux formes d'appel : retirer une commande des
+ // seules actions directes ne protégerait rien, l'extra `action` y donnant le même accès.
+ if (name.uppercase() in ExternalApi.BLOCKED_ACTIONS) {
+ AppLogger.w(ExternalApi.LOG_TAG, "REFUS '$name' — commande non exposée à l'API externe")
+ return true
+ }
+ val sc = ShortcutAction.values().firstOrNull { it.name.equals(name, ignoreCase = true) }
+ if (sc == null || sc == ShortcutAction.NONE) {
+ AppLogger.w(ExternalApi.LOG_TAG, "action inconnue : '$name'")
+ return true
+ }
+ // APPLY_PROFILE lit d'ordinaire l'id stocké pour la touche volant. Depuis l'API, le
+ // profil est nommé dans l'intent : on le résout et on l'applique directement.
+ if (sc == ShortcutAction.APPLY_PROFILE) {
+ applyProfileByName(intent.getStringExtra(ExternalApi.EXTRA_PROFILE))
+ return true
+ }
+ AppLogger.i(ExternalApi.LOG_TAG, "EXECUTE ${sc.name}")
+ executeToggle(sc)
+ return true
+ }
+
+ // ── ACTION_SET ────────────────────────────────────────────────────────
+ val key = intent.getStringExtra(ExternalApi.EXTRA_KEY).orEmpty()
+ val value = intent.getStringExtra(ExternalApi.EXTRA_VALUE).orEmpty()
+ AppLogger.i(ExternalApi.LOG_TAG, "SET $key=$value")
+
+ CoroutineScope(Dispatchers.IO).launch {
+ // Toutes ces écritures passent par MG4Hardware, donc par VehicleWriteGate : refusées
+ // en roulant sans que l'API ait à s'en préoccuper.
+ val ok = when (key) {
+ ExternalApi.SET_DRIVE_MODE -> DriveMode.values()
+ .firstOrNull { it.name.equals(value, true) }
+ ?.let { MG4Hardware.setDriveMode(it); true } ?: false
+ ExternalApi.SET_REGEN -> RegenLevel.values()
+ .firstOrNull { it.name.equals(value, true) }
+ ?.let { MG4Hardware.setRegenLevel(it); true } ?: false
+ ExternalApi.SET_SEAT_HEAT_LEFT ->
+ value.toIntOrNull()?.takeIf { it in 0..3 }
+ ?.let { MG4Hardware.setSeatHeatLeft(it); true } ?: false
+ ExternalApi.SET_SEAT_HEAT_RIGHT ->
+ value.toIntOrNull()?.takeIf { it in 0..3 }
+ ?.let { MG4Hardware.setSeatHeatRight(it); true } ?: false
+ ExternalApi.SET_STEERING_HEAT -> {
+ val on = value.equals("true", true) || value == "1"
+ MG4Hardware.setSteeringHeat(on); true
+ }
+ ExternalApi.SET_PROFILE -> { applyProfileByName(value); true }
+ else -> setClimateFromApi(key, value)
+ }
+ if (!ok) AppLogger.w(ExternalApi.LOG_TAG, "SET refusé — clé ou valeur invalide ($key=$value)")
+ }
+ return true
+ }
+
+ /**
+ * Clés `SET` de climatisation. Retourne false si [key] n'en est pas une — l'appelant
+ * s'en sert pour distinguer « clé inconnue » de « valeur invalide ».
+ *
+ * ⚠️ Bloquant : les commandes clim SAIC sont des bascules qui avancent d'un cran à la fois,
+ * donc plusieurs secondes possibles. Appelée depuis le contexte IO de [handleExternalApiIntent].
+ *
+ * Consigne et ventilation sont clampées aux **bornes réelles du véhicule**, pas à des
+ * valeurs codées en dur : elles diffèrent d'un firmware à l'autre.
+ */
+ private fun setClimateFromApi(key: String, value: String): Boolean {
+ val hvacKeys = setOf(
+ ExternalApi.SET_HVAC_POWER, ExternalApi.SET_HVAC_AC, ExternalApi.SET_HVAC_AUTO,
+ ExternalApi.SET_HVAC_TEMP, ExternalApi.SET_HVAC_FAN, ExternalApi.SET_HVAC_RECIRC,
+ ExternalApi.SET_DEFROST_FRONT, ExternalApi.SET_DEFROST_REAR
+ )
+ if (key !in hvacKeys) return false
+ if (!MG4Hardware.hasClimateControl()) {
+ AppLogger.w(ExternalApi.LOG_TAG, "SET $key ignoré — clim non pilotable sur ce firmware")
+ return true // clé connue : ce n'est pas une erreur de syntaxe
+ }
+ val on = value.equals("true", true) || value == "1"
+ return when (key) {
+ ExternalApi.SET_HVAC_POWER -> { MG4Hardware.setClimatePower(on); true }
+ ExternalApi.SET_HVAC_AC -> { MG4Hardware.setClimateAc(on); true }
+ ExternalApi.SET_HVAC_AUTO -> { MG4Hardware.setClimateAuto(on); true }
+ ExternalApi.SET_DEFROST_FRONT -> { MG4Hardware.setClimateDefrostFront(on); true }
+ ExternalApi.SET_DEFROST_REAR -> { MG4Hardware.setClimateDefrostRear(on); true }
+ ExternalApi.SET_HVAC_RECIRC -> {
+ val mode = when (value.uppercase()) {
+ "INNER", "0" -> MG4Hardware.LoopMode.INNER
+ "OUTSIDE", "1" -> MG4Hardware.LoopMode.OUTSIDE
+ "AUTO", "2" -> MG4Hardware.LoopMode.AUTO
+ else -> return true.also {
+ AppLogger.w(ExternalApi.LOG_TAG, "SET $key : valeur invalide '$value'")
+ }
+ }
+ MG4Hardware.setClimateLoopMode(mode); true
+ }
+ ExternalApi.SET_HVAC_TEMP, ExternalApi.SET_HVAC_FAN -> {
+ val n = value.toIntOrNull() ?: return true.also {
+ AppLogger.w(ExternalApi.LOG_TAG, "SET $key : valeur non numérique '$value'")
+ }
+ val state = MG4Hardware.getClimateState() ?: return true.also {
+ AppLogger.w(ExternalApi.LOG_TAG, "SET $key : état clim illisible")
+ }
+ if (key == ExternalApi.SET_HVAC_TEMP)
+ MG4Hardware.setClimateTemp(n.coerceIn(state.tempMin, state.tempMax))
+ else
+ MG4Hardware.setClimateFan(n.coerceIn(state.fanMin, state.fanMax))
+ true
+ }
+ else -> false
+ }
+ }
+
+ /** Applique un profil désigné par son NOM (insensible à la casse) ou son id. */
+ private fun applyProfileByName(nameOrId: String?) {
+ val wanted = nameOrId?.trim().orEmpty()
+ if (wanted.isEmpty()) {
+ AppLogger.w(ExternalApi.LOG_TAG, "APPLY_PROFILE sans nom de profil")
+ return
+ }
+ CoroutineScope(Dispatchers.IO).launch {
+ val pm = ProfileManager(applicationContext)
+ val profile = pm.getById(wanted)
+ ?: pm.getAll().firstOrNull { it.name.equals(wanted, ignoreCase = true) }
+ if (profile == null) {
+ AppLogger.w(ExternalApi.LOG_TAG, "profil introuvable : '$wanted'")
+ return@launch
+ }
+ AppLogger.i(ExternalApi.LOG_TAG, "application du profil '${profile.name}'")
+ ProfileApplier.apply(profile, autoStart = true) { ok ->
+ AppLogger.i(ExternalApi.LOG_TAG, "profil '${profile.name}' — ok=$ok")
+ }
+ }
+ }
+
override fun onBind(intent: Intent?): IBinder? = null
// ── Enregistrement dynamique du receiver ─────────────────────────────────
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 3cf8a689..152b847f 100644
--- a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt
+++ b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt
@@ -33,6 +33,7 @@ import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.google.android.material.button.MaterialButton
import com.mg4.control.BuildConfig
+import com.mg4.control.api.ExternalApi
import com.mg4.control.R
import com.mg4.control.util.QrCode
import com.mg4.control.debug.AppLogger
@@ -160,6 +161,50 @@ class SettingsFragment : Fragment() {
btnThemeDark.setOnClickListener { applyThemeMode("dark") }
btnThemeLight.setOnClickListener { applyThemeMode("light") }
+ // ── Canal de mise a jour beta ────────────────────────────────────────
+ // Aucun avertissement bloquant : une beta ne donne pas le controle du vehicule a un
+ // tiers, contrairement a l'API externe. Le texte sous l'interrupteur suffit, et il
+ // mentionne l'absence de retour arriere, qui est la vraie contrainte.
+ val switchBeta = view.findViewById(R.id.switch_beta_channel)
+ switchBeta.isChecked = prefs.getBoolean(UpdateChecker.KEY_BETA_CHANNEL, false)
+ switchBeta.setOnCheckedChangeListener { _, checked ->
+ prefs.edit().putBoolean(UpdateChecker.KEY_BETA_CHANNEL, checked).apply()
+ AppLogger.i("MG4_UPDATE", "Canal beta ${if (checked) "ACTIVE" else "desactive"}")
+ }
+
+ // ── API externe (issue #79) ──────────────────────────────────────────
+ // Le seul verrou de cette API : tant qu'il est off, le receiver et le provider refusent.
+ // L'ACTIVATION passe par une confirmation explicite ; la désactivation est immédiate —
+ // on ne met jamais d'obstacle devant un retour à l'état sûr.
+ val switchExternalApi = view.findViewById(R.id.switch_external_api)
+ switchExternalApi.isChecked = prefs.getBoolean(ExternalApi.KEY_ENABLED, false)
+ // Drapeau plutôt que retrait/remise de l'écouteur : les remises à zéro programmatiques
+ // ci-dessous rappellent l'écouteur, et sans garde on boucle.
+ var apiSwitchProgrammatic = false
+ switchExternalApi.setOnCheckedChangeListener { _, checked ->
+ if (apiSwitchProgrammatic) return@setOnCheckedChangeListener
+
+ if (!checked) {
+ prefs.edit().putBoolean(ExternalApi.KEY_ENABLED, false).apply()
+ AppLogger.i(ExternalApi.LOG_TAG, "API externe désactivée par l'utilisateur")
+ return@setOnCheckedChangeListener
+ }
+ // Repasse à off le temps de la question : l'interrupteur ne montre « activé »
+ // qu'après confirmation, jamais avant.
+ apiSwitchProgrammatic = true
+ switchExternalApi.isChecked = false
+ apiSwitchProgrammatic = false
+
+ showExternalApiConfirm { confirmed ->
+ if (!confirmed) return@showExternalApiConfirm
+ prefs.edit().putBoolean(ExternalApi.KEY_ENABLED, true).apply()
+ apiSwitchProgrammatic = true
+ switchExternalApi.isChecked = true
+ apiSwitchProgrammatic = false
+ AppLogger.i(ExternalApi.LOG_TAG, "API externe ACTIVÉE par l'utilisateur (confirmée)")
+ }
+ }
+
// ── Auto-apply ───────────────────────────────────────────────────────
val switchAutoApply = view.findViewById(R.id.switch_auto_apply)
switchAutoApply.isChecked = prefs.getBoolean("auto_apply_profile", true)
@@ -201,6 +246,7 @@ class SettingsFragment : Fragment() {
// Build offline : pas de réseau → on masque toute l'UI de mise à jour.
if (BuildConfig.OFFLINE) {
view.findViewById(R.id.row_auto_update).visibility = View.GONE
+ view.findViewById(R.id.row_beta_channel).visibility = View.GONE
view.findViewById(R.id.row_update_buttons).visibility = View.GONE
} else {
val switchAutoUpdate = view.findViewById(R.id.switch_auto_update)
@@ -762,4 +808,47 @@ class SettingsFragment : Fragment() {
}
}
+
+ /**
+ * Confirmation avant d'ouvrir l'API externe (issue #79).
+ *
+ * L'avertissement est construit en code plutôt que dans une chaîne : le premier paragraphe
+ * doit être rouge ET gras, ce qu'un `setMessage` sur une chaîne plate ne permet pas. C'est
+ * la seule information qui compte vraiment ici, elle ne doit pas se fondre dans le reste.
+ *
+ * [onResult] reçoit false sur Annuler comme sur une fermeture par l'extérieur : dans le
+ * doute on ne suppose jamais l'accord.
+ */
+ private fun showExternalApiConfirm(onResult: (Boolean) -> Unit) {
+ val danger = requireContext().getColor(R.color.dash_danger)
+ val warn = getString(R.string.external_api_confirm_warn)
+ val body = getString(R.string.external_api_confirm_msg)
+ // getText et non getString : la ressource contient un autour de « à vos risques et
+ // périls ». Le gras vient donc du fichier de chaînes, ce qui reste juste dans les six
+ // langues — le localiser en code aurait supposé de connaître la sous-chaîne traduite.
+ val risk = getText(R.string.external_api_confirm_risk)
+
+ val text = android.text.SpannableStringBuilder("$warn\n\n$body\n\n")
+ val riskStart = text.length
+ text.append(risk)
+
+ // Rouge + gras sur l'ouverture, rouge seul sur la clôture : l'avertissement encadre
+ // l'explication, et le gras reste réservé à la phrase la plus forte.
+ text.setSpan(android.text.style.ForegroundColorSpan(danger),
+ 0, warn.length, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ text.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD),
+ 0, warn.length, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ text.setSpan(android.text.style.ForegroundColorSpan(danger),
+ riskStart, text.length, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+ var answered = false
+ AlertDialog.Builder(requireContext())
+ .setTitle(R.string.external_api_confirm_title)
+ .setMessage(text)
+ .setNegativeButton(R.string.profile_cancel) { _, _ -> answered = true; onResult(false) }
+ .setPositiveButton(R.string.external_api_confirm_ok) { _, _ -> answered = true; onResult(true) }
+ .setOnDismissListener { if (!answered) onResult(false) }
+ .show()
+ }
+
}
diff --git a/app/src/main/java/com/mg4/control/update/UpdateChecker.kt b/app/src/main/java/com/mg4/control/update/UpdateChecker.kt
index 44f2566b..457516af 100644
--- a/app/src/main/java/com/mg4/control/update/UpdateChecker.kt
+++ b/app/src/main/java/com/mg4/control/update/UpdateChecker.kt
@@ -23,6 +23,25 @@ object UpdateChecker {
private const val GITHUB_API_URL =
"https://api.github.com/repos/SliDeeN/MG4Control/releases/latest"
+ /**
+ * Canal beta : la LISTE des releases, pre-releases comprises.
+ *
+ * `/releases/latest` les exclut par definition cote GitHub — c'est la raison pour laquelle
+ * les builds de test n'etaient jamais proposes. La liste est deja triee par date de creation
+ * decroissante ; on prend malgre tout la plus recente PAR COMPARAISON DE VERSION, parce qu'une
+ * correction publiee apres coup sur une ancienne branche casserait l'ordre chronologique.
+ */
+ private const val GITHUB_BETA_URL =
+ "https://api.github.com/repos/SliDeeN/MG4Control/releases?per_page=20"
+
+ /** Interrupteur « canal beta » — Reglages avances. Defaut false. */
+ const val PREFS_SETTINGS = "mg4_settings"
+ const val KEY_BETA_CHANNEL = "update_channel_beta"
+
+ fun isBetaChannel(context: Context): Boolean =
+ context.getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE)
+ .getBoolean(KEY_BETA_CHANNEL, false)
+
private const val GITLAB_API_URL =
"https://gitlab.com/api/v4/projects/SliDeeN%2Fmg4control/releases/permalink/latest"
@@ -60,8 +79,10 @@ object UpdateChecker {
.getSharedPreferences(PREFS_SKIP, Context.MODE_PRIVATE)
.getString(KEY_SKIP_VERSION, null)
- // Essai GitHub → fallback GitLab
- val release = fetchFromGitHub()
+ // Essai GitHub → fallback GitLab. Le canal beta ne concerne que GitHub :
+ // les builds de test ne sont pas publies sur GitLab.
+ val beta = isBetaChannel(context)
+ val release = fetchFromGitHub(beta)
?: fetchFromGitLab()
?: run {
AppLogger.w(TAG, "GitHub et GitLab inaccessibles")
@@ -99,9 +120,10 @@ object UpdateChecker {
// Requête GitHub
// -------------------------------------------------------------------------
- private fun fetchFromGitHub(): RawRelease? {
+ private fun fetchFromGitHub(beta: Boolean = false): RawRelease? {
return try {
- val conn = (URL(GITHUB_API_URL).openConnection() as HttpURLConnection).apply {
+ val url = if (beta) GITHUB_BETA_URL else GITHUB_API_URL
+ val conn = (URL(url).openConnection() as HttpURLConnection).apply {
setRequestProperty("Accept", "application/vnd.github.v3+json")
setRequestProperty("User-Agent", "MG4Control-Android")
connectTimeout = 10_000
@@ -112,9 +134,25 @@ object UpdateChecker {
conn.disconnect()
return null
}
- val json = JSONObject(conn.inputStream.bufferedReader().readText())
+ val payload = conn.inputStream.bufferedReader().readText()
conn.disconnect()
+ // Canal beta : la reponse est un TABLEAU de releases. On garde la plus haute au sens
+ // de la comparaison de versions, brouillons exclus.
+ val json = if (beta) {
+ val arr = org.json.JSONArray(payload)
+ var best: JSONObject? = null
+ for (i in 0 until arr.length()) {
+ val r = arr.getJSONObject(i)
+ if (r.optBoolean("draft", false)) continue
+ val t = r.optString("tag_name").trimStart('v')
+ if (best == null || isNewer(t, best!!.optString("tag_name").trimStart('v'))) best = r
+ }
+ best ?: return null
+ } else {
+ JSONObject(payload)
+ }
+
val tagName = json.getString("tag_name")
val notes = json.optString("body", "").take(400)
val assets = json.optJSONArray("assets") ?: return null
@@ -256,7 +294,49 @@ object UpdateChecker {
if (rv > cv) return true
if (rv < cv) return false
}
- return false
+ // Numeros identiques : c'est le suffixe de pre-release qui departage.
+ return preReleaseRank(preRelease(remote), preRelease(current)) > 0
+ }
+
+ /** Suffixe de pre-release : "2.6.6-beta2" -> "beta2" ; "" si version stable. */
+ @VisibleForTesting
+ internal fun preRelease(version: String): String =
+ version.trimStart('v', 'V').substringBefore('+').substringAfter('-', "")
+
+ /**
+ * Compare deux suffixes de pre-release (regle semver §11).
+ *
+ * Une version SANS suffixe l'emporte sur une version qui en a un : `2.6.6` est posterieure a
+ * `2.6.6-beta9`. C'est ce qui permet a la release stable de remplacer la derniere beta, et qui
+ * empeche une beta d'ecraser la stable de meme numero.
+ *
+ * Entre deux suffixes, comparaison identifiant par identifiant : les identifiants purement
+ * numeriques se comparent en nombres (`beta10` > `beta9`, ce qu'un tri lexical raterait).
+ */
+ @VisibleForTesting
+ internal fun preReleaseRank(remote: String, current: String): Int {
+ if (remote.isEmpty() && current.isEmpty()) return 0
+ if (remote.isEmpty()) return 1 // stable > pre-release
+ if (current.isEmpty()) return -1
+ val ri = remote.split('.')
+ val ci = current.split('.')
+ for (i in 0 until maxOf(ri.size, ci.size)) {
+ val a = ri.getOrNull(i) ?: return -1 // moins d'identifiants = anterieur
+ val b = ci.getOrNull(i) ?: return 1
+ val cmp = compareIdentifier(a, b)
+ if (cmp != 0) return cmp
+ }
+ return 0
+ }
+
+ /** "beta2" vs "beta10" : on isole le prefixe alphabetique et le nombre final. */
+ private fun compareIdentifier(a: String, b: String): Int {
+ val alphaA = a.takeWhile { !it.isDigit() }
+ val alphaB = b.takeWhile { !it.isDigit() }
+ if (alphaA != alphaB) return alphaA.compareTo(alphaB)
+ val numA = a.dropWhile { !it.isDigit() }.toLongOrNull() ?: -1L
+ val numB = b.dropWhile { !it.isDigit() }.toLongOrNull() ?: -1L
+ return numA.compareTo(numB)
}
/**
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
index 9d040c31..090715eb 100644
--- a/app/src/main/res/layout/fragment_settings.xml
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -493,6 +493,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tasten
Aktionen
+
+
+ Externe API (KeyMapper, Tasker…)
+ ⚠ Erlaubt jeder installierten App, Fahreinstellungen zu steuern. Die Geschwindigkeitssperre bleibt aktiv. Aus lassen, wenn nicht benötigt.
+
+
+ Externe API aktivieren?
+ JEDE INSTALLIERTE APP KANN DANN DAS FAHRZEUG STEUERN.
+ Fahrmodus, Rekuperation, Sitz- und Lenkradheizung, Klimaanlage und Profile werden für Dritt-Apps steuerbar, ohne weitere Zustimmung Ihrerseits.\n\nDie Geschwindigkeitssperre bleibt aktiv.\n\nNur aktivieren, wenn Sie KeyMapper, Tasker oder ein ähnliches Werkzeug wirklich nutzen.
+ Sie aktivieren dies auf eigene Gefahr: Sie bleiben allein verantwortlich für alles, was eine Dritt-App mit Ihrem Fahrzeug macht.
+ Bestätigen
+
+
+ Beta-Update-Kanal
+ Erhält Testversionen vor der offiziellen Veröffentlichung. Sie können Fehler enthalten. Eine Rückkehr zu einer früheren Version ist per Update nicht möglich.
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 4ce3df5c..c502011f 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -356,4 +356,19 @@
Buttons
Actions
+
+
+ External API (KeyMapper, Tasker…)
+ ⚠ Lets any installed app control driving settings. The speed lock still applies. Leave off if you don\'t need it.
+
+
+ Enable the external API?
+ ANY INSTALLED APP WILL BE ABLE TO TAKE CONTROL OF THE VEHICLE.
+ Drive mode, regeneration, heated seats and steering wheel, climate control and profiles become controllable by a third-party app, with no further approval from you.\n\nThe speed lock stays active.\n\nOnly enable this if you actually use KeyMapper, Tasker or a similar tool.
+ You enable this at your own risk: you remain solely responsible for whatever a third-party app does to your vehicle.
+ Confirm
+
+
+ Beta update channel
+ Receives test builds before their official release. They may contain bugs. Rolling back to an earlier version is not possible through the updater.
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 79999312..e80f3f4e 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -356,4 +356,19 @@
Botones
Acciones
+
+
+ API externa (KeyMapper, Tasker…)
+ ⚠ Permite que cualquier app instalada controle los ajustes de conducción. El bloqueo por velocidad sigue activo. Déjelo desactivado si no lo necesita.
+
+
+ ¿Activar la API externa?
+ CUALQUIER APLICACIÓN INSTALADA PODRÁ TOMAR EL CONTROL DEL VEHÍCULO.
+ Modo de conducción, regeneración, asientos y volante calefactados, climatización y perfiles quedan controlables por una app de terceros, sin más autorización por su parte.\n\nEl bloqueo por velocidad sigue activo.\n\nActive esto solo si realmente usa KeyMapper, Tasker o una herramienta similar.
+ Activa esta opción bajo su propia responsabilidad: usted sigue siendo el único responsable de lo que una aplicación de terceros haga con su vehículo.
+ Confirmar
+
+
+ Canal de actualización beta
+ Recibe versiones de prueba antes de su lanzamiento oficial. Pueden contener errores. No es posible volver a una versión anterior mediante la actualización.
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 527dd5a0..9ac0c6a6 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -356,4 +356,19 @@
Pulsanti
Azioni
+
+
+ API esterna (KeyMapper, Tasker…)
+ ⚠ Consente a qualsiasi app installata di controllare le impostazioni di guida. Il blocco per velocità resta attivo. Lasciare disattivato se non serve.
+
+
+ Attivare l\'API esterna?
+ QUALSIASI APP INSTALLATA POTRÀ PRENDERE IL CONTROLLO DEL VEICOLO.
+ Modalità di guida, rigenerazione, sedili e volante riscaldati, climatizzazione e profili diventano controllabili da un\'app di terze parti, senza ulteriore autorizzazione da parte sua.\n\nIl blocco per velocità resta attivo.\n\nAttivi questa opzione solo se usa davvero KeyMapper, Tasker o uno strumento equivalente.
+ Attiva questa opzione a suo rischio e pericolo: resta l\'unico responsabile di ciò che un\'app di terze parti farà al suo veicolo.
+ Conferma
+
+
+ Canale di aggiornamento beta
+ Riceve versioni di prova prima del rilascio ufficiale. Possono contenere errori. Non è possibile tornare a una versione precedente tramite l\'aggiornamento.
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index dc7ace06..26b845b3 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -356,4 +356,19 @@
Botões
Ações
+
+
+ API externa (KeyMapper, Tasker…)
+ ⚠ Permite que qualquer app instalada controle as definições de condução. O bloqueio por velocidade continua ativo. Deixe desativado se não precisar.
+
+
+ Ativar a API externa?
+ QUALQUER APLICAÇÃO INSTALADA PODERÁ ASSUMIR O CONTROLO DO VEÍCULO.
+ Modo de condução, regeneração, bancos e volante aquecidos, climatização e perfis passam a ser controláveis por uma app de terceiros, sem mais autorização da sua parte.\n\nO bloqueio por velocidade continua ativo.\n\nAtive esta opção apenas se usar realmente o KeyMapper, Tasker ou ferramenta equivalente.
+ Ativa esta opção por sua conta e risco: continua a ser o único responsável por aquilo que uma aplicação de terceiros fizer ao seu veículo.
+ Confirmar
+
+
+ Canal de atualização beta
+ Recebe versões de teste antes do lançamento oficial. Podem conter erros. Não é possível voltar a uma versão anterior através da atualização.
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 48f544da..8dd27775 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -358,4 +358,19 @@
Boutons
Actions
+
+
+ API externe (KeyMapper, Tasker…)
+ ⚠ Permet à toute application installée de piloter les réglages de conduite. Le verrou de vitesse reste actif. Laissez désactivé si vous n\'en avez pas l\'usage.
+
+
+ Activer l\'API externe ?
+ N\'IMPORTE QUELLE APPLICATION INSTALLÉE POURRA PRENDRE LE CONTRÔLE DU VÉHICULE.
+ Mode de conduite, régénération, sièges et volant chauffants, climatisation et profils deviennent pilotables par une application tierce, sans autorisation supplémentaire de votre part.\n\nLe blocage au-delà d\'une certaine vitesse reste actif.\n\nN\'activez cette option que si vous utilisez réellement KeyMapper, Tasker ou un outil équivalent.
+ Cette option s\'active à vos risques et périls : vous restez seul responsable de ce qu\'une application tierce fera de votre véhicule.
+ Confirmer
+
+
+ Canal de mise à jour bêta
+ Reçoit les versions de test avant leur sortie officielle. Elles peuvent contenir des bugs. Le retour à une version antérieure n\'est pas possible par mise à jour.
diff --git a/app/src/test/java/com/mg4/control/update/UpdateCheckerTest.kt b/app/src/test/java/com/mg4/control/update/UpdateCheckerTest.kt
index b9a67441..5c017aaa 100644
--- a/app/src/test/java/com/mg4/control/update/UpdateCheckerTest.kt
+++ b/app/src/test/java/com/mg4/control/update/UpdateCheckerTest.kt
@@ -82,4 +82,60 @@ class UpdateCheckerTest {
assertEquals(listOf(2, 6, 4), UpdateChecker.segments("2.6.4-offline"))
assertEquals(listOf(2, 6, 4), UpdateChecker.segments("2.6.4+build9"))
}
+
+ // ── Canal beta : precedence des pre-releases (semver §11) ────────────────
+
+ @Test
+ fun `beta d une version future remplace la stable actuelle`() {
+ assertTrue(UpdateChecker.isNewer("2.6.6-beta1", "2.6.5"))
+ }
+
+ @Test
+ fun `beta suivante remplace la precedente`() {
+ assertTrue(UpdateChecker.isNewer("2.6.6-beta2", "2.6.6-beta1"))
+ }
+
+ @Test
+ fun `beta 10 est posterieure a beta 9 - comparaison numerique et non lexicale`() {
+ assertTrue(UpdateChecker.isNewer("2.6.6-beta10", "2.6.6-beta9"))
+ }
+
+ @Test
+ fun `la stable remplace la derniere beta du meme numero`() {
+ assertTrue(UpdateChecker.isNewer("2.6.6", "2.6.6-beta9"))
+ }
+
+ @Test
+ fun `une beta n ecrase JAMAIS la stable de meme numero`() {
+ assertFalse(UpdateChecker.isNewer("2.6.6-beta9", "2.6.6"))
+ }
+
+ @Test
+ fun `beta anterieure a la stable installee est ignoree`() {
+ assertFalse(UpdateChecker.isNewer("2.6.5-beta3", "2.6.5"))
+ }
+
+ @Test
+ fun `meme beta - pas de mise a jour`() {
+ assertFalse(UpdateChecker.isNewer("2.6.6-beta1", "2.6.6-beta1"))
+ }
+
+ @Test
+ fun `suffixe du workflow beta - label point numero de run`() {
+ assertTrue(UpdateChecker.isNewer("2.6.6-beta.42", "2.6.6-beta.41"))
+ assertFalse(UpdateChecker.isNewer("2.6.6-beta.41", "2.6.6-beta.42"))
+ }
+
+ @Test
+ fun `rc est posterieure a beta - ordre alphabetique des identifiants`() {
+ assertTrue(UpdateChecker.isNewer("2.6.6-rc.1", "2.6.6-beta.9"))
+ }
+
+ @Test
+ fun `extraction du suffixe de pre-release`() {
+ assertEquals("beta.42", UpdateChecker.preRelease("v2.6.6-beta.42"))
+ assertEquals("", UpdateChecker.preRelease("2.6.6"))
+ assertEquals("", UpdateChecker.preRelease("v2.6.6+build7"))
+ }
+
}