|
| 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 | +} |
0 commit comments