Skip to content

Commit

Permalink
Fix UnsupportedOperationException if a leaked Activity.recreate is ca…
Browse files Browse the repository at this point in the history
…lled

Previously, ActivityController.recreate determined the current state of the
underlying Activity by calling the global

  ActivityLifecycleMonitorRegistry.getLifecycleStageOf

This method is unreliable for leaked Activities as a new
ActivityLifecycleMonitor is created before each test. This means that, for a
leaked Activity, the ActivityLifecycleMonitor from the previous test has
knowledge if its lifecycle state, not the new one.  When the Activity state was
queried using the current ActivityLifecycleMonitor, an
UnsupportedOperationException occurred.

To fix this, avoid using ActivityLifecycleMonitor as the source of truth for
Activity State in ActivityController, and instead add a new member variable to
ActivityController that maintains the state. Having ActivityController rely on
ActivityLifecycleMonitor for Activity state is circuitous -- AndroidX Test is
already using ActivityController to drive Activity lifecycles, so the
source-of-truth state should exist inside of ActivityController itself.

PiperOrigin-RevId: 410168815
  • Loading branch information
hoisie committed Nov 17, 2021
1 parent 326991f commit ff49469
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 34 deletions.
@@ -0,0 +1,34 @@
package org.robolectric.android.controller;

import android.app.Activity;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;

/**
* This test captures an issue where {@link ActivityController#recreate()} would throw an {@link
* UnsupportedOperationException} if an Activity from a previous test was recreated.
*/
@RunWith(AndroidJUnit4.class)
public class ActivityControllerRecreateTest {
private static final AtomicReference<ActivityController<Activity>> createdActivity =
new AtomicReference<>();

@Before
public void setUp() {
createdActivity.compareAndSet(null, Robolectric.buildActivity(Activity.class).create());
}

@Test
public void failsTryingToRecreateActivityFromOtherTest1() {
createdActivity.get().recreate();
}

@Test
public void failsTryingToRecreateActivityFromOtherTest2() {
createdActivity.get().recreate();
}
}
Expand Up @@ -7,7 +7,6 @@
import static org.robolectric.util.reflector.Reflector.reflector;

import android.app.Activity;
import android.app.ActivityThread;
import android.app.Application;
import android.app.Instrumentation;
import android.content.ComponentName;
Expand All @@ -19,8 +18,6 @@
import android.os.Bundle;
import android.view.ViewRootImpl;
import android.view.WindowManager;
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
import androidx.test.runner.lifecycle.Stage;
import javax.annotation.Nullable;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.shadow.api.Shadow;
Expand Down Expand Up @@ -49,7 +46,19 @@
public class ActivityController<T extends Activity>
extends ComponentController<ActivityController<T>, T> {

enum LifecycleState {
INITIAL,
CREATED,
RESTARTED,
STARTED,
RESUMED,
PAUSED,
STOPPED,
DESTROYED
}

private _Activity_ _component_;
private LifecycleState currentState = LifecycleState.INITIAL;

public static <T extends Activity> ActivityController<T> of(
T activity, Intent intent, @Nullable Bundle activityOptions) {
Expand Down Expand Up @@ -110,7 +119,11 @@ private ActivityInfo getActivityInfo(Application application) {
}

public ActivityController<T> create(@Nullable final Bundle bundle) {
shadowMainLooper.runPaused(() -> getInstrumentation().callActivityOnCreate(component, bundle));
shadowMainLooper.runPaused(
() -> {
getInstrumentation().callActivityOnCreate(component, bundle);
currentState = LifecycleState.CREATED;
});
return this;
}

Expand All @@ -119,23 +132,31 @@ public ActivityController<T> create(@Nullable final Bundle bundle) {
}

public ActivityController<T> restart() {
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
invokeWhilePaused(() -> _component_.performRestart());
} else {
invokeWhilePaused(() -> _component_.performRestart(true, "restart()"));
}
invokeWhilePaused(
() -> {
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_component_.performRestart();
} else {
_component_.performRestart(true, "restart()");
}
currentState = LifecycleState.RESTARTED;
});
return this;
}

public ActivityController<T> start() {
// Start and stop are tricky cases. Unlike other lifecycle methods such as
// Instrumentation#callActivityOnPause calls Activity#performPause, Activity#performStop calls
// Instrumentation#callActivityOnStop internally so the dependency direction is the opposite.
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
invokeWhilePaused(() -> _component_.performStart());
} else {
invokeWhilePaused(() -> _component_.performStart("start()"));
}
invokeWhilePaused(
() -> {
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_component_.performStart();
} else {
_component_.performStart("start()");
}
currentState = LifecycleState.STARTED;
});
return this;
}

Expand All @@ -151,11 +172,15 @@ public ActivityController<T> postCreate(@Nullable Bundle bundle) {
}

public ActivityController<T> resume() {
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
invokeWhilePaused(() -> _component_.performResume());
} else {
invokeWhilePaused(() -> _component_.performResume(true, "resume()"));
}
invokeWhilePaused(
() -> {
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_component_.performResume();
} else {
_component_.performResume(true, "resume()");
}
currentState = LifecycleState.RESUMED;
});
return this;
}

Expand Down Expand Up @@ -215,7 +240,11 @@ public ActivityController<T> userLeaving() {
}

public ActivityController<T> pause() {
shadowMainLooper.runPaused(() -> getInstrumentation().callActivityOnPause(component));
shadowMainLooper.runPaused(
() -> {
getInstrumentation().callActivityOnPause(component);
currentState = LifecycleState.PAUSED;
});
return this;
}

Expand All @@ -229,13 +258,17 @@ public ActivityController<T> stop() {
// Stop and start are tricky cases. Unlike other lifecycle methods such as
// Instrumentation#callActivityOnPause calls Activity#performPause, Activity#performStop calls
// Instrumentation#callActivityOnStop internally so the dependency direction is the opposite.
if (RuntimeEnvironment.getApiLevel() <= M) {
invokeWhilePaused(() -> _component_.performStop());
} else if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
invokeWhilePaused(() -> _component_.performStop(true));
} else {
invokeWhilePaused(() -> _component_.performStop(true, "stop()"));
}
invokeWhilePaused(
() -> {
if (RuntimeEnvironment.getApiLevel() <= M) {
_component_.performStop();
} else if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_component_.performStop(true);
} else {
_component_.performStop(true, "stop()");
}
currentState = LifecycleState.STOPPED;
});
return this;
}

Expand All @@ -245,6 +278,7 @@ public ActivityController<T> destroy() {
() -> {
getInstrumentation().callActivityOnDestroy(component);
makeActivityEligibleForGc();
currentState = LifecycleState.DESTROYED;
});
return this;
}
Expand Down Expand Up @@ -434,11 +468,11 @@ public ActivityController<T> configurationChange(final Configuration newConfigur
*/
@SuppressWarnings("unchecked")
public ActivityController<T> recreate() {
Stage originalStage =
ActivityLifecycleMonitorRegistry.getInstance().getLifecycleStageOf(component);

switch (originalStage) {
case PRE_ON_CREATE:
LifecycleState originalState = currentState;

switch (originalState) {
case INITIAL:
create();
// fall through
case CREATED:
Expand Down Expand Up @@ -489,7 +523,7 @@ public ActivityController<T> recreate() {

// Move back to the original stage. If the original stage was transient stage, it will bring it
// to resumed state to match the on device behavior.
switch (originalStage) {
switch (originalState) {
case PAUSED:
pause();
return this;
Expand All @@ -502,8 +536,9 @@ public ActivityController<T> recreate() {
}
}

private static Instrumentation getInstrumentation() {
return ((ActivityThread) RuntimeEnvironment.getActivityThread()).getInstrumentation();
// Get the Instrumentation object scoped to the Activity.
private Instrumentation getInstrumentation() {
return _component_.getInstrumentation();
}

/** Accessor interface for android.app.Activity.NonConfigurationInstances's internals. */
Expand Down

0 comments on commit ff49469

Please sign in to comment.