// Copyright 2012 Google Inc. All Rights Reserved. // // 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 com.google.collide.client.history; import com.google.collide.client.util.PathUtil; import com.google.collide.client.util.logging.Log; import com.google.collide.clientlibs.navigation.NavigationToken; import com.google.collide.json.client.JsoArray; import com.google.collide.json.client.JsoStringMap; import com.google.collide.json.shared.JsonArray; import com.google.collide.json.shared.JsonStringMap; import com.google.collide.json.shared.JsonStringMap.IterationCallback; import com.google.common.base.Preconditions; import com.google.gwt.event.shared.EventHandler; import com.google.gwt.event.shared.GwtEvent; import com.google.gwt.event.shared.SimpleEventBus; import javax.annotation.Nonnull; /** * A tier in Hierarchical History. * * We use Places to control application level navigations. Each time you * navigate to a Place, a history token is created. * * Note to self. Holy crazy generics batman! */ public abstract class Place { private class Scope { private PlaceNavigationEvent<?> currentChildPlaceNavigation = null; private final SimpleEventBus eventBus = new SimpleEventBus(); private final JsoStringMap< JsoArray<PlaceNavigationHandler<PlaceNavigationEvent<Place>>>> handlers = JsoStringMap.create(); private final JsoStringMap<Place> knownChildPlaces = JsoStringMap.create(); } /** * Used to iterate over the state key/value map present in a child * {@link PlaceNavigationEvent} to ensure that everything lines up with the * specified history token. */ private static class StateMatcher implements IterationCallback<String> { JsonStringMap<String> historyState; boolean matches; StateMatcher(JsonStringMap<String> historyState) { this.matches = true; this.historyState = historyState; } @Override public void onIteration(String key, String value) { matches = matches && (historyState.get(key) != null) && historyState.get(key).equals(value); } } /** * If we detect more than 20 {@link Place}s when walking the active set, then * we can assume we have a cycle somewhere. This acts as a bound. */ private static final int PLACE_LIMIT = 20; private static void cleanupChild(Place parent, Place child) { JsoArray<PlaceNavigationHandler<PlaceNavigationEvent<Place>>> handlers = parent.scope.handlers.get(child.getName().toLowerCase()); assert (handlers != null && handlers.size() > 0) : "Child handle disappeared from parent."; for (int j = 0, n = handlers.size(); j < n; j++) { PlaceNavigationHandler<PlaceNavigationEvent<Place>> handler = handlers.get(j); handler.cleanup(); } child.setIsActive(false, null); } private boolean createHistoryToken = true; private Place currentParentPlace; private boolean isActive = false; private final String name; private Scope scope = new Scope(); protected Place(String placeName) { this.name = placeName; } /** * @return The Place that is the parent of this Place (earlier on the active * Place stack). Will return {@code null} if this Place is not active. */ public Place getParentPlace() { return currentParentPlace; } /** * @return The current {@link PlaceNavigationEvent} that is the direct child * of this Place. */ public PlaceNavigationEvent<?> getCurrentChildPlaceNavigation() { return scope.currentChildPlaceNavigation; } /** * Walks the active {@link Place}s and returns a snapshot of the current state * of the application, which can be used to create an entry in History. */ public JsoArray<PlaceNavigationEvent<?>> collectHistorySnapshot() { return collectActiveChildPlaceNavigationEvents(); } JsoArray<PlaceNavigationEvent<?>> collectActiveChildPlaceNavigationEvents() { JsoArray<PlaceNavigationEvent<?>> snapshot = JsoArray.create(); PlaceNavigationEvent<?> child = getCurrentChildPlaceNavigation(); int placeCount = 0; while (child != null) { // Detect a cycle and shiny. if (placeCount > PLACE_LIMIT) { Log.error(getClass(), "We probably have a cycle in our Place chain!"); throw new RuntimeException("Cycle detected in Place chain!"); } if (child.getPlace().isActive()) { snapshot.add(child); placeCount++; } child = child.getPlace().getCurrentChildPlaceNavigation(); } return snapshot; } public PlaceNavigationEvent<? extends Place> createNavigationEvent() { return createNavigationEvent(JsoStringMap.<String>create()); } /** * Should create the associated {@link PlaceNavigationEvent} for the concrete * Place implementation. We pass along key/value pairs that were decoded from * the History Token, if there were any. This will be an empty, non-null map * if there were no such key/values passed in. * * Implementors are responsible for interpreting the <String,String> map and * initializing the {@link PlaceNavigationEvent} appropriately. * * @param decodedState key/value pairs encoded in the {@link HistoryPiece} * associated with this Place. * @return the {@link PlaceNavigationEvent} associated with this Place */ public abstract PlaceNavigationEvent<? extends Place> createNavigationEvent( JsonStringMap<String> decodedState); /** * This creates a child {@link PlaceNavigationEvent}s based on a * {@link HistoryPiece}. * * If there is no such child Place registered to us, then we return {@code * null}. * * @return the {@link PlaceNavigationEvent} for the child Place that is keyed * by the name present in the inputed {@link HistoryPiece}, or {@code * null} if there is no such child Place directly reachable from this * Place. */ private PlaceNavigationEvent<?> decodeChildNavigationEvent(NavigationToken childHistoryPiece) { Place childPlace = getRegisteredChild(childHistoryPiece.getPlaceName()); if (childPlace == null) { Log.warn(getClass(), "Attempting to decode a Child navigation event for a Place that was not registered to" + " us.", "Parent: ", getName(), " Potential Child: ", childHistoryPiece.getPlaceName()); return null; } return childPlace.createNavigationEvent(childHistoryPiece.getBookmarkableState()); } /** * Dispatch cleanup to current place and all its subchildren * * @param includeCurrentChildPlace whether to clean up everything or just the * subplaces */ private void dispatchCleanup(boolean includeCurrentChildPlace) { if (getCurrentChildPlaceNavigation() != null) { JsoArray<PlaceNavigationEvent<?>> activeChildren = collectActiveChildPlaceNavigationEvents(); // Decide if want to cleanup everything, or only subplaces int cleanLimit = includeCurrentChildPlace ? 0 : 1; // Walk the active subtree going bottom up, firing their cleanup handlers, // and letting them know they are inactive. for (int i = activeChildren.size() - 1; i >= cleanLimit; i--) { Place childPlace = activeChildren.get(i).getPlace(); Place place = (i > 0) ? activeChildren.get(i - 1).getPlace() : this; cleanupChild(place, childPlace); } } } /** * Takes in an array of {@link HistoryPiece}s representing Place navigations * rooted at this Place, and dispatches them in order. * * This method is intelligent, in that it will not re-dispatch Place * navigations that are already active. The last piece of the incoming history * pieces is always dispatched though. * * This method will walk the common links until it encounters one of the * following scenarios: * * 1. Our current active Place chain ran out, and we have 1 or more pieces of * history to turn into events and dispatch. * * 2. The history pieces are shorter than active Place chain (like when you * click back), in which case we simple dispatch the last item in the history * pieces, on the appropriate parent Place's scope. * * NOTE: If you call this method without first calling * {@link #disableHistorySnapshotting()}, then each tier of the history * dispatch will result in a history token. If you do disable snapshotting, * please be nice and re-enable it when you are done by calling * {@link #enableHistorySnapshotting()}. * */ public void dispatchHistory(JsonArray<NavigationToken> historyPieces) { // Terminate if there are no more pieces to dispatch. if (historyPieces.isEmpty()) { return; } NavigationToken piece = historyPieces.get(0); PlaceNavigationEvent<?> child = getCurrentChildPlaceNavigation(); // The active Place chain ran out, go ahead and dispatch for real. if (child == null || !isActive()) { dispatchHistoryNow(historyPieces); return; } // Compare child to see if it is the same. if (historyPieceMatchesPlaceEvent(child, piece)) { // We dispatch if this is the last history piece. if (historyPieces.size() == 1) { dispatchHistoryNow(historyPieces); } else { // Recurse downwards passing the remainder of the history array. child.getPlace().dispatchHistory(historyPieces.slice(1, historyPieces.size())); } return; } // If we get here, then we know that we have reached the end of the common // overlap with the active Places and the history pieces. dispatchHistoryNow(historyPieces); } void dispatchHistoryNow(JsonArray<NavigationToken> historyPieces) { // Terminate if there are no more pieces to dispatch. if (historyPieces.isEmpty()) { return; } PlaceNavigationEvent<?> childNavEvent = decodeChildNavigationEvent(historyPieces.get(0)); if (childNavEvent == null) { Log.warn(getClass(), "Attempted to dispatch a line of history rooted at: ", getName(), " but we had no such children.", historyPieces); return; } // Navigate to the child. This should invoke the PlaceNavigationHandler and // register any subsequent child Places. fireChildPlaceNavigation(childNavEvent); // Recurse downwards passing the remainder of the history array. childNavEvent.getPlace().dispatchHistoryNow(historyPieces.slice(1, historyPieces.size())); } public void disableHistorySnapshotting() { this.createHistoryToken = false; } public void enableHistorySnapshotting() { this.createHistoryToken = true; } /** * Dispatches a navigation event to the scope of this Place. */ public void fireChildPlaceNavigation(@Nonnull PlaceNavigationEvent<? extends Place> event) { @SuppressWarnings("unchecked") PlaceNavigationEvent<Place> navigationEvent = (PlaceNavigationEvent<Place>) event; // Make sure that we contain such a child registered to our scope. if (navigationEvent == null || getRegisteredChild(navigationEvent.getPlace().getName()) == null) { Log.warn(getClass(), "Attempted to navigate to a child place that was not registered to us.", navigationEvent); return; } // If we are not currently active, then we are not allowed to fire child // place navigations. if (!isActive) { Log.warn(getClass(), "Attempted to navigate to a child place when we were not active", navigationEvent, RootPlace.PLACE.collectHistorySnapshot().join(PathUtil.SEP)); return; } // Whether or not the Place we are navigating to is the same type as the // Place we are currently in. boolean isReEntrantDispatch = false; // Inform the previous active child (and all active sub Places in that // chain) that he is no longer active. if (scope.currentChildPlaceNavigation != null && scope.currentChildPlaceNavigation.getPlace().isActive()) { isReEntrantDispatch = scope.currentChildPlaceNavigation.getPlace() == navigationEvent.getPlace(); // Cleanup the old Place stack rooted at our current child place. If this // is a re-entrant dispatch, then we want to skip cleaning up ourselves. dispatchCleanup(!isReEntrantDispatch); } // Only reset the scope if we are navigating to a totally new Place. if (!isReEntrantDispatch) { // This ensures that when the child handler runs, it gets a clean scope // and therefore can't leak references to handler code. navigationEvent.getPlace().resetScope(); } JsoArray<PlaceNavigationHandler<PlaceNavigationEvent<Place>>> handlers = scope.handlers.get(navigationEvent.getPlace().getName().toLowerCase()); if (handlers == null || handlers.isEmpty()) { Log.warn(getClass(), "Firing navigation event with no registered handlers", navigationEvent); } for (int i = 0, n = handlers.size(); i < n; i++) { PlaceNavigationHandler<PlaceNavigationEvent<Place>> handler = handlers.get(i); if (isReEntrantDispatch) { handler.reEnterPlace( navigationEvent, !placesMatch(scope.currentChildPlaceNavigation, navigationEvent)); } else { handler.enterPlace(navigationEvent); } } // Tell the new one that he is active AFTER invoking place navigation // handlers. navigationEvent.getPlace().setIsActive(true, this); scope.currentChildPlaceNavigation = navigationEvent; // This should default to true. It gets set to false if we are replaying a // line of history, in which case it really doesn't make sense to snapshot // each tier of the replay. if (createHistoryToken) { // Snapshot the active history by asking the RootPlace for all active // Places. JsoArray<PlaceNavigationEvent<?>> historySnapshot = RootPlace.PLACE.collectHistorySnapshot(); // Set the History string now. HistoryUtils.createHistoryEntry(historySnapshot); } } /** * Dispatches a sequence of navigation events to the scope of this Place. * * The navigationEvents must be non-null. * * NOTE: If you call this method without first calling * {@link #disableHistorySnapshotting()}, then each tier of the history * dispatch will result in a history token. If you do disable snapshotting, * please be nice and re-enable it when you are done by calling * {@link #enableHistorySnapshotting()}. */ public void fireChildPlaceNavigations(JsoArray<PlaceNavigationEvent<?>> navigationEvents) { // Terminate if there are no more pieces to dispatch. if (navigationEvents.isEmpty()) { return; } Place place = this; for (int i = 0, n = navigationEvents.size(); i < n; i++) { PlaceNavigationEvent<?> childNavEvent = navigationEvents.get(i); // Navigate to the child. This should invoke the PlaceNavigationHandler // and register any subsequent child Places. place.fireChildPlaceNavigation(childNavEvent); place = childNavEvent.getPlace(); } } /** * Dispatches an event on our Place's Scope and all currently active child * Places. */ public void fireEvent(GwtEvent<?> event) { Place currPlace = this; while (currPlace != null && currPlace.isActive()) { fireEventInScope(currPlace, event); PlaceNavigationEvent<?> activeChildNavigation = currPlace.getCurrentChildPlaceNavigation(); currPlace = (activeChildNavigation == null) ? null : activeChildNavigation.getPlace(); } } /** * Dispatches an event on the specified Place's Scope {@link SimpleEventBus}. */ private void fireEventInScope(Place place, GwtEvent<?> event) { // If we are not currently active, then we are not allowed to fire anything. if (!isActive) { Log.warn(getClass(), "Attempted to fire a simple event when we were not active", event); return; } place.scope.eventBus.fireEvent(event); } public String getName() { return name; } protected Place getRegisteredChild(String childName) { return scope.knownChildPlaces.get(childName.toLowerCase()); } private boolean historyPieceMatchesPlaceEvent( PlaceNavigationEvent<?> event, NavigationToken historyPiece) { // First match the name. We use toLowerCase() to make it resilient to users // typing in URLs and mixing up cases. final boolean namesMatch = event.getPlace().getName().toLowerCase().equals(historyPiece.getPlaceName().toLowerCase()); // We also want to make sure the state that was passed in the parsed history // match our live state. StateMatcher matcher = new StateMatcher(historyPiece.getBookmarkableState()); // Now we match all state key/values. JsonStringMap<String> state = event.getBookmarkableState(); state.iterate(matcher); return namesMatch && matcher.matches; } /** * Compare two Places (including parameters) to see if they match * * We say a place matches if all the state in the Place we are navigating to * is already contained in the current Place. */ private boolean placesMatch(PlaceNavigationEvent<?> current, PlaceNavigationEvent<?> next) { if (current == null) { return false; } StateMatcher matcher = new StateMatcher(current.getBookmarkableState()); JsonStringMap<String> state = next.getBookmarkableState(); state.iterate(matcher); return matcher.matches; } public boolean isActive() { return isActive; } public boolean isLeaf() { PlaceNavigationEvent<?> activeChildPlaceNavigation = getCurrentChildPlaceNavigation(); return activeChildPlaceNavigation == null || !activeChildPlaceNavigation.getPlace().isActive(); } /** * @return true if this navigation event is active and is the leaf of the * place chain. */ public boolean isActiveLeaf() { return isLeaf() && isActive(); } /** * Registers a {@link PlaceNavigationHandler} to deal with navigations to a * particular child Place. * * <p>Subclasses are encouraged to restrict the types of children Places that * can be registered in their public API. But this API is still visible and * doesn't restrict the type of Place that can be added as a child. * * @param <C> subclass of {@link Place} that is the child place we are * registering. <C> must a Place that is initialized by handler * of appropriate type */ public <C extends Place> void registerChildHandler( C childPlace, PlaceNavigationHandler<? extends PlaceNavigationEvent<C>> handler) { String placeName = childPlace.getName().toLowerCase(); JsoArray<PlaceNavigationHandler<PlaceNavigationEvent<Place>>> placeHandlers = scope.handlers.get(placeName); if (placeHandlers == null) { placeHandlers = JsoArray.create(); } @SuppressWarnings("unchecked") // We promise this cast is okay... PlaceNavigationHandler<PlaceNavigationEvent<Place>> placeNavigationHandler = (PlaceNavigationHandler<PlaceNavigationEvent<Place>>) ((Object) handler); placeHandlers.add(placeNavigationHandler); scope.handlers.put(placeName, placeHandlers); scope.knownChildPlaces.put(placeName, childPlace); } /** * Registers an {@link EventHandler} on our Scope's {@link SimpleEventBus}. * Dispatches on * * @param <T> the type of the {@link EventHandler} */ public <T extends EventHandler> void registerSimpleEventHandler( GwtEvent.Type<T> eventType, T handler) { scope.eventBus.addHandler(eventType, handler); } void resetScope() { scope = new Scope(); } /** * Sets whether or not this Place is currently active. A Place is not allowed * to dispatch to its scope if it is not active. * * Note that this method is protected and not private simply because the * {@link RootPlace} needs to be able to set this. */ protected void setIsActive(boolean isActive, Place currentParentPlace) { this.isActive = isActive; this.currentParentPlace = currentParentPlace; } /** * Leaves the current place, re-entering its parent */ public void leave() { /* * This precondition is somewhat arbitrary, as long as a place is active and * not RootPlace then it should be fine to leave it. For now it's nice since * it restricts the scope of this method. */ Preconditions.checkState(isActiveLeaf(), "Place must be the active leaf to be left"); /* * TODO: This implementation means you can't leave an immediate * child of the RootPlace, which seems okay for now. When we rewrite the * place framework we'll make this more general. */ Place parent = getParentPlace(); Preconditions.checkNotNull(parent, "Parent cannot be null"); Place grandParent = parent.getParentPlace(); Preconditions.checkNotNull(grandParent, "Grandparent cannot be null"); grandParent.fireChildPlaceNavigation(parent.getCurrentChildPlaceNavigation()); } @Override public String toString() { return "Place{name=" + name + ", isActive=" + isActive + "}"; } }