Skip to content

Commit 3d33ab6

Browse files
committed
v2 shortcut system
1 parent 47d8923 commit 3d33ab6

9 files changed

Lines changed: 178 additions & 59 deletions

File tree

‎app/src/main/java/com/mg4/control/accessibility/AdvancedShortcuts.kt‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ import com.mg4.control.shortcut.ShortcutAction
1212
* véhicule. Ceux-ci passent par [KeyCaptureService], qui voit la touche AVANT le launcher et
1313
* peut la consommer.
1414
*
15-
* ⚠️ CONSÉQUENCE À CONNAÎTRE : une touche enregistrée ici est réclamée **en bloc**. Pour
16-
* intercepter un appui long il faut consommer le DOWN, or à cet instant on ne sait pas encore
17-
* si l'appui sera court, long ou double — et on ne peut pas « dé-consommer » après coup. Les
18-
* types d'appui non attribués ne retombent donc PAS sur le launcher : ils ne font rien.
19-
* C'est ce que [isClaimed] exprime, et c'est pour ça qu'elle ignore le type d'appui.
15+
* ⚠️ CONSÉQUENCE À CONNAÎTRE : une touche qui porte une action pour le profil actif est
16+
* interceptée **en bloc** ([isClaimedNow]). Pour intercepter un appui long il faut consommer le
17+
* DOWN, or à cet instant on ne sait pas encore si l'appui sera court, long ou double — et on ne
18+
* peut pas « dé-consommer » après coup.
19+
*
20+
* Les appuis qui ne portent aucune action ne sont pas perdus pour autant : la voiture ne connaît
21+
* d'origine que l'appui simple, donc tout appui sans action se comporte comme un appui simple, et
22+
* un appui simple sans action est RENVOYÉ au système une fois l'appui terminé (voir
23+
* [KeyCaptureService]). Seul coût : 300 ms de retard sur une touche qui porte un double appui.
2024
*/
2125
object AdvancedShortcuts {
2226

@@ -199,9 +203,18 @@ object AdvancedShortcuts {
199203
}
200204

201205
/**
202-
* Vrai si CETTE TOUCHE est réclamée — quel que soit le type d'appui ET quel que soit le
203-
* profil. Voir l'avertissement en tête de fichier : c'est cette méthode qui décide de la
204-
* consommation, et une touche se réclame en bloc.
206+
* Vrai si CETTE TOUCHE porte au moins une action qui s'appliquerait MAINTENANT, sous le profil
207+
* actif — quel que soit le type d'appui. C'est ce qui décide de l'interception : une touche
208+
* dont les raccourcis sont tous réservés à d'autres profils garde sa fonction d'origine, sans
209+
* même le délai d'un renvoi.
210+
*/
211+
fun isClaimedNow(context: Context, keyCode: Int): Boolean =
212+
PressType.values().any { resolve(context, keyCode, it) != null }
213+
214+
/**
215+
* Vrai si CETTE TOUCHE porte au moins un raccourci — quel que soit le type d'appui ET quel que
216+
* soit le profil. Ne décide plus de l'interception (voir [isClaimedNow]) ; sert à expliquer
217+
* dans le journal pourquoi une touche configurée est laissée au système.
205218
*
206219
* Balayage par préfixe plutôt que par énumération : les profils possibles ne sont pas
207220
* connus d'ici, et les tester un par un obligerait à charger la liste des profils à chaque

‎app/src/main/java/com/mg4/control/accessibility/KeyCaptureService.kt‎

Lines changed: 123 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package com.mg4.control.accessibility
22

33
import android.accessibilityservice.AccessibilityService
44
import android.accessibilityservice.AccessibilityServiceInfo
5+
import android.app.Instrumentation
56
import android.content.Context
67
import android.os.Handler
78
import android.os.Looper
9+
import android.os.SystemClock
810
import android.provider.Settings
911
import android.view.KeyEvent
1012
import android.view.accessibility.AccessibilityEvent
@@ -14,6 +16,8 @@ import com.mg4.control.service.ProfilePickerOverlay
1416
import com.mg4.control.shortcut.PressType
1517
import com.mg4.control.shortcut.ShortcutAction
1618
import 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
}

‎app/src/main/res/values-de/strings.xml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -466,10 +466,10 @@
466466
<string name="adv_sc_all_profiles">Alle Profile</string>
467467
<string name="adv_sc_profile_missing">Gelöschtes Profil</string>
468468
<string name="adv_sc_group_title">%1$s · %2$s</string>
469-
<string name="adv_sc_no_fallback">Kein Kürzel für die anderen Profile: die Taste ist dort belegt, tut aber nichts.</string>
469+
<string name="adv_sc_no_fallback">Kein Kürzel für die anderen Profile: die Taste behält dort ihre ursprüngliche Funktion.</string>
470470
<string name="adv_sc_edit_conflict">Dieses Profil hat bereits ein Kürzel auf dieser Taste und Druckart.</string>
471471
<string name="adv_sc_empty">Kein erweitertes Kürzel gespeichert.</string>
472-
<string name="adv_sc_claimed_warning">Die zugewiesene Taste kann nur noch für MG4Control verwendet werden, bis das Kürzel unter « KÜRZELLISTE » gelöscht wird.</string>
472+
<string name="adv_sc_claimed_warning">Drücke ohne Kürzel behalten die ursprüngliche Funktion der Taste. Bei einer Taste mit Doppeldruck erfolgt der ursprüngliche einfache Druck mit leichter Verzögerung (0,3 s).</string>
473473
<string name="adv_sc_delete">Löschen</string>
474474
<string name="adv_sc_status_label">Dienststatus</string>
475475
<string name="adv_sc_saved">Kürzel gespeichert</string>
@@ -513,7 +513,7 @@
513513

514514
<!-- Avertissement a l'activation des raccourcis avances -->
515515
<string name="adv_sc_enable_warn_title">Belegte Taste ändert ihre Rolle</string>
516-
<string name="adv_sc_enable_warn_msg">Eine belegte Taste verliert ihre ursprüngliche Funktion: MG4Control fängt die Taste vor dem Launcher ab, der sie gar nicht mehr sieht.\n\nBeispiel: Wird die Home-Taste belegt, führt sie nicht mehr zum MG-Launcher zurück. Um das wiederherzustellen, weisen Sie ihr die Aktion App starten zu und wählen Sie den Launcher.\n\nDie Taste wird komplett beansprucht: Wird nur der lange Druck belegt, sind auch kurzer und doppelter Druck wirkungslos.\n\nAlles wird wieder normal, sobald die Verknüpfung in der Kürzelliste gelöscht wird.</string>
516+
<string name="adv_sc_enable_warn_msg">MG4Control fängt belegte Tasten vor dem Launcher ab.\n\nEin Druck mit Kürzel ersetzt die ursprüngliche Funktion der Taste. Die anderen behalten sie: Wird der Doppeldruck der Home-Taste belegt, führt der einfache Druck weiterhin zum MG-Launcher zurück.\n\nEinziger Nachteil: Bei einer Taste mit Doppeldruck erfolgt der einfache Druck mit leichter Verzögerung (0,3 s), um sicherzugehen, dass kein zweiter Druck folgt.\n\nAlles wird wieder normal, sobald die Verknüpfung in der Kürzelliste gelöscht wird.</string>
517517
<string name="adv_sc_enable_warn_ok">Aktivieren</string>
518518

519519
<!-- Raccourcis : pistes audio -->

0 commit comments

Comments
 (0)