diff --git a/src/main/java/wtf/beatrice/autosqueal/Main.java b/src/main/java/wtf/beatrice/autosqueal/Main.java index ffe1cf7..77b0a47 100644 --- a/src/main/java/wtf/beatrice/autosqueal/Main.java +++ b/src/main/java/wtf/beatrice/autosqueal/Main.java @@ -16,6 +16,7 @@ public class Main { private static final Logger LOGGER = LogManager.getLogger(Main.class); private static MainWindow mainWindow; + private static AutoSquealConfig config; /** Whether the hook and its listener are registered, so retries don't add duplicates. */ private static boolean hookRegistered = false; @@ -23,7 +24,7 @@ public class Main { public static void main(String[] args) { LOGGER.info("Hello world!"); - AutoSquealConfig config = new ConfigStore().load(); + config = new ConfigStore().load(); mainWindow = new MainWindow(config); // ⌘Q on macOS, the IDE stop button and signals all bypass @@ -58,7 +59,8 @@ public class Main { LOGGER.info("Registering jnativehook library..."); try { GlobalScreen.registerNativeHook(); - GlobalScreen.addNativeKeyListener(new KeyPressListener()); + GlobalScreen.addNativeKeyListener( + new KeyPressListener(mainWindow::toggleRunning, mainWindow::notifyKeyboardActivity, config)); hookRegistered = true; LOGGER.info("Successfully registered jnativehook library!"); return true; @@ -89,8 +91,4 @@ public class Main { } } - public static MainWindow getMainWindow() { - return mainWindow; - } - } \ No newline at end of file diff --git a/src/main/java/wtf/beatrice/autosqueal/listener/KeyPressListener.java b/src/main/java/wtf/beatrice/autosqueal/listener/KeyPressListener.java index 8d1353e..58a9f27 100644 --- a/src/main/java/wtf/beatrice/autosqueal/listener/KeyPressListener.java +++ b/src/main/java/wtf/beatrice/autosqueal/listener/KeyPressListener.java @@ -4,16 +4,34 @@ import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent; import com.github.kwhat.jnativehook.keyboard.NativeKeyListener; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import wtf.beatrice.autosqueal.Main; +import wtf.beatrice.autosqueal.config.AutoSquealConfig; import java.util.ArrayList; import java.util.List; -public class KeyPressListener implements NativeKeyListener -{ +/** + * Listens for the global toggle hotkey, and reports every key press to the + * away detection: a user who is typing is present, hotkey or not. + * + * The listener is fully decoupled: it receives the actions to run instead + * of reaching into the main window, so it can be wired — and tested — on + * its own. + */ +public class KeyPressListener implements NativeKeyListener { + private static final Logger LOGGER = LogManager.getLogger(KeyPressListener.class); + + private final Runnable toggleAction; + private final Runnable keyboardActivityReporter; + private final AutoSquealConfig config; private final List pressedKeys = new ArrayList<>(); + public KeyPressListener(Runnable toggleAction, Runnable keyboardActivityReporter, AutoSquealConfig config) { + this.toggleAction = toggleAction; + this.keyboardActivityReporter = keyboardActivityReporter; + this.config = config; + } + @Override public void nativeKeyPressed(NativeKeyEvent e) { // the OS fires repeated "press" events while a key is being held down; ignore them, @@ -27,18 +45,22 @@ public class KeyPressListener implements NativeKeyListener // any key the user presses means they are present: let the away // detection know, so the app doesn't grab the mouse while they type - Main.getMainWindow().notifyKeyboardActivity(); + keyboardActivityReporter.run(); + + if (!config.isHotkeyEnabled()) { + return; + } // toggle only when the second key of the combo is pressed down: not only does this // fire exactly once per combo, but it also avoids toggling when any other key is // pressed or released while ctrl+alt happen to be held down if ((e.getKeyCode() == NativeKeyEvent.VC_CONTROL && pressedKeys.contains(NativeKeyEvent.VC_ALT)) || (e.getKeyCode() == NativeKeyEvent.VC_ALT && pressedKeys.contains(NativeKeyEvent.VC_CONTROL))) { - LOGGER.warn("Received shutdown keystroke: [{}][{}]", + LOGGER.info("Received toggle hotkey: [{}][{}]", NativeKeyEvent.getKeyText(NativeKeyEvent.VC_CONTROL), NativeKeyEvent.getKeyText(NativeKeyEvent.VC_ALT)); - Main.getMainWindow().toggleRunning(); + toggleAction.run(); } } diff --git a/src/test/java/wtf/beatrice/autosqueal/listener/KeyPressListenerTest.java b/src/test/java/wtf/beatrice/autosqueal/listener/KeyPressListenerTest.java new file mode 100644 index 0000000..6931797 --- /dev/null +++ b/src/test/java/wtf/beatrice/autosqueal/listener/KeyPressListenerTest.java @@ -0,0 +1,86 @@ +package wtf.beatrice.autosqueal.listener; + +import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent; +import org.junit.jupiter.api.Test; +import wtf.beatrice.autosqueal.config.AutoSquealConfig; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class KeyPressListenerTest +{ + private final List events = new ArrayList<>(); + private final AutoSquealConfig config = new AutoSquealConfig(); + private final KeyPressListener listener = new KeyPressListener( + () -> events.add("toggle"), () -> events.add("activity"), config); + + private static NativeKeyEvent press(int keyCode) { + return new NativeKeyEvent(NativeKeyEvent.NATIVE_KEY_PRESSED, 0, 0, keyCode, ' '); + } + + private static NativeKeyEvent release(int keyCode) { + return new NativeKeyEvent(NativeKeyEvent.NATIVE_KEY_RELEASED, 0, 0, keyCode, ' '); + } + + @Test + void theComboTogglesOnceOnItsSecondKey() { + listener.nativeKeyPressed(press(NativeKeyEvent.VC_CONTROL)); + + // ctrl alone is not the combo: it only reports presence + assertEquals(List.of("activity"), events); + + listener.nativeKeyPressed(press(NativeKeyEvent.VC_ALT)); + + // the second key completes the combo: presence first, then the toggle + assertEquals(List.of("activity", "activity", "toggle"), events); + } + + @Test + void theComboWorksInTheOtherOrderToo() { + listener.nativeKeyPressed(press(NativeKeyEvent.VC_ALT)); + listener.nativeKeyPressed(press(NativeKeyEvent.VC_CONTROL)); + + assertEquals(List.of("activity", "activity", "toggle"), events); + } + + @Test + void repeatedPressesAreIgnored() { + // the OS repeats press events while a key is held down: they must + // not toggle repeatedly + listener.nativeKeyPressed(press(NativeKeyEvent.VC_CONTROL)); + listener.nativeKeyPressed(press(NativeKeyEvent.VC_ALT)); + + events.clear(); + listener.nativeKeyPressed(press(NativeKeyEvent.VC_CONTROL)); + listener.nativeKeyPressed(press(NativeKeyEvent.VC_ALT)); + + assertEquals(List.of(), events); + } + + @Test + void theComboCanFireAgainAfterBothKeysAreReleased() { + listener.nativeKeyPressed(press(NativeKeyEvent.VC_CONTROL)); + listener.nativeKeyPressed(press(NativeKeyEvent.VC_ALT)); + listener.nativeKeyReleased(release(NativeKeyEvent.VC_CONTROL)); + listener.nativeKeyReleased(release(NativeKeyEvent.VC_ALT)); + + events.clear(); + listener.nativeKeyPressed(press(NativeKeyEvent.VC_CONTROL)); + listener.nativeKeyPressed(press(NativeKeyEvent.VC_ALT)); + + assertEquals(List.of("activity", "activity", "toggle"), events); + } + + @Test + void theHotkeyCanBeDisabledWithoutLosingPresenceReporting() { + config.setHotkeyEnabled(false); + + listener.nativeKeyPressed(press(NativeKeyEvent.VC_CONTROL)); + listener.nativeKeyPressed(press(NativeKeyEvent.VC_ALT)); + + // typing still counts as presence, but the combo does nothing + assertEquals(List.of("activity", "activity"), events); + } +} \ No newline at end of file