Skip to content

Commit 2d4e9fb

Browse files
committed
Keep lifecycle callbacks registered while a dialog is tracked so the strong dialog reference cannot leak its activity
1 parent b7096cb commit 2d4e9fb

2 files changed

Lines changed: 106 additions & 12 deletions

File tree

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

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@ public final class FeedbackShakeIntegration
4343
private volatile boolean enabled = false;
4444
private boolean detecting = false;
4545
// Strong reference on purpose: for a per-form opt-in the caller may not retain the created
46-
// form, so the controller must keep it alive to be able to show it on shake. Cleared when the
47-
// host activity is destroyed, when replaced by another dialog, and on close().
46+
// form, so the controller must keep it alive to be able to show it on shake. Lifecycle
47+
// callbacks stay registered as long as a dialog is tracked, so the reference is guaranteed
48+
// to be cleared once the host activity goes away (or another activity is created on top).
4849
private volatile @Nullable SentryFeedbackOptions.IShakeDialog trackedDialog;
4950
private boolean dialogRequestedShakeDetection = false;
51+
private boolean callbacksRegistered = false;
5052
private volatile @Nullable WeakReference<Activity> currentActivityRef;
5153

5254
public FeedbackShakeIntegration(final @NotNull Application application) {
@@ -113,6 +115,7 @@ public synchronized void setDialog(
113115
if (!enabled) {
114116
stopDetecting();
115117
}
118+
updateCallbackRegistration();
116119
return;
117120
}
118121
trackedDialog = dialog;
@@ -123,6 +126,24 @@ public synchronized void setDialog(
123126
// The previous dialog may have been the only reason detection was running.
124127
stopDetecting();
125128
}
129+
// Even without detection, keep listening for the tracked dialog's host activity being
130+
// destroyed, so the strong dialog reference can never outlive it (no activity leak).
131+
updateCallbackRegistration();
132+
}
133+
134+
/**
135+
* Lifecycle callbacks are needed while shake detection runs (to follow the current activity) or
136+
* while a dialog is tracked (to release it when its host activity goes away).
137+
*/
138+
private synchronized void updateCallbackRegistration() {
139+
final boolean needed = detecting || trackedDialog != null;
140+
if (needed && !callbacksRegistered) {
141+
callbacksRegistered = true;
142+
application.registerActivityLifecycleCallbacks(this);
143+
} else if (!needed && callbacksRegistered) {
144+
callbacksRegistered = false;
145+
application.unregisterActivityLifecycleCallbacks(this);
146+
}
126147
}
127148

128149
private synchronized void startDetecting(final @NotNull SentryAndroidOptions options) {
@@ -150,7 +171,7 @@ private synchronized void startDetecting(final @NotNull SentryAndroidOptions opt
150171
}
151172

152173
addIntegrationToSdkVersion("FeedbackShake");
153-
application.registerActivityLifecycleCallbacks(this);
174+
updateCallbackRegistration();
154175
options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed.");
155176

156177
// In case of a deferred init or runtime enable, hook into any already-resumed activity
@@ -167,7 +188,7 @@ private synchronized void stopDetecting() {
167188
}
168189
detecting = false;
169190

170-
application.unregisterActivityLifecycleCallbacks(this);
191+
updateCallbackRegistration();
171192
shakeDetector.close();
172193
currentActivityRef = null;
173194
}
@@ -178,6 +199,9 @@ public synchronized void close() throws IOException {
178199
trackedDialog = null;
179200
dialogRequestedShakeDetection = false;
180201
stopDetecting();
202+
// stopDetecting is a no-op when detection wasn't running, but a tracking-only dialog may
203+
// still have kept the callbacks registered.
204+
updateCallbackRegistration();
181205
}
182206

183207
@Override
@@ -201,7 +225,15 @@ public void onActivityPaused(final @NotNull Activity activity) {
201225

202226
@Override
203227
public void onActivityCreated(
204-
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {}
228+
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {
229+
// The user is navigating to a new activity: a dialog hosted by a different activity can't
230+
// be shown there, so stop tracking it (also releasing the strong reference early instead
231+
// of waiting for the host activity to be destroyed).
232+
final @Nullable SentryFeedbackOptions.IShakeDialog dialog = trackedDialog;
233+
if (dialog != null && findDialogActivity(dialog) != activity) {
234+
setDialog(null, false);
235+
}
236+
}
205237

206238
@Override
207239
public void onActivityStarted(final @NotNull Activity activity) {}

sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -296,13 +296,66 @@ class FeedbackShakeIntegrationTest {
296296
}
297297

298298
@Test
299-
fun `setDialog without startShakeDetection only tracks the dialog`() {
299+
fun `setDialog without startShakeDetection tracks the dialog but does not start detection`() {
300+
whenever(fixture.application.getSystemService(any())).thenReturn(null)
300301
val sut = fixture.getSut(useShakeGesture = false)
301302
sut.register(fixture.scopes, fixture.options)
302303

303304
sut.setDialog(createShakeDialog(), false)
304305

305-
verify(fixture.application, never()).registerActivityLifecycleCallbacks(any())
306+
// Callbacks are registered to release the dialog on activity destroy, but the shake
307+
// detector itself is not started.
308+
verify(fixture.application).registerActivityLifecycleCallbacks(any())
309+
verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE))
310+
}
311+
312+
@Test
313+
fun `clearing a tracking-only dialog unregisters the callbacks`() {
314+
val sut = fixture.getSut(useShakeGesture = false)
315+
sut.register(fixture.scopes, fixture.options)
316+
sut.setDialog(createShakeDialog(), false)
317+
318+
sut.setDialog(null, false)
319+
320+
verify(fixture.application).unregisterActivityLifecycleCallbacks(any())
321+
}
322+
323+
@Test
324+
fun `destroying the host activity of a tracking-only dialog releases it`() {
325+
// Guards against leaking the dialog (and its activity) through the strong reference when
326+
// detection is not running.
327+
val sut = fixture.getSut(useShakeGesture = false)
328+
sut.register(fixture.scopes, fixture.options)
329+
val dialog = createShakeDialog()
330+
sut.setDialog(dialog, false)
331+
332+
sut.onActivityDestroyed(dialog.activity)
333+
334+
verify(fixture.application).unregisterActivityLifecycleCallbacks(any())
335+
}
336+
337+
@Test
338+
fun `creating a different activity releases the tracked dialog`() {
339+
val sut = fixture.getSut(useShakeGesture = false)
340+
sut.register(fixture.scopes, fixture.options)
341+
sut.setDialog(createShakeDialog(), false)
342+
343+
val otherActivity = Robolectric.buildActivity(Activity::class.java).setup().get()
344+
sut.onActivityCreated(otherActivity, null)
345+
346+
verify(fixture.application).unregisterActivityLifecycleCallbacks(any())
347+
}
348+
349+
@Test
350+
fun `creating the dialog's own host activity keeps the tracked dialog`() {
351+
val sut = fixture.getSut(useShakeGesture = false)
352+
sut.register(fixture.scopes, fixture.options)
353+
val dialog = createShakeDialog()
354+
sut.setDialog(dialog, false)
355+
356+
sut.onActivityCreated(dialog.activity, null)
357+
358+
verify(fixture.application, never()).unregisterActivityLifecycleCallbacks(any())
306359
}
307360

308361
@Test
@@ -345,13 +398,18 @@ class FeedbackShakeIntegrationTest {
345398
}
346399

347400
@Test
348-
fun `disable stops shake detection when the tracked dialog did not opt in`() {
401+
fun `disable keeps callbacks registered while a tracking-only dialog is set`() {
349402
val sut = fixture.getSut(useShakeGesture = true)
350403
sut.register(fixture.scopes, fixture.options)
351-
sut.setDialog(createShakeDialog(), false)
404+
val dialog = createShakeDialog()
405+
sut.setDialog(dialog, false)
352406

353407
sut.disable()
354408

409+
// Detection stops, but the callbacks stay registered to release the tracked dialog once
410+
// its host activity goes away.
411+
verify(fixture.application, never()).unregisterActivityLifecycleCallbacks(any())
412+
sut.onActivityDestroyed(dialog.activity)
355413
verify(fixture.application).unregisterActivityLifecycleCallbacks(any())
356414
}
357415

@@ -370,14 +428,18 @@ class FeedbackShakeIntegrationTest {
370428
}
371429

372430
@Test
373-
fun `replacing an opted-in dialog with a tracking-only one stops detection when globally disabled`() {
431+
fun `replacing an opted-in dialog with a tracking-only one drops the opt-in`() {
374432
val sut = fixture.getSut(useShakeGesture = false)
375433
sut.register(fixture.scopes, fixture.options)
376434
sut.setDialog(createShakeDialog(), true)
377435

378-
// A different dialog only reporting visibility no longer justifies detection
379-
sut.setDialog(createShakeDialog(), false)
436+
// A different dialog only reporting visibility no longer justifies detection; the
437+
// callbacks stay registered only to track the new dialog's host activity.
438+
val dialog = createShakeDialog()
439+
sut.setDialog(dialog, false)
440+
verify(fixture.application, never()).unregisterActivityLifecycleCallbacks(any())
380441

442+
sut.setDialog(null, false)
381443
verify(fixture.application).unregisterActivityLifecycleCallbacks(any())
382444
}
383445

0 commit comments

Comments
 (0)