From 86878fb62dd85b3d302d13cdfe1109cda4d5bbdf Mon Sep 17 00:00:00 2001 From: Ujjwal Agrawal Date: Tue, 24 Jul 2018 12:47:24 +0530 Subject: [PATCH] Add feature to Browse commons via app (#1716) * Search activity, image search fragment added * Removed explore icon xml * Updated Javadocs for search Activity and Navigation Base Activity * SearchImageItem class updated * Javadocs added for search activity * removed redundant code and added javadocs for search image modules * Javadocs added for updateImageList method * Rename featuredImage to explore * Fixed null query issue * changed cisibility to gone in case of successful Fetch * Consolidate the networking libraries - drop volley in favor of OkHttp * Extracted a few networking related items into a new Dagger module and finished the process of mocking the main component for tests. * Refactoring to extract GpsCategoryModel and ensure single-responsibility-principle is maintained in CategoryApi. * fixed featured image back bug * Localisation updates from https://translatewiki.net. * Javadocs added * Add option to set image as wallpaper (#1535) * Add option to set image as wallpaper * Added java docs * Toast message on setting the wallpaper successfully * Localisation updates from https://translatewiki.net. * SearchHistory Fragment added * Search History Item added * Content Provider, RecentSearchesDao added * Database version changed to 7 and added methods for find, save ,.. * Delete icon deleted * Reverted changes in gradle files * Reverted changes in gradle files 2 * Optimized Improts * reverted refractor for container name * Refactored packagename, changed name to query in POJO class. * Updated lastUsed to lastSearched * Javadocs updated * Check if user has been in search page for 5 seconds if yes then save it to history * If else indentation updated * added import in test * edittext replaced with Searchview * RxSearchview added * Added support for API 21+ * Snackbar removed on success * Improved code * Pagination added * Removed unnecessary toast * Comment added in method * Support for landscape mode added * Fixed screen rotation issue on Explore and Search activity * Clear focus added * Delete all function added in Content Provider and called from fragment * Scrollbar Recyclerview added * Share Icon changed to 32 dp and back button added in explore, search activity. * Removed unnecessary code * Wrote and run tests for Recent Searches (creating db, migrating from versions, deletion, finding,.. * Category Search Fragment added * Adapter factory added * Renderer added * Improvements * Viewpager adapter added * Updated XML * Improvements in category card design * tabs colors changed * renamed images to media * Java docs improved * Javadoc added for setTabs * JavaDoc for ViewPagerAdapter added * Refreshed listview after delete * Added mediaContainer * Fixed ghost issue in image search fragment * Ghost issue for categories fixed * Removed Calling API call onback press * Category Details activity added * Menu added in category details activity * back button added * back button bugs * Improvements in category images fragment * JavaDoc added for some methods * trimming added, Tab layout hided, recent searches refreshed * SubCategory list fragment added, API added to extract subCategory Details * API params updated to get more precise results * Javadocs added for MWAPI method * Pagination removed * Fix API for fetching images inside category * Parent category API added * Fix #1704 * Fix #1704 corrected * Fix #1702 * Fix #1702 and #1704 * added try catch statements * Optimimzed imports * loops replaced with Functions * Javadocs for various methods added * Fix java docs for Dao * Javadocs for various methods added * Fix java docs for Dao * More javadocs added for explore Feature * Javadocs added * Javadocs added * Improvements in indentation (#1739) --- app/src/main/AndroidManifest.xml | 18 + .../main/java/fr/free/nrw/commons/Media.java | 12 +- .../category/CategoryDetailsActivity.java | 253 +++++++++++++++ .../commons/category/CategoryImageUtils.java | 21 +- .../category/CategoryImagesActivity.java | 96 +++++- .../category/CategoryImagesListFragment.java | 44 ++- .../nrw/commons/category/GridViewAdapter.java | 14 +- .../category/SubCategoryListFragment.java | 167 ++++++++++ .../free/nrw/commons/data/DBOpenHelper.java | 5 +- .../nrw/commons/di/ActivityBuilderModule.java | 8 + .../commons/di/CommonsApplicationModule.java | 13 + .../di/ContentProviderBuilderModule.java | 4 + .../nrw/commons/di/FragmentBuilderModule.java | 16 + .../nrw/commons/explore/SearchActivity.java | 243 ++++++++++++++ .../nrw/commons/explore/ViewPagerAdapter.java | 57 ++++ .../SearchCategoriesAdapterFactory.java | 32 ++ .../categories/SearchCategoriesRenderer.java | 56 ++++ .../categories/SearchCategoryFragment.java | 222 +++++++++++++ .../explore/images/SearchImageFragment.java | 245 ++++++++++++++ .../images/SearchImagesAdapterFactory.java | 35 ++ .../explore/images/SearchImagesRenderer.java | 75 +++++ .../explore/recentsearches/RecentSearch.java | 70 ++++ .../RecentSearchesContentProvider.java | 202 ++++++++++++ .../recentsearches/RecentSearchesDao.java | 235 ++++++++++++++ .../RecentSearchesFragment.java | 85 +++++ .../commons/media/MediaDetailFragment.java | 15 +- .../media/MediaDetailPagerFragment.java | 19 +- .../mwapi/ApacheHttpClientMediaWikiApi.java | 169 +++++++++- .../free/nrw/commons/mwapi/MediaWikiApi.java | 10 + .../commons/theme/NavigationBaseActivity.java | 25 +- .../ic_arrow_back_primary_24dp.png | Bin 0 -> 215 bytes .../drawable-hdpi/ic_delete_grey_700_24dp.png | Bin 0 -> 195 bytes .../ic_notifications_black_24dp.png | Bin 0 -> 282 bytes .../ic_notifications_none_black_24dp.png | Bin 0 -> 350 bytes .../drawable-hdpi/ic_search_white_24dp.png | Bin 0 -> 492 bytes .../res/drawable-ldpi/ic_explore_24dp.xml | 27 ++ .../ic_arrow_back_primary_24dp.png | Bin 0 -> 170 bytes .../drawable-mdpi/ic_delete_grey_700_24dp.png | Bin 0 -> 132 bytes .../ic_notifications_black_24dp.png | Bin 0 -> 213 bytes .../ic_notifications_none_black_24dp.png | Bin 0 -> 248 bytes .../drawable-mdpi/ic_search_white_24dp.png | Bin 0 -> 306 bytes .../ic_arrow_back_primary_24dp.png | Bin 0 -> 238 bytes .../ic_delete_grey_700_24dp.png | Bin 0 -> 194 bytes .../ic_notifications_black_24dp.png | Bin 0 -> 337 bytes .../ic_notifications_none_black_24dp.png | Bin 0 -> 429 bytes .../drawable-xhdpi/ic_search_white_24dp.png | Bin 0 -> 574 bytes .../ic_arrow_back_primary_24dp.png | Bin 0 -> 377 bytes .../ic_delete_grey_700_24dp.png | Bin 0 -> 276 bytes .../ic_notifications_black_24dp.png | Bin 0 -> 498 bytes .../ic_notifications_none_black_24dp.png | Bin 0 -> 638 bytes .../drawable-xxhdpi/ic_search_white_24dp.png | Bin 0 -> 899 bytes .../ic_arrow_back_primary_24dp.png | Bin 0 -> 366 bytes .../ic_delete_grey_700_24dp.png | Bin 0 -> 355 bytes .../ic_notifications_black_24dp.png | Bin 0 -> 633 bytes .../ic_notifications_none_black_24dp.png | Bin 0 -> 830 bytes .../drawable-xxxhdpi/ic_search_white_24dp.png | Bin 0 -> 1143 bytes .../main/res/drawable/ic_star_black_24dp.xml | 9 - .../res/layout/activity_category_details.xml | 61 ++++ app/src/main/res/layout/activity_search.xml | 93 ++++++ .../main/res/layout/fragment_browse_image.xml | 33 ++ .../res/layout/fragment_category_images.xml | 1 + .../res/layout/fragment_search_history.xml | 55 ++++ .../main/res/layout/item_recent_searches.xml | 9 + app/src/main/res/menu/drawer.xml | 6 +- .../res/menu/fragment_category_detail.xml | 9 + .../main/res/menu/fragment_image_detail.xml | 2 +- app/src/main/res/menu/menu_search.xml | 13 + app/src/main/res/values/strings.xml | 16 +- .../nrw/commons/TestCommonsApplication.kt | 1 - .../recentsearches/RecentSearchesDaoTest.kt | 307 ++++++++++++++++++ 70 files changed, 3030 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java create mode 100644 app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java create mode 100755 app/src/main/java/fr/free/nrw/commons/explore/ViewPagerAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesAdapterFactory.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesRenderer.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesAdapterFactory.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesRenderer.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearch.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesContentProvider.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java create mode 100644 app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java create mode 100644 app/src/main/res/drawable-hdpi/ic_arrow_back_primary_24dp.png create mode 100644 app/src/main/res/drawable-hdpi/ic_delete_grey_700_24dp.png create mode 100644 app/src/main/res/drawable-hdpi/ic_notifications_black_24dp.png create mode 100644 app/src/main/res/drawable-hdpi/ic_notifications_none_black_24dp.png create mode 100644 app/src/main/res/drawable-hdpi/ic_search_white_24dp.png create mode 100644 app/src/main/res/drawable-ldpi/ic_explore_24dp.xml create mode 100644 app/src/main/res/drawable-mdpi/ic_arrow_back_primary_24dp.png create mode 100644 app/src/main/res/drawable-mdpi/ic_delete_grey_700_24dp.png create mode 100644 app/src/main/res/drawable-mdpi/ic_notifications_black_24dp.png create mode 100644 app/src/main/res/drawable-mdpi/ic_notifications_none_black_24dp.png create mode 100644 app/src/main/res/drawable-mdpi/ic_search_white_24dp.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_arrow_back_primary_24dp.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_delete_grey_700_24dp.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_notifications_black_24dp.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_notifications_none_black_24dp.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_arrow_back_primary_24dp.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_delete_grey_700_24dp.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_notifications_black_24dp.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_notifications_none_black_24dp.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_arrow_back_primary_24dp.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_delete_grey_700_24dp.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_notifications_black_24dp.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_notifications_none_black_24dp.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png delete mode 100644 app/src/main/res/drawable/ic_star_black_24dp.xml create mode 100644 app/src/main/res/layout/activity_category_details.xml create mode 100644 app/src/main/res/layout/activity_search.xml create mode 100644 app/src/main/res/layout/fragment_browse_image.xml create mode 100644 app/src/main/res/layout/fragment_search_history.xml create mode 100644 app/src/main/res/layout/item_recent_searches.xml create mode 100644 app/src/main/res/menu/fragment_category_detail.xml create mode 100644 app/src/main/res/menu/menu_search.xml create mode 100644 app/src/test/kotlin/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDaoTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1c7961a48..5bd0dc523 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -97,6 +97,17 @@ android:label="@string/title_activity_featured_images" android:parentActivityName=".contributions.ContributionsActivity" /> + + + + + + diff --git a/app/src/main/java/fr/free/nrw/commons/Media.java b/app/src/main/java/fr/free/nrw/commons/Media.java index 5f6a498ea..04f097d08 100644 --- a/app/src/main/java/fr/free/nrw/commons/Media.java +++ b/app/src/main/java/fr/free/nrw/commons/Media.java @@ -3,13 +3,17 @@ package fr.free.nrw.commons; import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import java.util.ArrayList; +import java.util.Collection; import java.util.Date; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -356,12 +360,8 @@ public class Media implements Parcelable { * @param descriptions Media descriptions */ void setDescriptions(Map descriptions) { - for (String key : this.descriptions.keySet()) { - this.descriptions.remove(key); - } - for (String key : descriptions.keySet()) { - this.descriptions.put(key, descriptions.get(key)); - } + this.descriptions.clear(); + this.descriptions.putAll(descriptions); } /** diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java new file mode 100644 index 000000000..3ab3c2c07 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java @@ -0,0 +1,253 @@ +package fr.free.nrw.commons.category; + +import android.content.Context; +import android.content.Intent; +import android.database.DataSetObserver; +import android.os.Bundle; +import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.view.ViewPager; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.FrameLayout; +import android.widget.Toast; + +import java.util.ArrayList; +import java.util.List; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.PageTitle; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.explore.ViewPagerAdapter; +import fr.free.nrw.commons.media.MediaDetailPagerFragment; +import fr.free.nrw.commons.theme.NavigationBaseActivity; + +import static android.widget.Toast.LENGTH_SHORT; + +/** + * This activity displays details of a particular category + * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in + * a particular category on wikimedia commons. + */ + +public class CategoryDetailsActivity extends NavigationBaseActivity + implements MediaDetailPagerFragment.MediaDetailProvider, + AdapterView.OnItemClickListener{ + + + private FragmentManager supportFragmentManager; + private CategoryImagesListFragment categoryImagesListFragment; + private MediaDetailPagerFragment mediaDetails; + private String categoryName; + @BindView(R.id.mediaContainer) FrameLayout mediaContainer; + @BindView(R.id.tabLayout) TabLayout tabLayout; + @BindView(R.id.viewPager) ViewPager viewPager; + + ViewPagerAdapter viewPagerAdapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_category_details); + ButterKnife.bind(this); + supportFragmentManager = getSupportFragmentManager(); + viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager()); + viewPager.setAdapter(viewPagerAdapter); + viewPager.setOffscreenPageLimit(2); + tabLayout.setupWithViewPager(viewPager); + setTabs(); + setPageTitle(); + initDrawer(); + forceInitBackButton(); + } + + /** + * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab, + * Set the fragments according to the tab selected in the viewPager. + */ + private void setTabs() { + List fragmentList = new ArrayList<>(); + List titleList = new ArrayList<>(); + categoryImagesListFragment = new CategoryImagesListFragment(); + SubCategoryListFragment subCategoryListFragment = new SubCategoryListFragment(); + SubCategoryListFragment parentCategoryListFragment = new SubCategoryListFragment(); + categoryName = getIntent().getStringExtra("categoryName"); + if (getIntent() != null && categoryName != null) { + Bundle arguments = new Bundle(); + arguments.putString("categoryName", categoryName); + arguments.putBoolean("isParentCategory", false); + categoryImagesListFragment.setArguments(arguments); + subCategoryListFragment.setArguments(arguments); + Bundle parentCategoryArguments = new Bundle(); + parentCategoryArguments.putString("categoryName", categoryName); + parentCategoryArguments.putBoolean("isParentCategory", true); + parentCategoryListFragment.setArguments(parentCategoryArguments); + } + fragmentList.add(categoryImagesListFragment); + titleList.add("MEDIA"); + fragmentList.add(subCategoryListFragment); + titleList.add("SUBCATEGORIES"); + fragmentList.add(parentCategoryListFragment); + titleList.add("PARENT CATEGORIES"); + viewPagerAdapter.setTabData(fragmentList, titleList); + viewPagerAdapter.notifyDataSetChanged(); + + } + + /** + * Gets the passed categoryName from the intents and displays it as the page title + */ + private void setPageTitle() { + if (getIntent() != null && getIntent().getStringExtra("categoryName") != null) { + setTitle(getIntent().getStringExtra("categoryName")); + } + } + + /** + * This method is called onClick of media inside category details (CategoryImageListFragment). + */ + @Override + public void onItemClick(AdapterView adapterView, View view, int i, long l) { + tabLayout.setVisibility(View.GONE); + viewPager.setVisibility(View.GONE); + mediaContainer.setVisibility(View.VISIBLE); + if (mediaDetails == null || !mediaDetails.isVisible()) { + // set isFeaturedImage true for featured images, to include author field on media detail + mediaDetails = new MediaDetailPagerFragment(false, true); + FragmentManager supportFragmentManager = getSupportFragmentManager(); + supportFragmentManager + .beginTransaction() + .replace(R.id.mediaContainer, mediaDetails) + .addToBackStack(null) + .commit(); + supportFragmentManager.executePendingTransactions(); + } + mediaDetails.showImage(i); + forceInitBackButton(); + } + + + /** + * Consumers should be simply using this method to use this activity. + * @param context A Context of the application package implementing this class. + * @param categoryName Name of the category for displaying its details + */ + public static void startYourself(Context context, String categoryName) { + Intent intent = new Intent(context, CategoryDetailsActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + intent.putExtra("categoryName", categoryName); + context.startActivity(intent); + } + + /** + * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index + * @param i It is the index of which media object is to be returned which is same as + * current index of viewPager. + * @return Media Object + */ + @Override + public Media getMediaAtPosition(int i) { + if (categoryImagesListFragment.getAdapter() == null) { + // not yet ready to return data + return null; + } else { + return (Media) categoryImagesListFragment.getAdapter().getItem(i); + } + } + + /** + * This method is called on from getCount of MediaDetailPagerFragment + * The viewpager will contain same number of media items as that of media elements in adapter. + * @return Total Media count in the adapter + */ + @Override + public int getTotalMediaCount() { + if (categoryImagesListFragment.getAdapter() == null) { + return 0; + } + return categoryImagesListFragment.getAdapter().getCount(); + } + + /** + * This method is never called but it was in MediaDetailProvider Interface + * so it needs to be overrided. + */ + @Override + public void notifyDatasetChanged() { + + } + + /** + * This method is never called but it was in MediaDetailProvider Interface + * so it needs to be overrided. + */ + @Override + public void registerDataSetObserver(DataSetObserver observer) { + } + + /** + * This method is never called but it was in MediaDetailProvider Interface + * so it needs to be overrided. + */ + @Override + public void unregisterDataSetObserver(DataSetObserver observer) { + + } + + /** + * This method inflates the menu in the toolbar + */ + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.fragment_category_detail, menu); + return super.onCreateOptionsMenu(menu); + } + + /** + * This method handles the logic on ItemSelect in toolbar menu + * Currently only 1 choice is available to open category details page in browser + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + + // Handle item selection + switch (item.getItemId()) { + case R.id.menu_browser_current_category: + Intent viewIntent = new Intent(); + viewIntent.setAction(Intent.ACTION_VIEW); + viewIntent.setData(new PageTitle(categoryName).getCanonicalUri()); + //check if web browser available + if (viewIntent.resolveActivity(this.getPackageManager()) != null) { + startActivity(viewIntent); + } else { + Toast toast = Toast.makeText(this, getString(R.string.no_web_browser), LENGTH_SHORT); + toast.show(); + } + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + /** + * This method is called on backPressed of anyFragment in the activity. + * If condition is called when mediaDetailFragment is opened. + */ + @Override + public void onBackPressed() { + if (supportFragmentManager.getBackStackEntryCount() == 1){ + // back to search so show search toolbar and hide navigation toolbar + tabLayout.setVisibility(View.VISIBLE); + viewPager.setVisibility(View.VISIBLE); + mediaContainer.setVisibility(View.GONE); + } + super.onBackPressed(); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java index 18749847e..941201235 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImageUtils.java @@ -8,6 +8,7 @@ import org.w3c.dom.NodeList; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.List; @@ -27,12 +28,30 @@ public class CategoryImageUtils { List categoryImages = new ArrayList<>(); for (int i = 0; i < childNodes.getLength(); i++) { Node node = childNodes.item(i); - categoryImages.add(getMediaFromPage(node)); + if (getMediaFromPage(node).getFilename().substring(0,5).equals("File:")){ + categoryImages.add(getMediaFromPage(node)); + } } return categoryImages; } + /** + * The method iterates over the child nodes to return a list of Subcategory name + * sorted alphabetically + * @param childNodes + * @return + */ + public static List getSubCategoryList(NodeList childNodes) { + List subCategories = new ArrayList<>(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + subCategories.add(getMediaFromPage(node).getFilename()); + } + Collections.sort(subCategories); + return subCategories; + } + /** * Creates a new Media object from the XML response as received by the API * @param node diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java index 17601151c..382134472 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesActivity.java @@ -6,6 +6,9 @@ import android.database.DataSetObserver; import android.os.Bundle; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentTransaction; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; import android.view.View; import android.widget.AdapterView; @@ -13,7 +16,9 @@ import butterknife.ButterKnife; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.AuthenticatedActivity; +import fr.free.nrw.commons.explore.SearchActivity; import fr.free.nrw.commons.media.MediaDetailPagerFragment; +import fr.free.nrw.commons.theme.NavigationBaseActivity; /** * This activity displays pictures of a particular category @@ -43,6 +48,16 @@ public class CategoryImagesActivity } + /** + * This method is called on backPressed of anyFragment in the activity. + * We are changing the icon here from back to hamburger icon. + */ + @Override + public void onBackPressed() { + initDrawer(); + super.onBackPressed(); + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -54,11 +69,6 @@ public class CategoryImagesActivity supportFragmentManager = getSupportFragmentManager(); setCategoryImagesFragment(); supportFragmentManager.addOnBackStackChangedListener(this); - if (savedInstanceState != null) { - mediaDetails = (MediaDetailPagerFragment) supportFragmentManager - .findFragmentById(R.id.fragmentContainer); - - } requestAuthToken(); initDrawer(); setPageTitle(); @@ -94,6 +104,9 @@ public class CategoryImagesActivity public void onBackStackChanged() { } + /** + * This method is called onClick of media inside category details (CategoryImageListFragment). + */ @Override public void onItemClick(AdapterView adapterView, View view, int i, long l) { if (mediaDetails == null || !mediaDetails.isVisible()) { @@ -102,17 +115,34 @@ public class CategoryImagesActivity FragmentManager supportFragmentManager = getSupportFragmentManager(); supportFragmentManager .beginTransaction() - .replace(R.id.fragmentContainer, mediaDetails) + .hide(supportFragmentManager.getFragments().get(supportFragmentManager.getBackStackEntryCount())) + .add(R.id.fragmentContainer, mediaDetails) .addToBackStack(null) .commit(); - supportFragmentManager.executePendingTransactions(); + // Reason for using hide, add instead of replace is to maintain scroll position after + // coming back to the search activity. See https://github.com/commons-app/apps-android-commons/issues/1631 + // https://stackoverflow.com/questions/11353075/how-can-i-maintain-fragment-state-when-added-to-the-back-stack/19022550#19022550 supportFragmentManager.executePendingTransactions(); } mediaDetails.showImage(i); + forceInitBackButton(); + } + + /** + * This method is called on backPressed when mediaDetailFragment is opened in the activity. + */ + @Override + protected void onResume() { + if (supportFragmentManager.getBackStackEntryCount()==1){ + //FIXME: Temporary fix for screen rotation inside media details. If we don't call onBackPressed then fragment stack is increasing every time. + //FIXME: Similar issue like this https://github.com/commons-app/apps-android-commons/issues/894 + onBackPressed(); + } + super.onResume(); } /** * Consumers should be simply using this method to use this activity. - * @param context + * @param context A Context of the application package implementing this class. * @param title Page title * @param categoryName Name of the category for displaying its images */ @@ -124,6 +154,12 @@ public class CategoryImagesActivity context.startActivity(intent); } + /** + * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index + * @param i It is the index of which media object is to be returned which is same as + * current index of viewPager. + * @return Media Object + */ @Override public Media getMediaAtPosition(int i) { if (categoryImagesListFragment.getAdapter() == null) { @@ -134,6 +170,11 @@ public class CategoryImagesActivity } } + /** + * This method is called on from getCount of MediaDetailPagerFragment + * The viewpager will contain same number of media items as that of media elements in adapter. + * @return Total Media count in the adapter + */ @Override public int getTotalMediaCount() { if (categoryImagesListFragment.getAdapter() == null) { @@ -142,18 +183,57 @@ public class CategoryImagesActivity return categoryImagesListFragment.getAdapter().getCount(); } + /** + * This method is never called but it was in MediaDetailProvider Interface + * so it needs to be overrided. + */ @Override public void notifyDatasetChanged() { } + /** + * This method is never called but it was in MediaDetailProvider Interface + * so it needs to be overrided. + */ @Override public void registerDataSetObserver(DataSetObserver observer) { } + /** + * This method is never called but it was in MediaDetailProvider Interface + * so it needs to be overrided. + */ @Override public void unregisterDataSetObserver(DataSetObserver observer) { } + + /** + * This method inflates the menu in the toolbar + */ + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_search, menu); + return super.onCreateOptionsMenu(menu); + } + + /** + * This method handles the logic on ItemSelect in toolbar menu + * Currently only 1 choice is available to open search page of the app + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + + // Handle item selection + switch (item.getItemId()) { + case R.id.action_search: + NavigationBaseActivity.startActivityWithFlags(this, SearchActivity.class); + return true; + default: + return super.onOptionsItemSelected(item); + } + } } diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java index a44e19a29..385662a05 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java @@ -12,7 +12,9 @@ import android.widget.AdapterView; import android.widget.GridView; import android.widget.ListAdapter; import android.widget.ProgressBar; +import android.widget.RelativeLayout; import android.widget.TextView; +import android.widget.Toast; import java.util.List; import java.util.concurrent.TimeUnit; @@ -48,9 +50,9 @@ public class CategoryImagesListFragment extends DaggerFragment { TextView statusTextView; @BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar; @BindView(R.id.categoryImagesList) GridView gridView; - + @BindView(R.id.parentLayout) RelativeLayout parentLayout; private boolean hasMoreImages = true; - private boolean isLoading; + private boolean isLoading = true; private String categoryName = null; @Inject CategoryImageController controller; @@ -123,7 +125,7 @@ public class CategoryImagesListFragment extends DaggerFragment { statusTextView.setVisibility(VISIBLE); statusTextView.setText(getString(R.string.no_internet)); } else { - ViewUtil.showSnackbar(gridView, R.string.no_internet); + ViewUtil.showSnackbar(parentLayout, R.string.no_internet); } } @@ -132,15 +134,20 @@ public class CategoryImagesListFragment extends DaggerFragment { * @param throwable */ private void handleError(Throwable throwable) { - Timber.e(throwable, "Error occurred while loading featured images"); - initErrorView(); + Timber.e(throwable, "Error occurred while loading images inside a category"); + try{ + ViewUtil.showSnackbar(parentLayout, R.string.error_loading_images); + initErrorView(); + }catch (Exception e){ + e.printStackTrace(); + } + } /** * Handles the UI updates for a error scenario */ private void initErrorView() { - ViewUtil.showSnackbar(gridView, R.string.error_loading_images); progressBar.setVisibility(GONE); if (gridAdapter == null || gridAdapter.isEmpty()) { statusTextView.setVisibility(VISIBLE); @@ -152,7 +159,7 @@ public class CategoryImagesListFragment extends DaggerFragment { /** * Initializes the adapter with a list of Media objects - * @param mediaList + * @param mediaList List of new Media to be displayed */ private void setAdapter(List mediaList) { gridAdapter = new GridViewAdapter(this.getContext(), R.layout.layout_category_images, mediaList); @@ -176,6 +183,9 @@ public class CategoryImagesListFragment extends DaggerFragment { isLoading = true; fetchMoreImages(); } + if (!hasMoreImages){ + progressBar.setVisibility(GONE); + } } }); } @@ -201,7 +211,7 @@ public class CategoryImagesListFragment extends DaggerFragment { /** * Handles the success scenario * On first load, it initializes the grid view. On subsequent loads, it adds items to the adapter - * @param collection + * @param collection List of new Media to be displayed */ private void handleSuccess(List collection) { if(collection == null || collection.isEmpty()) { @@ -213,6 +223,10 @@ public class CategoryImagesListFragment extends DaggerFragment { if(gridAdapter == null) { setAdapter(collection); } else { + if (gridAdapter.containsAll(collection)) { + hasMoreImages = false; + return; + } gridAdapter.addItems(collection); } @@ -221,17 +235,13 @@ public class CategoryImagesListFragment extends DaggerFragment { statusTextView.setVisibility(GONE); } + /** + * It return an instance of gridView adapter which helps in extracting media details + * used by the gridView + * @return GridView Adapter + */ public ListAdapter getAdapter() { return gridView.getAdapter(); } - /** - * This method will be called on back pressed of CategoryImagesActivity. - * It initializes the grid view by setting adapter. - */ - @Override - public void onResume() { - gridView.setAdapter(gridAdapter); - super.onResume(); - } } diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java index c8e6066f6..f8c54905e 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java @@ -42,6 +42,18 @@ public class GridViewAdapter extends ArrayAdapter { notifyDataSetChanged(); } + /** + * Check the first item in the new list with old list and returns true if they are same + * Its triggered on successful response of the fetch images API. + * @param images + */ + public boolean containsAll(List images){ + if (data == null) { + data = new ArrayList<>(); + } + return images.get(0).getFilename().equals(data.get(0).getFilename()); + } + @Override public boolean isEmpty() { return data == null || data.isEmpty(); @@ -66,7 +78,7 @@ public class GridViewAdapter extends ArrayAdapter { MediaWikiImageView imageView = convertView.findViewById(R.id.categoryImageView); TextView fileName = convertView.findViewById(R.id.categoryImageTitle); TextView author = convertView.findViewById(R.id.categoryImageAuthor); - fileName.setText(item.getFilename()); + fileName.setText(item.getDisplayTitle()); setAuthorView(item, author); imageView.setMedia(item); return convertView; diff --git a/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java b/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java new file mode 100644 index 000000000..d3000e9b6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/SubCategoryListFragment.java @@ -0,0 +1,167 @@ +package fr.free.nrw.commons.category; + + +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Bundle; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.pedrogomez.renderers.RVRendererAdapter; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; +import fr.free.nrw.commons.explore.categories.SearchCategoriesAdapterFactory; +import fr.free.nrw.commons.mwapi.MediaWikiApi; +import fr.free.nrw.commons.utils.NetworkUtils; +import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +/** + * Displays the category search screen. + */ + +public class SubCategoryListFragment extends CommonsDaggerSupportFragment { + + private static int TIMEOUT_SECONDS = 15; + + @BindView(R.id.imagesListBox) + RecyclerView categoriesRecyclerView; + @BindView(R.id.imageSearchInProgress) + ProgressBar progressBar; + @BindView(R.id.imagesNotFound) + TextView categoriesNotFoundView; + + private String categoryName = null; + @Inject MediaWikiApi mwApi; + + private RVRendererAdapter categoriesAdapter; + private boolean isParentCategory = true; + + private final SearchCategoriesAdapterFactory adapterFactory = new SearchCategoriesAdapterFactory(item -> { + // Open SubCategory Details page + Intent intent = new Intent(getContext(), CategoryDetailsActivity.class); + intent.putExtra("categoryName", item); + getContext().startActivity(intent); + + }); + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_browse_image, container, false); + ButterKnife.bind(this, rootView); + categoryName = getArguments().getString("categoryName"); + isParentCategory = getArguments().getBoolean("isParentCategory"); + initSubCategoryList(); + if(getActivity().getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT){ + categoriesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + } + else{ + categoriesRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2)); + } + ArrayList items = new ArrayList<>(); + categoriesAdapter = adapterFactory.create(items); + categoriesRecyclerView.setAdapter(categoriesAdapter); + return rootView; + } + + /** + * Checks for internet connection and then initializes the recycler view with 25 categories of the searched query + * Clearing categoryAdapter every time new keyword is searched so that user can see only new results + */ + public void initSubCategoryList() { + categoriesNotFoundView.setVisibility(GONE); + if(!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + return; + } + progressBar.setVisibility(View.VISIBLE); + if (!isParentCategory){ + Observable.fromCallable(() -> mwApi.getSubCategoryList(categoryName)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(this::handleSuccess, this::handleError); + }else { + Observable.fromCallable(() -> mwApi.getParentCategoryList(categoryName)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(this::handleSuccess, this::handleError); + } + } + + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + * @param subCategoryList + */ + private void handleSuccess(List subCategoryList) { + if(subCategoryList == null || subCategoryList.isEmpty()) { + initEmptyView(); + } + else { + progressBar.setVisibility(View.GONE); + categoriesAdapter.addAll(subCategoryList); + categoriesAdapter.notifyDataSetChanged(); + } + } + + /** + * Logs and handles API error scenario + * @param throwable + */ + private void handleError(Throwable throwable) { + if (!isParentCategory){ + Timber.e(throwable, "Error occurred while loading queried subcategories"); + ViewUtil.showSnackbar(categoriesRecyclerView,R.string.error_loading_categories); + }else { + Timber.e(throwable, "Error occurred while loading queried parentcategories"); + ViewUtil.showSnackbar(categoriesRecyclerView,R.string.error_loading_categories); + } + } + + /** + * Handles the UI updates for a empty results scenario + */ + private void initEmptyView() { + progressBar.setVisibility(GONE); + categoriesNotFoundView.setVisibility(VISIBLE); + if (!isParentCategory){ + categoriesNotFoundView.setText(getString(R.string.no_subcategory_found)); + }else { + categoriesNotFoundView.setText(getString(R.string.no_parentcategory_found)); + } + + } + + /** + * Handles the UI updates for no internet scenario + */ + private void handleNoInternet() { + progressBar.setVisibility(GONE); + ViewUtil.showSnackbar(categoriesRecyclerView, R.string.no_internet); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java index 35305c5ba..b019a6303 100644 --- a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java +++ b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.java @@ -6,12 +6,13 @@ import android.database.sqlite.SQLiteOpenHelper; import fr.free.nrw.commons.category.CategoryDao; import fr.free.nrw.commons.contributions.ContributionDao; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao; import fr.free.nrw.commons.modifications.ModifierSequenceDao; public class DBOpenHelper extends SQLiteOpenHelper { private static final String DATABASE_NAME = "commons.db"; - private static final int DATABASE_VERSION = 6; + private static final int DATABASE_VERSION = 7; /** * Do not use directly - @Inject an instance where it's needed and let @@ -26,6 +27,7 @@ public class DBOpenHelper extends SQLiteOpenHelper { ContributionDao.Table.onCreate(sqLiteDatabase); ModifierSequenceDao.Table.onCreate(sqLiteDatabase); CategoryDao.Table.onCreate(sqLiteDatabase); + RecentSearchesDao.Table.onCreate(sqLiteDatabase); } @Override @@ -33,5 +35,6 @@ public class DBOpenHelper extends SQLiteOpenHelper { ContributionDao.Table.onUpdate(sqLiteDatabase, from, to); ModifierSequenceDao.Table.onUpdate(sqLiteDatabase, from, to); CategoryDao.Table.onUpdate(sqLiteDatabase, from, to); + RecentSearchesDao.Table.onUpdate(sqLiteDatabase, from, to); } } diff --git a/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java index 51aa85903..48248225e 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/ActivityBuilderModule.java @@ -6,8 +6,10 @@ import fr.free.nrw.commons.AboutActivity; import fr.free.nrw.commons.WelcomeActivity; import fr.free.nrw.commons.auth.LoginActivity; import fr.free.nrw.commons.auth.SignupActivity; +import fr.free.nrw.commons.category.CategoryDetailsActivity; import fr.free.nrw.commons.contributions.ContributionsActivity; import fr.free.nrw.commons.category.CategoryImagesActivity; +import fr.free.nrw.commons.explore.SearchActivity; import fr.free.nrw.commons.nearby.NearbyActivity; import fr.free.nrw.commons.notification.NotificationActivity; import fr.free.nrw.commons.settings.SettingsActivity; @@ -50,4 +52,10 @@ public abstract class ActivityBuilderModule { @ContributesAndroidInjector abstract CategoryImagesActivity bindFeaturedImagesActivity(); + + @ContributesAndroidInjector + abstract SearchActivity bindSearchActivity(); + + @ContributesAndroidInjector + abstract CategoryDetailsActivity bindCategoryDetailsActivity(); } diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java index af9841f81..39a9dac82 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java @@ -24,6 +24,7 @@ import fr.free.nrw.commons.wikidata.WikidataEditListenerImpl; import static android.content.Context.MODE_PRIVATE; import static fr.free.nrw.commons.contributions.ContributionsContentProvider.CONTRIBUTION_AUTHORITY; +import static fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider.RECENT_SEARCH_AUTHORITY; import static fr.free.nrw.commons.modifications.ModificationsContentProvider.MODIFICATIONS_AUTHORITY; @Module @@ -53,6 +54,18 @@ public class CommonsApplicationModule { return context.getContentResolver().acquireContentProviderClient(CATEGORY_AUTHORITY); } + /** + * This method is used to provide instance of RecentSearchContentProviderClient + * which provides content of Recent Searches from database + * @param context + * @return returns RecentSearchContentProviderClient + */ + @Provides + @Named("recentsearch") + public ContentProviderClient provideRecentSearchContentProviderClient(Context context) { + return context.getContentResolver().acquireContentProviderClient(RECENT_SEARCH_AUTHORITY); + } + @Provides @Named("contribution") public ContentProviderClient provideContributionContentProviderClient(Context context) { diff --git a/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java index f18c331c5..0db0ff7fb 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java @@ -4,6 +4,7 @@ import dagger.Module; import dagger.android.ContributesAndroidInjector; import fr.free.nrw.commons.category.CategoryContentProvider; import fr.free.nrw.commons.contributions.ContributionsContentProvider; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider; import fr.free.nrw.commons.modifications.ModificationsContentProvider; @Module @@ -19,4 +20,7 @@ public abstract class ContentProviderBuilderModule { @ContributesAndroidInjector abstract CategoryContentProvider bindCategoryContentProvider(); + @ContributesAndroidInjector + abstract RecentSearchesContentProvider bindRecentSearchesContentProvider(); + } diff --git a/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java index dfed64871..b14d8feef 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java @@ -3,8 +3,12 @@ package fr.free.nrw.commons.di; import dagger.Module; import dagger.android.ContributesAndroidInjector; import fr.free.nrw.commons.category.CategorizationFragment; +import fr.free.nrw.commons.category.SubCategoryListFragment; import fr.free.nrw.commons.contributions.ContributionsListFragment; import fr.free.nrw.commons.category.CategoryImagesListFragment; +import fr.free.nrw.commons.explore.categories.SearchCategoryFragment; +import fr.free.nrw.commons.explore.images.SearchImageFragment; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesFragment; import fr.free.nrw.commons.media.MediaDetailFragment; import fr.free.nrw.commons.media.MediaDetailPagerFragment; import fr.free.nrw.commons.nearby.NearbyListFragment; @@ -51,4 +55,16 @@ public abstract class FragmentBuilderModule { @ContributesAndroidInjector abstract CategoryImagesListFragment bindFeaturedImagesListFragment(); + @ContributesAndroidInjector + abstract SubCategoryListFragment bindSubCategoryListFragment(); + + @ContributesAndroidInjector + abstract SearchImageFragment bindBrowseImagesListFragment(); + + @ContributesAndroidInjector + abstract SearchCategoryFragment bindSearchCategoryListFragment(); + + @ContributesAndroidInjector + abstract RecentSearchesFragment bindRecentSearchesFragment(); + } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java new file mode 100644 index 000000000..f28065eb5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java @@ -0,0 +1,243 @@ +package fr.free.nrw.commons.explore; + +import android.database.DataSetObserver; +import android.os.Bundle; +import android.support.design.widget.TabLayout; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.view.ViewPager; +import android.support.v7.widget.Toolbar; +import android.text.TextUtils; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.SearchView; +import android.widget.Toast; + +import com.jakewharton.rxbinding2.view.RxView; +import com.jakewharton.rxbinding2.widget.RxSearchView; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.explore.categories.SearchCategoryFragment; +import fr.free.nrw.commons.explore.images.SearchImageFragment; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesFragment; +import fr.free.nrw.commons.media.MediaDetailPagerFragment; +import fr.free.nrw.commons.theme.NavigationBaseActivity; +import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.android.schedulers.AndroidSchedulers; + +/** + * Represents search screen of this app + */ + +public class SearchActivity extends NavigationBaseActivity implements MediaDetailPagerFragment.MediaDetailProvider{ + + @BindView(R.id.toolbar_search) Toolbar toolbar; + @BindView(R.id.searchHistoryContainer) FrameLayout searchHistoryContainer; + @BindView(R.id.mediaContainer) FrameLayout mediaContainer; + @BindView(R.id.searchBox) SearchView searchView; + @BindView(R.id.tabLayout) TabLayout tabLayout; + @BindView(R.id.viewPager) ViewPager viewPager; + + private SearchImageFragment searchImageFragment; + private SearchCategoryFragment searchCategoryFragment; + private RecentSearchesFragment recentSearchesFragment; + private FragmentManager supportFragmentManager; + private MediaDetailPagerFragment mediaDetails; + ViewPagerAdapter viewPagerAdapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_search); + ButterKnife.bind(this); + initDrawer(); + setTitle(getString(R.string.title_activity_search)); + toolbar.setNavigationOnClickListener(v->onBackPressed()); + supportFragmentManager = getSupportFragmentManager(); + setSearchHistoryFragment(); + viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager()); + viewPager.setAdapter(viewPagerAdapter); + tabLayout.setupWithViewPager(viewPager); + setTabs(); + searchView.setQueryHint(getString(R.string.search_commons)); + searchView.onActionViewExpanded(); + searchView.clearFocus(); + + } + + /** + * This method sets the search history fragment. + * Search history fragment is displayed when query is empty. + */ + private void setSearchHistoryFragment() { + recentSearchesFragment = new RecentSearchesFragment(); + FragmentTransaction transaction = supportFragmentManager.beginTransaction(); + transaction.add(R.id.searchHistoryContainer, recentSearchesFragment).commit(); + } + + /** + * Sets the titles in the tabLayout and fragments in the viewPager + */ + public void setTabs() { + List fragmentList = new ArrayList<>(); + List titleList = new ArrayList<>(); + searchImageFragment = new SearchImageFragment(); + searchCategoryFragment= new SearchCategoryFragment(); + fragmentList.add(searchImageFragment); + titleList.add("MEDIA"); + fragmentList.add(searchCategoryFragment); + titleList.add("CATEGORIES"); + + viewPagerAdapter.setTabData(fragmentList, titleList); + viewPagerAdapter.notifyDataSetChanged(); + RxSearchView.queryTextChanges(searchView) + .takeUntil(RxView.detaches(searchView)) + .debounce(500, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( query -> { + //update image list + if (!TextUtils.isEmpty(query)) { + viewPager.setVisibility(View.VISIBLE); + tabLayout.setVisibility(View.VISIBLE); + searchHistoryContainer.setVisibility(View.GONE); + searchImageFragment.updateImageList(query.toString()); + searchCategoryFragment.updateCategoryList(query.toString()); + }else { + viewPager.setVisibility(View.GONE); + tabLayout.setVisibility(View.GONE); + searchHistoryContainer.setVisibility(View.VISIBLE); + recentSearchesFragment.updateRecentSearches(); + // open search history fragment + } + } + ); + } + + /** + * returns Media Object at position + * @param i position of Media in the imagesRecyclerView adapter. + */ + @Override + public Media getMediaAtPosition(int i) { + return searchImageFragment.getImageAtPosition(i); + } + + /** + * returns total number of images present in the imagesRecyclerView adapter. + */ + @Override + public int getTotalMediaCount() { + return searchImageFragment.getTotalImagesCount(); + } + + /** + * This method is never called but it was in MediaDetailProvider Interface + * so it needs to be overrided. + */ + @Override + public void notifyDatasetChanged() { + + } + + /** + * This method is never called but it was in MediaDetailProvider Interface + * so it needs to be overrided. + */ + @Override + public void registerDataSetObserver(DataSetObserver observer) { + + } + + /** + * This method is never called but it was in MediaDetailProvider Interface + * so it needs to be overrided. + */ + @Override + public void unregisterDataSetObserver(DataSetObserver observer) { + + } + + /** + * Open media detail pager fragment on click of image in search results + * @param index item index that should be opened + */ + public void onSearchImageClicked(int index) { + ViewUtil.hideKeyboard(this.findViewById(R.id.searchBox)); + toolbar.setVisibility(View.GONE); + tabLayout.setVisibility(View.GONE); + viewPager.setVisibility(View.GONE); + mediaContainer.setVisibility(View.VISIBLE); + setNavigationBaseToolbarVisibility(true); + if (mediaDetails == null || !mediaDetails.isVisible()) { + // set isFeaturedImage true for featured images, to include author field on media detail + mediaDetails = new MediaDetailPagerFragment(false, true); + FragmentManager supportFragmentManager = getSupportFragmentManager(); + supportFragmentManager + .beginTransaction() + .hide(supportFragmentManager.getFragments().get(supportFragmentManager.getBackStackEntryCount())) + .add(R.id.mediaContainer, mediaDetails) + .addToBackStack(null) + .commit(); + // Reason for using hide, add instead of replace is to maintain scroll position after + // coming back to the search activity. See https://github.com/commons-app/apps-android-commons/issues/1631 + // https://stackoverflow.com/questions/11353075/how-can-i-maintain-fragment-state-when-added-to-the-back-stack/19022550#19022550 + supportFragmentManager.executePendingTransactions(); + } + mediaDetails.showImage(index); + forceInitBackButton(); + } + + /** + * This method is called on Screen Rotation + */ + @Override + protected void onResume() { + if (supportFragmentManager.getBackStackEntryCount()==1){ + //FIXME: Temporary fix for screen rotation inside media details. If we don't call onBackPressed then fragment stack is increasing every time. + //FIXME: Similar issue like this https://github.com/commons-app/apps-android-commons/issues/894 + // This is called on screen rotation when user is inside media details. Ideally it should show Media Details but since we are not saving the state now. We are throwing the user to search screen otherwise the app was crashing. + // + onBackPressed(); + } + super.onResume(); + } + + /** + * This method is called on backPressed of anyFragment in the activity. + * If condition is called when mediaDetailFragment is opened. + */ + @Override + public void onBackPressed() { + if (getSupportFragmentManager().getBackStackEntryCount() == 1){ + // back to search so show search toolbar and hide navigation toolbar + toolbar.setVisibility(View.VISIBLE); + tabLayout.setVisibility(View.VISIBLE); + viewPager.setVisibility(View.VISIBLE); + mediaContainer.setVisibility(View.GONE); + setNavigationBaseToolbarVisibility(false); + }else { + toolbar.setVisibility(View.GONE); + setNavigationBaseToolbarVisibility(true); + } + super.onBackPressed(); + } + + /** + * This method is called on click of a recent search to update query in SearchView. + * @param query Recent Search Query + */ + public void updateText(String query) { + searchView.setQuery(query, true); + // Clear focus of searchView now. searchView.clearFocus(); does not seem to work Check the below link for more details. + // https://stackoverflow.com/questions/6117967/how-to-remove-focus-without-setting-focus-to-another-control/15481511 + viewPager.requestFocus(); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/ViewPagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/explore/ViewPagerAdapter.java new file mode 100755 index 000000000..5ebbd4f37 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/ViewPagerAdapter.java @@ -0,0 +1,57 @@ +package fr.free.nrw.commons.explore; + +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; + +import java.util.ArrayList; +import java.util.List; + +/** + * This adapter will be used to display fragments in a ViewPager + */ +public class ViewPagerAdapter extends FragmentPagerAdapter { + private List fragmentList = new ArrayList<>(); + private List fragmentTitleList = new ArrayList<>(); + + public ViewPagerAdapter(FragmentManager manager) { + super(manager); + } + + /** + * This method returns the fragment of the viewpager at a particular position + * @param position + */ + @Override + public Fragment getItem(int position) { + return fragmentList.get(position); + } + + /** + * This method returns the total number of fragments in the viewpager. + * @return size + */ + @Override + public int getCount() { + return fragmentList.size(); + } + + /** + * This method sets the fragment and title list in the viewpager + * @param fragmentList List of all fragments to be displayed in the viewpager + * @param fragmentTitleList List of all titles of the fragments + */ + public void setTabData(List fragmentList, List fragmentTitleList) { + this.fragmentList = fragmentList; + this.fragmentTitleList = fragmentTitleList; + } + + /** + * This method returns the title of the page at a particular position + * @param position + */ + @Override + public CharSequence getPageTitle(int position) { + return fragmentTitleList.get(position); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesAdapterFactory.java new file mode 100644 index 000000000..3ac75e07e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesAdapterFactory.java @@ -0,0 +1,32 @@ +package fr.free.nrw.commons.explore.categories; + +import com.pedrogomez.renderers.ListAdapteeCollection; +import com.pedrogomez.renderers.RVRendererAdapter; +import com.pedrogomez.renderers.RendererBuilder; + +import java.util.Collections; +import java.util.List; + +/** + * This class helps in creating adapter for categoriesRecyclerView in SearchCategoryFragment, + * implementing onClicks on categoriesRecyclerView Items + **/ +public class SearchCategoriesAdapterFactory { + private final SearchCategoriesRenderer.CategoryClickedListener listener; + + public SearchCategoriesAdapterFactory(SearchCategoriesRenderer.CategoryClickedListener listener) { + this.listener = listener; + } + + /** + * This method creates a recyclerViewAdapter for Categories. + * @param searchImageItemList List of category name to be displayed + * @return categoriesAdapter + **/ + public RVRendererAdapter create(List searchImageItemList) { + RendererBuilder builder = new RendererBuilder().bind(String.class, new SearchCategoriesRenderer(listener)); + ListAdapteeCollection collection = new ListAdapteeCollection<>( + searchImageItemList != null ? searchImageItemList : Collections.emptyList()); + return new RVRendererAdapter<>(builder, collection); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesRenderer.java b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesRenderer.java new file mode 100644 index 000000000..631b7b552 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesRenderer.java @@ -0,0 +1,56 @@ +package fr.free.nrw.commons.explore.categories; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.pedrogomez.renderers.Renderer; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.R; + +/** + * presentation logic of individual category in search is handled here + **/ +class SearchCategoriesRenderer extends Renderer { + @BindView(R.id.textView1) TextView tvCategoryName; + + private final CategoryClickedListener listener; + + SearchCategoriesRenderer(CategoryClickedListener listener) { + this.listener = listener; + } + + @Override + protected View inflate(LayoutInflater layoutInflater, ViewGroup viewGroup) { + return layoutInflater.inflate(R.layout.item_recent_searches, viewGroup, false); + } + + @Override + protected void setUpView(View view) { + ButterKnife.bind(this, view); + } + + @Override + protected void hookListeners(View view) { + view.setOnClickListener(v -> { + String item = getContent(); + if (listener != null) { + listener.categoryClicked(item); + } + }); + } + + @Override + public void render() { + String item = getContent(); + tvCategoryName.setText(item.replaceFirst("^Category:", "")); + } + + public interface CategoryClickedListener { + void categoryClicked(String item); + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java new file mode 100644 index 000000000..7f2b3ff93 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java @@ -0,0 +1,222 @@ +package fr.free.nrw.commons.explore.categories; + + +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.os.Bundle; +import android.os.Handler; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.pedrogomez.renderers.RVRendererAdapter; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; +import javax.inject.Named; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.category.CategoryDetailsActivity; +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; +import fr.free.nrw.commons.explore.recentsearches.RecentSearch; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao; +import fr.free.nrw.commons.mwapi.MediaWikiApi; +import fr.free.nrw.commons.utils.NetworkUtils; +import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +/** + * Displays the category search screen. + */ + +public class SearchCategoryFragment extends CommonsDaggerSupportFragment { + + private static int TIMEOUT_SECONDS = 15; + + @BindView(R.id.imagesListBox) + RecyclerView categoriesRecyclerView; + @BindView(R.id.imageSearchInProgress) + ProgressBar progressBar; + @BindView(R.id.imagesNotFound) + TextView categoriesNotFoundView; + String query; + + @Inject RecentSearchesDao recentSearchesDao; + @Inject MediaWikiApi mwApi; + @Inject @Named("default_preferences") SharedPreferences prefs; + + private RVRendererAdapter categoriesAdapter; + private List queryList = new ArrayList<>(); + + private final SearchCategoriesAdapterFactory adapterFactory = new SearchCategoriesAdapterFactory(item -> { + // Called on Click of a individual category Item + // Open Category Details activity + CategoryDetailsActivity.startYourself(getContext(), item); + saveQuery(query); + }); + + /** + * This method saves Search Query in the Recent Searches Database. + * @param query + */ + private void saveQuery(String query) { + RecentSearch recentSearch = recentSearchesDao.find(query); + + // Newly searched query... + if (recentSearch == null) { + recentSearch = new RecentSearch(null, query, new Date()); + } + else { + recentSearch.setLastSearched(new Date()); + } + recentSearchesDao.save(recentSearch); + + } + + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_browse_image, container, false); + ButterKnife.bind(this, rootView); + if(getActivity().getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT){ + categoriesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + } + else{ + categoriesRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2)); + } + ArrayList items = new ArrayList<>(); + categoriesAdapter = adapterFactory.create(items); + categoriesRecyclerView.setAdapter(categoriesAdapter); + categoriesRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + // check if end of recycler view is reached, if yes then add more results to existing results + if (!recyclerView.canScrollVertically(1)) { + addCategoriesToList(query); + } + } + }); + return rootView; + } + + /** + * Checks for internet connection and then initializes the recycler view with 25 categories of the searched query + * Clearing categoryAdapter every time new keyword is searched so that user can see only new results + */ + public void updateCategoryList(String query) { + this.query = query; + categoriesNotFoundView.setVisibility(GONE); + if(!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + return; + } + progressBar.setVisibility(View.VISIBLE); + queryList.clear(); + categoriesAdapter.clear(); + Observable.fromCallable(() -> mwApi.searchCategory(query,queryList.size())) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(this::handleSuccess, this::handleError); + } + + + /** + * Adds more results to existing search results + */ + public void addCategoriesToList(String query) { + this.query = query; + progressBar.setVisibility(View.VISIBLE); + Observable.fromCallable(() -> mwApi.searchCategory(query,queryList.size())) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(this::handlePaginationSuccess, this::handleError); + } + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + * @param mediaList + */ + private void handlePaginationSuccess(List mediaList) { + queryList.addAll(mediaList); + progressBar.setVisibility(View.GONE); + categoriesAdapter.addAll(mediaList); + categoriesAdapter.notifyDataSetChanged(); + } + + + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + * @param mediaList + */ + private void handleSuccess(List mediaList) { + queryList = mediaList; + if(mediaList == null || mediaList.isEmpty()) { + initErrorView(); + } + else { + + progressBar.setVisibility(View.GONE); + categoriesAdapter.addAll(mediaList); + categoriesAdapter.notifyDataSetChanged(); + + // check if user is waiting for 5 seconds if yes then save search query to history. + Handler handler = new Handler(); + handler.postDelayed(() -> saveQuery(query), 5000); + } + } + + /** + * Logs and handles API error scenario + * @param throwable + */ + private void handleError(Throwable throwable) { + Timber.e(throwable, "Error occurred while loading queried categories"); + try { + initErrorView(); + ViewUtil.showSnackbar(categoriesRecyclerView, R.string.error_loading_categories); + }catch (Exception e){ + e.printStackTrace(); + } + + } + + /** + * Handles the UI updates for a error scenario + */ + private void initErrorView() { + progressBar.setVisibility(GONE); + categoriesNotFoundView.setVisibility(VISIBLE); + categoriesNotFoundView.setText(getString(R.string.categories_not_found, query)); + } + + /** + * Handles the UI updates for no internet scenario + */ + private void handleNoInternet() { + progressBar.setVisibility(GONE); + ViewUtil.showSnackbar(categoriesRecyclerView, R.string.no_internet); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java new file mode 100644 index 000000000..a503207e4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java @@ -0,0 +1,245 @@ +package fr.free.nrw.commons.explore.images; + + +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.os.Bundle; +import android.os.Handler; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; +import com.pedrogomez.renderers.RVRendererAdapter; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Named; +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; +import fr.free.nrw.commons.explore.SearchActivity; +import fr.free.nrw.commons.explore.recentsearches.RecentSearch; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao; +import fr.free.nrw.commons.mwapi.MediaWikiApi; +import fr.free.nrw.commons.utils.NetworkUtils; +import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +/** + * Displays the image search screen. + */ + +public class SearchImageFragment extends CommonsDaggerSupportFragment { + + private static int TIMEOUT_SECONDS = 15; + + @BindView(R.id.imagesListBox) + RecyclerView imagesRecyclerView; + @BindView(R.id.imageSearchInProgress) + ProgressBar progressBar; + @BindView(R.id.imagesNotFound) + TextView imagesNotFoundView; + String query; + + @Inject RecentSearchesDao recentSearchesDao; + @Inject MediaWikiApi mwApi; + @Inject @Named("default_preferences") SharedPreferences prefs; + + private RVRendererAdapter imagesAdapter; + private List queryList = new ArrayList<>(); + + private final SearchImagesAdapterFactory adapterFactory = new SearchImagesAdapterFactory(item -> { + // Called on Click of a individual media Item + int index = queryList.indexOf(item); + ((SearchActivity)getContext()).onSearchImageClicked(index); + saveQuery(query); + }); + + /** + * This method saves Search Query in the Recent Searches Database. + * @param query + */ + private void saveQuery(String query) { + RecentSearch recentSearch = recentSearchesDao.find(query); + + // Newly searched query... + if (recentSearch == null) { + recentSearch = new RecentSearch(null, query, new Date()); + } + else { + recentSearch.setLastSearched(new Date()); + } + + recentSearchesDao.save(recentSearch); + + } + + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_browse_image, container, false); + ButterKnife.bind(this, rootView); + if(getActivity().getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT){ + imagesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + } + else{ + imagesRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2)); + } + ArrayList items = new ArrayList<>(); + imagesAdapter = adapterFactory.create(items); + imagesRecyclerView.setAdapter(imagesAdapter); + imagesRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + // check if end of recycler view is reached, if yes then add more results to existing results + if (!recyclerView.canScrollVertically(1)) { + addImagesToList(query); + } + } + }); + return rootView; + } + + /** + * Checks for internet connection and then initializes the recycler view with 25 images of the searched query + * Clearing imageAdapter every time new keyword is searched so that user can see only new results + */ + public void updateImageList(String query) { + this.query = query; + imagesNotFoundView.setVisibility(GONE); + if(!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + return; + } + progressBar.setVisibility(View.VISIBLE); + queryList.clear(); + imagesAdapter.clear(); + Observable.fromCallable(() -> mwApi.searchImages(query,queryList.size())) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(this::handleSuccess, this::handleError); + } + + + /** + * Adds more results to existing search results + */ + public void addImagesToList(String query) { + this.query = query; + progressBar.setVisibility(View.VISIBLE); + Observable.fromCallable(() -> mwApi.searchImages(query,queryList.size())) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(this::handlePaginationSuccess, this::handleError); + } + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + * @param mediaList List of media to be added + */ + private void handlePaginationSuccess(List mediaList) { + queryList.addAll(mediaList); + progressBar.setVisibility(View.GONE); + imagesAdapter.addAll(mediaList); + imagesAdapter.notifyDataSetChanged(); + } + + + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + * @param mediaList List of media to be shown + */ + private void handleSuccess(List mediaList) { + queryList = mediaList; + if(mediaList == null || mediaList.isEmpty()) { + initErrorView(); + } + else { + + progressBar.setVisibility(View.GONE); + imagesAdapter.addAll(mediaList); + imagesAdapter.notifyDataSetChanged(); + + // check if user is waiting for 5 seconds if yes then save search query to history. + Handler handler = new Handler(); + handler.postDelayed(() -> saveQuery(query), 5000); + } + } + + /** + * Logs and handles API error scenario + * @param throwable + */ + private void handleError(Throwable throwable) { + Timber.e(throwable, "Error occurred while loading queried images"); + try { + initErrorView(); + ViewUtil.showSnackbar(imagesRecyclerView, R.string.error_loading_images); + }catch (Exception e){ + e.printStackTrace(); + } + } + + /** + * Handles the UI updates for a error scenario + */ + private void initErrorView() { + progressBar.setVisibility(GONE); + imagesNotFoundView.setVisibility(VISIBLE); + imagesNotFoundView.setText(getString(R.string.images_not_found, query)); + } + + /** + * Handles the UI updates for no internet scenario + */ + private void handleNoInternet() { + progressBar.setVisibility(GONE); + ViewUtil.showSnackbar(imagesRecyclerView, R.string.no_internet); + } + + /** + * returns total number of images present in the recyclerview adapter. + */ + public int getTotalImagesCount(){ + if (imagesAdapter == null) { + return 0; + } + else { + return imagesAdapter.getItemCount(); + } + } + + /** + * returns Media Object at position + * @param i position of Media in the recyclerview adapter. + */ + public Media getImageAtPosition(int i) { + if (imagesAdapter.getItem(i).getFilename() == null) { + // not yet ready to return data + return null; + } + else { + return new Media(imagesAdapter.getItem(i).getFilename()); + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesAdapterFactory.java new file mode 100644 index 000000000..94930e261 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesAdapterFactory.java @@ -0,0 +1,35 @@ +package fr.free.nrw.commons.explore.images; + +import com.pedrogomez.renderers.ListAdapteeCollection; +import com.pedrogomez.renderers.RVRendererAdapter; +import com.pedrogomez.renderers.RendererBuilder; + +import java.util.Collections; +import java.util.List; + +import fr.free.nrw.commons.Media; + +/** + * This class helps in creating adapter for imagesRecyclerView in SearchImagesFragment, + * implementing onClicks on imagesRecyclerView Items + **/ +class SearchImagesAdapterFactory { + private final SearchImagesRenderer.ImageClickedListener listener; + + SearchImagesAdapterFactory(SearchImagesRenderer.ImageClickedListener listener) { + this.listener = listener; + } + + /** + * This method creates a recyclerViewAdapter for Media. + * @param searchImageItemList List of Media objects to be displayed + * @return imagesAdapter + **/ + public RVRendererAdapter create(List searchImageItemList) { + RendererBuilder builder = new RendererBuilder() + .bind(Media.class, new SearchImagesRenderer(listener)); + ListAdapteeCollection collection = new ListAdapteeCollection<>( + searchImageItemList != null ? searchImageItemList : Collections.emptyList()); + return new RVRendererAdapter<>(builder, collection); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesRenderer.java b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesRenderer.java new file mode 100644 index 000000000..42c044d70 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesRenderer.java @@ -0,0 +1,75 @@ +package fr.free.nrw.commons.explore.images; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.pedrogomez.renderers.Renderer; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.MediaWikiImageView; +import fr.free.nrw.commons.R; + +/** + * presentation logic of individual image in search is handled here + **/ +class SearchImagesRenderer extends Renderer { + @BindView(R.id.categoryImageTitle) TextView tvImageName; + @BindView(R.id.categoryImageAuthor) TextView categoryImageAuthor; + @BindView(R.id.categoryImageView) + MediaWikiImageView browseImage; + + private final ImageClickedListener listener; + + SearchImagesRenderer(ImageClickedListener listener) { + this.listener = listener; + } + + @Override + protected View inflate(LayoutInflater layoutInflater, ViewGroup viewGroup) { + return layoutInflater.inflate(R.layout.layout_category_images, viewGroup, false); + } + + @Override + protected void setUpView(View view) { + ButterKnife.bind(this, view); + } + + @Override + protected void hookListeners(View view) { + view.setOnClickListener(v -> { + Media item = getContent(); + if (listener != null) { + listener.imageClicked(item); + } + }); + } + + @Override + public void render() { + Media item = getContent(); + tvImageName.setText(item.getDisplayTitle()); + browseImage.setMedia(item); + setAuthorView(item, categoryImageAuthor); + } + + interface ImageClickedListener { + void imageClicked(Media item); + } + + /** + * formats author name as "Uploaded by: authorName" and sets it in textview + */ + private void setAuthorView(Media item, TextView author) { + if (item.getCreator() != null && !item.getCreator().equals("")) { + author.setVisibility(View.GONE); + String uploadedByTemplate = getContext().getString(R.string.image_uploaded_by); + author.setText(String.format(uploadedByTemplate, item.getCreator())); + } else { + author.setVisibility(View.VISIBLE); + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearch.java b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearch.java new file mode 100644 index 000000000..c5172bb7c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearch.java @@ -0,0 +1,70 @@ +package fr.free.nrw.commons.explore.recentsearches; + +import android.net.Uri; +import java.util.Date; + +/** + * Represents a recently searched query + * Example - query = "butterfly" + */ +public class RecentSearch { + private Uri contentUri; + private String query; + private Date lastSearched; + + /** + * Constructor + * @param contentUri the content URI for this query + * @param query query name + * @param lastSearched last searched date + */ + public RecentSearch(Uri contentUri, String query, Date lastSearched) { + this.contentUri = contentUri; + this.query = query; + this.lastSearched = lastSearched; + } + + /** + * Gets query name + * @return query name + */ + public String getQuery() { + return query; + } + + /** + * Gets last searched date + * @return Last searched date + */ + public Date getLastSearched() { + // warning: Date objects are mutable. + return (Date)lastSearched.clone(); + } + + + /** + * Updates the last searched date + * @param lastSearched Last searched date + */ + public void setLastSearched(Date lastSearched) { + this.lastSearched = lastSearched; + } + + /** + * Gets the content URI for this query + * @return content URI + */ + public Uri getContentUri() { + return contentUri; + } + + /** + * Modifies the content URI - marking this query as already saved in the database + * + * @param contentUri the content URI + */ + public void setContentUri(Uri contentUri) { + this.contentUri = contentUri; + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesContentProvider.java b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesContentProvider.java new file mode 100644 index 000000000..bf3cf959a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesContentProvider.java @@ -0,0 +1,202 @@ +package fr.free.nrw.commons.explore.recentsearches; + +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import javax.inject.Inject; + +import fr.free.nrw.commons.contributions.ContributionDao; +import fr.free.nrw.commons.data.DBOpenHelper; +import fr.free.nrw.commons.di.CommonsDaggerContentProvider; +import timber.log.Timber; + +import static android.content.UriMatcher.NO_MATCH; +import static fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao.Table.ALL_FIELDS; +import static fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao.Table.COLUMN_ID; +import static fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao.Table.TABLE_NAME; + + +/** + * This class contains functions for executing queries for + * inserting, searching, deleting, editing recent searches in SqLite DB + **/ +public class RecentSearchesContentProvider extends CommonsDaggerContentProvider { + + public static final String RECENT_SEARCH_AUTHORITY = "fr.free.nrw.commons.explore.recentsearches.contentprovider"; + // For URI matcher + private static final int RECENT_SEARCHES = 1; + private static final int RECENT_SEARCHES_ID = 2; + private static final String BASE_PATH = "recent_searches"; + public static final Uri BASE_URI = Uri.parse("content://" + RECENT_SEARCH_AUTHORITY + "/" + BASE_PATH); + private static final UriMatcher uriMatcher = new UriMatcher(NO_MATCH); + + static { + uriMatcher.addURI(RECENT_SEARCH_AUTHORITY, BASE_PATH, RECENT_SEARCHES); + uriMatcher.addURI(RECENT_SEARCH_AUTHORITY, BASE_PATH + "/#", RECENT_SEARCHES_ID); + } + + public static Uri uriForId(int id) { + return Uri.parse(BASE_URI.toString() + "/" + id); + } + + @Inject DBOpenHelper dbOpenHelper; + + /** + * This functions executes query for searching recent searches in SqLite DB + **/ + @SuppressWarnings("ConstantConditions") + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + queryBuilder.setTables(TABLE_NAME); + + int uriType = uriMatcher.match(uri); + + SQLiteDatabase db = dbOpenHelper.getReadableDatabase(); + Cursor cursor; + + switch (uriType) { + case RECENT_SEARCHES: + cursor = queryBuilder.query(db, projection, selection, selectionArgs, + null, null, sortOrder); + break; + case RECENT_SEARCHES_ID: + cursor = queryBuilder.query(db, + ALL_FIELDS, + "_id = ?", + new String[]{uri.getLastPathSegment()}, + null, + null, + sortOrder + ); + break; + default: + throw new IllegalArgumentException("Unknown URI" + uri); + } + + cursor.setNotificationUri(getContext().getContentResolver(), uri); + + return cursor; + } + + @Override + public String getType(@NonNull Uri uri) { + return null; + } + + /** + * This functions executes query for inserting a recentSearch object in SqLite DB + **/ + @SuppressWarnings("ConstantConditions") + @Override + public Uri insert(@NonNull Uri uri, ContentValues contentValues) { + int uriType = uriMatcher.match(uri); + SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); + long id; + switch (uriType) { + case RECENT_SEARCHES: + id = sqlDB.insert(TABLE_NAME, null, contentValues); + break; + default: + throw new IllegalArgumentException("Unknown URI: " + uri); + } + getContext().getContentResolver().notifyChange(uri, null); + return Uri.parse(BASE_URI + "/" + id); + } + + /** + * This functions executes query for deleting a recentSearch object in SqLite DB + **/ + @Override + public int delete(@NonNull Uri uri, String s, String[] strings) { + int rows; + int uriType = uriMatcher.match(uri); + SQLiteDatabase db = dbOpenHelper.getReadableDatabase(); + switch (uriType) { + case RECENT_SEARCHES_ID: + Timber.d("Deleting recent searches id %s", uri.getLastPathSegment()); + rows = db.delete(RecentSearchesDao.Table.TABLE_NAME, + "_id = ?", + new String[]{uri.getLastPathSegment()} + ); + break; + default: + throw new IllegalArgumentException("Unknown URI" + uri); + } + getContext().getContentResolver().notifyChange(uri, null); + return rows; + } + + /** + * This functions executes query for inserting multiple recentSearch objects in SqLite DB + **/ + @SuppressWarnings("ConstantConditions") + @Override + public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) { + Timber.d("Hello, bulk insert! (RecentSearchesContentProvider)"); + int uriType = uriMatcher.match(uri); + SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); + sqlDB.beginTransaction(); + switch (uriType) { + case RECENT_SEARCHES: + for (ContentValues value : values) { + Timber.d("Inserting! %s", value); + sqlDB.insert(TABLE_NAME, null, value); + } + break; + default: + throw new IllegalArgumentException("Unknown URI: " + uri); + } + sqlDB.setTransactionSuccessful(); + sqlDB.endTransaction(); + getContext().getContentResolver().notifyChange(uri, null); + return values.length; + } + + /** + * This functions executes query for updating a particular recentSearch object in SqLite DB + **/ + @SuppressWarnings("ConstantConditions") + @Override + public int update(@NonNull Uri uri, ContentValues contentValues, String selection, + String[] selectionArgs) { + /* + SQL Injection warnings: First, note that we're not exposing this to the + outside world (exported="false"). Even then, we should make sure to sanitize + all user input appropriately. Input that passes through ContentValues + should be fine. So only issues are those that pass in via concating. + + In here, the only concat created argument is for id. It is cast to an int, + and will error out otherwise. + */ + int uriType = uriMatcher.match(uri); + SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); + int rowsUpdated; + switch (uriType) { + case RECENT_SEARCHES_ID: + if (TextUtils.isEmpty(selection)) { + int id = Integer.valueOf(uri.getLastPathSegment()); + rowsUpdated = sqlDB.update(TABLE_NAME, + contentValues, + COLUMN_ID + " = ?", + new String[]{String.valueOf(id)}); + } else { + throw new IllegalArgumentException( + "Parameter `selection` should be empty when updating an ID"); + } + break; + default: + throw new IllegalArgumentException("Unknown URI: " + uri + " with type " + uriType); + } + getContext().getContentResolver().notifyChange(uri, null); + return rowsUpdated; + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java new file mode 100644 index 000000000..4bc84b7b1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java @@ -0,0 +1,235 @@ +package fr.free.nrw.commons.explore.recentsearches; + +import android.content.ContentProviderClient; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.RemoteException; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Provider; + +/** + * This class doesn't execute queries in database directly instead it contains the logic behind + * inserting, deleting, searching data from recent searches database. + **/ +public class RecentSearchesDao { + + private final Provider clientProvider; + + @Inject + public RecentSearchesDao(@Named("recentsearch") Provider clientProvider) { + this.clientProvider = clientProvider; + } + + /** + * This method is called on click of media/ categories for storing them in recent searches + * @param recentSearch a recent searches object that is to be added in SqLite DB + */ + public void save(RecentSearch recentSearch) { + ContentProviderClient db = clientProvider.get(); + try { + if (recentSearch.getContentUri() == null) { + recentSearch.setContentUri(db.insert(RecentSearchesContentProvider.BASE_URI, toContentValues(recentSearch))); + } else { + db.update(recentSearch.getContentUri(), toContentValues(recentSearch), null, null); + } + } catch (RemoteException e) { + throw new RuntimeException(e); + } finally { + db.release(); + } + } + + /** + * This method is called on confirmation of delete recent searches. + * It deletes latest 10 recent searches from the database + * @param recentSearchesStringList list of recent searches to be deleted + */ + public void deleteAll(List recentSearchesStringList) { + ContentProviderClient db = clientProvider.get(); + for (String recentSearchName : recentSearchesStringList) { + try { + RecentSearch recentSearch = find(recentSearchName); + if (recentSearch.getContentUri() == null) { + throw new RuntimeException("tried to delete item with no content URI"); + } else { + Log.d("QUERY_NAME",recentSearch.getContentUri()+"- delete tried"); + db.delete(recentSearch.getContentUri(), null, null); + Log.d("QUERY_NAME",recentSearch.getQuery()+"- query deleted"); + } + } catch (RemoteException e) { + Log.d("Exception",e+"- query deleted"); + throw new RuntimeException(e); + } finally { + db.release(); + } + } + } + + /** + * Find persisted search query in database, based on its name. + * @param name Search query Ex- "butterfly" + * @return recently searched query from database, or null if not found + */ + @Nullable + public RecentSearch find(String name) { + Cursor cursor = null; + ContentProviderClient db = clientProvider.get(); + try { + cursor = db.query( + RecentSearchesContentProvider.BASE_URI, + Table.ALL_FIELDS, + Table.COLUMN_NAME + "=?", + new String[]{name}, + null); + if (cursor != null && cursor.moveToFirst()) { + return fromCursor(cursor); + } + } catch (RemoteException e) { + // This feels lazy, but to hell with checked exceptions. :) + throw new RuntimeException(e); + } finally { + if (cursor != null) { + cursor.close(); + } + db.release(); + } + return null; + } + + /** + * Retrieve recently-searched queries, ordered by descending date. + * @return a list containing recent searches + */ + @NonNull + public List recentSearches(int limit) { + List items = new ArrayList<>(); + Cursor cursor = null; + ContentProviderClient db = clientProvider.get(); + try { + cursor = db.query( RecentSearchesContentProvider.BASE_URI, Table.ALL_FIELDS, + null, new String[]{}, Table.COLUMN_LAST_USED + " DESC"); + // fixme add a limit on the original query instead of falling out of the loop? + while (cursor != null && cursor.moveToNext() && cursor.getPosition() < limit) { + items.add(fromCursor(cursor).getQuery()); + } + } catch (RemoteException e) { + throw new RuntimeException(e); + } finally { + if (cursor != null) { + cursor.close(); + } + db.release(); + } + return items; + } + + + /** + * It creates an Recent Searches object from data stored in the SQLite DB by using cursor + * @param cursor + * @return RecentSearch object + */ + @NonNull + RecentSearch fromCursor(Cursor cursor) { + // Hardcoding column positions! + return new RecentSearch( + RecentSearchesContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(Table.COLUMN_ID))), + cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)), + new Date(cursor.getLong(cursor.getColumnIndex(Table.COLUMN_LAST_USED))) + ); + } + + /** + * This class contains the database table architechture for recent searches, + * It also contains queries and logic necessary to the create, update, delete this table. + */ + private ContentValues toContentValues(RecentSearch recentSearch) { + ContentValues cv = new ContentValues(); + cv.put(RecentSearchesDao.Table.COLUMN_NAME, recentSearch.getQuery()); + cv.put(RecentSearchesDao.Table.COLUMN_LAST_USED, recentSearch.getLastSearched().getTime()); + return cv; + } + + /** + * This class contains the database table architechture for recent searches, + * It also contains queries and logic necessary to the create, update, delete this table. + */ + public static class Table { + public static final String TABLE_NAME = "recent_searches"; + public static final String COLUMN_ID = "_id"; + static final String COLUMN_NAME = "name"; + static final String COLUMN_LAST_USED = "last_used"; + + // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. + public static final String[] ALL_FIELDS = { + COLUMN_ID, + COLUMN_NAME, + COLUMN_LAST_USED, + }; + + static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME; + + static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " (" + + COLUMN_ID + " INTEGER PRIMARY KEY," + + COLUMN_NAME + " STRING," + + COLUMN_LAST_USED + " INTEGER" + + ");"; + + /** + * This method creates a RecentSearchesTable in SQLiteDatabase + * @param db SQLiteDatabase + */ + public static void onCreate(SQLiteDatabase db) { + db.execSQL(CREATE_TABLE_STATEMENT); + } + + /** + * This method deletes RecentSearchesTable from SQLiteDatabase + * @param db SQLiteDatabase + */ + public static void onDelete(SQLiteDatabase db) { + db.execSQL(DROP_TABLE_STATEMENT); + onCreate(db); + } + + /** + * This method is called on migrating from a older version to a newer version + * @param db SQLiteDatabase + * @param from Version from which we are migrating + * @param to Version to which we are migrating + */ + public static void onUpdate(SQLiteDatabase db, int from, int to) { + if (from == to) { + return; + } + if (from < 6) { + // doesn't exist yet + from++; + onUpdate(db, from, to); + return; + } + if (from == 6) { + // table added in version 7 + onCreate(db); + from++; + onUpdate(db, from, to); + return; + } + if (from == 7) { + from++; + onUpdate(db, from, to); + return; + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java new file mode 100644 index 000000000..5c109fbb4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java @@ -0,0 +1,85 @@ +package fr.free.nrw.commons.explore.recentsearches; + +import android.os.Bundle; +import android.support.v7.app.AlertDialog; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.Toast; + +import java.util.List; + +import javax.inject.Inject; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; +import fr.free.nrw.commons.explore.SearchActivity; + + +/** + * Displays the recent searches screen. + */ +public class RecentSearchesFragment extends CommonsDaggerSupportFragment { + @Inject RecentSearchesDao recentSearchesDao; + @BindView(R.id.recent_searches_list) ListView recentSearchesList; + List recentSearches; + ArrayAdapter adapter; + @BindView(R.id.recent_searches_delete_button) + ImageView recent_searches_delete_button; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_search_history, container, false); + ButterKnife.bind(this, rootView); + recentSearches = recentSearchesDao.recentSearches(10); + recent_searches_delete_button.setOnClickListener(v -> { + new AlertDialog.Builder(getContext()) + .setMessage(getString(R.string.delete_recent_searches_dialog)) + .setPositiveButton("YES", (dialog, which) -> { + recentSearchesDao.deleteAll(recentSearches); + Toast.makeText(getContext(),getString(R.string.search_history_deleted),Toast.LENGTH_SHORT).show(); + recentSearches = recentSearchesDao.recentSearches(10); + adapter = new ArrayAdapter(getContext(),R.layout.item_recent_searches, recentSearches); + recentSearchesList.setAdapter(adapter); + adapter.notifyDataSetChanged(); + dialog.dismiss(); + }) + .setNegativeButton("NO", null) + .create() + .show(); + }); + adapter = new ArrayAdapter(getContext(),R.layout.item_recent_searches, recentSearches); + recentSearchesList.setAdapter(adapter); + recentSearchesList.setOnItemClickListener((parent, view, position, id) -> ( + (SearchActivity)getContext()).updateText(recentSearches.get(position))); + adapter.notifyDataSetChanged(); + return rootView; + } + + /** + * This method is called on back press of activity + * so we are updating the list from database to refresh the recent searches list. + */ + @Override + public void onResume() { + recentSearches = recentSearchesDao.recentSearches(10); + adapter.notifyDataSetChanged(); + super.onResume(); + } + + /** + * This method is called when search query is null to update Recent Searches + */ + public void updateRecentSearches() { + recentSearches = recentSearchesDao.recentSearches(10); + adapter = new ArrayAdapter(getContext(),R.layout.item_recent_searches, recentSearches); + recentSearchesList.setAdapter(adapter); + adapter.notifyDataSetChanged(); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java index 4ae37ecfc..e7a21ed09 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java @@ -42,6 +42,7 @@ import fr.free.nrw.commons.MediaDataExtractor; import fr.free.nrw.commons.MediaWikiImageView; import fr.free.nrw.commons.PageTitle; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.category.CategoryDetailsActivity; import fr.free.nrw.commons.delete.DeleteTask; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.location.LatLng; @@ -430,17 +431,11 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { textView.setText(catName); if (categoriesLoaded && categoriesPresent) { textView.setOnClickListener(view -> { + // Open Category Details page String selectedCategoryTitle = "Category:" + catName; - Intent viewIntent = new Intent(); - viewIntent.setAction(Intent.ACTION_VIEW); - viewIntent.setData(new PageTitle(selectedCategoryTitle).getCanonicalUri()); - //check if web browser available - if (viewIntent.resolveActivity(getActivity().getPackageManager()) != null) { - startActivity(viewIntent); - } else { - Toast toast = Toast.makeText(getContext(), getString(R.string.no_web_browser), LENGTH_SHORT); - toast.show(); - } + Intent intent = new Intent(getContext(), CategoryDetailsActivity.class); + intent.putExtra("categoryName", selectedCategoryTitle); + getContext().startActivity(intent); }); } return item; diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java index bd985ef20..429436820 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java @@ -122,7 +122,10 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple Media m = provider.getMediaAtPosition(pager.getCurrentItem()); switch (item.getItemId()) { case R.id.menu_share_current_image: - // Share - intent set in onCreateOptionsMenu, around line 252 + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_TEXT, m.getDisplayTitle() + " \n" + m.getFilePageTitle().getCanonicalUri()); + startActivity(Intent.createChooser(shareIntent, "Share image via...")); return true; case R.id.menu_browser_current_image: // View in browser @@ -235,19 +238,7 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple menu.findItem(R.id.menu_share_current_image).setEnabled(true).setVisible(true); menu.findItem(R.id.menu_download_current_image).setEnabled(true).setVisible(true); - // Set ShareActionProvider Intent - ShareActionProvider mShareActionProvider = (ShareActionProvider) MenuItemCompat.getActionProvider(menu.findItem(R.id.menu_share_current_image)); - // On some phones null is returned for some reason: - // https://github.com/commons-app/apps-android-commons/issues/413 - if (mShareActionProvider != null) { - Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(Intent.EXTRA_TEXT, - m.getDisplayTitle() + " \n" + m.getFilePageTitle().getCanonicalUri()); - mShareActionProvider.setShareIntent(shareIntent); - } - - if (m instanceof Contribution) { + if (m instanceof Contribution ) { Contribution c = (Contribution) m; switch (c.getState()) { case Contribution.STATE_FAILED: diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java index 03ff52c8b..495321cd2 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java @@ -35,6 +35,7 @@ import java.net.URL; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.List; @@ -570,6 +571,89 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi { return NotificationUtils.getNotificationsFromList(context, childNodes); } + /** + * The method takes categoryName as input and returns a List of Subcategories + * It uses the generator query API to get the subcategories in a category, 500 at a time. + * Uses the query continue values for fetching paginated responses + * @param categoryName Category name as defined on commons + * @return + */ + @Override + @NonNull + public List getSubCategoryList(String categoryName) { + ApiResult apiResult = null; + try { + MWApi.RequestBuilder requestBuilder = api.action("query") + .param("generator", "categorymembers") + .param("format", "xml") + .param("gcmtype","subcat") + .param("gcmtitle", categoryName) + .param("prop", "info") + .param("gcmlimit", "500") + .param("iiprop", "url|extmetadata"); + + apiResult = requestBuilder.get(); + } catch (IOException e) { + Timber.e("Failed to obtain searchCategories", e); + } + + if (apiResult == null) { + return new ArrayList<>(); + } + + ApiResult categoryImagesNode = apiResult.getNode("/api/query/pages"); + if (categoryImagesNode == null + || categoryImagesNode.getDocument() == null + || categoryImagesNode.getDocument().getChildNodes() == null + || categoryImagesNode.getDocument().getChildNodes().getLength() == 0) { + return new ArrayList<>(); + } + + NodeList childNodes = categoryImagesNode.getDocument().getChildNodes(); + return CategoryImageUtils.getSubCategoryList(childNodes); + } + + /** + * The method takes categoryName as input and returns a List of parent categories + * It uses the generator query API to get the parent categories of a category, 500 at a time. + * @param categoryName Category name as defined on commons + * @return + */ + @Override + @NonNull + public List getParentCategoryList(String categoryName) { + ApiResult apiResult = null; + try { + MWApi.RequestBuilder requestBuilder = api.action("query") + .param("generator", "categories") + .param("format", "xml") + .param("titles", categoryName) + .param("prop", "info") + .param("cllimit", "500") + .param("iiprop", "url|extmetadata"); + + apiResult = requestBuilder.get(); + } catch (IOException e) { + Timber.e("Failed to obtain parent Categories", e); + } + + if (apiResult == null) { + return new ArrayList<>(); + } + + ApiResult categoryImagesNode = apiResult.getNode("/api/query/pages"); + if (categoryImagesNode == null + || categoryImagesNode.getDocument() == null + || categoryImagesNode.getDocument().getChildNodes() == null + || categoryImagesNode.getDocument().getChildNodes().getLength() == 0) { + return new ArrayList<>(); + } + + NodeList childNodes = categoryImagesNode.getDocument().getChildNodes(); + return CategoryImageUtils.getSubCategoryList(childNodes); + } + + /** * The method takes categoryName as input and returns a List of Media objects * It uses the generator query API to get the images in a category, 10 at a time. @@ -598,7 +682,6 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi { requestBuilder.param("continue", queryContinueValues.getContinueParam()); requestBuilder.param("gcmcontinue", queryContinueValues.getGcmContinueParam()); } - apiResult = requestBuilder.get(); } catch (IOException e) { Timber.e("Failed to obtain searchCategories", e); @@ -616,13 +699,93 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi { return new ArrayList<>(); } - QueryContinue queryContinue = getQueryContinue(apiResult.getNode("/api/continue").getDocument()); - setQueryContinueValues(categoryName, queryContinue); + if (apiResult.getNode("/api/continue").getDocument()==null){ + setQueryContinueValues(categoryName, null); + }else { + QueryContinue queryContinue = getQueryContinue(apiResult.getNode("/api/continue").getDocument()); + setQueryContinueValues(categoryName, queryContinue); + } NodeList childNodes = categoryImagesNode.getDocument().getChildNodes(); return CategoryImageUtils.getMediaList(childNodes); } + /** + * This method takes search keyword as input and returns a list of Media objects filtered using search query + * It uses the generator query API to get the images searched using a query, 25 at a time. + * @param query keyword to search images on commons + * @return + */ + @Override + @NonNull + public List searchImages(String query, int offset) { + List imageNodes = null; + try { + imageNodes = api.action("query") + .param("format", "xml") + .param("list", "search") + .param("srwhat", "text") + .param("srnamespace", "6") + .param("srlimit", "25") + .param("sroffset",offset) + .param("srsearch", query) + .get() + .getNodes("/api/query/search/p/@title"); + } catch (IOException e) { + Timber.e("Failed to obtain searchImages", e); + } + + if (imageNodes == null) { + return new ArrayList(); + } + + List images = new ArrayList<>(); + for (ApiResult imageNode : imageNodes) { + String imgName = imageNode.getDocument().getTextContent(); + images.add(new Media(imgName)); + } + + return images; + } + + /** + * This method takes search keyword as input and returns a list of categories objects filtered using search query + * It uses the generator query API to get the categories searched using a query, 25 at a time. + * @param query keyword to search categories on commons + * @return + */ + @Override + @NonNull + public List searchCategory(String query, int offset) { + List categoryNodes = null; + try { + categoryNodes = api.action("query") + .param("format", "xml") + .param("list", "search") + .param("srwhat", "text") + .param("srnamespace", "14") + .param("srlimit", "25") + .param("sroffset",offset) + .param("srsearch", query) + .get() + .getNodes("/api/query/search/p/@title"); + } catch (IOException e) { + Timber.e("Failed to obtain searchCategories", e); + } + + if (categoryNodes == null) { + return new ArrayList(); + } + + List categories = new ArrayList<>(); + for (ApiResult categoryNode : categoryNodes) { + String catName = categoryNode.getDocument().getTextContent(); + categories.add(catName); + } + return categories; + } + + /** * For APIs that return paginated responses, MediaWiki APIs uses the QueryContinue to facilitate fetching of subsequent pages * https://www.mediawiki.org/wiki/API:Raw_query_continue diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java index 4ac185a4a..c8f6f4961 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java @@ -41,6 +41,16 @@ public interface MediaWikiApi { List getCategoryImages(String categoryName); + List getSubCategoryList(String categoryName); + + List getParentCategoryList(String categoryName); + + @NonNull + List searchImages(String title, int offset); + + @NonNull + List searchCategory(String title, int offset); + @NonNull UploadResult uploadFile(String filename, InputStream file, long dataLength, String pageContents, String editSummary, ProgressListener progressListener) throws IOException; diff --git a/app/src/main/java/fr/free/nrw/commons/theme/NavigationBaseActivity.java b/app/src/main/java/fr/free/nrw/commons/theme/NavigationBaseActivity.java index 4a7322b57..7d975d2bf 100644 --- a/app/src/main/java/fr/free/nrw/commons/theme/NavigationBaseActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/theme/NavigationBaseActivity.java @@ -82,6 +82,15 @@ public abstract class NavigationBaseActivity extends BaseActivity toggle.setToolbarNavigationClickListener(v -> onBackPressed()); } + /** + * This method changes the toolbar icon to back regardless of any conditions that + * there is any fragment in the backStack or not + */ + public void forceInitBackButton() { + toggle.setDrawerIndicatorEnabled(false); + toggle.setToolbarNavigationClickListener(v -> onBackPressed()); + } + public void initBack() { setSupportActionBar(toolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); @@ -156,9 +165,9 @@ public abstract class NavigationBaseActivity extends BaseActivity drawerLayout.closeDrawer(navigationView); NotificationActivity.startYourself(this); return true; - case R.id.action_featured_images: + case R.id.action_explore: drawerLayout.closeDrawer(navigationView); - CategoryImagesActivity.startYourself(this, getString(R.string.title_activity_featured_images), FEATURED_IMAGES_CATEGORY); + CategoryImagesActivity.startYourself(this, getString(R.string.title_activity_explore), FEATURED_IMAGES_CATEGORY); return true; default: Timber.e("Unknown option [%s] selected from the navigation menu", itemId); @@ -186,4 +195,16 @@ public abstract class NavigationBaseActivity extends BaseActivity } context.startActivity(intent); } + + /** + * Handles visibility of navigation base toolbar + * @param show : Used to handle visibility of toolbar + */ + public void setNavigationBaseToolbarVisibility(boolean show){ + if (show){ + toolbar.setVisibility(View.VISIBLE); + }else { + toolbar.setVisibility(View.GONE); + } + } } diff --git a/app/src/main/res/drawable-hdpi/ic_arrow_back_primary_24dp.png b/app/src/main/res/drawable-hdpi/ic_arrow_back_primary_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..fe508b4505217477e2256b9ef0195ee0eb806dac GIT binary patch literal 215 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbB=6Sj}hE&{od)=3-K|!D`ap3~r z_BR^ucDWpX@jNZp`(P`NQr5qokGl8%Iv=y>s=UdvoW+5G@rmE8hz64U`a{nmYY>eF8biyS{Mbkq5HXTD>_P9~NK>&4afpHmDvbLc0~ Or3{{~elF{r5}E*=ds}A! literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi/ic_delete_grey_700_24dp.png b/app/src/main/res/drawable-hdpi/ic_delete_grey_700_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..de4a147fe909f8f2b918dd92253ebe46058c58a8 GIT binary patch literal 195 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBx;$MRLn>~)yG;} zdZ*rF&M4Xc$(0>13@&DU-u;oX{c3QbvSwk})7<5mG<0V^4ck{>STbc}CWWt+;x#paYN?#LqOA^;&2F0dV7QORIyP*cgca7}!M>+6g9 o*Zri8^}ZGQoxacCQhkwIRY_NFF+|JRjF?f`@-m$Pf%PS5dpgVP-rmjF+>TQ5j zdr=$PpnU^KwVf@{k}6w(3TlA{jj3A;jP6Q}kf3!3jBbSlodKW|5|jZz84{EOKu;v7 z1b_-8NP-HW01BYL03|?B(iMcnG(x$?)S_JM%}@JOHzAu1<0&l+S0Q|YxBa;wAD021+@(LX1yd`&{oVEanV9I$VHe}u1-*00;=l(6`>JykbsWR7%C+N z>Ooftizj~g_QZ0tiTkuNzvj0@C)9%!Qz)P-y`#umrUXk0j_Ne#+Fd$h?KDt{Os zp4*`DJ19p9>Y&B%Q1y#BH8J)zuT2S>qJ?J)mwArnO(;QIv~WWK)I{^Pl%NY**iisE zZ%-o`2tQq_uL^wck%2e?h#Qar8PLCg7$J!L3SyG)H%u|Ef>Om$#iD?C%tiWy;w&3K wr31;O2y}ux3CPn4DhNOz^29OocT9FbPt_;At55XVyq#L0FR>CnZcpFkvwo4bAmaVT_hX~uxNgV4#bUqIU|C4L8?OE#e(og6w8 z2~AJ9z;Pt@CEiO)$-#e-cjwQ0_wL@)h@!~&OEM~;{|?jy4L}o+0~&)mAhT$o9>@oM zN6Gd9azPE`Z|DkW}ZOT3F|IEL(q<_ z{RFiofqD$)K_OTR6c8U@63AznQ4M$tIuPHk1W=QlrZ8ojPU(>V%_V>aB;Yzn+HK;$ zkpP;IfU_Jx4OZM|134t1n*+#XZQ^X8F$uWH0dzzBcM?Dy5^#%sCQHx}@%JQvOq@-z z4Kr@n8}Yr9>O(F|drL9kl=$sIS5iQiI4ENau<4!3%#?Nad+Awb + + + + + + + + diff --git a/app/src/main/res/drawable-mdpi/ic_arrow_back_primary_24dp.png b/app/src/main/res/drawable-mdpi/ic_arrow_back_primary_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..07e187a26b892628d89ee51f285ea8ae35a6bccb GIT binary patch literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gjg`O^sAr-fhA9&6teBbP0l+XkK@GmNc literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_notifications_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_notifications_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..8e71d5461057e9ced7843a969cf9aeca57211ef3 GIT binary patch literal 213 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gjb39!fLn>}1|JWbX+c<4TqCw;V zjwAO1&UkQhJFxw4Ps-1VKEQHh^8}q4tfGs%SSLEv@SiM@*vCF~@rkAog@5fw3nc!r zhdNJV-Q@6xyZ@m9Pmr>6AM0Kh3BJR&Pa+E1Sh)QrCm*a9(y4IoWBu=WWU&~JA-B|t z4GS70xK9|$ELXk4A+^WRbVb&d=N{{7{J6Po7P2*O_&s6Gmy?GWepc^R{O~UDI?$aA Mp00i_>zopr0Lu}1|JWbX+c<4TqCw;V zjwAO1&UkQhJFxw4Ps-1VKEQHh^8}q4tfGs%SSLEv@SiM@*vCF~@rkAog@5d(AJ_!W zw>^>$*jOK6ni8qNePsUvwyO(Vb(BNi*oXA;Sc-jd;*IEcKg+gamRymJ1@qN64eq_l zA!jp-bSn6->NL6cYJ{A%F4AdOD(1w=xPDh4H`{tv-js+74r^F>BTmUC6*3!&{aG%+ we^2VI`Wa3qx_uohR6`b>tTF+x)c8*MV_X6AkA&ubki zrL&}*p9X}ag4B>Y%sDBr1*}L%IX(n@lK_43J1HVDl1t*GnA9o|V?d}B-wuR!3dA=B z6bMF|U;^LS6wn~ZG{H4|l_{V@(A5Mz_?}Z&z%{`Qe6=YcM=;g|Q}|A%fB?Z3x8+-C zPkO+2n9>JAfh0Ik58IL|#WR~u7Mf1ZFqgXX8l(HT=M`>CjXm$Tr3B zb*Oi2pJR^VgOEAid=+x}gv|A^FhCXq^bq!6fATDP0iV(ib#w1YW&i*H07*qoM6N<$ Eg8S-w-2eap literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_arrow_back_primary_24dp.png b/app/src/main/res/drawable-xhdpi/ic_arrow_back_primary_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..91d7ddcbf18ba21a21c5403ad10c66fb95febee7 GIT binary patch literal 238 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtt)4E9Ar-gY-rUXGYQW?6kmrEi zn!^%PBwG}3HnfYVbeT-h*w+8=(BGdbdMf)eqqd1uPGVr@3 z-v3CyrqK}fr|zr_qk+jj^Mr~T{|3(*{f5XI{)WsN?E^OZSRW|;(?8(*N4{b8kM#`Y zALALe{s_Fxv!O?v;UkA)-I0##8jqaYEW8x>iaQ>gJaTH@5u~K|wPPz+(#F;ZU8QNe mJHGDvw@}c5fsy5dy?SC*5cA5GS+9T|VDNPHb6Mw<&;$TQy7H zs_(AAiw9jURQE0@*Y@rC!2kc6vz)HTBo~#xJ6V8Q7|J78^Pj%RpnJI7b&JfpbBqiE zya((BSOp%v1j(X-0`@m;hx6qcZVCk0Ff86C#j`)7`Fh?L7oY1>zgL(&J^LYNIU@tZ ahgx~$6tJ@0Cm8R#!9B?U<%Hv1B^6+4 zdm;cHaBp{%G00000NkvXXu0mjfA^d_E literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_notifications_none_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_notifications_none_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..56a194cc113d805f8f47681aeec49ffa0d8317c7 GIT binary patch literal 429 zcmV;e0aE^nP)kBmw;g zr59)d1B<`_*OX4+5tvzuQ)W0*3V~N(b4K|i8=NgvV3?ygWmuFz7uXpJEr34I2I@!~ zuPqGgcbLU0@Bn-nZizRbVwt4uMO*VLIiBfCz|y2#A0PT$Mn=AdvVHNIV@@X%`9C@;*r}B~V29 ze@nQWb8;a9@`))l0cE)zR0sl@MIZ}7AjY#!Z8-moLwN>b{I!o?DKj7CS->P)_M-M$#e+yGC_aZ$U&H!eeT7~seGO4c{iC*}_agWJ)o0Lx=?GgW z!=&VAcQteeJ`f}``I1d`cB2`Fp?Mii2`B+2@P7p|Ko6J!8(_yc{GI|`AbpAgMc~D- z9t)sw3Iajk!EhdZKtGni8Srknk2fGHTOeq1@_S$alz=$k1N=Y&C66ejM(vuJ?~G3K)_id_*Fb1*WK2nS($D<(!HX=%Qjn4g!xTXGf$!nwWTUE{a_d`7T7a5(`vt$=U+D zg(AQT<(kXrg$l$~o7j|y5R>Ab-4QA6bf1t|vfjqZuvrnu-^pAL=xh-}#fY^BN%oEp zuvcY;cwm3xFu%lPFDavcNZ1KHIksgk>UFk~auRzsY+}TJE!o>DdA1m_8gul(Qqerd z##V~;^JCUkZdq@$W;1NZWWmxgk!W~nYq8Bs;+XrIEfa~lmo|5sCK5F-?M^pLByPO4 zy>2E^_0so&x)X_0`(RP?x`4*Uz9)y!Z>m;KUVY?K0!ly$C;``e11^I(^pCv{-2eap M07*qoM6N<$f*{%P#Q*>R literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_back_primary_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_arrow_back_primary_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..ec21c6d594f91f5469217b27e77908e6f7e9ab46 GIT binary patch literal 377 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2V07?waSW-r_4f8@-a`fgEe}N+ zChQQ-=nZNTh`k`WKB&Rt|4evWxY_cq)FeI*21XVE2Qa~+(7?n{ z>dfHvpr&mOqu1k}oeWcsNf!6=nY3Ph{`a(=)&-$C$yWqZn3ju8kenmsP;8;EI_HwZ zvjQ)snMX8PQ^c0D0980&IlQWJj%j1egEg#A*wT3ySk76lI_HkVyMiL7okvcxJ`q!A zpO8JLzVTT>NHEI@Ek8kp(-sVdM|&7HdZgcZcKb#C=XDdkd-bC6EJ-Ki5ov@47} zPO18Fs(h6y=-;x`V;@1PLn>~)y=};K$Uvmwq2E`& zT@_np9KOs66|i;mSIjeYa*JJ~vi_;`Khq2SUnVNP4Dy=0kDXCK!J&bHk%?u6-|ss! zX4mEDWUmp*(DM^svh-ny`>fpuwoCxYatJ7>XEJ7SY?-x_8OVlcAvr0`a{Uzh?wUlp^xtKFtJP(TpBw39^E_GjC+zS3{l|-$ d;a1eXVU2L*ys4jJ;SKaAgQu&X%Q~loCIE+MSReoZ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_notifications_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_notifications_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..dc4c15d4d7e3f53514be3355cb53255145e7474b GIT binary patch literal 498 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2VBG2H;uunK>+S8mUWWrDj(zkh z6%<#u*E^6c!1GS_Xk_4$R}UUdF>O|eYh8R@!1V*0QKoqL(&h^rj~)EMcJ5$QkFSs4 zrzdmLfB5G*|D3odzx}db(dLVZHw7CVSd;`BIb0TKeNu^kD)?e|s^*kQE1ka1`s}d( zp^uU!?~}ici$BM(eJW=7smIJ}*}%jk_`;n#$bZ)~H_HaMS9@*0MrJqey0p^dOyi`h z0kMm-r7uh~(O@*b>kxaHYk~61#ZEG@Is=OTJ)acp)d65&MgU z!E66vEu{;341Ia*f-kr@UI+=k;Ny59CisGr_6o8BPK~ zKbfNBD2=w{_%q{yI^q0AG8!mhgVRdFWT1=#pp3~tmIELQKmhuaQA(0>La8_as_@t( zZ#%R=A<8DD>;OednQMfG4Jb}2xqxX;iE9o8&>H2#L!4HcHB){E+E)P6oOQDg2B-kqve}6@%Dju(3tU60zpXpa1m#7T{3j*mL5~>M>eS84e1_ z6Qq7TggHnc*GQ^ybcxk(AV1-_LNW#C-$*LXdEEmb0BH_{zhoi+@&!<>hwDboM4&y9{Umv>q{joim{E zh*f97P+&D2kMc9h6_9G5vtT&5n!?vRj)0~jrj?BVRnu*P8=y@^Le5(X@FwVp@cW7r zpbzDdGPC7eK>`T5Qg8kn@UKY>-nm zzd;0gh^vf^07C#j2*0nn9d}GB&SqX%3h<@E=RJ-miXRjK*9-^uNa1Un>yhhN#9498 zAe?i>?)fg~uEvvyuETl{IcYFrT7~wQd)p_6{VA0l;YnbwgE8k73At@M4z?9WI;)(zD??VoS9 zA(<)#>WWpTTk|m?XK`9lwrdlT$-zdk<7vXSFImci2uL1xKy6K6Q?Jz_lopl`)DC&? zDj?ZRa6{O)TEgx_6VH%+JPB;#dCnqME@Pq$FH8@$gBBf>cN=Aghp)bakm{15(oNcj-WCbOI@f zm*9%{qzIrcAT`oH#V)GwAEZXwle7v_Bkjw46r@6$H;QsCTmT6m0VIF~kVpUtAOYlF Ze*qRnf}$J5j9UNz002ovPDHLkV1mQMj!6Ij literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_arrow_back_primary_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_arrow_back_primary_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..7bac2fce5a4646fbddf87974fba85fc7b55f460d GIT binary patch literal 366 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7Ro>U^Mk~aSW-r_4dwg-a`QbE`j0? zlon3bZOPL(BQQfka6#mL+k++r{VgBMZ|C=)EfQvAU|?oAz`$U@z`z4%#)m&=Xn7?6 zoXbJ)-%`P_&icrfsgL4?61V=Cf0yyeBm4We89Vl~9JusxH?xlXe&YijKVlh=9eK}T zpjBtyQ25cBv8U%gYeLQ+K8D4Qmopwa?$6XA&mv&Yp-?B_@JFHHha9v@HBN7GAQg&WM*iZ{fR-L!hoBB zh5tEI!vj`c27x_G*cqC{w=z0>*vQ8qP_u!Pfkpl;^N0ABPr_52HP|1@IQ^8K<*cEe mWZ7~nwhP6ZFdv)T<38=@elIqv?j0~389ZJ6T-G@yGywoD0er^* literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delete_grey_700_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_delete_grey_700_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..10e27028790db44124e7d84e221e573500772758 GIT binary patch literal 355 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7Ro>VAS$-aSW-r_4dwb-X;eI*TAwH zJR8-l7P6ZL^={dNRbf7=GNpxU%~5 z?TFoP3d5gOsl_<;zctox^*@_wU9kH`^$we>XLmBNFf=eQ2td60>Lv5FU~=$uaSW-r_4bCnzH^|&@j~A# zXS_-T)$|YeapX6y^%hpM7EIg{dZ|2NqJ$(zbJtSkStZxcdTHm{`kwR5$hcc?``zX* z-~FX8-{#iI7ruWR@jNH7=gv_M1_lQN;?YqSUD9>MAoNVOB*Ua!w_TI1>PxhJnJ3(d zuiO7|zj6vg$Noe1$F_?CX)lHbE{2cSIT$W5DEzZxWM*KnW!NdeV9Bt^RQs)0e~>kc z!)=+}*>>Cw^=9gG+hz93AKUpehv8pFkQHaecHu2;md8~X?4u{W;^ey@eRqy9L#FpH ziAO)Sy|XlTmwj;I;D^1keOd?nxQ=bl?Uy>x^<}0~!@KfGInjo9Yac{+9oxQl=MLXe z#)7GbKITrp#b_2}voAMR+aYXK6~hOm-fWf=)du@?*9$&4sNAs0tbw)v*!Hv8Ocvh+ z9(V{lTy<_(CDyQVM*VI1u(yeSa%_1TO!<#*Uz^2noGsccMT_y+Kh8I(n7^X0E+*@>o z%dqaW47UT%+%^V{BEi1(oG;Z5{J8#1f+5LGgYQTA6{Ghji==10FOplL~KZ8LO-gU!HZNZ4<~kPgg&ebxsLQ0G~+h9{>OV literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notifications_none_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_notifications_none_black_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..f0237c57e1fc96900bc24e6f7f10914d140b4668 GIT binary patch literal 830 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7Ro>U}p7naSW-r_4dxjY~ete10UIP z6p|Fzb;Wiq%n&av6cNddEe&3BL3KfJdG-pcU7Anq3pC3@R)hv>>q%a9YRh}Opycu_ z52^abcn9vY)%xYr%fG*~u9<$}PWAWSA@66k^pwsIKXxLXhmnDS1A&rRS8ACqnRe~M zaV`dx+nz7yUa?)W{Sqg`q~B-dP5#siaxol?S{}Y$%ETWqau1_t$GHwcj(GmSd~m@N^H;9q)r5e&0PSeL(bO|KkbY zmWW^4Q{uVG+U=k$flG23O1l)rT9dHt>L>*1oeI|CSxE%ohY%<)baiuk`q z?)H{{<}W=u7`VmM{|C?VE8xGpt1VNWTal^8>*U)7XWzbe%U}@go*XBAarR5r*wb6) zUshza*=F&uSozQOihD`+{Z32*W|1@Hu=*TXIg|@cS3V zj2#Qpof&MND#^Sr|GmbV=YQ$-^W_4pwbD^n?!W4LxPR`w^F77Vv)-64Tvu+9yLM&& j*$?Fmzyt|J6Tbap5=i_fzok;63}maPtDnm{r-UW|+M!yG literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png new file mode 100644 index 0000000000000000000000000000000000000000..47cc04fbef0475eb8cb71a9a5e4f9c8cab23102d GIT binary patch literal 1143 zcmV--1c>{IP)VYihD(q7*-Yld4dA*JN~yRAmLNORo4ZScD2l8DMFo`U{H=reeB<_lC2Ghn| z6HtX5=kEaJ0VWa$c9HXh3ReW=B4@ZeA@#q!OWH%kv+9a~Mr5~B9%4e3$dT9$7X)Ay z)>(Zt$kC3j#O4w4UA9@kC}Nynq%pA=VxI8OW&yH?DYFJ7_gE6+maPI} zh>v%*0m;=O;w-jNK$|3~4+ahMl9E_kZ4@vqNovv{0goiHPS_}5QIb@XK?0g3vCi8l zK=!dVX{SP55^KUn0sE4q3JelZD2erpjRH<2N#z+NAYT&eF$vHsAWdD6-Y)2XZo+zR z!buwiELb!Qy)B7#$;FA-ltBV!B(cu8xD=5vr1<=a-6t29B1#a8Tr~y=s6)gVvvoOo zTawtE0Ro;#V%)U(dB6x_e0@n{Vx@@P8Y4CfC_{X_pJ_~N5fR@N+aJoWAdI}{xW|G$zg2}S zemdlhVxer#9Y=1AJql>z(~j(Rc)Rb~=G@F-ybXqL^NJ=wrO44vroA?7sa)v+4Zgi2 z<)Ij9v2VxE8Lm1p$?|ggk}siOFs=A11!VT&95U}MDRwV3ixE?yc#x7WevXS(%^Aen z&RN25xh^0_4DSw^9c|b}0hv!i%76l=n0$Zekc$E`_k?wg#eWN26c2}($r6HoAU?*z z{i*`~AOC>;eHahZ23-{J=X2TB5Q_imlw}WQvV<@%#*YOp2T=Hz?Cc{)UfO`m0y6NE zDg1E6Ru)Cy6zlnzCFOe_-@D&+0sr2t`SQesm?0;`n$EH4&kw{rVM)vbT7Ax0diD}JNPv2J!jeG6KAmn7V21?if&!*1sN$1=>?Cy1Ff?2`RTrNG zc&9^U@kxNAJL3ovKmrI7KmrI7KmrI7KmrI7KmrI7Kmv&W@DubK=*O$7;Qasq002ov JPDHLkV1hGl{7V1; literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_star_black_24dp.xml b/app/src/main/res/drawable/ic_star_black_24dp.xml deleted file mode 100644 index a87ca098d..000000000 --- a/app/src/main/res/drawable/ic_star_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_category_details.xml b/app/src/main/res/layout/activity_category_details.xml new file mode 100644 index 000000000..04b50074a --- /dev/null +++ b/app/src/main/res/layout/activity_category_details.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml new file mode 100644 index 000000000..dce1ef163 --- /dev/null +++ b/app/src/main/res/layout/activity_search.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_browse_image.xml b/app/src/main/res/layout/fragment_browse_image.xml new file mode 100644 index 000000000..af6a7860b --- /dev/null +++ b/app/src/main/res/layout/fragment_browse_image.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_category_images.xml b/app/src/main/res/layout/fragment_category_images.xml index 001f0a780..f317b2f23 100644 --- a/app/src/main/res/layout/fragment_category_images.xml +++ b/app/src/main/res/layout/fragment_category_images.xml @@ -1,6 +1,7 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_recent_searches.xml b/app/src/main/res/layout/item_recent_searches.xml new file mode 100644 index 000000000..6b0b0b68e --- /dev/null +++ b/app/src/main/res/layout/item_recent_searches.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/menu/drawer.xml b/app/src/main/res/menu/drawer.xml index ae6e0cce2..ceb802867 100644 --- a/app/src/main/res/menu/drawer.xml +++ b/app/src/main/res/menu/drawer.xml @@ -11,9 +11,9 @@ android:title="@string/navigation_item_nearby" /> + android:id="@+id/action_explore" + android:icon="@drawable/ic_explore_24dp" + android:title="@string/navigation_item_explore"/> diff --git a/app/src/main/res/menu/fragment_category_detail.xml b/app/src/main/res/menu/fragment_category_detail.xml new file mode 100644 index 000000000..fb23763e3 --- /dev/null +++ b/app/src/main/res/menu/fragment_category_detail.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/fragment_image_detail.xml b/app/src/main/res/menu/fragment_image_detail.xml index 70a35951a..e0970e256 100644 --- a/app/src/main/res/menu/fragment_image_detail.xml +++ b/app/src/main/res/menu/fragment_image_detail.xml @@ -4,7 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + + + + \ 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 8b2226610..51e2717af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,7 @@ + Explore + Explore Appearance General Feedback @@ -83,6 +85,7 @@ Settings Sign Up Featured Images + Category About The Wikimedia Commons app is an open-source app created and maintained by grantees and volunteers of the Wikimedia community. The Wikimedia Foundation is not involved in the creation, development, or maintenance of the app. Wikimedia Commons @@ -196,6 +199,8 @@ Background Image Media Image Failed No Image Found + No subcategories found + No parent categories found Upload Image Mount Zao Llamas @@ -286,11 +291,20 @@ Error fetching nearby places. Pic of the Day Pic of the Day + Search + Search Commons + No Images matching %1$s found + Search + Recent searches: + Recently searched queries + Error occurred while loading categories. + Error occurred while loading subcategories. Image successfully added to %1$s on Wikidata! Failed to update corresponding Wikidata entity! Set wallpaper Wallpaper set successfully! - + Are you sure you want to clear your search history? + Search history deleted Login session expired, please log in again. diff --git a/app/src/test/kotlin/fr/free/nrw/commons/TestCommonsApplication.kt b/app/src/test/kotlin/fr/free/nrw/commons/TestCommonsApplication.kt index 84f6d5f10..edb9fc374 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/TestCommonsApplication.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/TestCommonsApplication.kt @@ -38,7 +38,6 @@ class MockCommonsApplicationModule(appContext: Context) : CommonsApplicationModu val accountUtil: AccountUtil = mock() val appSharedPreferences: SharedPreferences = mock() val defaultSharedPreferences: SharedPreferences = mock() - val categorySharedPreferences: SharedPreferences = mock() val otherSharedPreferences: SharedPreferences = mock() val uploadController: UploadController = mock() val mockSessionManager: SessionManager = mock() diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDaoTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDaoTest.kt new file mode 100644 index 000000000..d764c8da7 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDaoTest.kt @@ -0,0 +1,307 @@ +package fr.free.nrw.commons.explore.recentsearches + +import android.content.ContentProviderClient +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.database.sqlite.SQLiteDatabase +import android.os.RemoteException +import com.nhaarman.mockito_kotlin.* +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.TestCommonsApplication +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider.BASE_URI +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider.uriForId +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao.Table.* +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.* + +@RunWith(RobolectricTestRunner::class) +@Config(constants = BuildConfig::class, sdk = [21], application = TestCommonsApplication::class) +class RecentSearchesDaoTest { + + private val columns = arrayOf(COLUMN_ID, COLUMN_NAME, COLUMN_LAST_USED) + private val client: ContentProviderClient = mock() + private val database: SQLiteDatabase = mock() + private val captor = argumentCaptor() + private val queryCaptor = argumentCaptor>() + + private lateinit var testObject: RecentSearchesDao + + @Before + fun setUp() { + testObject = RecentSearchesDao { client } + } + + /** + * Unit Test for creating a table for recent Searches + */ + @Test + fun createTable() { + onCreate(database) + verify(database).execSQL(CREATE_TABLE_STATEMENT) + } + + /** + * Unit Test for deleting table for recent Searches + */ + @Test + fun deleteTable() { + onDelete(database) + inOrder(database) { + verify(database).execSQL(DROP_TABLE_STATEMENT) + verify(database).execSQL(CREATE_TABLE_STATEMENT) + } + } + + /** + * Unit Test for migrating from database version 1 to 2 for recent Searches Table + */ + @Test + fun migrateTableVersionFrom_v1_to_v2() { + onUpdate(database, 1, 2) + // Table didnt exist before v7 + verifyZeroInteractions(database) + } + + /** + * Unit Test for migrating from database version 2 to 3 for recent Searches Table + */ + @Test + fun migrateTableVersionFrom_v2_to_v3() { + onUpdate(database, 2, 3) + // Table didnt exist before v7 + verifyZeroInteractions(database) + } + + /** + * Unit Test for migrating from database version 3 to 4 for recent Searches Table + */ + @Test + fun migrateTableVersionFrom_v3_to_v4() { + onUpdate(database, 3, 4) + // Table didnt exist before v7 + verifyZeroInteractions(database) + } + + /** + * Unit Test for migrating from database version 4 to 5 for recent Searches Table + */ + @Test + fun migrateTableVersionFrom_v4_to_v5() { + onUpdate(database, 4, 5) + // Table didnt exist before v7 + verifyZeroInteractions(database) + } + + /** + * Unit Test for migrating from database version 5 to 6 for recent Searches Table + */ + @Test + fun migrateTableVersionFrom_v5_to_v6() { + onUpdate(database, 5, 6) + // Table didnt exist before v7 + verifyZeroInteractions(database) + } + + /** + * Unit Test for migrating from database version 6 to 7 for recent Searches Table + */ + @Test + fun migrateTableVersionFrom_v6_to_v7() { + onUpdate(database, 6, 7) + verify(database).execSQL(CREATE_TABLE_STATEMENT) + } + + /** + * Unit Test for migrating from database version 7 to 8 for recent Searches Table + */ + @Test + fun migrateTableVersionFrom_v7_to_v8() { + onUpdate(database, 7, 8) + // Table didnt change in version 8 + verifyZeroInteractions(database) + } + + /** + * Unit Test for migrating from creating a row without using ID in recent Searches Table + */ + @Test + fun createFromCursor() { + createCursor(1).let { cursor -> + cursor.moveToFirst() + testObject.fromCursor(cursor).let { + assertEquals(uriForId(1), it.contentUri) + assertEquals("butterfly", it.query) + assertEquals(123, it.lastSearched.time) + } + } + } + + /** + * Unit Test for migrating from updating a row using contentUri in recent Searches Table + */ + @Test + fun saveExistingQuery() { + createCursor(1).let { + val recentSearch = testObject.fromCursor(it.apply { moveToFirst() }) + + testObject.save(recentSearch) + + verify(client).update(eq(recentSearch.contentUri), captor.capture(), isNull(), isNull()) + captor.firstValue.let { cv -> + assertEquals(2, cv.size()) + assertEquals(recentSearch.query, cv.getAsString(COLUMN_NAME)) + assertEquals(recentSearch.lastSearched.time, cv.getAsLong(COLUMN_LAST_USED)) + } + } + } + + /** + * Unit Test for migrating from creating a row using ID in recent Searches Table + */ + @Test + fun saveNewQuery() { + val contentUri = RecentSearchesContentProvider.uriForId(111) + whenever(client.insert(isA(), isA())).thenReturn(contentUri) + val recentSearch = RecentSearch(null, "butterfly", Date(234L)) + + testObject.save(recentSearch) + + verify(client).insert(eq(BASE_URI), captor.capture()) + captor.firstValue.let { cv -> + assertEquals(2, cv.size()) + assertEquals(recentSearch.query, cv.getAsString(COLUMN_NAME)) + assertEquals(recentSearch.lastSearched.time, cv.getAsLong(COLUMN_LAST_USED)) + assertEquals(contentUri, recentSearch.contentUri) + } + } + + /** + * Unit Test for checking translation exceptions in searching a row from DB using recent search query + */ + @Test(expected = RuntimeException::class) + fun findRecentSearchTranslatesExceptions() { + whenever(client.query(any(), any(), any(), any(), anyOrNull())).thenThrow(RemoteException("")) + testObject.find("butterfly") + } + + /** + * Unit Test for checking data if it's not present in searching a row from DB using recent search query + */ + @Test + fun whenTheresNoDataFindReturnsNull_nullCursor() { + whenever(client.query(any(), any(), any(), any(), any())).thenReturn(null) + assertNull(testObject.find("butterfly")) + } + + /** + * Unit Test for checking data if it's not present in searching a row from DB using recent search query + */ + @Test + fun whenTheresNoDataFindReturnsNull_emptyCursor() { + whenever(client.query(any(), any(), any(), any(), any())).thenReturn(createCursor(0)) + assertNull(testObject.find("butterfly")) + } + + /** + * Unit Test for checking if cursor's are closed after use or not + */ + @Test + fun cursorsAreClosedAfterUse() { + val mockCursor: Cursor = mock() + whenever(client.query(any(), any(), any(), any(), anyOrNull())).thenReturn(mockCursor) + whenever(mockCursor.moveToFirst()).thenReturn(false) + + testObject.find("butterfly") + + verify(mockCursor).close() + } + + /** + * Unit Test for checking search results after searching a row from DB using recent search query + */ + @Test + fun findRecentSearchQuery() { + whenever(client.query(any(), any(), any(), any(), anyOrNull())).thenReturn(createCursor(1)) + + val recentSearch = testObject.find("butterfly") + assertNotNull(recentSearch) + + assertEquals(uriForId(1), recentSearch?.contentUri) + assertEquals("butterfly", recentSearch?.query) + assertEquals(123L, recentSearch?.lastSearched?.time) + + verify(client).query( + eq(BASE_URI), + eq(ALL_FIELDS), + eq("$COLUMN_NAME=?"), + queryCaptor.capture(), + isNull() + ) + assertEquals("butterfly", queryCaptor.firstValue[0]) + } + + /** + * Unit Test for checking if cursor's are closed after recent search query or not + */ + @Test + fun cursorsAreClosedAfterRecentSearchQuery() { + val mockCursor: Cursor = mock() + whenever(client.query(any(), any(), anyOrNull(), any(), any())).thenReturn(mockCursor) + whenever(mockCursor.moveToFirst()).thenReturn(false) + + testObject.recentSearches(1) + + verify(mockCursor).close() + } + + /** + * Unit Test for checking when recent searches returns less than the limit + */ + @Test + fun recentSearchesReturnsLessThanLimit() { + whenever(client.query(any(), any(), anyOrNull(), any(), any())).thenReturn(createCursor(1)) + + val result = testObject.recentSearches(10) + + assertEquals(1, result.size) + assertEquals("butterfly", result[0]) + + verify(client).query( + eq(BASE_URI), + eq(ALL_FIELDS), + isNull(), + queryCaptor.capture(), + eq("$COLUMN_LAST_USED DESC") + ) + assertEquals(0, queryCaptor.firstValue.size) + } + + /** + * Unit Test for checking size or list recieved from recent searches + */ + @Test + fun recentSearchesHonorsLimit() { + whenever(client.query(any(), any(), anyOrNull(), any(), any())).thenReturn(createCursor(10)) + + val result = testObject.recentSearches(5) + + assertEquals(5, result.size) + } + + /** + * Unit Test for creating entries in recent searches database. + * @param rowCount No of rows + */ + private fun createCursor(rowCount: Int) = MatrixCursor(columns, rowCount).apply { + for (i in 0 until rowCount) { + addRow(listOf("1", "butterfly", "123")) + } + } + +} \ No newline at end of file