diff --git a/plugin/src/main/java/net/grandcentrix/thirtyinch/plugin/TiActivityPlugin.java b/plugin/src/main/java/net/grandcentrix/thirtyinch/plugin/TiActivityPlugin.java index 0fcad119..215abca1 100644 --- a/plugin/src/main/java/net/grandcentrix/thirtyinch/plugin/TiActivityPlugin.java +++ b/plugin/src/main/java/net/grandcentrix/thirtyinch/plugin/TiActivityPlugin.java @@ -29,6 +29,7 @@ import net.grandcentrix.thirtyinch.internal.TiLoggingTagProvider; import net.grandcentrix.thirtyinch.internal.TiPresenterProvider; import net.grandcentrix.thirtyinch.internal.TiViewProvider; +import net.grandcentrix.thirtyinch.internal.UiThreadExecutor; import net.grandcentrix.thirtyinch.util.AndroidDeveloperOptions; import net.grandcentrix.thirtyinch.util.AnnotationUtil; @@ -39,6 +40,7 @@ import android.support.annotation.Nullable; import java.util.List; +import java.util.concurrent.Executor; /** * Binds a {@link TiPresenter} to an {@link Activity} @@ -57,6 +59,8 @@ public class TiActivityPlugin

, V extends TiView> extend private TiActivityDelegate mDelegate; + private final UiThreadExecutor mUiThreadExecutor = new UiThreadExecutor(); + /** * Binds a {@link TiPresenter} returned by the {@link TiPresenterProvider} to the {@link * Activity} and all future {@link Activity} instances created due to configuration changes. @@ -118,6 +122,11 @@ public P getRetainedPresenter() { return null; } + @Override + public Executor getUiThreadExecutor() { + return mUiThreadExecutor; + } + /** * Invalidates the cache of the latest bound view. Forces the next binding of the view to run * through all the interceptors (again). @@ -194,11 +203,6 @@ public void onStop() { mDelegate.onStop_afterSuper(); } - @Override - public boolean postToMessageQueue(final Runnable runnable) { - return getActivity().getWindow().getDecorView().post(runnable); - } - @SuppressWarnings("unchecked") @NonNull @Override diff --git a/plugin/src/main/java/net/grandcentrix/thirtyinch/plugin/TiFragmentPlugin.java b/plugin/src/main/java/net/grandcentrix/thirtyinch/plugin/TiFragmentPlugin.java index 603e3479..9c074d40 100644 --- a/plugin/src/main/java/net/grandcentrix/thirtyinch/plugin/TiFragmentPlugin.java +++ b/plugin/src/main/java/net/grandcentrix/thirtyinch/plugin/TiFragmentPlugin.java @@ -29,6 +29,7 @@ import net.grandcentrix.thirtyinch.internal.TiLoggingTagProvider; import net.grandcentrix.thirtyinch.internal.TiPresenterProvider; import net.grandcentrix.thirtyinch.internal.TiViewProvider; +import net.grandcentrix.thirtyinch.internal.UiThreadExecutor; import net.grandcentrix.thirtyinch.util.AndroidDeveloperOptions; import net.grandcentrix.thirtyinch.util.AnnotationUtil; @@ -41,6 +42,7 @@ import android.view.ViewGroup; import java.util.List; +import java.util.concurrent.Executor; /** * Adds a {@link TiPresenter} to a Fragment. Can be used for both, {@link Fragment} and @@ -58,6 +60,8 @@ public class TiFragmentPlugin

, V extends TiView> extend private TiFragmentDelegate mDelegate; + private final UiThreadExecutor mUiThreadExecutor = new UiThreadExecutor(); + /** * Binds a {@link TiPresenter} returned by the {@link TiPresenterProvider} to the {@link * Fragment} and all future {@link Fragment} instances created due to configuration changes. @@ -99,7 +103,6 @@ public List getInterceptors( return mDelegate.getInterceptors(predicate); } - @Override public String getLoggingTag() { return TAG; @@ -109,6 +112,11 @@ public P getPresenter() { return mDelegate.getPresenter(); } + @Override + public Executor getUiThreadExecutor() { + return mUiThreadExecutor; + } + /** * Invalidates the cache of the latest bound view. Forces the next binding of the view to run * through all the interceptors (again). @@ -187,11 +195,6 @@ public void onStop() { super.onStop(); } - @Override - public boolean postToMessageQueue(final Runnable runnable) { - return getFragment().getActivity().getWindow().getDecorView().post(runnable); - } - /** * the default implementation assumes that the fragment is the view and implements the {@link * TiView} interface. Override this method for a different behaviour. diff --git a/rx/src/main/java/net/grandcentrix/thirtyinch/rx/RxTiPresenterUtils.java b/rx/src/main/java/net/grandcentrix/thirtyinch/rx/RxTiPresenterUtils.java index 889fa050..9d520822 100644 --- a/rx/src/main/java/net/grandcentrix/thirtyinch/rx/RxTiPresenterUtils.java +++ b/rx/src/main/java/net/grandcentrix/thirtyinch/rx/RxTiPresenterUtils.java @@ -114,40 +114,37 @@ public Observable call(Observable observable) { * TiPresenter#attachView(TiView)} and before calling {@link TiPresenter#detachView()}. */ public static Observable isViewReady(final TiPresenter presenter) { - return Observable.create( - new Observable.OnSubscribe() { - @Override - public void call(final Subscriber subscriber) { - if (!subscriber.isUnsubscribed()) { - subscriber.onNext(presenter.getState() - == TiPresenter.State.VIEW_ATTACHED); - } - - final Removable removable = presenter - .addLifecycleObserver(new TiLifecycleObserver() { - @Override - public void onChange(final TiPresenter.State state, - final boolean hasLifecycleMethodBeenCalled) { - if (!subscriber.isUnsubscribed()) { - subscriber.onNext(state - == TiPresenter.State.VIEW_ATTACHED); - } - } - }); - - subscriber.add(new Subscription() { - @Override - public boolean isUnsubscribed() { - return removable.isRemoved(); - } + return Observable.create(new Observable.OnSubscribe() { + @Override + public void call(final Subscriber subscriber) { + if (!subscriber.isUnsubscribed()) { + subscriber.onNext(presenter.getState() == TiPresenter.State.VIEW_ATTACHED); + } + final Removable removable = presenter + .addLifecycleObserver(new TiLifecycleObserver() { @Override - public void unsubscribe() { - removable.remove(); + public void onChange(final TiPresenter.State state, + final boolean hasLifecycleMethodBeenCalled) { + if (!subscriber.isUnsubscribed()) { + subscriber.onNext(state == TiPresenter.State.VIEW_ATTACHED + && hasLifecycleMethodBeenCalled); + } } }); + + subscriber.add(new Subscription() { + @Override + public boolean isUnsubscribed() { + return removable.isRemoved(); + } + + @Override + public void unsubscribe() { + removable.remove(); } - }) - .distinctUntilChanged(); + }); + } + }).distinctUntilChanged(); } } diff --git a/rx2/src/main/java/net/grandcentrix/thirtyinch/rx2/RxTiPresenterUtils.java b/rx2/src/main/java/net/grandcentrix/thirtyinch/rx2/RxTiPresenterUtils.java index 6de3ad8c..5ae1dfdd 100644 --- a/rx2/src/main/java/net/grandcentrix/thirtyinch/rx2/RxTiPresenterUtils.java +++ b/rx2/src/main/java/net/grandcentrix/thirtyinch/rx2/RxTiPresenterUtils.java @@ -32,42 +32,39 @@ public class RxTiPresenterUtils { * TiPresenter#attachView(TiView)} and before calling {@link TiPresenter#detachView()}. */ public static Observable isViewReady(final TiPresenter presenter) { - return Observable.create( - new ObservableOnSubscribe() { - @Override - public void subscribe(final ObservableEmitter emitter) - throws Exception { - if (!emitter.isDisposed()) { - emitter.onNext(presenter.getState() - == TiPresenter.State.VIEW_ATTACHED); - } - - final Removable removable = presenter - .addLifecycleObserver(new TiLifecycleObserver() { - @Override - public void onChange(final TiPresenter.State state, - final boolean hasLifecycleMethodBeenCalled) { - if (!emitter.isDisposed()) { - emitter.onNext(state == - TiPresenter.State.VIEW_ATTACHED); - } - } - }); - - emitter.setDisposable(new Disposable() { - @Override - public void dispose() { - removable.remove(); - } + return Observable.create(new ObservableOnSubscribe() { + @Override + public void subscribe(final ObservableEmitter emitter) + throws Exception { + if (!emitter.isDisposed()) { + emitter.onNext(presenter.getState() == TiPresenter.State.VIEW_ATTACHED); + } + final Removable removable = presenter + .addLifecycleObserver(new TiLifecycleObserver() { @Override - public boolean isDisposed() { - return removable.isRemoved(); + public void onChange(final TiPresenter.State state, + final boolean hasLifecycleMethodBeenCalled) { + if (!emitter.isDisposed()) { + emitter.onNext(state == TiPresenter.State.VIEW_ATTACHED + && hasLifecycleMethodBeenCalled); + } } }); + + emitter.setDisposable(new Disposable() { + @Override + public void dispose() { + removable.remove(); + } + + @Override + public boolean isDisposed() { + return removable.isRemoved(); } - }) - .distinctUntilChanged(); + }); + } + }).distinctUntilChanged(); } } diff --git a/sample/src/main/java/net/grandcentrix/thirtyinch/sample/HelloWorldPresenter.java b/sample/src/main/java/net/grandcentrix/thirtyinch/sample/HelloWorldPresenter.java index 679dd2d6..f90eed38 100644 --- a/sample/src/main/java/net/grandcentrix/thirtyinch/sample/HelloWorldPresenter.java +++ b/sample/src/main/java/net/grandcentrix/thirtyinch/sample/HelloWorldPresenter.java @@ -16,6 +16,7 @@ package net.grandcentrix.thirtyinch.sample; import net.grandcentrix.thirtyinch.TiPresenter; +import net.grandcentrix.thirtyinch.ViewAction; import net.grandcentrix.thirtyinch.rx.RxTiPresenterSubscriptionHandler; import net.grandcentrix.thirtyinch.rx.RxTiPresenterUtils; @@ -73,7 +74,12 @@ protected void onCreate() { .subscribe(new Action1() { @Override public void call(final Long uptime) { - getView().showPresenterUpTime(uptime); + sendToView(new ViewAction() { + @Override + public void call(final HelloWorldView view) { + view.showPresenterUpTime(uptime); + } + }); } })); diff --git a/sample/src/main/java/net/grandcentrix/thirtyinch/sample/HelloWorldView.java b/sample/src/main/java/net/grandcentrix/thirtyinch/sample/HelloWorldView.java index 3d7a3201..674eaf48 100644 --- a/sample/src/main/java/net/grandcentrix/thirtyinch/sample/HelloWorldView.java +++ b/sample/src/main/java/net/grandcentrix/thirtyinch/sample/HelloWorldView.java @@ -25,7 +25,6 @@ public interface HelloWorldView extends TiView { Observable onButtonClicked(); - @CallOnMainThread void showPresenterUpTime(Long uptime); @CallOnMainThread diff --git a/test/src/main/java/net/grandcentrix/thirtyinch/test/TiPresenterInstructor.java b/test/src/main/java/net/grandcentrix/thirtyinch/test/TiPresenterInstructor.java index 61f7a458..538515e0 100644 --- a/test/src/main/java/net/grandcentrix/thirtyinch/test/TiPresenterInstructor.java +++ b/test/src/main/java/net/grandcentrix/thirtyinch/test/TiPresenterInstructor.java @@ -18,6 +18,8 @@ import net.grandcentrix.thirtyinch.TiPresenter; import net.grandcentrix.thirtyinch.TiView; +import java.util.concurrent.Executor; + public class TiPresenterInstructor { private TiPresenter mPresenter; @@ -32,6 +34,12 @@ public TiPresenterInstructor(final TiPresenter presenter) { public void attachView(final V view) { detachView(); + mPresenter.setUiThreadExecutor(new Executor() { + @Override + public void execute(final Runnable action) { + action.run(); + } + }); mPresenter.attachView(view); } @@ -59,6 +67,7 @@ public void detachView() { break; case VIEW_ATTACHED: mPresenter.detachView(); + mPresenter.setUiThreadExecutor(null); break; case DESTROYED: throw new IllegalStateException( diff --git a/thirtyinch/src/androidTest/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegateTest.java b/thirtyinch/src/androidTest/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegateTest.java index 1fa5eff1..134a7a32 100644 --- a/thirtyinch/src/androidTest/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegateTest.java +++ b/thirtyinch/src/androidTest/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegateTest.java @@ -29,6 +29,8 @@ import android.support.test.runner.AndroidJUnit4; import android.test.suitebuilder.annotation.SmallTest; +import java.util.concurrent.Executor; + import static junit.framework.Assert.assertEquals; @RunWith(AndroidJUnit4.class) @@ -239,9 +241,13 @@ public boolean isDontKeepActivitiesEnabled() { } @Override - public boolean postToMessageQueue(final Runnable action) { - action.run(); - return true; + public Executor getUiThreadExecutor() { + return new Executor() { + @Override + public void execute(@NonNull final Runnable action) { + action.run(); + } + }; } }, new TiViewProvider() { diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiActivity.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiActivity.java index c8f37f01..700f4606 100644 --- a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiActivity.java +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiActivity.java @@ -22,6 +22,7 @@ import net.grandcentrix.thirtyinch.internal.TiLoggingTagProvider; import net.grandcentrix.thirtyinch.internal.TiPresenterProvider; import net.grandcentrix.thirtyinch.internal.TiViewProvider; +import net.grandcentrix.thirtyinch.internal.UiThreadExecutor; import net.grandcentrix.thirtyinch.util.AndroidDeveloperOptions; import net.grandcentrix.thirtyinch.util.AnnotationUtil; @@ -32,6 +33,7 @@ import android.support.v7.app.AppCompatActivity; import java.util.List; +import java.util.concurrent.Executor; /** * Created by pascalwelsch on 9/8/15. @@ -48,6 +50,8 @@ public abstract class TiActivity

, V extends TiView> private final TiActivityDelegate mDelegate = new TiActivityDelegate<>(this, this, this, this); + private final UiThreadExecutor mUiThreadExecutor = new UiThreadExecutor(); + @NonNull @Override public Removable addBindViewInterceptor(@NonNull final BindViewInterceptor interceptor) { @@ -90,6 +94,11 @@ public P getRetainedPresenter() { return null; } + @Override + public Executor getUiThreadExecutor() { + return mUiThreadExecutor; + } + /** * Invalidates the cache of the latest bound view. Forces the next binding of the view to run * through all the interceptors (again). @@ -136,11 +145,6 @@ public Object onRetainCustomNonConfigurationInstance() { return null; } - @Override - public boolean postToMessageQueue(final Runnable runnable) { - return getWindow().getDecorView().post(runnable); - } - @SuppressWarnings("unchecked") @NonNull @Override diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiDialogFragment.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiDialogFragment.java index 7d4d7338..ed9bf3fc 100644 --- a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiDialogFragment.java +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiDialogFragment.java @@ -21,6 +21,7 @@ import net.grandcentrix.thirtyinch.internal.TiLoggingTagProvider; import net.grandcentrix.thirtyinch.internal.TiPresenterProvider; import net.grandcentrix.thirtyinch.internal.TiViewProvider; +import net.grandcentrix.thirtyinch.internal.UiThreadExecutor; import net.grandcentrix.thirtyinch.util.AndroidDeveloperOptions; import net.grandcentrix.thirtyinch.util.AnnotationUtil; @@ -33,6 +34,7 @@ import android.view.ViewGroup; import java.util.List; +import java.util.concurrent.Executor; public abstract class TiDialogFragment

, V extends TiView> extends AppCompatDialogFragment @@ -74,6 +76,11 @@ public P getPresenter() { return mDelegate.getPresenter(); } + @Override + public Executor getUiThreadExecutor() { + return new UiThreadExecutor(); + } + /** * Invalidates the cache of the latest bound view. Forces the next binding of the view to run * through all the interceptors (again). @@ -152,11 +159,6 @@ public void onStop() { super.onStop(); } - @Override - public boolean postToMessageQueue(final Runnable runnable) { - return getActivity().getWindow().getDecorView().post(runnable); - } - /** * the default implementation assumes that the fragment is the view and implements the {@link * TiView} interface. Override this method for a different behaviour. diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiFragment.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiFragment.java index 9a0567bc..51422478 100644 --- a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiFragment.java +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiFragment.java @@ -21,6 +21,7 @@ import net.grandcentrix.thirtyinch.internal.TiLoggingTagProvider; import net.grandcentrix.thirtyinch.internal.TiPresenterProvider; import net.grandcentrix.thirtyinch.internal.TiViewProvider; +import net.grandcentrix.thirtyinch.internal.UiThreadExecutor; import net.grandcentrix.thirtyinch.util.AndroidDeveloperOptions; import net.grandcentrix.thirtyinch.util.AnnotationUtil; @@ -33,6 +34,7 @@ import android.view.ViewGroup; import java.util.List; +import java.util.concurrent.Executor; public abstract class TiFragment

, V extends TiView> extends Fragment implements DelegatedTiFragment, TiPresenterProvider

, TiLoggingTagProvider, @@ -45,6 +47,8 @@ public abstract class TiFragment

, V extends TiView> ext private final TiFragmentDelegate mDelegate = new TiFragmentDelegate<>(this, this, this, this); + private final UiThreadExecutor mUiThreadExecutor = new UiThreadExecutor(); + @NonNull @Override public Removable addBindViewInterceptor(@NonNull final BindViewInterceptor interceptor) { @@ -73,6 +77,11 @@ public P getPresenter() { return mDelegate.getPresenter(); } + @Override + public Executor getUiThreadExecutor() { + return mUiThreadExecutor; + } + /** * Invalidates the cache of the latest bound view. Forces the next binding of the view to run * through all the interceptors (again). @@ -151,11 +160,6 @@ public void onStop() { super.onStop(); } - @Override - public boolean postToMessageQueue(final Runnable runnable) { - return getActivity().getWindow().getDecorView().post(runnable); - } - /** * the default implementation assumes that the fragment is the view and implements the {@link * TiView} interface. Override this method for a different behaviour. diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiPresenter.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiPresenter.java index 276ed6ba..4ff8ca00 100644 --- a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiPresenter.java +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/TiPresenter.java @@ -28,6 +28,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Queue; +import java.util.concurrent.Executor; import java.util.concurrent.LinkedBlockingQueue; /** @@ -89,6 +90,12 @@ public enum State { private State mState = State.INITIALIZED; + /** + * Executor for UI operations, must be set by the view implementation + */ + @Nullable + private Executor mUiThreadExecutor; + private V mView; public static void setDefaultConfig(final TiConfiguration config) { @@ -109,7 +116,9 @@ public TiPresenter(final TiConfiguration config) { } /** - * Observes the lifecycle state of this presenter. + * Observes the lifecycle state of this presenter. Observers get called in order they are + * added for constructive events and in reversed order for destructive events. First in, last + * out. * * @param observer called when lifecycle state changes after the lifecycle method such as * {@link @@ -187,6 +196,8 @@ public void attachView(@NonNull final V view) { + " did not call through to super.onWakeUp()"); } moveToState(State.VIEW_ATTACHED, true); + + sendPostponedActionsToView(view); } /** @@ -315,6 +326,43 @@ public boolean isViewAttached() { return mState == State.VIEW_ATTACHED; } + /** + * Runs the specified action on the UI thread. It only works when a view is attached + *

+ * When you are looking for a way to execute code when the view got available in the future + * have a look at {@link #sendToView(ViewAction)} + * + * @param action the action to run on the UI thread + * @throws IllegalStateException when the executor is not available (most likely because the + * view is not attached) + */ + public void runOnUiThread(@NonNull final Runnable action) { + if (mUiThreadExecutor != null) { + mUiThreadExecutor.execute(action); + } else { + if (getView() == null) { + throw new IllegalStateException("view is not attached, " + + "no executor available to run ui interactions on"); + } else { + throw new IllegalStateException("no ui thread executor available"); + } + } + } + + /** + * sets the Executor used for the {@link #runOnUiThread(Runnable)} method. + *

+ * This Executor is most likely the {@link net.grandcentrix.thirtyinch.internal.UiThreadExecutor} + * posting the work on the Android Main Thread. + * When using the {@code TiPresenterInstructor} in your tests an {@link Executor} for the + * current {@link Thread} is used, therefore all executed actions run synchronous. + * + * @param uiThreadExecutor executor for view interactions + */ + public void setUiThreadExecutor(@Nullable final Executor uiThreadExecutor) { + mUiThreadExecutor = uiThreadExecutor; + } + @Override public String toString() { final String viewName; @@ -350,11 +398,6 @@ protected void onAttachView(@NonNull V view) { "don't call #onAttachView(TiView) directly, call #attachView(TiView)"); } mCalled = true; - - // send all queued actions since the view was detached to the new view. - // It's part of the super call because there might be usecases where the implementer - // wants to execute actions on the view before executing the queued ones. - sendPostponedActionsToView(view); } /** @@ -424,7 +467,7 @@ protected void onWakeUp() { } /** - * Executes the {@link ViewAction} when the view is available. + * Executes the {@link ViewAction} when the view is available on the UI thread. * Once a view is attached the actions get called in the same order they have been added. * When the view is already attached the action will be executed immediately. *

@@ -445,10 +488,15 @@ protected void onWakeUp() { * @see #sendPostponedActionsToView * @see #onAttachView(TiView) */ - protected void sendToView(ViewAction action) { + protected void sendToView(final ViewAction action) { final V view = getView(); if (view != null) { - action.call(view); + runOnUiThread(new Runnable() { + @Override + public void run() { + action.call(view); + } + }); } else { mPostponedViewActions.add(action); } @@ -506,8 +554,20 @@ private void moveToState(final State newState, final boolean hasLifecycleMethodB mState = newState; } - for (int i = 0; i < mLifecycleObservers.size(); i++) { - mLifecycleObservers.get(i).onChange(newState, hasLifecycleMethodBeenCalled); + switch (newState) { + case INITIALIZED: + case VIEW_ATTACHED: + for (int i = 0; i < mLifecycleObservers.size(); i++) { + mLifecycleObservers.get(i).onChange(newState, hasLifecycleMethodBeenCalled); + } + break; + + case VIEW_DETACHED: + case DESTROYED: + // reverse observer order for teardown events; first in, last out + for (int i = mLifecycleObservers.size() - 1; i >= 0; i--) { + mLifecycleObservers.get(i).onChange(newState, hasLifecycleMethodBeenCalled); + } } } @@ -516,7 +576,7 @@ private void moveToState(final State newState, final boolean hasLifecycleMethodB * * @param view where the actions will be sent to */ - private void sendPostponedActionsToView(V view) { + private void sendPostponedActionsToView(@NonNull final V view) { while (!mPostponedViewActions.isEmpty()) { mPostponedViewActions.poll().call(view); } diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/DelegatedTiActivity.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/DelegatedTiActivity.java index 8acbf657..00f76680 100644 --- a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/DelegatedTiActivity.java +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/DelegatedTiActivity.java @@ -19,6 +19,8 @@ import android.support.annotation.Nullable; import android.support.v7.app.AppCompatActivity; +import java.util.concurrent.Executor; + /** * This interface, implemented by Activities allows easy testing of the {@link TiActivityDelegate} * without mocking Android classes such as {@link Activity} @@ -32,6 +34,11 @@ public interface DelegatedTiActivity

{ @Nullable P getRetainedPresenter(); + /** + * @return {@link UiThreadExecutor} + */ + Executor getUiThreadExecutor(); + /** * @return {@link Activity#isChangingConfigurations()} */ @@ -46,9 +53,4 @@ public interface DelegatedTiActivity

{ * @return true when the developer option "Don't keep Activities" is enabled */ boolean isDontKeepActivitiesEnabled(); - - /** - * Post the runnable on the UI queue - */ - boolean postToMessageQueue(Runnable runnable); } diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/DelegatedTiFragment.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/DelegatedTiFragment.java index ea932a91..c8c636b8 100644 --- a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/DelegatedTiFragment.java +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/DelegatedTiFragment.java @@ -18,8 +18,15 @@ import android.app.Activity; import android.support.v4.app.Fragment; +import java.util.concurrent.Executor; + public interface DelegatedTiFragment { + /** + * @return {@link UiThreadExecutor} + */ + Executor getUiThreadExecutor(); + /** * @return true when the developer option "Don't keep Activities" is enabled */ @@ -45,11 +52,6 @@ public interface DelegatedTiFragment { */ boolean isHostingActivityFinishing(); - /** - * Post the runnable on the UI queue - */ - boolean postToMessageQueue(Runnable runnable); - /** * Call {@link Fragment#setRetainInstance(boolean)} */ diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegate.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegate.java index 0c7165a9..307b2dde 100644 --- a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegate.java +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegate.java @@ -71,6 +71,8 @@ public class TiActivityDelegate

, V extends TiView> private final DelegatedTiActivity

mTiActivity; + private Removable mUiThreadBinderRemovable; + private final PresenterViewBinder mViewBinder; private TiViewProvider mViewProvider; @@ -188,9 +190,24 @@ public void onCreate_afterSuper(final Bundle savedInstanceState) { if (config.isDistinctUntilChangedInterceptorEnabled()) { addBindViewInterceptor(new DistinctUntilChangedInterceptor()); } + + //noinspection unchecked + final UiThreadExecutorAutoBinder uiThreadAutoBinder = + new UiThreadExecutorAutoBinder(mPresenter, mTiActivity.getUiThreadExecutor()); + + // bind ui thread to presenter when view is attached + mUiThreadBinderRemovable = mPresenter.addLifecycleObserver(uiThreadAutoBinder); } public void onDestroy_afterSuper() { + + // unregister observer and don't leak it + if (mUiThreadBinderRemovable != null) { + mUiThreadBinderRemovable.remove(); + mUiThreadBinderRemovable = null; + } + + // destroy the presenter based on configuration final TiConfiguration config = mPresenter.getConfig(); boolean destroyPresenter = false; @@ -240,7 +257,8 @@ public void onSaveInstanceState_afterSuper(final Bundle outState) { public void onStart_afterSuper() { mActivityStarted = true; - mTiActivity.postToMessageQueue(new Runnable() { + // post to the UI queue to delay bindView until all queued work has finished + mTiActivity.getUiThreadExecutor().execute(new Runnable() { @Override public void run() { // check if still started. It happens that onStop got already called, specially diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/TiFragmentDelegate.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/TiFragmentDelegate.java index 853ee0bb..2924933e 100644 --- a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/TiFragmentDelegate.java +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/TiFragmentDelegate.java @@ -62,6 +62,8 @@ public class TiFragmentDelegate

, V extends TiView> private final DelegatedTiFragment mTiFragment; + private Removable mUiThreadBinderRemovable; + private final PresenterViewBinder mViewBinder; private final TiViewProvider mViewProvider; @@ -160,6 +162,13 @@ public void onCreate_afterSuper(final Bundle savedInstanceState) { if (config.shouldRetainPresenter()) { mTiFragment.setFragmentRetainInstance(true); } + + //noinspection unchecked + final UiThreadExecutorAutoBinder uiThreadAutoBinder = + new UiThreadExecutorAutoBinder(mPresenter, mTiFragment.getUiThreadExecutor()); + + // bind ui thread to presenter when view is attached + mUiThreadBinderRemovable = mPresenter.addLifecycleObserver(uiThreadAutoBinder); } public void onDestroyView_beforeSuper() { @@ -169,6 +178,12 @@ public void onDestroyView_beforeSuper() { public void onDestroy_afterSuper() { //FIXME handle attach/detach state + // unregister observer and don't leak it + if (mUiThreadBinderRemovable != null) { + mUiThreadBinderRemovable.remove(); + mUiThreadBinderRemovable = null; + } + logState(); boolean destroyPresenter = false; @@ -220,7 +235,7 @@ public void onStart_afterSuper() { mActivityStarted = true; if (isUiPossible()) { - mTiFragment.postToMessageQueue(new Runnable() { + mTiFragment.getUiThreadExecutor().execute(new Runnable() { @Override public void run() { if (isUiPossible() && mActivityStarted) { diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/UiThreadExecutor.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/UiThreadExecutor.java new file mode 100644 index 00000000..a9edb2a5 --- /dev/null +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/UiThreadExecutor.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2017 grandcentrix GmbH + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.grandcentrix.thirtyinch.internal; + +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.NonNull; + +import java.util.concurrent.Executor; + +/** + * Executes work on the UI thread. If the current thread is the UI thread, then the action is + * executed immediately. If the current thread is not the UI thread, the action is posted to the + * event queue of the UI thread. + */ +public class UiThreadExecutor implements Executor { + + private final Handler mHandler = new Handler(Looper.getMainLooper()); + + private Thread mUiThread = Looper.getMainLooper().getThread(); + + @Override + public void execute(@NonNull Runnable command) { + if (Thread.currentThread() == mUiThread) { + // already on main thread, simply execute + command.run(); + } else { + mHandler.post(command); + } + } +} diff --git a/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/UiThreadExecutorAutoBinder.java b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/UiThreadExecutorAutoBinder.java new file mode 100644 index 00000000..b619b005 --- /dev/null +++ b/thirtyinch/src/main/java/net/grandcentrix/thirtyinch/internal/UiThreadExecutorAutoBinder.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2017 grandcentrix GmbH + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.grandcentrix.thirtyinch.internal; + +import net.grandcentrix.thirtyinch.TiLifecycleObserver; +import net.grandcentrix.thirtyinch.TiPresenter; + +import java.util.concurrent.Executor; + +/** + * binds a ui thread executor to the presenter when this view is attached + */ +public class UiThreadExecutorAutoBinder implements TiLifecycleObserver { + + private final TiPresenter mPresenter; + + private final Executor mUiThreadExecutor; + + public UiThreadExecutorAutoBinder(final TiPresenter presenter, + final Executor uiThreadExecutor) { + mPresenter = presenter; + mUiThreadExecutor = uiThreadExecutor; + } + + @Override + public void onChange(final TiPresenter.State state, + final boolean hasLifecycleMethodBeenCalled) { + + if (state == TiPresenter.State.VIEW_ATTACHED && !hasLifecycleMethodBeenCalled) { + // before super.onAttachView(view) + mPresenter.setUiThreadExecutor(mUiThreadExecutor); + } + if (state == TiPresenter.State.VIEW_DETACHED && hasLifecycleMethodBeenCalled) { + // after super.onDetachView() + mPresenter.setUiThreadExecutor(null); + } + } +} diff --git a/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/SendToViewTest.java b/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/SendToViewTest.java index 50c98599..76b8f5d5 100644 --- a/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/SendToViewTest.java +++ b/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/SendToViewTest.java @@ -19,6 +19,17 @@ import org.junit.Test; import org.mockito.InOrder; +import android.support.annotation.NonNull; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +import static junit.framework.Assert.assertNotSame; +import static junit.framework.Assert.assertTrue; import static org.assertj.core.api.Java6Assertions.assertThat; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -41,10 +52,18 @@ private interface TestView extends TiView { void doSomething3(); } + private Executor mImmediatelySameThread = new Executor() { + @Override + public void execute(@NonNull final Runnable action) { + action.run(); + } + }; + @Test public void sendToViewInOrder() throws Exception { final TestPresenter presenter = new TestPresenter(); presenter.create(); + presenter.setUiThreadExecutor(mImmediatelySameThread); assertThat(presenter.getQueuedViewActions()).hasSize(0); presenter.sendToView(new ViewAction() { @@ -78,10 +97,49 @@ public void call(final TestView view) { inOrder.verify(view).doSomething2(); } + @Test + public void testSendToViewRunsOnTheMainThread() throws Exception { + + // Given a presenter with executor (single thread) + final TiPresenter presenter = new TiPresenter() { + }; + presenter.create(); + + final ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(final Runnable r) { + return new Thread(r, "test ui thread"); + } + }); + presenter.setUiThreadExecutor(executor); + presenter.attachView(mock(TiView.class)); + + final Thread testThread = Thread.currentThread(); + + // When send work to the view + final CountDownLatch latch = new CountDownLatch(1); + + presenter.sendToView(new ViewAction() { + @Override + public void call(final TiView tiView) { + // Then the work gets executed on the ui thread + final Thread currentThread = Thread.currentThread(); + assertNotSame(testThread, currentThread); + assertTrue("executed on wrong thread", + "test ui thread".equals(currentThread.getName())); + latch.countDown(); + } + }); + + // wait a reasonable amount of time for the thread to execute the work + latch.await(5, TimeUnit.SECONDS); + } + @Test public void viewAttached() throws Exception { final TestPresenter presenter = new TestPresenter(); presenter.create(); + presenter.setUiThreadExecutor(mImmediatelySameThread); assertThat(presenter.getQueuedViewActions()).hasSize(0); final TestView view = mock(TestView.class); @@ -101,6 +159,7 @@ public void call(final TestView view) { public void viewDetached() throws Exception { final TestPresenter presenter = new TestPresenter(); presenter.create(); + presenter.setUiThreadExecutor(mImmediatelySameThread); assertThat(presenter.getQueuedViewActions()).hasSize(0); presenter.sendToView(new ViewAction() { @@ -122,6 +181,7 @@ public void call(final TestView view) { public void viewReceivesNoInteractionsAfterDetaching() throws Exception { final TestPresenter presenter = new TestPresenter(); presenter.create(); + presenter.setUiThreadExecutor(mImmediatelySameThread); assertThat(presenter.getQueuedViewActions()).hasSize(0); final TestView view = mock(TestView.class); @@ -142,7 +202,6 @@ public void call(final TestView view) { verify(view).doSomething1(); assertThat(presenter.getQueuedViewActions()).hasSize(0); - presenter.detachView(); presenter.sendToView(new ViewAction() { diff --git a/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/TiLifecycleObserverTest.java b/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/TiLifecycleObserverTest.java index d0010464..4dae633e 100644 --- a/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/TiLifecycleObserverTest.java +++ b/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/TiLifecycleObserverTest.java @@ -18,6 +18,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.InOrder; import java.util.ArrayList; import java.util.List; @@ -25,6 +26,7 @@ import static junit.framework.Assert.assertNotNull; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; public class TiLifecycleObserverTest { @@ -51,6 +53,97 @@ public void tearDown() throws Exception { mView = null; } + @Test + public void testCalledAttachedInCorrectOrder() throws Exception { + mPresenter.create(); + + // Given 2 observers + final TiLifecycleObserver observer1 = mock(TiLifecycleObserver.class); + mPresenter.addLifecycleObserver(observer1); + final TiLifecycleObserver observer2 = mock(TiLifecycleObserver.class); + mPresenter.addLifecycleObserver(observer2); + + // When a view attaches + mPresenter.attachView(mock(TiView.class)); + + // Then the last added observer gets called last + final InOrder inOrder = inOrder(observer1, observer2); + inOrder.verify(observer1).onChange(TiPresenter.State.VIEW_ATTACHED, false); + inOrder.verify(observer2).onChange(TiPresenter.State.VIEW_ATTACHED, false); + + inOrder.verify(observer1).onChange(TiPresenter.State.VIEW_ATTACHED, true); + inOrder.verify(observer2).onChange(TiPresenter.State.VIEW_ATTACHED, true); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void testCalledCreateInCorrectOrder() throws Exception { + + // Given 2 observers + final TiLifecycleObserver observer1 = mock(TiLifecycleObserver.class); + mPresenter.addLifecycleObserver(observer1); + final TiLifecycleObserver observer2 = mock(TiLifecycleObserver.class); + mPresenter.addLifecycleObserver(observer2); + + // When the presenter gets created and reached view detached state + mPresenter.create(); + + // Then the last added observer gets called first because it's a destructive event + final InOrder inOrder = inOrder(observer1, observer2); + inOrder.verify(observer2).onChange(TiPresenter.State.VIEW_DETACHED, false); + inOrder.verify(observer1).onChange(TiPresenter.State.VIEW_DETACHED, false); + + inOrder.verify(observer2).onChange(TiPresenter.State.VIEW_DETACHED, true); + inOrder.verify(observer1).onChange(TiPresenter.State.VIEW_DETACHED, true); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void testCalledDestroyInCorrectOrder() throws Exception { + + // Given a presenter with 2 added observers + mPresenter.create(); + final TiLifecycleObserver observer1 = mock(TiLifecycleObserver.class); + mPresenter.addLifecycleObserver(observer1); + final TiLifecycleObserver observer2 = mock(TiLifecycleObserver.class); + mPresenter.addLifecycleObserver(observer2); + + // When the presenter gets destroyed + mPresenter.destroy(); + + // Then the last added observer gets called first + final InOrder inOrder = inOrder(observer1, observer2); + inOrder.verify(observer2).onChange(TiPresenter.State.DESTROYED, false); + inOrder.verify(observer1).onChange(TiPresenter.State.DESTROYED, false); + + inOrder.verify(observer2).onChange(TiPresenter.State.DESTROYED, true); + inOrder.verify(observer1).onChange(TiPresenter.State.DESTROYED, true); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void testCalledDetachedInCorrectOrder() throws Exception { + mPresenter.create(); + mPresenter.attachView(mock(TiView.class)); + + // Given 2 observers + final TiLifecycleObserver observer1 = mock(TiLifecycleObserver.class); + mPresenter.addLifecycleObserver(observer1); + final TiLifecycleObserver observer2 = mock(TiLifecycleObserver.class); + mPresenter.addLifecycleObserver(observer2); + + // When the view detached + mPresenter.detachView(); + + // Then the last added observer gets called first + final InOrder inOrder = inOrder(observer1, observer2); + inOrder.verify(observer2).onChange(TiPresenter.State.VIEW_DETACHED, false); + inOrder.verify(observer1).onChange(TiPresenter.State.VIEW_DETACHED, false); + + inOrder.verify(observer2).onChange(TiPresenter.State.VIEW_DETACHED, true); + inOrder.verify(observer1).onChange(TiPresenter.State.VIEW_DETACHED, true); + } + @Test public void testCreate() throws Exception { final List states = new ArrayList<>(); diff --git a/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/TiPresenterTest.java b/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/TiPresenterTest.java index ea4836eb..e2e42668 100644 --- a/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/TiPresenterTest.java +++ b/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/TiPresenterTest.java @@ -21,10 +21,18 @@ import android.support.annotation.NonNull; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotSame; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsEqual.equalTo; import static org.mockito.Mockito.mock; @@ -271,6 +279,34 @@ public void testGetView() throws Exception { assertThat(mPresenter.getView(), equalTo(mView)); } + @Test + public void testMissingUiExecutorAndDetachedView() throws Exception { + final TiPresenter presenter = new TiPresenter() { + }; + + try { + presenter.runOnUiThread(mock(Runnable.class)); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), containsString("view")); + assertThat(e.getMessage(), containsString("no executor")); + } + } + + @Test + public void testMissingUiExecutorAttachedView() throws Exception { + final TiPresenter presenter = new TiPresenter() { + }; + presenter.create(); + presenter.attachView(mock(TiView.class)); + + try { + presenter.runOnUiThread(mock(Runnable.class)); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), not(containsString("view"))); + assertThat(e.getMessage(), containsString("no ui thread executor")); + } + } + @Test public void testOnAttachViewSuperNotCalled() throws Exception { TiPresenter presenter = new TiPresenter() { @@ -308,6 +344,44 @@ protected void onDetachView() { } } + @Test + public void testRunOnUiExecutor() throws Exception { + + // Given a presenter with executor (single thread) + final TiPresenter presenter = new TiPresenter() { + }; + presenter.create(); + + final ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(final Runnable r) { + return new Thread(r, "test ui thread"); + } + }); + presenter.setUiThreadExecutor(executor); + presenter.attachView(mock(TiView.class)); + + final Thread testThread = Thread.currentThread(); + + // When scheduling work to the UI thread + final CountDownLatch latch = new CountDownLatch(1); + + presenter.runOnUiThread(new Runnable() { + @Override + public void run() { + // Then the work gets executed on the correct thread + final Thread currentThread = Thread.currentThread(); + assertNotSame(testThread, currentThread); + assertTrue("executed on wrong thread", + "test ui thread".equals(currentThread.getName())); + latch.countDown(); + } + }); + + // wait a reasonable amount of time for the thread to execute the work + latch.await(5, TimeUnit.SECONDS); + } + @Test public void testSleepSuperNotCalled() throws Exception { TiPresenter presenter = new TiPresenter() { @@ -336,7 +410,6 @@ public void testToString() throws Exception { assertThat(mPresenter.toString(), containsString("{view = Mock for TiView, hashCode: ")); } - @Test public void testWakeUpSuperNotCalled() throws Exception { TiPresenter presenter = new TiPresenter() { diff --git a/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegateBuilder.java b/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegateBuilder.java index cf9f457b..88b8d3a5 100644 --- a/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegateBuilder.java +++ b/thirtyinch/src/test/java/net/grandcentrix/thirtyinch/internal/TiActivityDelegateBuilder.java @@ -21,6 +21,8 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import java.util.concurrent.Executor; + import static org.mockito.Mockito.mock; public class TiActivityDelegateBuilder { @@ -59,6 +61,16 @@ public TiPresenter getRetainedPresenter() { return mRetainedPresenterProvider.providePresenter(); } + @Override + public Executor getUiThreadExecutor() { + return new Executor() { + @Override + public void execute(@NonNull final Runnable action) { + action.run(); + } + }; + } + @Override public boolean isActivityChangingConfigurations() { return mIsChangingConfigurations; @@ -74,11 +86,6 @@ public boolean isDontKeepActivitiesEnabled() { return mIsDontKeepActivitiesEnabled; } - @Override - public boolean postToMessageQueue(final Runnable runnable) { - runnable.run(); - return true; - } }, new TiViewProvider() { @NonNull @Override