/* * Copyright 2013 Square Inc. * * 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 flow; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.support.annotation.CheckResult; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.view.View; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import static flow.Preconditions.checkArgument; import static flow.Preconditions.checkNotNull; /** Holds the current truth, the history of screens, and exposes operations to change it. */ public final class Flow { static final Object ROOT_KEY = new Object() { @Override public String toString() { return Flow.class.getName() + ".ROOT_KEY"; } }; /** * Convenience overload of {@link #get(Context)}. */ @NonNull public static Flow get(@NonNull View view) { return get(view.getContext()); } /** * Returns the Flow instance for the {@link Activity} that owns the given context. * Note that it is not safe to call this method before the first call to that * Activity's {@link Activity#onResume()} method in the current Android task. In practice * this boils down to two rules: * <ol> * <li>In views, do not access Flow before {@link View#onAttachedToWindow()} is called. * <li>In activities, do not access flow before {@link Activity#onResume()} is called. * </ol> */ @NonNull public static Flow get(@NonNull Context context) { Flow flow = InternalContextWrapper.getFlow(context); if (null == flow) { throw new IllegalStateException("Context was not wrapped with flow. " + "Make sure attachBaseContext was overridden in your main activity"); } return flow; } /** @return null if context has no Flow key embedded. */ @Nullable public static <T> T getKey(@NonNull Context context) { final FlowContextWrapper wrapper = FlowContextWrapper.get(context); if (wrapper == null) return null; return wrapper.services.getKey(); } /** @return null if view's Context has no Flow key embedded. */ @Nullable public static <T> T getKey(@NonNull View view) { return getKey(view.getContext()); } /** @return null if context does not contain the named service. */ @Nullable public static <T> T getService(@NonNull String serviceName, @NonNull Context context) { final FlowContextWrapper wrapper = FlowContextWrapper.get(context); if (wrapper == null) return null; return wrapper.services.getService(serviceName); } /** @return null if context does not contain the named service. */ @Nullable public static <T> T getService(@NonNull String serviceName, @NonNull View view) { return getService(serviceName, view.getContext()); } @NonNull public static Installer configure(@NonNull Context baseContext, @NonNull Activity activity) { return new Installer(baseContext, activity); } /** Adds a history as an extra to an Intent. */ public static void addHistory(@NonNull Intent intent, @NonNull History history, @NonNull KeyParceler parceler) { InternalLifecycleIntegration.addHistoryToIntent(intent, history, parceler); } /** * Handles an Intent carrying a History extra. * * @return true if the Intent contains a History and it was handled. */ @CheckResult public static boolean onNewIntent(@NonNull Intent intent, @NonNull Activity activity) { //noinspection ConstantConditions checkArgument(intent != null, "intent may not be null"); if (intent.hasExtra(InternalLifecycleIntegration.INTENT_KEY)) { InternalLifecycleIntegration.require(activity).onNewIntent(intent); return true; } return false; } private History history; private HistoryFilter historyFilter = new NotPersistentHistoryFilter(); private Dispatcher dispatcher; private PendingTraversal pendingTraversal; private List<Object> tearDownKeys = new ArrayList<>(); private final KeyManager keyManager; Flow(KeyManager keyManager, History history) { this.keyManager = keyManager; this.history = history; } @NonNull public History getHistory() { return history; } History getFilteredHistory() { return historyFilter.scrubHistory(getHistory()); } /** * Set the dispatcher, may receive an immediate call to {@link Dispatcher#dispatch}. If a {@link * Traversal Traversal} is currently in progress with a previous Dispatcher, that Traversal will * not be affected. */ public void setDispatcher(@NonNull Dispatcher dispatcher) { setDispatcher(dispatcher, false); } /** * Set the {@link HistoryFilter}, responsible for scrubbing history before it is persisted. * Use this to customize the default behavior described on {@link NotPersistent}. */ public void setHistoryFilter(@NonNull HistoryFilter historyFilter) { this.historyFilter = historyFilter; } void setDispatcher(@NonNull Dispatcher dispatcher, final boolean restore) { this.dispatcher = checkNotNull(dispatcher, "dispatcher"); if (pendingTraversal == null || // (pendingTraversal.state == TraversalState.DISPATCHED && pendingTraversal.next == null)) { // Nothing is happening; // OR, there is an outstanding callback and nothing will happen after it; // So enqueue a bootstrap traversal. move(new PendingTraversal() { @Override void doExecute() { bootstrap(history, restore); } }); return; } if (pendingTraversal.state == TraversalState.ENQUEUED) { // A traversal was enqueued while we had no dispatcher, run it now. pendingTraversal.execute(); return; } if (pendingTraversal.state != TraversalState.DISPATCHED) { throw new AssertionError("Hanging traversal in unexpected state " + pendingTraversal.state); } } /** * Remove the dispatcher. A noop if the given dispatcher is not the current one. * <p> * No further {@link Traversal Traversals}, including Traversals currently enqueued, will execute * until a new dispatcher is set. */ public void removeDispatcher(@NonNull Dispatcher dispatcher) { // This mechanism protects against out of order calls to this method and setDispatcher // (e.g. if an outgoing activity is paused after an incoming one resumes). if (this.dispatcher == checkNotNull(dispatcher, "dispatcher")) this.dispatcher = null; } /** * Replaces the history with the one given and dispatches in the given direction. */ public void setHistory(@NonNull final History history, @NonNull final Direction direction) { move(new PendingTraversal() { @Override void doExecute() { dispatch(preserveEquivalentPrefix(getHistory(), history), direction); } }); } /** * Replaces the history with the given key and dispatches in the given direction. */ public void replaceHistory(@NonNull final Object key, @NonNull final Direction direction) { setHistory(getHistory().buildUpon().clear().push(key).build(), direction); } /** * Replaces the top key of the history with the given key and dispatches in the given direction. */ public void replaceTop(@NonNull final Object key, @NonNull final Direction direction) { setHistory(getHistory().buildUpon().pop(1).push(key).build(), direction); } /** * Updates the history such that the given key is at the top and dispatches the updated * history. * * If newTopKey is already at the top of the history, the history will be unchanged, but it will * be dispatched with direction {@link Direction#REPLACE}. * * If newTopKey is already on the history but not at the top, the stack will pop until newTopKey * is at the top, and the dispatch direction will be {@link Direction#BACKWARD}. * * If newTopKey is not already on the history, it will be pushed and the dispatch direction will * be {@link Direction#FORWARD}. * * Objects' equality is always checked using {@link Object#equals(Object)}. */ public void set(@NonNull final Object newTopKey) { move(new PendingTraversal() { @Override void doExecute() { if (newTopKey.equals(history.top())) { dispatch(history, Direction.REPLACE); return; } History.Builder builder = history.buildUpon(); int count = 0; // Search backward to see if we already have newTop on the stack Object preservedInstance = null; for (Iterator<Object> it = history.reverseIterator(); it.hasNext(); ) { Object entry = it.next(); // If we find newTop on the stack, pop back to it. if (entry.equals(newTopKey)) { for (int i = 0; i < history.size() - count; i++) { preservedInstance = builder.pop(); } break; } else { count++; } } History newHistory; if (preservedInstance != null) { // newTop was on the history. Put the preserved instance back on and dispatch. builder.push(preservedInstance); newHistory = builder.build(); dispatch(newHistory, Direction.BACKWARD); } else { // newTop was not on the history. Push it on and dispatch. builder.push(newTopKey); newHistory = builder.build(); dispatch(newHistory, Direction.FORWARD); } } }); } /** * Go back one key. Typically called from {@link Activity#onBackPressed()}, with * the return value determining whether or not to call super. E.g. * <pre> * public void onBackPressed() { * if (!Flow.get(this).goBack()) { * super.onBackPressed(); * } * } * </pre> * * @return false if going back is not possible. */ @CheckResult public boolean goBack() { boolean canGoBack = history.size() > 1 || (pendingTraversal != null && pendingTraversal.state != TraversalState.FINISHED); if (!canGoBack) return false; move(new PendingTraversal() { @Override void doExecute() { if (history.size() <= 1) { // The history shrank while this op was pending. It happens, let's // no-op. See lengthy discussions: // https://github.com/square/flow/issues/195 // https://github.com/square/flow/pull/197 return; } History.Builder builder = history.buildUpon(); builder.pop(); final History newHistory = builder.build(); dispatch(newHistory, Direction.BACKWARD); } }); return true; } private void move(PendingTraversal pendingTraversal) { if (this.pendingTraversal == null) { this.pendingTraversal = pendingTraversal; // If there is no dispatcher wait until one shows up before executing. if (dispatcher != null) pendingTraversal.execute(); } else { this.pendingTraversal.enqueue(pendingTraversal); } } private static History preserveEquivalentPrefix(History current, History proposed) { Iterator<Object> oldIt = current.reverseIterator(); Iterator<Object> newIt = proposed.reverseIterator(); History.Builder preserving = current.buildUpon().clear(); while (newIt.hasNext()) { Object newEntry = newIt.next(); if (!oldIt.hasNext()) { preserving.push(newEntry); break; } Object oldEntry = oldIt.next(); if (oldEntry.equals(newEntry)) { preserving.push(oldEntry); } else { preserving.push(newEntry); break; } } while (newIt.hasNext()) { preserving.push(newIt.next()); } return preserving.build(); } private enum TraversalState { /** {@link PendingTraversal#execute} has not been called. */ ENQUEUED, /** * {@link PendingTraversal#execute} was called, waiting for {@link * PendingTraversal#onTraversalCompleted}. */ DISPATCHED, /** * {@link PendingTraversal#onTraversalCompleted} was called. */ FINISHED } private abstract class PendingTraversal implements TraversalCallback { TraversalState state = TraversalState.ENQUEUED; PendingTraversal next; History nextHistory; void enqueue(PendingTraversal pendingTraversal) { if (this.next == null) { this.next = pendingTraversal; } else { this.next.enqueue(pendingTraversal); } } @Override public void onTraversalCompleted() { if (state != TraversalState.DISPATCHED) { throw new IllegalStateException( state == TraversalState.FINISHED ? "onComplete already called for this transition" : "transition not yet dispatched!"); } // Is not set by noop and bootstrap transitions. if (nextHistory != null) { tearDownKeys.add(history.top()); history = nextHistory; } state = TraversalState.FINISHED; pendingTraversal = next; if (pendingTraversal == null) { final Iterator<Object> it = tearDownKeys.iterator(); while (it.hasNext()) { keyManager.tearDown(it.next()); it.remove(); } keyManager.clearStatesExcept(history.asList()); } else if (dispatcher != null) { pendingTraversal.execute(); } } void bootstrap(History history, boolean restore) { if (dispatcher == null) { throw new AssertionError("Bad doExecute method allowed dispatcher to be cleared"); } if (!restore) { keyManager.setUp(history.top()); } dispatcher.dispatch(new Traversal(null, history, Direction.REPLACE, keyManager), this); } void dispatch(History nextHistory, Direction direction) { this.nextHistory = checkNotNull(nextHistory, "nextHistory"); if (dispatcher == null) { throw new AssertionError("Bad doExecute method allowed dispatcher to be cleared"); } keyManager.setUp(nextHistory.top()); dispatcher.dispatch(new Traversal(getHistory(), nextHistory, direction, keyManager), this); } final void execute() { if (state != TraversalState.ENQUEUED) throw new AssertionError("unexpected state " + state); if (dispatcher == null) throw new AssertionError("Caller must ensure that dispatcher is set"); state = TraversalState.DISPATCHED; doExecute(); } /** * Must be synchronous and end with a call to {@link #dispatch} or {@link * #onTraversalCompleted()}. */ abstract void doExecute(); } }