diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6de3e1e..1031298 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,8 +11,8 @@ android {
applicationId = "com.mg4.control"
minSdk = 28
targetSdk = 34
- versionCode = 15
- versionName = "2.6.3"
+ versionCode = 16
+ versionName = "2.6.4"
}
// Signature avec la clé plateforme de la ROM (requise par sharedUserId=android.uid.system).
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c177052..fd8037a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -28,6 +28,9 @@
android:theme="@style/Theme.MG4Control"
android:supportsRtl="true">
+
+
+
audioGet(AUDIO_GET_LOUDNESS)
- isOldSdkSound() -> getLoudnessOldSdk()
- else -> -1
- }
- fun setLoudnessState(s: Int): Boolean = when {
- isA9Sound() -> audioSet(AUDIO_SET_LOUDNESS, s)
- isOldSdkSound() -> setLoudnessOldSdk(s != 0)
- else -> false
+ // ── Volume média — VOL_TYPE_MEDIA=0 ────────────────────────────────────────
+ // Routage par firmware : old-SDK (133/68/165) → SmartSoundManager (reflection) ;
+ // A9 (69/131/132) → ICarAudioService via binder caradapter (tx getMax=0x5 / getVol=0x6 / setVol=0x7).
+ private const val VOL_TYPE_MEDIA = 0
+ private const val AUDIO_GET_MAX_VOL = 0x5
+ private const val AUDIO_GET_VOLUME = 0x6
+ private const val AUDIO_SET_VOLUME = 0x7
+
+ private const val VOL_TAG = "MG4_VOL"
+
+ /** Max du volume média (borne le slider). -1 si indisponible. */
+ fun getMediaVolumeMax(): Int {
+ val v = when {
+ isOldSdkSound() -> smartSoundGetInt("getMaxVolume", VOL_TYPE_MEDIA)
+ isA9Sound() -> audioGetArg(AUDIO_GET_MAX_VOL, VOL_TYPE_MEDIA)
+ else -> -1
+ }
+ AppLogger.i(VOL_TAG, "getMediaVolumeMax = $v [oldSdk=${isOldSdkSound()} a9=${isA9Sound()} smartSound=${sSmartSound != null} audioHelper=${sAudioHelper != null}]")
+ logMediaVolumeDiag() // A9 : compare type-0 vs group-id (no-op ailleurs)
+ return v
+ }
+
+ /** Volume média courant. -1 si indisponible. */
+ fun getMediaVolume(): Int {
+ val v = when {
+ isOldSdkSound() -> smartSoundGetInt("getVolume", VOL_TYPE_MEDIA)
+ isA9Sound() -> audioGetArg(AUDIO_GET_VOLUME, VOL_TYPE_MEDIA)
+ else -> -1
+ }
+ AppLogger.i(VOL_TAG, "getMediaVolume = $v")
+ return v
+ }
+
+ /** Fixe le volume média. setVolume(type, niveau, flags=0). */
+ fun setMediaVolume(level: Int): Boolean {
+ val ok = when {
+ isOldSdkSound() -> smartSoundSetVolume(level)
+ isA9Sound() -> audioSet3(AUDIO_SET_VOLUME, VOL_TYPE_MEDIA, level, 0)
+ else -> false
+ }
+ AppLogger.i(VOL_TAG, "setMediaVolume($level) = $ok")
+ return ok
}
- // ── old-SDK : SmartSoundManager.getLoudnessState()/setLoudnessState(boolean) via reflection ──
- private fun getLoudnessOldSdk(): Int {
+ // old-SDK : SmartSoundManager (reflection)
+ private fun smartSoundGetInt(method: String, arg: Int): Int {
val m = sSmartSound ?: return -1
+ return try { m.javaClass.getMethod(method, Int::class.javaPrimitiveType).invoke(m, arg) as? Int ?: -1 }
+ catch (_: Exception) { -1 }
+ }
+ private fun smartSoundSetVolume(level: Int): Boolean {
+ val m = sSmartSound ?: return false
return try {
- val on = m.javaClass.getMethod("getLoudnessState").invoke(m) as? Boolean ?: return -1
- if (on) 1 else 0
- } catch (e: Exception) { AppLogger.w(TAG, " getLoudnessOldSdk exc: ${e.message}"); -1 }
+ m.javaClass.getMethod("setVolume", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
+ .invoke(m, VOL_TYPE_MEDIA, level, 0); true
+ } catch (e: Exception) { AppLogger.w(TAG, "setMediaVolume oldSdk exc: ${e.message}"); false }
}
- private fun setLoudnessOldSdk(on: Boolean): Boolean {
- val m = sSmartSound ?: return false
+ // A9 : ICarAudioService via binder. getVolume(usage)/getMaxVolume(usage) = 1 arg ; setVolume(usage,val,flags) = 3 args.
+ private fun audioGetArg(txCode: Int, arg: Int): Int {
+ val h = sAudioHelper ?: return -1
+ if (!h.isBinderAlive) { sAudioHelper = null; return -1 }
+ val data = Parcel.obtain(); val reply = Parcel.obtain()
return try {
- m.javaClass.getMethod("setLoudnessState", Boolean::class.javaPrimitiveType).invoke(m, on)
- true
- } catch (e: Exception) { AppLogger.w(TAG, " setLoudnessOldSdk exc: ${e.message}"); false }
+ data.writeInterfaceToken(sAudioDescriptor); data.writeInt(arg)
+ if (h.transact(txCode, data, reply, 0)) { reply.readException(); reply.readInt() } else -1
+ } catch (_: Exception) { -1 } finally { data.recycle(); reply.recycle() }
+ }
+ private fun audioSet3(txCode: Int, a: Int, b: Int, c: Int): Boolean {
+ val h = sAudioHelper ?: return false
+ if (!h.isBinderAlive) { sAudioHelper = null; return false }
+ val data = Parcel.obtain(); val reply = Parcel.obtain()
+ return try {
+ data.writeInterfaceToken(sAudioDescriptor); data.writeInt(a); data.writeInt(b); data.writeInt(c)
+ h.transact(txCode, data, reply, 0).also { if (it) reply.readException() }
+ } catch (_: Exception) { false } finally { data.recycle(); reply.recycle() }
+ }
+
+ // Diagnostic A9 : le param de getVolume/getMaxVolume est-il un "type" (0=media) ou un
+ // "group id" (AAOS) ? On logge les deux pour comparer au max réel de la voiture.
+ private const val AUDIO_GET_GROUP_FOR_USAGE = 0xe // getVolumeGroupIdForUsage(usage)
+ private const val USAGE_MEDIA_AAOS = 1 // AudioAttributes.USAGE_MEDIA
+ fun logMediaVolumeDiag() {
+ if (!isA9Sound()) return
+ val maxT = audioGetArg(AUDIO_GET_MAX_VOL, VOL_TYPE_MEDIA)
+ val volT = audioGetArg(AUDIO_GET_VOLUME, VOL_TYPE_MEDIA)
+ val grp = audioGetArg(AUDIO_GET_GROUP_FOR_USAGE, USAGE_MEDIA_AAOS)
+ val maxG = if (grp in 0..64) audioGetArg(AUDIO_GET_MAX_VOL, grp) else -1
+ val volG = if (grp in 0..64) audioGetArg(AUDIO_GET_VOLUME, grp) else -1
+ AppLogger.i(VOL_TAG, "A9 diag: parType0[max=$maxT vol=$volT] groupForUsage(MEDIA)=$grp parGroup[max=$maxG vol=$volG]")
+ }
+
+ // ── Baisse du volume à l'ouverture d'une porte avant (v1 : SWI133) ──────────
+ // Détection via l'API Car AOSP **CarPropertyManager** (service "property") + permission
+ // CAR_VENDOR_EXTENSION (déjà déclarée). IDs portes AVANT confirmés par un utilisateur :
+ // FL/FR "ratio" (taux d'ouverture, >0 = ouverte) + "mode". Le SDK SAIC (getIntProperty)
+ // ne les lisait pas. Poll ~700ms, change-only (log MG4_DOOR), baisse à l'ouverture.
+ // Pas de restauration. Connexion Car async (createCar + ServiceConnection).
+
+ private const val DOORWATCH_TAG = "MG4_DOOR"
+ // Signal d'ouverture confirmé sur SWI133 : DLOCK_DOOR_OPEN_STS (propriété de zone PORTE),
+ // areaId 0x1 = porte AV-gauche, 0x4 = AV-droite ; valeur 1 = ouverte, 0 = fermée.
+ private const val DOOR_OPEN_PROP = 0x2640c623
+ private val DOOR_FRONT_AREAS = intArrayOf(0x1, 0x4)
+ private val sDoorReadLast = HashMap()
+ @Volatile private var sDoorSubProperty = false // voie A : property.registerListener attachée
+ @Volatile private var sDoorSubDoorlock = false // voie B : doorlock.registerCallback attachée
+ @Volatile private var sDoorWatcherOn = false
+ @Volatile private var sCarInstance: Any? = null
+ @Volatile private var sCarPropMgr: Any? = null // CarPropertyManager (service "property")
+ @Volatile private var sCarDoorMgr: Any? = null // CarDoorLockManager (service "doorlock")
+ @Volatile private var sDoorConnecting = false
+ @Volatile private var sAnyFrontOpenPrev = false
+ @Volatile private var sVolumeBeforeDrop = -1 // volume mémorisé à l'ouverture (pour restauration)
+
+ /** Baisse du volume à l'ouverture de porte : détection DLOCK_DOOR_OPEN_STS via CarPropertyManager.
+ * Lisible/fonctionnel uniquement sur SWI132 et SWI133 ; ailleurs le prop n'est pas exposé à l'app. */
+ fun hasDoorVolumeFeature(): Boolean {
+ val gen = FirmwareInfo.getGeneration()
+ return gen == FirmwareInfo.Gen.SWI132 || gen == FirmwareInfo.Gen.SWI133
+ }
+
+ private fun doorVolumeEnabled(): Boolean =
+ sAppContext?.getSharedPreferences("mg4_settings", 0)?.getBoolean("door_volume_enabled", false) ?: false
+
+ private fun doorVolumeLevel(): Int =
+ sAppContext?.getSharedPreferences("mg4_settings", 0)?.getInt("door_volume_level", 0) ?: 0
+
+ private fun doorRestoreEnabled(): Boolean =
+ sAppContext?.getSharedPreferences("mg4_settings", 0)?.getBoolean("door_volume_restore", false) ?: false
+
+ /** areaIds des portes choisies par l'utilisateur (gauche=0x1, droite=0x4). */
+ private fun doorTriggerAreas(): IntArray {
+ val p = sAppContext?.getSharedPreferences("mg4_settings", 0) ?: return DOOR_FRONT_AREAS
+ val list = ArrayList(2)
+ if (p.getBoolean("door_volume_left", true)) list += 0x1
+ if (p.getBoolean("door_volume_right", true)) list += 0x4
+ return list.toIntArray()
+ }
+
+ /** Démarrage auto au boot (appelé par init) : ne lance le watcher que si la feature est activée. */
+ fun startDoorWatcherIfEnabled() {
+ if (hasDoorVolumeFeature() && doorVolumeEnabled()) startDoorVolumeWatcher()
+ }
+
+ fun startDoorVolumeWatcher() {
+ if (!hasDoorVolumeFeature()) return
+ sDoorWatcherOn = true
+ connectCarProperty()
+ }
+
+ fun stopDoorVolumeWatcher() {
+ sDoorWatcherOn = false // poll conservé ; on ne déclenche plus la baisse
+ AppLogger.i(TAG, " DoorVolumeWatcher: déclenchement désactivé")
+ }
+
+ /** Sonde du bouton Diagnostic : logge le volume + l'état des portes à l'instant du clic. */
+ fun runDoorVolumeDiag() {
+ AppLogger.i(VOL_TAG, "── DIAG (bouton Diagnostic) ──")
+ getMediaVolumeMax()
+ getMediaVolume()
+ connectCarProperty() // idempotent ; normalement déjà connecté depuis l'init
+ registerDoorCallback() // re-tente la souscription si pas encore posée
+ probeDoorSnapshot()
+ }
+
+ private fun probeDoorSnapshot() {
+ if (sCarPropMgr == null && sCarDoorMgr == null) {
+ AppLogger.w(DOORWATCH_TAG, "DIAG porte: aucun manager Car (createCar échec ?)")
+ return
+ }
+ AppLogger.i(DOORWATCH_TAG, "DIAG porte: property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null}")
+ for (area in DOOR_FRONT_AREAS) {
+ val v = readDoorOpen(area)
+ AppLogger.i(DOORWATCH_TAG, "DIAG area=0x${area.toString(16)} = ${v ?: "illisible"}")
+ }
+ AppLogger.i(DOORWATCH_TAG, "DIAG souscription: property=$sDoorSubProperty doorlock=$sDoorSubDoorlock état=" +
+ if (sDoorReadLast.isEmpty()) "(aucun event reçu)"
+ else sDoorReadLast.entries.joinToString { "0x${it.key.toString(16)}=${it.value}" })
+ }
+
+ /** Connexion (async) à l'API Car AOSP → CarPropertyManager ("property") ET CarDoorLockManager
+ * ("doorlock"). Selon le firmware, la porte est exposée par l'un ou l'autre → on lit via les deux. */
+ private fun connectCarProperty() {
+ if (sCarPropMgr != null || sCarDoorMgr != null || sDoorConnecting) return
+ val ctx = sAppContext ?: return
+ sDoorConnecting = true
+ try {
+ val carCls = ctx.classLoader.loadClass("android.car.Car")
+ val conn = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
+ try {
+ val car = sCarInstance ?: return
+ val getMgr = carCls.getMethod("getCarManager", String::class.java)
+ sCarPropMgr = try { getMgr.invoke(car, "property") } catch (_: Exception) { null }
+ sCarDoorMgr = try { getMgr.invoke(car, "doorlock") } catch (_: Exception) { null }
+ AppLogger.i(DOORWATCH_TAG, "managers: property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null}")
+ if (sCarPropMgr != null || sCarDoorMgr != null) startDoorPolling()
+ else AppLogger.w(DOORWATCH_TAG, "aucun manager porte disponible")
+ } catch (e: Exception) { AppLogger.w(DOORWATCH_TAG, "getCarManager échec: ${e.message}") }
+ }
+ override fun onServiceDisconnected(name: ComponentName?) { sCarPropMgr = null; sCarDoorMgr = null; sDoorSubProperty = false; sDoorSubDoorlock = false }
+ }
+ val car = carCls.getMethod("createCar", Context::class.java, ServiceConnection::class.java)
+ .invoke(null, ctx, conn)
+ sCarInstance = car
+ try { carCls.getMethod("connect").invoke(car) } catch (_: Exception) {}
+ AppLogger.i(DOORWATCH_TAG, "connexion Car (property + doorlock)…")
+ } catch (e: Exception) {
+ sDoorConnecting = false
+ AppLogger.w(DOORWATCH_TAG, "Car createCar échec: ${e.message}")
+ }
}
+
+ // Lit DLOCK_DOOR_OPEN_STS(areaId) via CarPropertyManager.getIntProperty puis, en repli,
+ // via CarDoorLockManager.getProperty(Integer, propId, areaId).getValue(). null si aucun.
+ private fun readDoorOpen(area: Int): Int? {
+ sCarPropMgr?.let { m ->
+ try {
+ return m.javaClass.getMethod("getIntProperty", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
+ .invoke(m, DOOR_OPEN_PROP, area) as? Int
+ } catch (_: Exception) {}
+ }
+ sCarDoorMgr?.let { m ->
+ try {
+ val cpv = m.javaClass.getMethod("getProperty", Class::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
+ .invoke(m, Integer::class.java, DOOR_OPEN_PROP, area) ?: return null
+ return cpv.javaClass.getMethod("getValue").invoke(cpv) as? Int
+ } catch (_: Exception) {}
+ }
+ return null
+ }
+
+ private fun startDoorPolling() {
+ registerDoorCallback() // souscription ON_CHANGE (complète le poll)
+ AppLogger.i(DOORWATCH_TAG, "watcher porte actif (property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null})")
+ val h = Handler(Looper.getMainLooper())
+ val poll = object : Runnable {
+ override fun run() {
+ if (sCarPropMgr == null && sCarDoorMgr == null) return
+ // DLOCK_DOOR_OPEN_STS sur les portes avant (0x1 gauche / 0x4 droite).
+ for (area in DOOR_FRONT_AREAS) {
+ val v = readDoorOpen(area) ?: continue
+ onDoorAreaValue(area, v)
+ }
+ h.postDelayed(this, 500L)
+ }
+ }
+ h.post(poll)
+ }
+
+ /** Point d'entrée unique pour une valeur porte (poll OU événement de souscription).
+ * Met à jour l'état, log en change-only, puis réévalue le déclenchement volume. */
+ @Synchronized
+ private fun onDoorAreaValue(area: Int, v: Int) {
+ val prev = sDoorReadLast[area]
+ if (prev == null || prev != v) {
+ sDoorReadLast[area] = v
+ AppLogger.i(DOORWATCH_TAG, "area=0x${area.toString(16)} ${prev ?: "?"} → $v")
+ }
+ evaluateDoorTrigger()
+ }
+
+ /** Recalcule "au moins une porte choisie ouverte" à partir de l'état accumulé et applique
+ * la baisse (front d'ouverture) / la restauration (front de fermeture). Idempotent. */
+ private fun evaluateDoorTrigger() {
+ if (!(sDoorWatcherOn && doorVolumeEnabled())) return
+ val triggerAreas = doorTriggerAreas()
+ val anyOpen = triggerAreas.any { sDoorReadLast[it] == 1 }
+ if (anyOpen && !sAnyFrontOpenPrev) {
+ val level = doorVolumeLevel()
+ CoroutineScope(Dispatchers.IO).launch {
+ sVolumeBeforeDrop = getMediaVolume()
+ val ok = setMediaVolume(level)
+ AppLogger.i(DOORWATCH_TAG, "porte ouverte → vol $sVolumeBeforeDrop→$level = $ok")
+ }
+ } else if (!anyOpen && sAnyFrontOpenPrev) {
+ val restore = sVolumeBeforeDrop
+ sVolumeBeforeDrop = -1
+ if (doorRestoreEnabled() && restore >= 0) {
+ CoroutineScope(Dispatchers.IO).launch {
+ val ok = setMediaVolume(restore)
+ AppLogger.i(DOORWATCH_TAG, "porte fermée → restauration vol $restore = $ok")
+ }
+ }
+ }
+ sAnyFrontOpenPrev = anyOpen
+ }
+
+ /** InvocationHandler commun aux deux interfaces de callback (onChangeEvent/onErrorEvent).
+ * Filtre DLOCK_DOOR_OPEN_STS et alimente onDoorAreaValue. Gère aussi les méthodes Object. */
+ private fun doorEventHandler(src: String) = InvocationHandler { proxy, method, args ->
+ when (method.name) {
+ "onChangeEvent" -> {
+ try {
+ val cpv = args?.getOrNull(0)
+ if (cpv != null) {
+ val pid = cpv.javaClass.getMethod("getPropertyId").invoke(cpv) as? Int
+ val area = cpv.javaClass.getMethod("getAreaId").invoke(cpv) as? Int ?: 0
+ val raw = cpv.javaClass.getMethod("getValue").invoke(cpv)
+ val v = when (raw) {
+ is Boolean -> if (raw) 1 else 0
+ is Number -> raw.toInt()
+ else -> null
+ }
+ if (pid == DOOR_OPEN_PROP && v != null) {
+ AppLogger.i(DOORWATCH_TAG, "EVENT[$src] area=0x${area.toString(16)} = $v")
+ onDoorAreaValue(area, v)
+ }
+ }
+ } catch (e: Exception) { AppLogger.w(DOORWATCH_TAG, "onChangeEvent: ${e.message}") }
+ null
+ }
+ "onErrorEvent" -> { AppLogger.w(DOORWATCH_TAG, "EVENT erreur porte"); null }
+ "hashCode" -> System.identityHashCode(proxy)
+ "equals" -> proxy === args?.getOrNull(0)
+ "toString" -> "MG4DoorListener@" + Integer.toHexString(System.identityHashCode(proxy))
+ else -> null
+ }
+ }
+
+ /**
+ * S'abonne aux changements de DLOCK_DOOR_OPEN_STS. Nécessaire sur SWI69/131/68/165 où le prop
+ * n'est PAS lisible à la demande (getProperty lève IllegalArgumentException "Failed to get value")
+ * mais poussé en ON_CHANGE. IMPORTANT : le Proxy doit être défini par le classloader de l'APP
+ * (android.car est en BootClassLoader → Proxy.newProxyInstance y échoue). On tente DEUX voies :
+ * A) CarPropertyManager.registerListener (service "property")
+ * B) CarDoorLockManager.registerCallback (service "doorlock", comme la SystemUI d'origine)
+ */
+ private fun registerDoorCallback() {
+ val cl = sAppContext?.classLoader ?: return
+
+ // Voie A — CarPropertyManager.registerListener(CarPropertyEventListener, propId, rate).
+ // rate=5f (et non 0f) : si le VHAL déclare la prop CONTINUOUS, un rate 0 = aucune mise à jour.
+ if (!sDoorSubProperty) sCarPropMgr?.let { m ->
+ try {
+ val iface = cl.loadClass("android.car.hardware.property.CarPropertyManager\$CarPropertyEventListener")
+ val proxy = Proxy.newProxyInstance(cl, arrayOf(iface), doorEventHandler("property"))
+ val ok = m.javaClass.getMethod("registerListener", iface,
+ Int::class.javaPrimitiveType, Float::class.javaPrimitiveType)
+ .invoke(m, proxy, DOOR_OPEN_PROP, 5f)
+ sDoorSubProperty = true
+ AppLogger.i(DOORWATCH_TAG, "souscription porte (property.registerListener rate=5) OK=$ok")
+ } catch (e: Exception) {
+ val c = e.cause ?: e
+ AppLogger.w(DOORWATCH_TAG, "property.registerListener échec: ${c.javaClass.simpleName}: ${c.message}")
+ }
+ }
+
+ // Voie B — CarDoorLockManager.registerCallback(CarDoorLockEventCallback) — voie de l'OEM
+ if (!sDoorSubDoorlock) sCarDoorMgr?.let { m ->
+ try {
+ val iface = cl.loadClass("android.car.hardware.doorlock.CarDoorLockManager\$CarDoorLockEventCallback")
+ val proxy = Proxy.newProxyInstance(cl, arrayOf(iface), doorEventHandler("doorlock"))
+ m.javaClass.getMethod("registerCallback", iface).invoke(m, proxy)
+ sDoorSubDoorlock = true
+ AppLogger.i(DOORWATCH_TAG, "souscription porte (doorlock.registerCallback) OK")
+ } catch (e: Exception) {
+ val c = e.cause ?: e
+ AppLogger.w(DOORWATCH_TAG, "doorlock.registerCallback échec: ${c.javaClass.simpleName}: ${c.message}")
+ }
+ }
+ }
+
fun getSpeedVolumeLevel(): Int = audioGet(AUDIO_GET_SPEED_VOL)
fun setSpeedVolumeLevel(l: Int): Boolean = audioSet(AUDIO_SET_SPEED_VOL, l.coerceIn(AUDIO_TYPE_MIN, AUDIO_TYPE_MAX))
fun getSoundFieldType(): Int = -1
diff --git a/app/src/main/java/com/mg4/control/ui/AudioFragment.kt b/app/src/main/java/com/mg4/control/ui/AudioFragment.kt
index dce6aee..7d45d5e 100644
--- a/app/src/main/java/com/mg4/control/ui/AudioFragment.kt
+++ b/app/src/main/java/com/mg4/control/ui/AudioFragment.kt
@@ -1,12 +1,15 @@
package com.mg4.control.ui
+import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.CheckBox
import android.widget.Switch
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
+import com.google.android.material.slider.Slider
import com.mg4.control.R
import com.mg4.control.hardware.MG4Hardware
import kotlinx.coroutines.Dispatchers
@@ -22,22 +25,71 @@ class AudioFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- val switch = view.findViewById(R.id.switch_loudness)
+ // ── Volume à l'ouverture d'une porte (SWI132/133) ───────────────────
+ setupDoorVolume(view)
+ }
- // Loudness : l'AIDL vendor est BOOLÉEN → 1 = ON, 0 = OFF (et non 2/1).
- switch.setOnCheckedChangeListener { _, checked ->
- if (switch.isPressed) {
- viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
- MG4Hardware.setLoudnessState(if (checked) 1 else 0)
- }
- }
+ private fun setupDoorVolume(view: View) {
+ val card = view.findViewById(R.id.door_volume_card)
+ if (!MG4Hardware.hasDoorVolumeFeature()) {
+ card.visibility = View.GONE
+ return
+ }
+
+ val prefs = requireContext().getSharedPreferences("mg4_settings", Context.MODE_PRIVATE)
+ val toggle = view.findViewById(R.id.switch_door_volume)
+ val slider = view.findViewById(R.id.slider_door_volume)
+ val restore = view.findViewById(R.id.switch_door_restore)
+ val cbLeft = view.findViewById(R.id.cb_door_left)
+ val cbRight = view.findViewById(R.id.cb_door_right)
+
+ val enabled = prefs.getBoolean("door_volume_enabled", false)
+ toggle.isChecked = enabled
+ restore.isChecked = prefs.getBoolean("door_volume_restore", false)
+ cbLeft.isChecked = prefs.getBoolean("door_volume_left", true)
+ cbRight.isChecked = prefs.getBoolean("door_volume_right", true)
+
+ fun setSubEnabled(e: Boolean) {
+ slider.isEnabled = e; restore.isEnabled = e; cbLeft.isEnabled = e; cbRight.isEnabled = e
+ }
+ setSubEnabled(enabled)
+
+ // Si déjà activé, (re)démarre le watcher à l'ouverture de l'onglet (idempotent).
+ if (enabled) {
+ viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { MG4Hardware.startDoorVolumeWatcher() }
}
+ // Borne le slider sur le max réel de la voiture (sinon valeur XML par défaut).
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
- val state = MG4Hardware.getLoudnessState()
+ val max = MG4Hardware.getMediaVolumeMax()
withContext(Dispatchers.Main) {
- if (isAdded && state >= 0) switch.isChecked = (state == 1)
+ if (!isAdded) return@withContext
+ if (max > 0) slider.valueTo = max.toFloat()
+ val level = prefs.getInt("door_volume_level", 0)
+ slider.value = level.coerceIn(0, slider.valueTo.toInt()).toFloat()
}
}
+
+ toggle.setOnCheckedChangeListener { _, checked ->
+ prefs.edit().putBoolean("door_volume_enabled", checked).apply()
+ setSubEnabled(checked)
+ viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
+ if (checked) MG4Hardware.startDoorVolumeWatcher()
+ else MG4Hardware.stopDoorVolumeWatcher()
+ }
+ }
+
+ slider.addOnChangeListener { _, value, fromUser ->
+ if (fromUser) prefs.edit().putInt("door_volume_level", value.toInt()).apply()
+ }
+ restore.setOnCheckedChangeListener { _, checked ->
+ prefs.edit().putBoolean("door_volume_restore", checked).apply()
+ }
+ cbLeft.setOnCheckedChangeListener { _, checked ->
+ prefs.edit().putBoolean("door_volume_left", checked).apply()
+ }
+ cbRight.setOnCheckedChangeListener { _, checked ->
+ prefs.edit().putBoolean("door_volume_right", checked).apply()
+ }
}
}
diff --git a/app/src/main/java/com/mg4/control/ui/ConsoleFragment.kt b/app/src/main/java/com/mg4/control/ui/ConsoleFragment.kt
index d5c4e2e..42ab071 100644
--- a/app/src/main/java/com/mg4/control/ui/ConsoleFragment.kt
+++ b/app/src/main/java/com/mg4/control/ui/ConsoleFragment.kt
@@ -2,6 +2,8 @@ package com.mg4.control.ui
import android.graphics.Color
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.view.LayoutInflater
@@ -25,6 +27,19 @@ class ConsoleFragment : Fragment() {
activity?.runOnUiThread { if (isAdded) renderLog() }
}
+ // Auto-refresh périodique (status + logs) sans casser la position de scroll.
+ private val handler = Handler(Looper.getMainLooper())
+ private var lastLogCount = -1
+ private val refresh = object : Runnable {
+ override fun run() {
+ if (isAdded) {
+ renderStatus()
+ if (AppLogger.entries.size != lastLogCount) renderLog()
+ }
+ handler.postDelayed(this, 800L)
+ }
+ }
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.fragment_console, container, false)
@@ -45,6 +60,7 @@ class ConsoleFragment : Fragment() {
override fun onResume() {
super.onResume()
AppLogger.addListener(logListener)
+ handler.post(refresh)
renderStatus()
renderLog()
}
@@ -52,6 +68,7 @@ class ConsoleFragment : Fragment() {
override fun onPause() {
super.onPause()
AppLogger.removeListener(logListener)
+ handler.removeCallbacks(refresh)
}
// ---- Status banner ----
@@ -77,7 +94,18 @@ class ConsoleFragment : Fragment() {
// ---- Log list ----
+ /** True si l'utilisateur est (quasi) tout en bas → on suit les nouveaux logs ; sinon on le laisse. */
+ private fun isAtBottom(): Boolean {
+ val child = scrollView.getChildAt(0) ?: return true
+ val marginPx = (24 * resources.displayMetrics.density).toInt()
+ return child.bottom - (scrollView.height + scrollView.scrollY) <= marginPx
+ }
+
private fun renderLog() {
+ // Mémorise la position AVANT de remplacer le texte : on ne re-scrolle en bas
+ // que si l'utilisateur suivait déjà le bas (sinon on préserve sa lecture).
+ val followBottom = isAtBottom()
+ lastLogCount = AppLogger.entries.size
val sb = SpannableStringBuilder()
AppLogger.entries.forEach { entry ->
val prefix = "[${entry.time}] "
@@ -96,6 +124,6 @@ class ConsoleFragment : Fragment() {
sb.setSpan(ForegroundColorSpan(color), start + prefix.length + tag.length, sb.length, 0)
}
textLog.text = sb
- scrollView.post { scrollView.fullScroll(View.FOCUS_DOWN) }
+ if (followBottom) scrollView.post { scrollView.fullScroll(View.FOCUS_DOWN) }
}
}
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 5e0a616..fca6019 100644
--- a/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt
+++ b/app/src/main/java/com/mg4/control/ui/SettingsFragment.kt
@@ -19,6 +19,7 @@ import android.os.Environment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
@@ -34,8 +35,10 @@ import com.mg4.control.util.QrCode
import com.mg4.control.debug.AppLogger
import com.mg4.control.debug.CrashLogger
import com.mg4.control.hardware.MG4Hardware
+import com.mg4.control.update.ApkCleanup
import com.mg4.control.update.UpdateChecker
import com.mg4.control.update.UpdateDialogManager
+import java.io.File
import com.mg4.control.util.FirmwareHelper
import com.mg4.control.util.LocaleHelper
import kotlinx.coroutines.CoroutineScope
@@ -246,7 +249,7 @@ class SettingsFragment : Fragment() {
Environment.DIRECTORY_DOWNLOADS
)
val apkFiles = downloadsDir.listFiles { _, name ->
- name.startsWith("MGControl") && name.endsWith(".apk")
+ ApkCleanup.isAppApk(name)
} ?: emptyArray()
val count = apkFiles.count { it.delete() }
@@ -351,11 +354,16 @@ class SettingsFragment : Fragment() {
private fun showDiagnosticDialog() {
val ctx = requireContext()
+ // Sonde diagnostic : logge volume + état des portes AVANT de rendre les logs,
+ // pour que le rapport les contienne (indépendant du toggle / de l'onglet Audio).
+ MG4Hardware.runDoorVolumeDiag()
+
val appVersion = try {
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "?"
} catch (e: Exception) { "?" }
// ── Layout : crash banner (optionnel) + rapport matériel ──────────────
+ var btnClearCrash: Button? = null
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(
@@ -387,6 +395,12 @@ class SettingsFragment : Fragment() {
setBackgroundColor(ctx.getColor(R.color.dash_border))
}
container.addView(divider)
+
+ // "Effacer crash" déplacé dans le contenu (le slot neutre sert au Télécharger)
+ btnClearCrash = Button(ctx).apply {
+ text = getString(R.string.diag_clear_crash)
+ }
+ container.addView(btnClearCrash)
}
// Section rapport matériel
@@ -429,26 +443,36 @@ class SettingsFragment : Fragment() {
.setTitle(title)
.setView(scrollView)
.setPositiveButton(getString(R.string.diag_copy), null)
- .setNeutralButton(if (crashLog != null) getString(R.string.diag_clear_crash) else null, null)
+ .setNeutralButton(getString(R.string.diag_download), null)
.setNegativeButton(getString(R.string.nav_close), null)
.create()
dialog.window?.setBackgroundDrawable(ColorDrawable(ctx.getColor(R.color.dash_card)))
+ // Rapport court (30 dernières lignes) pour le presse-papier ; rapport complet pour le fichier.
+ fun buildReport(fullLog: Boolean) = buildString {
+ if (crashLog != null) { appendLine(crashLog); appendLine() }
+ appendLine(tvReport.text)
+ appendLine()
+ if (fullLog) {
+ val entries = AppLogger.entries
+ appendLine("─── AppLogger (${entries.size} entrées) ───")
+ entries.forEach { e -> appendLine("${e.time} [${e.level.name[0]}] ${e.tag}: ${e.msg}") }
+ } else appendLine(tvLogs.text)
+ }
+
dialog.setOnShowListener {
// "Copier" — copie tout sans fermer le dialog
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
- val fullReport = buildString {
- if (crashLog != null) { appendLine(crashLog); appendLine() }
- appendLine(tvReport.text)
- appendLine()
- appendLine(tvLogs.text)
- }
val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- cm.setPrimaryClip(ClipData.newPlainText("MG4Control Diagnostic", fullReport))
+ cm.setPrimaryClip(ClipData.newPlainText("MG4Control Diagnostic", buildReport(false)))
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.text = getString(R.string.diag_copied)
}
- // "Effacer crash" — supprime le fichier et ferme le dialog
+ // "Télécharger" — écrit le rapport complet dans le dossier Download de la voiture
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
+ downloadDiagnostic(ctx, buildReport(true))
+ }
+ // "Effacer crash" (dans le contenu) — supprime le fichier et ferme le dialog
+ btnClearCrash?.setOnClickListener {
CrashLogger.clear(ctx)
dialog.dismiss()
}
@@ -465,6 +489,21 @@ class SettingsFragment : Fragment() {
}
}
+ /** Écrit le rapport de diagnostic dans le dossier Download de la voiture (fichier .txt horodaté). */
+ private fun downloadDiagnostic(ctx: Context, report: String) {
+ try {
+ val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ if (!dir.exists()) dir.mkdirs()
+ val ts = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.US)
+ .format(java.util.Date())
+ val file = File(dir, "MG4Control_diag_$ts.txt")
+ file.writeText(report)
+ Toast.makeText(ctx, getString(R.string.diag_downloaded, file.absolutePath), Toast.LENGTH_LONG).show()
+ } catch (e: Exception) {
+ Toast.makeText(ctx, getString(R.string.diag_download_failed, e.message ?: "?"), Toast.LENGTH_LONG).show()
+ }
+ }
+
// ── Dialog À propos ──────────────────────────────────────────────────────
private fun showInfosDialog() {
diff --git a/app/src/main/java/com/mg4/control/update/ApkCleanup.kt b/app/src/main/java/com/mg4/control/update/ApkCleanup.kt
index 1a994c7..2d2984c 100644
--- a/app/src/main/java/com/mg4/control/update/ApkCleanup.kt
+++ b/app/src/main/java/com/mg4/control/update/ApkCleanup.kt
@@ -4,7 +4,7 @@ import android.os.Environment
import android.util.Log
/**
- * Nettoie automatiquement les anciens fichiers MGControl*.apk dans le dossier Téléchargements.
+ * Nettoie automatiquement les APK de l'app dans le dossier Téléchargements.
* Ne conserve que les [MAX_APK] plus récents.
*/
object ApkCleanup {
@@ -12,10 +12,20 @@ object ApkCleanup {
private const val TAG = "ApkCleanup"
private const val MAX_APK = 5
+ /**
+ * Vrai si [name] est un APK de MG4Control dans les Téléchargements.
+ * Couvre le naming actuel `MG4Control--.apk` ET l'ancien `MGControl.apk`
+ * (pour nettoyer aussi les téléchargements déjà présents avant le changement de nom).
+ */
+ fun isAppApk(name: String): Boolean =
+ (name.startsWith("MG4Control", ignoreCase = true) ||
+ name.startsWith("MGControl", ignoreCase = true)) &&
+ name.endsWith(".apk", ignoreCase = true)
+
fun cleanIfNeeded() {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val apkFiles = downloadsDir.listFiles { f ->
- f.isFile && f.name.startsWith("MGControl") && f.name.endsWith(".apk")
+ f.isFile && isAppApk(f.name)
} ?: return
if (apkFiles.size <= MAX_APK) return
diff --git a/app/src/main/java/com/mg4/control/update/UpdateDialogManager.kt b/app/src/main/java/com/mg4/control/update/UpdateDialogManager.kt
index e7bea92..6a78bec 100644
--- a/app/src/main/java/com/mg4/control/update/UpdateDialogManager.kt
+++ b/app/src/main/java/com/mg4/control/update/UpdateDialogManager.kt
@@ -168,7 +168,7 @@ object UpdateDialogManager {
val fileName = info.apkUrl
.substringAfterLast('/')
.substringBefore('?')
- .ifBlank { "MGControl${info.versionName}.apk" }
+ .ifBlank { "MG4Control-${info.versionName}.apk" }
val request = DownloadManager.Request(Uri.parse(info.apkUrl)).apply {
setTitle("MG4Control ${info.versionName}")
diff --git a/app/src/main/res/layout/fragment_audio.xml b/app/src/main/res/layout/fragment_audio.xml
index 0e3a74b..a4d90e2 100644
--- a/app/src/main/res/layout/fragment_audio.xml
+++ b/app/src/main/res/layout/fragment_audio.xml
@@ -9,45 +9,128 @@
android:gravity="center"
android:padding="20dp">
+
+ android:paddingTop="20dp"
+ android:paddingBottom="20dp"
+ android:layout_marginTop="16dp">
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:textSize="15sp" />
-
+ android:layout_height="wrap_content" />
-
+
+ android:layout_height="wrap_content"
+ android:text="@string/door_volume_doors_label"
+ android:textColor="@color/dash_text_lo"
+ android:textSize="14sp"
+ android:layout_marginTop="12dp" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index df6bd62..8af9e22 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -168,6 +168,9 @@
Kopiert!
Analysiere…
Absturzbericht löschen
+ Herunterladen
+ Gespeichert: %1$s
+ Download fehlgeschlagen: %1$s
Nach Updates suchen
@@ -271,8 +274,13 @@
Keine gekoppelten Geräte
Audio
- Loudness
- Klangverstärkung
+ Lautstärke bei Türöffnung
+ Senkt die Medienlautstärke beim Öffnen einer Vordertür
+ Zielpegel
+ Beim Schließen wiederherstellen
+ Auslösende Türen
+ Links
+ Rechts
Profile wiederherstellen?
%d gespeicherte(s) Profil(e) auf diesem Fahrzeug gefunden. Wiederherstellen?
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 6d449de..f847f19 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -168,6 +168,9 @@
Copied!
Analysing…
Clear crash log
+ Download
+ Saved: %1$s
+ Download failed: %1$s
Check for updates
@@ -271,8 +274,13 @@
No paired devices
Audio
- Loudness
- Sound intensity boost
+ Door-open volume
+ Lowers media volume when a front door opens
+ Target level
+ Restore on close
+ Trigger doors
+ Left
+ Right
Restore profiles?
%d saved profile(s) were found on this vehicle. Restore them?
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 44c2a22..162b352 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -168,6 +168,9 @@
¡Copiado!
Analizando…
Borrar informe de crash
+ Descargar
+ Guardado: %1$s
+ Error de descarga: %1$s
Buscar actualizaciones
@@ -271,8 +274,13 @@
No hay dispositivos emparejados
Audio
- Loudness
- Refuerzo de intensidad de sonido
+ Volumen al abrir la puerta
+ Reduce el volumen multimedia al abrir una puerta delantera
+ Nivel objetivo
+ Restaurar al cerrar
+ Puertas que activan
+ Izquierda
+ Derecha
¿Restaurar perfiles?
Se encontraron %d perfil(es) guardado(s) en este vehículo. ¿Restaurarlos?
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index c11aaa9..e464687 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -168,6 +168,9 @@
Copiato!
Analisi in corso…
Cancella crash log
+ Scarica
+ Salvato: %1$s
+ Download non riuscito: %1$s
Controlla aggiornamenti
@@ -271,8 +274,13 @@
Nessun dispositivo associato
Audio
- Loudness
- Potenziamento dell\'intensità sonora
+ Volume all\'apertura della porta
+ Abbassa il volume multimediale all\'apertura di una porta anteriore
+ Livello target
+ Ripristina alla chiusura
+ Porte di attivazione
+ Sinistra
+ Destra
Ripristinare i profili?
Trovati %d profilo/i salvato/i su questo veicolo. Ripristinarli?
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index a89d765..042a572 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -168,6 +168,9 @@
Copiado!
A analisar…
Limpar relatório de crash
+ Baixar
+ Salvo: %1$s
+ Falha no download: %1$s
Verificar atualizações
@@ -271,8 +274,13 @@
Nenhum dispositivo emparelhado
Audio
- Loudness
- Reforço de intensidade sonora
+ Volume ao abrir a porta
+ Baixa o volume de média ao abrir uma porta dianteira
+ Nível alvo
+ Restaurar ao fechar
+ Portas de ativação
+ Esquerda
+ Direita
Restaurar perfis?
Foram encontrados %d perfil(is) guardado(s) neste veículo. Restaurar?
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c713c6a..4a6340f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -170,6 +170,9 @@
Copié !
Analyse en cours…
Effacer le crash
+ Télécharger
+ Enregistré : %1$s
+ Échec du téléchargement : %1$s
Vérifier les mises à jour
@@ -273,8 +276,13 @@
Aucun appareil associé
Audio
- Loudness
- Boost d\'intensité sonore
+ Volume à l\'ouverture de porte
+ Baisse le volume média à l\'ouverture d\'une porte avant
+ Niveau cible
+ Restaurer à la fermeture
+ Portes déclencheuses
+ Gauche
+ Droite
Restaurer les profils ?
%d profil(s) sauvegardé(s) ont été trouvés sur ce véhicule. Les restaurer ?