diff --git a/README.md b/README.md index e3c7796c8..271d6aa9a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ + + + + # Loop Habit Tracker Loop is a simple Android app that helps you create and maintain good habits, diff --git a/app/build.gradle b/app/build.gradle index a1a1bb4bf..14e4da3d0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,13 +2,16 @@ apply plugin: 'com.android.application' android { compileSdkVersion 23 - buildToolsVersion "21.1.2" + buildToolsVersion "23.0.1" defaultConfig { applicationId "org.isoron.uhabits" minSdkVersion 15 targetSdkVersion 23 + buildConfigField "Integer", "databaseVersion", "12" + buildConfigField "String", "databaseFilename", "\"uhabits.db\"" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } @@ -40,3 +43,13 @@ dependencies { androidTestCompile 'com.android.support.test.espresso:espresso-intents:2.2.1' } + +task grantAnimationPermission(type: Exec, dependsOn: 'installDebug') { + commandLine "adb shell pm grant org.isoron.uhabits android.permission.SET_ANIMATION_SCALE".split(' ') +} + +tasks.whenTaskAdded { task -> + if (task.name.startsWith('connected')) { + task.dependsOn grantAnimationPermission + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/isoron/uhabits/HabitMatchers.java b/app/src/androidTest/java/org/isoron/uhabits/ui/HabitMatchers.java similarity index 98% rename from app/src/androidTest/java/org/isoron/uhabits/HabitMatchers.java rename to app/src/androidTest/java/org/isoron/uhabits/ui/HabitMatchers.java index c96b24c16..0fbb13f34 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/HabitMatchers.java +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/HabitMatchers.java @@ -17,7 +17,7 @@ * with this program. If not, see . */ -package org.isoron.uhabits; +package org.isoron.uhabits.ui; import android.view.View; import android.widget.Adapter; diff --git a/app/src/androidTest/java/org/isoron/uhabits/HabitViewActions.java b/app/src/androidTest/java/org/isoron/uhabits/ui/HabitViewActions.java similarity index 98% rename from app/src/androidTest/java/org/isoron/uhabits/HabitViewActions.java rename to app/src/androidTest/java/org/isoron/uhabits/ui/HabitViewActions.java index 7c9a7e0a2..afa630ea0 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/HabitViewActions.java +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/HabitViewActions.java @@ -17,7 +17,7 @@ * with this program. If not, see . */ -package org.isoron.uhabits; +package org.isoron.uhabits.ui; import android.support.test.espresso.UiController; import android.support.test.espresso.ViewAction; @@ -32,6 +32,7 @@ import android.widget.LinearLayout; import android.widget.TextView; import org.hamcrest.Matcher; +import org.isoron.uhabits.R; import java.security.InvalidParameterException; import java.util.Random; diff --git a/app/src/androidTest/java/org/isoron/uhabits/MainActivityActions.java b/app/src/androidTest/java/org/isoron/uhabits/ui/MainActivityActions.java similarity index 95% rename from app/src/androidTest/java/org/isoron/uhabits/MainActivityActions.java rename to app/src/androidTest/java/org/isoron/uhabits/ui/MainActivityActions.java index 3ce26da37..412bee5f9 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/MainActivityActions.java +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/MainActivityActions.java @@ -17,11 +17,11 @@ * with this program. If not, see . */ -package org.isoron.uhabits; +package org.isoron.uhabits.ui; -import android.content.Context; -import android.support.test.InstrumentationRegistry; +import android.support.test.espresso.matcher.ViewMatchers; +import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; import java.util.Collections; @@ -45,8 +45,8 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; -import static org.isoron.uhabits.HabitMatchers.containsHabit; -import static org.isoron.uhabits.HabitMatchers.withName; +import static org.isoron.uhabits.ui.HabitMatchers.containsHabit; +import static org.isoron.uhabits.ui.HabitMatchers.withName; public class MainActivityActions { diff --git a/app/src/androidTest/java/org/isoron/uhabits/MainTest.java b/app/src/androidTest/java/org/isoron/uhabits/ui/MainTest.java similarity index 79% rename from app/src/androidTest/java/org/isoron/uhabits/MainTest.java rename to app/src/androidTest/java/org/isoron/uhabits/ui/MainTest.java index b6047c69a..05877fcb0 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/MainTest.java +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/MainTest.java @@ -1,4 +1,4 @@ -package org.isoron.uhabits; +package org.isoron.uhabits.ui; import android.content.Context; import android.support.test.InstrumentationRegistry; @@ -7,6 +7,8 @@ import android.support.test.espresso.intent.rule.IntentsTestRule; import android.support.test.runner.AndroidJUnit4; import android.test.suitebuilder.annotation.LargeTest; +import org.isoron.uhabits.MainActivity; +import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; import org.junit.Before; import org.junit.Rule; @@ -37,20 +39,20 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.endsWith; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; -import static org.isoron.uhabits.HabitMatchers.withName; -import static org.isoron.uhabits.HabitViewActions.clickAtRandomLocations; -import static org.isoron.uhabits.HabitViewActions.toggleAllCheckmarks; -import static org.isoron.uhabits.MainActivityActions.addHabit; -import static org.isoron.uhabits.MainActivityActions.assertHabitExists; -import static org.isoron.uhabits.MainActivityActions.assertHabitsDontExist; -import static org.isoron.uhabits.MainActivityActions.assertHabitsExist; -import static org.isoron.uhabits.MainActivityActions.clickActionModeMenuItem; -import static org.isoron.uhabits.MainActivityActions.deleteHabit; -import static org.isoron.uhabits.MainActivityActions.deleteHabits; -import static org.isoron.uhabits.MainActivityActions.selectHabit; -import static org.isoron.uhabits.MainActivityActions.selectHabits; -import static org.isoron.uhabits.MainActivityActions.typeHabitData; -import static org.isoron.uhabits.ShowHabitActivityActions.openHistoryEditor; +import static org.isoron.uhabits.ui.HabitMatchers.withName; +import static org.isoron.uhabits.ui.HabitViewActions.clickAtRandomLocations; +import static org.isoron.uhabits.ui.HabitViewActions.toggleAllCheckmarks; +import static org.isoron.uhabits.ui.MainActivityActions.addHabit; +import static org.isoron.uhabits.ui.MainActivityActions.assertHabitExists; +import static org.isoron.uhabits.ui.MainActivityActions.assertHabitsDontExist; +import static org.isoron.uhabits.ui.MainActivityActions.assertHabitsExist; +import static org.isoron.uhabits.ui.MainActivityActions.clickActionModeMenuItem; +import static org.isoron.uhabits.ui.MainActivityActions.deleteHabit; +import static org.isoron.uhabits.ui.MainActivityActions.deleteHabits; +import static org.isoron.uhabits.ui.MainActivityActions.selectHabit; +import static org.isoron.uhabits.ui.MainActivityActions.selectHabits; +import static org.isoron.uhabits.ui.MainActivityActions.typeHabitData; +import static org.isoron.uhabits.ui.ShowHabitActivityActions.openHistoryEditor; @RunWith(AndroidJUnit4.class) @LargeTest @@ -61,6 +63,16 @@ public class MainTest MainActivity.class); @Before + public void setup() + { + Context context = InstrumentationRegistry.getInstrumentation().getContext(); + SystemHelper sys = new SystemHelper(context); + sys.disableAllAnimations(); + sys.unlockScreen(); + + skipTutorial(); + } + public void skipTutorial() { try @@ -118,7 +130,7 @@ public class MainTest } @Test - public void testAddHabitAndViewStats() + public void testAddHabitAndViewStats() throws InterruptedException { String name = addHabit(true); @@ -126,6 +138,8 @@ public class MainTest .onChildView(withId(R.id.llButtons)) .perform(toggleAllCheckmarks()); + Thread.sleep(1200); + onData(allOf(is(instanceOf(Habit.class)), withName(name))) .onChildView(withId(R.id.label)) .perform(click()); diff --git a/app/src/androidTest/java/org/isoron/uhabits/ShowHabitActivityActions.java b/app/src/androidTest/java/org/isoron/uhabits/ui/ShowHabitActivityActions.java similarity index 86% rename from app/src/androidTest/java/org/isoron/uhabits/ShowHabitActivityActions.java rename to app/src/androidTest/java/org/isoron/uhabits/ui/ShowHabitActivityActions.java index cacc9f21f..31a89c397 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/ShowHabitActivityActions.java +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/ShowHabitActivityActions.java @@ -17,18 +17,21 @@ * with this program. If not, see . */ -package org.isoron.uhabits; +package org.isoron.uhabits.ui; + +import android.support.test.espresso.matcher.ViewMatchers; + +import org.isoron.uhabits.R; import static android.support.test.espresso.Espresso.onView; import static android.support.test.espresso.action.ViewActions.click; import static android.support.test.espresso.action.ViewActions.scrollTo; -import static android.support.test.espresso.matcher.ViewMatchers.withId; public class ShowHabitActivityActions { public static void openHistoryEditor() { - onView(withId(R.id.btEditHistory)) + onView(ViewMatchers.withId(R.id.btEditHistory)) .perform(scrollTo(), click()); } } diff --git a/app/src/androidTest/java/org/isoron/uhabits/ui/SystemHelper.java b/app/src/androidTest/java/org/isoron/uhabits/ui/SystemHelper.java new file mode 100644 index 000000000..5fa69e80e --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/SystemHelper.java @@ -0,0 +1,89 @@ +package org.isoron.uhabits.ui; + +import android.app.KeyguardManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.IBinder; +import android.support.test.runner.AndroidJUnitRunner; +import android.util.Log; + +import java.lang.reflect.Method; + +public final class SystemHelper extends AndroidJUnitRunner +{ + private static final String ANIMATION_PERMISSION = "android.permission.SET_ANIMATION_SCALE"; + private static final float DISABLED = 0.0f; + private static final float DEFAULT = 1.0f; + + private final Context context; + + SystemHelper(Context context) + { + this.context = context; + } + + void unlockScreen() + { + try + { + KeyguardManager mKeyGuardManager = (KeyguardManager) context + .getSystemService(Context.KEYGUARD_SERVICE); + KeyguardManager.KeyguardLock mLock = mKeyGuardManager.newKeyguardLock("lock"); + mLock.disableKeyguard(); + } + catch (Exception e) + { + e.printStackTrace(); + } + } + + void disableAllAnimations() + { + Log.i("SystemAnimations", "Trying to disable animations"); + int permStatus = context.checkCallingOrSelfPermission(ANIMATION_PERMISSION); + if (permStatus == PackageManager.PERMISSION_GRANTED) + setSystemAnimationsScale(DISABLED); + else + Log.e("SystemAnimations", "Permission denied"); + + } + + void enableAllAnimations() + { + int permStatus = context.checkCallingOrSelfPermission(ANIMATION_PERMISSION); + if (permStatus == PackageManager.PERMISSION_GRANTED) + { + setSystemAnimationsScale(DEFAULT); + } + } + + private void setSystemAnimationsScale(float animationScale) + { + try + { + Class windowManagerStubClazz = Class.forName("android.view.IWindowManager$Stub"); + Method asInterface = + windowManagerStubClazz.getDeclaredMethod("asInterface", IBinder.class); + Class serviceManagerClazz = Class.forName("android.os.ServiceManager"); + Method getService = serviceManagerClazz.getDeclaredMethod("getService", String.class); + Class windowManagerClazz = Class.forName("android.view.IWindowManager"); + Method setAnimationScales = + windowManagerClazz.getDeclaredMethod("setAnimationScales", float[].class); + Method getAnimationScales = windowManagerClazz.getDeclaredMethod("getAnimationScales"); + + IBinder windowManagerBinder = (IBinder) getService.invoke(null, "window"); + Object windowManagerObj = asInterface.invoke(null, windowManagerBinder); + float[] currentScales = (float[]) getAnimationScales.invoke(windowManagerObj); + for (int i = 0; i < currentScales.length; i++) + currentScales[i] = animationScale; + + setAnimationScales.invoke(windowManagerObj, new Object[]{currentScales}); + Log.i("SystemAnimations", "All animations successfully disabled"); + } + catch (Exception e) + { + Log.e("SystemAnimations", + "Could not change animation scale to " + animationScale + " :'("); + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/models/CheckmarkListTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/models/CheckmarkListTest.java new file mode 100644 index 000000000..5c017da75 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/models/CheckmarkListTest.java @@ -0,0 +1,148 @@ +/* + * 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.unit.models; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.isoron.uhabits.models.Checkmark.CHECKED_EXPLICITLY; +import static org.isoron.uhabits.models.Checkmark.CHECKED_IMPLICITLY; +import static org.isoron.uhabits.models.Checkmark.UNCHECKED; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class CheckmarkListTest +{ + Habit nonDailyHabit; + private Habit emptyHabit; + + @Before + public void prepare() + { + HabitFixtures.purgeHabits(); + DateHelper.setFixedLocalTime(HabitFixtures.FIXED_LOCAL_TIME); + nonDailyHabit = HabitFixtures.createNonDailyHabit(); + emptyHabit = HabitFixtures.createEmptyHabit(); + } + + @After + public void tearDown() + { + DateHelper.setFixedLocalTime(null); + } + + @Test + public void getAllValues_testNonDailyHabit() + { + int[] expectedValues = { CHECKED_EXPLICITLY, UNCHECKED, CHECKED_IMPLICITLY, + CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, UNCHECKED, + CHECKED_IMPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY }; + + int[] actualValues = nonDailyHabit.checkmarks.getAllValues(); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void getAllValues_testMoveForwardInTime() + { + travelInTime(3); + + int[] expectedValues = { UNCHECKED, UNCHECKED, UNCHECKED, CHECKED_EXPLICITLY, UNCHECKED, + CHECKED_IMPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, + UNCHECKED, CHECKED_IMPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY }; + + int[] actualValues = nonDailyHabit.checkmarks.getAllValues(); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void getAllValues_testMoveBackwardsInTime() + { + travelInTime(-3); + + int[] expectedValues = { CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, + UNCHECKED, CHECKED_IMPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY }; + + int[] actualValues = nonDailyHabit.checkmarks.getAllValues(); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void getAllValues_testEmptyHabit() + { + int[] expectedValues = new int[0]; + int[] actualValues = emptyHabit.checkmarks.getAllValues(); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void getValues_testInvalidInterval() + { + int values[] = nonDailyHabit.checkmarks.getValues(100L, -100L); + assertThat(values, equalTo(new int[0])); + } + + @Test + public void getValues_testValidInterval() + { + long from = DateHelper.getStartOfToday() - 15 * DateHelper.millisecondsInOneDay; + long to = DateHelper.getStartOfToday() - 5 * DateHelper.millisecondsInOneDay; + + int[] expectedValues = { CHECKED_EXPLICITLY, UNCHECKED, CHECKED_IMPLICITLY, + CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, UNCHECKED, UNCHECKED, UNCHECKED, UNCHECKED, + UNCHECKED, UNCHECKED }; + + int[] actualValues = nonDailyHabit.checkmarks.getValues(from, to); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void getTodayValue_testNonDailyHabit() + { + travelInTime(-1); + assertThat(nonDailyHabit.checkmarks.getTodayValue(), equalTo(UNCHECKED)); + + travelInTime(0); + assertThat(nonDailyHabit.checkmarks.getTodayValue(), equalTo(CHECKED_EXPLICITLY)); + + travelInTime(1); + assertThat(nonDailyHabit.checkmarks.getTodayValue(), equalTo(UNCHECKED)); + } + + private void travelInTime(int days) + { + DateHelper.setFixedLocalTime(HabitFixtures.FIXED_LOCAL_TIME + + days * DateHelper.millisecondsInOneDay); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/models/HabitFixtures.java b/app/src/androidTest/java/org/isoron/uhabits/unit/models/HabitFixtures.java new file mode 100644 index 000000000..3cbea7a0d --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/models/HabitFixtures.java @@ -0,0 +1,60 @@ +/* + * 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.unit.models; + +import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; + +public class HabitFixtures +{ + public static final long FIXED_LOCAL_TIME = 1422172800000L; // 8:00am, January 25th, 2015 (UTC) + public static boolean NON_DAILY_HABIT_CHECKS[] = { true, false, false, true, true, true, false, + false, true, true }; + + static Habit createNonDailyHabit() + { + Habit habit = new Habit(); + habit.freqNum = 2; + habit.freqDen = 3; + habit.save(); + + long timestamp = DateHelper.getStartOfToday(); + for(boolean c : NON_DAILY_HABIT_CHECKS) + { + if(c) habit.repetitions.toggle(timestamp); + timestamp -= DateHelper.millisecondsInOneDay; + } + + return habit; + } + + static Habit createEmptyHabit() + { + Habit habit = new Habit(); + habit.save(); + return habit; + } + + static void purgeHabits() + { + for(Habit h : Habit.getAll(true)) + h.cascadeDelete(); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/models/HabitTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/models/HabitTest.java new file mode 100644 index 000000000..d46826974 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/models/HabitTest.java @@ -0,0 +1,95 @@ +/* + * 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.unit.models; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.models.Habit; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.LinkedList; +import java.util.List; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class HabitTest +{ + @Before + public void prepare() + { + HabitFixtures.purgeHabits(); + } + + @Test + public void reorderTest() + { + List ids = new LinkedList<>(); + + for (int i = 0; i < 10; i++) + { + Habit h = new Habit(); + h.save(); + ids.add(h.getId()); + assertThat(h.position, is(i)); + } + + int from = 5, to = 2; + int expectedPosition[] = {0, 1, 3, 4, 5, 2, 6, 7, 8, 9}; + + Habit fromHabit = Habit.get(ids.get(from)); + Habit toHabit = Habit.get(ids.get(to)); + Habit.reorder(fromHabit, toHabit); + + for (int i = 0; i < 10; i++) + { + Habit h = Habit.get(ids.get(i)); + assertThat(h.position, is(expectedPosition[i])); + } + } + + @Test + public void rebuildOrderTest() + { + List ids = new LinkedList<>(); + int originalPositions[] = { 0, 1, 1, 4, 6, 8, 10, 10, 13}; + + for (int p : originalPositions) + { + Habit h = new Habit(); + h.position = p; + h.save(); + ids.add(h.getId()); + } + + Habit.rebuildOrder(); + + for (int i = 0; i < originalPositions.length; i++) + { + Habit h = Habit.get(ids.get(i)); + assertThat(h.position, is(i)); + } + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/models/RepetitionListTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/models/RepetitionListTest.java new file mode 100644 index 000000000..1c30c28d0 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/models/RepetitionListTest.java @@ -0,0 +1,162 @@ +/* + * 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.unit.models; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.Random; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class RepetitionListTest +{ + Habit habit; + private Habit emptyHabit; + + @Before + public void prepare() + { + HabitFixtures.purgeHabits(); + DateHelper.setFixedLocalTime(HabitFixtures.FIXED_LOCAL_TIME); + habit = HabitFixtures.createNonDailyHabit(); + emptyHabit = HabitFixtures.createEmptyHabit(); + } + + @After + public void tearDown() + { + DateHelper.setFixedLocalTime(null); + } + + @Test + public void contains_testNonDailyHabit() + { + long current = DateHelper.getStartOfToday(); + + for(boolean b : HabitFixtures.NON_DAILY_HABIT_CHECKS) + { + assertThat(habit.repetitions.contains(current), equalTo(b)); + current -= DateHelper.millisecondsInOneDay; + } + + for(int i = 0; i < 3; i++) + { + assertThat(habit.repetitions.contains(current), equalTo(false)); + current -= DateHelper.millisecondsInOneDay; + } + } + + @Test + public void delete_test() + { + long timestamp = DateHelper.getStartOfToday(); + assertThat(habit.repetitions.contains(timestamp), equalTo(true)); + + habit.repetitions.delete(timestamp); + assertThat(habit.repetitions.contains(timestamp), equalTo(false)); + } + + @Test + public void toggle_test() + { + long timestamp = DateHelper.getStartOfToday(); + assertThat(habit.repetitions.contains(timestamp), equalTo(true)); + + habit.repetitions.toggle(timestamp); + assertThat(habit.repetitions.contains(timestamp), equalTo(false)); + + habit.repetitions.toggle(timestamp); + assertThat(habit.repetitions.contains(timestamp), equalTo(true)); + } + + @Test + public void getWeekDayFrequency_test() + { + Random random = new Random(); + Integer weekdayCount[][] = new Integer[12][7]; + Integer monthCount[] = new Integer[12]; + + Arrays.fill(monthCount, 0); + for(Integer row[] : weekdayCount) + Arrays.fill(row, 0); + + GregorianCalendar day = DateHelper.getStartOfTodayCalendar(); + + // Sets the current date to the end of November + day.set(2015, 10, 30); + DateHelper.setFixedLocalTime(day.getTimeInMillis()); + + // Add repetitions randomly from January to December + // Leaves the month of March empty, to check that it returns null + day.set(2015, 0, 1); + for(int i = 0; i < 365; i ++) + { + if(random.nextBoolean()) + { + int month = day.get(Calendar.MONTH); + int week = day.get(Calendar.DAY_OF_WEEK) % 7; + + if(month != 2) + { + if (month <= 10) + { + weekdayCount[month][week]++; + monthCount[month]++; + } + emptyHabit.repetitions.toggle(day.getTimeInMillis()); + } + } + + day.add(Calendar.DAY_OF_YEAR, 1); + } + + HashMap freq = emptyHabit.repetitions.getWeekdayFrequency(); + + // Repetitions until November should be counted correctly + for(int month = 0; month < 11; month++) + { + day.set(2015, month, 1); + Integer actualCount[] = freq.get(day.getTimeInMillis()); + if(monthCount[month] == 0) + assertThat(actualCount, equalTo(null)); + else + assertThat(actualCount, equalTo(weekdayCount[month])); + } + + // Repetitions in December should be discarded + day.set(2015, 11, 1); + assertThat(freq.get(day.getTimeInMillis()), equalTo(null)); + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..10d453a48 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 545c02988..6bbdfba86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,19 +35,13 @@ android:maxSdkVersion="18"/> - - diff --git a/app/src/main/java/org/isoron/helpers/DateHelper.java b/app/src/main/java/org/isoron/helpers/DateHelper.java index 0aef8c0c6..dbc20b35b 100644 --- a/app/src/main/java/org/isoron/helpers/DateHelper.java +++ b/app/src/main/java/org/isoron/helpers/DateHelper.java @@ -32,14 +32,22 @@ import java.util.TimeZone; public class DateHelper { public static int millisecondsInOneDay = 24 * 60 * 60 * 1000; + private static Long fixedLocalTime = null; public static long getLocalTime() { + if(fixedLocalTime != null) return fixedLocalTime; + TimeZone tz = TimeZone.getDefault(); long now = new Date().getTime(); return now + tz.getOffset(now); } + public static void setFixedLocalTime(Long timestamp) + { + fixedLocalTime = timestamp; + } + public static long toLocalTime(long timestamp) { TimeZone tz = TimeZone.getDefault(); @@ -54,9 +62,7 @@ public class DateHelper public static GregorianCalendar getStartOfTodayCalendar() { - GregorianCalendar day = new GregorianCalendar(TimeZone.getTimeZone("GMT")); - day.setTimeInMillis(DateHelper.getStartOfDay(DateHelper.getLocalTime())); - return day; + return getCalendar(getStartOfToday()); } public static GregorianCalendar getCalendar(long timestamp) @@ -187,5 +193,4 @@ public class DateHelper return weekday; } - } diff --git a/app/src/main/java/org/isoron/uhabits/HabitBroadcastReceiver.java b/app/src/main/java/org/isoron/uhabits/HabitBroadcastReceiver.java index a43757c84..c4969b835 100644 --- a/app/src/main/java/org/isoron/uhabits/HabitBroadcastReceiver.java +++ b/app/src/main/java/org/isoron/uhabits/HabitBroadcastReceiver.java @@ -38,6 +38,7 @@ import android.support.v4.content.LocalBroadcastManager; import org.isoron.helpers.DateHelper; import org.isoron.uhabits.helpers.ReminderHelper; +import org.isoron.uhabits.models.Checkmark; import org.isoron.uhabits.models.Habit; import java.util.Date; @@ -145,7 +146,7 @@ public class HabitBroadcastReceiver extends BroadcastReceiver Long timestamp = intent.getLongExtra("timestamp", DateHelper.getStartOfToday()); Long reminderTime = intent.getLongExtra("reminderTime", DateHelper.getStartOfToday()); - if (habit.repetitions.hasImplicitRepToday()) return; + if (habit.checkmarks.getTodayValue() != Checkmark.UNCHECKED) return; habit.highlight = 1; habit.save(); diff --git a/app/src/main/java/org/isoron/uhabits/HabitsApplication.java b/app/src/main/java/org/isoron/uhabits/HabitsApplication.java new file mode 100644 index 000000000..a7573d946 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/HabitsApplication.java @@ -0,0 +1,78 @@ +/* + * 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; + +import android.app.Application; + +import com.activeandroid.ActiveAndroid; +import com.activeandroid.Configuration; + +import java.io.File; + +public class HabitsApplication extends Application +{ + private boolean isTestMode() + { + try + { + getClassLoader().loadClass("org.isoron.uhabits.unit.models.HabitTest"); + return true; + } + catch (final Exception e) + { + return false; + } + } + + private void deleteDB(String databaseFilename) + { + File databaseFile = new File(String.format("%s/../databases/%s", + getApplicationContext().getFilesDir().getPath(), databaseFilename)); + + if(databaseFile.exists()) databaseFile.delete(); + } + + @Override + public void onCreate() + { + super.onCreate(); + String databaseFilename = BuildConfig.databaseFilename; + + if (isTestMode()) + { + databaseFilename = "test.db"; + deleteDB(databaseFilename); + } + + Configuration dbConfig = new Configuration.Builder(this) + .setDatabaseName(databaseFilename) + .setDatabaseVersion(BuildConfig.databaseVersion) + .create(); + + ActiveAndroid.initialize(dbConfig); + } + + @Override + public void onTerminate() + { + ActiveAndroid.dispose(); + super.onTerminate(); + } +} diff --git a/app/src/main/java/org/isoron/uhabits/fragments/ShowHabitFragment.java b/app/src/main/java/org/isoron/uhabits/fragments/ShowHabitFragment.java index e1dde9c66..6f57abc9c 100644 --- a/app/src/main/java/org/isoron/uhabits/fragments/ShowHabitFragment.java +++ b/app/src/main/java/org/isoron/uhabits/fragments/ShowHabitFragment.java @@ -71,8 +71,6 @@ public class ShowHabitFragment extends Fragment activity = (ShowHabitActivity) getActivity(); habit = activity.habit; - habit.checkmarks.rebuild(); - Button btEditHistory = (Button) view.findViewById(R.id.btEditHistory); streakView = (HabitStreakView) view.findViewById(R.id.streakView); scoreView = (HabitScoreView) view.findViewById(R.id.scoreView); diff --git a/app/src/main/java/org/isoron/uhabits/models/Checkmark.java b/app/src/main/java/org/isoron/uhabits/models/Checkmark.java index e1007a26f..089008efd 100644 --- a/app/src/main/java/org/isoron/uhabits/models/Checkmark.java +++ b/app/src/main/java/org/isoron/uhabits/models/Checkmark.java @@ -26,9 +26,21 @@ import com.activeandroid.annotation.Table; @Table(name = "Checkmarks") public class Checkmark extends Model { - + /** + * Indicates that there was no repetition at the timestamp, even though a repetition was + * expected. + */ public static final int UNCHECKED = 0; + + /** + * Indicates that there was no repetition at the timestamp, but one was not expected in any + * case, due to the frequency of the habit. + */ public static final int CHECKED_IMPLICITLY = 1; + + /** + * Indicates that there was a repetition at the timestamp. + */ public static final int CHECKED_EXPLICITLY = 2; @Column(name = "habit") @@ -38,10 +50,9 @@ public class Checkmark extends Model public Long timestamp; /** - * Indicates whether there is a checkmark at the given timestamp or not, and whether the - * checkmark is explicit or implicit. An explicit checkmark indicates that there is a - * repetition at that day. An implicit checkmark indicates that there is no repetition at that - * day, but a repetition was not needed, due to the frequency of the habit. + * Indicates whether there is a repetition at the given timestamp or not, and whether the + * repetition was expected. Assumes one of the values UNCHECKED, CHECKED_EXPLICITLY or + * CHECKED_IMPLICITLY. */ @Column(name = "value") public Integer value; diff --git a/app/src/main/java/org/isoron/uhabits/models/CheckmarkList.java b/app/src/main/java/org/isoron/uhabits/models/CheckmarkList.java index 04c8fe458..af638e903 100644 --- a/app/src/main/java/org/isoron/uhabits/models/CheckmarkList.java +++ b/app/src/main/java/org/isoron/uhabits/models/CheckmarkList.java @@ -40,6 +40,12 @@ public class CheckmarkList this.habit = habit; } + /** + * Deletes every checkmark that has timestamp either equal or newer than a given timestamp. + * These checkmarks will be recomputed at the next time they are queried. + * + * @param timestamp the timestamp + */ public void deleteNewerThan(long timestamp) { new Delete().from(Checkmark.class) @@ -48,10 +54,21 @@ public class CheckmarkList .execute(); } + /** + * Returns the values of the checkmarks that fall inside a certain interval of time. + * + * The values are returned in an array containing one integer value for each day of the + * interval. The first entry corresponds to the most recent day in the interval. Each subsequent + * entry corresponds to one day older than the previous entry. The boundaries of the time + * interval are included. + * + * @param fromTimestamp timestamp for the oldest checkmark + * @param toTimestamp timestamp for the newest checkmark + * @return values for the checkmarks inside the given interval + */ public int[] getValues(Long fromTimestamp, Long toTimestamp) { - rebuild(); - + buildCache(fromTimestamp, toTimestamp); if(fromTimestamp > toTimestamp) return new int[0]; String query = "select value, timestamp from Checkmarks where " + @@ -81,53 +98,59 @@ public class CheckmarkList return checks; } + /** + * Computes and returns the values for all the checkmarks, since the oldest repetition of the + * habit until today. If there are no repetitions at all, returns an empty array. + * + * The values are returned in an array containing one integer value for each day since the + * first repetition of the habit until today. The first entry corresponds to today, the second + * entry corresponds to yesterday, and so on. + * + * @return values for the checkmarks in the interval + */ public int[] getAllValues() { Repetition oldestRep = habit.repetitions.getOldest(); if(oldestRep == null) return new int[0]; - Long toTimestamp = DateHelper.getStartOfToday(); Long fromTimestamp = oldestRep.timestamp; + Long toTimestamp = DateHelper.getStartOfToday(); + return getValues(fromTimestamp, toTimestamp); } - public void rebuild() + /** + * Computes and stores one checkmark for each day that falls inside the specified interval of + * time. Days that already have a corresponding checkmark are skipped. + * + * @param from timestamp for the beginning of the interval + * @param to timestamp for the end of the interval + */ + public void buildCache(long from, long to) { - long beginning; - long today = DateHelper.getStartOfToday(); long day = DateHelper.millisecondsInOneDay; - Checkmark newestCheckmark = getNewest(); - if (newestCheckmark == null) - { - Repetition oldestRep = habit.repetitions.getOldest(); - if (oldestRep == null) return; - - beginning = oldestRep.timestamp; - } - else - { - beginning = newestCheckmark.timestamp + day; - } - - if (beginning > today) return; + Checkmark newestCheckmark = findNewest(); + if(newestCheckmark != null) + from = Math.max(from, newestCheckmark.timestamp + day); - long beginningExtended = beginning - (long) (habit.freqDen) * day; - List reps = habit.repetitions.selectFromTo(beginningExtended, today).execute(); + if(from > to) return; - int nDays = (int) ((today - beginning) / day) + 1; - int nDaysExtended = (int) ((today - beginningExtended) / day) + 1; + long fromExtended = from - (long) (habit.freqDen) * day; + List reps = habit.repetitions + .selectFromTo(fromExtended, to) + .execute(); + int nDays = (int) ((to - from) / day) + 1; + int nDaysExtended = (int) ((to - fromExtended) / day) + 1; int checks[] = new int[nDaysExtended]; - // explicit checks for (Repetition rep : reps) { - int offset = (int) ((rep.timestamp - beginningExtended) / day); - checks[nDaysExtended - offset - 1] = 2; + int offset = (int) ((rep.timestamp - fromExtended) / day); + checks[nDaysExtended - offset - 1] = Checkmark.CHECKED_EXPLICITLY; } - // implicit checks for (int i = 0; i < nDays; i++) { int counter = 0; @@ -135,7 +158,9 @@ public class CheckmarkList for (int j = 0; j < habit.freqDen; j++) if (checks[i + j] == 2) counter++; - if (counter >= habit.freqNum) checks[i] = Math.max(checks[i], 1); + if (counter >= habit.freqNum) + if(checks[i] != Checkmark.CHECKED_EXPLICITLY) + checks[i] = Checkmark.CHECKED_IMPLICITLY; } ActiveAndroid.beginTransaction(); @@ -146,33 +171,48 @@ public class CheckmarkList { Checkmark c = new Checkmark(); c.habit = habit; - c.timestamp = today - i * day; + c.timestamp = to - i * day; c.value = checks[i]; c.save(); } ActiveAndroid.setTransactionSuccessful(); - } finally + } + finally { ActiveAndroid.endTransaction(); } } - public Checkmark getNewest() + /** + * Returns newest checkmark that has already been computed. Ignores any checkmark that has + * timestamp in the future. This does not update the cache. + */ + private Checkmark findNewest() { return new Select().from(Checkmark.class) .where("habit = ?", habit.getId()) + .and("timestamp <= ?", DateHelper.getStartOfToday()) .orderBy("timestamp desc") .limit(1) .executeSingle(); } - public int getCurrentValue() + /** + * Returns the checkmark for today. + */ + public Checkmark getToday() { - rebuild(); - Checkmark c = getNewest(); + long today = DateHelper.getStartOfToday(); + buildCache(today, today); + return findNewest(); + } - if(c != null) return c.value; - else return 0; + /** + * Returns the value of today's checkmark. + */ + public int getTodayValue() + { + return getToday().value; } } diff --git a/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java b/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java index f21038748..ebdf35c66 100644 --- a/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java +++ b/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java @@ -30,7 +30,6 @@ import com.activeandroid.query.Select; import org.isoron.helpers.DateHelper; import java.util.Arrays; -import java.util.Calendar; import java.util.GregorianCalendar; import java.util.HashMap; @@ -48,6 +47,7 @@ public class RepetitionList { return new Select().from(Repetition.class) .where("habit = ?", habit.getId()) + .and("timestamp <= ?", DateHelper.getStartOfToday()) .orderBy("timestamp"); } @@ -56,12 +56,23 @@ public class RepetitionList return select().and("timestamp >= ?", timeFrom).and("timestamp <= ?", timeTo); } + /** + * Checks whether there is a repetition at a given timestamp. + * + * @param timestamp the timestamp to check + * @return true if there is a repetition + */ public boolean contains(long timestamp) { int count = select().where("timestamp = ?", timestamp).count(); return (count > 0); } + /** + * Deletes the repetition at a given timestamp, if it exists. + * + * @param timestamp the timestamp of the repetition to delete + */ public void delete(long timestamp) { new Delete().from(Repetition.class) @@ -70,11 +81,12 @@ public class RepetitionList .execute(); } - public Repetition getOldestNewerThan(long timestamp) - { - return select().where("timestamp > ?", timestamp).limit(1).executeSingle(); - } - + /** + * Toggles the repetition at a certain timestamp. That is, deletes the repetition if it exists + * or creates one if it does not. + * + * @param timestamp the timestamp of the repetition to toggle + */ public void toggle(long timestamp) { timestamp = DateHelper.getStartOfDay(timestamp); @@ -96,18 +108,27 @@ public class RepetitionList habit.streaks.deleteNewerThan(timestamp); } + /** + * Returns the oldest repetition for the habit. If there is no repetition, returns null. + * Repetitions in the future are discarded. + * + * @return oldest repetition for the habit + */ public Repetition getOldest() { return (Repetition) select().limit(1).executeSingle(); } - public boolean hasImplicitRepToday() - { - long today = DateHelper.getStartOfToday(); - int reps[] = habit.checkmarks.getValues(today - DateHelper.millisecondsInOneDay, today); - return (reps[0] > 0); - } - + /** + * Returns the total number of repetitions for each month, from the first repetition until + * today, grouped by day of week. The repetitions are returned in a HashMap. The key is the + * timestamp for the first day of the month, at midnight (00:00). The value is an integer + * array with 7 entries. The first entry contains the total number of repetitions during + * the specified month that occurred on a Saturday. The second entry corresponds to Sunday, + * and so on. If there are no repetitions during a certain month, the value is null. + * + * @return total number of repetitions by month versus day of week + */ public HashMap getWeekdayFrequency() { Repetition oldestRep = getOldest(); @@ -117,10 +138,11 @@ public class RepetitionList "strftime('%m', timestamp / 1000, 'unixepoch') as month," + "strftime('%w', timestamp / 1000, 'unixepoch') as weekday, " + "count(*) from repetitions " + - "where habit = ? " + + "where habit = ? and timestamp <= ? " + "group by year, month, weekday"; - String[] params = { habit.getId().toString() }; + String[] params = { habit.getId().toString(), + Long.toString(DateHelper.getStartOfToday()) }; SQLiteDatabase db = Cache.openDatabase(); Cursor cursor = db.rawQuery(query, params); diff --git a/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java b/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java index d6b6fcd5c..ba310c70f 100644 --- a/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java +++ b/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java @@ -114,7 +114,7 @@ public class CheckmarkView extends View public void setHabit(Habit habit) { - this.check_status = habit.checkmarks.getCurrentValue(); + this.check_status = habit.checkmarks.getTodayValue(); this.star_status = habit.scores.getCurrentStarStatus(); this.primaryColor = Color.argb(230, Color.red(habit.color), Color.green(habit.color), Color.blue(habit.color)); this.label = habit.name; diff --git a/build.gradle b/build.gradle index f4d8c542e..5e38f58a7 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:1.5.0' + classpath 'com.android.tools.build:gradle:2.1.0-alpha2' } } diff --git a/circle.yml b/circle.yml new file mode 100644 index 000000000..368dbe466 --- /dev/null +++ b/circle.yml @@ -0,0 +1,15 @@ +checkout: + post: + - git submodule sync + - git submodule update --init + +test: + override: + - emulator -avd circleci-android22 -no-audio -no-window: + background: true + parallel: true + - circle-android wait-for-boot + - adb shell input keyevent 82 + - ./gradlew connectedAndroidTest + - cp -r app/build/outputs $CIRCLE_ARTIFACTS || echo ok + - cp -r app/build/reports/androidTests/connected/* $CIRCLE_TEST_REPORTS || echo ok \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0c71e760d..d57051703 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.2.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip diff --git a/libs/drag-sort-listview b/libs/drag-sort-listview index 318d69cf6..54ca667d4 160000 --- a/libs/drag-sort-listview +++ b/libs/drag-sort-listview @@ -1 +1 @@ -Subproject commit 318d69cf6b2adc287cf8944bb847dd7139c60376 +Subproject commit 54ca667d4cfb0e38d0c9df816360059ac0675afe