diff --git a/.gitignore b/.gitignore index 2535b53d..ec359fda 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ build #assets bin gen -proguard-project.txt project.properties gradle.properties local.properties diff --git a/android/build.gradle b/android/build.gradle index d00daa8d..025b7469 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:2.0.0-beta4' + classpath 'com.android.tools.build:gradle:2.0.0-beta5' classpath 'de.felixschulze.gradle:gradle-spoon-plugin:2.6.1' classpath 'com.github.ben-manes:gradle-versions-plugin:0.12.0' classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8' @@ -82,6 +82,10 @@ android { // hack for instrumentation testing :-( exclude 'LICENSE.txt' + + exclude 'META-INF/maven/com.google.guava/guava/pom.properties' + exclude 'META-INF/maven/com.google.guava/guava/pom.xml' + } lintOptions { @@ -119,6 +123,8 @@ dependencies { exclude module: 'recyclerview-v7' } + androidTestCompile('com.android.support.test.espresso:espresso-web:2.2.1') + androidTestCompile 'com.squareup.spoon:spoon-client:1.3.2' androidTestCompile 'com.squareup.assertj:assertj-android:1.1.1' @@ -145,6 +151,7 @@ dependencies { compile 'com.squareup.okhttp:okhttp:2.7.4' compile 'com.larswerkman:HoloColorPicker:1.5' compile 'com.google.code.findbugs:jsr305:3.0.1' + compile 'com.squareup.moshi:moshi:1.1.0' compile 'net.steamcrafted:load-toast:1.0.10' diff --git a/android/proguard-project.txt b/android/proguard-project.txt new file mode 100644 index 00000000..b0d5142c --- /dev/null +++ b/android/proguard-project.txt @@ -0,0 +1,110 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +#http://stackoverflow.com/questions/19274974/android-badparcelableexception-only-with-signed-apk +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +-keepclassmembers class fqcn.of.javascript.interface.for.webview { + public *; +} + +# optimize +-optimizationpasses 2 +-optimizations !code/simplification/arithmetic +-dontusemixedcaseclassnames +-dontskipnonpubliclibraryclasses + +# AppCompat + +-dontwarn android.support.v7.** +-keep class android.support.v7.** { *; } +-keep interface android.support.v7.** { *; } + +# Keep line numbers to alleviate debugging stack traces + +-renamesourcefileattribute SourceFile + +-keepattributes SourceFile,LineNumberTable + +### for api client + + +-keepattributes Signature,RuntimeVisibleAnnotations,AnnotationDefault + +-keepclassmembers class * { + @com.google.api.client.util.Key ; +} + +# Needed by Guava +# See https://groups.google.com/forum/#!topic/guava-discuss/YCZzeCiIVoI + + +-dontwarn sun.misc.Unsafe +-dontwarn com.google.common.collect.MinMaxPriorityQueue + +# Needed by google-http-client-android when linking against an older platform version + +-dontwarn com.google.api.client.extensions.android.** + +# Needed by google-api-client-android when linking against an older platform version + +-dontwarn com.google.api.client.googleapis.extensions.android.** + + +#### for butterknife +-dontwarn butterknife.internal.** +-keep class **$$ViewBinder { *; } +-keepnames class * { @butterknife.Bind *;} + +#### for support 22 +-dontwarn android.support.** + +#### for guava +-dontwarn javax.annotation.** +-dontwarn javax.inject.** +-dontwarn sun.misc.Unsafe +-dontwarn com.google.common.collect.MinMaxPriorityQueue + +-keep,allowoptimization class com.google.inject.** { *; } +-keep,allowoptimization class javax.inject.** { *; } +-keep,allowoptimization class javax.annotation.** { *; } +-keep,allowoptimization class com.google.inject.Binder + +-keepclasseswithmembers public class * { + public static void main(java.lang.String[]); +} + +-keepclassmembers,allowoptimization class com.google.common.* { + void finalizeReferent(); + void startFinalizer(java.lang.Class,java.lang.Object); +} + +-keepclassmembers class * { + @com.google.common.eventbus.Subscribe *; +} + + +-dontwarn java.nio.file.Files +-dontwarn java.nio.file.Path +-dontwarn java.nio.file.OpenOption +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement + +-keep public class com.google.android.gms.** +-dontwarn com.google.android.gms.** + diff --git a/android/src/androidTest/java/org/ligi/passandroid/TestComponent.java b/android/src/androidTest/java/org/ligi/passandroid/TestComponent.java index c0d8402d..3194592a 100644 --- a/android/src/androidTest/java/org/ligi/passandroid/TestComponent.java +++ b/android/src/androidTest/java/org/ligi/passandroid/TestComponent.java @@ -16,4 +16,6 @@ public interface TestComponent extends AppComponent { void inject(ThePastLocationsStore thePastLocationsStore); void inject(TheBarCodeEditFragment theBarCodeEditFragment); + + void inject(ThePassListSwiping thePassListSwiping); } diff --git a/android/src/androidTest/java/org/ligi/passandroid/TestModule.java b/android/src/androidTest/java/org/ligi/passandroid/TestModule.java index 2c4f793f..725553ff 100644 --- a/android/src/androidTest/java/org/ligi/passandroid/TestModule.java +++ b/android/src/androidTest/java/org/ligi/passandroid/TestModule.java @@ -13,6 +13,7 @@ import org.ligi.passandroid.model.comparator.PassSortOrder; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import javax.inject.Singleton; @@ -29,10 +30,11 @@ public class TestModule { public TestModule() { passList = new ArrayList<>(); - final PassImpl object = new PassImpl(); - object.setDescription("description"); - object.setBarCode(new BarCode(BarcodeFormat.AZTEC, "messageprobe")); - passList.add(object); + final PassImpl pass = new PassImpl(); + pass.setId(UUID.randomUUID().toString()); + pass.setDescription("description"); + pass.setBarCode(new BarCode(BarcodeFormat.AZTEC, "messageprobe")); + passList.add(pass); } public TestModule(List passList) { diff --git a/android/src/androidTest/java/org/ligi/passandroid/TheEmptyPassList.java b/android/src/androidTest/java/org/ligi/passandroid/TheEmptyPassList.java index 685bf446..d73c050e 100644 --- a/android/src/androidTest/java/org/ligi/passandroid/TheEmptyPassList.java +++ b/android/src/androidTest/java/org/ligi/passandroid/TheEmptyPassList.java @@ -14,6 +14,7 @@ import static android.support.test.espresso.action.ViewActions.click; import static android.support.test.espresso.assertion.ViewAssertions.matches; import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static org.ligi.passandroid.steps.HelpSteps.checkThatHelpIsThere; public class TheEmptyPassList extends BaseIntegration { @@ -38,7 +39,8 @@ public class TheEmptyPassList extends BaseIntegration { @MediumTest public void testHelpGoesToHelp() { onView(withId(R.id.menu_help)).perform(click()); - onView(withId(R.id.help_tv)).check(matches(isDisplayed())); + + checkThatHelpIsThere(); } } diff --git a/android/src/androidTest/java/org/ligi/passandroid/TheHelpActivity.java b/android/src/androidTest/java/org/ligi/passandroid/TheHelpActivity.java index 098bc5cb..9dd4a40c 100644 --- a/android/src/androidTest/java/org/ligi/passandroid/TheHelpActivity.java +++ b/android/src/androidTest/java/org/ligi/passandroid/TheHelpActivity.java @@ -8,13 +8,11 @@ import org.ligi.passandroid.ui.HelpActivity; import static android.support.test.espresso.Espresso.onView; import static android.support.test.espresso.action.ViewActions.click; -import static android.support.test.espresso.assertion.ViewAssertions.matches; import static android.support.test.espresso.matcher.ViewMatchers.isClickable; -import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; import static android.support.test.espresso.matcher.ViewMatchers.withContentDescription; -import static android.support.test.espresso.matcher.ViewMatchers.withId; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.Matchers.allOf; +import static org.ligi.passandroid.steps.HelpSteps.checkThatHelpIsThere; public class TheHelpActivity extends BaseIntegration { @@ -31,7 +29,9 @@ public class TheHelpActivity extends BaseIntegration { @SmallTest public void testHelpIsThere() { - onView(withId(R.id.help_tv)).check(matches(isDisplayed())); + + checkThatHelpIsThere(); + Spoon.screenshot(getActivity(), "help"); } diff --git a/android/src/androidTest/java/org/ligi/passandroid/ThePassListActivity.java b/android/src/androidTest/java/org/ligi/passandroid/ThePassListActivity.java index 40f8f0b4..b3e87944 100644 --- a/android/src/androidTest/java/org/ligi/passandroid/ThePassListActivity.java +++ b/android/src/androidTest/java/org/ligi/passandroid/ThePassListActivity.java @@ -14,6 +14,7 @@ import static android.support.test.espresso.contrib.DrawerActions.open; import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; import static android.support.test.espresso.matcher.ViewMatchers.withId; import static org.hamcrest.CoreMatchers.not; +import static org.ligi.passandroid.steps.HelpSteps.checkThatHelpIsThere; @TargetApi(14) public class ThePassListActivity extends BaseIntegration { @@ -34,14 +35,15 @@ public class ThePassListActivity extends BaseIntegration { @MediumTest public void testListIsThere() { - onView(withId(R.id.content_list)).check(matches(isDisplayed())); + onView(withId(R.id.pass_recyclerview)).check(matches(isDisplayed())); Spoon.screenshot(getActivity(), "list"); } @MediumTest public void testHelpMenuBringsUsToHelp() { onView(withId(R.id.menu_help)).perform(click()); - onView(withId(R.id.help_tv)).check(matches(isDisplayed())); + + checkThatHelpIsThere(); } @MediumTest diff --git a/android/src/androidTest/java/org/ligi/passandroid/ThePassListSwiping.java b/android/src/androidTest/java/org/ligi/passandroid/ThePassListSwiping.java new file mode 100644 index 00000000..a7716037 --- /dev/null +++ b/android/src/androidTest/java/org/ligi/passandroid/ThePassListSwiping.java @@ -0,0 +1,113 @@ +package org.ligi.passandroid; + +import org.ligi.passandroid.model.PassStore; +import org.ligi.passandroid.ui.PassListActivity; + +import javax.inject.Inject; + +public class ThePassListSwiping extends BaseIntegration { + + public static final String CUSTOM_PROBE = "custom"; + @Inject + PassStore passStore; + + public ThePassListSwiping() { + super(PassListActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + + final TestComponent testComponent = DaggerTestComponent.builder().build(); + + testComponent.inject(this); + + App.setComponent(testComponent); + getActivity(); + } + + /* + + TODO figure out why this is flaky on some devices :-( + + @MediumTest + public void testDialogOpensWhenSwipeRight() { + onView(withId(R.id.pass_recyclerview)).perform(click()); + + onView(withId(R.id.pass_recyclerview)).perform(RecyclerViewActions.actionOnItemAtPosition(0,swipeRight())); + + onView(withText("description")).perform(swipeRight()); + RecyclerViewActions.actionOnItemAtPosition(0,swipeRight()); + + onView(withText(R.string.move_to_new_topic)).check(matches(isDisplayed())); + } + + @MediumTest + public void testWeCanMoveToTrash() { + onView(withId(R.id.pass_recyclerview)).perform(click()); + onView(withText("description")).perform(swipeRight()); + + onView(withId(R.id.suggestion_button_trash)).perform(click()); + + onView(withText(R.string.topic_trash)).check(matches(isDisplayed())); + assertThat(passStore.getClassifier().getTopics()).containsExactly(getActivity().getString(R.string.topic_trash)); + } + + + @MediumTest + public void testWeCanMoveToArchive() { + onView(withId(R.id.pass_recyclerview)).perform(click()); + onView(withText("description")).perform(swipeRight()); + + onView(withId(R.id.suggestion_button_archive)).perform(click()); + + onView(withText(R.string.topic_archive)).check(matches(isDisplayed())); + assertThat(passStore.getClassifier().getTopics()).containsExactly(getActivity().getString(R.string.topic_archive)); + } + + + @MediumTest + public void testWeCanMoveToCustom() { + onView(withId(R.id.pass_recyclerview)).perform(click()); + onView(withText("description")).perform(swipeLeft()); + + onView(withId(R.id.new_topic_edit)).perform(typeText(CUSTOM_PROBE)); + + onView(withText(android.R.string.ok)).perform(click()); + + assertThat(passStore.getClassifier().getTopics()).containsExactly(CUSTOM_PROBE); + } + + + @MediumTest + public void testDialogOpensWhenSwipeLeft() { + onView(withId(R.id.pass_recyclerview)).perform(click()); + onView(withId(R.id.pass_recyclerview)).perform(RecyclerViewActions.actionOnItemAtPosition(0, longClick())); + onView(withId(R.id.pass_recyclerview)).perform(RecyclerViewActions.actionOnItemAtPosition(0, swipeLeft())); + + onView(withText(R.string.move_to_new_topic)).check(matches(isDisplayed())); + } + + + + public static ViewAction swipeRight() { + return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW, + translate(GeneralLocation.CENTER_LEFT, -0.1f, 0), + GeneralLocation.CENTER_RIGHT, Press.FINGER)); + } + + static CoordinatesProvider translate(final CoordinatesProvider coords, + final float dx, final float dy) { + return new CoordinatesProvider() { + @Override + public float[] calculateCoordinates(View view) { + float xy[] = coords.calculateCoordinates(view); + xy[0] += dx * view.getWidth(); + xy[1] += dy * view.getHeight(); + return xy; + } + }; + } + */ +} diff --git a/android/src/androidTest/java/org/ligi/passandroid/injections/FixedPassListPassStore.java b/android/src/androidTest/java/org/ligi/passandroid/injections/FixedPassListPassStore.java index d3962b4b..54b6c4cb 100644 --- a/android/src/androidTest/java/org/ligi/passandroid/injections/FixedPassListPassStore.java +++ b/android/src/androidTest/java/org/ligi/passandroid/injections/FixedPassListPassStore.java @@ -2,15 +2,18 @@ package org.ligi.passandroid.injections; import org.ligi.passandroid.model.FiledPass; import org.ligi.passandroid.model.Pass; +import org.ligi.passandroid.model.PassClassifier; import org.ligi.passandroid.model.PassStore; -import org.ligi.passandroid.model.comparator.PassSortOrder; +import java.util.Collection; +import java.util.HashMap; import java.util.List; public class FixedPassListPassStore implements PassStore { private final List passes; private Pass actPass; + private PassClassifier passClassifier; public FixedPassListPassStore(List passes) { this.passes = passes; @@ -46,10 +49,6 @@ public class FixedPassListPassStore implements PassStore { return null; } - @Override - public void sort(PassSortOrder order) { - } - @Override public Pass getCurrentPass() { return actPass; @@ -60,6 +59,14 @@ public class FixedPassListPassStore implements PassStore { actPass = pass; } + @Override + public PassClassifier getClassifier() { + if (passClassifier == null) { + passClassifier = new PassClassifier(new HashMap>()); + } + return passClassifier; + } + @Override public boolean deletePassWithId(String id) { return false; diff --git a/android/src/androidTest/java/org/ligi/passandroid/steps/HelpSteps.java b/android/src/androidTest/java/org/ligi/passandroid/steps/HelpSteps.java new file mode 100644 index 00000000..f42a3e4a --- /dev/null +++ b/android/src/androidTest/java/org/ligi/passandroid/steps/HelpSteps.java @@ -0,0 +1,13 @@ +package org.ligi.passandroid.steps; + +import android.support.test.espresso.web.assertion.WebViewAssertions; + +import static android.support.test.espresso.web.matcher.DomMatchers.containingTextInBody; +import static android.support.test.espresso.web.sugar.Web.onWebView; + +public class HelpSteps { + public static void checkThatHelpIsThere() { + onWebView().check(WebViewAssertions.webContent(containingTextInBody("Example passes"))); + } + +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 6584b087..2a70d660 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -22,17 +22,21 @@ android:label="@string/app_name" android:theme="@style/AppTheme"> - + + + android:label="@string/app_name" + android:theme="@style/AppBaseThemeNoActionbar"> - + @@ -697,7 +701,7 @@ - + @@ -743,6 +747,7 @@ passList = new ArrayList<>(); private Pass actPass; + private final PassClassifier passClassifier; public AndroidFileSystemPassStore(final Context context, final Settings settings) { this.context = context; path = settings.getPassesDir(); refreshPassesList(); + final File classificationFile = new File(settings.getStateDir(), "classification_state.json"); + passClassifier = new FileBackedPassClassifier(classificationFile); } @Override @@ -124,11 +125,6 @@ public class AndroidFileSystemPassStore implements PassStore { return getCachedPassOrLoad(id); } - @Override - public void sort(final PassSortOrder order) { - Collections.sort(passList, order.toComparator()); - } - @Override public Pass getCurrentPass() { return actPass; @@ -139,9 +135,18 @@ public class AndroidFileSystemPassStore implements PassStore { actPass = pass; } + @Override + public PassClassifier getClassifier() { + return passClassifier; + } + @Override public boolean deletePassWithId(final String id) { - return AXT.at(new File(getPathForID(id))).deleteRecursive(); + final boolean result = AXT.at(new File(getPathForID(id))).deleteRecursive(); + if (result) { + refreshPassesList(); + } + return result; } public String getPathForID(final String id) { diff --git a/android/src/main/java/org/ligi/passandroid/model/AndroidSettings.java b/android/src/main/java/org/ligi/passandroid/model/AndroidSettings.java new file mode 100644 index 00000000..f22bad8d --- /dev/null +++ b/android/src/main/java/org/ligi/passandroid/model/AndroidSettings.java @@ -0,0 +1,61 @@ +package org.ligi.passandroid.model; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Environment; +import android.preference.PreferenceManager; + +import org.ligi.passandroid.model.comparator.PassSortOrder; + +import java.io.File; + +public class AndroidSettings implements Settings { + public static final String ORDER_KEY = "order"; + public final Context context; + + final SharedPreferences sharedPreferences; + + public AndroidSettings(Context context) { + this.context = context; + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + } + + @Override + public void setSortOrder(PassSortOrder order) { + sharedPreferences.edit().putInt(ORDER_KEY, order.getInt()).apply(); + } + + @Override + public PassSortOrder getSortOrder() { + int id = sharedPreferences.getInt(ORDER_KEY, 0); + for (PassSortOrder order : PassSortOrder.values()) { + if (order.getInt() == id) { + return order; + } + } + return PassSortOrder.DATE; + } + + @Override + public boolean doTraceDroidEmailSend() { + // will be overridden in test-module + return true; + } + + @Override + public String getPassesDir() { + return context.getFilesDir().getAbsolutePath() + "/passes"; + } + + @Override + public File getStateDir() { + return new File(context.getFilesDir(), "state"); + } + + + @Override + public String getShareDir() { + return Environment.getExternalStorageDirectory() + "/tmp/passbook_share_tmp/"; + } + +} diff --git a/android/src/main/java/org/ligi/passandroid/model/FileBackedPassClassifier.java b/android/src/main/java/org/ligi/passandroid/model/FileBackedPassClassifier.java new file mode 100644 index 00000000..e7737f16 --- /dev/null +++ b/android/src/main/java/org/ligi/passandroid/model/FileBackedPassClassifier.java @@ -0,0 +1,82 @@ +package org.ligi.passandroid.model; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; + +import java.io.File; +import java.io.IOException; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import okio.BufferedSink; +import okio.Okio; + +public class FileBackedPassClassifier extends PassClassifier { + + private final JsonAdapter adapter; + private final File backed_file; + + public FileBackedPassClassifier(final File backed_file) { + super(getBase(backed_file)); + + this.backed_file = backed_file; + adapter = getAdapter(); + } + + private static JsonAdapter getAdapter() { + final Moshi build = new Moshi.Builder().build(); + return build.adapter(Map.class); + } + + @SuppressWarnings("unchecked") + private static Map> getBase(final File backed_file) { + + if (backed_file.exists()) { + try { + return (Map>) getAdapter().fromJson(Okio.buffer(Okio.source(backed_file))); + } catch (IOException e) { + e.printStackTrace(); + } + } + + return new HashMap<>(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private BufferedSink getBufferedSinkFromMaybeCreatedFile() { + try { + if (!backed_file.exists()) { + final File parentFile = backed_file.getParentFile(); + if (!parentFile.exists()) { + parentFile.mkdirs(); + } + backed_file.createNewFile(); + } + + return Okio.buffer(Okio.sink(backed_file)); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + @Override + public void processDataChange() { + super.processDataChange(); + + // write + if (adapter != null) { + final BufferedSink buffer = getBufferedSinkFromMaybeCreatedFile(); + + if (buffer != null) { + try { + adapter.toJson(buffer, pass_id_list_by_topic); + buffer.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } +} diff --git a/android/src/main/java/org/ligi/passandroid/model/PassClassifier.java b/android/src/main/java/org/ligi/passandroid/model/PassClassifier.java new file mode 100644 index 00000000..1db53ebb --- /dev/null +++ b/android/src/main/java/org/ligi/passandroid/model/PassClassifier.java @@ -0,0 +1,118 @@ +package org.ligi.passandroid.model; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CopyOnWriteArraySet; + +public class PassClassifier { + + public interface OnClassificationChangeListener { + void OnClassificationChange(); + } + + public Set onClassificationChangeListeners = new CopyOnWriteArraySet<>(); + + public final static String DEFAULT_TOPIC = "active"; + + protected final Map> pass_id_list_by_topic; + private final Map topic_by_id = new HashMap<>(); + + public PassClassifier(Map> pass_id_list_by_topic) { + this.pass_id_list_by_topic = pass_id_list_by_topic; + + processDataChange(); + } + + public void processDataChange() { + calculateReverseMapping(); + removeEmpty(); + makeSureDefaultTopicExists(); + } + + private void calculateReverseMapping() { + topic_by_id.clear(); + for (Map.Entry> stringListEntry : pass_id_list_by_topic.entrySet()) { + final String topic = stringListEntry.getKey(); + for (String id : stringListEntry.getValue()) { + topic_by_id.put(id, topic); + } + } + + } + + private void makeSureDefaultTopicExists() { + + if (pass_id_list_by_topic.isEmpty()) { + pass_id_list_by_topic.put(DEFAULT_TOPIC, new TreeSet()); + } + } + + private void removeEmpty() { + final Set toRemove = new HashSet<>(); + + for (Map.Entry> stringListEntry : pass_id_list_by_topic.entrySet()) { + if (stringListEntry.getValue().isEmpty()) { + toRemove.add(stringListEntry.getKey()); + } + } + + for (String s : toRemove) { + pass_id_list_by_topic.remove(s); + } + } + + public void moveToTopic(final Pass pass, final String newTopic) { + if (topic_by_id.containsKey(pass.getId())) { + final String oldTopic = topic_by_id.get(pass.getId()); + final Collection idsForOldTopic = pass_id_list_by_topic.get(oldTopic); + idsForOldTopic.remove(pass.getId()); + if (idsForOldTopic.isEmpty()) { + pass_id_list_by_topic.remove(oldTopic); + } + + } + + upsertPassToTopic(pass, newTopic); + + processDataChange(); + + notifyDataChange(); + } + + public void notifyDataChange() { + for (OnClassificationChangeListener onClassificationChangeListener : onClassificationChangeListeners) { + onClassificationChangeListener.OnClassificationChange(); + } + } + + private void upsertPassToTopic(Pass pass, String newTopic) { + if (!pass_id_list_by_topic.containsKey(newTopic)) { + pass_id_list_by_topic.put(newTopic, new TreeSet()); + } + + final Collection strings = pass_id_list_by_topic.get(newTopic); + if (!strings.contains(pass.getId())) { + strings.add(pass.getId()); + processDataChange(); + } + } + + public String[] getTopics() { + final Set strings = pass_id_list_by_topic.keySet(); + return pass_id_list_by_topic.keySet().toArray(new String[strings.size()]); + } + + public String getTopic(Pass pass) { + if(topic_by_id.containsKey(pass.getId())) { + return topic_by_id.get(pass.getId()); + } + + upsertPassToTopic(pass,DEFAULT_TOPIC); + + return DEFAULT_TOPIC; + } +} diff --git a/android/src/main/java/org/ligi/passandroid/model/PassStore.java b/android/src/main/java/org/ligi/passandroid/model/PassStore.java index 520a9b8b..15fe3ee4 100644 --- a/android/src/main/java/org/ligi/passandroid/model/PassStore.java +++ b/android/src/main/java/org/ligi/passandroid/model/PassStore.java @@ -2,33 +2,28 @@ package org.ligi.passandroid.model; import android.support.annotation.Nullable; -import org.ligi.passandroid.model.comparator.PassSortOrder; - import java.util.List; public interface PassStore { - boolean deletePassWithId(String id); - - String getPathForID(String id); - - - List getPassList(); - - void preCachePassesList(); - void deleteCacheForId(String id); void refreshPassesList(); Pass getPassbookForId(String id); + boolean deletePassWithId(String id); - void sort(PassSortOrder order); + String getPathForID(String id); + + void preCachePassesList(); + + List getPassList(); @Nullable Pass getCurrentPass(); void setCurrentPass(@Nullable Pass pass); + PassClassifier getClassifier(); } diff --git a/android/src/main/java/org/ligi/passandroid/model/PassStoreProjection.java b/android/src/main/java/org/ligi/passandroid/model/PassStoreProjection.java new file mode 100644 index 00000000..9b7750da --- /dev/null +++ b/android/src/main/java/org/ligi/passandroid/model/PassStoreProjection.java @@ -0,0 +1,43 @@ +package org.ligi.passandroid.model; + +import org.ligi.passandroid.model.comparator.PassSortOrder; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class PassStoreProjection { + + private List passList = new ArrayList<>(); + + private final PassStore passStore; + private final String topic; + private final PassSortOrder passSortOrder; + + public PassStoreProjection(final PassStore passStore, final String topic, PassSortOrder order) { + + this.passStore = passStore; + this.topic = topic; + this.passSortOrder = order; + + refresh(); + } + + public List getPassList() { + return passList; + } + + public void refresh() { + ArrayList newPassList = new ArrayList<>(); + + for (FiledPass filedPass : passStore.getPassList()) { + if (passStore.getClassifier().getTopic(filedPass).equals(topic)) { + newPassList.add(filedPass); + } + } + + Collections.sort(newPassList, passSortOrder.toComparator()); + + passList = newPassList; + } +} diff --git a/android/src/main/java/org/ligi/passandroid/model/Settings.java b/android/src/main/java/org/ligi/passandroid/model/Settings.java index 46a4596e..aa5a91ab 100644 --- a/android/src/main/java/org/ligi/passandroid/model/Settings.java +++ b/android/src/main/java/org/ligi/passandroid/model/Settings.java @@ -1,48 +1,21 @@ package org.ligi.passandroid.model; -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Environment; -import android.preference.PreferenceManager; - import org.ligi.passandroid.model.comparator.PassSortOrder; -public class Settings { - public static final String ORDER_KEY = "order"; - public final Context context; +import java.io.File; - private final SharedPreferences sharedPreferences; +public interface Settings { - public Settings(Context context) { - this.context = context; - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - } + void setSortOrder(PassSortOrder order); - public void setSortOrder(PassSortOrder order) { - sharedPreferences.edit().putInt(ORDER_KEY, order.getInt()).apply(); - } + PassSortOrder getSortOrder(); - public PassSortOrder getSortOrder() { - int id = sharedPreferences.getInt(ORDER_KEY, 0); - for (PassSortOrder order : PassSortOrder.values()) { - if (order.getInt() == id) { - return order; - } - } - return PassSortOrder.DATE; - } + boolean doTraceDroidEmailSend(); - public boolean doTraceDroidEmailSend() { - // will be overridden in test-module - return true; - } + String getPassesDir(); - public String getPassesDir() { - return context.getFilesDir().getAbsolutePath() + "/passes"; - } + File getStateDir(); - public String getShareDir() { - return Environment.getExternalStorageDirectory() + "/tmp/passbook_share_tmp/"; - } + String getShareDir(); } diff --git a/android/src/main/java/org/ligi/passandroid/ui/HelpActivity.java b/android/src/main/java/org/ligi/passandroid/ui/HelpActivity.java index bd4a96be..82afff13 100644 --- a/android/src/main/java/org/ligi/passandroid/ui/HelpActivity.java +++ b/android/src/main/java/org/ligi/passandroid/ui/HelpActivity.java @@ -2,19 +2,23 @@ package org.ligi.passandroid.ui; import android.os.Bundle; import android.support.v7.app.AppCompatActivity; -import android.text.Html; -import android.text.method.LinkMovementMethod; -import android.text.util.Linkify; +import android.support.v7.widget.Toolbar; import android.view.MenuItem; -import android.widget.TextView; +import android.webkit.WebSettings; +import android.webkit.WebView; + +import org.ligi.passandroid.R; + import butterknife.Bind; import butterknife.ButterKnife; -import org.ligi.passandroid.R; public class HelpActivity extends AppCompatActivity { - @Bind(R.id.help_tv) - TextView helpTextView; + @Bind(R.id.help_webview) + WebView helpWebView; + + @Bind(R.id.toolbar) + Toolbar toolbar; @Override protected void onCreate(Bundle savedInstanceState) { @@ -22,11 +26,13 @@ public class HelpActivity extends AppCompatActivity { setContentView(R.layout.activity_help); ButterKnife.bind(this); - helpTextView.setText(Html.fromHtml(getString(R.string.help_content))); + WebSettings webSettings = helpWebView.getSettings(); + webSettings.setJavaScriptEnabled(true); + webSettings.setStandardFontFamily("Sans-Serif"); - Linkify.addLinks(helpTextView, Linkify.ALL); + helpWebView.loadData(getString(R.string.help_content),"text/html","utf-8"); - helpTextView.setMovementMethod(LinkMovementMethod.getInstance()); + setSupportActionBar(toolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); } diff --git a/android/src/main/java/org/ligi/passandroid/ui/MoveToNewTopicUI.java b/android/src/main/java/org/ligi/passandroid/ui/MoveToNewTopicUI.java new file mode 100644 index 00000000..776b21a2 --- /dev/null +++ b/android/src/main/java/org/ligi/passandroid/ui/MoveToNewTopicUI.java @@ -0,0 +1,84 @@ +package org.ligi.passandroid.ui; + +import android.app.Activity; +import android.content.DialogInterface; +import android.support.v7.app.AlertDialog; +import android.view.View; +import android.widget.EditText; + +import org.ligi.passandroid.R; +import org.ligi.passandroid.helper.MoveHelper; +import org.ligi.passandroid.model.Pass; +import org.ligi.passandroid.model.PassStore; + +import butterknife.Bind; +import butterknife.ButterKnife; +import butterknife.OnClick; + +class MoveToNewTopicUI { + + private final Activity context; + private final PassStore passStore; + private final Pass pass; + + @Bind(R.id.new_topic_edit) + EditText newTopicEditText; + private AlertDialog dialog; + + @OnClick(R.id.suggestion_button_trash) + void onTrashClick() { + move(context.getString(R.string.topic_trash)); + } + + @OnClick(R.id.suggestion_button_archive) + void onArchiveClick() { + move(context.getString(R.string.topic_archive)); + } + + private void move(String topic) { + MoveHelper.moveWithUndoSnackbar(passStore.getClassifier(),pass,topic,context); + + dialog.dismiss(); + } + + MoveToNewTopicUI(Activity context, PassStore passStore, Pass pass) { + this.context = context; + this.passStore = passStore; + this.pass = pass; + } + + + public void showTopicMove() { + + dialog = new AlertDialog.Builder(context) + .setTitle(context.getString(R.string.move_to_new_topic)) + .setView(R.layout.dialog_move_to_new_topic) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // empty but needed + } + }) + .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + passStore.getClassifier().notifyDataChange(); + } + }) + .show(); + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (newTopicEditText.getText().toString().isEmpty()) { + newTopicEditText.setError("cannot be empty"); + newTopicEditText.requestFocus(); + } else { + move(newTopicEditText.getText().toString()); + } + } + }); + ButterKnife.bind(this, dialog); + } + +} diff --git a/android/src/main/java/org/ligi/passandroid/ui/MyShyFABBehavior.java b/android/src/main/java/org/ligi/passandroid/ui/MyShyFABBehavior.java new file mode 100644 index 00000000..54aaed82 --- /dev/null +++ b/android/src/main/java/org/ligi/passandroid/ui/MyShyFABBehavior.java @@ -0,0 +1,66 @@ +package org.ligi.passandroid.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.support.design.widget.AppBarLayout; +import android.support.design.widget.CoordinatorLayout; +import android.support.design.widget.Snackbar; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.view.View; + +import net.i2p.android.ext.floatingactionbutton.FloatingActionsMenu; + +import org.ligi.passandroid.R; + +public class MyShyFABBehavior extends CoordinatorLayout.Behavior { + + public MyShyFABBehavior() { + } + + public MyShyFABBehavior(Context context, AttributeSet attrs) { + super(context, attrs); + } + + + @Override + public boolean layoutDependsOn(CoordinatorLayout parent, FloatingActionsMenu child, View dependency) { + return dependency instanceof Snackbar.SnackbarLayout || dependency instanceof AppBarLayout; + } + + @Override + public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionsMenu child, View dependency) { + if (dependency instanceof Snackbar.SnackbarLayout) { + updateFabTranslationForSnackbar(child, dependency); + } + if (dependency instanceof AppBarLayout) { + final CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams(); + final int fabBottomMargin = lp.bottomMargin; + final int distanceToScroll; + if (child.isExpanded()) { + distanceToScroll = child.getHeight() + fabBottomMargin; + } else { + distanceToScroll = (int) (child.getContext().getResources().getDimension(R.dimen.fab_size_normal) + 2 * fabBottomMargin); + } + final float ratio = ViewCompat.getY(dependency) / getToolbarHeight(dependency.getContext()); + + ViewCompat.setTranslationY(child, -distanceToScroll * ratio); + } + return false; + } + + private void updateFabTranslationForSnackbar(FloatingActionsMenu child, View dependency) { + final float translationY = ViewCompat.getTranslationY(dependency) - dependency.getHeight(); + final float translationYClipped = Math.min(0, translationY); + ViewCompat.setTranslationY(child, translationYClipped); + } + + private int getToolbarHeight(Context context) { + final TypedArray styledAttributes = context.getTheme().obtainStyledAttributes( + new int[]{R.attr.actionBarSize}); + int toolbarHeight = (int) styledAttributes.getDimension(0, 0); + styledAttributes.recycle(); + + return toolbarHeight; + } +} diff --git a/android/src/main/java/org/ligi/passandroid/ui/PassAdapter.java b/android/src/main/java/org/ligi/passandroid/ui/PassAdapter.java index c9377f04..dfe33228 100644 --- a/android/src/main/java/org/ligi/passandroid/ui/PassAdapter.java +++ b/android/src/main/java/org/ligi/passandroid/ui/PassAdapter.java @@ -1,5 +1,6 @@ package org.ligi.passandroid.ui; +import android.support.v7.app.AppCompatActivity; import android.support.v7.view.ActionMode; import android.support.v7.widget.CardView; import android.support.v7.widget.RecyclerView; @@ -15,6 +16,7 @@ import org.ligi.passandroid.R; import org.ligi.passandroid.model.FiledPass; import org.ligi.passandroid.model.Pass; import org.ligi.passandroid.model.PassStore; +import org.ligi.passandroid.model.PassStoreProjection; import java.util.List; @@ -25,10 +27,13 @@ public class PassAdapter extends RecyclerView.Adapter { @Inject PassStore passStore; - protected final PassListActivity passListActivity; + protected final AppCompatActivity passListActivity; + private final PassStoreProjection passStoreProjection; + ActionMode actionMode; - public PassAdapter(PassListActivity passListActivity) { + public PassAdapter(AppCompatActivity passListActivity, PassStoreProjection passStoreProjection) { + this.passStoreProjection = passStoreProjection; App.component().inject(this); this.passListActivity = passListActivity; } @@ -42,10 +47,10 @@ public class PassAdapter extends RecyclerView.Adapter { } @Override - public void onBindViewHolder(PassViewHolder viewHolder, final int longClickedCardPosition) { - final Pass pass = passStore.getPassList().get(longClickedCardPosition); + public void onBindViewHolder(final PassViewHolder viewHolder, int longClickedCardPosition) { + final Pass pass = passStoreProjection.getPassList().get(longClickedCardPosition); - viewHolder.apply(pass,passListActivity); + viewHolder.apply(pass, passListActivity); final CardView root = viewHolder.root; @@ -62,7 +67,7 @@ public class PassAdapter extends RecyclerView.Adapter { @Override public boolean onLongClick(View v) { - final Pass pass = getList().get(longClickedCardPosition); + final Pass pass = getList().get(viewHolder.getAdapterPosition()); if (actionMode != null) { final boolean clickedOnDifferentItem = actionMode.getTag() == null || !actionMode.getTag().equals(pass); @@ -88,7 +93,7 @@ public class PassAdapter extends RecyclerView.Adapter { @Override public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { - if (new PassMenuOptions(passListActivity, getList().get(longClickedCardPosition)).process(menuItem)) { + if (new PassMenuOptions(passListActivity, getList().get(viewHolder.getAdapterPosition())).process(menuItem)) { actionMode.finish(); return true; } @@ -103,7 +108,7 @@ public class PassAdapter extends RecyclerView.Adapter { } }); - actionMode.setTag(getList().get(longClickedCardPosition)); + actionMode.setTag(getList().get(viewHolder.getAdapterPosition())); root.setCardElevation(v.getContext().getResources().getDimension(R.dimen.card_longclick_elevation)); @@ -119,8 +124,8 @@ public class PassAdapter extends RecyclerView.Adapter { return position; } - public List getList() { - return passStore.getPassList(); + private List getList() { + return passStoreProjection.getPassList(); } @Override diff --git a/android/src/main/java/org/ligi/passandroid/ui/PassListActivity.java b/android/src/main/java/org/ligi/passandroid/ui/PassListActivity.java index b00ce815..814cabde 100644 --- a/android/src/main/java/org/ligi/passandroid/ui/PassListActivity.java +++ b/android/src/main/java/org/ligi/passandroid/ui/PassListActivity.java @@ -8,10 +8,14 @@ import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.support.design.widget.Snackbar; +import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentStatePagerAdapter; +import android.support.v4.view.ViewPager; import android.support.v4.widget.DrawerLayout; import android.support.v7.app.ActionBarDrawerToggle; -import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -31,6 +35,7 @@ import org.ligi.passandroid.events.SortOrderChangeEvent; import org.ligi.passandroid.events.TypeFocusEvent; import org.ligi.passandroid.helper.PassUtil; import org.ligi.passandroid.model.FiledPass; +import org.ligi.passandroid.model.PassClassifier; import org.ligi.snackengage.SnackEngage; import org.ligi.snackengage.snacks.DefaultRateSnack; import org.ligi.tracedroid.TraceDroid; @@ -40,15 +45,20 @@ import butterknife.Bind; import butterknife.ButterKnife; import butterknife.OnClick; -public class PassListActivity extends PassAndroidActivity { +public class PassListActivity extends PassAndroidActivity implements PassClassifier.OnClassificationChangeListener { private static final int OPEN_FILE_READ_REQUEST_CODE = 1000; - private PassAdapter passAdapter; private ActionBarDrawerToggle drawerToggle; - @Bind(R.id.content_list) - RecyclerView recyclerView; + @Bind(R.id.toolbar) + Toolbar toolbar; + + @Bind(R.id.tab_layout) + TabLayout tabLayout; + + @Bind(R.id.view_pager) + ViewPager viewPager; @Bind(R.id.drawer_layout) DrawerLayout drawer; @@ -59,11 +69,14 @@ public class PassListActivity extends PassAndroidActivity { @Bind(R.id.fam) FloatingActionsMenu floatingActionsMenu; + private PassTopicFragmentPagerAdapter adapter; + @OnClick(R.id.fab_action_create_pass) void onFABClick() { final FiledPass pass = PassUtil.createEmptyPass(); passStore.setCurrentPass(pass); + passStore.getClassifier().moveToTopic(pass,adapter.getPageTitle(tabLayout.getSelectedTabPosition()).toString()); pass.save(passStore); AXT.at(this).startCommonIntent().activityFromClass(PassEditActivity.class); floatingActionsMenu.collapse(); @@ -86,7 +99,6 @@ public class PassListActivity extends PassAndroidActivity { @Bind(R.id.fab_action_open_file) FloatingActionButton openFileFAB; - public final static int VERSION_STARTING_TO_SUPPORT_STORAGE_FRAMEWORK = 19; @OnClick(R.id.fab_action_open_file) @@ -120,7 +132,6 @@ public class PassListActivity extends PassAndroidActivity { @Subscribe public void typeFocus(TypeFocusEvent typeFocusEvent) { - scrollToType(typeFocusEvent.type); drawer.closeDrawers(); } @@ -131,12 +142,9 @@ public class PassListActivity extends PassAndroidActivity { public void run() { passStore.refreshPassesList(); - passStore.sort(settings.getSortOrder()); AXT.at(emptyView).setVisibility(passStore.getPassList().isEmpty()); - passAdapter.notifyDataSetChanged(); - } }); @@ -155,11 +163,9 @@ public class PassListActivity extends PassAndroidActivity { ButterKnife.bind(this); - AXT.at(openFileFAB).setVisibility(Build.VERSION.SDK_INT >= VERSION_STARTING_TO_SUPPORT_STORAGE_FRAMEWORK); + setSupportActionBar(toolbar); - final LinearLayoutManager llm = new LinearLayoutManager(this); - llm.setOrientation(LinearLayoutManager.VERTICAL); - recyclerView.setLayoutManager(llm); + AXT.at(openFileFAB).setVisibility(Build.VERSION.SDK_INT >= VERSION_STARTING_TO_SUPPORT_STORAGE_FRAMEWORK); // don't want too many windows in worst case - so check for errors first if (TraceDroid.getStackTraceFiles().length > 0) { @@ -170,7 +176,7 @@ public class PassListActivity extends PassAndroidActivity { } else { // if no error - check if there is a new version of the app tracker.trackEvent("ui_event", "processFile", "updatenotice", null); - SnackEngage.from(this).withSnack(new DefaultRateSnack()).build().engageWhenAppropriate(); + SnackEngage.from(floatingActionsMenu).withSnack(new DefaultRateSnack()).build().engageWhenAppropriate(); } drawerToggle = new ActionBarDrawerToggle(this, drawer, R.string.drawer_open, R.string.drawer_close) { @@ -185,19 +191,13 @@ public class PassListActivity extends PassAndroidActivity { if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); } - passAdapter = new PassAdapter(this); - recyclerView.setAdapter(passAdapter); + + adapter = new PassTopicFragmentPagerAdapter(passStore.getClassifier(), getSupportFragmentManager()); + viewPager.setAdapter(adapter); + + tabLayout.setupWithViewPager(viewPager); } - private void scrollToType(String type) { - - for (int i = 0; i < passAdapter.getItemCount(); i++) { - if (passStore.getPassList().get(i).getTypeNotNull().equals(type)) { - recyclerView.scrollToPosition(i); - return; // we are done - } - } - } @Override @@ -222,6 +222,9 @@ public class PassListActivity extends PassAndroidActivity { bus.register(this); refreshPasses(); + + adapter.notifyDataSetChanged(); + passStore.getClassifier().onClassificationChangeListeners.add(this); } @Override @@ -245,8 +248,77 @@ public class PassListActivity extends PassAndroidActivity { @Override protected void onPause() { + passStore.getClassifier().onClassificationChangeListeners.remove(this); bus.unregister(this); super.onPause(); } + @Override + public void OnClassificationChange() { + refreshPasses(); + + adapter.notifyDataSetChanged(); + + setupWithViewPagerIfNeeded(); + } + + private void setupWithViewPagerIfNeeded() { + if (!areTabLayoutAndViewPagerInSync()) { + tabLayout.setupWithViewPager(viewPager); + } + } + + private boolean areTabLayoutAndViewPagerInSync() { + if (adapter.getCount() == tabLayout.getTabCount()) { + for (int i=0;i - + android:layout_height="match_parent" + android:orientation="vertical"> - - \ No newline at end of file + android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"> + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/activity_pass_view_base.xml b/android/src/main/res/layout/activity_pass_view_base.xml index bbaaed0d..54c05126 100644 --- a/android/src/main/res/layout/activity_pass_view_base.xml +++ b/android/src/main/res/layout/activity_pass_view_base.xml @@ -1,8 +1,35 @@ - + - - + android:layout_height="match_parent" + android:orientation="vertical"> - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/android/src/main/res/layout/dialog_move_to_new_topic.xml b/android/src/main/res/layout/dialog_move_to_new_topic.xml new file mode 100644 index 00000000..44637577 --- /dev/null +++ b/android/src/main/res/layout/dialog_move_to_new_topic.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + +