diff --git a/.travis.yml b/.travis.yml index b7fc726..4a669ca 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,7 +35,19 @@ android: before_script: - chmod +x gradlew -script: - - ./gradlew clean assembleDebug - - ./gradlew checkstyle - - ./gradlew testDevDebug \ No newline at end of file +jobs: + include: + - stage: build + name: "Assemble Debug" + script: ./gradlew clean assembleDebug + - stage: static-analysis + name: "Static Analysis" + script: ./gradlew checkstyle + - stage: test + name: "Deploy to GCP" + script: ./gradlew testDevDebug + +stages: + - build + - static-analysis + - test \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index f613589..265338d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -41,17 +41,14 @@ final JavaVersion JAVA_VERSION = JavaVersion.VERSION_1_8 android { // Keep version in sync with /project.properties compileSdkVersion 28 - compileOptions { sourceCompatibility = JAVA_VERSION targetCompatibility = JAVA_VERSION } - dexOptions { preDexLibraries = preDexEnabled jumboMode = true } - defaultConfig { applicationId 'org.wikipedia' minSdkVersion 19 @@ -64,11 +61,10 @@ android { javaCompileOptions { annotationProcessorOptions { arguments('butterknife.minSdk': minSdkVersion.apiString, - 'butterknife.debuggable': 'false') + 'butterknife.debuggable': 'false') } } } - sourceSets { test { java.srcDirs += 'src/testlib/java' @@ -77,7 +73,6 @@ android { java.srcDirs += 'src/testlib/java' } } - signingConfigs { prod { setSigningConfigKey(prod, PROD_PROPS) @@ -86,7 +81,6 @@ android { setSigningConfigKey(debug, REPO_PROPS) } } - buildTypes { debug { minifyEnabled true @@ -99,9 +93,7 @@ android { testProguardFile 'test-proguard-rules.pro' } } - flavorDimensions 'default' - productFlavors { dev { versionName computeVersionName('dev') @@ -144,7 +136,6 @@ android { dimension 'default' } } - splits { abi { enable true @@ -153,21 +144,30 @@ android { universalApk true } } - packagingOptions { - exclude 'META-INF/services/javax.annotation.processing.Processor' // required by Butter Knife + exclude 'META-INF/services/javax.annotation.processing.Processor' + // required by Butter Knife // For Espresso testing libraries. See http://stackoverflow.com/q/33800924/970346. exclude 'META-INF/maven/com.google.guava/guava/pom.xml' exclude 'META-INF/maven/com.google.guava/guava/pom.properties' + exclude 'META-INF/DEPENDENCIES' + exclude 'META-INF/LICENSE' + exclude 'META-INF/LICENSE.txt' + exclude 'META-INF/license.txt' + exclude 'META-INF/NOTICE' + exclude 'META-INF/NOTICE.txt' + exclude 'META-INF/notice.txt' + exclude 'META-INF/ASL2.0' + exclude 'META-INF/INDEX.LIST' } - testOptions { unitTests { includeAndroidResources = true returnDefaultValues = true } } + buildToolsVersion '28.0.3' } // Map for the version code that gives each ABI a value. @@ -190,35 +190,34 @@ apply from: '../gradle/src/checkstyle.gradle' dependencies { // To keep the Maven Central dependencies up-to-date + // use http://gradleplease.appspot.com/ or http://search.maven.org/. - // Debug with ./gradlew -q app:dependencies --configuration compile + // Debug with ./gradlew -q app:dependencies --configuration compile String okHttpVersion = '3.11.0' String supportVersion = '28.0.0' String retrofitVersion = '2.4.0' String espressoVersion = '3.0.1' String butterKnifeVersion = '8.8.1' - String frescoVersion = '1.11.0' // When updating this version, resync file copies under app/src/main/java/com/facebook + String frescoVersion = '1.11.0' + + // When updating this version, resync file copies under app/src/main/java/com/facebook String testingSupportVersion = '1.0.2' String mockitoCore = 'org.mockito:mockito-core:1.9.5' String leakCanaryVersion = '1.6.2' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "com.android.support:support-compat:$supportVersion" implementation "com.android.support:support-core-utils:$supportVersion" implementation "com.android.support:support-core-ui:$supportVersion" implementation "com.android.support:support-fragment:$supportVersion" implementation 'com.android.support.constraint:constraint-layout:1.1.3' - implementation "com.android.support:cardview-v7:$supportVersion" implementation "com.android.support:design:$supportVersion" implementation "com.android.support:recyclerview-v7:$supportVersion" implementation "com.android.support:palette-v7:$supportVersion" implementation "com.android.support:preference-v14:$supportVersion" implementation "com.android.support:exifinterface:$supportVersion" - - implementation ('com.github.michael-rapp:chrome-like-tab-switcher:0.3.7') { + implementation('com.github.michael-rapp:chrome-like-tab-switcher:0.3.7') { // TODO: remove when this component updates its own dependency on Support 28.0.0 exclude group: 'com.android.support' } @@ -232,37 +231,33 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion" - implementation "io.reactivex.rxjava2:rxjava:2.2.3" - implementation "io.reactivex.rxjava2:rxandroid:2.1.0" + implementation 'io.reactivex.rxjava2:rxjava:2.2.3' + implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' implementation 'com.getkeepsafe.taptargetview:taptargetview:1.12.0' implementation "com.jakewharton:butterknife:$butterKnifeVersion" - implementation "com.mapbox.mapboxsdk:mapbox-android-sdk:6.6.2" + implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:6.6.2' implementation 'net.hockeyapp.android:HockeySDK:5.1.0' implementation 'org.apache.commons:commons-lang3:3.8.1' implementation 'org.jsoup:jsoup:1.11.3' - implementation 'com.google.api-client:google-api-client-android:1.22.0' exclude module: 'httpclient' implementation 'com.google.http-client:google-http-client-gson:1.20.0' exclude module: 'httpclient' implementation 'com.google.apis:google-api-services-vision:v1-rev2-1.21.0' - + implementation 'com.google.code.findbugs:jsr305:2.0.1' annotationProcessor "com.jakewharton:butterknife-compiler:$butterKnifeVersion" - android.productFlavors.each { flavor -> String dep - if('dev' == flavor.name) { + if ('dev' == flavor.name) { dep = "com.squareup.leakcanary:leakcanary-android:$leakCanaryVersion" } else { dep = "com.squareup.leakcanary:leakcanary-android-no-op:$leakCanaryVersion" } "${flavor.name}Implementation" dep } - testImplementation 'junit:junit:4.12' testImplementation mockitoCore testImplementation 'org.robolectric:robolectric:3.8' testImplementation "com.squareup.okhttp3:mockwebserver:$okHttpVersion" - testImplementation "commons-io:commons-io:2.6" - + testImplementation 'commons-io:commons-io:2.6' androidTestImplementation mockitoCore androidTestRuntimeOnly 'com.crittercism.dexmaker:dexmaker:1.4' androidTestImplementation 'com.crittercism.dexmaker:dexmaker-mockito:1.4' @@ -270,8 +265,12 @@ dependencies { // Required by Android JUnit Runner. androidTestImplementation "com.android.support:support-annotations:$supportVersion" - androidTestImplementation "com.android.support.test:rules:$testingSupportVersion" // JUnit Rules - androidTestImplementation "com.android.support.test:runner:$testingSupportVersion" // Android JUnit Runner + androidTestImplementation "com.android.support.test:rules:$testingSupportVersion" + + // JUnit Rules + androidTestImplementation "com.android.support.test:runner:$testingSupportVersion" + + // Android JUnit Runner androidTestImplementation "com.android.support.test.espresso:espresso-core:$espressoVersion" androidTestImplementation "com.android.support.test.espresso:espresso-contrib:$espressoVersion" } diff --git a/app/src/androidTest/java/org/wikipedia/WikipediaTestRunner.java b/app/src/androidTest/java/org/wikipedia/WikipediaTestRunner.java index 1ea5a1d..76e4902 100644 --- a/app/src/androidTest/java/org/wikipedia/WikipediaTestRunner.java +++ b/app/src/androidTest/java/org/wikipedia/WikipediaTestRunner.java @@ -6,8 +6,6 @@ import android.support.test.InstrumentationRegistry; import android.support.test.runner.AndroidJUnitRunner; -import org.wikipedia.dataclient.okhttp.TestStubInterceptor; -import org.wikipedia.espresso.MockInstrumentationInterceptor; import org.wikipedia.espresso.util.ConfigurationTools; import org.wikipedia.settings.Prefs; import org.wikipedia.settings.PrefsIoUtil; @@ -20,8 +18,8 @@ public class WikipediaTestRunner extends AndroidJUnitRunner { @Override public void onStart() { - deviceRequirementsCheck(); - TestStubInterceptor.setCallback(new MockInstrumentationInterceptor(InstrumentationRegistry.getContext())); +// deviceRequirementsCheck(); +// TestStubInterceptor.setCallback(new MockInstrumentationInterceptor(InstrumentationRegistry.getContext())); clearAppInfo(); disableOnboarding(); cleanUpComparisonResults(); diff --git a/app/src/androidTest/java/org/wikipedia/espresso/discover/DiscoverTest.java b/app/src/androidTest/java/org/wikipedia/espresso/discover/DiscoverTest.java new file mode 100644 index 0000000..02909cb --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/espresso/discover/DiscoverTest.java @@ -0,0 +1,165 @@ +package org.wikipedia.espresso.discover; + +import android.support.test.espresso.ViewInteraction; +import android.support.test.espresso.contrib.RecyclerViewActions; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; +import android.view.View; + +import org.hamcrest.Matcher; +import org.hamcrest.core.IsInstanceOf; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.wikipedia.R; +import org.wikipedia.main.MainActivity; + +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.swipeLeft; +import static android.support.test.espresso.action.ViewActions.swipeRight; +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.withClassName; +import static android.support.test.espresso.matcher.ViewMatchers.withContentDescription; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.core.StringEndsWith.endsWith; +import static org.wikipedia.espresso.util.ViewTools.WAIT_FOR_2000; +import static org.wikipedia.espresso.util.ViewTools.childAtPosition; +import static org.wikipedia.espresso.util.ViewTools.viewIsDisplayed; +import static org.wikipedia.espresso.util.ViewTools.waitFor; +import static org.wikipedia.espresso.util.ViewTools.whileWithMaxSteps; + +@SuppressWarnings("checkstyle:magicnumber") +@RunWith(AndroidJUnit4.class) +public class DiscoverTest { + + @Rule + public ActivityTestRule activityTestRule = new ActivityTestRule<>(MainActivity.class); + + + @Test + public void testScrollToDiscoverCard() throws Exception { + // Wait until the feed is displayed + whileWithMaxSteps( + () -> !viewIsDisplayed(R.id.fragment_feed_feed), + () -> waitFor(WAIT_FOR_2000)); + + testDiscoverCardInFeed(); + waitFor(2000); + testDiscoverButtonsDisplayed(); + waitFor(2000); + testDiscoverButtons(); + } + + private void testDiscoverCardInFeed() { + waitFor(2000); + onView(withId(R.id.fragment_feed_feed)).perform(RecyclerViewActions.scrollTo( + withClassName(endsWith("RandomCardView")) + )); + waitFor(2000); + onView(withClassName(endsWith("RandomCardView"))).perform(click()); + } + + private boolean discoverCardIsDisplayed() { + final boolean[] isDisplayed = {true}; + + onView(withClassName(endsWith("RandomCardView"))) + .withFailureHandler((Throwable error, Matcher viewMatcher) -> isDisplayed[0] = false) + .check(matches(isDisplayed())); + + return isDisplayed[0]; + } + + private void testDiscoverButtonsDisplayed() { + ViewInteraction relativeLayout = onView( + allOf(withId(R.id.spinner_layout), + childAtPosition( + allOf(withId(R.id.random_coordinator_layout), + childAtPosition( + withId(R.id.fragment_container), + 0)), + 0), + isDisplayed())); + relativeLayout.check(matches(isDisplayed())); + + ViewInteraction imageView = onView( + allOf(withId(R.id.random_back_button), + childAtPosition( + childAtPosition( + IsInstanceOf.instanceOf(android.widget.FrameLayout.class), + 0), + 0), + isDisplayed())); + imageView.check(matches(isDisplayed())); + + ViewInteraction imageButton = onView( + allOf(withId(R.id.random_next_button), withContentDescription("Load another article"), + childAtPosition( + childAtPosition( + IsInstanceOf.instanceOf(android.widget.FrameLayout.class), + 0), + 1), + isDisplayed())); + imageButton.check(matches(isDisplayed())); + + ViewInteraction imageView2 = onView( + allOf(withId(R.id.random_save_button), withContentDescription("Add to reading list"), + childAtPosition( + childAtPosition( + IsInstanceOf.instanceOf(android.widget.FrameLayout.class), + 0), + 2), + isDisplayed())); + imageView2.check(matches(isDisplayed())); + + } + + private void testDiscoverButtons() { + + ViewInteraction floatingActionButton = onView( + allOf(withId(R.id.random_next_button), withContentDescription("Load another article"), + childAtPosition( + childAtPosition( + withClassName(is("android.widget.FrameLayout")), + 0), + 1), + isDisplayed())); + floatingActionButton.perform(click()); + + ViewInteraction viewPagerWithVelocity = onView( + allOf(withId(R.id.random_item_pager), + childAtPosition( + allOf(withId(R.id.random_coordinator_layout), + childAtPosition( + withId(R.id.fragment_container), + 0)), + 1), + isDisplayed())); + viewPagerWithVelocity.perform(swipeLeft()); + + ViewInteraction appCompatImageView = onView( + allOf(withId(R.id.random_back_button), + childAtPosition( + childAtPosition( + withClassName(is("android.widget.FrameLayout")), + 0), + 0), + isDisplayed())); + appCompatImageView.perform(click()); + + ViewInteraction viewPagerWithVelocity2 = onView( + allOf(withId(R.id.random_item_pager), + childAtPosition( + allOf(withId(R.id.random_coordinator_layout), + childAtPosition( + withId(R.id.fragment_container), + 0)), + 1), + isDisplayed())); + viewPagerWithVelocity2.perform(swipeRight()); + + } +} diff --git a/app/src/androidTest/java/org/wikipedia/espresso/feed/FeedCategoryTest.java b/app/src/androidTest/java/org/wikipedia/espresso/feed/FeedCategoryTest.java new file mode 100644 index 0000000..fdf8ecf --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/espresso/feed/FeedCategoryTest.java @@ -0,0 +1,131 @@ +package org.wikipedia.espresso.feed; + +import android.support.test.espresso.contrib.RecyclerViewActions; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; +import android.view.View; + +import org.hamcrest.Matcher; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.wikipedia.R; +import org.wikipedia.main.MainActivity; + +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.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.withClassName; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.core.StringEndsWith.endsWith; +import static org.wikipedia.espresso.util.ViewTools.WAIT_FOR_2000; +import static org.wikipedia.espresso.util.ViewTools.childAtPosition; +import static org.wikipedia.espresso.util.ViewTools.viewIsDisplayed; +import static org.wikipedia.espresso.util.ViewTools.waitFor; +import static org.wikipedia.espresso.util.ViewTools.whileWithMaxSteps; + +@SuppressWarnings("checkstyle:magicnumber") +@RunWith(AndroidJUnit4.class) +public class FeedCategoryTest { + + @Rule + public ActivityTestRule activityTestRule = new ActivityTestRule<>(MainActivity.class); + + + @Test + public void testScrollToCategoriesCard() throws Exception { + // Wait until the feed is displayed + waitFor(WAIT_FOR_2000); + + testCategoriesCardInFeed(); + + waitFor(WAIT_FOR_2000); + + // Wait until the top categories are displayed + whileWithMaxSteps( + () -> !viewIsDisplayed(R.id.categories_scroll_view), + () -> waitFor(WAIT_FOR_2000)); + + testTopCardsExist(); + waitFor(WAIT_FOR_2000); + + testBrowseOnCategory(); + } + + private void testCategoriesCardInFeed() { + onView(withId(R.id.fragment_feed_feed)).perform(RecyclerViewActions.scrollTo( + withClassName(endsWith("CategoriesCardView")) + )); + onView(withClassName(endsWith("CategoriesCardView"))).perform(click()); + } + + private void testTopCardsExist() { + final String[] categories = { + "Culture", + "Geography", + "Health", + "History", + "Human activities", + "Mathematics", + "Nature", + "People", + "Philosophy", + "Religion", + "Society", + "Technology" + }; + + int index = 0; + for (String category : categories) { + onView( + childAtPosition( + childAtPosition( + childAtPosition( + withId(R.id.categories_grid_layout), + index++ + ), + 0 + ), + 1 + )).check(matches(withText(category))); + } + } + + private void testBrowseOnCategory() { + onView( + childAtPosition( + childAtPosition( + childAtPosition( + withId(R.id.categories_grid_layout), + 0 + ), + 0 + ), + 1 + )).perform(click()); + + waitFor(2000); + + onView(childAtPosition( + childAtPosition( + withId(R.id.action_bar_container), + 0 + ), + 0 + )).check(matches(withText("Culture"))); + + onView(withId(R.id.decor_content_parent)).check(matches(isDisplayed())); + } + + private boolean categoryCardIsDisplayed() { + final boolean[] isDisplayed = {true}; + + onView(withClassName(endsWith("CategoriesCardView"))) + .withFailureHandler((Throwable error, Matcher viewMatcher) -> isDisplayed[0] = false) + .check(matches(isDisplayed())); + + return isDisplayed[0]; + } +} diff --git a/app/src/androidTest/java/org/wikipedia/espresso/feed/categories/CategorySearchTest.java b/app/src/androidTest/java/org/wikipedia/espresso/feed/categories/CategorySearchTest.java new file mode 100644 index 0000000..612cbfc --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/espresso/feed/categories/CategorySearchTest.java @@ -0,0 +1,170 @@ +package org.wikipedia.espresso.feed.categories; + + +import android.support.test.espresso.DataInteraction; +import android.support.test.espresso.ViewInteraction; +import android.support.test.filters.LargeTest; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.hamcrest.core.IsInstanceOf; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.wikipedia.R; +import org.wikipedia.WikipediaApp; +import org.wikipedia.feed.categories.CategoriesActivity; +import org.wikipedia.main.MainActivity; + +import static android.support.test.espresso.Espresso.onData; +import static android.support.test.espresso.Espresso.onView; +import static android.support.test.espresso.Espresso.pressBack; +import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.action.ViewActions.closeSoftKeyboard; +import static android.support.test.espresso.action.ViewActions.replaceText; +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.withClassName; +import static android.support.test.espresso.matcher.ViewMatchers.withContentDescription; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.anything; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.core.AllOf.allOf; +import static org.wikipedia.espresso.util.ViewTools.WAIT_FOR_1000; +import static org.wikipedia.espresso.util.ViewTools.WAIT_FOR_2000; +import static org.wikipedia.espresso.util.ViewTools.waitFor; + +@LargeTest +@RunWith(AndroidJUnit4.class) +public class CategorySearchTest { + + @Rule + public ActivityTestRule mainActivityTestRule = new ActivityTestRule<>(MainActivity.class, true, false); + private ActivityTestRule catergoriesActivityTestRule = new ActivityTestRule<>(CategoriesActivity.class, true, false); + + @Test + public void categorySearchTest() { + MainActivity activity = mainActivityTestRule.launchActivity(null); + + waitFor(WAIT_FOR_2000); + + catergoriesActivityTestRule.launchActivity(CategoriesActivity.newIntent(activity.getApplicationContext(), WikipediaApp.getInstance().getWikiSite())); + + waitFor(WAIT_FOR_2000); + + ViewInteraction actionMenuItemView = onView( + allOf(withId(R.id.menu_feed_search), withContentDescription("Search Wikipedia"), + childAtPosition( + childAtPosition( + withId(R.id.action_bar), + 2), + 0), + isDisplayed())); + actionMenuItemView.perform(click()); + + ViewInteraction searchAutoComplete = onView( + withId(R.id.search_src_text)); + searchAutoComplete.perform(replaceText("people"), closeSoftKeyboard()); + + waitFor(WAIT_FOR_2000); + + ViewInteraction textView = onView( + allOf(withId(R.id.item_categories_result_title), withText("People"), + childAtPosition( + allOf(withId(R.id.categories_search_results_list), + childAtPosition( + IsInstanceOf.instanceOf(android.widget.FrameLayout.class), + 0)), + 0), + isDisplayed())); + textView.check(matches(withText("People"))); + + DataInteraction appCompatTextView2 = onData(anything()) + .inAdapterView(allOf(withId(R.id.categories_search_results_list), + childAtPosition( + withClassName(is("android.widget.FrameLayout")), + 0))) + .atPosition(0); + appCompatTextView2.perform(click()); + + waitFor(WAIT_FOR_2000); + + ViewInteraction textView2 = onView( + allOf(withId(R.id.item_categories_result_title), withText("Person"), + childAtPosition( + childAtPosition( + withId(R.id.categories_results_list), + 0), + 1), + isDisplayed())); + textView2.check(matches(withText("Person"))); + + ViewInteraction appCompatImageButton = onView( + allOf(withContentDescription("Navigate up"), + childAtPosition( + allOf(withId(R.id.action_bar), + childAtPosition( + withId(R.id.action_bar_container), + 0)), + 1), + isDisplayed())); + appCompatImageButton.perform(click()); + + waitFor(WAIT_FOR_1000); + + pressBack(); + pressBack(); + + ViewInteraction textView3 = onView( + allOf(withId(R.id.top_categories), withText("Top Categories"), + childAtPosition( + childAtPosition( + withId(R.id.categories_scroll_view), + 0), + 0), + isDisplayed())); + textView3.check(matches(withText("Top Categories"))); + + pressBack(); + + waitFor(WAIT_FOR_1000); + + ViewInteraction imageView = onView( + allOf(withId(R.id.single_fragment_toolbar_wordmark), + childAtPosition( + allOf(withId(R.id.hamburger_and_wordmark_layout), + childAtPosition( + withId(R.id.single_fragment_toolbar), + 0)), + 1), + isDisplayed())); + imageView.check(matches(isDisplayed())); + + } + + private static Matcher childAtPosition( + final Matcher parentMatcher, final int position) { + + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("Child at position " + position + " in parent "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + return parent instanceof ViewGroup && parentMatcher.matches(parent) + && view.equals(((ViewGroup) parent).getChildAt(position)); + } + }; + } +} diff --git a/app/src/androidTest/java/org/wikipedia/espresso/page/SelectiveTranslationTest.java b/app/src/androidTest/java/org/wikipedia/espresso/page/SelectiveTranslationTest.java new file mode 100644 index 0000000..15a4a5e --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/espresso/page/SelectiveTranslationTest.java @@ -0,0 +1,173 @@ +package org.wikipedia.espresso.page; + + +import android.support.test.espresso.DataInteraction; +import android.support.test.espresso.ViewInteraction; +import android.support.test.filters.LargeTest; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.wikipedia.R; +import org.wikipedia.main.MainActivity; + +import static android.support.test.espresso.Espresso.onData; +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.closeSoftKeyboard; +import static android.support.test.espresso.action.ViewActions.longClick; +import static android.support.test.espresso.action.ViewActions.replaceText; +import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.matcher.RootMatchers.isPlatformPopup; +import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.withClassName; +import static android.support.test.espresso.matcher.ViewMatchers.withContentDescription; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.anything; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.core.AllOf.allOf; + +@LargeTest +@RunWith(AndroidJUnit4.class) +@SuppressWarnings("checkstyle:magicnumber") +public class SelectiveTranslationTest { + + @Rule + public ActivityTestRule mActivityTestRule = new ActivityTestRule<>(MainActivity.class); + + @Test + public void selectiveTranslationTest() { + ViewInteraction linearLayout = onView( + allOf(withId(R.id.search_container), + childAtPosition( + childAtPosition( + withId(R.id.fragment_feed_feed), + 0), + 0), + isDisplayed())); + linearLayout.perform(click()); + + // Added a sleep statement to match the app's execution delay. + // The recommended way to handle such scenarios is to use Espresso idling resources: + // https://google.github.io/android-testing-support-library/docs/espresso/idling-resource/index.html + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + ViewInteraction searchAutoComplete = onView( + allOf(withId(R.id.search_src_text), + childAtPosition( + allOf(withId(R.id.search_plate), + childAtPosition( + withId(R.id.search_edit_frame), + 1)), + 0), + isDisplayed())); + searchAutoComplete.perform(replaceText("wikipedia"), closeSoftKeyboard()); + + try { + Thread.sleep(3000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + DataInteraction constraintLayout = onData(anything()) + .inAdapterView(allOf(withId(R.id.search_results_list), + childAtPosition( + withId(R.id.search_results_container), + 1))) + .atPosition(0); + constraintLayout.perform(click()); + + // Added a sleep statement to match the app's execution delay. + // The recommended way to handle such scenarios is to use Espresso idling resources: + // https://google.github.io/android-testing-support-library/docs/espresso/idling-resource/index.html + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + ViewInteraction observableWebView = onView( + allOf(withId(R.id.page_web_view), + childAtPosition( + allOf(withId(R.id.page_contents_container), + childAtPosition( + withId(R.id.page_fragment), + 0)), + 0), + isDisplayed())); + observableWebView.perform(longClick()); + + // Added a sleep statement to match the app's execution delay. + // The recommended way to handle such scenarios is to use Espresso idling resources: + // https://google.github.io/android-testing-support-library/docs/espresso/idling-resource/index.html + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + ViewInteraction imageButton = onView( + allOf(withClassName(is("android.widget.ImageButton")), withContentDescription("More options"), + childAtPosition( + childAtPosition( + withClassName(is("android.widget.LinearLayout")), + 0), + 2), + isDisplayed())).inRoot(isPlatformPopup()); + imageButton.perform(click()); + + DataInteraction linearLayout2 = onData(anything()) + .inAdapterView(childAtPosition( + withClassName(is("android.widget.RelativeLayout")), + 0)) + .atPosition(1).inRoot(isPlatformPopup()); + linearLayout2.perform(click()); + + ViewInteraction actionBarTab = onView( + allOf(childAtPosition( + childAtPosition( + withId(R.id.horizontal_scroll_languages), + 0), + 1), + isDisplayed())); + actionBarTab.check(matches(isDisplayed())); + + ViewInteraction textView = onView( + withId(R.id.translate_translated_text)); + + textView.check(matches(withText("that"))); + + } + + private static Matcher childAtPosition( + final Matcher parentMatcher, final int position) { + + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("Child at position " + position + " in parent "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + return parent instanceof ViewGroup && parentMatcher.matches(parent) + && view.equals(((ViewGroup) parent).getChildAt(position)); + } + }; + } +} diff --git a/app/src/androidTest/java/org/wikipedia/main/ImageSearchTest.java b/app/src/androidTest/java/org/wikipedia/main/ImageSearchTest.java new file mode 100644 index 0000000..158c367 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/main/ImageSearchTest.java @@ -0,0 +1,118 @@ +package org.wikipedia.main; + + +import android.support.test.espresso.ViewInteraction; +import android.support.test.filters.LargeTest; +import android.support.test.rule.ActivityTestRule; +import android.support.test.rule.GrantPermissionRule; +import android.support.test.runner.AndroidJUnit4; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.hamcrest.core.IsInstanceOf; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.wikipedia.R; + +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.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.withClassName; +import static android.support.test.espresso.matcher.ViewMatchers.withContentDescription; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.is; + +@LargeTest +@RunWith(AndroidJUnit4.class) +@SuppressWarnings("checkstyle:magicnumber") +public class ImageSearchTest { + + @Rule + public ActivityTestRule mActivityTestRule = new ActivityTestRule<>(MainActivity.class); + + @Rule + public GrantPermissionRule mGrantPermissionRule = + GrantPermissionRule.grant( + "android.permission.CAMERA"); + + @Test + public void imageSearchTest() { + // Added a sleep statement to match the app's execution delay. + // The recommended way to handle such scenarios is to use Espresso idling resources: + // https://google.github.io/android-testing-support-library/docs/espresso/idling-resource/index.html + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + ViewInteraction imageView = onView( + allOf(withId(R.id.camera_search_button), withContentDescription("Search Wikipedia"), + childAtPosition( + allOf(withId(R.id.search_container), + childAtPosition( + IsInstanceOf.instanceOf(android.widget.FrameLayout.class), + 0)), + 3), + isDisplayed())); + imageView.check(matches(isDisplayed())); + + ViewInteraction appCompatImageView = onView( + allOf(withId(R.id.camera_search_button), withContentDescription("Search Wikipedia"), + childAtPosition( + allOf(withId(R.id.search_container), + childAtPosition( + withClassName(is("org.wikipedia.feed.searchbar.SearchCardView")), + 0)), + 3), + isDisplayed())); + appCompatImageView.perform(click()); + + ViewInteraction textView = onView( + allOf(withId(android.R.id.title), withText("Take a photo"), + childAtPosition( + childAtPosition( + IsInstanceOf.instanceOf(android.widget.LinearLayout.class), + 0), + 0), + isDisplayed())); + textView.check(matches(withText("Take a photo"))); + + ViewInteraction textView2 = onView( + allOf(withId(android.R.id.title), withText("Choose from gallery"), + childAtPosition( + childAtPosition( + IsInstanceOf.instanceOf(android.widget.LinearLayout.class), + 0), + 0), + isDisplayed())); + textView2.check(matches(withText("Choose from gallery"))); + } + + private static Matcher childAtPosition( + final Matcher parentMatcher, final int position) { + + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("Child at position " + position + " in parent "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + return parent instanceof ViewGroup && parentMatcher.matches(parent) + && view.equals(((ViewGroup) parent).getChildAt(position)); + } + }; + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 876c074..fdb61bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -266,6 +266,17 @@ android:name=".editactionfeed.EditTasksActivity" android:label="@string/edit_tasks_activity_title" android:theme="@style/AppTheme.ActionBar" /> + + + + + nearbySearch(@NonNull @Query("ggscoord") String coord, @Query("ggsradius") double radius); + @GET(MW_API_PREFIX + "action=query&formatversion=2" + + "&redirects=&converttitles=&prop=description%7Cpageimages&piprop=thumbnail" + + "&pilicense=any&generator=categorymembers&gcmtype=page&pithumbsize=" + PREFERRED_THUMB_SIZE) + @NonNull Observable getPagesInCategory(@Query("gcmtitle") String category, @Query("gcmlimit") int limit); + + @GET(MW_API_PREFIX + "action=query&formatversion=2" + + "&list=allcategories&acdir=ascending&acprop=size") + @NonNull Observable searchForCategory(@Query("acprefix") String searchTerm, + @Query("aclimit") int limit, + @Query("acmin") int minMembers); + + @GET(MW_API_PREFIX + "action=query&formatversion=2" + + "&prop=categories&clshow=!hidden") + @NonNull Observable getCategoriesInPage(@Query("titles") String title, @Query("cllimit") int limit); // ------- Miscellaneous ------- diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryCategory.java b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryCategory.java new file mode 100644 index 0000000..0af410f --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryCategory.java @@ -0,0 +1,40 @@ +package org.wikipedia.dataclient.mwapi; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.wikipedia.model.BaseModel; + +public class MwQueryCategory extends BaseModel { + @SuppressWarnings("unused") @NonNull private String category; + @SuppressWarnings("unused") @Nullable private int size; + @SuppressWarnings("unused") @Nullable private int pages; + @SuppressWarnings("unused") @Nullable private int files; + @SuppressWarnings("unused") @Nullable private int subcats; + + + @NonNull + public String category() { + return category; + } + + @Nullable + public int size() { + return size; + } + + @Nullable + public int pages() { + return pages; + } + + @Nullable + public int files() { + return files; + } + + @Nullable + public int subcats() { + return subcats; + } +} diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryPage.java b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryPage.java index a11f613..3253543 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryPage.java +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryPage.java @@ -29,6 +29,7 @@ public class MwQueryPage extends BaseModel { @SuppressWarnings("unused") @Nullable private String extract; @SuppressWarnings("unused") @Nullable private Thumbnail thumbnail; @SuppressWarnings("unused") @Nullable private String description; + @SuppressWarnings("unused") @Nullable private List categories; @SuppressWarnings("unused") @SerializedName("descriptionsource") @Nullable private String descriptionSource; @SuppressWarnings("unused") @SerializedName("imageinfo") @Nullable private List imageInfo; @SuppressWarnings("unused") @SerializedName("videoinfo") @Nullable private List videoInfo; @@ -40,7 +41,7 @@ public class MwQueryPage extends BaseModel { return title; } - public int index() { + @Nullable public int index() { return index; } @@ -90,6 +91,11 @@ public String descriptionSource() { return descriptionSource; } + @Nullable + public List categories() { + return categories; + } + @Nullable public ImageInfo imageInfo() { return imageInfo != null ? imageInfo.get(0) : null; } @@ -190,4 +196,12 @@ public boolean isDisambiguation() { return disambiguation != null; } } + + public static class Category { + @SuppressWarnings("unused") @NonNull private String title; + @NonNull + public String title() { + return title; + } + } } diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.java b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.java index faaa82f..f15ab8b 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.java +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.java @@ -21,6 +21,7 @@ import java.util.Map; public class MwQueryResult extends BaseModel implements PostProcessingTypeAdapter.PostProcessable { + @SuppressWarnings("unused") @Nullable @SerializedName("allcategories") private List categories; @SuppressWarnings("unused") @Nullable private List pages; @SuppressWarnings("unused") @Nullable private List redirects; @SuppressWarnings("unused") @Nullable private List converted; @@ -40,6 +41,10 @@ public class MwQueryResult extends BaseModel implements PostProcessingTypeAdapte return pages; } + @Nullable public List categories() { + return categories; + } + @Nullable public MwQueryPage firstPage() { if (pages != null && pages.size() > 0) { return pages.get(0); diff --git a/app/src/main/java/org/wikipedia/feed/FeedContentType.java b/app/src/main/java/org/wikipedia/feed/FeedContentType.java index edbd809..7d18fad 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedContentType.java +++ b/app/src/main/java/org/wikipedia/feed/FeedContentType.java @@ -8,6 +8,7 @@ import org.wikipedia.WikipediaApp; import org.wikipedia.feed.aggregated.AggregatedFeedContentClient; import org.wikipedia.feed.becauseyouread.BecauseYouReadClient; +import org.wikipedia.feed.categories.feedcard.CategoriesClient; import org.wikipedia.feed.dataclient.FeedClient; import org.wikipedia.feed.mainpage.MainPageClient; import org.wikipedia.feed.random.RandomClient; @@ -76,6 +77,13 @@ public FeedClient newClient(AggregatedFeedContentClient aggregatedClient, int ag public FeedClient newClient(AggregatedFeedContentClient aggregatedClient, int age) { return isEnabled() ? new BecauseYouReadClient() : null; } + }, + CATEGORIES(9, R.string.view_categories_card_title, R.string.feed_item_type_categories, false) { + @Nullable + @Override + public FeedClient newClient(AggregatedFeedContentClient aggregatedClient, int age) { + return isEnabled() && age == 0 ? new CategoriesClient() : null; + } }; private static final EnumCodeMap MAP diff --git a/app/src/main/java/org/wikipedia/feed/FeedFragment.java b/app/src/main/java/org/wikipedia/feed/FeedFragment.java index 10d178f..f15c0f4 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedFragment.java +++ b/app/src/main/java/org/wikipedia/feed/FeedFragment.java @@ -26,6 +26,8 @@ import org.wikipedia.WikipediaApp; import org.wikipedia.activity.FragmentUtil; import org.wikipedia.analytics.FeedFunnel; +import org.wikipedia.feed.categories.CategoriesActivity; +import org.wikipedia.feed.categories.feedcard.CategoriesCardView; import org.wikipedia.feed.configure.ConfigureActivity; import org.wikipedia.feed.configure.ConfigureItemLanguageDialogView; import org.wikipedia.feed.configure.LanguageItemAdapter; @@ -124,6 +126,7 @@ public void onCreate(Bundle savedInstanceState) { feedView.setCallback(feedCallback); feedView.addOnScrollListener(feedScrollListener); + swipeRefreshLayout.setColorSchemeResources(ResourceUtil.getThemedAttributeId(requireContext(), R.attr.colorAccent)); swipeRefreshLayout.setOnRefreshListener(this::refresh); @@ -510,6 +513,11 @@ public void onGetRandomError(@NonNull Throwable t, @NonNull final RandomCardView public void onMoreContentSelected(@NonNull Card card) { startActivity(MostReadArticlesActivity.newIntent(requireContext(), (MostReadListCard) card)); } + + @Override + public void onCategoriesClick(CategoriesCardView view) { + startActivity(CategoriesActivity.newIntent(requireActivity(), app.getWikiSite())); + } } private class FeedScrollListener extends RecyclerView.OnScrollListener { diff --git a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.java b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.java index 862bbae..95868d4 100644 --- a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.java +++ b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.java @@ -25,22 +25,22 @@ public List onthisday() { } @Nullable - RbPageSummary tfa() { + public RbPageSummary tfa() { return tfa; } @Nullable - List news() { + public List news() { return news; } @Nullable - MostReadArticles mostRead() { + public MostReadArticles mostRead() { return mostRead; } @Nullable - FeaturedImage potd() { + public FeaturedImage potd() { return image; } } diff --git a/app/src/main/java/org/wikipedia/feed/categories/CategoriesActivity.java b/app/src/main/java/org/wikipedia/feed/categories/CategoriesActivity.java new file mode 100644 index 0000000..6a73eea --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/CategoriesActivity.java @@ -0,0 +1,30 @@ +package org.wikipedia.feed.categories; + + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; + +import org.wikipedia.activity.SingleFragmentActivity; +import org.wikipedia.dataclient.WikiSite; + +public class CategoriesActivity extends SingleFragmentActivity { + public static final String WIKISITE = "wikisite"; + + public static Intent newIntent(@NonNull Context context, @NonNull WikiSite wikiSite) { + return new Intent(context, CategoriesActivity.class) + .putExtra(WIKISITE, wikiSite); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getSupportActionBar().setElevation(0f); + } + + @Override + protected CategoriesFragment createFragment() { + return CategoriesFragment.newInstance(getIntent().getParcelableExtra(WIKISITE)); + } +} diff --git a/app/src/main/java/org/wikipedia/feed/categories/CategoriesFragment.java b/app/src/main/java/org/wikipedia/feed/categories/CategoriesFragment.java new file mode 100644 index 0000000..8ef60c1 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/CategoriesFragment.java @@ -0,0 +1,328 @@ +package org.wikipedia.feed.categories; + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.view.ActionMode; +import android.support.v7.widget.CardView; +import android.support.v7.widget.Toolbar; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.GridLayout; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import org.wikipedia.R; +import org.wikipedia.dataclient.ServiceFactory; +import org.wikipedia.dataclient.WikiSite; +import org.wikipedia.dataclient.mwapi.MwQueryPage; +import org.wikipedia.feed.categories.recommended.RecommendedCategoriesArrayAdapter; +import org.wikipedia.feed.categories.recommended.RecommendedCategoriesClient; +import org.wikipedia.feed.categories.result.CategoriesResultActivity; +import org.wikipedia.feed.categories.result.CategoriesSearchResults; +import org.wikipedia.history.SearchActionModeCallback; +import org.wikipedia.search.SearchResult; +import org.wikipedia.search.SearchResults; +import org.wikipedia.views.ViewUtil; + +import java.util.ArrayList; +import java.util.List; + +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.Unbinder; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; + +import static android.view.View.GONE; +import static org.wikipedia.feed.categories.CategoriesActivity.WIKISITE; + +public class CategoriesFragment extends Fragment { + private static final int BATCH_SIZE = 20; + + private Unbinder unbinder; + private WikiSite wiki; + private CompositeDisposable disposables = new CompositeDisposable(); + private CategoriesFragment.SearchCallback searchActionModeCallback = new SearchCallback(); + + + @BindView(R.id.recommended_categories_list) ListView recommendedCategoriesListView; + @BindView(R.id.recommended_categories_title) TextView recommendedCategoriesTitle; + @BindView(R.id.recommended_categories_card) CardView recommendedCategoriesCard; + + @BindView(R.id.categories_toolbar) Toolbar categoriesToolbar; + @BindView(R.id.categories_scroll_view) ScrollView container; + @BindView(R.id.categories_search_results_list) ListView searchResultsList; + + private List searchResults = new ArrayList<>(); + + private boolean searching = false; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @NonNull + public static CategoriesFragment newInstance(WikiSite wikiSite) { + CategoriesFragment instance = new CategoriesFragment(); + Bundle args = new Bundle(); + args.putParcelable(WIKISITE, wikiSite); + instance.setArguments(args); + return instance; + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + View view = inflater.inflate(R.layout.fragment_categories, container, false); + unbinder = ButterKnife.bind(this, view); + wiki = requireActivity().getIntent().getParcelableExtra(WIKISITE); + + populateRecommendedCategories(getContext()); + + SearchResultAdapter adapter = new SearchResultAdapter(inflater); + searchResultsList.setAdapter(adapter); + searchResultsList.setOnItemClickListener((parent, view1, position, id) -> { + searchOnCategory(searchResults.get(position)); + }); + + view.setFocusableInTouchMode(true); + view.requestFocus(); + view.setOnKeyListener((v, keyCode, event) -> { + if (keyCode == KeyEvent.KEYCODE_BACK && searching) { + searching = false; + this.container.setVisibility(View.VISIBLE); + searchResultsList.setVisibility(View.GONE); + return true; + } + return false; + }); + + GridLayout topCategories = view.findViewById(R.id.categories_grid_layout); + setupCategoriesGrid(topCategories); + + return view; + } + + @Override + public void onDestroyView() { + unbinder.unbind(); + unbinder = null; + disposables.clear(); + super.onDestroyView(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_feed, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_feed_search: + getAppCompatActivity().startSupportActionMode(searchActionModeCallback); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + private AppCompatActivity getAppCompatActivity() { + return (AppCompatActivity) getActivity(); + } + + private void setupCategoriesGrid(GridLayout grid) { + for (int i = 0; i < grid.getChildCount(); i++) { + CardView card = (CardView) grid.getChildAt(i); + card.setOnClickListener(v -> { + TextView category = v.findViewWithTag(getString(R.string.category_card_text)); + searchOnCategory(category.getText().toString()); + }); + } + } + + private Boolean isAPortalCategory(String title) { + return title.contains("Portal"); + } + + private void populateRecommendedCategories(Context context) { + RecommendedCategoriesClient recommendedCategoriesClient = new RecommendedCategoriesClient(); + recommendedCategoriesClient.request(wiki, pageTitles -> { + if (pageTitles.size() > 0) { + RecommendedCategoriesArrayAdapter categoriesAdapter = new RecommendedCategoriesArrayAdapter(context, pageTitles); + recommendedCategoriesListView.setAdapter(categoriesAdapter); + recommendedCategoriesListView.setVisibility(View.VISIBLE); + recommendedCategoriesTitle.setVisibility(View.VISIBLE); + recommendedCategoriesCard.setVisibility(View.VISIBLE); + + recommendedCategoriesListView.setOnItemClickListener((parent, view1, position, id) -> { + String name = ((MwQueryPage.Category) parent.getItemAtPosition(position)).title(); + searchOnCategory(name.substring(name.indexOf(':') + 1)); + }); + + setListViewHeightBasedOnChildren(recommendedCategoriesListView); + } + }); + + if (recommendedCategoriesListView.getAdapter() == null || ((RecommendedCategoriesArrayAdapter) recommendedCategoriesListView.getAdapter()).getValues().isEmpty()) { + recommendedCategoriesListView.setVisibility(GONE); + recommendedCategoriesTitle.setVisibility(GONE); + recommendedCategoriesCard.setVisibility(GONE); + } + } + + private void searchOnCategory(String category) { + disposables.add(ServiceFactory.get(wiki).getPagesInCategory("Category:" + category.replace(" ", "_"), BATCH_SIZE) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .map(response -> { + if (response != null && response.success() && response.query().pages() != null) { + List pages = response.query().pages(); + pages.removeIf(page -> isAPortalCategory(page.title())); + return new SearchResults(pages, wiki, response.continuation(), null); + } + return new SearchResults(); + }) + .subscribe(results -> { + ArrayList categoryResult = (ArrayList)results.getResults(); + startActivity(CategoriesResultActivity.newIntent(requireContext(), category, categoryResult)); + }, caught -> { + Toast.makeText(requireActivity(), caught.getMessage(), Toast.LENGTH_LONG).show(); + })); + } + + private BaseAdapter getAdapter() { + return (BaseAdapter) searchResultsList.getAdapter(); + } + + private class SearchCallback extends SearchActionModeCallback { + @Nullable private ActionMode actionMode; + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + actionMode = mode; + ViewUtil.finishActionModeWhenTappingOnView(getView(), actionMode); + ViewUtil.finishActionModeWhenTappingOnView(container, actionMode); + + return super.onCreateActionMode(mode, menu); + } + + @Override + protected void onQueryChange(String s) { + if (s.length() == 0) { + searching = false; + container.setVisibility(View.VISIBLE); + searchResultsList.setVisibility(View.GONE); + return; + } + disposables.add(ServiceFactory.get(wiki).searchForCategory(s, BATCH_SIZE, 1) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .map(response -> { + if (response != null && response.success() && response.query().categories() != null) { + return new CategoriesSearchResults(response.query().categories(), response.continuation()); + } + return new CategoriesSearchResults(); + }) + .subscribe(results -> { + searching = true; + container.setVisibility(View.GONE); + searchResultsList.setVisibility(View.VISIBLE); + searchResults = results.getResults(); + getAdapter().notifyDataSetChanged(); + }, caught -> { + Toast.makeText(requireActivity(), caught.getMessage(), Toast.LENGTH_LONG).show(); + }) + ); + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + super.onDestroyActionMode(mode); + actionMode = null; + } + + @Override + protected String getSearchHintString() { + return getString(R.string.search_hint_categories); + } + + @Override + protected boolean finishActionModeIfKeyboardHiding() { + return true; + } + } + + + private final class SearchResultAdapter extends BaseAdapter { + private final LayoutInflater inflater; + + SearchResultAdapter(LayoutInflater inflater) { + this.inflater = inflater; + } + + @Override + public int getCount() { + return searchResults.size(); + } + + @Override + public Object getItem(int position) { + return searchResults.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = inflater.inflate(R.layout.item_category_search_result, parent, false); + } + String categoryTitle = (String) getItem(position); + + TextView categorySearchResultTitle = convertView.findViewById(R.id.item_categories_result_title); + categorySearchResultTitle.setText(categoryTitle); + + return convertView; + } + } + + // Method required for ListView in ScrollView + private void setListViewHeightBasedOnChildren(ListView listView) { + ListAdapter listAdapter = listView.getAdapter(); + if (listAdapter == null) { + return; + } + + int totalHeight = 0; + for (int i = 0; i < listAdapter.getCount(); i++) { + View listItem = listAdapter.getView(i, null, listView); + listItem.measure(0, 0); + totalHeight += listItem.getMeasuredHeight(); + } + + ViewGroup.LayoutParams params = listView.getLayoutParams(); + params.height = totalHeight + (listView.getDividerHeight() * (listAdapter.getCount() - 1)); + listView.setLayoutParams(params); + listView.requestLayout(); + } +} diff --git a/app/src/main/java/org/wikipedia/feed/categories/feedcard/CategoriesCard.java b/app/src/main/java/org/wikipedia/feed/categories/feedcard/CategoriesCard.java new file mode 100644 index 0000000..65e607c --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/feedcard/CategoriesCard.java @@ -0,0 +1,14 @@ +package org.wikipedia.feed.categories.feedcard; + +import android.support.annotation.NonNull; + +import org.wikipedia.feed.model.Card; +import org.wikipedia.feed.model.CardType; + +public class CategoriesCard extends Card { + @NonNull + @Override + public CardType type() { + return CardType.CATEGORIES; + } +} diff --git a/app/src/main/java/org/wikipedia/feed/categories/feedcard/CategoriesCardView.java b/app/src/main/java/org/wikipedia/feed/categories/feedcard/CategoriesCardView.java new file mode 100644 index 0000000..bc637cc --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/feedcard/CategoriesCardView.java @@ -0,0 +1,41 @@ +package org.wikipedia.feed.categories.feedcard; + +import android.content.Context; +import android.view.View; + +import org.wikipedia.R; +import org.wikipedia.feed.view.StaticCardView; + +import io.reactivex.annotations.NonNull; + +public class CategoriesCardView extends StaticCardView { + + public interface Callback { + void onCategoriesClick(@NonNull CategoriesCardView view); + } + + public CategoriesCardView(Context context) { + super(context); + } + + @Override public void setCard(@NonNull CategoriesCard card) { + super.setCard(card); + setTitle(getString(R.string.view_categories_card_title)); + setSubtitle(getString(R.string.view_categories_card_subtitle)); + setIcon(R.drawable.ic_category_24dp); + setContainerBackground(R.color.orange700); + setAction(R.drawable.ic_arrow_forward_black_24dp, R.string.view_categories_card_action); + } + + protected void onContentClick(View v) { + if (getCallback() != null) { + getCallback().onCategoriesClick(CategoriesCardView.this); + } + } + + protected void onActionClick(View v) { + if (getCallback() != null) { + getCallback().onCategoriesClick(CategoriesCardView.this); + } + } +} diff --git a/app/src/main/java/org/wikipedia/feed/categories/feedcard/CategoriesClient.java b/app/src/main/java/org/wikipedia/feed/categories/feedcard/CategoriesClient.java new file mode 100644 index 0000000..c39c821 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/feedcard/CategoriesClient.java @@ -0,0 +1,12 @@ +package org.wikipedia.feed.categories.feedcard; + +import org.wikipedia.dataclient.WikiSite; +import org.wikipedia.feed.dataclient.DummyClient; +import org.wikipedia.feed.model.Card; + +public class CategoriesClient extends DummyClient { + @Override + public Card getNewCard(WikiSite wiki) { + return new CategoriesCard(); + } +} diff --git a/app/src/main/java/org/wikipedia/feed/categories/recommended/RecommendedCategoriesArrayAdapter.java b/app/src/main/java/org/wikipedia/feed/categories/recommended/RecommendedCategoriesArrayAdapter.java new file mode 100644 index 0000000..79bcf05 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/recommended/RecommendedCategoriesArrayAdapter.java @@ -0,0 +1,38 @@ +package org.wikipedia.feed.categories.recommended; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import org.wikipedia.R; +import org.wikipedia.dataclient.mwapi.MwQueryPage; + +import java.util.List; + +public class RecommendedCategoriesArrayAdapter extends ArrayAdapter { + + private List categories; + private LayoutInflater inflater; + + public RecommendedCategoriesArrayAdapter(Context context, List categories) { + super(context, R.layout.recommended_categories_row, categories); + this.categories = categories; + inflater = (LayoutInflater.from(context)); + } + + public List getValues() { + return this.categories; + } + + @Override + public View getView(int position, View view, ViewGroup parent) { + view = inflater.inflate(R.layout.recommended_categories_row, parent, false); + TextView title = view.findViewById(R.id.recommended_row_title); + String name = categories.get(position).title(); + title.setText(name.substring(name.indexOf(':') + 1)); + return view; + } +} diff --git a/app/src/main/java/org/wikipedia/feed/categories/recommended/RecommendedCategoriesClient.java b/app/src/main/java/org/wikipedia/feed/categories/recommended/RecommendedCategoriesClient.java new file mode 100644 index 0000000..1d3458b --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/recommended/RecommendedCategoriesClient.java @@ -0,0 +1,55 @@ +package org.wikipedia.feed.categories.recommended; + +import android.support.annotation.NonNull; + +import org.wikipedia.dataclient.ServiceFactory; +import org.wikipedia.dataclient.WikiSite; +import org.wikipedia.dataclient.mwapi.MwQueryPage; +import org.wikipedia.history.HistoryEntry; +import org.wikipedia.page.bottomcontent.MainPageReadMoreTopicTask; + +import java.util.Collections; +import java.util.List; + +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; + +public class RecommendedCategoriesClient { + + public interface Delegate { + void run(List categories); + } + + private static final int HISTORY_ENTRY_INDEX = 0; + private static final int CATEGORY_LIMIT = 5; + + private CompositeDisposable disposables = new CompositeDisposable(); + + public void request(WikiSite wiki, Delegate callback) { + + disposables.add(Observable.fromCallable(new MainPageReadMoreTopicTask(HISTORY_ENTRY_INDEX)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(entry -> getCategoriesForHistoryEntry(entry, wiki, callback))); + } + + private void getCategoriesForHistoryEntry(@NonNull final HistoryEntry entry, WikiSite wiki, + final Delegate callback) { + + disposables.add(ServiceFactory.get(wiki).getCategoriesInPage(entry.getTitle().toString(), CATEGORY_LIMIT) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .map(response -> { + if (response != null && response.success() && response.query().pages() != null && !response.query().pages().isEmpty()) { + return response.query().pages().get(0).categories(); + } + return Collections.emptyList(); + }) + .subscribe(results -> { + callback.run((List) results); + })); + } + +} diff --git a/app/src/main/java/org/wikipedia/feed/categories/result/CategoriesResultActivity.java b/app/src/main/java/org/wikipedia/feed/categories/result/CategoriesResultActivity.java new file mode 100644 index 0000000..3fe518e --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/result/CategoriesResultActivity.java @@ -0,0 +1,34 @@ +package org.wikipedia.feed.categories.result; + + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; + +import org.wikipedia.activity.SingleFragmentActivity; +import org.wikipedia.search.SearchResult; + +import java.util.ArrayList; + +public class CategoriesResultActivity extends SingleFragmentActivity { + public static final String CATEGORY_TOPIC = "categoryTopic"; + public static final String CATEGORY_RESULT = "categoryResult"; + + public static Intent newIntent(@NonNull Context context, @NonNull String categoryTopic, @NonNull ArrayList categoryResult) { + return new Intent(context, CategoriesResultActivity.class) + .putExtra(CATEGORY_TOPIC, categoryTopic) + .putParcelableArrayListExtra(CATEGORY_RESULT, categoryResult); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getSupportActionBar().setElevation(0f); + } + + @Override + protected CategoriesResultFragment createFragment() { + return CategoriesResultFragment.newInstance(); + } +} diff --git a/app/src/main/java/org/wikipedia/feed/categories/result/CategoriesResultFragment.java b/app/src/main/java/org/wikipedia/feed/categories/result/CategoriesResultFragment.java new file mode 100644 index 0000000..5cf1985 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/result/CategoriesResultFragment.java @@ -0,0 +1,108 @@ +package org.wikipedia.feed.categories.result; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import org.wikipedia.R; +import org.wikipedia.history.HistoryEntry; +import org.wikipedia.page.PageActivity; +import org.wikipedia.page.PageTitle; +import org.wikipedia.search.SearchResult; +import org.wikipedia.views.ViewUtil; + +import java.util.ArrayList; + +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnItemClick; +import butterknife.Unbinder; + +import static org.wikipedia.feed.categories.result.CategoriesResultActivity.CATEGORY_RESULT; +import static org.wikipedia.feed.categories.result.CategoriesResultActivity.CATEGORY_TOPIC; + +public class CategoriesResultFragment extends Fragment { + private Unbinder unbinder; + private String categoryTopic; + private ArrayList categoryResult; + + @BindView(R.id.categories_results_list) ListView categoryResultList; + + @NonNull + public static CategoriesResultFragment newInstance() { + CategoriesResultFragment instance = new CategoriesResultFragment(); + return instance; + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + View view = inflater.inflate(R.layout.fragment_categories_result, container, false); + unbinder = ButterKnife.bind(this, view); + categoryTopic = requireActivity().getIntent().getStringExtra(CATEGORY_TOPIC); + categoryResult = requireActivity().getIntent().getParcelableArrayListExtra(CATEGORY_RESULT); + + categoryResultList = view.findViewById(R.id.categories_results_list); + CategoriesResultAdapter customerAdapter = new CategoriesResultAdapter(); + categoryResultList.setAdapter(customerAdapter); + + // Change the fragment title + getActivity().setTitle(categoryTopic); + + return view; + } + + /** + * Dynamically add layouts to the ListView based off category result + */ + private class CategoriesResultAdapter extends BaseAdapter { + + @Override + public int getCount() { + return categoryResult.size(); + } + + @Override + public Object getItem(int position) { + return categoryResult.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + convertView = getLayoutInflater().inflate(R.layout.item_categories_result, null); + TextView categoryTitle = (TextView) convertView.findViewById(R.id.item_categories_result_title); + categoryTitle.setText(categoryResult.get(position).getPageTitle().getDisplayText()); + ViewUtil.loadImageUrlInto(convertView.findViewById(R.id.item_categories_result_image), + categoryResult.get(position).getPageTitle().getThumbUrl()); + return convertView; + } + } + + private BaseAdapter getAdapter() { + return (BaseAdapter) categoryResultList.getAdapter(); + } + + @OnItemClick(R.id.categories_results_list) void onItemClick(ListView view, int position) { + PageTitle item = ((SearchResult) getAdapter().getItem(position)).getPageTitle(); + HistoryEntry historyEntry = new HistoryEntry(item, HistoryEntry.SOURCE_CATEGORY); + startActivity(PageActivity.newIntentForCurrentTab(requireContext(), historyEntry, historyEntry.getTitle())); + } + + @Override + public void onDestroyView() { + unbinder.unbind(); + unbinder = null; + super.onDestroyView(); + } +} diff --git a/app/src/main/java/org/wikipedia/feed/categories/result/CategoriesSearchResults.java b/app/src/main/java/org/wikipedia/feed/categories/result/CategoriesSearchResults.java new file mode 100644 index 0000000..4cee608 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/categories/result/CategoriesSearchResults.java @@ -0,0 +1,40 @@ +package org.wikipedia.feed.categories.result; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.wikipedia.dataclient.mwapi.MwQueryCategory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class CategoriesSearchResults { + @NonNull private List results; + @Nullable private Map continuation; + + public CategoriesSearchResults() { + this.results = new ArrayList<>(); + this.continuation = null; + } + + public CategoriesSearchResults( + @NonNull List categories, + @Nullable Map continuation + ) { + List catogryResults = new ArrayList<>(); + for (MwQueryCategory category : categories) { + catogryResults.add(category.category()); + } + this.results = catogryResults; + this.continuation = continuation; + } + + @NonNull public List getResults() { + return this.results; + } + + @Nullable public Map getContinuation() { + return this.continuation; + } +} diff --git a/app/src/main/java/org/wikipedia/feed/model/CardType.java b/app/src/main/java/org/wikipedia/feed/model/CardType.java index da766eb..cb1ba4f 100644 --- a/app/src/main/java/org/wikipedia/feed/model/CardType.java +++ b/app/src/main/java/org/wikipedia/feed/model/CardType.java @@ -6,6 +6,7 @@ import org.wikipedia.feed.FeedContentType; import org.wikipedia.feed.announcement.AnnouncementCardView; import org.wikipedia.feed.becauseyouread.BecauseYouReadCardView; +import org.wikipedia.feed.categories.feedcard.CategoriesCardView; import org.wikipedia.feed.dayheader.DayHeaderCardView; import org.wikipedia.feed.featured.FeaturedArticleCardView; import org.wikipedia.feed.image.FeaturedImageCardView; @@ -102,6 +103,11 @@ public enum CardType implements EnumCode { return new AnnouncementCardView(ctx); } }, + CATEGORIES(21, FeedContentType.CATEGORIES) { + @NonNull @Override public FeedCardView newView(@NonNull Context ctx) { + return new CategoriesCardView(ctx); + } + }, DAY_HEADER(97) { @NonNull @Override public FeedCardView newView(@NonNull Context ctx) { return new DayHeaderCardView(ctx); diff --git a/app/src/main/java/org/wikipedia/feed/random/RandomCardView.java b/app/src/main/java/org/wikipedia/feed/random/RandomCardView.java index bad23b9..3eec8ad 100644 --- a/app/src/main/java/org/wikipedia/feed/random/RandomCardView.java +++ b/app/src/main/java/org/wikipedia/feed/random/RandomCardView.java @@ -36,9 +36,9 @@ public RandomCardView(@NonNull Context context) { super.setCard(card); setTitle(getString(R.string.view_random_card_title)); setSubtitle(getString(R.string.view_random_card_subtitle)); - setIcon(R.drawable.ic_casino_accent50_24dp); + setIcon(R.drawable.ic_discover_black_24pd); setContainerBackground(R.color.accent50); - setAction(R.drawable.ic_casino_accent50_24dp, R.string.view_random_card_action); + setAction(R.drawable.ic_discover_black_24pd, R.string.view_random_card_action); } protected void onContentClick(View v) { diff --git a/app/src/main/java/org/wikipedia/feed/view/FeedAdapter.java b/app/src/main/java/org/wikipedia/feed/view/FeedAdapter.java index e8d1662..637d9f4 100644 --- a/app/src/main/java/org/wikipedia/feed/view/FeedAdapter.java +++ b/app/src/main/java/org/wikipedia/feed/view/FeedAdapter.java @@ -11,6 +11,7 @@ import org.wikipedia.feed.FeedCoordinatorBase; import org.wikipedia.feed.announcement.AnnouncementCardView; import org.wikipedia.feed.becauseyouread.BecauseYouReadCardView; +import org.wikipedia.feed.categories.feedcard.CategoriesCardView; import org.wikipedia.feed.dayheader.DayHeaderCardView; import org.wikipedia.feed.image.FeaturedImageCardView; import org.wikipedia.feed.model.Card; @@ -29,7 +30,9 @@ public class FeedAdapter> extends DefaultRecycl public interface Callback extends ItemTouchHelperSwipeAdapter.Callback, ListCardItemView.Callback, CardHeaderView.Callback, FeaturedImageCardView.Callback, SearchCardView.Callback, NewsListCardView.Callback, AnnouncementCardView.Callback, - RandomCardView.Callback, ListCardView.Callback, BecauseYouReadCardView.Callback { + RandomCardView.Callback, ListCardView.Callback, BecauseYouReadCardView.Callback, + CategoriesCardView.Callback { + void onShowCard(@Nullable Card card); void onRequestMore(); void onRetryFromOffline(); diff --git a/app/src/main/java/org/wikipedia/history/HistoryEntry.java b/app/src/main/java/org/wikipedia/history/HistoryEntry.java index 8b117d2..c9ecd9f 100644 --- a/app/src/main/java/org/wikipedia/history/HistoryEntry.java +++ b/app/src/main/java/org/wikipedia/history/HistoryEntry.java @@ -40,6 +40,7 @@ public class HistoryEntry implements Parcelable { public static final int SOURCE_NOTIFICATION_SYSTEM = 26; public static final int SOURCE_FLOATING_QUEUE = 27; public static final int SOURCE_EDIT_DESCRIPTION = 28; + public static final int SOURCE_CATEGORY = 29; @NonNull private final PageTitle title; @NonNull private final Date timestamp; diff --git a/app/src/main/java/org/wikipedia/language/translation/Data.java b/app/src/main/java/org/wikipedia/language/translation/Data.java new file mode 100644 index 0000000..c973b5f --- /dev/null +++ b/app/src/main/java/org/wikipedia/language/translation/Data.java @@ -0,0 +1,22 @@ +package org.wikipedia.language.translation; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +public class Data { + + @SerializedName("translations") + @Expose + private List translations = null; + + public List getTranslations() { + return translations; + } + + public void setTranslations(List translations) { + this.translations = translations; + } + +} diff --git a/app/src/main/java/org/wikipedia/language/translation/TranslateDialog.java b/app/src/main/java/org/wikipedia/language/translation/TranslateDialog.java new file mode 100644 index 0000000..d8532d4 --- /dev/null +++ b/app/src/main/java/org/wikipedia/language/translation/TranslateDialog.java @@ -0,0 +1,141 @@ +package org.wikipedia.language.translation; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import org.wikipedia.R; +import org.wikipedia.page.ExtendedBottomSheetDialogFragment; +import org.wikipedia.page.PageTitle; +import org.wikipedia.views.LanguageScrollView; + +import java.util.ArrayList; +import java.util.Arrays; + +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.Unbinder; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; + +import static org.wikipedia.util.L10nUtil.setConditionalLayoutDirection; + +public class TranslateDialog extends ExtendedBottomSheetDialogFragment implements LanguageScrollView.Callback{ + public interface Callback { + void translateShowDialogForTerm(@NonNull String term); + } + + private static final String TITLE = "title"; + private static final String SELECTED_TEXT = "selected_text"; + + private ProgressBar progressBar; + private CompositeDisposable disposables = new CompositeDisposable(); + private View rootView; + private TranslationClient translationClient; + + private boolean initialized = false; + + private PageTitle pageTitle; + private String selectedText; + + @BindView(R.id.lang_scroll) LanguageScrollView languageScrollView; + @BindView(R.id.language_scroll_container) View languageScrollContainer; + @BindView(R.id.more_languages) TextView moreButton; + @BindView(R.id.translate_translated_text) TextView translationText; + private Unbinder unbinder; + + public static TranslateDialog newInstance(@NonNull PageTitle title, @NonNull String selectedText) { + TranslateDialog dialog = new TranslateDialog(); + Bundle args = new Bundle(); + args.putParcelable(TITLE, title); + args.putString(SELECTED_TEXT, selectedText); + dialog.setArguments(args); + return dialog; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + translationClient = new TranslationClient(getContext()); + pageTitle = getArguments().getParcelable(TITLE); + selectedText = getArguments().getString(SELECTED_TEXT); + } + + public void onCreate(Bundle savedInstanceState, TranslationClient translationClient, ProgressBar progressBar, TextView translationText, CompositeDisposable disposables, String selectedText) { + super.onCreate(savedInstanceState); + this.translationClient = translationClient; + this.pageTitle = getArguments().getParcelable(TITLE); + this.selectedText = selectedText; + this.progressBar = progressBar; + this.translationText = translationText; + this.disposables = disposables; + } + + @Override + public void onDestroy() { + disposables.clear(); + unbinder.unbind(); + super.onDestroy(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + rootView = inflater.inflate(R.layout.dialog_translate, container); + unbinder = ButterKnife.bind(this, rootView); + progressBar = rootView.findViewById(R.id.dialog_translate_progress); + + ArrayList langList = new ArrayList<>(Arrays.asList(TranslationClient.Language.values())); + ArrayList langNames = new ArrayList<>(); + for (TranslationClient.Language lang : langList) { + langNames.add(lang.name()); + } + + setConditionalLayoutDirection(rootView, pageTitle.getWikiSite().languageCode()); + languageScrollContainer.setVisibility(View.VISIBLE); + moreButton.setVisibility(View.GONE); + languageScrollView.setUpLanguageScrollTabData(langNames, this, 0); + translationText.setText(selectedText); + + return rootView; + } + + public void translateText(String text, String language) { + progressBar.setVisibility(View.VISIBLE); + translationText.setText(""); + disposables.add(translationClient.translate(text, language) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(translationResponse -> { + progressBar.setVisibility(View.GONE); + String translated = translationResponse.getData().getTranslations().get(0).getTranslatedText(); + translationText.setText(translated); + })); + } + + @Override + public void onLanguageTabSelected(String selectedLanguageCode) { + if (!initialized) { + initialized = true; + return; + } + + // Original text + if (selectedLanguageCode.equals(TranslationClient.Language.EN.name())) { + translationText.setText(selectedText); + return; + } + + translateText(selectedText, selectedLanguageCode); + } + + @Override + public void onLanguageButtonClicked() { + // Stub + } +} diff --git a/app/src/main/java/org/wikipedia/language/translation/Translation.java b/app/src/main/java/org/wikipedia/language/translation/Translation.java new file mode 100644 index 0000000..12d4b44 --- /dev/null +++ b/app/src/main/java/org/wikipedia/language/translation/Translation.java @@ -0,0 +1,21 @@ + +package org.wikipedia.language.translation; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +public class Translation { + + @SerializedName("translatedText") + @Expose + private String translatedText; + + public String getTranslatedText() { + return translatedText; + } + + public void setTranslatedText(String translatedText) { + this.translatedText = translatedText; + } + +} diff --git a/app/src/main/java/org/wikipedia/language/translation/TranslationClient.java b/app/src/main/java/org/wikipedia/language/translation/TranslationClient.java new file mode 100644 index 0000000..ab89513 --- /dev/null +++ b/app/src/main/java/org/wikipedia/language/translation/TranslationClient.java @@ -0,0 +1,56 @@ +package org.wikipedia.language.translation; + +import android.content.Context; + +import org.wikipedia.R; + +import org.wikipedia.json.GsonUtil; + +import io.reactivex.Observable; +import retrofit2.Retrofit; +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; +import retrofit2.converter.gson.GsonConverterFactory; + +public class TranslationClient { + public enum Language { + EN, + FR, + ES, + HI, + EL, + AR, + PT, + RO, + RU, + JA, + DE, + IT + } + + TranslationService service; + String apiKey; + + public TranslationClient(Context applicationContext) { + apiKey = applicationContext.getString(R.string.google_translation_api_key); + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://translation.googleapis.com/") + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) + .build(); + + service = retrofit.create(TranslationService.class); + } + + public TranslationClient(String apiKey, TranslationService service) { + this.apiKey = apiKey; + this.service = service; + } + + public Observable translate(String text, String target, String source) { + return service.translate(text, source, target, apiKey); + } + + public Observable translate(String text, String target) { + return translate(text, target, Language.EN.name()); + } +} diff --git a/app/src/main/java/org/wikipedia/language/translation/TranslationResponse.java b/app/src/main/java/org/wikipedia/language/translation/TranslationResponse.java new file mode 100644 index 0000000..a84fe8b --- /dev/null +++ b/app/src/main/java/org/wikipedia/language/translation/TranslationResponse.java @@ -0,0 +1,21 @@ + +package org.wikipedia.language.translation; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +public class TranslationResponse { + + @SerializedName("data") + @Expose + private Data data; + + public Data getData() { + return data; + } + + public void setData(Data data) { + this.data = data; + } + +} diff --git a/app/src/main/java/org/wikipedia/language/translation/TranslationService.java b/app/src/main/java/org/wikipedia/language/translation/TranslationService.java new file mode 100644 index 0000000..f3e7439 --- /dev/null +++ b/app/src/main/java/org/wikipedia/language/translation/TranslationService.java @@ -0,0 +1,16 @@ +package org.wikipedia.language.translation; + +import io.reactivex.Observable; +import retrofit2.http.POST; +import retrofit2.http.Query; + +public interface TranslationService { + @POST("language/translate/v2?format=text") + Observable translate( + @Query("q") String text, + @Query("source") String source, + @Query("target") String target, + @Query("key") String key + ); + +} diff --git a/app/src/main/java/org/wikipedia/page/shareafact/ShareHandler.java b/app/src/main/java/org/wikipedia/page/shareafact/ShareHandler.java index d85c15e..44e15fe 100755 --- a/app/src/main/java/org/wikipedia/page/shareafact/ShareHandler.java +++ b/app/src/main/java/org/wikipedia/page/shareafact/ShareHandler.java @@ -23,6 +23,8 @@ import org.wikipedia.dataclient.ServiceFactory; import org.wikipedia.dataclient.mwapi.MwQueryPage; import org.wikipedia.gallery.ImageLicense; +import org.wikipedia.language.translation.TranslateDialog; +import org.wikipedia.language.translation.TranslationClient; import org.wikipedia.page.Namespace; import org.wikipedia.page.NoDimBottomSheetDialog; import org.wikipedia.page.Page; @@ -55,6 +57,7 @@ public class ShareHandler { private static final String PAYLOAD_PURPOSE_SHARE = "share"; private static final String PAYLOAD_PURPOSE_DEFINE = "define"; private static final String PAYLOAD_PURPOSE_HEAR = "hear"; + private static final String PAYLOAD_PURPOSE_TRANSLATE = "translate"; private static final String PAYLOAD_PURPOSE_EDIT_HERE = "edit_here"; private static final String PAYLOAD_TEXT_KEY = "text"; @@ -89,6 +92,9 @@ public ShareHandler(@NonNull PageFragment fragment, @NonNull CommunicationBridge case PAYLOAD_PURPOSE_HEAR: onHearPayload(text); break; + case PAYLOAD_PURPOSE_TRANSLATE: + onTranslatePayload(text); + break; case PAYLOAD_PURPOSE_EDIT_HERE: onEditHerePayload(messagePayload.optInt("sectionID", 0), text, messagePayload.optBoolean("editDescription", false)); break; @@ -114,6 +120,11 @@ public void showWiktionaryDefinition(String text) { fragment.showBottomSheet(WiktionaryDialog.newInstance(title, text)); } + public void showTranslateMenu(String text) { + PageTitle title = fragment.getTitle(); + fragment.showBottomSheet(TranslateDialog.newInstance(title, text)); + } + private void onSharePayload(@NonNull String text) { if (funnel == null) { createFunnel(); @@ -146,7 +157,7 @@ public void onDone(String utteranceId) { @Override public void onError(String utteranceId) { fragment.getActivity().runOnUiThread(() -> fragment.getStopTTSButton().hide()); - System.err.println("ERROR: Something went wrong during the TTS process."); + L.e("ERROR: Something went wrong during the TTS process."); } }); @@ -169,6 +180,10 @@ public void onError(String utteranceId) { } } + private void onTranslatePayload(String text) { + showTranslateMenu(text.toLowerCase(Locale.getDefault())); + } + private void onEditHerePayload(int sectionID, String text, boolean isEditingDescription) { if (sectionID == 0 && isEditingDescription) { fragment.verifyBeforeEditingDescription(text); @@ -247,6 +262,12 @@ private void handleSelection(Menu menu, MenuItem shareItem) { defineItem.setOnMenuItemClickListener(new RequestTextSelectOnMenuItemClickListener(PAYLOAD_PURPOSE_DEFINE)); } + MenuItem translateItem = menu.findItem(R.id.menu_text_select_translate); + if (isTranslationDialogEnabledForArticleLanguage()) { + translateItem.setVisible(true); + translateItem.setOnMenuItemClickListener(new RequestTextSelectOnMenuItemClickListener(PAYLOAD_PURPOSE_TRANSLATE)); + } + MenuItem hearItem = menu.findItem(R.id.menu_text_select_hear); hearItem.setOnMenuItemClickListener(new RequestTextSelectOnMenuItemClickListener(PAYLOAD_PURPOSE_HEAR)); @@ -268,6 +289,11 @@ private boolean isWiktionaryDialogEnabledForArticleLanguage() { .contains(fragment.getTitle().getWikiSite().languageCode()); } + private boolean isTranslationDialogEnabledForArticleLanguage() { + return fragment.getTitle().getWikiSite().languageCode().toUpperCase() + .equals(TranslationClient.Language.EN.name()); + } + private void postShowShareToolTip(final MenuItem shareItem) { fragment.getView().post(() -> { View shareItemView = ActivityUtil.getMenuItemView(fragment.requireActivity(), shareItem); diff --git a/app/src/main/java/org/wikipedia/random/RandomFragment.java b/app/src/main/java/org/wikipedia/random/RandomFragment.java index 6e6baf0..2fd0ae9 100644 --- a/app/src/main/java/org/wikipedia/random/RandomFragment.java +++ b/app/src/main/java/org/wikipedia/random/RandomFragment.java @@ -1,5 +1,6 @@ package org.wikipedia.random; +import android.app.Activity; import android.content.DialogInterface; import android.graphics.drawable.Animatable; import android.os.Bundle; @@ -9,16 +10,24 @@ import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.view.GestureDetectorCompat; import android.support.v4.view.ViewPager; import android.support.v7.app.AppCompatActivity; +import android.view.GestureDetector; import android.view.LayoutInflater; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; +import android.widget.ArrayAdapter; import android.widget.ImageView; +import android.widget.Spinner; import org.wikipedia.R; import org.wikipedia.WikipediaApp; import org.wikipedia.analytics.RandomizerFunnel; +import org.wikipedia.dataclient.ServiceFactory; +import org.wikipedia.feed.aggregated.AggregatedFeedContent; +import org.wikipedia.feed.model.UtcDate; import org.wikipedia.history.HistoryEntry; import org.wikipedia.page.ExclusiveBottomSheetPresenter; import org.wikipedia.page.PageActivity; @@ -28,9 +37,13 @@ import org.wikipedia.readinglist.database.ReadingListDbHelper; import org.wikipedia.readinglist.database.ReadingListPage; import org.wikipedia.util.AnimationUtil; +import org.wikipedia.util.DateUtil; import org.wikipedia.util.FeedbackUtil; import org.wikipedia.util.log.L; +import java.util.Arrays; +import java.util.List; + import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; @@ -39,19 +52,31 @@ import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.schedulers.Schedulers; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; public class RandomFragment extends Fragment { - @BindView(R.id.random_item_pager) ViewPager randomPager; - @BindView(R.id.random_next_button) FloatingActionButton nextButton; - @BindView(R.id.random_save_button) ImageView saveButton; - @BindView(R.id.random_back_button) View backButton; + @BindView(R.id.spinner) + Spinner spinner; + @BindView(R.id.random_item_pager) + ViewPager randomPager; + @BindView(R.id.random_next_button) + FloatingActionButton nextButton; + @BindView(R.id.random_save_button) + ImageView saveButton; + @BindView(R.id.random_back_button) + View backButton; private Unbinder unbinder; private ExclusiveBottomSheetPresenter bottomSheetPresenter = new ExclusiveBottomSheetPresenter(); private boolean saveButtonState; private ViewPagerListener viewPagerListener = new ViewPagerListener(); - @Nullable private RandomizerFunnel funnel; + @Nullable + private RandomizerFunnel funnel; private CompositeDisposable disposables = new CompositeDisposable(); + private AggregatedFeedContent aggregatedContent; + @NonNull public static RandomFragment newInstance() { return new RandomFragment(); @@ -69,6 +94,8 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, randomPager.setAdapter(new RandomItemAdapter((AppCompatActivity) requireActivity())); randomPager.setPageTransformer(true, new AnimationUtil.PagerTransformer()); randomPager.addOnPageChangeListener(viewPagerListener); + GestureDetectorCompat gdt = new GestureDetectorCompat(requireActivity(), new SwipeGestureListener(requireActivity())); + randomPager.setOnTouchListener((v, event) -> gdt.onTouchEvent(event)); updateSaveShareButton(); updateBackButton(0); @@ -78,9 +105,33 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, funnel = new RandomizerFunnel(WikipediaApp.getInstance(), WikipediaApp.getInstance().getWikiSite(), requireActivity().getIntent().getIntExtra(RandomActivity.INVOKE_SOURCE_EXTRA, 0)); + + List values = setDiscoverDropdownValues(); + ArrayAdapter adapter = new ArrayAdapter<>(requireActivity(), android.R.layout.simple_spinner_item, values); + adapter.setDropDownViewResource(android.R.layout.simple_dropdown_item_1line); + spinner.setAdapter(adapter); + spinner.bringToFront(); + spinner.setVisibility(View.GONE); + + fetchAggregatedContent(); + return view; } + public List setDiscoverDropdownValues() { + return Arrays.asList("Random", "Trending"); + } + + // Get the next page in viewpager + public void moveNext() { + randomPager.setCurrentItem(randomPager.getCurrentItem() + 1, true); + } + + // Get the previous page in viewpager + public void movePrevious() { + randomPager.setCurrentItem(randomPager.getCurrentItem() - 1, true); + } + @Override public void onDestroyView() { disposables.clear(); @@ -94,28 +145,31 @@ public void onDestroyView() { super.onDestroyView(); } - @OnClick(R.id.random_next_button) void onNextClick() { + @OnClick(R.id.random_next_button) + void onNextClick() { if (nextButton.getDrawable() instanceof Animatable) { ((Animatable) nextButton.getDrawable()).start(); } viewPagerListener.setNextPageSelectedAutomatic(); - randomPager.setCurrentItem(randomPager.getCurrentItem() + 1, true); + moveNext(); if (funnel != null) { funnel.clickedForward(); } } - @OnClick(R.id.random_back_button) void onBackClick() { + @OnClick(R.id.random_back_button) + void onBackClick() { viewPagerListener.setNextPageSelectedAutomatic(); if (randomPager.getCurrentItem() > 0) { - randomPager.setCurrentItem(randomPager.getCurrentItem() - 1, true); + movePrevious(); if (funnel != null) { funnel.clickedBack(); } } } - @OnClick(R.id.random_save_button) void onSaveShareClick() { + @OnClick(R.id.random_save_button) + void onSaveShareClick() { PageTitle title = getTopTitle(); if (title == null) { return; @@ -149,6 +203,10 @@ public void onSelectPage(@NonNull PageTitle title) { new HistoryEntry(title, HistoryEntry.SOURCE_RANDOM), title)); } + public String getDropdownValue() { + return spinner.getSelectedItem().toString(); + } + public void onAddPageToList(@NonNull PageTitle title) { bottomSheetPresenter.show(getChildFragmentManager(), AddToReadingListDialog.newInstance(title, @@ -185,12 +243,14 @@ public void onChildLoaded() { updateSaveShareButton(); } - @Nullable private PageTitle getTopTitle() { + @Nullable + private PageTitle getTopTitle() { RandomItemFragment f = getTopChild(); return f == null ? null : f.getTitle(); } - @Nullable private RandomItemFragment getTopChild() { + @Nullable + private RandomItemFragment getTopChild() { FragmentManager fm = getFragmentManager(); for (Fragment f : fm.getFragments()) { if (f instanceof RandomItemFragment @@ -201,7 +261,31 @@ public void onChildLoaded() { return null; } - private class RandomItemAdapter extends FragmentPagerAdapter{ + private void fetchAggregatedContent() { + UtcDate date = DateUtil.getUtcRequestDateFor(0); + for (String appLangCode : WikipediaApp.getInstance().language().getAppLanguageCodes()) { + Call call = ServiceFactory.getRest(WikipediaApp.getInstance().getWikiSite()).getAggregatedFeed(appLangCode, date.year(), date.month(), date.date()); + call.enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + aggregatedContent = response.body(); + spinner.setVisibility(View.VISIBLE); + } + } + @Override + public void onFailure(Call call, Throwable t) { + L.e(t); + } + }); + } + } + + public AggregatedFeedContent getAggregatedContent() { + return this.aggregatedContent; + } + + private class RandomItemAdapter extends FragmentPagerAdapter { RandomItemAdapter(AppCompatActivity activity) { super(activity.getSupportFragmentManager()); @@ -255,4 +339,100 @@ public void onPageSelected(int position) { public void onPageScrollStateChanged(int state) { } } + + public class SwipeGestureListener implements GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener { + + // Min x and y axis swipe distance + static final int X_MIN_DISTANCE = 100; + static final int Y_MIN_DISTANCE = 300; + + // Max x and y axis swipe distance + static final int X_MAX_DISTANCE = 1000; + static final int Y_MAX_DISTANCE = 1000; + + private Activity activity; + private MotionEvent mLastOnDownEvent = null; + + public SwipeGestureListener(Activity activity) { + this.activity = activity; + } + + @Override + public boolean onDown(MotionEvent e) { + mLastOnDownEvent = e; + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (e1 == null) { + e1 = mLastOnDownEvent; + } + if (e1 == null || e2 == null) { + return false; + } + + // Get swipe delta value in x axis + float deltaX = e1.getX() - e2.getX(); + + // Get swipe delta value in y axis + float deltaY = e1.getY() - e2.getY(); + + // Get absolute value + float deltaXAbs = Math.abs(deltaX); + float deltaYAbs = Math.abs(deltaY); + + // Valid swipes if delta is between min and max distance + if ((deltaXAbs >= X_MIN_DISTANCE) && (deltaXAbs <= X_MAX_DISTANCE)) { + if (deltaX > 0) { + moveNext(); + } else { + movePrevious(); + } + } + + // Valid swipe up + if ((deltaYAbs >= Y_MIN_DISTANCE) && (deltaYAbs <= Y_MAX_DISTANCE) && deltaY > 0) { + PageTitle title = getTopTitle(); + onSelectPage(title); + } + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + return false; + } + + @Override + public void onLongPress(MotionEvent e) { + + } + + @Override + public void onShowPress(MotionEvent e) { + + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return false; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + saveButton.performClick(); + return true; + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + return false; + } + } } diff --git a/app/src/main/java/org/wikipedia/random/RandomItemFragment.java b/app/src/main/java/org/wikipedia/random/RandomItemFragment.java index 0b38f25..8125a23 100644 --- a/app/src/main/java/org/wikipedia/random/RandomItemFragment.java +++ b/app/src/main/java/org/wikipedia/random/RandomItemFragment.java @@ -23,9 +23,10 @@ import org.wikipedia.views.GoneIfEmptyTextView; import org.wikipedia.views.WikiErrorView; +import java.util.List; + import butterknife.BindView; import butterknife.ButterKnife; -import butterknife.OnClick; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.schedulers.Schedulers; @@ -38,7 +39,6 @@ public class RandomItemFragment extends Fragment { @BindView(R.id.view_random_article_card_article_subtitle) GoneIfEmptyTextView articleSubtitleView; @BindView(R.id.view_random_article_card_extract) TextView extractView; @BindView(R.id.random_item_error_view) WikiErrorView errorView; - private CompositeDisposable disposables = new CompositeDisposable(); @Nullable private RbPageSummary summary; private int pagerPosition = -1; @@ -66,6 +66,7 @@ public void onCreate(Bundle savedInstanceState) { setRetainInstance(true); } + @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { super.onCreateView(inflater, container, savedInstanceState); @@ -91,6 +92,10 @@ public void onDestroy() { } private void getRandomPage() { + if (parent().getDropdownValue().equals("Trending")) { + getRandomTrendingArticle(); + return; + } disposables.add(ServiceFactory.getRest(WikipediaApp.getInstance().getWikiSite()).getRandomSummary() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) @@ -101,6 +106,19 @@ private void getRandomPage() { }, this::setErrorState)); } + public void getRandomTrendingArticle() { + if (parent().getAggregatedContent() != null) { + List trendingPages = parent().getAggregatedContent().mostRead().articles(); + if (!trendingPages.isEmpty()) { + summary = trendingPages.get(0); + trendingPages.remove(summary); + trendingPages.add(summary); + updateContents(); + parent().onChildLoaded(); + } + } + } + private void setErrorState(@NonNull Throwable t) { L.e(t); errorView.setError(t); @@ -109,12 +127,6 @@ private void setErrorState(@NonNull Throwable t) { containerView.setVisibility(View.GONE); } - @OnClick(R.id.view_random_article_card_text_container) void onClick(View v) { - if (getTitle() != null) { - parent().onSelectPage(getTitle()); - } - } - public void updateContents() { errorView.setVisibility(View.GONE); containerView.setVisibility(summary == null ? View.GONE : View.VISIBLE); diff --git a/app/src/main/java/org/wikipedia/tts/TextToSpeechWrapper.java b/app/src/main/java/org/wikipedia/tts/TextToSpeechWrapper.java index 30d436f..ba49b45 100644 --- a/app/src/main/java/org/wikipedia/tts/TextToSpeechWrapper.java +++ b/app/src/main/java/org/wikipedia/tts/TextToSpeechWrapper.java @@ -7,6 +7,7 @@ import android.speech.tts.UtteranceProgressListener; import org.wikipedia.settings.Prefs; +import org.wikipedia.util.log.L; public class TextToSpeechWrapper { private TextToSpeech tts; @@ -21,7 +22,7 @@ public TextToSpeechWrapper(Context context) { tts.setPitch(Prefs.getTTSPitch()); tts.setSpeechRate(Prefs.getTTSSpeechRate()); } else { - System.err.println("ERROR: Failed to initialize TTS engine."); + L.e("ERROR: Failed to initialize TTS engine."); } }); } @@ -55,7 +56,7 @@ public void initAndSpeak(Context context, String text) { tts.setSpeechRate(Prefs.getTTSSpeechRate()); speakWithUtteranceListener(text); } else { - System.err.println("ERROR: Failed to initialize TTS engine."); + L.e("ERROR: Failed to initialize TTS engine."); } }); } else { @@ -77,7 +78,7 @@ public void onDone(String utteranceId) { @Override public void onError(String utteranceId) { - System.err.println("ERROR: Something went wrong during the TTS process."); + L.e("ERROR: Something went wrong during the TTS process."); } }); speakWithUtteranceId(text, "ttsWrapper"); diff --git a/app/src/main/res/drawable/appshortcut_ic_random.xml b/app/src/main/res/drawable/appshortcut_ic_random.xml index ac3da39..92ad982 100644 --- a/app/src/main/res/drawable/appshortcut_ic_random.xml +++ b/app/src/main/res/drawable/appshortcut_ic_random.xml @@ -11,5 +11,5 @@ android:bottom="@dimen/app_shortcut_icon_margin" android:left="@dimen/app_shortcut_icon_margin" android:right="@dimen/app_shortcut_icon_margin" - android:drawable="@drawable/ic_casino_accent50_24dp"/> + android:drawable="@drawable/ic_discover_black_24pd"/> \ No newline at end of file diff --git a/app/src/main/res/drawable/background_spinner.xml b/app/src/main/res/drawable/background_spinner.xml new file mode 100644 index 0000000..6820b77 --- /dev/null +++ b/app/src/main/res/drawable/background_spinner.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_culture.xml b/app/src/main/res/drawable/ic_categories_culture.xml new file mode 100644 index 0000000..022351e --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_culture.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_geography.xml b/app/src/main/res/drawable/ic_categories_geography.xml new file mode 100644 index 0000000..a4f2f0d --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_geography.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_health.xml b/app/src/main/res/drawable/ic_categories_health.xml new file mode 100644 index 0000000..ccb7565 --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_health.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_history.xml b/app/src/main/res/drawable/ic_categories_history.xml new file mode 100644 index 0000000..3c82f86 --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_history.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_human_activities.xml b/app/src/main/res/drawable/ic_categories_human_activities.xml new file mode 100644 index 0000000..ffcfc6f --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_human_activities.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_mathematics.xml b/app/src/main/res/drawable/ic_categories_mathematics.xml new file mode 100644 index 0000000..fc1e6cf --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_mathematics.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_nature.xml b/app/src/main/res/drawable/ic_categories_nature.xml new file mode 100644 index 0000000..02ed1a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_nature.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_people.xml b/app/src/main/res/drawable/ic_categories_people.xml new file mode 100644 index 0000000..a931a09 --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_people.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_philosophy.xml b/app/src/main/res/drawable/ic_categories_philosophy.xml new file mode 100644 index 0000000..bffde09 --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_philosophy.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_religion.xml b/app/src/main/res/drawable/ic_categories_religion.xml new file mode 100644 index 0000000..38a6c87 --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_religion.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_society.xml b/app/src/main/res/drawable/ic_categories_society.xml new file mode 100644 index 0000000..340c928 --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_society.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_categories_technology.xml b/app/src/main/res/drawable/ic_categories_technology.xml new file mode 100644 index 0000000..2bd60ec --- /dev/null +++ b/app/src/main/res/drawable/ic_categories_technology.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_category_24dp.xml b/app/src/main/res/drawable/ic_category_24dp.xml new file mode 100644 index 0000000..ba008f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_category_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_discover_black_24pd.xml b/app/src/main/res/drawable/ic_discover_black_24pd.xml new file mode 100644 index 0000000..d706781 --- /dev/null +++ b/app/src/main/res/drawable/ic_discover_black_24pd.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_translate_black.xml b/app/src/main/res/drawable/ic_translate_black.xml new file mode 100644 index 0000000..2e04cd0 --- /dev/null +++ b/app/src/main/res/drawable/ic_translate_black.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_translate.xml b/app/src/main/res/layout/dialog_translate.xml new file mode 100644 index 0000000..d5ef093 --- /dev/null +++ b/app/src/main/res/layout/dialog_translate.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_categories.xml b/app/src/main/res/layout/fragment_categories.xml new file mode 100644 index 0000000..2b31c23 --- /dev/null +++ b/app/src/main/res/layout/fragment_categories.xml @@ -0,0 +1,553 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_categories_result.xml b/app/src/main/res/layout/fragment_categories_result.xml new file mode 100644 index 0000000..6d8f73f --- /dev/null +++ b/app/src/main/res/layout/fragment_categories_result.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_random.xml b/app/src/main/res/layout/fragment_random.xml index 690141a..2f50297 100644 --- a/app/src/main/res/layout/fragment_random.xml +++ b/app/src/main/res/layout/fragment_random.xml @@ -8,10 +8,26 @@ android:orientation="vertical" android:background="?attr/main_toolbar_color"> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_category_search_result.xml b/app/src/main/res/layout/item_category_search_result.xml new file mode 100644 index 0000000..70dde65 --- /dev/null +++ b/app/src/main/res/layout/item_category_search_result.xml @@ -0,0 +1,15 @@ + + diff --git a/app/src/main/res/layout/recommended_categories_row.xml b/app/src/main/res/layout/recommended_categories_row.xml new file mode 100644 index 0000000..5cbf475 --- /dev/null +++ b/app/src/main/res/layout/recommended_categories_row.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/view_on_this_day_page_actions.xml b/app/src/main/res/layout/view_on_this_day_page_actions.xml index a646871..c838cf6 100644 --- a/app/src/main/res/layout/view_on_this_day_page_actions.xml +++ b/app/src/main/res/layout/view_on_this_day_page_actions.xml @@ -33,25 +33,27 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?attr/selectableItemBackground"> + + app:srcCompat="@drawable/ic_bookmark_white_24dp" /> + + android:textSize="16sp" /> + + - + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index b607c6b..634fa31 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -33,6 +33,7 @@ #14866d #fef6e7 #fc3 + #f57c00 #e1dad1 diff --git a/app/src/main/res/values/secrets.xml b/app/src/main/res/values/secrets.xml index 906fe55..b34f655 100644 --- a/app/src/main/res/values/secrets.xml +++ b/app/src/main/res/values/secrets.xml @@ -1,4 +1,5 @@ - \ No newline at end of file + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01d5432..4d7af25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,7 @@ Share Define Hear + Translate Edit here @@ -214,6 +215,7 @@ Find previous This is the first occurrence This is the last occurrence + Cannot Find Translation This may be an unconstructive edit. Are you sure you want to publish it? You cannot publish this edit. Please go back and change it. @@ -528,10 +530,13 @@ Continue reading Because you read - Randomizer - Flip through random articles to read from Wikipedia. - Roll the dice - Load another random article + Categories + Browse articles by category + View categories + Discover + Flip through articles to read from Wikipedia. + Swipe away + Load another article Today on Wikipedia Main page on %s View main page @@ -572,6 +577,7 @@ Daily featured image from Wikimedia Commons Main page of Wikipedia with daily featured content Generate random articles to read + Explore articles by category There\'s nothing on your Explore feed Customize Customize your Explore feed

You can now choose what to show on your feed, and also prioritize your favorite types of content.]]>
@@ -677,7 +683,7 @@ - Random + Discover Continue reading Search More like this @@ -745,7 +751,7 @@ Choose from gallery - + Turn off history Browse articles without saving any reading history. History is turned off @@ -765,4 +771,22 @@ 2x 3x + + + Top Categories + Recommended for You + Geography + Health + Philosophy + Mathematics + Nature + Technology + Religion + People + History + Society + Human activities + Culture + card_text + Search for a category diff --git a/app/src/main/res/values/strings_no_translate.xml b/app/src/main/res/values/strings_no_translate.xml index d06e122..bb5b5f7 100644 --- a/app/src/main/res/values/strings_no_translate.xml +++ b/app/src/main/res/values/strings_no_translate.xml @@ -101,6 +101,7 @@ \u2022     + categoriesActivityTransition newsItemTransition randomActivityTransition onThisDayTransition diff --git a/app/src/test/java/org/wikipedia/feed/categories/CategoriesInPageTest.java b/app/src/test/java/org/wikipedia/feed/categories/CategoriesInPageTest.java new file mode 100644 index 0000000..16ddc1b --- /dev/null +++ b/app/src/test/java/org/wikipedia/feed/categories/CategoriesInPageTest.java @@ -0,0 +1,65 @@ +package org.wikipedia.feed.categories; + +import com.google.gson.stream.MalformedJsonException; + +import org.junit.Test; +import org.wikipedia.dataclient.mwapi.MwQueryPage; +import org.wikipedia.test.MockRetrofitTest; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.Observable; +import io.reactivex.observers.TestObserver; + +public class CategoriesInPageTest extends MockRetrofitTest { + private static final int BATCH_SIZE = 20; + + private Observable> getObservable() { + return getApiService().getCategoriesInPage("Jackie Chan", BATCH_SIZE) + .map(response -> { + if (response != null && response.success() && response.query().pages() != null) { + return response.query().pages(); + } + return new ArrayList<>(); + }); + } + + @Test + public void testRequestSuccessNoContinuation() throws Throwable { + enqueueFromFile("categories_in_page.json"); + TestObserver> observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertComplete() + .assertNoErrors() + .assertValue(result -> result.get(0).categories().get(0).title().equals("Category:1954 births")); + } + + @Test + public void testRequestResponseApiError() throws Throwable { + enqueueFromFile("api_error.json"); + TestObserver> observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertError(Exception.class); + } + + @Test + public void testRequestResponseFailure() throws Throwable { + enqueue404(); + TestObserver> observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertError(Exception.class); + } + + @Test + public void testRequestResponseMalformed() throws Throwable { + server().enqueue("(╯°□°)╯︵ ┻━┻"); + TestObserver> observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertError(MalformedJsonException.class); + } +} diff --git a/app/src/test/java/org/wikipedia/feed/categories/PagesInCategoryTest.java b/app/src/test/java/org/wikipedia/feed/categories/PagesInCategoryTest.java new file mode 100644 index 0000000..6416e73 --- /dev/null +++ b/app/src/test/java/org/wikipedia/feed/categories/PagesInCategoryTest.java @@ -0,0 +1,72 @@ +package org.wikipedia.feed.categories; + +import com.google.gson.stream.MalformedJsonException; + +import org.junit.Test; +import org.wikipedia.dataclient.WikiSite; +import org.wikipedia.search.SearchResults; +import org.wikipedia.test.MockRetrofitTest; + +import io.reactivex.Observable; +import io.reactivex.observers.TestObserver; + +public class PagesInCategoryTest extends MockRetrofitTest { + private static final WikiSite TESTWIKI = new WikiSite("test.wikimedia.org"); + private static final int BATCH_SIZE = 20; + + private Observable getObservable() { + return getApiService().getPagesInCategory("Category:Physics", BATCH_SIZE) + .map(response -> { + if (response != null && response.success() && response.query().pages() != null) { + return new SearchResults(response.query().pages(), TESTWIKI, response.continuation(), null); + } + return new SearchResults(); + }); + } + + @Test public void testRequestSuccessNoContinuation() throws Throwable { + enqueueFromFile("pages_in_category.json"); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertComplete() + .assertNoErrors() + .assertValue(result -> result.getResults().get(0).getPageTitle().getDisplayText().equals("Physics")); + } + + @Test public void testRequestSuccessWithContinuation() throws Throwable { + enqueueFromFile("pages_in_category.json"); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertComplete().assertNoErrors() + .assertValue(result -> result.getContinuation().get("continue").equals("gcmcontinue||") + && result.getContinuation().get("gcmcontinue") + .equals("page|313f312d4f4b4543043731294f042d2947292d394f59011a01dc19|51215300")); + } + + @Test public void testRequestResponseApiError() throws Throwable { + enqueueFromFile("api_error.json"); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertError(Exception.class); + } + + @Test public void testRequestResponseFailure() throws Throwable { + enqueue404(); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertError(Exception.class); + } + + @Test public void testRequestResponseMalformed() throws Throwable { + server().enqueue("'"); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertError(MalformedJsonException.class); + } + +} diff --git a/app/src/test/java/org/wikipedia/feed/categories/SearchCategoriesTest.java b/app/src/test/java/org/wikipedia/feed/categories/SearchCategoriesTest.java new file mode 100644 index 0000000..a018d98 --- /dev/null +++ b/app/src/test/java/org/wikipedia/feed/categories/SearchCategoriesTest.java @@ -0,0 +1,85 @@ +package org.wikipedia.feed.categories; + +import com.google.gson.stream.MalformedJsonException; + +import org.junit.Test; +import org.wikipedia.feed.categories.result.CategoriesSearchResults; +import org.wikipedia.test.MockRetrofitTest; + +import io.reactivex.Observable; +import io.reactivex.observers.TestObserver; + +public class SearchCategoriesTest extends MockRetrofitTest { + private static final int BATCH_SIZE = 20; + + private Observable getObservable() { + return getApiService().searchForCategory("Marvel", BATCH_SIZE, 1) + .map(response -> { + if (response != null && response.success() && response.query().categories() != null) { + return new CategoriesSearchResults(response.query().categories(), response.continuation()); + } + return new CategoriesSearchResults(); + }); + } + + @Test + public void testRequestSuccessNoContinuation() throws Throwable { + enqueueFromFile("search_on_categories.json"); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertComplete() + .assertNoErrors() + .assertValue(result -> result.getResults().get(0).equals("Marvel-themed areas at Disney parks")); + } + + @Test + public void testRequestSuccessWithContinuation() throws Throwable { + enqueueFromFile("search_on_categories.json"); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertComplete() + .assertNoErrors() + .assertValue(result -> result.getContinuation().get("continue").equals("-||") + && result.getContinuation().get("accontinue").equals("Marvel_Cinematic_Universe_character_lists")); + } + + @Test + public void testRequestSuccessNoResults() throws Throwable { + enqueueFromFile("search_on_categories_empty.json"); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertComplete() + .assertNoErrors() + .assertValue(result -> result.getResults().isEmpty()); + } + + @Test + public void testRequestResponseApiError() throws Throwable { + enqueueFromFile("api_error.json"); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertError(Exception.class); + } + + @Test + public void testRequestResponseFailure() throws Throwable { + enqueue404(); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertError(Exception.class); + } + + @Test + public void testRequestResponseMalformed() throws Throwable { + server().enqueue("(╯°□°)╯︵ ┻━┻"); + TestObserver observer = new TestObserver<>(); + getObservable().subscribe(observer); + + observer.assertError(MalformedJsonException.class); + } +} diff --git a/app/src/test/java/org/wikipedia/language/TranslateDialogTests.java b/app/src/test/java/org/wikipedia/language/TranslateDialogTests.java new file mode 100644 index 0000000..5f90afd --- /dev/null +++ b/app/src/test/java/org/wikipedia/language/TranslateDialogTests.java @@ -0,0 +1,94 @@ +package org.wikipedia.language; + +import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.wikipedia.language.translation.TranslateDialog; +import org.wikipedia.language.translation.TranslationClient; +import org.wikipedia.language.translation.TranslationResponse; +import org.wikipedia.page.PageTitle; + + +import io.reactivex.Observable; +import io.reactivex.android.plugins.RxAndroidPlugins; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class TranslateDialogTests { + private String apiKey = "jehwreh"; + private String selectedText = "mon-text"; + private String sampleLanguage = "EN"; + private String otherLanguage = "FR"; + private TranslateDialog translateDialog; + + @Mock + private PageTitle pageTitleMock; + + @Mock + private TranslationClient translationClientMock; + + @Mock + private ProgressBar progressBarMock; + + @Mock + private TextView textMock; + + @Mock + private Observable responseMock; + + static { + RxAndroidPlugins.setInitMainThreadSchedulerHandler(schedulerCallable -> Schedulers.trampoline()); + } + + @Before + public void setUp() { + pageTitleMock = mock(PageTitle.class); + translationClientMock = mock(TranslationClient.class); + progressBarMock = mock(ProgressBar.class); + textMock = mock(TextView.class); + responseMock = mock(Observable.class); + + translateDialog = TranslateDialog.newInstance(pageTitleMock, selectedText); + } + + @Test + public void testTranslateText() { + translateDialog.onCreate(null, translationClientMock, progressBarMock, textMock, new CompositeDisposable(), selectedText); + when(translationClientMock.translate(selectedText, sampleLanguage)).thenReturn(responseMock); + + translateDialog.translateText(selectedText, sampleLanguage); + verify(progressBarMock).setVisibility(View.VISIBLE); + verify(textMock).setText(""); + verify(translationClientMock).translate(selectedText, sampleLanguage); + } + + @Test + public void testSelectLanguageTabEnglish() { + translateDialog.onCreate(null, translationClientMock, progressBarMock, textMock, new CompositeDisposable(), selectedText); + when(translationClientMock.translate(selectedText, sampleLanguage)).thenReturn(responseMock); + + translateDialog.onLanguageTabSelected(""); + translateDialog.onLanguageTabSelected(sampleLanguage); + verify(textMock).setText(selectedText); + } + + @Test + public void testSelectLanguageTabNotEnglish() { + translateDialog.onCreate(null, translationClientMock, progressBarMock, textMock, new CompositeDisposable(), selectedText); + when(translationClientMock.translate(selectedText, otherLanguage)).thenReturn(responseMock); + + translateDialog.onLanguageTabSelected(""); + translateDialog.onLanguageTabSelected(otherLanguage); + verify(progressBarMock).setVisibility(View.VISIBLE); + verify(textMock).setText(""); + verify(translationClientMock).translate(selectedText, otherLanguage); + } +} diff --git a/app/src/test/java/org/wikipedia/language/TranslationClientTests.java b/app/src/test/java/org/wikipedia/language/TranslationClientTests.java new file mode 100644 index 0000000..4871f61 --- /dev/null +++ b/app/src/test/java/org/wikipedia/language/TranslationClientTests.java @@ -0,0 +1,38 @@ +package org.wikipedia.language; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.wikipedia.language.translation.TranslationClient; +import org.wikipedia.language.translation.TranslationService; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class TranslationClientTests { + private String apiKey = "jehwreh"; + private String text = "sampleText"; + private String target = "sampleSource"; + private TranslationClient translationClient; + + @Mock + private TranslationService translationService; + + @Before + public void setUp() { + translationService = mock(TranslationService.class); + translationClient = new TranslationClient(apiKey, translationService); + } + + @Test + public void testTranslateEnglish() { + translationClient.translate(text, target); + verify(translationService).translate(text, "EN", target, apiKey); + } + + @Test + public void testTranslateNotEnglish() { + translationClient.translate(text, target, "FR"); + verify(translationService).translate(text, "FR", target, apiKey); + } +} diff --git a/app/src/test/res/raw/categories_in_page.json b/app/src/test/res/raw/categories_in_page.json new file mode 100644 index 0000000..40f4935 --- /dev/null +++ b/app/src/test/res/raw/categories_in_page.json @@ -0,0 +1,97 @@ +{ + "continue": { + "clcontinue": "144936|Hong_Kong_male_taekwondo_practitioners", + "continue": "||" + }, + "query": { + "pages": [ + { + "pageid": 144936, + "ns": 0, + "title": "Jackie Chan", + "categories": [ + { + "ns": 14, + "title": "Category:1954 births" + }, + { + "ns": 14, + "title": "Category:20th-century Hong Kong male actors" + }, + { + "ns": 14, + "title": "Category:21st-century Hong Kong male actors" + }, + { + "ns": 14, + "title": "Category:Academy Honorary Award recipients" + }, + { + "ns": 14, + "title": "Category:Action choreographers" + }, + { + "ns": 14, + "title": "Category:Cantopop singers" + }, + { + "ns": 14, + "title": "Category:Chinese Jeet Kune Do practitioners" + }, + { + "ns": 14, + "title": "Category:Hong Kong Mandopop singers" + }, + { + "ns": 14, + "title": "Category:Hong Kong entrepreneurs" + }, + { + "ns": 14, + "title": "Category:Hong Kong fashion businesspeople" + }, + { + "ns": 14, + "title": "Category:Hong Kong fashion designers" + }, + { + "ns": 14, + "title": "Category:Hong Kong film directors" + }, + { + "ns": 14, + "title": "Category:Hong Kong film producers" + }, + { + "ns": 14, + "title": "Category:Hong Kong hapkido practitioners" + }, + { + "ns": 14, + "title": "Category:Hong Kong kung fu practitioners" + }, + { + "ns": 14, + "title": "Category:Hong Kong male child actors" + }, + { + "ns": 14, + "title": "Category:Hong Kong male comedians" + }, + { + "ns": 14, + "title": "Category:Hong Kong male film actors" + }, + { + "ns": 14, + "title": "Category:Hong Kong male judoka" + }, + { + "ns": 14, + "title": "Category:Hong Kong male singers" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/app/src/test/res/raw/pages_in_category.json b/app/src/test/res/raw/pages_in_category.json new file mode 100644 index 0000000..fc0628e --- /dev/null +++ b/app/src/test/res/raw/pages_in_category.json @@ -0,0 +1,145 @@ +{ + "batchcomplete": true, + "continue": { + "gcmcontinue": "page|313f312d4f4b4543043731294f042d2947292d394f59011a01dc19|51215300", + "continue": "gcmcontinue||" + }, + "query": { + "pages": [ + { + "pageid": 22939, + "ns": 0, + "title": "Physics", + "description": "study of matter and its motion, along with related concepts such as energy and force", + "descriptionsource": "central", + "thumbnail": { + "source": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/CollageFisica.jpg/50px-CollageFisica.jpg", + "width": 50, + "height": 36 + } + }, + { + "pageid": 24489, + "ns": 0, + "title": "Outline of physics", + "description": "Wikimedia list article", + "descriptionsource": "central" + }, + { + "pageid": 1653925, + "ns": 100, + "title": "Portal:Physics", + "description": "Wikipedia's portal for exploring content related to Physics", + "descriptionsource": "local" + }, + { + "pageid": 2664158, + "ns": 0, + "title": "Center of percussion" + }, + { + "pageid": 3445246, + "ns": 0, + "title": "Glossary of classical physics", + "description": "Common terms used in classical physics", + "descriptionsource": "local" + }, + { + "pageid": 5435566, + "ns": 0, + "title": "Action-angle coordinates", + "description": "set of canonical coordinates", + "descriptionsource": "central" + }, + { + "pageid": 9079863, + "ns": 0, + "title": "Aerometer", + "description": "instrument to measure properties of air or gasses", + "descriptionsource": "central" + }, + { + "pageid": 31969872, + "ns": 0, + "title": "Droplet vaporization" + }, + { + "pageid": 33327002, + "ns": 0, + "title": "Cabbeling", + "description": "When two separate water parcels mix to form a third which is denser and sinks below both constituentss", + "descriptionsource": "local", + "thumbnail": { + "source": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/TS_Diagram.jpg/50px-TS_Diagram.jpg", + "width": 50, + "height": 38 + } + }, + { + "pageid": 48520204, + "ns": 0, + "title": "Computational anatomy" + }, + { + "pageid": 49342572, + "ns": 0, + "title": "Group actions in computational anatomy" + }, + { + "pageid": 49885288, + "ns": 0, + "title": "Dirac membrane" + }, + { + "pageid": 50333728, + "ns": 0, + "title": "Diffuse field acoustic testing" + }, + { + "pageid": 51084847, + "ns": 0, + "title": "Dispersive medium" + }, + { + "pageid": 52657328, + "ns": 0, + "title": "Bayesian model of computational anatomy" + }, + { + "pageid": 53790076, + "ns": 0, + "title": "Conformon" + }, + { + "pageid": 53991267, + "ns": 0, + "title": "CCPForge", + "thumbnail": { + "source": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Rayleigh-Taylor_instability.jpg/50px-Rayleigh-Taylor_instability.jpg", + "width": 50, + "height": 50 + } + }, + { + "pageid": 55503653, + "ns": 0, + "title": "Camelback potential", + "thumbnail": { + "source": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Camelback_Potential.svg/50px-Camelback_Potential.svg.png", + "width": 50, + "height": 32 + } + }, + { + "pageid": 55823152, + "ns": 0, + "title": "Diffeomorphometry" + }, + { + "pageid": 57780625, + "ns": 0, + "title": "Diffraction-limited storage ring" + } + ] + } +} \ No newline at end of file diff --git a/app/src/test/res/raw/search_on_categories.json b/app/src/test/res/raw/search_on_categories.json new file mode 100644 index 0000000..719bded --- /dev/null +++ b/app/src/test/res/raw/search_on_categories.json @@ -0,0 +1,81 @@ +{ + "batchcomplete": true, + "continue": { + "accontinue": "Marvel_Cinematic_Universe_character_lists", + "continue": "-||" + }, + "query": { + "allcategories": [ + { + "category": "Marvel-themed areas at Disney parks", + "size": 4, + "pages": 4, + "files": 0, + "subcats": 0 + }, + { + "category": "Marvel 1602", + "size": 4, + "pages": 4, + "files": 0, + "subcats": 0 + }, + { + "category": "Marvel 2099", + "size": 4, + "pages": 2, + "files": 0, + "subcats": 2 + }, + { + "category": "Marvel 2099 characters", + "size": 22, + "pages": 22, + "files": 0, + "subcats": 0 + }, + { + "category": "Marvel 2099 titles", + "size": 10, + "pages": 10, + "files": 0, + "subcats": 0 + }, + { + "category": "Marvel Action Universe", + "size": 6, + "pages": 6, + "files": 0, + "subcats": 0 + }, + { + "category": "Marvel Animated Features", + "size": 11, + "pages": 11, + "files": 0, + "subcats": 0 + }, + { + "category": "Marvel Animation", + "size": 16, + "pages": 15, + "files": 0, + "subcats": 1 + }, + { + "category": "Marvel Cinematic Universe", + "size": 21, + "pages": 12, + "files": 0, + "subcats": 9 + }, + { + "category": "Marvel Cinematic Universe album covers", + "size": 28, + "pages": 0, + "files": 28, + "subcats": 0 + } + ] + } +} \ No newline at end of file diff --git a/app/src/test/res/raw/search_on_categories_empty.json b/app/src/test/res/raw/search_on_categories_empty.json new file mode 100644 index 0000000..b7f73e3 --- /dev/null +++ b/app/src/test/res/raw/search_on_categories_empty.json @@ -0,0 +1,6 @@ +{ + "batchcomplete": true, + "query": { + "allcategories": [] + } +} \ No newline at end of file diff --git a/githooks/commit-msg b/githooks/commit-msg new file mode 100644 index 0000000..22e766d --- /dev/null +++ b/githooks/commit-msg @@ -0,0 +1,10 @@ +#!/bin/sh + +# regex to validate in commit msg +commit_regex='(#[0-9]+|merge)' +error_msg="Aborting commit. Your commit message is missing either a issue number ('#9') or 'Merge'" + +if ! grep -iqE "$commit_regex" "$1"; then + echo "$error_msg" >&2 + exit 1 +fi diff --git a/githooks/setup_hook.sh b/githooks/setup_hook.sh new file mode 100644 index 0000000..6cab114 --- /dev/null +++ b/githooks/setup_hook.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +rm ../.git/hooks/commit-msg.sample +cp commit-msg ../.git/hooks/ +chmod +x ../.git/hooks/commit-msg