From c95e660c2c9cca725eb3c3d98c3a7f29a7b1c4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beatrice=20Dellac=C3=A0?= Date: Tue, 29 Sep 2026 11:54:04 +0200 Subject: [PATCH] fix away detection, pause while user is present --- readme.md | 2 + .../autosqueal/controls/CursorMover.java | 17 +- .../controls/RobotMouseTracker.java | 37 ++++ .../controls/SingleStepMovementTask.java | 24 ++- .../listener/CursorMoveListener.java | 87 ++++++--- .../beatrice/autosqueal/ui/MainWindow.java | 16 +- .../controls/RobotMouseTrackerTest.java | 31 ++++ .../listener/CursorMoveListenerTest.java | 167 ++++++++++++++++++ 8 files changed, 347 insertions(+), 34 deletions(-) create mode 100644 src/main/java/wtf/beatrice/autosqueal/controls/RobotMouseTracker.java create mode 100644 src/test/java/wtf/beatrice/autosqueal/controls/RobotMouseTrackerTest.java create mode 100644 src/test/java/wtf/beatrice/autosqueal/listener/CursorMoveListenerTest.java diff --git a/readme.md b/readme.md index 5c43cb0..01b2dd9 100644 --- a/readme.md +++ b/readme.md @@ -8,6 +8,8 @@ *little java tool to automatically perform mouse actions* + +*once started, it only acts when you are away: it detects your activity and pauses itself while you are using the machine, so it never fights you for the mouse.* ## supported systems | system | support | diff --git a/src/main/java/wtf/beatrice/autosqueal/controls/CursorMover.java b/src/main/java/wtf/beatrice/autosqueal/controls/CursorMover.java index 3f0b55d..bcdbd5e 100644 --- a/src/main/java/wtf/beatrice/autosqueal/controls/CursorMover.java +++ b/src/main/java/wtf/beatrice/autosqueal/controls/CursorMover.java @@ -8,11 +8,15 @@ import java.awt.*; import java.security.SecureRandom; import java.util.Random; import java.util.concurrent.ScheduledExecutorService; +import java.util.function.BooleanSupplier; /** * 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. * + * 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. + * * The movement itself is executed by a {@link SingleStepMovementTask}, which * re-schedules itself on the shared scheduler until it reaches its destination. */ @@ -24,16 +28,25 @@ public class CursorMover implements Runnable private final Random random = new SecureRandom(); private final ScheduledExecutorService scheduler; + private final RobotMouseTracker robotTracker; + private final BooleanSupplier isUserAway; private int iteration = 0; - public CursorMover(ScheduledExecutorService scheduler) { + public CursorMover(ScheduledExecutorService scheduler, RobotMouseTracker robotTracker, BooleanSupplier isUserAway) { this.scheduler = scheduler; + this.robotTracker = robotTracker; + this.isUserAway = isUserAway; } @Override public void run() { + if (!isUserAway.getAsBoolean()) { + LOGGER.debug("User is present, skipping movement"); + return; + } + Point location = MouseInfo.getPointerInfo().getLocation(); LOGGER.info("Starting coordinates: {}, {}", location.x, location.y); @@ -58,7 +71,7 @@ public class CursorMover implements Runnable LOGGER.info("Destination coordinates: {}, {}", destX, destY); try { - SingleStepMovementTask movement = new SingleStepMovementTask(scheduler, destX, destY, click); + SingleStepMovementTask movement = new SingleStepMovementTask(scheduler, robotTracker, destX, destY, click); scheduler.execute(movement); } catch (AWTException ex) { LOGGER.error("Could not start movement task", ex); diff --git a/src/main/java/wtf/beatrice/autosqueal/controls/RobotMouseTracker.java b/src/main/java/wtf/beatrice/autosqueal/controls/RobotMouseTracker.java new file mode 100644 index 0000000..25446d1 --- /dev/null +++ b/src/main/java/wtf/beatrice/autosqueal/controls/RobotMouseTracker.java @@ -0,0 +1,37 @@ +package wtf.beatrice.autosqueal.controls; + +/** + * Keeps track of the mouse movements the app itself performs, so that other + * components (like the away detection) can tell them apart from the user's. + * + * Written by the movement tasks, read by whoever needs to know. + */ +public class RobotMouseTracker { + + private volatile boolean moving = false; + private volatile int lastX = Integer.MIN_VALUE; + private volatile int lastY = Integer.MIN_VALUE; + + /** Marks the start of a movement performed by the app. */ + public void moveStarted() { + moving = true; + } + + /** + * Marks the end of a movement performed by the app, which left the cursor + * at the given coordinates. + */ + public void moveEnded(int x, int y) { + lastX = x; + lastY = y; + moving = false; + } + + public boolean isMoving() { + return moving; + } + + public boolean isLastPosition(int x, int y) { + return x == lastX && y == lastY; + } +} \ No newline at end of file diff --git a/src/main/java/wtf/beatrice/autosqueal/controls/SingleStepMovementTask.java b/src/main/java/wtf/beatrice/autosqueal/controls/SingleStepMovementTask.java index 907cf4f..fe16dca 100644 --- a/src/main/java/wtf/beatrice/autosqueal/controls/SingleStepMovementTask.java +++ b/src/main/java/wtf/beatrice/autosqueal/controls/SingleStepMovementTask.java @@ -15,6 +15,9 @@ import java.util.concurrent.TimeUnit; * * When it gets there, it can optionally perform a double click, scheduled as * separate one-shot actions so that the scheduler is never blocked. + * + * All the movements it performs are reported to the {@link RobotMouseTracker}, + * so that the away detection can tell them apart from the user's. */ public class SingleStepMovementTask implements Runnable { @@ -24,6 +27,7 @@ public class SingleStepMovementTask implements Runnable { private static final long STEP_DELAY_MILLISECONDS = 2L; private final ScheduledExecutorService scheduler; + private final RobotMouseTracker robotTracker; private final Robot robot; private final int destX; private final int destY; @@ -34,9 +38,10 @@ public class SingleStepMovementTask implements Runnable { private float stepX; private float stepY; - public SingleStepMovementTask(ScheduledExecutorService scheduler, int destinationX, int destinationY, boolean click) throws AWTException { + public SingleStepMovementTask(ScheduledExecutorService scheduler, RobotMouseTracker robotTracker, int destinationX, int destinationY, boolean click) throws AWTException { this.scheduler = scheduler; + this.robotTracker = robotTracker; this.destX = destinationX; this.destY = destinationY; this.click = click; @@ -60,6 +65,20 @@ public class SingleStepMovementTask implements Runnable { @Override public void run() { + // any position change from here on is the app's doing + robotTracker.moveStarted(); + + try { + tick(); + } catch (RuntimeException ex) { + // whatever happened, don't leave the tracker stuck on "moving" + LOGGER.error("Unexpected error while moving the cursor", ex); + robotTracker.moveEnded(Math.round(currentX), Math.round(currentY)); + } + } + + private void tick() { + if (hasReachedDestination()) { onDestinationReached(); return; @@ -81,6 +100,7 @@ public class SingleStepMovementTask implements Runnable { // the scheduler was shut down mid-movement: the automation was // stopped, so just end the movement chain here LOGGER.debug("Movement interrupted: scheduler is shut down"); + robotTracker.moveEnded(Math.round(currentX), Math.round(currentY)); } } @@ -91,6 +111,8 @@ public class SingleStepMovementTask implements Runnable { private void onDestinationReached() { LOGGER.info("Reached destination [{}, {}], stopping mover", destX, destY); + robotTracker.moveEnded(destX, destY); + if (click) { scheduleClickSequence(); } diff --git a/src/main/java/wtf/beatrice/autosqueal/listener/CursorMoveListener.java b/src/main/java/wtf/beatrice/autosqueal/listener/CursorMoveListener.java index fde5f4a..04a06ec 100644 --- a/src/main/java/wtf/beatrice/autosqueal/listener/CursorMoveListener.java +++ b/src/main/java/wtf/beatrice/autosqueal/listener/CursorMoveListener.java @@ -2,51 +2,86 @@ package wtf.beatrice.autosqueal.listener; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import wtf.beatrice.autosqueal.controls.RobotMouseTracker; import java.awt.*; -import java.util.TimerTask; +import java.util.function.Supplier; -public class CursorMoveListener extends TimerTask { - - private int oldX; - private int oldY; - - private int loops; - private boolean isUserAway; +/** + * Watches the cursor position and tracks whether the user is away: if the cursor + * stays still for long enough — ignoring the movements the app performs on its + * own — the user is considered away. + */ +public class CursorMoveListener implements Runnable { private static final Logger LOGGER = LogManager.getLogger(CursorMoveListener.class); + /** Number of consecutive still polls after which the user is considered away. */ + private static final int LOOPS_BEFORE_AWAY = 30; - public CursorMoveListener() { - oldX = 0; - oldY = 0; - loops = 0; + private final RobotMouseTracker robotTracker; + private final Supplier currentPosition; + + private int lastSeenX; + private int lastSeenY; + private boolean firstPoll = true; + private int loops = 0; + private volatile boolean userAway = false; + + public CursorMoveListener(RobotMouseTracker robotTracker) { + this(robotTracker, () -> MouseInfo.getPointerInfo().getLocation()); + } + + CursorMoveListener(RobotMouseTracker robotTracker, Supplier currentPosition) { + this.robotTracker = robotTracker; + this.currentPosition = currentPosition; } @Override public void run() { - int newX = MouseInfo.getPointerInfo().getLocation().x; - int newY = MouseInfo.getPointerInfo().getLocation().y; - if(newX != oldX || newY != oldY) { - // cursor has been moved + Point location = currentPosition.get(); + + if (firstPoll) { + // seed the comparison baseline, so that the first poll is not + // mistaken for a movement + firstPoll = false; + lastSeenX = location.x; + lastSeenY = location.y; + return; + } + + boolean positionChanged = location.x != lastSeenX || location.y != lastSeenY; + boolean userMoved = positionChanged && !isAppMovement(location); + + if (userMoved) { + if (userAway) { + LOGGER.info("User is no longer away!"); + } loops = 0; - LOGGER.info("User is no longer away!"); - } else { - if (loops < 30) { - loops++; - } else { + } else if (loops < LOOPS_BEFORE_AWAY) { + loops++; + if (loops == LOOPS_BEFORE_AWAY) { LOGGER.info("User is away!"); } } - isUserAway = loops >= 30; + userAway = loops >= LOOPS_BEFORE_AWAY; - oldX = newX; - oldY = newY; + lastSeenX = location.x; + lastSeenY = location.y; + } + + /** + * Whether a position change was (most likely) caused by the app itself: + * either a movement is currently in progress, or the cursor is exactly + * where the app last left it. + */ + private boolean isAppMovement(Point location) { + return robotTracker.isMoving() || robotTracker.isLastPosition(location.x, location.y); } public boolean isUserAway() { - return isUserAway; + return userAway; } -} +} \ 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 2f359d7..04976d6 100644 --- a/src/main/java/wtf/beatrice/autosqueal/ui/MainWindow.java +++ b/src/main/java/wtf/beatrice/autosqueal/ui/MainWindow.java @@ -5,6 +5,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import wtf.beatrice.autosqueal.Main; import wtf.beatrice.autosqueal.controls.CursorMover; +import wtf.beatrice.autosqueal.controls.RobotMouseTracker; +import wtf.beatrice.autosqueal.listener.CursorMoveListener; import wtf.beatrice.autosqueal.util.RunnerUtil; import wtf.beatrice.autosqueal.util.SystemUtil; @@ -25,6 +27,7 @@ public class MainWindow private static final int WINDOW_WIDTH = 800; private static final long AUTOMATION_START_DELAY_SECONDS = 1L; + private static final long AWAY_POLL_INTERVAL_SECONDS = 1L; private final JFrame frame = new JFrame(); private Button toggleButton; @@ -144,12 +147,15 @@ public class MainWindow thread.setDaemon(true); return thread; }); - cursorMover = new CursorMover(scheduler); - // note: away-detection (CursorMoveListener) is intentionally not scheduled here. - // as it is, it cannot tell the user's movements apart from the movements the app - // itself makes, so it would always think the user is present. it will be reworked - // and re-enabled in a future iteration. + // away-detection: polls the cursor position and ignores the movements + // the app performs itself, as reported by the robot mouse tracker. + // the automation only moves the mouse when the user is away. + RobotMouseTracker robotTracker = new RobotMouseTracker(); + CursorMoveListener cursorMoveListener = new CursorMoveListener(robotTracker); + scheduler.scheduleWithFixedDelay(cursorMoveListener, 0L, AWAY_POLL_INTERVAL_SECONDS, TimeUnit.SECONDS); + + cursorMover = new CursorMover(scheduler, robotTracker, cursorMoveListener::isUserAway); scheduler.scheduleWithFixedDelay(cursorMover, AUTOMATION_START_DELAY_SECONDS, RunnerUtil.SECONDS_BETWEEN_MOVES, diff --git a/src/test/java/wtf/beatrice/autosqueal/controls/RobotMouseTrackerTest.java b/src/test/java/wtf/beatrice/autosqueal/controls/RobotMouseTrackerTest.java new file mode 100644 index 0000000..7b114a6 --- /dev/null +++ b/src/test/java/wtf/beatrice/autosqueal/controls/RobotMouseTrackerTest.java @@ -0,0 +1,31 @@ +package wtf.beatrice.autosqueal.controls; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RobotMouseTrackerTest +{ + @Test + void startsIdle() { + RobotMouseTracker tracker = new RobotMouseTracker(); + + assertFalse(tracker.isMoving()); + assertFalse(tracker.isLastPosition(10, 20)); + } + + @Test + void tracksOngoingMovement() { + RobotMouseTracker tracker = new RobotMouseTracker(); + + tracker.moveStarted(); + assertTrue(tracker.isMoving()); + + tracker.moveEnded(15, 25); + assertFalse(tracker.isMoving()); + assertTrue(tracker.isLastPosition(15, 25)); + assertFalse(tracker.isLastPosition(15, 26)); + assertFalse(tracker.isLastPosition(25, 15)); + } +} \ No newline at end of file diff --git a/src/test/java/wtf/beatrice/autosqueal/listener/CursorMoveListenerTest.java b/src/test/java/wtf/beatrice/autosqueal/listener/CursorMoveListenerTest.java new file mode 100644 index 0000000..405093c --- /dev/null +++ b/src/test/java/wtf/beatrice/autosqueal/listener/CursorMoveListenerTest.java @@ -0,0 +1,167 @@ +package wtf.beatrice.autosqueal.listener; + +import org.junit.jupiter.api.Test; +import wtf.beatrice.autosqueal.controls.RobotMouseTracker; + +import java.awt.*; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CursorMoveListenerTest +{ + private static final int LOOPS_BEFORE_AWAY = 30; + + private final RobotMouseTracker robotTracker = new RobotMouseTracker(); + + /** Builds a listener that reads scripted cursor positions, one per poll. */ + private CursorMoveListener scriptedListener(Point... positions) { + Queue positionQueue = new ArrayDeque<>(java.util.List.of(positions)); + return new CursorMoveListener(robotTracker, positionQueue::remove); + } + + private static Point at(int x, int y) { + return new Point(x, y); + } + + @Test + void userIsAwayAfterThirtyStillSeconds() { + Point[] still = new Point[LOOPS_BEFORE_AWAY + 2]; + for (int i = 0; i < still.length; i++) { + still[i] = at(100, 100); + } + CursorMoveListener listener = scriptedListener(still); + + // the first poll seeds the baseline and does not count + for (int i = 0; i < LOOPS_BEFORE_AWAY; i++) { + listener.run(); + assertFalse(listener.isUserAway()); + } + + listener.run(); + assertTrue(listener.isUserAway()); + } + + @Test + void firstPollDoesNotCountAsMovement() { + // the very first position read must seed the baseline: starting anywhere + // else would have counted as a user movement and reset the away timer + CursorMoveListener listener = scriptedListener(at(500, 300), at(500, 300)); + + listener.run(); + listener.run(); + + assertFalse(listener.isUserAway()); + // still at (500, 300), so the baseline was seeded correctly + } + + @Test + void userMovementResetsTheAwayTimer() { + List positions = new ArrayList<>(); + positions.add(at(100, 100)); // baseline + for (int i = 0; i < 5; i++) positions.add(at(100, 100)); // 5 still polls + positions.add(at(140, 100)); // the user moves the mouse + for (int i = 0; i < LOOPS_BEFORE_AWAY; i++) positions.add(at(140, 100)); // then it stays put + + CursorMoveListener listener = scriptedListener(positions.toArray(Point[]::new)); + + listener.run(); // seed the baseline + for (int i = 0; i < 5; i++) listener.run(); // loops = 5 + assertFalse(listener.isUserAway()); + + listener.run(); // the user moved: the away timer resets + assertFalse(listener.isUserAway()); + + // ...and it still takes 30 more still polls to be away again + for (int i = 0; i < LOOPS_BEFORE_AWAY - 1; i++) listener.run(); // loops = 29 + assertFalse(listener.isUserAway()); + + listener.run(); // loops = 30 + assertTrue(listener.isUserAway()); + } + + @Test + void appMovementsDoNotKeepTheUserPresent() { + // this is the regression the away detection was disconnected for: the + // app moves the mouse every few seconds, and before the fix those + // movements were indistinguishable from the user's, so the app would + // always think the user was present + + // every poll finds the cursor where the app last left it + List positions = new ArrayList<>(); + positions.add(at(100, 100)); // baseline + for (int i = 1; i <= 40; i++) positions.add(at(100 + i, 100)); + + CursorMoveListener listener = scriptedListener(positions.toArray(Point[]::new)); + + listener.run(); // seed the baseline + + for (int i = 1; i <= 40; i++) { + // the app just finished a movement, leaving the cursor exactly + // where this poll will find it + robotTracker.moveEnded(100 + i, 100); + listener.run(); + } + + // every position change was the app's own doing: the user is away + assertTrue(listener.isUserAway()); + } + + @Test + void ongoingAppMovementsDoNotKeepTheUserPresent() { + // same as above, but the polls happen while the app is mid-movement, + // with the cursor transiently at intermediate positions + Point[] positions = new Point[41]; + positions[0] = at(100, 100); + for (int i = 1; i < positions.length; i++) { + positions[i] = at(100 + (i / 2), 100); + } + CursorMoveListener listener = scriptedListener(positions); + + robotTracker.moveStarted(); + for (int i = 1; i < positions.length; i++) { + listener.run(); + } + + assertTrue(listener.isUserAway()); + } + + @Test + void userComingBackIsDetectedWhileTheAppIsWiggling() { + // the app keeps wiggling while the user is away; when the user comes + // back and moves the mouse, the away state must end + + // polls: baseline, 30 still polls, two app wiggles, then the user's move + Point[] positions = new Point[LOOPS_BEFORE_AWAY + 4]; + for (int i = 0; i <= LOOPS_BEFORE_AWAY; i++) { + positions[i] = at(100, 100); + } + positions[LOOPS_BEFORE_AWAY + 1] = at(150, 100); + positions[LOOPS_BEFORE_AWAY + 2] = at(200, 100); + positions[LOOPS_BEFORE_AWAY + 3] = at(333, 100); + CursorMoveListener listener = scriptedListener(positions); + + // baseline + 30 still polls: the user goes away + for (int i = 0; i <= LOOPS_BEFORE_AWAY; i++) { + listener.run(); + } + assertTrue(listener.isUserAway()); + + // the app wiggles: each movement is discounted, the user stays "away" + robotTracker.moveEnded(150, 100); + listener.run(); + assertTrue(listener.isUserAway()); + + robotTracker.moveEnded(200, 100); + listener.run(); + assertTrue(listener.isUserAway()); + + // the user comes back and grabs the mouse + listener.run(); + assertFalse(listener.isUserAway()); + } +} \ No newline at end of file