Skip to content

Commit 9af79e5

Browse files
authored
Merge pull request #85 from SliDeeN/beta-OTA
Merge new design, api for tiers apk and beta OTA
2 parents 7edef29 + 804b102 commit 9af79e5

18 files changed

Lines changed: 1337 additions & 66 deletions

File tree

‎.github/workflows/beta.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,11 @@ jobs:
116116
uses: softprops/action-gh-release@v2
117117
with:
118118
tag_name: v${{ steps.ver.outputs.full }}
119-
name: Beta ${{ steps.ver.outputs.full }}
119+
# Sans target_commitish, GitHub cree le tag sur la branche par defaut : la beta 2.6.6-beta.2
120+
# a ainsi ete taguee sur main alors qu'elle etait construite depuis beta-OTA. L'APK etait
121+
# bonne, mais le tag pointait vers un code qui n'etait pas celui livre.
122+
target_commitish: ${{ github.sha }}
123+
name: MG4Control ${{ steps.ver.outputs.full }}
120124
prerelease: true
121125
files: dist/*.apk
122126
body: |

‎README.md‎

Lines changed: 384 additions & 57 deletions
Large diffs are not rendered by default.

‎app/build.gradle.kts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ android {
1111
applicationId = "com.mg4.control"
1212
minSdk = 28
1313
targetSdk = 34
14-
versionCode = 17
15-
versionName = "2.6.5"
14+
versionCode = 18
15+
// Suffixe injecte par la CI beta : -Pmg4.versionSuffix=-beta42 produit "2.6.6-beta42".
16+
// L'APK installe annonce alors EXACTEMENT ce que dit le tag de la release, sans quoi
17+
// l'OTA reproposerait la meme mise a jour indefiniment.
18+
versionName = "2.6.6" + (project.findProperty("mg4.versionSuffix") as String? ?: "")
1619
}
1720

1821
// Signature avec la clé plateforme de la ROM (requise par sharedUserId=android.uid.system).

‎app/src/main/AndroidManifest.xml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,52 @@
7070
hors du manifest de base évite aussi un conflit d'authority qui empêcherait
7171
d'installer les APK online et offline côte à côte. -->
7272

73+
<!-- ═══ API externe (issue #79) — KeyMapper, Tasker, etc. ═══════════════
74+
⚠️ Exporté SANS permission, et c'est délibéré : KeyMapper et Tasker viennent du
75+
Play Store, elles ne seront jamais signées avec la clé plateforme, donc une
76+
permission "signature" les exclurait. Et un broadcast ne porte aucune identité
77+
d'émetteur : on ne peut pas filtrer l'appelant ici.
78+
79+
Le verrou est donc AILLEURS, et il est double :
80+
1. l'interrupteur "API externe" des Réglages, DÉSACTIVÉ PAR DÉFAUT — tant qu'il
81+
est off, le receiver sort immédiatement ;
82+
2. VehicleWriteGate, posé dans les primitives d'écriture de MG4Hardware, donc
83+
sous tout point d'entrée : aucune écriture véhicule en roulant.
84+
Toute commande reçue est journalisée (tag MG4_API), refus compris. -->
85+
<receiver
86+
android:name=".api.ExternalApiReceiver"
87+
android:exported="true">
88+
<intent-filter>
89+
<!-- Forme riche : un extra nomme la commande (Tasker, adb, scripts). -->
90+
<action android:name="com.mg4.control.action.EXECUTE" />
91+
<action android:name="com.mg4.control.action.SET" />
92+
<!-- Une action par commande : KeyMapper ne sait saisir QUE la chaine
93+
d'action, son editeur d'intent n'a pas de champ extras.
94+
Les commandes de securite active (ADAS, AEB, TSR, alertes) et l'extinction
95+
vehicule sont VOLONTAIREMENT absentes — cf. ExternalApi.BLOCKED_ACTIONS,
96+
qui les refuse aussi via EXECUTE. -->
97+
<action android:name="com.mg4.control.action.ONE_PEDAL" />
98+
<action android:name="com.mg4.control.action.ENERGY_SAVING_TOGGLE" />
99+
<action android:name="com.mg4.control.action.PROFILE_PICKER" />
100+
<action android:name="com.mg4.control.action.OPEN_APP" />
101+
</intent-filter>
102+
</receiver>
103+
104+
<!-- Lecture seule de l'état véhicule. Contrairement au receiver, un provider CONNAÎT son
105+
appelant (getCallingPackage) : d'où la liste blanche optionnelle et la journalisation
106+
nominative. Aucune écriture ne passe par ici. -->
107+
<!-- Authority dérivée de l'applicationId : les variantes online et offline s'installent
108+
côte à côte, et deux paquets ne peuvent pas déclarer la même authority
109+
(INSTALL_FAILED_CONFLICTING_PROVIDER — le FileProvider s'y était déjà heurté).
110+
Pas de readPermission : Tasker est un APK figé, il ne peut pas déclarer une
111+
permission que nous inventerions — en exiger une fermerait la porte qu'on ouvre.
112+
Le contrôle est en code et il est RÉEL ici, contrairement au receiver :
113+
getCallingPackage() est fiable, donc interrupteur + liste blanche + journal nominatif. -->
114+
<provider
115+
android:name=".api.StateProvider"
116+
android:authorities="${applicationId}.state"
117+
android:exported="true" />
118+
73119
<receiver
74120
android:name=".receiver.BootReceiver"
75121
android:exported="true"
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package com.mg4.control.api
2+
3+
import android.content.Context
4+
5+
/**
6+
* Contrat de l'API externe — celle qu'utilisent KeyMapper, Tasker et consorts (issue #79).
7+
*
8+
* ⚠️ SÉCURITÉ — à lire avant de toucher à ce fichier.
9+
*
10+
* T-902 avait fermé le receiver hardkey parce que n'importe quelle application installée pouvait
11+
* forger son broadcast et piloter mode de conduite, régen, ADAS et AEB. Cette API rouvre
12+
* délibérément cette voie, parce que c'est la seule façon de servir des applications du Play Store :
13+
* KeyMapper et Tasker ne seront JAMAIS signées avec la clé plateforme, donc une permission
14+
* `signature` les exclurait. Il n'existe pas non plus de moyen fiable de connaître l'émetteur d'un
15+
* broadcast — une liste blanche est impossible de ce côté-là.
16+
*
17+
* Le garde-fou est donc ailleurs, et il est double :
18+
* 1. **[isEnabled] — interrupteur dans Réglages, DÉSACTIVÉ PAR DÉFAUT.** Tant que le propriétaire
19+
* ne l'a pas activé sciemment, rien n'est joignable. C'est le vrai verrou.
20+
* 2. Le **verrou de vitesse** est hérité gratuitement : `VehicleWriteGate.allow()` est posé dans
21+
* les primitives d'écriture de `MG4Hardware`, donc en dessous de tout point d'entrée. Aucun
22+
* appelant externe ne peut écrire en roulant, quoi qu'il envoie.
23+
*
24+
* Toute écriture passant par ici est journalisée (tag [LOG_TAG]) : en cas de comportement
25+
* inattendu sur un véhicule, on doit pouvoir dire qui a demandé quoi.
26+
*/
27+
object ExternalApi {
28+
29+
const val LOG_TAG = "MG4_API"
30+
31+
private const val PREFS = "mg4_settings"
32+
33+
/** Interrupteur maître, Réglages → Réglages avancés. Défaut : false, volontairement. */
34+
const val KEY_ENABLED = "external_api_enabled"
35+
36+
/**
37+
* Liste blanche d'appelants, séparée par des virgules. Vide = tous acceptés.
38+
*
39+
* Uniquement exploitable par le ContentProvider : lui seul peut vérifier son appelant
40+
* (`getCallingPackage()`). Un broadcast ne porte pas d'identité d'émetteur.
41+
*/
42+
const val KEY_ALLOWLIST = "external_api_allowlist"
43+
44+
// ── Broadcasts entrants ──────────────────────────────────────────────────
45+
46+
/**
47+
* Préfixe des actions « une commande = une action d'intent » :
48+
* `com.mg4.control.action.ONE_PEDAL`, `…ADAS_CYCLE`, `…PROFILE_PICKER`…
49+
*
50+
* ⚠️ RAISON D'ÊTRE, ne pas fusionner avec [ACTION_EXECUTE] en croyant simplifier :
51+
* KeyMapper ne sait saisir QUE la chaîne d'action, son éditeur d'intent n'a pas de champ
52+
* « extras ». Une API qui exige un extra lui est inutilisable. Cette forme-là ne demande
53+
* rien d'autre que l'action, donc elle marche avec n'importe quel outil capable d'émettre
54+
* un intent.
55+
*/
56+
const val ACTION_PREFIX = "com.mg4.control.action."
57+
58+
/** Commandes exposées en action directe (celles qui ne réclament aucun paramètre). */
59+
val DIRECT_ACTIONS = listOf(
60+
"ONE_PEDAL", "ENERGY_SAVING_TOGGLE", "PROFILE_PICKER", "OPEN_APP"
61+
)
62+
63+
/**
64+
* Commandes VOLONTAIREMENT hors API, quelle que soit la forme d'appel.
65+
*
66+
* Ces sept-là touchent à la sécurité active ou coupent le véhicule ; les exposer à toute
67+
* application installée n'est pas un risque acceptable. Le filtre s'applique aussi à
68+
* [ACTION_EXECUTE] : les retirer des seules actions directes n'aurait rien protégé, puisque
69+
* l'extra `action` y donnait le même accès sans authentification supplémentaire.
70+
*
71+
* Elles restent évidemment pilotables depuis l'application et les raccourcis volant.
72+
*/
73+
val BLOCKED_ACTIONS = setOf(
74+
"VEHICLE_POWER_OFF", "SOUND_WARNING", "OVERSPEED_ALARM", "SPEED_LIMIT_TONE",
75+
"ADAS_CYCLE", "AEB_CYCLE", "TSR_TOGGLE"
76+
)
77+
78+
/** Nom de ShortcutAction porté par une action directe, ou null si ce n'en est pas une. */
79+
fun directActionName(action: String?): String? {
80+
val a = action ?: return null
81+
if (!a.startsWith(ACTION_PREFIX)) return null
82+
val name = a.removePrefix(ACTION_PREFIX)
83+
return name.takeIf { it in DIRECT_ACTIONS }
84+
}
85+
86+
/**
87+
* Forme riche, pour les appelants qui savent joindre des extras (Tasker, adb, scripts).
88+
* Extra [EXTRA_ACTION] = nom de ShortcutAction, [EXTRA_PROFILE] pour APPLY_PROFILE.
89+
*/
90+
const val ACTION_EXECUTE = "com.mg4.control.action.EXECUTE"
91+
92+
/** Écrit un réglage. Extras [EXTRA_KEY] et [EXTRA_VALUE]. */
93+
const val ACTION_SET = "com.mg4.control.action.SET"
94+
95+
const val EXTRA_ACTION = "action"
96+
const val EXTRA_KEY = "key"
97+
const val EXTRA_VALUE = "value"
98+
/** Nom OU identifiant du profil, pour `action=APPLY_PROFILE` et `key=profile`. */
99+
const val EXTRA_PROFILE = "profile"
100+
101+
// ── Clés acceptées par ACTION_SET ────────────────────────────────────────
102+
const val SET_DRIVE_MODE = "drive_mode" // ECO|NORMAL|SPORT|SNOW|CUSTOM
103+
const val SET_REGEN = "regen" // OFF|LOW|MEDIUM|HIGH|ADAPTIVE|ONE_PEDAL
104+
const val SET_SEAT_HEAT_LEFT = "seat_heat_left" // 0..3
105+
const val SET_SEAT_HEAT_RIGHT = "seat_heat_right" // 0..3
106+
const val SET_STEERING_HEAT = "steering_heat" // 0|1 (ou false|true)
107+
const val SET_PROFILE = "profile" // nom ou id
108+
109+
// ── Climatisation ────────────────────────────────────────────────────────
110+
// Réglages de confort : ils ne changent pas le comportement routier, contrairement aux
111+
// commandes de [BLOCKED_ACTIONS]. Ignorés si le firmware n'expose pas la clim.
112+
const val SET_HVAC_POWER = "hvac_power" // 0|1
113+
const val SET_HVAC_AC = "ac" // 0|1
114+
const val SET_HVAC_AUTO = "hvac_auto" // 0|1
115+
const val SET_HVAC_TEMP = "hvac_temp" // °C, clampé aux bornes réelles du véhicule
116+
const val SET_HVAC_FAN = "hvac_fan" // niveau, clampé aux bornes réelles
117+
const val SET_HVAC_RECIRC = "hvac_recirc" // INNER|OUTSIDE|AUTO (ou 0|1|2)
118+
const val SET_DEFROST_FRONT = "defrost_front" // 0|1
119+
const val SET_DEFROST_REAR = "defrost_rear" // 0|1
120+
121+
// ── Lecture (ContentProvider) ────────────────────────────────────────────
122+
123+
/**
124+
* `content://<applicationId>.state/state` → une ligne, une colonne par valeur.
125+
*
126+
* ⚠️ L'authority suit l'applicationId, elle n'est donc PAS une constante : la variante
127+
* offline s'installe à côté de l'online et deux paquets ne peuvent pas déclarer la même
128+
* (INSTALL_FAILED_CONFLICTING_PROVIDER). Les intégrateurs doivent viser
129+
* `com.mg4.control.state` ou `com.mg4.control.offline.state` selon la variante installée.
130+
*/
131+
fun authority(context: Context): String = context.packageName + ".state"
132+
133+
const val PATH_STATE = "state"
134+
135+
fun isEnabled(context: Context): Boolean =
136+
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_ENABLED, false)
137+
138+
/**
139+
* Vrai si [caller] est autorisé. Liste vide = pas de filtrage (l'interrupteur maître reste le
140+
* verrou). Un appelant inconnu de la plateforme (`null`) est refusé dès que la liste est posée.
141+
*/
142+
fun isCallerAllowed(context: Context, caller: String?): Boolean {
143+
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
144+
.getString(KEY_ALLOWLIST, "").orEmpty().trim()
145+
if (raw.isEmpty()) return true
146+
val allowed = raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }
147+
return caller != null && allowed.any { it.equals(caller, ignoreCase = true) }
148+
}
149+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.mg4.control.api
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import com.mg4.control.debug.AppLogger
7+
import com.mg4.control.service.MG4ControlService
8+
9+
/**
10+
* Point d'entrée des applications tierces (KeyMapper, Tasker…) — issue #79.
11+
*
12+
* Déclaré dans le Manifest, donc joignable application fermée : on ne fait ici QUE le contrôle
13+
* d'accès et le relais. Le travail réel part dans [MG4ControlService], pour deux raisons — un
14+
* receiver ne dispose que de dix secondes, et le service détient déjà l'état des bascules et le
15+
* répartiteur d'actions.
16+
*
17+
* Voir [ExternalApi] pour la discussion sécurité : le verrou est l'interrupteur des Réglages,
18+
* désactivé par défaut.
19+
*/
20+
class ExternalApiReceiver : BroadcastReceiver() {
21+
22+
override fun onReceive(context: Context, intent: Intent) {
23+
val action = intent.action ?: return
24+
val direct = ExternalApi.directActionName(action)
25+
if (direct == null &&
26+
action != ExternalApi.ACTION_EXECUTE && action != ExternalApi.ACTION_SET) return
27+
28+
// On journalise AUSSI les refus : « l'API ne répond pas » doit être diagnosticable sans
29+
// avoir à deviner si c'est l'interrupteur ou l'intent qui est en cause.
30+
if (!ExternalApi.isEnabled(context)) {
31+
AppLogger.i(ExternalApi.LOG_TAG, "REFUS $action — API externe désactivée dans Réglages")
32+
return
33+
}
34+
35+
AppLogger.i(ExternalApi.LOG_TAG, "REÇU $action " +
36+
"action=${intent.getStringExtra(ExternalApi.EXTRA_ACTION)} " +
37+
"key=${intent.getStringExtra(ExternalApi.EXTRA_KEY)} " +
38+
"value=${intent.extras?.get(ExternalApi.EXTRA_VALUE)} " +
39+
"profile=${intent.getStringExtra(ExternalApi.EXTRA_PROFILE)}")
40+
41+
val relay = Intent(context, MG4ControlService::class.java).apply {
42+
// Une action directe est convertie en forme riche : le service n'a ainsi qu'un seul
43+
// chemin de traitement, quel que soit le vocabulaire employé par l'appelant.
44+
setAction(if (direct != null) ExternalApi.ACTION_EXECUTE else action)
45+
putExtra(ExternalApi.EXTRA_ACTION, direct ?: intent.getStringExtra(ExternalApi.EXTRA_ACTION))
46+
putExtra(ExternalApi.EXTRA_KEY, intent.getStringExtra(ExternalApi.EXTRA_KEY))
47+
putExtra(ExternalApi.EXTRA_PROFILE, intent.getStringExtra(ExternalApi.EXTRA_PROFILE))
48+
// La valeur arrive en texte (adb, Tasker) ou en entier (KeyMapper) : on relaie la forme
49+
// texte, seule commune aux deux, et le service se charge de l'interpréter.
50+
putExtra(ExternalApi.EXTRA_VALUE, intent.extras?.get(ExternalApi.EXTRA_VALUE)?.toString())
51+
}
52+
runCatching { context.startForegroundService(relay) }
53+
.onFailure { AppLogger.w(ExternalApi.LOG_TAG, "relais vers le service impossible : ${it.message}") }
54+
}
55+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.mg4.control.api
2+
3+
import android.content.ContentProvider
4+
import android.content.ContentValues
5+
import android.database.Cursor
6+
import android.database.MatrixCursor
7+
import android.net.Uri
8+
import com.mg4.control.debug.AppLogger
9+
import com.mg4.control.hardware.MG4Hardware
10+
import com.mg4.control.profile.ProfileManager
11+
import com.mg4.control.util.FirmwareInfo
12+
13+
/**
14+
* Lecture de l'état véhicule par les applications tierces — `content://com.mg4.control.state/state`.
15+
*
16+
* Pourquoi un ContentProvider et pas un broadcast : un broadcast ne sait pas retourner de valeur,
17+
* et surtout il ne porte AUCUNE identité d'émetteur. Ici `callingPackage` est fiable (fourni par la
18+
* plateforme), ce qui permet à la fois de journaliser qui lit et d'appliquer une liste blanche.
19+
*
20+
* Lecture seule — aucune écriture véhicule ne passe par ce composant, [insert]/[update]/[delete]
21+
* sont volontairement inertes.
22+
*
23+
* Le format est un curseur d'UNE ligne, une colonne par valeur : c'est ce que savent consommer
24+
* Tasker et les outils d'automatisation. Une valeur illisible sort à `null` plutôt qu'à zéro — un
25+
* zéro se confondrait avec « siège éteint » ou « à l'arrêt ».
26+
*/
27+
class StateProvider : ContentProvider() {
28+
29+
private companion object {
30+
val COLUMNS = arrayOf(
31+
"drive_mode", "regen", "seat_heat_left", "seat_heat_right", "steering_heat",
32+
"speed_kmh", "outside_temp_c", "tsr", "energy_saving", "aeb_enabled",
33+
"firmware", "profiles", "default_profile"
34+
)
35+
}
36+
37+
override fun onCreate(): Boolean = true
38+
39+
override fun query(
40+
uri: Uri, projection: Array<out String>?, selection: String?,
41+
selectionArgs: Array<out String>?, sortOrder: String?
42+
): Cursor? {
43+
val ctx = context ?: return null
44+
val caller = callingPackage
45+
46+
if (!ExternalApi.isEnabled(ctx)) {
47+
AppLogger.i(ExternalApi.LOG_TAG, "LECTURE refusée (appelant=$caller) — API désactivée")
48+
return null
49+
}
50+
if (!ExternalApi.isCallerAllowed(ctx, caller)) {
51+
AppLogger.w(ExternalApi.LOG_TAG, "LECTURE refusée — $caller hors liste blanche")
52+
return null
53+
}
54+
55+
val pm = ProfileManager(ctx)
56+
val profiles = pm.getAll()
57+
val cursor = MatrixCursor(COLUMNS)
58+
cursor.addRow(arrayOf(
59+
MG4Hardware.getDriveMode()?.name,
60+
MG4Hardware.getRegenLevel()?.name,
61+
MG4Hardware.getSeatHeatLeft().takeIf { it >= 0 },
62+
MG4Hardware.getSeatHeatRight().takeIf { it >= 0 },
63+
if (MG4Hardware.isSteeringHeatOn()) 1 else 0,
64+
MG4Hardware.getVehicleSpeedKmh(),
65+
MG4Hardware.getOutsideTempCelsius(),
66+
if (MG4Hardware.isTsrOn()) 1 else 0,
67+
if (MG4Hardware.isEnergySavingOn()) 1 else 0,
68+
if (MG4Hardware.isAebEnabled()) 1 else 0,
69+
FirmwareInfo.getGeneration().name,
70+
profiles.joinToString("|") { it.name },
71+
profiles.firstOrNull { it.id == pm.getDefaultId() }?.name
72+
))
73+
AppLogger.i(ExternalApi.LOG_TAG, "LECTURE par $caller")
74+
return cursor
75+
}
76+
77+
override fun getType(uri: Uri): String =
78+
"vnd.android.cursor.item/vnd.${context?.packageName}.${ExternalApi.PATH_STATE}"
79+
80+
// Lecture seule, par conception : aucune écriture véhicule ne doit passer par un provider
81+
// exporté. Les écritures ont leur propre chemin (ExternalApiReceiver), journalisé et
82+
// soumis au verrou de vitesse.
83+
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
84+
override fun update(uri: Uri, values: ContentValues?, selection: String?,
85+
selectionArgs: Array<out String>?): Int = 0
86+
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
87+
}

0 commit comments

Comments
 (0)