From 6919c7ff2570ee275668748ce954cb1a3a3f8a2f Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Mon, 17 Aug 2026 21:45:36 +0200 Subject: [PATCH 1/6] add external API to call MG4Control --- app/src/main/AndroidManifest.xml | 50 +++++++ .../java/com/mg4/control/api/ExternalApi.kt | 124 +++++++++++++++++ .../mg4/control/api/ExternalApiReceiver.kt | 55 ++++++++ .../java/com/mg4/control/api/StateProvider.kt | 87 ++++++++++++ .../mg4/control/service/MG4ControlService.kt | 126 ++++++++++++++++++ .../com/mg4/control/ui/SettingsFragment.kt | 64 +++++++++ app/src/main/res/layout/fragment_settings.xml | 48 +++++++ app/src/main/res/values-de/strings.xml | 10 ++ app/src/main/res/values-en/strings.xml | 10 ++ app/src/main/res/values-es/strings.xml | 10 ++ app/src/main/res/values-it/strings.xml | 10 ++ app/src/main/res/values-pt/strings.xml | 10 ++ app/src/main/res/values/strings.xml | 10 ++ 13 files changed, 614 insertions(+) create mode 100644 app/src/main/java/com/mg4/control/api/ExternalApi.kt create mode 100644 app/src/main/java/com/mg4/control/api/ExternalApiReceiver.kt create mode 100644 app/src/main/java/com/mg4/control/api/StateProvider.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ce3abb3..cf80c48 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -70,6 +70,56 @@ 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 0000000..1bf3824 --- /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 0000000..51a6306 --- /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 31500ba..14b4054 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,108 @@ 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() + 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 -> false + } + if (!ok) AppLogger.w(ExternalApi.LOG_TAG, "SET refusé — clé ou valeur invalide ($key=$value)") + } + return true + } + + /** 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 3cf8a68..360dc59 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,39 @@ class SettingsFragment : Fragment() { btnThemeDark.setOnClickListener { applyThemeMode("dark") } btnThemeLight.setOnClickListener { applyThemeMode("light") } + // ── 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) @@ -762,4 +796,34 @@ 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 warn = getString(R.string.external_api_confirm_warn) + val body = getString(R.string.external_api_confirm_msg) + val text = android.text.SpannableStringBuilder("$warn\n\n$body") + text.setSpan(android.text.style.ForegroundColorSpan(requireContext().getColor(R.color.dash_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) + + 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/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 9d040c3..92d2a2f 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, ADAS, Sitzheizung 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. + Bestätigen diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4ce3df5..2a50c4e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -356,4 +356,14 @@ 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, ADAS, heated seats 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. + Confirm diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7999931..9df7d7c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -356,4 +356,14 @@ 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, ADAS, asientos calefactados 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. + Confirmar diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 527dd5a..882fb00 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -356,4 +356,14 @@ 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, ADAS, sedili riscaldati 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. + Conferma diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index dc7ace0..c20cd5e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -356,4 +356,14 @@ 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, ADAS, bancos aquecidos 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. + Confirmar diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48f544d..e883973 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -358,4 +358,14 @@ 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, ADAS, sièges chauffants 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. + Confirmer From aa534e45ec37a527465f4ebfa7c657c20ac3a676 Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Mon, 17 Aug 2026 22:03:53 +0200 Subject: [PATCH 2/6] add/remove intents + update readme --- README.md | 367 ++++++++++++++++-- app/src/main/AndroidManifest.xml | 12 +- .../java/com/mg4/control/api/ExternalApi.kt | 31 +- .../mg4/control/service/MG4ControlService.kt | 64 ++- 4 files changed, 427 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 88d2a5c..51c7ac7 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,20 +354,38 @@ 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 ``` -Un second appui sur PROFILS ou RÉGLAGES ferme la vue et revient au dashboard. +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 | -### 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 +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**. ### Dark theme — palette de couleurs @@ -347,6 +402,117 @@ Disposition en **2 rangées** (ratio 2:1) optimisée pour 1280×480 : --- +## 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 +``` + +### 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. + +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. + +### Sécurité et diagnostic + +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é. + +--- + ## Compilation et installation Vous pouvez directement télécharger la dernière version de MG4Control via les releases : https://github.com/SliDeeN/MG4Control/releases @@ -416,8 +582,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 +608,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,20 +855,38 @@ 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 | -### 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 +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**. ### Dark Theme — Color Palette @@ -716,6 +903,116 @@ A second press on PROFILS or RÉGLAGES closes the view and returns to the dashbo --- +## 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. + +| 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 | + +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 +``` + +Complete silence = stale APK or service not running. `REFUS … API externe désactivée` = the +Settings toggle was never confirmed. + +--- + ## Build & Installation You can download the latest version of MG4Control directly from the releases page: https://github.com/SliDeeN/MG4Control/releases diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf80c48..9833286 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -90,17 +90,13 @@ + 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. --> - - - - - - - diff --git a/app/src/main/java/com/mg4/control/api/ExternalApi.kt b/app/src/main/java/com/mg4/control/api/ExternalApi.kt index da5aaf8..5c64ffc 100644 --- a/app/src/main/java/com/mg4/control/api/ExternalApi.kt +++ b/app/src/main/java/com/mg4/control/api/ExternalApi.kt @@ -57,9 +57,22 @@ object ExternalApi { /** Commandes exposées en action directe (celles qui ne réclament aucun paramètre). */ val DIRECT_ACTIONS = listOf( - "ONE_PEDAL", "AEB_CYCLE", "SOUND_WARNING", "OVERSPEED_ALARM", "SPEED_LIMIT_TONE", - "ADAS_CYCLE", "ENERGY_SAVING_TOGGLE", "TSR_TOGGLE", "PROFILE_PICKER", - "VEHICLE_POWER_OFF", "OPEN_APP" + "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. */ @@ -93,6 +106,18 @@ object ExternalApi { 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) ──────────────────────────────────────────── /** 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 14b4054..626bd05 100644 --- a/app/src/main/java/com/mg4/control/service/MG4ControlService.kt +++ b/app/src/main/java/com/mg4/control/service/MG4ControlService.kt @@ -183,6 +183,12 @@ class MG4ControlService : Service() { 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'") @@ -225,13 +231,69 @@ class MG4ControlService : Service() { MG4Hardware.setSteeringHeat(on); true } ExternalApi.SET_PROFILE -> { applyProfileByName(value); true } - else -> false + 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() From dcb78542b6408dc949b36c893f515a39b643a437 Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Mon, 17 Aug 2026 22:13:09 +0200 Subject: [PATCH 3/6] update readme + warning message for API tiers --- README.md | 78 +++++++++++++------ .../com/mg4/control/ui/SettingsFragment.kt | 17 +++- app/src/main/res/values-de/strings.xml | 3 +- app/src/main/res/values-en/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 3 +- app/src/main/res/values-it/strings.xml | 3 +- app/src/main/res/values-pt/strings.xml | 3 +- app/src/main/res/values/strings.xml | 3 +- 8 files changed, 81 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 51c7ac7..1f4c9a5 100644 --- a/README.md +++ b/README.md @@ -387,18 +387,33 @@ Valeurs communes aux écrans refondus, calées sur la lisibilité au volant : ti 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**. -### Dark theme — palette de couleurs - -| 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 | +### 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. --- @@ -888,18 +903,33 @@ Values shared by the reworked screens, tuned for readability while driving: titl section headers **13sp**, labels and buttons **16sp**, button height **52dp**, rail tabs **64dp**, rail width **180dp**, card padding **14dp**. -### Dark Theme — Color Palette - -| 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 | +### 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. --- 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 360dc59..fd534ac 100644 --- a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt @@ -808,13 +808,26 @@ class SettingsFragment : Fragment() { * 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) - val text = android.text.SpannableStringBuilder("$warn\n\n$body") - text.setSpan(android.text.style.ForegroundColorSpan(requireContext().getColor(R.color.dash_danger)), + // 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()) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 857de35..da082f2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -364,6 +364,7 @@ Externe API aktivieren? JEDE INSTALLIERTE APP KANN DANN DAS FAHRZEUG STEUERN. - Fahrmodus, Rekuperation, ADAS, Sitzheizung 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. + 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 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2a50c4e..eb9f4f6 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -364,6 +364,7 @@ Enable the external API? ANY INSTALLED APP WILL BE ABLE TO TAKE CONTROL OF THE VEHICLE. - Drive mode, regeneration, ADAS, heated seats 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. + 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9df7d7c..3410f8f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -364,6 +364,7 @@ ¿Activar la API externa? CUALQUIER APLICACIÓN INSTALADA PODRÁ TOMAR EL CONTROL DEL VEHÍCULO. - Modo de conducción, regeneración, ADAS, asientos calefactados 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. + 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 882fb00..9105087 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -364,6 +364,7 @@ Attivare l\'API esterna? QUALSIASI APP INSTALLATA POTRÀ PRENDERE IL CONTROLLO DEL VEICOLO. - Modalità di guida, rigenerazione, ADAS, sedili riscaldati 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. + 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 diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c20cd5e..9c8bd62 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -364,6 +364,7 @@ Ativar a API externa? QUALQUER APLICAÇÃO INSTALADA PODERÁ ASSUMIR O CONTROLO DO VEÍCULO. - Modo de condução, regeneração, ADAS, bancos aquecidos 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. + 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e883973..7db23bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -366,6 +366,7 @@ 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, ADAS, sièges chauffants 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. + 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 From a04645409add3f3c323d2679f7549bb515f22466 Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Tue, 18 Aug 2026 10:51:27 +0200 Subject: [PATCH 4/6] add beta OTA channel + beta CI --- .github/workflows/beta.yml | 134 ++++++++++++++++++ app/build.gradle.kts | 5 +- .../com/mg4/control/ui/SettingsFragment.kt | 12 ++ .../com/mg4/control/update/UpdateChecker.kt | 92 +++++++++++- app/src/main/res/layout/fragment_settings.xml | 49 +++++++ app/src/main/res/values-de/strings.xml | 4 + app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-it/strings.xml | 4 + app/src/main/res/values-pt/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../mg4/control/update/UpdateCheckerTest.kt | 56 ++++++++ 12 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/beta.yml diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml new file mode 100644 index 0000000..19a4562 --- /dev/null +++ b/.github/workflows/beta.yml @@ -0,0 +1,134 @@ +name: Beta release + +# Publie une PRE-RELEASE GitHub consommee par le canal beta de l'application. +# +# Difference avec "Test build" : ce workflow PUBLIE, donc les APK arrivent en OTA chez les +# testeurs qui ont coche « Canal de mise a jour beta ». Ne le declencher que sur un etat du +# code qu'on accepte de diffuser. +# +# Le numero de version installe DOIT correspondre au tag, sinon la mise a jour est reproposee +# en boucle : d'ou -Pmg4.versionSuffix, qui injecte le suffixe dans le versionName de l'APK. +# +# La release stable (release.yml) reste prioritaire : a numero egal, une pre-release est +# consideree ANTERIEURE (precedence semver), donc la stable remplace toujours la derniere beta. +on: + workflow_dispatch: + inputs: + label: + description: "Identifiant de la beta (ex: beta1, rc2)" + type: string + default: beta + notes: + description: "Ce qu'il faut tester / ce qui a change" + type: string + default: "" + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + env: + MG4_KEYSTORE_BASE64: ${{ secrets.MG4_KEYSTORE_BASE64 }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Decode platform keystore + run: | + if [ -z "$MG4_KEYSTORE_BASE64" ]; then + echo "::error::MG4_KEYSTORE_BASE64 absent. Une beta NON signee par la cle plateforme" + echo "::error::serait refusee a l'installation par le controle de signature (T-901)." + exit 1 + fi + printf '%s' "$MG4_KEYSTORE_BASE64" | tr -d '[:space:]' | base64 -d > "$RUNNER_TEMP/platform.keystore" + if [ "$(head -c1 "$RUNNER_TEMP/platform.keystore" | od -An -tx1 | tr -d ' ')" != "30" ]; then + echo "::error::MG4_KEYSTORE_BASE64 est invalide. Recreer le secret avec : base64 -w0 platform.keystore" + exit 1 + fi + echo "MG4_KEYSTORE=$RUNNER_TEMP/platform.keystore" >> "$GITHUB_ENV" + + # Suffixe unique et croissant : le numero de run garantit qu'une beta publiee apres une + # autre est bien vue comme posterieure, meme si le libelle saisi est identique. + - name: Compute version + id: ver + run: | + # sed plutot que grep -oP : PCRE depend de la locale du runner, sed non. + BASE=$(sed -n 's/.*versionName = "\([^"]*\)".*/\1/p' app/build.gradle.kts | head -1) + if [ -z "$BASE" ]; then + echo "::error::versionName introuvable dans app/build.gradle.kts"; exit 1 + fi + SUFFIX="-${{ inputs.label }}.${{ github.run_number }}" + echo "base=$BASE" >> "$GITHUB_OUTPUT" + echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" + echo "full=${BASE}${SUFFIX}" >> "$GITHUB_OUTPUT" + echo "Version beta : ${BASE}${SUFFIX}" + + - name: Build online + offline APKs + env: + MG4_KEYSTORE_PASSWORD: ${{ secrets.MG4_KEYSTORE_PASSWORD }} + MG4_KEY_ALIAS: ${{ secrets.MG4_KEY_ALIAS }} + MG4_KEY_PASSWORD: ${{ secrets.MG4_KEY_PASSWORD }} + run: | + chmod +x gradlew + ./gradlew assembleOnlineRelease assembleOfflineRelease --no-daemon \ + -Pmg4.versionSuffix='${{ steps.ver.outputs.suffix }}' + + - name: Collect APKs + run: | + mkdir -p dist + find app/build/outputs/apk -name "MG4Control-*.apk" -exec cp {} dist/ \; + ls -la dist/ + + # Controle que l'APK annonce bien la version du tag : c'est la condition pour que l'OTA + # cesse de proposer la mise a jour une fois installee. + - name: Verify APK version matches the tag + run: | + AAPT=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/aapt2 | sort -V | tail -1) + EXPECTED='${{ steps.ver.outputs.full }}' + for f in dist/*.apk; do + GOT=$("$AAPT" dump badging "$f" | grep -oP "versionName='\K[^']+") + echo "$(basename "$f") -> $GOT" + case "$GOT" in + "$EXPECTED"|"$EXPECTED-offline") ;; + *) echo "::error::$(basename "$f") annonce '$GOT', attendu '$EXPECTED'"; exit 1 ;; + esac + done + + - name: Signature report + run: | + APKSIGNER=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/apksigner | sort -V | tail -1) + for f in dist/*.apk; do + echo "-- $(basename "$f") --" + "$APKSIGNER" verify --print-certs -v "$f" 2>&1 \ + | grep -E 'Verified using v[0-9]|certificate SHA-256 digest' || echo " (non verifiable)" + done + + - name: Publish pre-release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.ver.outputs.full }} + name: Beta ${{ steps.ver.outputs.full }} + prerelease: true + files: dist/*.apk + body: | + ## MG4Control ${{ steps.ver.outputs.full }} — version de test + + > Diffusee uniquement aux utilisateurs ayant active + > **Reglages -> Reglages avances -> Canal de mise a jour beta**. + > Le retour a une version anterieure n'est pas possible par mise a jour. + + ${{ inputs.notes }} + + | Variante | Fichier | Reseau | Mise a jour auto | + |---|---|---|---| + | **Online** | `MG4Control-online-*.apk` | oui | oui | + | **Offline** | `MG4Control-offline-*.apk` | aucun | non | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c991c15..5c66c25 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,7 +12,10 @@ android { minSdk = 28 targetSdk = 34 versionCode = 17 - versionName = "2.6.5" + // 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.5" + (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/java/com/mg4/control/ui/SettingsFragment.kt b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt index fd534ac..152b847 100644 --- a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt @@ -161,6 +161,17 @@ 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 — @@ -235,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) 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 44f2566..457516a 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 92d2a2f..090715e 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -573,6 +573,55 @@ + + + + + + + + + + + + + + + + + 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 eb9f4f6..c502011 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -367,4 +367,8 @@ 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 3410f8f..e80f3f4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -367,4 +367,8 @@ 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 9105087..9ac0c6a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -367,4 +367,8 @@ 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 9c8bd62..26b845b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -367,4 +367,8 @@ 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 7db23bf..8dd2777 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -369,4 +369,8 @@ 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 b9a6744..5c017aa 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")) + } + } From 6ee360d5e9b7832a1109cd09d3f88f6fed2ff135 Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Tue, 18 Aug 2026 11:42:50 +0200 Subject: [PATCH 5/6] prepare 2.6.6 beta --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c66c25..5ded59e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,11 +11,11 @@ android { applicationId = "com.mg4.control" minSdk = 28 targetSdk = 34 - versionCode = 17 + 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.5" + (project.findProperty("mg4.versionSuffix") as String? ?: "") + versionName = "2.6.6" + (project.findProperty("mg4.versionSuffix") as String? ?: "") } // Signature avec la clé plateforme de la ROM (requise par sharedUserId=android.uid.system). From 41cf658dd4d2e5b2dac175f8ef447bb6f005def0 Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Tue, 18 Aug 2026 21:15:24 +0200 Subject: [PATCH 6/6] fix CI beta branch --- .github/workflows/beta.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 19a4562..df00fb8 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: |