@@ -2,9 +2,11 @@ package com.mg4.control.accessibility
22
33import android.accessibilityservice.AccessibilityService
44import android.accessibilityservice.AccessibilityServiceInfo
5+ import android.app.Instrumentation
56import android.content.Context
67import android.os.Handler
78import android.os.Looper
9+ import android.os.SystemClock
810import android.provider.Settings
911import android.view.KeyEvent
1012import android.view.accessibility.AccessibilityEvent
@@ -14,6 +16,8 @@ import com.mg4.control.service.ProfilePickerOverlay
1416import com.mg4.control.shortcut.PressType
1517import com.mg4.control.shortcut.ShortcutAction
1618import com.mg4.control.util.GarageMode
19+ import java.util.concurrent.ConcurrentHashMap
20+ import java.util.concurrent.Executors
1721
1822/* *
1923 * Interception des touches volant, AVANT le launcher.
@@ -26,8 +30,9 @@ import com.mg4.control.util.GarageMode
2630 * AVANT l'application au premier plan, et peut la consommer en renvoyant `true` depuis [onKeyEvent].
2731 *
2832 * ⚠️ PÉRIMÈTRE DE LA CONSOMMATION, à ne pas élargir à la légère. Trois cas seulement :
29- * • les touches EXPLICITEMENT enregistrées dans [AdvancedShortcuts], et uniquement si
30- * l'interrupteur des raccourcis avancés est actif ;
33+ * • les touches qui portent une action dans [AdvancedShortcuts] POUR LE PROFIL ACTIF, et
34+ * uniquement si l'interrupteur des raccourcis avancés est actif — un appui qui s'avère sans
35+ * action y est renvoyé au système (voir [rejouer]) ;
3136 * • la touche pressée PENDANT un enregistrement, le temps d'un seul appui ;
3237 * • le joystick droit (297-301) PENDANT que le popup de profils est affiché — il y sert à
3338 * naviguer, et l'avaler est ce qui empêche le volume et la piste de changer en même temps.
@@ -69,13 +74,40 @@ class KeyCaptureService : AccessibilityService() {
6974 /* * Touches du joystick dont l'appui a servi à naviguer : leur fin d'appui est avalée aussi. */
7075 private val navigationEnCours = mutableSetOf<Int >()
7176
77+ /* *
78+ * Touches interceptées pour un raccourci avancé, entre leur DOWN et leur UP.
79+ *
80+ * La décision est prise UNE fois, au DOWN, puis tenue jusqu'au UP : elle dépend du profil actif,
81+ * qui peut changer pendant l'appui (connexion Bluetooth), et un UP dont le DOWN n'a pas été
82+ * vu par le système — ou l'inverse — laisserait une touche à moitié enfoncée.
83+ */
84+ private val touchesPrises = mutableSetOf<Int >()
85+
86+ /* * Dernier DOWN intercepté par touche : modèle de l'événement renvoyé au système. */
87+ private val modeles = mutableMapOf<Int , KeyEvent >()
88+
89+ /* *
90+ * Appuis renvoyés au système, repérés par leur `downTime` → code de touche.
91+ *
92+ * Un événement injecté ne repasse normalement pas par le filtre d'accessibilité. Mais si ce
93+ * firmware le faisait, on le réintercepterait et le renverrait sans fin — le volant serait
94+ * paralysé. Le coût de la précaution est nul.
95+ */
96+ private val rejeux = ConcurrentHashMap <Long , Int >()
97+
98+ /* * `Instrumentation.sendKeySync` refuse le thread principal : les renvois ont leur propre fil. */
99+ private val injecteur = Executors .newSingleThreadExecutor()
100+
72101 override fun onKeyEvent (event : KeyEvent ? ): Boolean {
73102 // Tout est encapsulé : une exception qui remonterait d'ici déciderait à notre place du
74103 // sort de la touche. On ne laisse jamais une erreur avaler une commande du volant.
75104 try {
76105 event ? : return false
77106 val code = event.keyCode
78107
108+ // Notre propre renvoi : il appartient au système, on n'y touche pas.
109+ if (rejeux[event.downTime] == code) return false
110+
79111 if (event.action == KeyEvent .ACTION_DOWN ) {
80112 AppLogger .i(TAG , " TOUCHE keycode=$code (${KeyEvent .keyCodeToString(code)} ) " +
81113 " source=${event.source} repeat=${event.repeatCount} " )
@@ -111,6 +143,17 @@ class KeyCaptureService : AccessibilityService() {
111143 return true
112144 }
113145
146+ // Suite d'un appui intercepté au DOWN : répétitions avalées, UP traité. Testé avant le
147+ // Mode Garage et l'interrupteur, pour la même raison : un appui commencé se termine
148+ // chez celui qui l'a commencé.
149+ if (code in touchesPrises && ! (event.action == KeyEvent .ACTION_DOWN && event.repeatCount == 0 )) {
150+ if (event.action == KeyEvent .ACTION_UP ) {
151+ touchesPrises.remove(code)
152+ surRelachement(code)
153+ }
154+ return true
155+ }
156+
114157 // Mode Garage : ne RIEN consommer. C'est ici que se joue le retour des touches au
115158 // launcher d'origine — un raccourci avancé réclame sa touche en bloc, et seul un
116159 // `false` rendu ici la laisse repartir vers l'application au premier plan.
@@ -132,18 +175,25 @@ class KeyCaptureService : AccessibilityService() {
132175 return true
133176 }
134177
135- // Seules les touches explicitement enregistrées sont interceptées. Tout le reste
136- // traverse : c'est ce qui garantit qu'un bug ici ne peut pas paralyser le volant.
137- if (! AdvancedShortcuts .isEnabled(this ) || ! AdvancedShortcuts .isClaimed(this , code)) {
178+ // Tout ce qui n'est pas le PREMIER down d'un appui traverse : répétitions et UP d'un
179+ // appui que le système a vu commencer lui reviennent.
180+ if (event.action != KeyEvent .ACTION_DOWN || event.repeatCount != 0 ) return false
181+ if (! AdvancedShortcuts .isEnabled(this )) return false
182+
183+ // Seules les touches qui portent une action pour le profil ACTIF sont interceptées.
184+ // Tout le reste traverse : c'est ce qui garantit qu'un bug ici ne peut pas paralyser
185+ // le volant. Exception : le second appui d'un double appui en attente, attendu quoi
186+ // qu'il arrive au profil entre-temps.
187+ if (code !in fenetreDouble && ! AdvancedShortcuts .isClaimedNow(this , code)) {
188+ if (AdvancedShortcuts .isClaimed(this , code)) {
189+ AppLogger .i(TAG , " touche $code — raccourcis réservés à d'autres profils → laissée au système" )
190+ }
138191 return false
139192 }
140193
141- when (event.action) {
142- // Ne traiter que le PREMIER down : la répétition automatique en envoie d'autres
143- // tant que la touche est tenue, et relancerait la mécanique à chaque fois.
144- KeyEvent .ACTION_DOWN -> if (event.repeatCount == 0 ) surAppui(code)
145- KeyEvent .ACTION_UP -> surRelachement(code)
146- }
194+ touchesPrises.add(code)
195+ modeles[code] = KeyEvent (event) // copie : l'événement reçu peut être recyclé
196+ surAppui(code)
147197 return true // touche réclamée : le launcher ne la verra pas
148198 } catch (e: Exception ) {
149199 AppLogger .w(TAG , " onKeyEvent exception : ${e.message} " )
@@ -162,13 +212,18 @@ class KeyCaptureService : AccessibilityService() {
162212 val fenetre = fenetreDouble.remove(code)
163213 if (fenetre != null ) {
164214 handler.removeCallbacks(fenetre)
165- annulerLong(code) // un second appui n'ouvre pas d'appui long
166215 val m = AdvancedShortcuts .resolve(this , code, PressType .DOUBLE )
167- AppLogger .i(TAG , " touche $code — DOUBLE appui → ${m?.action?.name ? : " aucune" } " +
168- (m?.profileId?.let { " (profil $it )" } ? : " " ))
169- doubleDeclenche.add(code)
170- if (m != null ) declencher(m)
171- return
216+ if (m != null ) {
217+ annulerLong(code) // un second appui n'ouvre pas d'appui long
218+ AppLogger .i(TAG , " touche $code — DOUBLE appui → ${m.action.name} " +
219+ (m.profileId?.let { " (profil $it )" } ? : " " ))
220+ doubleDeclenche.add(code)
221+ declencher(m)
222+ return
223+ }
224+ // Le double appui a disparu pendant la fenêtre (changement de profil) : le premier
225+ // appui se conclut comme un simple, et celui-ci suit son cours normal.
226+ fenetre.run ()
172227 }
173228
174229 // ── Appui long : déclenché au SEUIL, pas au relâchement ──
@@ -197,7 +252,8 @@ class KeyCaptureService : AccessibilityService() {
197252 * Fin d'un appui.
198253 *
199254 * Le relâchement ne déclenche plus que l'appui court — et encore, à retardement si la touche
200- * porte aussi un double appui.
255+ * porte aussi un double appui. Un appui maintenu sans appui long configuré arrive ici aussi :
256+ * la voiture ne connaissant que l'appui simple, c'est ainsi qu'elle l'aurait traité.
201257 */
202258 private fun surRelachement (code : Int ) {
203259 annulerLong(code) // relâchée avant le seuil : l'appui long n'aura pas lieu
@@ -207,17 +263,13 @@ class KeyCaptureService : AccessibilityService() {
207263 if (longDeclenche.remove(code)) return
208264
209265 val simple = AdvancedShortcuts .resolve(this , code, PressType .SINGLE )
210- // La fenêtre d'attente dépend de l'EXISTENCE d'un double appui sur la touche, pas de sa
211- // résolution : une variante réservée à un autre profil doit quand même faire attendre,
212- // sinon un double appui sous ce profil déclencherait d'abord le simple.
213- val aDouble = PressType .DOUBLE .let { d ->
214- AdvancedShortcuts .all(this ).any { it.keyCode == code && it.press == d }
215- }
266+ // Résolu pour le profil ACTIF : un double appui réservé à un autre profil ne se
267+ // déclencherait pas ici, l'attendre ne ferait que retarder l'appui simple.
268+ val aDouble = AdvancedShortcuts .resolve(this , code, PressType .DOUBLE ) != null
216269
217270 // Sans double appui sur cette touche, rien à attendre : l'action part immédiatement.
218271 if (! aDouble) {
219- AppLogger .i(TAG , " touche $code — appui court → ${simple?.action?.name ? : " aucune" } " )
220- if (simple != null ) declencher(simple)
272+ conclureAppuiCourt(code, simple, " appui court" )
221273 return
222274 }
223275
@@ -226,15 +278,52 @@ class KeyCaptureService : AccessibilityService() {
226278 // ouverte même sans action d'appui court, car c'est elle qui détecte le second appui.
227279 val fenetre = Runnable {
228280 fenetreDouble.remove(code)
229- AppLogger .i(TAG , " touche $code — appui court confirmé " +
230- " (aucun second appui en ${AdvancedShortcuts .DOUBLE_TAP_MS } ms) " +
231- " → ${simple?.action?.name ? : " aucune" } " )
232- if (simple != null ) declencher(simple)
281+ conclureAppuiCourt(code, simple,
282+ " appui court confirmé (aucun second appui en ${AdvancedShortcuts .DOUBLE_TAP_MS } ms)" )
233283 }
234284 fenetreDouble[code] = fenetre
235285 handler.postDelayed(fenetre, AdvancedShortcuts .DOUBLE_TAP_MS )
236286 }
237287
288+ /* * Appui simple établi : son action s'il en a une, sinon la fonction d'origine de la touche. */
289+ private fun conclureAppuiCourt (code : Int , simple : AdvancedShortcuts .Mapping ? , quoi : String ) {
290+ if (simple != null ) {
291+ AppLogger .i(TAG , " touche $code — $quoi → ${simple.action.name} " )
292+ declencher(simple)
293+ } else {
294+ AppLogger .i(TAG , " touche $code — $quoi sans action MG4Control → rendu au système" )
295+ rejouer(code)
296+ }
297+ }
298+
299+ /* *
300+ * Renvoie au système un appui simple sur [code], comme si la touche venait d'être pressée.
301+ *
302+ * C'est la seule façon de rendre sa fonction d'origine à une touche déjà interceptée : un
303+ * DOWN consommé ne peut pas être « relâché » vers le launcher. On injecte donc un appui neuf,
304+ * copié du DOWN d'origine (périphérique, scan code, source) pour que le système le traite
305+ * exactement comme la vraie touche. Possible parce que l'app tourne en `android.uid.system`,
306+ * qui détient l'injection d'événements — c'est aussi ce que fait KeyMapper.
307+ */
308+ private fun rejouer (code : Int ) {
309+ val modele = modeles[code] ? : return
310+ val t = SystemClock .uptimeMillis()
311+ rejeux[t] = code
312+ handler.postDelayed({ rejeux.remove(t) }, 2_000L )
313+
314+ fun evenement (action : Int ) = KeyEvent (
315+ t, SystemClock .uptimeMillis(), action, code, 0 ,
316+ modele.metaState, modele.deviceId, modele.scanCode, modele.flags, modele.source
317+ )
318+ injecteur.execute {
319+ runCatching {
320+ val instrumentation = Instrumentation ()
321+ instrumentation.sendKeySync(evenement(KeyEvent .ACTION_DOWN ))
322+ instrumentation.sendKeySync(evenement(KeyEvent .ACTION_UP ))
323+ }.onFailure { AppLogger .w(TAG , " touche $code — renvoi au système impossible : ${it.message} " ) }
324+ }
325+ }
326+
238327 private fun annulerLong (code : Int ) {
239328 minuterieLongue.remove(code)?.let { handler.removeCallbacks(it) }
240329 }
@@ -270,6 +359,10 @@ class KeyCaptureService : AccessibilityService() {
270359 longDeclenche.clear()
271360 doubleDeclenche.clear()
272361 navigationEnCours.clear()
362+ touchesPrises.clear()
363+ modeles.clear()
364+ rejeux.clear()
365+ injecteur.shutdownNow()
273366 AppLogger .i(TAG , " service déconnecté" )
274367 super .onDestroy()
275368 }
0 commit comments