Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
441 changes: 384 additions & 57 deletions README.md

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
46 changes: 46 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->

<!-- ═══ API externe (issue #79) — KeyMapper, Tasker, etc. ═══════════════
⚠️ Exporté SANS permission, et c'est délibéré : KeyMapper et Tasker viennent du
Play Store, elles ne seront jamais signées avec la clé plateforme, donc une
permission "signature" les exclurait. Et un broadcast ne porte aucune identité
d'émetteur : on ne peut pas filtrer l'appelant ici.

Le verrou est donc AILLEURS, et il est double :
1. l'interrupteur "API externe" des Réglages, DÉSACTIVÉ PAR DÉFAUT — tant qu'il
est off, le receiver sort immédiatement ;
2. VehicleWriteGate, posé dans les primitives d'écriture de MG4Hardware, donc
sous tout point d'entrée : aucune écriture véhicule en roulant.
Toute commande reçue est journalisée (tag MG4_API), refus compris. -->
<receiver
android:name=".api.ExternalApiReceiver"
android:exported="true">
<intent-filter>
<!-- Forme riche : un extra nomme la commande (Tasker, adb, scripts). -->
<action android:name="com.mg4.control.action.EXECUTE" />
<action android:name="com.mg4.control.action.SET" />
<!-- Une action par commande : KeyMapper ne sait saisir QUE la chaine
d'action, son editeur d'intent n'a pas de champ extras.
Les commandes de securite active (ADAS, AEB, TSR, alertes) et l'extinction
vehicule sont VOLONTAIREMENT absentes — cf. ExternalApi.BLOCKED_ACTIONS,
qui les refuse aussi via EXECUTE. -->
<action android:name="com.mg4.control.action.ONE_PEDAL" />
<action android:name="com.mg4.control.action.ENERGY_SAVING_TOGGLE" />
<action android:name="com.mg4.control.action.PROFILE_PICKER" />
<action android:name="com.mg4.control.action.OPEN_APP" />
</intent-filter>
</receiver>

<!-- Lecture seule de l'état véhicule. Contrairement au receiver, un provider CONNAÎT son
appelant (getCallingPackage) : d'où la liste blanche optionnelle et la journalisation
nominative. Aucune écriture ne passe par ici. -->
<!-- Authority dérivée de l'applicationId : les variantes online et offline s'installent
côte à côte, et deux paquets ne peuvent pas déclarer la même authority
(INSTALL_FAILED_CONFLICTING_PROVIDER — le FileProvider s'y était déjà heurté).
Pas de readPermission : Tasker est un APK figé, il ne peut pas déclarer une
permission que nous inventerions — en exiger une fermerait la porte qu'on ouvre.
Le contrôle est en code et il est RÉEL ici, contrairement au receiver :
getCallingPackage() est fiable, donc interrupteur + liste blanche + journal nominatif. -->
<provider
android:name=".api.StateProvider"
android:authorities="${applicationId}.state"
android:exported="true" />

<receiver
android:name=".receiver.BootReceiver"
android:exported="true"
Expand Down
149 changes: 149 additions & 0 deletions app/src/main/java/com/mg4/control/api/ExternalApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package com.mg4.control.api

import android.content.Context

/**
* Contrat de l'API externe — celle qu'utilisent KeyMapper, Tasker et consorts (issue #79).
*
* ⚠️ SÉCURITÉ — à lire avant de toucher à ce fichier.
*
* T-902 avait fermé le receiver hardkey parce que n'importe quelle application installée pouvait
* forger son broadcast et piloter mode de conduite, régen, ADAS et AEB. Cette API rouvre
* délibérément cette voie, parce que c'est la seule façon de servir des applications du Play Store :
* KeyMapper et Tasker ne seront JAMAIS signées avec la clé plateforme, donc une permission
* `signature` les exclurait. Il n'existe pas non plus de moyen fiable de connaître l'émetteur d'un
* broadcast — une liste blanche est impossible de ce côté-là.
*
* Le garde-fou est donc ailleurs, et il est double :
* 1. **[isEnabled] — interrupteur dans Réglages, DÉSACTIVÉ PAR DÉFAUT.** Tant que le propriétaire
* ne l'a pas activé sciemment, rien n'est joignable. C'est le vrai verrou.
* 2. Le **verrou de vitesse** est hérité gratuitement : `VehicleWriteGate.allow()` est posé dans
* les primitives d'écriture de `MG4Hardware`, donc en dessous de tout point d'entrée. Aucun
* appelant externe ne peut écrire en roulant, quoi qu'il envoie.
*
* Toute écriture passant par ici est journalisée (tag [LOG_TAG]) : en cas de comportement
* inattendu sur un véhicule, on doit pouvoir dire qui a demandé quoi.
*/
object ExternalApi {

const val LOG_TAG = "MG4_API"

private const val PREFS = "mg4_settings"

/** Interrupteur maître, Réglages → Réglages avancés. Défaut : false, volontairement. */
const val KEY_ENABLED = "external_api_enabled"

/**
* Liste blanche d'appelants, séparée par des virgules. Vide = tous acceptés.
*
* Uniquement exploitable par le ContentProvider : lui seul peut vérifier son appelant
* (`getCallingPackage()`). Un broadcast ne porte pas d'identité d'émetteur.
*/
const val KEY_ALLOWLIST = "external_api_allowlist"

// ── Broadcasts entrants ──────────────────────────────────────────────────

/**
* Préfixe des actions « une commande = une action d'intent » :
* `com.mg4.control.action.ONE_PEDAL`, `…ADAS_CYCLE`, `…PROFILE_PICKER`…
*
* ⚠️ RAISON D'ÊTRE, ne pas fusionner avec [ACTION_EXECUTE] en croyant simplifier :
* KeyMapper ne sait saisir QUE la chaîne d'action, son éditeur d'intent n'a pas de champ
* « extras ». Une API qui exige un extra lui est inutilisable. Cette forme-là ne demande
* rien d'autre que l'action, donc elle marche avec n'importe quel outil capable d'émettre
* un intent.
*/
const val ACTION_PREFIX = "com.mg4.control.action."

/** Commandes exposées en action directe (celles qui ne réclament aucun paramètre). */
val DIRECT_ACTIONS = listOf(
"ONE_PEDAL", "ENERGY_SAVING_TOGGLE", "PROFILE_PICKER", "OPEN_APP"
)

/**
* Commandes VOLONTAIREMENT hors API, quelle que soit la forme d'appel.
*
* Ces sept-là touchent à la sécurité active ou coupent le véhicule ; les exposer à toute
* application installée n'est pas un risque acceptable. Le filtre s'applique aussi à
* [ACTION_EXECUTE] : les retirer des seules actions directes n'aurait rien protégé, puisque
* l'extra `action` y donnait le même accès sans authentification supplémentaire.
*
* Elles restent évidemment pilotables depuis l'application et les raccourcis volant.
*/
val BLOCKED_ACTIONS = setOf(
"VEHICLE_POWER_OFF", "SOUND_WARNING", "OVERSPEED_ALARM", "SPEED_LIMIT_TONE",
"ADAS_CYCLE", "AEB_CYCLE", "TSR_TOGGLE"
)

/** Nom de ShortcutAction porté par une action directe, ou null si ce n'en est pas une. */
fun directActionName(action: String?): String? {
val a = action ?: return null
if (!a.startsWith(ACTION_PREFIX)) return null
val name = a.removePrefix(ACTION_PREFIX)
return name.takeIf { it in DIRECT_ACTIONS }
}

/**
* Forme riche, pour les appelants qui savent joindre des extras (Tasker, adb, scripts).
* Extra [EXTRA_ACTION] = nom de ShortcutAction, [EXTRA_PROFILE] pour APPLY_PROFILE.
*/
const val ACTION_EXECUTE = "com.mg4.control.action.EXECUTE"

/** Écrit un réglage. Extras [EXTRA_KEY] et [EXTRA_VALUE]. */
const val ACTION_SET = "com.mg4.control.action.SET"

const val EXTRA_ACTION = "action"
const val EXTRA_KEY = "key"
const val EXTRA_VALUE = "value"
/** Nom OU identifiant du profil, pour `action=APPLY_PROFILE` et `key=profile`. */
const val EXTRA_PROFILE = "profile"

// ── Clés acceptées par ACTION_SET ────────────────────────────────────────
const val SET_DRIVE_MODE = "drive_mode" // ECO|NORMAL|SPORT|SNOW|CUSTOM
const val SET_REGEN = "regen" // OFF|LOW|MEDIUM|HIGH|ADAPTIVE|ONE_PEDAL
const val SET_SEAT_HEAT_LEFT = "seat_heat_left" // 0..3
const val SET_SEAT_HEAT_RIGHT = "seat_heat_right" // 0..3
const val SET_STEERING_HEAT = "steering_heat" // 0|1 (ou false|true)
const val SET_PROFILE = "profile" // nom ou id

// ── Climatisation ────────────────────────────────────────────────────────
// Réglages de confort : ils ne changent pas le comportement routier, contrairement aux
// commandes de [BLOCKED_ACTIONS]. Ignorés si le firmware n'expose pas la clim.
const val SET_HVAC_POWER = "hvac_power" // 0|1
const val SET_HVAC_AC = "ac" // 0|1
const val SET_HVAC_AUTO = "hvac_auto" // 0|1
const val SET_HVAC_TEMP = "hvac_temp" // °C, clampé aux bornes réelles du véhicule
const val SET_HVAC_FAN = "hvac_fan" // niveau, clampé aux bornes réelles
const val SET_HVAC_RECIRC = "hvac_recirc" // INNER|OUTSIDE|AUTO (ou 0|1|2)
const val SET_DEFROST_FRONT = "defrost_front" // 0|1
const val SET_DEFROST_REAR = "defrost_rear" // 0|1

// ── Lecture (ContentProvider) ────────────────────────────────────────────

/**
* `content://<applicationId>.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) }
}
}
55 changes: 55 additions & 0 deletions app/src/main/java/com/mg4/control/api/ExternalApiReceiver.kt
Original file line number Diff line number Diff line change
@@ -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}") }
}
}
87 changes: 87 additions & 0 deletions app/src/main/java/com/mg4/control/api/StateProvider.kt
Original file line number Diff line number Diff line change
@@ -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<out String>?, selection: String?,
selectionArgs: Array<out String>?, 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<out String>?): Int = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
}
Loading
Loading