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());