/* * Copyright 2013 serso aka se.solovyev * * 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. * * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Contact details * * Email: se.solovyev@gmail.com * Site: http://se.solovyev.org */ package org.solovyev.android.calculator.history; import android.app.Application; import android.content.SharedPreferences; import android.os.Handler; import android.support.annotation.NonNull; import android.text.TextUtils; import com.google.common.base.Strings; import com.squareup.otto.Bus; import com.squareup.otto.Subscribe; import org.json.JSONArray; import org.json.JSONException; import org.solovyev.android.Check; import org.solovyev.android.calculator.AppModule; import org.solovyev.android.calculator.Calculator; import org.solovyev.android.calculator.Display; import org.solovyev.android.calculator.DisplayState; import org.solovyev.android.calculator.Editor; import org.solovyev.android.calculator.EditorState; import org.solovyev.android.calculator.Engine.Preferences; import org.solovyev.android.calculator.ErrorReporter; import org.solovyev.android.calculator.Runnables; import org.solovyev.android.calculator.json.Json; import org.solovyev.android.io.FileSystem; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.concurrent.Executor; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; import static android.text.TextUtils.isEmpty; @Singleton public class History { public static final String OLD_HISTORY_PREFS_KEY = "org.solovyev.android.calculator.CalculatorModel_history"; private static final ClearedEvent CLEARED_EVENT_RECENT = new ClearedEvent(true); private static final ClearedEvent CLEARED_EVENT_SAVED = new ClearedEvent(false); private static final int MAX_INTERMEDIATE_STREAK = 5; @NonNull private final Runnable writeRecent = new WriteTask(true); @NonNull private final Runnable writeSaved = new WriteTask(false); @Nonnull private final RecentHistory recent = new RecentHistory(); @Nonnull private final List<HistoryState> saved = new ArrayList<>(); @Nonnull private final Runnables whenLoadedRunnables = new Runnables(); private boolean loaded; @Inject Application application; @Inject Bus bus; @Inject Handler handler; @Inject SharedPreferences preferences; @Inject Editor editor; @Inject Display display; @Inject ErrorReporter errorReporter; @Inject FileSystem fileSystem; @Inject @Named(AppModule.THREAD_BACKGROUND) Executor backgroundThread; @Inject @Named(AppModule.DIR_FILES) File filesDir; @Nullable static List<HistoryState> convertOldHistory(@NonNull String xml) throws Exception { final OldHistory history = OldHistory.fromXml(xml); if (history == null) { // strange, history seems to be broken. Avoid clearing the preference return null; } final List<HistoryState> states = new ArrayList<>(); for (OldHistoryState state : history.getItems()) { final OldEditorHistoryState oldEditor = state.getEditorState(); final OldDisplayHistoryState oldDisplay = state.getDisplayState(); final String editorText = oldEditor.getText(); final EditorState editor = EditorState.create(Strings.nullToEmpty(editorText), oldEditor.getCursorPosition()); final DisplayState display = DisplayState.createValid(oldDisplay.getJsclOperation(), null, Strings.nullToEmpty(oldDisplay.getEditorState().getText()), Calculator.NO_SEQUENCE); states.add(HistoryState.builder(editor, display).withTime(state.getTime()).withComment(state.getComment()).build()); } return states; } private static boolean isIntermediate(@Nonnull String olderText, @Nonnull String newerText, char separator) { if (TextUtils.isEmpty(olderText)) { return true; } if (TextUtils.isEmpty(newerText)) { return false; } olderText = trimGroupingSeparators(olderText, separator); newerText = trimGroupingSeparators(newerText, separator); final int diff = newerText.length() - olderText.length(); if (diff >= 1) { return newerText.startsWith(olderText); } else if (diff <= 1) { return olderText.startsWith(newerText); } else if (diff == 0) { return olderText.equals(newerText); } return false; } @NonNull static String trimGroupingSeparators(@NonNull String text, char separator) { if (separator == 0) { return text; } final StringBuilder sb = new StringBuilder(text.length()); for (int i = 0; i < text.length(); i++) { if (i == 0 || i == text.length() - 1) { // grouping separator can't be the first and the last character sb.append(text.charAt(i)); continue; } if (Character.isDigit(text.charAt(i - 1)) && text.charAt(i) == separator && Character.isDigit(text.charAt(i + 1))) { // grouping separator => skip continue; } sb.append(text.charAt(i)); } return sb.toString(); } @Inject public History() { } public void init(@NonNull Executor initThread) { Check.isMainThread(); bus.register(this); initThread.execute(new Runnable() { @Override public void run() { initAsync(); } }); } void setLoaded(boolean loaded) { this.loaded = loaded; } @NonNull File getSavedHistoryFile() { return new File(filesDir, "history-saved.json"); } @NonNull File getRecentHistoryFile() { return new File(filesDir, "history-recent.json"); } private void migrateOldHistory() { try { final String xml = preferences.getString(OLD_HISTORY_PREFS_KEY, null); if (isEmpty(xml)) { return; } final List<HistoryState> states = convertOldHistory(xml); if (states == null) { return; } final JSONArray json = Json.toJson(states); fileSystem.write(getSavedHistoryFile(), json.toString()); preferences.edit().remove(OLD_HISTORY_PREFS_KEY).apply(); } catch (Exception e) { errorReporter.onException(e); } } private void initAsync() { Check.isNotMainThread(); migrateOldHistory(); final List<HistoryState> recentStates = tryLoadStates(getRecentHistoryFile()); final List<HistoryState> savedStates = tryLoadStates(getSavedHistoryFile()); handler.post(new Runnable() { @Override public void run() { onLoaded(recentStates, savedStates); } }); } private void onLoaded(@NonNull List<HistoryState> recentStates, @NonNull List<HistoryState> savedStates) { Check.isTrue(saved.isEmpty()); Check.isMainThread(); final boolean wasEmpty = recent.isEmpty(); recent.addInitial(recentStates); saved.addAll(savedStates); if (wasEmpty) { // user has typed nothing while we were loading, let's use recent history to restore // editor state editor.onHistoryLoaded(recent); } else { // user has types something => we should schedule save postRecentWrite(); } loaded = true; whenLoadedRunnables.run(); } @Nonnull private List<HistoryState> tryLoadStates(@NonNull File file) { try { return Json.load(file, fileSystem, HistoryState.JSON_CREATOR); } catch (IOException | JSONException e) { errorReporter.onException(e); } return Collections.emptyList(); } public void addRecent(@Nonnull HistoryState state) { Check.isMainThread(); if (recent.isEmpty() && state.isEmpty()) { // don't add empty states to empty history return; } if (recent.add(state)) { onRecentChanged(new AddedEvent(state, true)); } } public void updateSaved(@Nonnull HistoryState state) { Check.isMainThread(); final int i = saved.indexOf(state); if(i >= 0) { saved.set(i, state); onSavedChanged(new UpdatedEvent(state, false)); } else { saved.add(state); onSavedChanged(new AddedEvent(state, false)); } } private void onRecentChanged(@Nonnull Object event) { postRecentWrite(); bus.post(event); } private void postRecentWrite() { handler.removeCallbacks(writeRecent); handler.postDelayed(writeRecent, 5000); } private void onSavedChanged(@Nonnull Object event) { postSavedWrite(); bus.post(event); } private void postSavedWrite() { handler.removeCallbacks(writeSaved); handler.postDelayed(writeSaved, 500); } @Nonnull public List<HistoryState> getRecent() { return getRecent(true); } @Nonnull private List<HistoryState> getRecent(boolean forUi) { Check.isMainThread(); final List<HistoryState> result = new LinkedList<>(); final char separator = Preferences.Output.separator.getPreference(preferences); final List<HistoryState> states = recent.asList(); final int statesCount = states.size(); int streak = 0; for (int i = 1; i < statesCount; i++) { final HistoryState olderState = states.get(i - 1); final HistoryState newerState = states.get(i); final String olderText = olderState.editor.getTextString(); final String newerText = newerState.editor.getTextString(); if (streak >= MAX_INTERMEDIATE_STREAK || !isIntermediate(olderText, newerText, separator)) { result.add(0, olderState); streak = 0; } else { streak++; } } if (statesCount > 0) { // try add last state if not empty final HistoryState state = states.get(statesCount - 1); if (!state.editor.isEmpty() || !forUi) { result.add(0, state); } } return result; } @Nonnull public List<HistoryState> getSaved() { Check.isMainThread(); return new ArrayList<>(saved); } public void clearRecent() { Check.isMainThread(); recent.clear(); onRecentChanged(CLEARED_EVENT_RECENT); } public void clearSaved() { Check.isMainThread(); saved.clear(); onSavedChanged(CLEARED_EVENT_SAVED); } public void undo() { final HistoryState state = recent.undo(); if (state == null) { return; } applyHistoryState(state); } public void redo() { final HistoryState state = recent.redo(); if (state == null) { return; } applyHistoryState(state); } private void applyHistoryState(@Nonnull HistoryState state) { editor.setState(state.editor); display.setState(state.display); } public void removeSaved(@Nonnull HistoryState state) { Check.isMainThread(); saved.remove(state); onSavedChanged(new RemovedEvent(state, false)); } @Subscribe public void onDisplayChanged(@Nonnull Display.ChangedEvent e) { final EditorState editorState = editor.getState(); final DisplayState displayState = e.newState; if (editorState.sequence != displayState.sequence) { return; } addRecent(HistoryState.builder(editorState, displayState).build()); } public boolean isLoaded() { return loaded; } public void runWhenLoaded(@NonNull Runnable runnable) { Check.isTrue(!loaded); whenLoadedRunnables.add(runnable); } public static class ClearedEvent { public final boolean recent; ClearedEvent(boolean recent) { this.recent = recent; } } public static class RemovedEvent extends StateEvent { RemovedEvent(@Nonnull HistoryState state, boolean recent) { super(state, recent); } } public static class AddedEvent extends StateEvent { AddedEvent(@Nonnull HistoryState state, boolean recent) { super(state, recent); } } public static class UpdatedEvent extends StateEvent { UpdatedEvent(@Nonnull HistoryState state, boolean recent) { super(state, recent); } } public abstract static class StateEvent { @Nonnull public final HistoryState state; public final boolean recent; protected StateEvent(@Nonnull HistoryState state, boolean recent) { this.state = state; this.recent = recent; } } private class WriteTask implements Runnable { private final boolean recent; public WriteTask(boolean recent) { this.recent = recent; } @Override public void run() { Check.isMainThread(); if (!loaded) { return; } // don't need to save intermediate states, thus {@link History#getRecent} final List<HistoryState> states = recent ? getRecent(false) : getSaved(); backgroundThread.execute(new Runnable() { @Override public void run() { final File file = recent ? getRecentHistoryFile() : getSavedHistoryFile(); final JSONArray array = Json.toJson(states); fileSystem.writeSilently(file, array.toString()); } }); } } }