package de.blau.android.osm;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map.Entry;
import java.util.TreeMap;
import org.acra.ACRA;
import android.util.Log;
import de.blau.android.Logic;
import de.blau.android.exception.StorageException;
/**
* This class provides undo support.
* It is absolutely critical that {@link StorageDelegator} calls {@link #save(OsmElement)} each and every time
* something changes, as otherwise undo will create inconsistencies.
*
* Checkpoints should be created at appropriate intervals, e.g. before each user action is performed, using
* {@link #createCheckpoint(String)}.
*
* The undo function works by storing the original state of each {@link OsmElement} before changes are performed
* in each check point. As soon as a state is saved, any further changes within the same checkpoint will be
* ignored, as the state at the beginning of the checkpoint is already stored.
*
* On undo, the state is restored. This includes not only the values of the element, but also to its presence
* in the currentStorage and apiStorage. For this reason, the state includes whether the element was in each
* of the storages, and on undo, it will be added or deleted if necessary.
*
* Avoid calling functions that change the state from other threads except the main one.
* This may mess up your menu due to calls to updateIcon. You have been warned.
*
* @author Jan Schejbal
*/
public class UndoStorage implements Serializable {
private static final long serialVersionUID = 2L;
private static final String TAG = "UndoStorage";
// Original storages for "contains" checks and restoration
private Storage currentStorage;
private final Storage apiStorage;
private final LinkedList<Checkpoint> undoCheckpoints = new LinkedList<Checkpoint>();
private final LinkedList<Checkpoint> redoCheckpoints = new LinkedList<Checkpoint>();
/**
* Creates a new UndoStorage.
* You need to pass the storage objects to which changes are applied.
* Please ensure that any time the {@link Logic} starts to use different objects,
* a new UndoStorage pointing to the correct objects is created.
* Otherwise, undo will mess up your data badly.
*
* @param currentStorage the currentStorage in use
* @param apiStorage the apiStorage in use
*/
public UndoStorage(Storage currentStorage, Storage apiStorage) {
this.currentStorage = currentStorage;
this.apiStorage = apiStorage;
}
/**
* Set currentStorage without creating a new instance
* @param currentStorage
*/
public void setCurrentStorage(Storage currentStorage) {
this.currentStorage = currentStorage;
}
/**
* Call to create a new checkpoint. When the user performs an undo operation,
* the state will be reverted to what it was at the last checkpoint.
* Checkpoints should NOT be created checkpoints for changes that are made as part of other operations.
* For this reason, checkpoints usually need to be triggered in {@link Logic}, not {@link StorageDelegator}.
* @param name the name of the checkpoint, used for debugging and display purposes
*/
public void createCheckpoint(String name) {
Log.d("UndoStorage", "creating checkpoint " + name);
if (undoCheckpoints.isEmpty() || !undoCheckpoints.getLast().isEmpty()) {
undoCheckpoints.add(new Checkpoint(name));
redoCheckpoints.clear();
} else {
// Empty checkpoint exists, just rename it
Log.d("UndoStorage", "renaming checkpoint " + name);
undoCheckpoints.getLast().name = name;
}
while (undoCheckpoints.size() > 100) {
undoCheckpoints.removeFirst();
}
}
/**
* remove checkpoint from list. typically called when we otherwise would have an empty checkpoint at the top
*/
public void removeCheckpoint(String name) {
if (!undoCheckpoints.isEmpty() && undoCheckpoints.getLast().isEmpty() && undoCheckpoints.getLast().name.equals(name))
undoCheckpoints.removeLast();
}
/**
* Saves the current state of the element in the checkpoint. Call before any changes to the element.
* A checkpoint needs to be created first using {@link #createCheckpoint(String)},
* otherwise an error is logged and the function does nothing.
* @param element the element to save
*/
void save(OsmElement element) {
try {
if (undoCheckpoints.isEmpty()) {
Log.e(TAG, "Attempted to save without valid checkpoint - forgot to call createCheckpoint()");
return;
}
undoCheckpoints.getLast().add(element);
redoCheckpoints.clear();
} catch (Exception ex) {
ACRA.getErrorReporter().putCustomData("STATUS","NOCRASH");
ACRA.getErrorReporter().handleException(ex); // don't crash the app send a report
}
}
/**
* Performs an undo operation, restoring the state at the last undo checkpoint.
* A redo checkpoint is automatically created.
* If no checkpoint is available, an error is logged and the function does nothing.
* @return the name of the undo checkpoint used, or null if no checkpoint was available
*/
public String undo() {
if (!canUndo()) {
Log.w(TAG, "Attempted to undo, but no undo checkpoints available");
return null;
}
String name = undoCheckpoints.getLast().name;
Checkpoint redoPoint = new Checkpoint(name);
undoCheckpoints.removeLast().restore(redoPoint);
redoCheckpoints.add(redoPoint);
return name;
}
/**
* Performs an redo operation, restoring the state at the next redo checkpoint.
* A new undo checkpoint is automatically created.
* If no checkpoint is available, an error is logged and the function does nothing.
* @return the name of the redo checkpoint used, or null if no checkpoint was available
*/
public String redo() {
if (!canRedo()) {
Log.e(TAG, "Attempted to redo, but no redo checkpoints available");
return null;
}
String name = redoCheckpoints.getLast().name;
Checkpoint reundoPoint = new Checkpoint(name);
redoCheckpoints.removeLast().restore(reundoPoint);
undoCheckpoints.add(reundoPoint);
return name;
}
/**
* @return true if at least one undo checkpoint is available. The checkpoint itself is not checked for emptyness.
*/
public boolean canUndo() {
return !undoCheckpoints.isEmpty();
}
/**
* @return true if at least one redo checkpoint is available.
*/
public boolean canRedo() {
return !redoCheckpoints.isEmpty();
}
/**
* Represents an undo checkpoint to which the user can revert.
* Any time an element is <b>first</b> changed since the checkpoint was created,
* the original element state is saved.
* (This is ensured by calling {@link #add(OsmElement)} on each change - repeated changes are ignored.)
*
* The checkpoint can later be restored using {@link #restore(Checkpoint)}.
*/
private class Checkpoint implements Serializable {
private static final long serialVersionUID = 1L;
private final HashMap<OsmElement, UndoElement> elements = new HashMap<OsmElement, UndoElement>();
public String name;
public Checkpoint(String name) {
this.name = name;
}
/**
* Store the current state of the element, unless a state is already stored.
* Called before any changes to the element occur via {@link UndoStorage#save(OsmElement)}.
* @param element the element to save
*/
public void add(OsmElement element) throws IllegalArgumentException {
if (elements.containsKey(element)) return;
if (element instanceof Node) elements.put(element, new UndoNode((Node)element));
else if (element instanceof Way) elements.put(element, new UndoWay((Way)element));
else if (element instanceof Relation) elements.put(element, new UndoRelation((Relation)element));
else throw new IllegalArgumentException("Unsupported element type");
}
/**
* Restores the storages to the state at the time of the creation of this checkpoint.
* @param redoCheckpoint optional - if given, the reverted elements are added to this checkpoint
* to make a "redo" feature possible
*/
public void restore(Checkpoint redoCheckpoint) {
for (Entry<OsmElement, UndoElement> entry : elements.entrySet()) {
if (redoCheckpoint != null) redoCheckpoint.add(entry.getKey()); // save current state
entry.getValue().restore();
}
}
/**
* @return true if no elements have yet been stored in this checkpoint
*/
public boolean isEmpty() {
return elements.isEmpty();
}
/**
* @return a string representation of the Checkpoint (its name)
*/
@Override
public String toString() {
return name;
}
}
/**
* This class contains a past state of a {@link OsmElement}.
* It is stored in {@link Checkpoint}s and used to restore the state of the element on undo.
* The constructor saves the state, {@link #restore()} restores it.
* @author Jan
*/
private abstract class UndoElement implements Serializable {
private static final long serialVersionUID = 1L;
final OsmElement element;
private final long osmId;
private final long osmVersion;
private final byte state;
private final TreeMap<String, String> tags;
private final boolean inCurrentStorage;
private final boolean inApiStorage;
private final ArrayList<Relation> parentRelations;
public UndoElement(OsmElement originalElement) {
element = originalElement;
osmId = originalElement.osmId;
osmVersion = originalElement.osmVersion;
state = originalElement.state;
tags = originalElement.tags == null ? new TreeMap<String, String>() : new TreeMap<String, String>(originalElement.tags);
inCurrentStorage = currentStorage.contains(originalElement);
inApiStorage = apiStorage.contains(originalElement);
if (originalElement.parentRelations != null) {
parentRelations = new ArrayList<Relation>(originalElement.parentRelations);
} else {
parentRelations = null;
}
}
/**
* Restores the saved state of the element
*/
public void restore() {
// Restore element existence
try {
if (inCurrentStorage) currentStorage.insertElementSafe(element);
else currentStorage.removeElement(element);
if (inApiStorage) apiStorage.insertElementSafe(element);
else apiStorage.removeElement(element);
} catch (StorageException e) {
//TODO handle OOM
e.printStackTrace();
}
// restore saved values
element.osmId = osmId;
element.osmVersion = osmVersion;
element.state = state;
element.setTags(tags);
if (parentRelations != null) {
element.parentRelations = new ArrayList<Relation>();
element.parentRelations.addAll(parentRelations);
} else {
element.parentRelations = null;
}
}
public String getDescription() {
// Use the name if it exists
if (tags != null) {
String name = tags.get("name");
if (name != null && name.length() > 0) {
return name;
}
// Then the house number
String housenb = tags.get("addr:housenumber");
if (housenb != null && housenb.length() > 0) {
return "house " + housenb;
}
// Then the value of the most 'important' tag the element has
for (String tag : Tags.IMPORTANT_TAGS) {
String value = tags.get(tag);
if (value != null && value.length() > 0) {
return element.getName() + " " + tag + ":" + value;
}
}
}
// Failing the above, the OSM ID
return element.getName() + " #" + Long.toString(element.getOsmId());
}
}
/**
* Stores a past state of a node
* @see UndoElement
*/
private class UndoNode extends UndoElement implements Serializable {
private static final long serialVersionUID = 1L;
private final int lat;
private final int lon;
public UndoNode(Node originalNode) {
super(originalNode);
lat = originalNode.lat;
lon = originalNode.lon;
}
@Override
public void restore() {
super.restore();
((Node)element).lat = lat;
((Node)element).lon = lon;
}
}
/**
* Stores a past state of a way
* @see UndoElement
*/
private class UndoWay extends UndoElement implements Serializable {
private static final long serialVersionUID = 1L;
private ArrayList<Node> nodes;
public UndoWay(Way originalWay) {
super(originalWay);
nodes = new ArrayList<Node>(originalWay.nodes);
}
@Override
public void restore() {
super.restore();
((Way)element).nodes.clear();
((Way)element).nodes.addAll(nodes);
}
}
/**
* Stores a past state of a relation
* @see UndoElement
*/
private class UndoRelation extends UndoElement implements Serializable {
private static final long serialVersionUID = 1L;
private ArrayList<RelationMember> members;
public UndoRelation(Relation originalRelation) {
super(originalRelation);
members = new ArrayList<RelationMember>(originalRelation.members);
}
@Override
public void restore() {
Log.d("Undo","Restoring relation " + element.getDescription());
super.restore();
((Relation)element).members.clear();
((Relation)element).members.addAll(members);
}
}
/**
* Provides a list of names for the actions that can be undone
* @return a list of names, oldest action first (i.e. the last action will be the first to be undone)
*/
public String[] getUndoActions() {
String[] result = new String[undoCheckpoints.size()];
int i = 0;
for (Checkpoint checkpoint : undoCheckpoints) {
String message = checkpoint.name + "<br>";
for (UndoElement u:checkpoint.elements.values()) {
message = message + "<small>" + u.getDescription() + "</small><br>";
}
result[i++] = message;
}
return result;
}
/**
* Provides a list of names for the actions that can be redone
* @return a list of names, newest action first (i.e. the last action will be the first to be redone)
*/
public String[] getRedoActions() {
String[] result = new String[redoCheckpoints.size()];
int i = 0;
for (Checkpoint checkpoint : redoCheckpoints) {
String message = checkpoint.name + "<br>";
for (UndoElement u:checkpoint.elements.values()) {
message = message + "<small>" + u.getDescription() + "</small><br>";
}
result[i++] = message;
}
return result;
}
}