1+ // Adapted from Square's Seismic library.
2+ // Copyright 2010 Square, Inc.
3+ // Licensed under the Apache License, Version 2.0.
4+ // https://github.com/square/seismic
15package io .sentry .android .core ;
26
37import android .content .Context ;
711import android .hardware .SensorManager ;
812import android .os .Handler ;
913import android .os .HandlerThread ;
10- import android .os .SystemClock ;
1114import io .sentry .ILogger ;
1215import io .sentry .SentryLevel ;
13- import java .util .concurrent .atomic .AtomicLong ;
1416import org .jetbrains .annotations .ApiStatus ;
1517import org .jetbrains .annotations .NotNull ;
1618import org .jetbrains .annotations .Nullable ;
2123 * <p>The accelerometer sensor (TYPE_ACCELEROMETER) does NOT require any special permissions on
2224 * Android. The BODY_SENSORS permission is only needed for heart rate and similar body sensors.
2325 *
24- * <p>Requires at least {@link #SHAKE_COUNT_THRESHOLD} accelerometer readings above {@link
25- * #SHAKE_THRESHOLD_GRAVITY} within {@link #SHAKE_WINDOW_MS} to trigger a shake event .
26+ * <p>Uses a rolling sample window: if more than 75% of accelerometer readings in the past 0.5s
27+ * exceed {@link #ACCELERATION_THRESHOLD}, a shake is detected. Based on Square's Seismic library .
2628 *
2729 * <p>Sensor events are delivered on a background {@link HandlerThread} to avoid polluting the main
2830 * thread.
2931 */
3032@ ApiStatus .Internal
3133public final class SentryShakeDetector implements SensorEventListener {
3234
33- private static final float SHAKE_THRESHOLD_GRAVITY = 2.7f ;
34- private static final int SHAKE_WINDOW_MS = 1500 ;
35- private static final int SHAKE_COUNT_THRESHOLD = 2 ;
36- private static final int SHAKE_COOLDOWN_MS = 1000 ;
35+ static final int ACCELERATION_THRESHOLD = 13 ;
3736
3837 private @ Nullable SensorManager sensorManager ;
3938 private @ Nullable Sensor accelerometer ;
4039 private @ Nullable HandlerThread handlerThread ;
4140 private @ Nullable Handler handler ;
42- private final @ NotNull AtomicLong lastShakeTimestamp = new AtomicLong (0 );
4341 private volatile @ Nullable Listener listener ;
4442 private @ NotNull ILogger logger ;
4543
46- private int shakeCount = 0 ;
47- private long firstShakeTimestamp = 0 ;
44+ private final @ NotNull SampleQueue queue = new SampleQueue ();
4845
4946 public interface Listener {
5047 void onShake ();
@@ -94,17 +91,25 @@ public void start(final @NotNull Context context, final @NotNull Listener shakeL
9491
9592 public void stop () {
9693 listener = null ;
97- shakeCount = 0 ;
98- firstShakeTimestamp = 0 ;
9994 if (sensorManager != null ) {
10095 sensorManager .unregisterListener (this );
10196 }
97+ // Post clear to the HandlerThread so all queue access stays single-threaded
98+ final @ Nullable Handler h = handler ;
99+ if (h != null ) {
100+ h .post (
101+ () -> {
102+ //noinspection Convert2MethodRef
103+ queue .clear ();
104+ });
105+ }
102106 }
103107
104108 /** Stops detection and releases the background thread. */
105109 public void close () {
106110 stop ();
107111 if (handlerThread != null ) {
112+ // quitSafely drains pending messages (including the clear posted by stop) before exiting
108113 handlerThread .quitSafely ();
109114 handlerThread = null ;
110115 handler = null ;
@@ -116,32 +121,17 @@ public void onSensorChanged(final @NotNull SensorEvent event) {
116121 if (event .sensor .getType () != Sensor .TYPE_ACCELEROMETER ) {
117122 return ;
118123 }
119- float gX = event .values [0 ] / SensorManager .GRAVITY_EARTH ;
120- float gY = event .values [1 ] / SensorManager .GRAVITY_EARTH ;
121- float gZ = event .values [2 ] / SensorManager .GRAVITY_EARTH ;
122- double gForceSquared = gX * gX + gY * gY + gZ * gZ ;
123- if (gForceSquared > SHAKE_THRESHOLD_GRAVITY * SHAKE_THRESHOLD_GRAVITY ) {
124- long now = SystemClock .elapsedRealtime ();
125-
126- // Reset counter if outside the detection window
127- if (now - firstShakeTimestamp > SHAKE_WINDOW_MS ) {
128- shakeCount = 0 ;
129- firstShakeTimestamp = now ;
130- }
131-
132- shakeCount ++;
133-
134- if (shakeCount >= SHAKE_COUNT_THRESHOLD ) {
135- // Enforce cooldown so we don't fire repeatedly
136- long lastShake = lastShakeTimestamp .get ();
137- if (now - lastShake > SHAKE_COOLDOWN_MS ) {
138- lastShakeTimestamp .set (now );
139- shakeCount = 0 ;
140- final @ Nullable Listener currentListener = listener ;
141- if (currentListener != null ) {
142- currentListener .onShake ();
143- }
144- }
124+ final float ax = event .values [0 ];
125+ final float ay = event .values [1 ];
126+ final float az = event .values [2 ];
127+ final boolean accelerating = Math .sqrt (ax * ax + ay * ay + az * az ) > ACCELERATION_THRESHOLD ;
128+
129+ queue .add (event .timestamp , accelerating );
130+ if (queue .isShaking ()) {
131+ queue .clear ();
132+ final @ Nullable Listener currentListener = listener ;
133+ if (currentListener != null ) {
134+ currentListener .onShake ();
145135 }
146136 }
147137 }
@@ -150,4 +140,96 @@ public void onSensorChanged(final @NotNull SensorEvent event) {
150140 public void onAccuracyChanged (final @ NotNull Sensor sensor , final int accuracy ) {
151141 // Not needed for shake detection.
152142 }
143+
144+ static class SampleQueue {
145+ private static final long MAX_WINDOW_SIZE_NS = 500_000_000L ; // 0.5s
146+ private static final long MIN_WINDOW_SIZE_NS = MAX_WINDOW_SIZE_NS >> 1 ; // 0.25s
147+ private static final int MIN_QUEUE_SIZE = 4 ;
148+
149+ private final @ NotNull SamplePool pool = new SamplePool ();
150+ private @ Nullable Sample oldest ;
151+ private @ Nullable Sample newest ;
152+ private int sampleCount ;
153+ private int acceleratingCount ;
154+
155+ void add (final long timestamp , final boolean accelerating ) {
156+ purge (timestamp - MAX_WINDOW_SIZE_NS );
157+
158+ final @ NotNull Sample added = pool .acquire ();
159+ added .timestamp = timestamp ;
160+ added .accelerating = accelerating ;
161+ added .next = null ;
162+ if (newest != null ) {
163+ newest .next = added ;
164+ }
165+ newest = added ;
166+ if (oldest == null ) {
167+ oldest = added ;
168+ }
169+
170+ sampleCount ++;
171+ if (accelerating ) {
172+ acceleratingCount ++;
173+ }
174+ }
175+
176+ void clear () {
177+ while (oldest != null ) {
178+ final @ NotNull Sample removed = oldest ;
179+ oldest = removed .next ;
180+ pool .release (removed );
181+ }
182+ newest = null ;
183+ sampleCount = 0 ;
184+ acceleratingCount = 0 ;
185+ }
186+
187+ private void purge (final long cutoff ) {
188+ while (sampleCount >= MIN_QUEUE_SIZE && oldest != null && cutoff - oldest .timestamp > 0 ) {
189+ final @ NotNull Sample removed = oldest ;
190+ if (removed .accelerating ) {
191+ acceleratingCount --;
192+ }
193+ sampleCount --;
194+ oldest = removed .next ;
195+ if (oldest == null ) {
196+ newest = null ;
197+ }
198+ pool .release (removed );
199+ }
200+ }
201+
202+ boolean isShaking () {
203+ return newest != null
204+ && oldest != null
205+ && newest .timestamp - oldest .timestamp >= MIN_WINDOW_SIZE_NS
206+ && acceleratingCount >= (sampleCount >> 1 ) + (sampleCount >> 2 );
207+ }
208+ }
209+
210+ static class Sample {
211+ long timestamp ;
212+ boolean accelerating ;
213+ @ Nullable Sample next ;
214+ }
215+
216+ static class SamplePool {
217+ private @ Nullable Sample head ;
218+
219+ @ NotNull
220+ Sample acquire () {
221+ Sample acquired = head ;
222+ if (acquired == null ) {
223+ acquired = new Sample ();
224+ } else {
225+ head = acquired .next ;
226+ }
227+ return acquired ;
228+ }
229+
230+ void release (final @ NotNull Sample sample ) {
231+ sample .next = head ;
232+ head = sample ;
233+ }
234+ }
153235}
0 commit comments