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 ?