Skip to content

Commit 7b2a325

Browse files
authored
Merge 0141709 into 8558cac
2 parents 8558cac + 0141709 commit 7b2a325

6 files changed

Lines changed: 239 additions & 56 deletions

File tree

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,17 @@ The repository is organized into multiple modules:
144144
4. New features must be **opt-in by default** - extend `SentryOptions` or similar Option classes with getters/setters
145145
5. Consider backwards compatibility
146146

147+
### Third-Party Code Attribution
148+
When adapting code from third-party libraries:
149+
1. Add a license header at the top of the adapted file (before the `package` statement):
150+
```java
151+
// Adapted from <Library Name>.
152+
// Copyright <year> <copyright holder>.
153+
// Licensed under the <License Name>.
154+
// <source URL>
155+
```
156+
2. Add a full attribution entry to `THIRD_PARTY_NOTICES.md` following the existing format (Source, License, Copyright, Scope, full license text)
157+
147158
### Getting PR Information
148159

149160
Use `gh pr view` to get PR details from the current branch. This is needed when adding changelog entries, which require the PR number.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
### Fixes
2424

2525
- Fix soft input keyboard not being shown on the Feedback form ([#5359](https://github.com/getsentry/sentry-java/pull/5359))
26+
- Fix shake-to-report not triggering on some devices due to high acceleration threshold ([#5366](https://github.com/getsentry/sentry-java/pull/5366))
27+
- Fix feedback form retaining previous message when shown again via shake ([#5366](https://github.com/getsentry/sentry-java/pull/5366))
2628

2729
### Dependencies
2830

THIRD_PARTY_NOTICES.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,34 @@ limitations under the License.
118118

119119
---
120120

121+
## Square — Seismic (Apache 2.0)
122+
123+
**Source:** https://github.com/square/seismic<br>
124+
**License:** Apache License 2.0<br>
125+
**Copyright:** Copyright 2010 Square, Inc.
126+
127+
### Scope
128+
129+
The Sentry Java SDK includes an adapted version of Square's Seismic shake detection algorithm. The rolling sample window approach and `SampleQueue`/`SamplePool` data structures in `io.sentry.android.core.SentryShakeDetector` are based on Seismic's `ShakeDetector`.
130+
131+
```
132+
Copyright 2010 Square, Inc.
133+
134+
Licensed under the Apache License, Version 2.0 (the "License");
135+
you may not use this file except in compliance with the License.
136+
You may obtain a copy of the License at
137+
138+
http://www.apache.org/licenses/LICENSE-2.0
139+
140+
Unless required by applicable law or agreed to in writing, software
141+
distributed under the License is distributed on an "AS IS" BASIS,
142+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
143+
See the License for the specific language governing permissions and
144+
limitations under the License.
145+
```
146+
147+
---
148+
121149
## Square — Curtains (Apache 2.0)
122150

123151
**Source:** https://github.com/square/curtains (v1.2.5)<br>

sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java

Lines changed: 121 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
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
15
package io.sentry.android.core;
26

37
import android.content.Context;
@@ -7,10 +11,8 @@
711
import android.hardware.SensorManager;
812
import android.os.Handler;
913
import android.os.HandlerThread;
10-
import android.os.SystemClock;
1114
import io.sentry.ILogger;
1215
import io.sentry.SentryLevel;
13-
import java.util.concurrent.atomic.AtomicLong;
1416
import org.jetbrains.annotations.ApiStatus;
1517
import org.jetbrains.annotations.NotNull;
1618
import org.jetbrains.annotations.Nullable;
@@ -21,30 +23,25 @@
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
3133
public 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
}

sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,12 @@ public void setOnDismissListener(final @Nullable OnDismissListener listener) {
324324
@Override
325325
protected void onStart() {
326326
super.onStart();
327+
// Clear the message field so subsequent show() calls start with a fresh form
328+
final @NotNull EditText edtMessage =
329+
findViewById(R.id.sentry_dialog_user_feedback_edt_description);
330+
edtMessage.getText().clear();
331+
edtMessage.setError(null);
332+
327333
final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions();
328334
final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions();
329335
final @Nullable Runnable onFormOpen = feedbackOptions.getOnFormOpen();

0 commit comments

Comments
 (0)