From d0b04a79a36aa35917562330c0d690775992ee5c Mon Sep 17 00:00:00 2001 From: kharrison-jp Date: Thu, 19 Mar 2026 12:50:41 -0600 Subject: [PATCH 1/3] Fix NPE in handleTouchMove when activePath is null --- .../com/jackpocket/scratchoff/paths/ScratchPathManager.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java b/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java index b04897b..f20937b 100644 --- a/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java +++ b/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java @@ -82,6 +82,10 @@ protected void handleTouchMove(int pointerIndex, float x, float y) { Path activePath = this.activePaths[pointerIndex]; + if (activePath == null) { + return; + } + // If the active Path has been drawn, it would have been reset to an empty state if (activePath.isEmpty()) { activePath.moveTo(x, y); From 4d3ec2eb6a6d9a63ab2e02b8cfb0bc201f693fe6 Mon Sep 17 00:00:00 2001 From: kharrison-jp Date: Thu, 19 Mar 2026 14:12:34 -0600 Subject: [PATCH 2/3] Create path if active path is null --- .../com/jackpocket/scratchoff/paths/ScratchPathManager.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java b/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java index f20937b..9ba040a 100644 --- a/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java +++ b/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java @@ -83,7 +83,8 @@ protected void handleTouchMove(int pointerIndex, float x, float y) { Path activePath = this.activePaths[pointerIndex]; if (activePath == null) { - return; + createPath(pointerIndex, x, y); + activePath = this.activePaths[pointerIndex]; } // If the active Path has been drawn, it would have been reset to an empty state From c9ca291a8afccafa172ce0f565ac90b297e7913f Mon Sep 17 00:00:00 2001 From: kharrison-jp Date: Fri, 20 Mar 2026 09:29:06 -0600 Subject: [PATCH 3/3] Refine configuration options & add unit tests --- .../scratchoff/ScratchableLayoutDrawer.java | 11 +++++++++ .../scratchoff/ScratchoffController.java | 21 ++++++++++++++++- .../scratchoff/paths/ScratchPathManager.java | 14 +++++++++++ .../paths/ScratchPathManagerTests.kt | 23 +++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchableLayoutDrawer.java b/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchableLayoutDrawer.java index a870a0f..7803c15 100644 --- a/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchableLayoutDrawer.java +++ b/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchableLayoutDrawer.java @@ -60,6 +60,8 @@ public interface Delegate { private Long activeClearTag = 0L; + private boolean activePathRecoveryEnabled = false; + private WeakReference initializeLayoutTarget = new WeakReference<>(null); private ViewTreeObserver.OnGlobalLayoutListener initializationGlobalLayoutListener; @@ -443,4 +445,13 @@ public ScratchableLayoutDrawer setClearAnimationInterpolator(Interpolator clearA return this; } + + @SuppressWarnings("WeakerAccess") + public ScratchableLayoutDrawer setActivePathRecoveryEnabled(boolean activePathRecoveryEnabled) { + this.activePathRecoveryEnabled = activePathRecoveryEnabled; + + pathManager.setActivePathRecoveryEnabled(activePathRecoveryEnabled); + + return this; + } } diff --git a/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchoffController.java b/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchoffController.java index 975195a..c5a5d66 100644 --- a/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchoffController.java +++ b/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchoffController.java @@ -79,6 +79,8 @@ public interface ThresholdChangedListener { private final LinkedBlockingQueue history = new LinkedBlockingQueue(); private boolean stateRestorationEnabled; + private boolean activePathRecoveryEnabled = false; + /** * Create a new {@link ScratchoffController} instance targeting a scratchable layout. */ @@ -152,7 +154,8 @@ public ScratchoffController attach() { protected ScratchableLayoutDrawer createLayoutDrawer() { return new ScratchableLayoutDrawer(this) .setClearAnimationDurationMs(clearAnimationDurationMs) - .setClearAnimationInterpolator(clearAnimationInterpolator); + .setClearAnimationInterpolator(clearAnimationInterpolator) + .setActivePathRecoveryEnabled(activePathRecoveryEnabled); } protected ScratchoffThresholdProcessor createThresholdProcessor() { @@ -480,6 +483,22 @@ public ScratchoffController setStateRestorationEnabled(boolean stateRestorationE return this; } + /** + * Set whether or not to recover from a null active {@link android.graphics.Path} during + * a touch-move event, instead of allowing a {@link NullPointerException} to be thrown. + * This can occur when a move event arrives without a preceding down event, + * e.g. due to race conditions during layout callbacks. + * The default for this value is false for the original (crashing) behavior. + */ + public ScratchoffController setActivePathRecoveryEnabled( + boolean activePathRecoveryEnabled + ) { + + this.activePathRecoveryEnabled = activePathRecoveryEnabled; + + return this; + } + public View getViewBehind() { return behindView.get(); } diff --git a/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java b/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java index 9ba040a..8b707e2 100644 --- a/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java +++ b/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java @@ -18,6 +18,8 @@ public class ScratchPathManager implements ScratchPathPointsAggregator { private final ArrayList paths = new ArrayList<>(); private float scale = 1f; + private boolean activePathRecoveryEnabled = false; + public ScratchPathManager() { } @@ -27,6 +29,12 @@ public ScratchPathManager setScale(float scale) { return this; } + public ScratchPathManager setActivePathRecoveryEnabled(boolean activePathRecoveryEnabled) { + this.activePathRecoveryEnabled = activePathRecoveryEnabled; + + return this; + } + @Override public void addScratchPathPoints(Collection events) { for (ScratchPathPoint event : events) { @@ -82,7 +90,13 @@ protected void handleTouchMove(int pointerIndex, float x, float y) { Path activePath = this.activePaths[pointerIndex]; + // Recover from a null activePath caused by a move event arriving without + // a preceding down event (e.g. due to race conditions during layout callbacks) if (activePath == null) { + if (!activePathRecoveryEnabled) { + return; + } + createPath(pointerIndex, x, y); activePath = this.activePaths[pointerIndex]; } diff --git a/scratchoff/src/test/java/com/jackpocket/scratchoff/paths/ScratchPathManagerTests.kt b/scratchoff/src/test/java/com/jackpocket/scratchoff/paths/ScratchPathManagerTests.kt index 659aea9..f2ccc5e 100644 --- a/scratchoff/src/test/java/com/jackpocket/scratchoff/paths/ScratchPathManagerTests.kt +++ b/scratchoff/src/test/java/com/jackpocket/scratchoff/paths/ScratchPathManagerTests.kt @@ -61,6 +61,29 @@ class ScratchPathManagerTests { assertEquals(value, pathBounds.isEmpty) } + @Test + fun testMoveWithNullActivePathIsIgnoredWhenRecoveryDisabled() { + val manager = ScratchPathManager() + + assertEquals(0, manager.paths.size) + + manager.handleTouchMove(0, 1f, 1f) + + assertEquals(0, manager.paths.size) + } + + @Test + fun testMoveWithNullActivePathCreatesPathWhenRecoveryEnabled() { + val manager = ScratchPathManager() + manager.setActivePathRecoveryEnabled(true) + + assertEquals(0, manager.paths.size) + + manager.handleTouchMove(0, 1f, 1f) + + assertEquals(1, manager.paths.size) + } + @Test fun testTouchDownAndMovePassthroughIncludesScalar() { val manager = object: ScratchPathManager() {