diff --git a/src/main/java/wtf/beatrice/autosqueal/controls/CursorMover.java b/src/main/java/wtf/beatrice/autosqueal/controls/CursorMover.java index e536b72..eae70a2 100644 --- a/src/main/java/wtf/beatrice/autosqueal/controls/CursorMover.java +++ b/src/main/java/wtf/beatrice/autosqueal/controls/CursorMover.java @@ -2,7 +2,7 @@ package wtf.beatrice.autosqueal.controls; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import wtf.beatrice.autosqueal.util.RunnerUtil; +import wtf.beatrice.autosqueal.config.AutoSquealConfig; import java.awt.*; import java.security.SecureRandom; @@ -10,10 +10,11 @@ import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.function.BooleanSupplier; +import java.util.function.Supplier; /** * Periodically starts a new movement of the mouse cursor to a random position, - * or to the top-right corner of the screen for a double click. + * or to the top-right corner of the main screen for a double click. * * The movements only happen when the user is away: while the user is actively * using the machine, they are skipped, so the app never fights for the mouse. @@ -21,26 +22,50 @@ import java.util.function.BooleanSupplier; * the user is still away at every step, and gives the mouse back when they * are not. * - * The movement itself is executed by a {@link SingleStepMovementTask}, which - * runs on its own virtual thread until it reaches its destination. + * Everything that varies — how often to move, how fast, whether and how + * often to click, which screens to travel — comes from the settings, which + * are read live at every movement. The movement itself is executed by a + * {@link SingleStepMovementTask}, which runs on its own virtual thread + * until it reaches its destination. */ -public class CursorMover implements Runnable -{ +public class CursorMover implements Runnable { + private static final Logger LOGGER = LogManager.getLogger(CursorMover.class); - private static final int LOOPS_BEFORE_CLICK = 5; + /** Distance from the screen edge, so the corner click lands inside the screen. */ + private static final int CORNER_OFFSET_PIXELS = 5; - private final Random random = new SecureRandom(); + private final Random random; private final ExecutorService movementExecutor; private final RobotMouseTracker robotTracker; private final BooleanSupplier isUserAway; + private final AutoSquealConfig config; + private final Supplier movementBounds; + private final Supplier primaryScreenBounds; + private final Supplier currentPosition; + /** Movements started since the last corner click. */ private int iteration = 0; - public CursorMover(ExecutorService movementExecutor, RobotMouseTracker robotTracker, BooleanSupplier isUserAway) { + public CursorMover(ExecutorService movementExecutor, RobotMouseTracker robotTracker, + BooleanSupplier isUserAway, AutoSquealConfig config, + Supplier movementBounds, Supplier primaryScreenBounds) { + this(movementExecutor, robotTracker, isUserAway, config, movementBounds, primaryScreenBounds, + new SecureRandom(), () -> MouseInfo.getPointerInfo().getLocation()); + } + + CursorMover(ExecutorService movementExecutor, RobotMouseTracker robotTracker, + BooleanSupplier isUserAway, AutoSquealConfig config, + Supplier movementBounds, Supplier primaryScreenBounds, + Random random, Supplier currentPosition) { this.movementExecutor = movementExecutor; this.robotTracker = robotTracker; this.isUserAway = isUserAway; + this.config = config; + this.movementBounds = movementBounds; + this.primaryScreenBounds = primaryScreenBounds; + this.random = random; + this.currentPosition = currentPosition; } @Override @@ -51,34 +76,38 @@ public class CursorMover implements Runnable return; } - Point location = MouseInfo.getPointerInfo().getLocation(); + Point location = currentPosition.get(); LOGGER.info("Starting coordinates: {}, {}", location.x, location.y); int destX; int destY; boolean click; - if (iteration == LOOPS_BEFORE_CLICK) { - destX = RunnerUtil.SCREEN_WIDTH - 5; - destY = 5; + // one movement every N is a corner double click, so that the + // notification area gets exercised too. the corner is always the + // main screen's top-right one, whatever screens the cursor may + // otherwise travel to. + if (config.isClickEnabled() && iteration == config.getClickEveryNMoves() - 1) { + Rectangle primary = primaryScreenBounds.get(); + destX = primary.x + primary.width - CORNER_OFFSET_PIXELS; + destY = primary.y + CORNER_OFFSET_PIXELS; click = true; - iteration = 0; } else { - destX = random.nextInt(RunnerUtil.SCREEN_WIDTH); - destY = random.nextInt(RunnerUtil.SCREEN_HEIGHT); + Rectangle bounds = movementBounds.get(); + destX = bounds.x + random.nextInt(Math.max(1, bounds.width)); + destY = bounds.y + random.nextInt(Math.max(1, bounds.height)); click = false; - iteration++; } LOGGER.info("Destination coordinates: {}, {}", destX, destY); try { - SingleStepMovementTask movement = new SingleStepMovementTask(robotTracker, destX, destY, click, isUserAway); + SingleStepMovementTask movement = new SingleStepMovementTask( + robotTracker, destX, destY, click, isUserAway, + config.getStepDelayMilliseconds(), location); movementExecutor.execute(movement); - } catch (AWTException ex) { - LOGGER.error("Could not start movement task", ex); } catch (RejectedExecutionException ex) { LOGGER.debug("Movement not started: automation is shutting down"); } diff --git a/src/main/java/wtf/beatrice/autosqueal/controls/SingleStepMovementTask.java b/src/main/java/wtf/beatrice/autosqueal/controls/SingleStepMovementTask.java index 3c17662..8b7f39f 100644 --- a/src/main/java/wtf/beatrice/autosqueal/controls/SingleStepMovementTask.java +++ b/src/main/java/wtf/beatrice/autosqueal/controls/SingleStepMovementTask.java @@ -25,33 +25,31 @@ public class SingleStepMovementTask implements Runnable { private static final Logger LOGGER = LogManager.getLogger(SingleStepMovementTask.class); - /** Delay between two consecutive cursor steps, in milliseconds. */ - private static final long STEP_DELAY_MILLISECONDS = 2L; - private final RobotMouseTracker robotTracker; - private final Robot robot; private final int destX; private final int destY; private final boolean click; private final BooleanSupplier userIsAway; + private final long stepDelayMilliseconds; private float currentX; private float currentY; private float stepX; private float stepY; - public SingleStepMovementTask(RobotMouseTracker robotTracker, int destinationX, int destinationY, boolean click, - BooleanSupplier userIsAway) throws AWTException { + public SingleStepMovementTask(RobotMouseTracker robotTracker, int destinationX, int destinationY, + boolean click, BooleanSupplier userIsAway, long stepDelayMilliseconds, + Point start) { this.robotTracker = robotTracker; this.destX = destinationX; this.destY = destinationY; this.click = click; this.userIsAway = userIsAway; + this.stepDelayMilliseconds = stepDelayMilliseconds; - Point location = MouseInfo.getPointerInfo().getLocation(); - this.currentX = location.x; - this.currentY = location.y; + this.currentX = start.x; + this.currentY = start.y; int lengthX = Math.round(Math.abs(currentX - destX)); int lengthY = Math.round(Math.abs(currentY - destY)); @@ -61,13 +59,22 @@ public class SingleStepMovementTask implements Runnable { LOGGER.info("Dest: [{}, {}], Curr: [{}, {}]", destX, destY, currentX, currentY); LOGGER.info("Step: [{}, {}]", stepX, stepY); - - this.robot = new Robot(); } @Override public void run() { + // the robot is created here, not in the constructor: building a + // movement must never touch the system on its own, or there would be + // no way to queue one for testing or display without moving a mouse + Robot robot; + try { + robot = new Robot(); + } catch (AWTException ex) { + LOGGER.error("Could not create a robot, skipping this movement", ex); + return; + } + // any position change from here on is the app's doing robotTracker.moveStarted(); @@ -91,10 +98,10 @@ public class SingleStepMovementTask implements Runnable { robot.mouseMove(Math.round(currentX), Math.round(currentY)); - Thread.sleep(STEP_DELAY_MILLISECONDS); + Thread.sleep(stepDelayMilliseconds); } - onDestinationReached(); + onDestinationReached(robot); } catch (InterruptedException ex) { // the movement was interrupted: the automation was stopped, so // just end the movement here @@ -111,13 +118,13 @@ public class SingleStepMovementTask implements Runnable { return Math.round(currentX) == destX && Math.round(currentY) == destY; } - private void onDestinationReached() throws InterruptedException { + private void onDestinationReached(Robot robot) throws InterruptedException { LOGGER.info("Reached destination [{}, {}], stopping mover", destX, destY); robotTracker.moveEnded(destX, destY); if (click) { - performClickSequence(); + performClickSequence(robot); } } @@ -125,7 +132,7 @@ public class SingleStepMovementTask implements Runnable { * Performs a double click with the same pacing the app has always used: * press at +500ms, release at +700ms, press at +1200ms, release at +1400ms. */ - private void performClickSequence() throws InterruptedException { + private void performClickSequence(Robot robot) throws InterruptedException { Thread.sleep(500L); robot.mousePress(InputEvent.BUTTON1_DOWN_MASK); Thread.sleep(200L); @@ -183,4 +190,18 @@ public class SingleStepMovementTask implements Runnable { } return current; } + + // package-private: read by the tests, and soon by the live status view + + int getDestX() { + return destX; + } + + int getDestY() { + return destY; + } + + boolean isClick() { + return click; + } } \ No newline at end of file diff --git a/src/main/java/wtf/beatrice/autosqueal/ui/MainWindow.java b/src/main/java/wtf/beatrice/autosqueal/ui/MainWindow.java index 96fa3de..84079d0 100644 --- a/src/main/java/wtf/beatrice/autosqueal/ui/MainWindow.java +++ b/src/main/java/wtf/beatrice/autosqueal/ui/MainWindow.java @@ -5,6 +5,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import wtf.beatrice.autosqueal.Main; import wtf.beatrice.autosqueal.config.AutoSquealConfig; +import wtf.beatrice.autosqueal.config.ScreenArea; import wtf.beatrice.autosqueal.controls.CursorMover; import wtf.beatrice.autosqueal.controls.RobotMouseTracker; import wtf.beatrice.autosqueal.listener.CursorMoveListener; @@ -22,6 +23,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; public class MainWindow { @@ -116,7 +118,9 @@ public class MainWindow addTrayIcon(); } - startAutomation(); + if (config.isStartAutomatically()) { + startAutomation(); + } updateToggleLabel(); } @@ -321,10 +325,18 @@ public class MainWindow // the automation only moves the mouse when the user is away. periodicScheduler.scheduleWithFixedDelay(awayDetector, 0L, AWAY_POLL_INTERVAL_SECONDS, TimeUnit.SECONDS); - cursorMover = new CursorMover(movementExecutor, robotTracker, awayDetector::isUserAway); + // the movement bounds are read again at every movement, so a change + // of setting applies from the next movement without a restart. the + // corner click always aims at the main screen, whatever screens the + // cursor may otherwise travel to. + Supplier movementBounds = () -> RunnerUtil.screenBounds(config.getMovementScreen()); + Supplier primaryScreenBounds = () -> RunnerUtil.screenBounds(ScreenArea.PRIMARY); + + cursorMover = new CursorMover(movementExecutor, robotTracker, awayDetector::isUserAway, + config, movementBounds, primaryScreenBounds); periodicScheduler.scheduleWithFixedDelay(cursorMover, AUTOMATION_START_DELAY_SECONDS, - RunnerUtil.SECONDS_BETWEEN_MOVES, + config.getMoveIntervalSeconds(), TimeUnit.SECONDS); } diff --git a/src/main/java/wtf/beatrice/autosqueal/util/RunnerUtil.java b/src/main/java/wtf/beatrice/autosqueal/util/RunnerUtil.java index a0046d9..09ec663 100644 --- a/src/main/java/wtf/beatrice/autosqueal/util/RunnerUtil.java +++ b/src/main/java/wtf/beatrice/autosqueal/util/RunnerUtil.java @@ -1,5 +1,7 @@ package wtf.beatrice.autosqueal.util; +import wtf.beatrice.autosqueal.config.ScreenArea; + import java.awt.*; public class RunnerUtil { @@ -8,9 +10,35 @@ public class RunnerUtil { throw new AssertionError("The RunnerUtil class is not intended to be instantiated."); } - public static final int SECONDS_BETWEEN_MOVES = 10; - public static final int SCREEN_HEIGHT = Toolkit.getDefaultToolkit().getScreenSize().height; public static final int SCREEN_WIDTH = Toolkit.getDefaultToolkit().getScreenSize().width; + /** + * The bounds of the screens the cursor may travel to, according to the + * given setting. Fetched on every call, so that a change of setting, or + * a screen plugged in while the app runs, is picked up by the next + * movement. + * + * The result is never empty: when no screen is found, the main screen + * is returned, because it always exists. + */ + public static Rectangle screenBounds(ScreenArea area) { + Rectangle bounds = new Rectangle(); + + if (area == ScreenArea.ALL) { + for (GraphicsDevice device : GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices()) { + bounds.add(device.getDefaultConfiguration().getBounds()); + } + } + + if (bounds.isEmpty()) { + // the main screen was requested, or no other screen was found + bounds = GraphicsEnvironment.getLocalGraphicsEnvironment() + .getDefaultScreenDevice().getDefaultConfiguration().getBounds(); + } + + // defensive copy: the callers treat the rectangle as theirs + return new Rectangle(bounds); + } + } diff --git a/src/test/java/wtf/beatrice/autosqueal/controls/CursorMoverTest.java b/src/test/java/wtf/beatrice/autosqueal/controls/CursorMoverTest.java new file mode 100644 index 0000000..55c8d2e --- /dev/null +++ b/src/test/java/wtf/beatrice/autosqueal/controls/CursorMoverTest.java @@ -0,0 +1,187 @@ +package wtf.beatrice.autosqueal.controls; + +import org.junit.jupiter.api.Test; +import wtf.beatrice.autosqueal.config.AutoSquealConfig; + +import java.awt.Point; +import java.awt.Rectangle; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CursorMoverTest +{ + private static final Point CURSOR_AT = new Point(100, 100); + + private final RobotMouseTracker robotTracker = new RobotMouseTracker(); + private final AutoSquealConfig config = new AutoSquealConfig(); + private final RecordingExecutor executor = new RecordingExecutor(); + + /** Bounds of a pretend multi-screen setup: the main screen is offset. */ + private final Rectangle movementBounds = new Rectangle(100, 50, 800, 600); + private final Rectangle primaryBounds = new Rectangle(100, 50, 800, 600); + + /** A mover that queues its movements on the recording executor. */ + private CursorMover mover() { + return new CursorMover(executor, robotTracker, () -> true, config, + () -> movementBounds, () -> primaryBounds, + new Random(42), () -> CURSOR_AT); + } + + private List movements() { + List movements = new ArrayList<>(); + for (Runnable movement : executor.movements) { + movements.add((SingleStepMovementTask) movement); + } + return movements; + } + + @Test + void movementsAreSkippedWhileTheUserIsPresent() { + // the automation only moves the mouse when the user is away + CursorMover presentMover = new CursorMover(executor, robotTracker, () -> false, config, + () -> movementBounds, () -> primaryBounds, + new Random(42), () -> CURSOR_AT); + + for (int i = 0; i < 10; i++) { + presentMover.run(); + } + + assertTrue(executor.movements.isEmpty()); + } + + @Test + void randomMovementsStayWithinTheBounds() { + config.setClickEnabled(false); + + CursorMover cursorMover = mover(); + for (int i = 0; i < 50; i++) { + cursorMover.run(); + } + + List movements = movements(); + assertEquals(50, movements.size()); + for (SingleStepMovementTask movement : movements) { + assertFalse(movement.isClick()); + assertTrue(movement.getDestX() >= 100, "destX below the bounds: " + movement.getDestX()); + assertTrue(movement.getDestX() < 900, "destX above the bounds: " + movement.getDestX()); + assertTrue(movement.getDestY() >= 50, "destY below the bounds: " + movement.getDestY()); + assertTrue(movement.getDestY() < 650, "destY above the bounds: " + movement.getDestY()); + } + } + + @Test + void cornerClicksHappenEveryNthMovement() { + config.setClickEveryNMoves(3); + + CursorMover cursorMover = mover(); + for (int i = 0; i < 6; i++) { + cursorMover.run(); + } + + List movements = movements(); + assertEquals(6, movements.size()); + + // one movement every 3: the 3rd and the 6th are corner clicks + assertFalse(movements.get(0).isClick()); + assertFalse(movements.get(1).isClick()); + assertTrue(movements.get(2).isClick()); + assertFalse(movements.get(3).isClick()); + assertFalse(movements.get(4).isClick()); + assertTrue(movements.get(5).isClick()); + } + + @Test + void cornerClicksAimAtTheMainScreensTopRight() { + // the click must open the notifications, which live on the main + // screen's corner: wherever the movement bounds are, the click + // destination follows the main screen + config.setClickEveryNMoves(3); + Rectangle elsewhere = new Rectangle(5000, 5000, 200, 200); + CursorMover cursorMover = new CursorMover(executor, robotTracker, () -> true, config, + () -> elsewhere, () -> primaryBounds, + new Random(42), () -> CURSOR_AT); + + cursorMover.run(); // iteration 0: a normal movement + cursorMover.run(); // iteration 1: a normal movement + cursorMover.run(); // iteration 2: the corner click + + List movements = movements(); + assertTrue(movements.get(2).isClick()); + // the main screen's top-right corner, 5px inside + assertEquals(100 + 800 - 5, movements.get(2).getDestX()); + assertEquals(50 + 5, movements.get(2).getDestY()); + } + + @Test + void clicksCanBeTurnedOff() { + config.setClickEnabled(false); + + CursorMover cursorMover = mover(); + for (int i = 0; i < 10; i++) { + cursorMover.run(); + } + + for (SingleStepMovementTask movement : movements()) { + assertFalse(movement.isClick()); + } + } + + @Test + void clickCadenceIsReadLive() { + config.setClickEveryNMoves(2); + + CursorMover cursorMover = mover(); + cursorMover.run(); // iteration 0 + cursorMover.run(); // iteration 1: click, cadence 2 + assertTrue(movements().get(1).isClick()); + + // the user loosens the cadence while the automation runs + config.setClickEveryNMoves(4); + cursorMover.run(); // iteration 0 + cursorMover.run(); // iteration 1 + cursorMover.run(); // iteration 2 + cursorMover.run(); // iteration 3: click, cadence 4 + + List movements = movements(); + assertEquals(6, movements.size()); + assertFalse(movements.get(2).isClick()); + assertFalse(movements.get(3).isClick()); + assertFalse(movements.get(4).isClick()); + assertTrue(movements.get(5).isClick()); + } + + /** An executor that records the tasks instead of running them. */ + private static class RecordingExecutor extends AbstractExecutorService { + + final List movements = new ArrayList<>(); + + public void execute(Runnable command) { + movements.add(command); + } + + public void shutdown() { } + + public List shutdownNow() { + return List.of(); + } + + public boolean isShutdown() { + return false; + } + + public boolean isTerminated() { + return false; + } + + public boolean awaitTermination(long timeout, TimeUnit unit) { + return false; + } + } +} \ No newline at end of file