diff --git a/app/build.gradle b/app/build.gradle index 5c9ef9d71..2396ab7f0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,7 +12,7 @@ android { minSdkVersion 15 targetSdkVersion 25 - buildConfigField "Integer", "databaseVersion", "18" + buildConfigField "Integer", "databaseVersion", "19" buildConfigField "String", "databaseFilename", "\"uhabits.db\"" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" @@ -80,6 +80,10 @@ dependencies { provided 'javax.annotation:jsr250-api:1.0' + compile ('io.socket:socket.io-client:0.7.0') { + exclude group: 'org.json', module: 'json' + } + testApt 'com.google.dagger:dagger-compiler:2.2' testCompile 'junit:junit:4.12' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2a9260417..bf1bf2571 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,8 @@ + + selectedHabits) + public ArchiveHabitsCommand(@NonNull HabitList habitList, + @NonNull List selectedHabits) + { + super(); + this.habitList = habitList; + this.selectedHabits = selectedHabits; + } + + public ArchiveHabitsCommand(@NonNull String id, + @NonNull HabitList habitList, + @NonNull List selectedHabits) { + super(id); this.habitList = habitList; this.selectedHabits = selectedHabits; } + public static Command fromJSON(@NonNull JSONObject json, + @NonNull HabitList habitList) + throws JSONException + { + String id = json.getString("id"); + JSONObject data = (JSONObject) json.get("data"); + JSONArray habitIds = data.getJSONArray("ids"); + + LinkedList selectedHabits = + habitListFromJSON(habitList, habitIds); + return new ArchiveHabitsCommand(id, habitList, selectedHabits); + } + @Override public void execute() { @@ -58,6 +87,24 @@ public class ArchiveHabitsCommand extends Command return R.string.toast_habit_unarchived; } + @Nullable + @Override + public JSONObject toJSON() + { + try + { + JSONObject root = super.toJSON(); + JSONObject data = root.getJSONObject("data"); + root.put("event", "ArchiveHabits"); + data.put("ids", habitListToJSON(selectedHabits)); + return root; + } + catch (JSONException e) + { + throw new RuntimeException(e.getMessage()); + } + } + @Override public void undo() { diff --git a/app/src/main/java/org/isoron/uhabits/commands/ChangeHabitColorCommand.java b/app/src/main/java/org/isoron/uhabits/commands/ChangeHabitColorCommand.java index 503acc40f..6b4f0ddc8 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/ChangeHabitColorCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/ChangeHabitColorCommand.java @@ -19,11 +19,16 @@ package org.isoron.uhabits.commands; +import android.support.annotation.*; + import org.isoron.uhabits.*; import org.isoron.uhabits.models.*; +import org.json.*; import java.util.*; +import static org.isoron.uhabits.commands.CommandParser.*; + /** * Command to change the color of a list of habits. */ @@ -37,16 +42,35 @@ public class ChangeHabitColorCommand extends Command Integer newColor; - public ChangeHabitColorCommand(HabitList habitList, - List selected, - Integer newColor) + public ChangeHabitColorCommand(@NonNull HabitList habitList, + @NonNull List selected, + @NonNull Integer newColor) { - this.habitList = habitList; - this.selected = selected; - this.newColor = newColor; - this.originalColors = new ArrayList<>(selected.size()); + super(); + init(habitList, selected, newColor); + } - for (Habit h : selected) originalColors.add(h.getColor()); + public ChangeHabitColorCommand(@NonNull String id, + @NonNull HabitList habitList, + @NonNull List selected, + @NonNull Integer newColor) + { + super(id); + init(habitList, selected, newColor); + } + + @NonNull + public static Command fromJSON(@NonNull JSONObject json, + @NonNull HabitList habitList) + throws JSONException + { + String id = json.getString("id"); + JSONObject data = (JSONObject) json.get("data"); + JSONArray habitIds = data.getJSONArray("ids"); + int newColor = data.getInt("color"); + + LinkedList selected = habitListFromJSON(habitList, habitIds); + return new ChangeHabitColorCommand(id, habitList, selected, newColor); } @Override @@ -68,6 +92,25 @@ public class ChangeHabitColorCommand extends Command return R.string.toast_habit_changed; } + @Override + @NonNull + public JSONObject toJSON() + { + try + { + JSONObject root = super.toJSON(); + JSONObject data = root.getJSONObject("data"); + root.put("event", "ChangeHabitColor"); + data.put("ids", habitListToJSON(selected)); + data.put("color", newColor); + return root; + } + catch (JSONException e) + { + throw new RuntimeException(e.getMessage()); + } + } + @Override public void undo() { @@ -75,4 +118,16 @@ public class ChangeHabitColorCommand extends Command for (Habit h : selected) h.setColor(originalColors.get(k++)); habitList.update(selected); } + + private void init(@NonNull HabitList habitList, + @NonNull List selected, + @NonNull Integer newColor) + { + this.habitList = habitList; + this.selected = selected; + this.newColor = newColor; + this.originalColors = new ArrayList<>(selected.size()); + + for (Habit h : selected) originalColors.add(h.getColor()); + } } diff --git a/app/src/main/java/org/isoron/uhabits/commands/Command.java b/app/src/main/java/org/isoron/uhabits/commands/Command.java index e319a5095..e6e397d51 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/Command.java +++ b/app/src/main/java/org/isoron/uhabits/commands/Command.java @@ -19,6 +19,9 @@ package org.isoron.uhabits.commands; +import org.isoron.uhabits.utils.*; +import org.json.*; + /** * A Command represents a desired set of changes that should be performed on the * models. @@ -30,6 +33,18 @@ package org.isoron.uhabits.commands; */ public abstract class Command { + private final String id; + + public Command() + { + id = DatabaseUtils.getRandomId(); + } + + public Command(String id) + { + this.id = id; + } + public abstract void execute(); public Integer getExecuteStringId() @@ -43,4 +58,25 @@ public abstract class Command } public abstract void undo(); + + public JSONObject toJSON() + { + try + { + JSONObject root = new JSONObject(); + JSONObject data = new JSONObject(); + root.put("id", getId()); + root.put("data", data); + return root; + } + catch (JSONException e) + { + throw new RuntimeException(e.getMessage()); + } + } + + public String getId() + { + return id; + } } diff --git a/app/src/main/java/org/isoron/uhabits/commands/CommandParser.java b/app/src/main/java/org/isoron/uhabits/commands/CommandParser.java new file mode 100644 index 000000000..4519d673f --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/commands/CommandParser.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.commands; + +import android.support.annotation.*; + +import org.isoron.uhabits.models.*; +import org.json.*; + +import java.util.*; + +public class CommandParser +{ + + private HabitList habitList; + + private ModelFactory modelFactory; + + public CommandParser(@NonNull HabitList habitList, + @NonNull ModelFactory modelFactory) + { + this.habitList = habitList; + this.modelFactory = modelFactory; + } + + @NonNull + public static LinkedList habitListFromJSON( + @NonNull HabitList habitList, @NonNull JSONArray habitIds) + throws JSONException + { + LinkedList habits = new LinkedList<>(); + + for (int i = 0; i < habitIds.length(); i++) + { + Long hId = habitIds.getLong(i); + Habit h = habitList.getById(hId); + if (h == null) continue; + + habits.add(h); + } + + return habits; + } + + @NonNull + protected static JSONArray habitListToJSON(List habits) + { + JSONArray habitIds = new JSONArray(); + for (Habit h : habits) habitIds.put(h.getId()); + return habitIds; + } + + @NonNull + public Command fromJSON(@NonNull JSONObject json) throws JSONException + { + switch (json.getString("event")) + { + case "ToggleRepetition": + return ToggleRepetitionCommand.fromJSON(json, habitList); + + case "ArchiveHabits": + return ArchiveHabitsCommand.fromJSON(json, habitList); + + case "UnarchiveHabits": + return UnarchiveHabitsCommand.fromJSON(json, habitList); + + case "ChangeHabitColor": + return ChangeHabitColorCommand.fromJSON(json, habitList); + + case "CreateHabit": + return CreateHabitCommand.fromJSON(json, habitList, + modelFactory); + + case "DeleteHabits": + return DeleteHabitsCommand.fromJSON(json, habitList); + + case "EditHabit": + return EditHabitCommand.fromJSON(json, habitList, modelFactory); + +// TODO: Implement this +// case "ReorderHabit": +// return ReorderHabitCommand.fromJSON(json); + + } + + return null; + } +} diff --git a/app/src/main/java/org/isoron/uhabits/commands/CreateHabitCommand.java b/app/src/main/java/org/isoron/uhabits/commands/CreateHabitCommand.java index 51f08c482..792d60f79 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/CreateHabitCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/CreateHabitCommand.java @@ -25,6 +25,7 @@ import com.google.auto.factory.*; import org.isoron.uhabits.*; import org.isoron.uhabits.models.*; +import org.json.*; /** * Command to create a habit. @@ -36,19 +37,50 @@ public class CreateHabitCommand extends Command HabitList habitList; + @NonNull private Habit model; + @Nullable private Long savedId; public CreateHabitCommand(@Provided @NonNull ModelFactory modelFactory, @NonNull HabitList habitList, @NonNull Habit model) { + super(); this.modelFactory = modelFactory; this.habitList = habitList; this.model = model; } + public CreateHabitCommand(@Provided @NonNull ModelFactory modelFactory, + @NonNull String commandId, + @NonNull HabitList habitList, + @NonNull Habit model, + @Nullable Long savedId) + { + super(commandId); + this.modelFactory = modelFactory; + this.habitList = habitList; + this.model = model; + this.savedId = savedId; + } + + @NonNull + public static Command fromJSON(@NonNull JSONObject root, + @NonNull HabitList habitList, + @NonNull ModelFactory modelFactory) + throws JSONException + { + String commandId = root.getString("id"); + JSONObject data = (JSONObject) root.get("data"); + Habit model = Habit.fromJSON(data.getJSONObject("habit"), modelFactory); + Long savedId = data.getLong("id"); + + return new CreateHabitCommand(modelFactory, commandId, habitList, model, + savedId); + } + @Override public void execute() { @@ -72,6 +104,24 @@ public class CreateHabitCommand extends Command return R.string.toast_habit_deleted; } + @Override + public JSONObject toJSON() + { + try + { + JSONObject root = super.toJSON(); + JSONObject data = root.getJSONObject("data"); + root.put("event", "CreateHabit"); + data.put("habit", model.toJSON()); + data.put("id", savedId); + return root; + } + catch (JSONException e) + { + throw new RuntimeException(e.getMessage()); + } + } + @Override public void undo() { @@ -80,5 +130,4 @@ public class CreateHabitCommand extends Command habitList.remove(habit); } - } \ No newline at end of file diff --git a/app/src/main/java/org/isoron/uhabits/commands/DeleteHabitsCommand.java b/app/src/main/java/org/isoron/uhabits/commands/DeleteHabitsCommand.java index ca184d8bf..17c28b08f 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/DeleteHabitsCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/DeleteHabitsCommand.java @@ -19,11 +19,17 @@ package org.isoron.uhabits.commands; +import android.support.annotation.*; + import org.isoron.uhabits.*; import org.isoron.uhabits.models.*; +import org.json.*; import java.util.*; +import static org.isoron.uhabits.commands.CommandParser.habitListFromJSON; +import static org.isoron.uhabits.commands.CommandParser.habitListToJSON; + /** * Command to delete a list of habits. */ @@ -33,12 +39,36 @@ public class DeleteHabitsCommand extends Command private List habits; - public DeleteHabitsCommand(HabitList habitList, List habits) + public DeleteHabitsCommand(@NonNull HabitList habitList, + @NonNull List habits) + { + super(); + this.habits = habits; + this.habitList = habitList; + } + + public DeleteHabitsCommand(@NonNull String id, + @NonNull HabitList habitList, + @NonNull List habits) { + super(id); this.habits = habits; this.habitList = habitList; } + @NonNull + public static Command fromJSON(@NonNull JSONObject json, + @NonNull HabitList habitList) + throws JSONException + { + String id = json.getString("id"); + JSONObject data = (JSONObject) json.get("data"); + JSONArray habitIds = data.getJSONArray("ids"); + + LinkedList habits = habitListFromJSON(habitList, habitIds); + return new DeleteHabitsCommand(id, habitList, habits); + } + @Override public void execute() { @@ -63,6 +93,24 @@ public class DeleteHabitsCommand extends Command return R.string.toast_habit_restored; } + @Override + @NonNull + public JSONObject toJSON() + { + try + { + JSONObject root = super.toJSON(); + JSONObject data = root.getJSONObject("data"); + root.put("event", "DeleteHabits"); + data.put("ids", habitListToJSON(habits)); + return root; + } + catch (JSONException e) + { + throw new RuntimeException(e.getMessage()); + } + } + @Override public void undo() { diff --git a/app/src/main/java/org/isoron/uhabits/commands/EditHabitCommand.java b/app/src/main/java/org/isoron/uhabits/commands/EditHabitCommand.java index dd105fb99..13646a099 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/EditHabitCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/EditHabitCommand.java @@ -25,6 +25,7 @@ import com.google.auto.factory.*; import org.isoron.uhabits.*; import org.isoron.uhabits.models.*; +import org.json.*; /** * Command to modify a habit. @@ -42,27 +43,42 @@ public class EditHabitCommand extends Command private boolean hasFrequencyChanged; - private final boolean hasTargetChanged; + private boolean hasTargetChanged; public EditHabitCommand(@Provided @NonNull ModelFactory modelFactory, @NonNull HabitList habitList, @NonNull Habit original, @NonNull Habit modified) { - this.habitList = habitList; - this.savedId = original.getId(); - this.modified = modelFactory.buildHabit(); - this.original = modelFactory.buildHabit(); + super(); + init(modelFactory, habitList, original, modified); + } - this.modified.copyFrom(modified); - this.original.copyFrom(original); + public EditHabitCommand(@Provided @NonNull ModelFactory modelFactory, + @NonNull String id, + @NonNull HabitList habitList, + @NonNull Habit original, + @NonNull Habit modified) + { + super(id); + init(modelFactory, habitList, original, modified); + } - Frequency originalFreq = this.original.getFrequency(); - Frequency modifiedFreq = this.modified.getFrequency(); - hasFrequencyChanged = (!originalFreq.equals(modifiedFreq)); - hasTargetChanged = - (original.getTargetType() != modified.getTargetType() || - original.getTargetValue() != modified.getTargetValue()); + @NonNull + public static Command fromJSON(@NonNull JSONObject root, + @NonNull HabitList habitList, + @NonNull ModelFactory modelFactory) + throws JSONException + { + String commandId = root.getString("id"); + JSONObject data = (JSONObject) root.get("data"); + Habit original = habitList.getById(data.getLong("id")); + if (original == null) throw new HabitNotFoundException(); + + Habit modified = + Habit.fromJSON(data.getJSONObject("params"), modelFactory); + return new EditHabitCommand(modelFactory, commandId, habitList, + original, modified); } @Override @@ -83,6 +99,24 @@ public class EditHabitCommand extends Command return R.string.toast_habit_changed_back; } + @Override + public JSONObject toJSON() + { + try + { + JSONObject root = super.toJSON(); + JSONObject data = root.getJSONObject("data"); + root.put("event", "EditHabit"); + data.put("id", savedId); + data.put("params", modified.toJSON()); + return root; + } + catch (JSONException e) + { + throw new RuntimeException(e.getMessage()); + } + } + @Override public void undo() { @@ -100,6 +134,27 @@ public class EditHabitCommand extends Command invalidateIfNeeded(habit); } + private void init(@NonNull ModelFactory modelFactory, + @NonNull HabitList habitList, + @NonNull Habit original, + @NonNull Habit modified) + { + this.habitList = habitList; + this.savedId = original.getId(); + this.modified = modelFactory.buildHabit(); + this.original = modelFactory.buildHabit(); + + this.modified.copyFrom(modified); + this.original.copyFrom(original); + + Frequency originalFreq = this.original.getFrequency(); + Frequency modifiedFreq = this.modified.getFrequency(); + hasFrequencyChanged = (!originalFreq.equals(modifiedFreq)); + hasTargetChanged = + (original.getTargetType() != modified.getTargetType() || + original.getTargetValue() != modified.getTargetValue()); + } + private void invalidateIfNeeded(Habit habit) { if (hasFrequencyChanged || hasTargetChanged) diff --git a/app/src/main/java/org/isoron/uhabits/commands/ToggleRepetitionCommand.java b/app/src/main/java/org/isoron/uhabits/commands/ToggleRepetitionCommand.java index 5cc2fa8ba..b08823bfa 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/ToggleRepetitionCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/ToggleRepetitionCommand.java @@ -19,36 +19,85 @@ package org.isoron.uhabits.commands; +import android.support.annotation.*; + import org.isoron.uhabits.models.*; +import org.json.*; /** * Command to toggle a repetition. */ public class ToggleRepetitionCommand extends Command { - private Long offset; + private Long timestamp; + private Habit habit; - public ToggleRepetitionCommand(Habit habit, long offset) + public ToggleRepetitionCommand(@NonNull Habit habit, long timestamp) { - this.offset = offset; + super(); + this.timestamp = timestamp; this.habit = habit; } - @Override - public void execute() + public ToggleRepetitionCommand(@NonNull String id, + @NonNull Habit habit, + long timestamp) + { + super(id); + this.timestamp = timestamp; + this.habit = habit; + } + + @NonNull + public static Command fromJSON(@NonNull JSONObject json, + @NonNull HabitList habitList) + throws JSONException { - habit.getRepetitions().toggleTimestamp(offset); + String id = json.getString("id"); + JSONObject data = (JSONObject) json.get("data"); + Long habitId = data.getLong("habit"); + Long timestamp = data.getLong("timestamp"); + + Habit habit = habitList.getById(habitId); + if (habit == null) throw new HabitNotFoundException(); + + return new ToggleRepetitionCommand(id, habit, timestamp); } @Override - public void undo() + public void execute() { - execute(); + habit.getRepetitions().toggleTimestamp(timestamp); } public Habit getHabit() { return habit; } + + @Override + @NonNull + public JSONObject toJSON() + { + try + { + JSONObject root = super.toJSON(); + JSONObject data = root.getJSONObject("data"); + root.put("event", "ToggleRepetition"); + data.put("habit", habit.getId()); + data.put("timestamp", timestamp); + return root; + } + catch (JSONException e) + { + throw new RuntimeException(e.getMessage()); + } + } + + @Override + public void undo() + { + execute(); + } } \ No newline at end of file diff --git a/app/src/main/java/org/isoron/uhabits/commands/UnarchiveHabitsCommand.java b/app/src/main/java/org/isoron/uhabits/commands/UnarchiveHabitsCommand.java index 6e45cda7b..e832cd6c0 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/UnarchiveHabitsCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/UnarchiveHabitsCommand.java @@ -19,11 +19,16 @@ package org.isoron.uhabits.commands; +import android.support.annotation.*; + import org.isoron.uhabits.*; import org.isoron.uhabits.models.*; +import org.json.*; import java.util.*; +import static org.isoron.uhabits.commands.CommandParser.*; + /** * Command to unarchive a list of habits. */ @@ -33,23 +38,40 @@ public class UnarchiveHabitsCommand extends Command private List habits; - public UnarchiveHabitsCommand(HabitList habitList, List selected) + public UnarchiveHabitsCommand(@NonNull HabitList habitList, + @NonNull List selected) { + super(); this.habits = selected; this.habitList = habitList; } - @Override - public void execute() + public UnarchiveHabitsCommand(@NonNull String id, + @NonNull HabitList habitList, + @NonNull List selected) { - for(Habit h : habits) h.setArchived(false); - habitList.update(habits); + super(id); + this.habits = selected; + this.habitList = habitList; + } + + @NonNull + public static Command fromJSON(@NonNull JSONObject json, + @NonNull HabitList habitList) + throws JSONException + { + String id = json.getString("id"); + JSONObject data = (JSONObject) json.get("data"); + JSONArray habitIds = data.getJSONArray("ids"); + + LinkedList selected = habitListFromJSON(habitList, habitIds); + return new UnarchiveHabitsCommand(id, habitList, selected); } @Override - public void undo() + public void execute() { - for(Habit h : habits) h.setArchived(true); + for (Habit h : habits) h.setArchived(false); habitList.update(habits); } @@ -64,4 +86,30 @@ public class UnarchiveHabitsCommand extends Command { return R.string.toast_habit_archived; } + + @Override + @NonNull + public JSONObject toJSON() + { + try + { + JSONObject root = super.toJSON(); + JSONObject data = root.getJSONObject("data"); + root.put("event", "UnarchiveHabits"); + data.put("ids", habitListToJSON(habits)); + return root; + } + catch (JSONException e) + { + throw new RuntimeException(e.getMessage()); + } + } + + @Override + public void undo() + { + for (Habit h : habits) h.setArchived(true); + habitList.update(habits); + } + } \ No newline at end of file diff --git a/app/src/main/java/org/isoron/uhabits/models/Habit.java b/app/src/main/java/org/isoron/uhabits/models/Habit.java index fbc6d5d88..59edf63ba 100644 --- a/app/src/main/java/org/isoron/uhabits/models/Habit.java +++ b/app/src/main/java/org/isoron/uhabits/models/Habit.java @@ -23,6 +23,7 @@ import android.net.*; import android.support.annotation.*; import org.apache.commons.lang3.builder.*; +import org.json.*; import java.util.*; @@ -371,4 +372,64 @@ public class Habit .append("unit", unit) .toString(); } + + @NonNull + public JSONObject toJSON() + { + try + { + JSONObject json = new JSONObject(); + json.put("name", name); + json.put("description", description); + json.put("freqNum", frequency.getNumerator()); + json.put("freqDen", frequency.getDenominator()); + json.put("color", color); + json.put("type", type); + json.put("targetType", targetType); + json.put("targetValue", targetValue); + json.put("unit", unit); + json.put("archived", archived); + + if(reminder != null) + { + json.put("reminderHour", reminder.getHour()); + json.put("reminderMin", reminder.getMinute()); + json.put("reminderDays", reminder.getDays().toInteger()); + } + + return json; + } + catch(JSONException e) + { + throw new RuntimeException(e.getMessage()); + } + } + + @NonNull + public static Habit fromJSON(@NonNull JSONObject json, + @NonNull ModelFactory modelFactory) + throws JSONException + { + Habit habit = modelFactory.buildHabit(); + habit.name = json.getString("name"); + habit.description = json.getString("description"); + int freqNum = json.getInt("freqNum"); + int freqDen = json.getInt("freqDen"); + habit.frequency = new Frequency(freqNum, freqDen); + habit.color = json.getInt("color"); + habit.archived = json.getBoolean("archived"); + habit.targetValue = json.getInt("targetValue"); + habit.targetType = json.getInt("targetType"); + habit.unit = json.getString("unit"); + habit.type = json.getInt("type"); + + if(json.has("reminderHour")) + { + int hour = json.getInt("reminderHour"); + int min = json.getInt("reminderMin"); + int days = json.getInt("reminderDays"); + habit.reminder = new Reminder(hour, min, new WeekdayList(days)); + } + return habit; + } } diff --git a/app/src/main/java/org/isoron/uhabits/preferences/Preferences.java b/app/src/main/java/org/isoron/uhabits/preferences/Preferences.java index ac84468cf..3ff1b2906 100644 --- a/app/src/main/java/org/isoron/uhabits/preferences/Preferences.java +++ b/app/src/main/java/org/isoron/uhabits/preferences/Preferences.java @@ -77,11 +77,6 @@ public class Preferences } } - public boolean isNumericalHabitsFeatureEnabled() - { - return prefs.getBoolean("pref_feature_numerical_habits", false); - } - public void setDefaultOrder(HabitList.Order order) { prefs.edit().putString("pref_default_order", order.name()).apply(); @@ -120,6 +115,16 @@ public class Preferences return prefs.getLong("last_hint_timestamp", -1); } + public long getLastSync() + { + return prefs.getLong("lastSync", 0); + } + + public void setLastSync(long timestamp) + { + prefs.edit().putLong("last_sync", timestamp).apply(); + } + public boolean getShowArchived() { return prefs.getBoolean("pref_show_archived", false); @@ -145,6 +150,11 @@ public class Preferences return Long.parseLong(prefs.getString("pref_snooze_interval", "15")); } + public String getSyncKey() + { + return prefs.getString("pref_sync_key", ""); + } + public int getTheme() { return prefs.getInt("pref_theme", ThemeSwitcher.THEME_LIGHT); @@ -186,6 +196,11 @@ public class Preferences prefs.edit().putBoolean("pref_first_run", isFirstRun).apply(); } + public boolean isNumericalHabitsFeatureEnabled() + { + return prefs.getBoolean("pref_feature_numerical_habits", false); + } + public boolean isPureBlackEnabled() { return prefs.getBoolean("pref_pure_black", false); diff --git a/app/src/main/java/org/isoron/uhabits/sync/Event.java b/app/src/main/java/org/isoron/uhabits/sync/Event.java new file mode 100644 index 000000000..fa20c1ac5 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/sync/Event.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync; + +import android.support.annotation.NonNull; + +import com.activeandroid.Model; +import com.activeandroid.annotation.Column; +import com.activeandroid.annotation.Table; +import com.activeandroid.query.Select; + +import java.util.List; + +@Table(name = "Events") +public class Event extends Model +{ + @NonNull + @Column(name = "timestamp") + public Long timestamp; + + @NonNull + @Column(name = "message") + public String message; + + @NonNull + @Column(name = "server_id") + public String serverId; + + public Event() + { + timestamp = 0L; + message = ""; + serverId = ""; + } + + public Event(@NonNull String serverId, long timestamp, @NonNull String message) + { + this.serverId = serverId; + this.timestamp = timestamp; + this.message = message; + } + + @NonNull + public static List getAll() + { + return new Select().from(Event.class).orderBy("timestamp").execute(); + } +} diff --git a/app/src/main/java/org/isoron/uhabits/sync/SyncManager.java b/app/src/main/java/org/isoron/uhabits/sync/SyncManager.java new file mode 100644 index 000000000..2a769ffa6 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/sync/SyncManager.java @@ -0,0 +1,334 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync; + +import android.content.*; +import android.support.annotation.*; +import android.util.*; + +import org.isoron.uhabits.*; +import org.isoron.uhabits.commands.*; +import org.isoron.uhabits.preferences.*; +import org.isoron.uhabits.utils.*; +import org.json.*; + +import java.io.*; +import java.net.*; +import java.security.*; +import java.security.cert.Certificate; +import java.security.cert.*; +import java.util.*; + +import javax.net.ssl.*; + +import io.socket.client.*; +import io.socket.client.Socket; +import io.socket.emitter.*; + +import static io.socket.client.Socket.*; + +public class SyncManager +{ + public static final String EVENT_AUTH = "auth"; + + public static final String EVENT_AUTH_OK = "authOK"; + + public static final String EVENT_EXECUTE_EVENT = "execute"; + + public static final String EVENT_FETCH = "fetch"; + + public static final String EVENT_FETCH_OK = "fetchOK"; + + public static final String EVENT_POST_EVENT = "postEvent"; + + public static final String SYNC_SERVER_URL = + "https://sync.loophabits.org:4000"; + + private static String CLIENT_ID; + + private static String GROUP_KEY; + + @NonNull + private Socket socket; + + @NonNull + private LinkedList pendingConfirmation; + + @NonNull + private List pendingEmit; + + private boolean readyToEmit = false; + + private Context context; + + private final Preferences prefs; + + private CommandRunner commandRunner; + + private CommandParser commandParser; + + public SyncManager(@NonNull Context context, + @NonNull Preferences prefs, + @NonNull CommandRunner commandRunner, + @NonNull CommandParser commandParser) + { + this.context = context; + this.prefs = prefs; + this.commandRunner = commandRunner; + this.commandParser = commandParser; + + pendingConfirmation = new LinkedList<>(); + pendingEmit = Event.getAll(); + + GROUP_KEY = prefs.getSyncKey(); + CLIENT_ID = DatabaseUtils.getRandomId(); + + Log.d("SyncManager", CLIENT_ID); + + try + { + IO.setDefaultSSLContext(getCACertSSLContext()); + socket = IO.socket(SYNC_SERVER_URL); + logSocketEvent(socket, EVENT_CONNECT, "Connected"); + logSocketEvent(socket, EVENT_CONNECT_TIMEOUT, "Connect timeout"); + logSocketEvent(socket, EVENT_CONNECTING, "Connecting..."); + logSocketEvent(socket, EVENT_CONNECT_ERROR, "Connect error"); + logSocketEvent(socket, EVENT_DISCONNECT, "Disconnected"); + logSocketEvent(socket, EVENT_RECONNECT, "Reconnected"); + logSocketEvent(socket, EVENT_RECONNECT_ATTEMPT, "Reconnecting..."); + logSocketEvent(socket, EVENT_RECONNECT_ERROR, "Reconnect error"); + logSocketEvent(socket, EVENT_RECONNECT_FAILED, "Reconnect failed"); + logSocketEvent(socket, EVENT_DISCONNECT, "Disconnected"); + logSocketEvent(socket, EVENT_PING, "Ping"); + logSocketEvent(socket, EVENT_PONG, "Pong"); + + socket.on(EVENT_CONNECT, new OnConnectListener()); + socket.on(EVENT_DISCONNECT, new OnDisconnectListener()); + socket.on(EVENT_EXECUTE_EVENT, new OnExecuteCommandListener()); + socket.on(EVENT_AUTH_OK, new OnAuthOKListener()); + socket.on(EVENT_FETCH_OK, new OnFetchOKListener()); + + socket.connect(); + } + catch (URISyntaxException e) + { + throw new RuntimeException(e); + } + } + + public void close() + { + socket.off(); + socket.close(); + } + + public void postCommand(Command command) + { + JSONObject msg = command.toJSON(); + if (msg == null) return; + + Long now = new Date().getTime(); + Event e = new Event(command.getId(), now, msg.toString()); + e.save(); + + Log.i("SyncManager", "Adding to outbox: " + msg.toString()); + + pendingEmit.add(e); + if (readyToEmit) emitPending(); + } + + private void emitPending() + { + try + { + for (Event e : pendingEmit) + { + Log.i("SyncManager", "Emitting: " + e.message); + socket.emit(EVENT_POST_EVENT, new JSONObject(e.message)); + pendingConfirmation.add(e); + } + + pendingEmit.clear(); + } + catch (JSONException e) + { + throw new RuntimeException(e); + } + } + + private SSLContext getCACertSSLContext() + { + try + { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + InputStream caInput = context.getAssets().open("cacert.pem"); + Certificate ca = cf.generateCertificate(caInput); + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + ks.setCertificateEntry("ca", ca); + + TrustManagerFactory tmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, tmf.getTrustManagers(), null); + + return ctx; + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + private void logSocketEvent(Socket socket, String event, final String msg) + { + socket.on(event, args -> Log.i("SyncManager", msg)); + } + + private void updateLastSync(Long timestamp) + { + prefs.setLastSync(timestamp + 1); + } + + private class OnAuthOKListener implements Emitter.Listener + { + @Override + public void call(Object... args) + { + Log.i("SyncManager", "Auth OK"); + Log.i("SyncManager", "Requesting commands since last sync"); + + Long lastSync = prefs.getLastSync(); + socket.emit(EVENT_FETCH, buildFetchMessage(lastSync)); + } + + private JSONObject buildFetchMessage(Long lastSync) + { + try + { + JSONObject json = new JSONObject(); + json.put("since", lastSync); + return json; + } + catch (JSONException e) + { + throw new RuntimeException(e); + } + } + } + + private class OnConnectListener implements Emitter.Listener + { + @Override + public void call(Object... args) + { + Log.i("SyncManager", "Sending auth message"); + socket.emit(EVENT_AUTH, buildAuthMessage()); + } + + private JSONObject buildAuthMessage() + { + try + { + JSONObject json = new JSONObject(); + json.put("groupKey", GROUP_KEY); + json.put("clientId", CLIENT_ID); + json.put("version", BuildConfig.VERSION_NAME); + return json; + } + catch (JSONException e) + { + throw new RuntimeException(e); + } + } + } + + private class OnDisconnectListener implements Emitter.Listener + { + @Override + public void call(Object... args) + { + readyToEmit = false; + } + } + + private class OnExecuteCommandListener implements Emitter.Listener + { + @Override + public void call(Object... args) + { + try + { + Log.d("SyncManager", + String.format("Received command: %s", args[0].toString())); + JSONObject root = new JSONObject(args[0].toString()); + updateLastSync(root.getLong("timestamp")); + executeCommand(root); + } + catch (JSONException e) + { + throw new RuntimeException(e); + } + } + + private void executeCommand(JSONObject root) throws JSONException + { + Command received = commandParser.fromJSON(root); + for (Event e : pendingConfirmation) + { + if (e.serverId.equals(received.getId())) + { + Log.i("SyncManager", "Pending command confirmed"); + pendingConfirmation.remove(e); + e.delete(); + return; + } + } + + Log.d("SyncManager", "Executing received command"); + commandRunner.execute(received, null); + } + } + + private class OnFetchOKListener implements Emitter.Listener + { + @Override + public void call(Object... args) + { + try + { + Log.i("SyncManager", "Fetch OK"); + + JSONObject json = (JSONObject) args[0]; + updateLastSync(json.getLong("timestamp")); + + emitPending(); + readyToEmit = true; + } + catch (JSONException e) + { + throw new RuntimeException(e); + } + } + } +} diff --git a/app/src/main/java/org/isoron/uhabits/utils/DatabaseUtils.java b/app/src/main/java/org/isoron/uhabits/utils/DatabaseUtils.java index 4db87a8db..146842e2c 100644 --- a/app/src/main/java/org/isoron/uhabits/utils/DatabaseUtils.java +++ b/app/src/main/java/org/isoron/uhabits/utils/DatabaseUtils.java @@ -27,9 +27,12 @@ import com.activeandroid.*; import org.isoron.uhabits.*; import org.isoron.uhabits.models.sqlite.*; import org.isoron.uhabits.models.sqlite.records.*; +import org.isoron.uhabits.sync.*; import java.io.*; +import java.math.*; import java.text.*; +import java.util.*; public abstract class DatabaseUtils { @@ -67,6 +70,11 @@ public abstract class DatabaseUtils return databaseFilename; } + public static String getRandomId() + { + return new BigInteger(260, new Random()).toString(32).substring(0, 32); + } + @SuppressWarnings("unchecked") public static void initializeActiveAndroid(Context context) { @@ -74,7 +82,8 @@ public abstract class DatabaseUtils .setDatabaseName(getDatabaseFilename()) .setDatabaseVersion(BuildConfig.databaseVersion) .addModelClasses(CheckmarkRecord.class, HabitRecord.class, - RepetitionRecord.class, ScoreRecord.class, StreakRecord.class) + RepetitionRecord.class, ScoreRecord.class, StreakRecord.class, + Event.class) .create(); try diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index cbea18fef..7acafd361 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -147,6 +147,11 @@ android:key="pref_feature_numerical_habits" android:title="Enable numerical habits"/> + + \ No newline at end of file diff --git a/app/src/test/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreenTest.java b/app/src/test/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreenTest.java index 425ca4f71..749b5685f 100644 --- a/app/src/test/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreenTest.java +++ b/app/src/test/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreenTest.java @@ -32,6 +32,7 @@ import org.isoron.uhabits.commands.*; import org.isoron.uhabits.intents.*; import org.isoron.uhabits.io.*; import org.isoron.uhabits.models.*; +import org.isoron.uhabits.preferences.*; import org.junit.*; import org.junit.runner.*; import org.junit.runners.*; @@ -77,6 +78,8 @@ public class ListHabitsScreenTest extends BaseUnitTest private ListHabitsScreen baseScreen; + private Preferences prefs; + @Before @Override public void setUp() @@ -93,10 +96,12 @@ public class ListHabitsScreenTest extends BaseUnitTest filePickerDialogFactory = mock(FilePickerDialogFactory.class); colorPickerDialogFactory = mock(ColorPickerDialogFactory.class); dialogFactory = mock(EditHabitDialogFactory.class); + prefs = mock(Preferences.class); screen = spy(new ListHabitsScreen(activity, commandRunner, dirFinder, rootView, intentFactory, themeSwitcher, confirmDeleteDialogFactory, - filePickerDialogFactory, colorPickerDialogFactory, dialogFactory)); + filePickerDialogFactory, colorPickerDialogFactory, dialogFactory, + prefs)); doNothing().when(screen).showMessage(anyInt());