@@ -3777,13 +3777,102 @@ object MG4Hardware {
37773777 else -> " inconnue ($v )"
37783778 }
37793779
3780+ // ── Projection : le service allgo, hors SDK SAIC ────────────────────────
3781+ //
3782+ // CarPlay et Android Auto ne se pilotent PAS par le même chemin selon le firmware, et les
3783+ // deux voies sont exactement complémentaires — vérifié dans les 6 launchers :
3784+ // • SWI133 : `ICpAaBinderInterface` du SDK média SAIC ;
3785+ // • SWI68 / 165 / 69 / 131 / 132 : ce service-ci, celui de la pile de projection allgo,
3786+ // que le launcher appelle lui-même.
3787+ // Aucun firmware n'a les deux, aucun n'en est dépourvu.
3788+ private const val RUI_PKG = " com.allgo.rui"
3789+ private const val RUI_CLS = " com.allgo.rui.RemoteUIService"
3790+ private const val DESC_RUI = " com.allgo.rui.IRemoteUIService"
3791+ private const val TX_RUI_MEDIA_KEY = 0x12 // sendMediaPlayControlKey(int) : int
3792+
3793+ /* *
3794+ * Valeurs de `sendMediaPlayControlKey`, relevées dans les méthodes du launcher SWI68 :
3795+ * `playRemoteUIyMusicResource`, `pauseRemoteUIyMusicResource`, `touchNextRemoteUiMusic`,
3796+ * `touchPreviousRemoteUiMusic`. Ce ne sont PAS des keycodes Android — c'est une énumération
3797+ * propre à allgo, qu'il aurait été impossible de deviner.
3798+ *
3799+ * Piste suivante et précédente se donnent en DEUX temps, appui puis relâchement, comme le
3800+ * fait le launcher.
3801+ */
3802+ private const val RUI_PLAY = 1
3803+ private const val RUI_PAUSE = 2
3804+ private const val RUI_NEXT_DOWN = 7
3805+ private const val RUI_PREV_DOWN = 8
3806+ private const val RUI_NEXT_UP = 11
3807+ private const val RUI_PREV_UP = 12
3808+
3809+ /* * Envoie une commande à la pile de projection. Rend faux si le service ne répond pas. */
3810+ private fun ruiEnvoyer (valeur : Int ): Boolean {
3811+ val binder = serviceBinder(" " , RUI_PKG , RUI_CLS ) ? : return false
3812+ val data = Parcel .obtain()
3813+ val reply = Parcel .obtain()
3814+ return try {
3815+ data.writeInterfaceToken(DESC_RUI )
3816+ data.writeInt(valeur)
3817+ binder.transact(TX_RUI_MEDIA_KEY , data, reply, 0 )
3818+ reply.readException()
3819+ // La méthode rend un entier : on le journalise sans l'interpréter. Sa signification
3820+ // est inconnue, mais il distinguera un refus d'un acquittement le jour où il faudra.
3821+ val retour = if (reply.dataAvail() > 0 ) reply.readInt() else 0
3822+ AppLogger .i(MEDIA_TAG , " projection : sendMediaPlayControlKey($valeur ) → $retour " )
3823+ true
3824+ } catch (e: Exception ) {
3825+ AppLogger .w(MEDIA_TAG , " projection : commande $valeur refusée — ${(e.cause ? : e).message} " )
3826+ false
3827+ } finally {
3828+ data.recycle()
3829+ reply.recycle()
3830+ }
3831+ }
3832+
3833+ /* *
3834+ * Commande média de la projection par la voie allgo.
3835+ *
3836+ * [joue] décide du sens de la bascule lecture/pause : ce service n'expose pas d'état, c'est
3837+ * l'appelant qui l'a déjà déterminé (session, ou `isMusicActive` à défaut).
3838+ */
3839+ private fun ruiMedia (cmd : CmdMedia , joue : Boolean ): Boolean = when (cmd) {
3840+ // Appui PUIS relâchement : un appui laissé « enfoncé » serait interprété comme une
3841+ // avance rapide, c'est le rôle des deux codes distincts.
3842+ CmdMedia .SUIVANT ->
3843+ ruiEnvoyer(RUI_NEXT_DOWN ) && ruiEnvoyer(RUI_NEXT_UP )
3844+ CmdMedia .PRECEDENT ->
3845+ ruiEnvoyer(RUI_PREV_DOWN ) && ruiEnvoyer(RUI_PREV_UP )
3846+ CmdMedia .LECTURE_PAUSE ->
3847+ ruiEnvoyer(if (joue) RUI_PAUSE else RUI_PLAY )
3848+ }
3849+
37803850 private enum class CmdMedia { SUIVANT , PRECEDENT , LECTURE_PAUSE }
37813851
37823852 // Accès croisé : onServiceConnected arrive sur le thread principal, la lecture se fait
37833853 // depuis le contexte IO du raccourci. Des HashMap simples se corrompraient silencieusement.
37843854 private val sMediaBinders = ConcurrentHashMap <String , IBinder >()
37853855 private val sMediaConns = ConcurrentHashMap <String , ServiceConnection >()
37863856
3857+ /* *
3858+ * Actions dont la liaison n'aboutit pas, avec l'instant du constat.
3859+ *
3860+ * ⚠️ Sans cette mémoire, un service absent du firmware coûtait [MEDIA_BIND_MS] à CHAQUE
3861+ * appui : mesuré sur SWI68, où `CPAA_PLAYER_ACTION` n'existe pas, 1,2 s d'attente avant
3862+ * même d'essayer la voie suivante. Un raccourci de volant qui répond en une seconde et
3863+ * demie passe pour cassé, même quand il finit par agir.
3864+ *
3865+ * Le constat est daté plutôt que définitif : un service peut être simplement lent à
3866+ * démarrer, et l'exclure pour toujours sur un seul échec serait excessif.
3867+ */
3868+ private val sMediaSansReponse = ConcurrentHashMap <String , Long >()
3869+
3870+ /* * Délai d'attente d'une liaison, en ms. */
3871+ private const val MEDIA_BIND_MS = 1200L
3872+
3873+ /* * Durée pendant laquelle on ne retente pas une action qui n'a pas répondu. */
3874+ private const val MEDIA_ABSENT_MS = 300_000L
3875+
37873876 /* *
37883877 * Binder du service média pour une action donnée, ou null.
37893878 *
@@ -3804,45 +3893,75 @@ object MG4Hardware {
38043893 * c'est ainsi que `RadioOptionManager` procède, et une liaison par classe échouerait.
38053894 */
38063895 private fun serviceBinder (action : String , pkg : String , cls : String? ,
3807- attenteMs : Long = 1200): IBinder ? {
3808- sMediaBinders[action]?.let { if (it.isBinderAlive) return it else sMediaBinders.remove(action) }
3896+ attenteMs : Long = MEDIA_BIND_MS ): IBinder ? {
3897+ // Clé de cache : l'action quand il y en a une, sinon le composant visé. Le service de
3898+ // projection allgo se lie en effet par COMPOSANT seul, sans action — deux services
3899+ // différents ne doivent pas se partager une entrée de cache.
3900+ val cle = if (action.isEmpty()) " $pkg /$cls " else action
3901+ sMediaBinders[cle]?.let { if (it.isBinderAlive) return it else sMediaBinders.remove(cle) }
38093902 val ctx = sAppContext ? : return null
38103903
3811- if (! sMediaConns.containsKey(action)) {
3904+ // Cible déjà constatée sans réponse récemment : on rend la main tout de suite plutôt
3905+ // que de refaire attendre l'utilisateur pour le même constat.
3906+ sMediaSansReponse[cle]?.let { instant ->
3907+ if (SystemClock .uptimeMillis() - instant < MEDIA_ABSENT_MS ) return null
3908+ sMediaSansReponse.remove(cle)
3909+ }
3910+
3911+ if (! sMediaConns.containsKey(cle)) {
38123912 val conn = object : ServiceConnection {
38133913 override fun onServiceConnected (name : ComponentName ? , service : IBinder ? ) {
3814- if (service != null ) sMediaBinders[action] = service
3815- AppLogger .i(MEDIA_TAG , " lié à ${action.substringAfterLast(' .' )} " )
3914+ if (service != null ) sMediaBinders[cle] = service
3915+ sMediaSansReponse.remove(cle)
3916+ AppLogger .i(MEDIA_TAG , " lié à ${cle.substringAfterLast(' .' )} " )
38163917 }
38173918 override fun onServiceDisconnected (name : ComponentName ? ) {
3818- sMediaBinders.remove(action)
3819- AppLogger .w(MEDIA_TAG , " liaison perdue : ${action.substringAfterLast(' .' )} " )
3919+ sMediaBinders.remove(cle)
3920+ AppLogger .w(MEDIA_TAG , " liaison perdue : ${cle.substringAfterLast(' .' )} " )
3921+ }
3922+ // Le service existe mais refuse CETTE action : son onBind rend null. Le système
3923+ // nous le dit ici, immédiatement — inutile d'attendre le délai pour le découvrir.
3924+ override fun onNullBinding (name : ComponentName ? ) {
3925+ sMediaSansReponse[cle] = SystemClock .uptimeMillis()
3926+ AppLogger .i(MEDIA_TAG , " ${cle.substringAfterLast(' .' )} : " +
3927+ " le service ne fournit pas cette interface sur ce firmware" )
38203928 }
38213929 }
3822- sMediaConns[action ] = conn
3930+ sMediaConns[cle ] = conn
38233931 val ok = try {
3824- val intent = Intent (action)
3932+ val intent = if (action.isEmpty()) Intent () else Intent (action)
38253933 if (cls != null ) intent.setClassName(pkg, cls) else intent.setPackage(pkg)
38263934 ctx.bindService(intent, conn, Context .BIND_AUTO_CREATE )
38273935 } catch (e: Exception ) {
3828- AppLogger .w(MEDIA_TAG , " bindService ${action .substringAfterLast(' .' )} : " +
3936+ AppLogger .w(MEDIA_TAG , " bindService ${cle .substringAfterLast(' .' )} : " +
38293937 " ${(e.cause ? : e).message} " )
38303938 false
38313939 }
38323940 if (! ok) {
3833- sMediaConns.remove(action)
3834- AppLogger .w(MEDIA_TAG , " service média indisponible pour " +
3835- " ${action.substringAfterLast(' .' )} (absent de ce firmware ?)" )
3941+ sMediaConns.remove(cle)
3942+ sMediaSansReponse[cle] = SystemClock .uptimeMillis()
3943+ AppLogger .w(MEDIA_TAG , " service indisponible pour " +
3944+ " ${cle.substringAfterLast(' .' )} (absent de ce firmware ?)" )
38363945 return null
38373946 }
38383947 }
38393948
38403949 val limite = SystemClock .uptimeMillis() + attenteMs
38413950 while (SystemClock .uptimeMillis() < limite) {
3842- sMediaBinders[action ]?.let { return it }
3951+ sMediaBinders[cle ]?.let { return it }
38433952 try { Thread .sleep(40 ) } catch (_: InterruptedException ) {}
38443953 }
3845- AppLogger .w(MEDIA_TAG , " liaison ${action.substringAfterLast(' .' )} non établie en ${attenteMs} ms" )
3954+ // Seule une attente COMPLÈTE vaut constat. La sonde de diagnostic interroge avec un
3955+ // délai raccourci : en tirer une conclusion mettrait de côté un service simplement lent,
3956+ // et le raccourci suivant en pâtirait sans raison.
3957+ if (attenteMs >= MEDIA_BIND_MS ) {
3958+ sMediaSansReponse[cle] = SystemClock .uptimeMillis()
3959+ AppLogger .w(MEDIA_TAG , " liaison ${cle.substringAfterLast(' .' )} non établie en " +
3960+ " ${attenteMs} ms — mise de côté ${MEDIA_ABSENT_MS / 1000 } s" )
3961+ } else {
3962+ AppLogger .w(MEDIA_TAG , " liaison ${cle.substringAfterLast(' .' )} non établie en " +
3963+ " ${attenteMs} ms (sondage court, aucun constat retenu)" )
3964+ }
38463965 return null
38473966 }
38483967
@@ -4095,23 +4214,14 @@ object MG4Hardware {
40954214 else cible.transportControls.play()
40964215 }
40974216 } else {
4098- // Repli : le bouton média, envoyé À CETTE SESSION précise. Beaucoup
4099- // d'applications — les projections en particulier — traitent les touches média
4100- // sans rien déclarer dans `actions`.
4217+ // Action non déclarée : on ne tente RIEN par cette voie et on rend la main.
41014218 //
4102- // ⚠️ Ce n'est PAS le `dispatchMediaKeyEvent` global qui avait causé le
4103- // changement de source : celui-ci vise le contrôleur qu'on a identifié comme
4104- // étant celui qui joue. Aucune autre session ne peut l'intercepter.
4105- val code = when (cmd) {
4106- CmdMedia .SUIVANT -> KeyEvent .KEYCODE_MEDIA_NEXT
4107- CmdMedia .PRECEDENT -> KeyEvent .KEYCODE_MEDIA_PREVIOUS
4108- CmdMedia .LECTURE_PAUSE -> KeyEvent .KEYCODE_MEDIA_PLAY_PAUSE
4109- }
4110- val t = SystemClock .uptimeMillis()
4111- cible.dispatchMediaButtonEvent(KeyEvent (t, t, KeyEvent .ACTION_DOWN , code, 0 ))
4112- cible.dispatchMediaButtonEvent(KeyEvent (t, t, KeyEvent .ACTION_UP , code, 0 ))
4113- AppLogger .i(MEDIA_TAG , " action non déclarée — bouton média ${
4114- KeyEvent .keyCodeToString(code)} envoyé à ${cible.packageName} " )
4219+ // Le bouton média envoyé à la session avait été essayé ici — sans effet, mesuré
4220+ // sur SWI68 et SWI131. La vraie réponse pour la projection est le service allgo,
4221+ // et rendre « vrai » à tort empêcherait l'appelant de l'essayer.
4222+ AppLogger .i(MEDIA_TAG , " ${cmd.name} non déclarée par ${cible.packageName} — " +
4223+ " voie session abandonnée" )
4224+ return false
41154225 }
41164226 true
41174227 } catch (e: Exception ) {
@@ -4137,15 +4247,20 @@ object MG4Hardware {
41374247 private fun commandeMedia (cmd : CmdMedia ): Boolean {
41384248 val src = mediaSourceCourante()
41394249
4140- // Service SAIC muet : c'est le cas des firmwares A9, où il n'existe pas. La touche
4141- // média d'Android devient alors la seule voie — elle atteindra au moins le Bluetooth .
4250+ // Service SAIC muet : c'est le cas des firmwares A9, où il n'existe pas. Trois voies
4251+ // s'y succèdent, de la plus précise à la plus grossière .
41424252 if (src < 0 ) {
41434253 // Firmwares A9 : le service média SAIC n'existe pas, et leur launcher n'en utilise
41444254 // pas non plus — il pilote les sessions média du framework. C'est donc la voie
41454255 // normale ici, pas un pis-aller.
41464256 AppLogger .i(MEDIA_TAG , " service média SAIC absent — pilotage par session" )
41474257 if (sessionMedia(cmd)) return true
41484258
4259+ // La session couvre radio, Bluetooth et USB sur ces firmwares. Ce qu'elle ne couvre
4260+ // pas, c'est la projection : elle ne déclare ni piste suivante ni précédente. D'où
4261+ // le service allgo, qui est justement la voie que leur launcher emprunte.
4262+ if (ruiMedia(cmd, musiqueEnCours())) return true
4263+
41494264 // Ultime recours, si l'énumération des sessions est refusée : la touche média, mais
41504265 // UNIQUEMENT si quelque chose joue. Sans ce garde-fou, l'envoyer pendant que la
41514266 // radio joue (isMusicActive = faux, mesuré) réveillerait la session Bluetooth et
@@ -4214,11 +4329,18 @@ object MG4Hardware {
42144329 CmdMedia .LECTURE_PAUSE -> if (enLecture(cible)) 1 else 2
42154330 })
42164331
4217- cible == SRC_CARPLAY || cible == SRC_AA -> mediaTransact(ACT_CPAA , DESC_CPAA , when (cmd) {
4218- CmdMedia .SUIVANT -> 4
4219- CmdMedia .PRECEDENT -> 3
4220- CmdMedia .LECTURE_PAUSE -> if (enLecture(cible)) 2 else 1
4221- })
4332+ cible == SRC_CARPLAY || cible == SRC_AA -> {
4333+ // L'état n'est lu que si la commande en dépend : inutile d'imposer une lecture
4334+ // binder à « piste suivante », qui n'en a que faire.
4335+ val joue = cmd == CmdMedia .LECTURE_PAUSE && enLecture(cible)
4336+ // SWI133 par le SDK SAIC, tous les autres par le service allgo. Les deux voies
4337+ // sont exclusives selon le firmware : celle qui est absente échoue sans effet.
4338+ mediaTransact(ACT_CPAA , DESC_CPAA , when (cmd) {
4339+ CmdMedia .SUIVANT -> 4
4340+ CmdMedia .PRECEDENT -> 3
4341+ CmdMedia .LECTURE_PAUSE -> if (joue) 2 else 1
4342+ }) || ruiMedia(cmd, joue)
4343+ }
42224344
42234345 else -> {
42244346 AppLogger .i(MEDIA_TAG , " source ${nomSource(cible)} sans lecteur identifié — " +
0 commit comments