From f607c1c14dfe4a4f0fbb5f893123eda065361abc Mon Sep 17 00:00:00 2001 From: Vivek Maskara Date: Mon, 19 Nov 2018 14:31:35 +0530 Subject: [PATCH] Multiple uploads with over haul (#1968) * Added new upload activity that receives shared files from the gallery. Cards show and hide, plus titles are correct. Displayed thumbnails for the shared images * Better handling of the view paging plus error handling for required fields. * Code cleanup to make things more readable. * Extracted a model from the category search fragment that can possibly be shared with the new upload activity. * Added category selection to the combined upload screen. * Cleanup before the home-stretch on the GUI. * Adding license selection. * Fixed build warnings + cleanup * Start to support the dark theme. * Work in progress to add quality checking. * Fixing merge. * GPSExtractor: optimized away the EXifInterface object * Implemented submit functionality, temporarily fixed jacoco crash by disabling DUMMY UploadView object. * Implemented uploading of categories along with the picture. The category screen now displays GPS and recent categories when nothing is searched. * Implemented caching of files. Did some work on picture quality detection. * Implemented too dark picture detection. * Added a side card for zoom and map buttons along with pretty animations for stuff. * Added duplicate image on commons checking and fixed files not getting proper file extensions in several places. * Added support for map button and switched in-app upload buttons to UploadActivity * Pretty pretty animations! * Implemented zoom functionality for th background image. Just pinching on the image works instead of requiring buttons. * Added multi-language descriptions with categories by region. * Reimplemented the duplicate title checker and implemented a check against putting the same language twice in the description. * Javadocs for Description and UploadPresenter, plus some general cleanup. * Small code changes. * Implemented login checks for the Upload screen. * Implement receiving data from Nearby. * Feature/permissions library (#1855) * Added permission for Dexter, the runtime permission handling library * [Preparing fir issue #1773] Added a utility function which would take the user to app settings screen where he could manually give us the required permission * Added an alert dialog with positive and negative callback [Preparing fir issue #1773] * Improvements in the way External Storage Permission is handled in MultipleShareActivity[Bug fix #1697] 1. Used dexter to handle the external storage permission 2. Behaviour changes : When user tries to share(uppload) images to commons via MultipleShareActivity, following decision tree is followed a. If the app has permission for external storage, normal upload operation is followed b. If the app does not has the permission for external storage, dexter is used to ask for the same c. If the user gives us the required permission, normal upload flow is proceeded d. If the doesnot gives us the required permission a rationale dialog is shown with the appropriate message to let him know why we need the permission e. If he presses okay, steps a-c are followed and if he presses cancel, we close the app. f. If while asking for permission, the user chooses never ask again, then next time he tries to upload an image via MSA, the rational dialog follows the app setting screen where he could manually give us the required permission and the onActivityResult of same is handled * Added a Constants class to handle request and result codes from one place and other related constants common to the all app elements * replaced hardcoded strings ok and cancel in DialogUtil to string resources * init permission rationale dialog in activities onCreate * Code formatting, updated access modifiers wherever required, added javadocs for new methods created * *shifted constants to app class *Added JavaDocs in PermissionUtils * removed class REQUEST_CODES from CommonsApplication and instead put the enclosing constants in the App class itself * Made Codacy happy. * Abstarcted permission acquisition into new class DexterPermissionObtainer * Fixed Nearby upload detection * Migrated bad picture detection from AsyncTask to RxJava. * Removed ShareActivity and related dead code * Removed dead or duplicate code from FileProcessor * Added info button to title EditText * Fixed the add description button not disappearing. Added "Starting Upload" toast. Added link to the license on final screen. Made it so that the map button is hidden when image lacks gps coords. * Support in app multiple uploads * Minor changes to fix build * Changes to fix pending issues with upload flow * Fix display of similar image fragment * When uploading several files at once the date is missing #1854 (#2) * Bug fix issue #1854 * updated ContributionsDao to save create date, which it was not doing currently [it was instead saving current date] * UploadItem accepts are dateCreated param * Added a function in UploadModel, getFileCreatedDate which tries to fetched the file creaction date from all possible content providers. * Fix pending issues in upload flow * Make multiple uploads work for Google Photos * Fix default state for upload activity * Fix keyboard state for license screen * Fix descriptions for uploads * wip * Fix language spinner --- app/build.gradle | 4 +- .../nrw/commons/upload/FileUtilsTest.java | 30 - app/src/main/AndroidManifest.xml | 28 +- .../main/java/fr/free/nrw/commons/Utils.java | 53 +- .../commons/auth/AuthenticatedActivity.java | 3 +- .../category/CategoriesAdapterFactory.java | 11 +- .../nrw/commons/category/CategoriesModel.java | 227 ++++++ .../commons/category/CategoriesRenderer.java | 8 +- .../category/CategorizationFragment.java | 421 ----------- .../category/CategoryClickedListener.java | 5 + .../nrw/commons/category/CategoryItem.java | 7 +- .../category/CategoryRendererAdapter.java | 22 + .../commons/contributions/Contribution.java | 47 +- .../contributions/ContributionController.java | 45 +- .../contributions/ContributionDao.java | 3 +- .../ContributionsListFragment.java | 33 +- .../nrw/commons/di/ActivityBuilderModule.java | 12 +- .../commons/di/CommonsApplicationModule.java | 38 + .../nrw/commons/di/FragmentBuilderModule.java | 12 - .../explore/images/SearchImageFragment.java | 15 +- .../free/nrw/commons/nearby/DirectUpload.java | 4 +- .../free/nrw/commons/theme/BaseActivity.java | 2 +- .../free/nrw/commons/upload/Description.java | 80 ++- .../commons/upload/DescriptionsAdapter.java | 286 ++++---- .../upload/DetectUnwantedPicturesAsync.java | 82 --- .../upload/DexterPermissionObtainer.java | 153 ++++ .../nrw/commons/upload/ExistingFileAsync.java | 95 --- .../nrw/commons/upload/FileProcessor.java | 122 +--- .../fr/free/nrw/commons/upload/FileUtils.java | 86 ++- .../free/nrw/commons/upload/GPSExtractor.java | 78 +- .../upload/HeightLimitedRecyclerView.java | 52 ++ .../fr/free/nrw/commons/upload/Language.java | 1 - .../commons/upload/MultipleShareActivity.java | 493 ------------- .../upload/MultipleUploadListFragment.java | 254 ------- .../nrw/commons/upload/ShareActivity.java | 674 ------------------ .../commons/upload/SimilarImageInterface.java | 5 + .../commons/upload/SingleUploadFragment.java | 389 ---------- .../upload/SpinnerLanguagesAdapter.java | 89 ++- .../upload/ThumbnailClickedListener.java | 5 + .../fr/free/nrw/commons/upload/Title.java | 37 + .../nrw/commons/upload/UploadActivity.java | 607 ++++++++++++++++ .../UploadCategoriesAdapterFactory.java | 27 + .../upload/UploadCategoriesRenderer.java | 52 ++ .../nrw/commons/upload/UploadController.java | 60 +- .../free/nrw/commons/upload/UploadModel.java | 400 +++++++++++ .../nrw/commons/upload/UploadPresenter.java | 430 +++++++++++ .../nrw/commons/upload/UploadService.java | 5 +- .../upload/UploadThumbnailRenderer.java | 49 ++ .../UploadThumbnailsAdapterFactory.java | 26 + .../free/nrw/commons/upload/UploadView.java | 82 +++ .../free/nrw/commons/upload/UrlLicense.java | 108 +-- .../commons/utils/AbstractTextWatcher.java | 30 + .../java/fr/free/nrw/commons/utils/BiMap.java | 41 ++ .../fr/free/nrw/commons/utils/DialogUtil.java | 43 ++ .../fr/free/nrw/commons/utils/ImageUtils.java | 76 +- .../nrw/commons/utils/PermissionUtils.java | 1 + .../free/nrw/commons/utils/StringUtils.java | 4 + .../fr/free/nrw/commons/utils/ViewUtil.java | 25 + .../main/res/drawable/ic_error_red_24dp.xml | 5 + .../drawable/ic_expand_less_black_24dp.xml | 9 + .../drawable/ic_expand_less_white_24dp.xml | 9 + .../drawable/ic_expand_more_black_24dp.xml | 9 + .../drawable/ic_expand_more_white_24dp.xml | 9 + app/src/main/res/layout/activity_upload.xml | 74 ++ .../layout/activity_upload_bottom_card.xml | 99 +++ .../res/layout/activity_upload_categories.xml | 127 ++++ .../res/layout/activity_upload_license.xml | 116 +++ .../layout/activity_upload_please_wait.xml | 31 + .../res/layout/activity_upload_right_card.xml | 45 ++ .../res/layout/activity_upload_top_card.xml | 66 ++ .../res/layout/fragment_categorization.xml | 69 -- .../res/layout/fragment_single_upload.xml | 59 +- .../main/res/layout/item_upload_thumbnail.xml | 37 + .../res/layout/layout_categories_item.xml | 2 +- .../layout/layout_upload_categories_item.xml | 10 + .../main/res/layout/row_item_description.xml | 57 +- app/src/main/res/layout/row_item_title.xml | 19 + app/src/main/res/values-ar/strings.xml | 4 +- app/src/main/res/values-ast/strings.xml | 6 +- app/src/main/res/values-b+sr+Latn/strings.xml | 2 +- app/src/main/res/values-ba/strings.xml | 6 +- app/src/main/res/values-bg/strings.xml | 2 +- app/src/main/res/values-bn/strings.xml | 6 +- app/src/main/res/values-br/strings.xml | 2 +- app/src/main/res/values-bs/strings.xml | 2 +- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-ckb/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 6 +- app/src/main/res/values-da/strings.xml | 6 +- app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values-dty/strings.xml | 2 +- app/src/main/res/values-el/strings.xml | 6 +- app/src/main/res/values-es/strings.xml | 6 +- app/src/main/res/values-eu/strings.xml | 6 +- app/src/main/res/values-fa/strings.xml | 6 +- app/src/main/res/values-fi/strings.xml | 6 +- app/src/main/res/values-fr/strings.xml | 6 +- app/src/main/res/values-frr/strings.xml | 2 +- app/src/main/res/values-gl/strings.xml | 6 +- app/src/main/res/values-hi/strings.xml | 4 +- app/src/main/res/values-hr/strings.xml | 6 +- app/src/main/res/values-hu/strings.xml | 6 +- app/src/main/res/values-is/strings.xml | 6 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-iw/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 6 +- app/src/main/res/values-jv/strings.xml | 2 +- app/src/main/res/values-kab/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 6 +- app/src/main/res/values-lb/strings.xml | 2 +- app/src/main/res/values-li/strings.xml | 2 +- app/src/main/res/values-lt/strings.xml | 2 +- app/src/main/res/values-mk/strings.xml | 6 +- app/src/main/res/values-ml/strings.xml | 2 +- app/src/main/res/values-mr/strings.xml | 6 +- app/src/main/res/values-nb/strings.xml | 6 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pa/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pms/strings.xml | 6 +- app/src/main/res/values-pt-rBR/strings.xml | 6 +- app/src/main/res/values-pt/strings.xml | 6 +- app/src/main/res/values-ru/strings.xml | 6 +- app/src/main/res/values-sd/strings.xml | 2 +- app/src/main/res/values-si/strings.xml | 2 +- app/src/main/res/values-sk/strings.xml | 2 +- app/src/main/res/values-sr/strings.xml | 6 +- app/src/main/res/values-su/strings.xml | 6 +- app/src/main/res/values-sv/strings.xml | 6 +- app/src/main/res/values-th/strings.xml | 6 +- app/src/main/res/values-tr/strings.xml | 6 +- app/src/main/res/values-uk/strings.xml | 6 +- app/src/main/res/values-vi/strings.xml | 2 +- app/src/main/res/values-zh/strings.xml | 6 +- app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 42 +- .../contributions/ContributionDaoTest.kt | 3 - gradle.properties | 2 + 139 files changed, 4012 insertions(+), 3212 deletions(-) delete mode 100644 app/src/androidTest/java/fr/free/nrw/commons/upload/FileUtilsTest.java create mode 100644 app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java delete mode 100644 app/src/main/java/fr/free/nrw/commons/category/CategorizationFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java create mode 100644 app/src/main/java/fr/free/nrw/commons/category/CategoryRendererAdapter.java delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/DetectUnwantedPicturesAsync.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/DexterPermissionObtainer.java delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/ExistingFileAsync.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/HeightLimitedRecyclerView.java delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/MultipleShareActivity.java delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/MultipleUploadListFragment.java delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/ShareActivity.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.java delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/SingleUploadFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/ThumbnailClickedListener.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/Title.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadCategoriesAdapterFactory.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadCategoriesRenderer.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailRenderer.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailsAdapterFactory.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadView.java create mode 100644 app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.java create mode 100644 app/src/main/java/fr/free/nrw/commons/utils/BiMap.java create mode 100644 app/src/main/res/drawable/ic_error_red_24dp.xml create mode 100644 app/src/main/res/drawable/ic_expand_less_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_expand_less_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_expand_more_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_expand_more_white_24dp.xml create mode 100644 app/src/main/res/layout/activity_upload.xml create mode 100644 app/src/main/res/layout/activity_upload_bottom_card.xml create mode 100644 app/src/main/res/layout/activity_upload_categories.xml create mode 100644 app/src/main/res/layout/activity_upload_license.xml create mode 100644 app/src/main/res/layout/activity_upload_please_wait.xml create mode 100644 app/src/main/res/layout/activity_upload_right_card.xml create mode 100644 app/src/main/res/layout/activity_upload_top_card.xml delete mode 100644 app/src/main/res/layout/fragment_categorization.xml create mode 100644 app/src/main/res/layout/item_upload_thumbnail.xml create mode 100644 app/src/main/res/layout/layout_upload_categories_item.xml create mode 100644 app/src/main/res/layout/row_item_title.xml diff --git a/app/build.gradle b/app/build.gradle index 2b32d1a3d..6118d99d5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,6 +31,7 @@ dependencies { transitive = true } implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0' + //noinspection GradleCompatible implementation "com.android.support:support-v4:$SUPPORT_LIB_VERSION" implementation "com.android.support:appcompat-v7:$SUPPORT_LIB_VERSION" @@ -43,6 +44,7 @@ dependencies { implementation 'com.squareup.okio:okio:1.14.0' implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' // Because RxAndroid releases are few and far between, it is recommended you also + // explicitly depend on RxJava's latest version for bug fixes and new features. implementation 'io.reactivex.rxjava2:rxjava:2.2.0' implementation 'com.jakewharton.rxbinding2:rxbinding:2.1.1' @@ -130,7 +132,7 @@ android { flavorDimensions 'tier' productFlavors { prod { - + applicationId 'fr.free.nrw.commons' buildConfigField "String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"" diff --git a/app/src/androidTest/java/fr/free/nrw/commons/upload/FileUtilsTest.java b/app/src/androidTest/java/fr/free/nrw/commons/upload/FileUtilsTest.java deleted file mode 100644 index 636d30a1b..000000000 --- a/app/src/androidTest/java/fr/free/nrw/commons/upload/FileUtilsTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.net.Uri; -import android.support.test.InstrumentationRegistry; -import android.support.test.runner.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import fr.free.nrw.commons.BuildConfig; - -import static org.hamcrest.CoreMatchers.is; -import static org.junit.Assert.assertThat; - -@RunWith(AndroidJUnit4.class) -public class FileUtilsTest { - @Test - public void isSelfOwned() throws Exception { - Uri uri = Uri.parse("content://" + BuildConfig.APPLICATION_ID + ".provider/document/1"); - boolean selfOwned = FileUtils.isSelfOwned(InstrumentationRegistry.getTargetContext(), uri); - assertThat(selfOwned, is(true)); - } - - @Test - public void isNotSelfOwned() throws Exception { - Uri uri = Uri.parse("content://com.android.providers.media.documents/document/1"); - boolean selfOwned = FileUtils.isSelfOwned(InstrumentationRegistry.getTargetContext(), uri); - assertThat(selfOwned, is(false)); - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6cbead479..ad76ee14d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ - + @@ -41,42 +41,35 @@ - - + + - - - - + + + - - - - + - + @@ -172,21 +165,18 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" /> - - - Map arraysToMap(K[] kArray, V[] vArray){ + if(kArray.length!=vArray.length) + throw new RuntimeException("arraysToMap array sizes don't match"); + Map map=new LinkedHashMap<>(); + for (int i=0;i create(List placeList) { + public CategoryRendererAdapter create(List placeList) { RendererBuilder builder = new RendererBuilder() .bind(CategoryItem.class, new CategoriesRenderer(listener)); ListAdapteeCollection collection = new ListAdapteeCollection<>( placeList != null ? placeList : Collections.emptyList()); - return new RVRendererAdapter<>(builder, collection); + return new CategoryRendererAdapter(builder, collection); } } diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java new file mode 100644 index 000000000..8375f4972 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java @@ -0,0 +1,227 @@ +package fr.free.nrw.commons.category; + +import android.content.SharedPreferences; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Named; + +import fr.free.nrw.commons.mwapi.MediaWikiApi; +import fr.free.nrw.commons.upload.GpsCategoryModel; +import fr.free.nrw.commons.utils.StringSortingUtils; +import io.reactivex.Observable; +import timber.log.Timber; + +public class CategoriesModel implements CategoryClickedListener { + private static final int SEARCH_CATS_LIMIT = 25; + + private final MediaWikiApi mwApi; + private final CategoryDao categoryDao; + private final SharedPreferences prefs; + private final SharedPreferences directPrefs; + + private HashMap> categoriesCache; + private List selectedCategories; + + @Inject GpsCategoryModel gpsCategoryModel; + @Inject + public CategoriesModel(MediaWikiApi mwApi, + CategoryDao categoryDao, + @Named("default_preferences") SharedPreferences prefs, + @Named("direct_nearby_upload_prefs") SharedPreferences directPrefs) { + this.mwApi = mwApi; + this.categoryDao = categoryDao; + this.prefs = prefs; + this.directPrefs = directPrefs; + this.categoriesCache = new HashMap<>(); + this.selectedCategories = new ArrayList<>(); + } + + //region Misc. utility methods + public Comparator sortBySimilarity(final String filter) { + Comparator stringSimilarityComparator = StringSortingUtils.sortBySimilarity(filter); + return (firstItem, secondItem) -> stringSimilarityComparator + .compare(firstItem.getName(), secondItem.getName()); + } + + public boolean containsYear(String item) { + //Check for current and previous year to exclude these categories from removal + Calendar now = Calendar.getInstance(); + int year = now.get(Calendar.YEAR); + String yearInString = String.valueOf(year); + + int prevYear = year - 1; + String prevYearInString = String.valueOf(prevYear); + Timber.d("Previous year: %s", prevYearInString); + + //Check if item contains a 4-digit word anywhere within the string (.* is wildcard) + //And that item does not equal the current year or previous year + //And if it is an irrelevant category such as Media_needing_categories_as_of_16_June_2017(Issue #750) + //Check if the year in the form of XX(X)0s is relevant, i.e. in the 2000s or 2010s as stated in Issue #1029 + return ((item.matches(".*(19|20)\\d{2}.*") && !item.contains(yearInString) && !item.contains(prevYearInString)) + || item.matches("(.*)needing(.*)") || item.matches("(.*)taken on(.*)") + || (item.matches(".*0s.*") && !item.matches(".*(200|201)0s.*"))); + } + + public void updateCategoryCount(CategoryItem item) { + Category category = categoryDao.find(item.getName()); + + // Newly used category... + if (category == null) { + category = new Category(null, item.getName(), new Date(), 0); + } + + category.incTimesUsed(); + categoryDao.save(category); + } + //endregion + + //region Category Caching + public void cacheAll(HashMap> categories) { + categoriesCache.putAll(categories); + } + + public HashMap> getCategoriesCache() { + return categoriesCache; + } + + boolean cacheContainsKey(String term) { + return categoriesCache.containsKey(term); + } + //endregion + + //region Category searching + public Observable searchAll(String term, List imageTitleList) { + //If user hasn't typed anything in yet, get GPS and recent items + if (TextUtils.isEmpty(term)) { + return gpsCategories() + .concatWith(titleCategories(imageTitleList)) + .concatWith(recentCategories()); + } + + //if user types in something that is in cache, return cached category + if (cacheContainsKey(term)) { + return Observable.fromIterable(getCachedCategories(term)) + .map(name -> new CategoryItem(name, false)); + } + + //otherwise, search API for matching categories + return mwApi + .allCategories(term, SEARCH_CATS_LIMIT) + .map(name -> new CategoryItem(name, false)); + } + + public Observable searchCategories(String term, List imageTitleList) { + //If user hasn't typed anything in yet, get GPS and recent items + if (TextUtils.isEmpty(term)) { + return gpsCategories() + .concatWith(titleCategories(imageTitleList)) + .concatWith(recentCategories()); + } + + return mwApi + .searchCategories(term, SEARCH_CATS_LIMIT) + .map(s -> new CategoryItem(s, false)); + } + + private ArrayList getCachedCategories(String term) { + return categoriesCache.get(term); + } + + public Observable defaultCategories(List titleList) { + Observable directCat = directCategories(); + if (hasDirectCategories()) { + Timber.d("Image has direct Cat"); + return directCat + .concatWith(gpsCategories()) + .concatWith(titleCategories(titleList)) + .concatWith(recentCategories()); + } else { + Timber.d("Image has no direct Cat"); + return gpsCategories() + .concatWith(titleCategories(titleList)) + .concatWith(recentCategories()); + } + } + + private boolean hasDirectCategories() { + return !directPrefs.getString("Category", "").equals(""); + } + + private Observable directCategories() { + String directCategory = directPrefs.getString("Category", ""); + List categoryList = new ArrayList<>(); + Timber.d("Direct category found: " + directCategory); + + if (!directCategory.equals("")) { + categoryList.add(directCategory); + Timber.d("DirectCat does not equal emptyString. Direct Cat list has " + categoryList); + } + return Observable.fromIterable(categoryList).map(name -> new CategoryItem(name, false)); + } + + Observable gpsCategories() { + return Observable.fromIterable(gpsCategoryModel.getCategoryList()) + .map(name -> new CategoryItem(name, false)); + } + + private Observable titleCategories(List titleList) { + return Observable.fromIterable(titleList) + .concatMap(this::getTitleCategories); + } + + private Observable getTitleCategories(String title) { + return mwApi.searchTitles(title, SEARCH_CATS_LIMIT) + .map(name -> new CategoryItem(name, false)); + } + + private Observable recentCategories() { + return Observable.fromIterable(categoryDao.recentCategories(SEARCH_CATS_LIMIT)) + .map(s -> new CategoryItem(s, false)); + } + //endregion + + //region Category Selection + @Override + public void categoryClicked(CategoryItem item) { + if (item.isSelected()) { + selectCategory(item); + updateCategoryCount(item); + } else { + unselectCategory(item); + } + } + + public void selectCategory(CategoryItem item) { + selectedCategories.add(item); + } + + public void unselectCategory(CategoryItem item) { + selectedCategories.remove(item); + } + + public int selectedCategoriesCount() { + return selectedCategories.size(); + } + + public List getSelectedCategories() { + return selectedCategories; + } + + public List getCategoryStringList() { + List output = new ArrayList<>(); + for (CategoryItem item : selectedCategories) { + output.add(item.getName()); + } + return output; + } + //endregion + +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java b/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java index 81cccdb72..f9a349ccb 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesRenderer.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.category; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -11,7 +12,7 @@ import butterknife.BindView; import butterknife.ButterKnife; import fr.free.nrw.commons.R; -class CategoriesRenderer extends Renderer { +public class CategoriesRenderer extends Renderer { @BindView(R.id.tvName) CheckedTextView checkedView; private final CategoryClickedListener listener; @@ -44,11 +45,8 @@ class CategoriesRenderer extends Renderer { @Override public void render() { CategoryItem item = getContent(); + Log.e("Commons", "Rendering: "+item); checkedView.setChecked(item.isSelected()); checkedView.setText(item.getName()); } - - interface CategoryClickedListener { - void categoryClicked(CategoryItem item); - } } diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategorizationFragment.java b/app/src/main/java/fr/free/nrw/commons/category/CategorizationFragment.java deleted file mode 100644 index c53c29379..000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategorizationFragment.java +++ /dev/null @@ -1,421 +0,0 @@ -package fr.free.nrw.commons.category; - - -import android.content.SharedPreferences; -import android.os.Bundle; -import android.support.v7.app.AlertDialog; -import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.RecyclerView; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.ProgressBar; -import android.widget.TextView; - -import com.jakewharton.rxbinding2.view.RxView; -import com.jakewharton.rxbinding2.widget.RxTextView; -import com.pedrogomez.renderers.RVRendererAdapter; - -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Comparator; -import java.util.Date; -import java.util.HashMap; -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.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.mwapi.MediaWikiApi; -import fr.free.nrw.commons.upload.GpsCategoryModel; -import fr.free.nrw.commons.utils.StringSortingUtils; -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.KeyEvent.ACTION_UP; -import static android.view.KeyEvent.KEYCODE_BACK; - -/** - * Displays the category suggestion and selection screen. Category search is initiated here. - */ -public class CategorizationFragment extends CommonsDaggerSupportFragment { - - public static final int SEARCH_CATS_LIMIT = 25; - - @BindView(R.id.categoriesListBox) - RecyclerView categoriesList; - @BindView(R.id.categoriesSearchBox) - EditText categoriesFilter; - @BindView(R.id.categoriesSearchInProgress) - ProgressBar categoriesSearchInProgress; - @BindView(R.id.categoriesNotFound) - TextView categoriesNotFoundView; - @BindView(R.id.categoriesExplanation) - TextView categoriesSkip; - - @Inject MediaWikiApi mwApi; - @Inject @Named("default_preferences") SharedPreferences prefs; - @Inject @Named("prefs") SharedPreferences prefsPrefs; - @Inject @Named("direct_nearby_upload_prefs") SharedPreferences directPrefs; - @Inject CategoryDao categoryDao; - @Inject GpsCategoryModel gpsCategoryModel; - - private RVRendererAdapter categoriesAdapter; - private OnCategoriesSaveHandler onCategoriesSaveHandler; - private HashMap> categoriesCache; - private List selectedCategories = new ArrayList<>(); - private TitleTextWatcher textWatcher = new TitleTextWatcher(); - private boolean hasDirectCategories = false; - - private final CategoriesAdapterFactory adapterFactory = new CategoriesAdapterFactory(item -> { - if (item.isSelected()) { - selectedCategories.add(item); - updateCategoryCount(item); - } else { - selectedCategories.remove(item); - } - }); - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - View rootView = inflater.inflate(R.layout.fragment_categorization, container, false); - ButterKnife.bind(this, rootView); - - categoriesList.setLayoutManager(new LinearLayoutManager(getContext())); - - ArrayList items = new ArrayList<>(); - categoriesCache = new HashMap<>(); - if (savedInstanceState != null) { - items.addAll(savedInstanceState.getParcelableArrayList("currentCategories")); - //noinspection unchecked - categoriesCache.putAll((HashMap>) savedInstanceState - .getSerializable("categoriesCache")); - } - - categoriesAdapter = adapterFactory.create(items); - categoriesList.setAdapter(categoriesAdapter); - - - categoriesFilter.addTextChangedListener(textWatcher); - - categoriesFilter.setOnFocusChangeListener((v, hasFocus) -> { - if (!hasFocus) { - ViewUtil.hideKeyboard(v); - } - }); - - RxTextView.textChanges(categoriesFilter) - .takeUntil(RxView.detaches(categoriesFilter)) - .debounce(500, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(filter -> updateCategoryList(filter.toString())); - return rootView; - } - - @Override - public void onDestroyView() { - categoriesFilter.removeTextChangedListener(textWatcher); - super.onDestroyView(); - } - - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - menu.clear(); - inflater.inflate(R.menu.fragment_categorization, menu); - } - - @Override - public void onResume() { - super.onResume(); - - View rootView = getView(); - if (rootView != null) { - rootView.setFocusableInTouchMode(true); - rootView.requestFocus(); - rootView.setOnKeyListener((v, keyCode, event) -> { - if (event.getAction() == ACTION_UP && keyCode == KEYCODE_BACK) { - showBackButtonDialog(); - return true; - } - return false; - }); - } - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - int itemCount = categoriesAdapter.getItemCount(); - ArrayList items = new ArrayList<>(itemCount); - for (int i = 0; i < itemCount; i++) { - items.add(categoriesAdapter.getItem(i)); - } - outState.putParcelableArrayList("currentCategories", items); - outState.putSerializable("categoriesCache", categoriesCache); - } - - @Override - public boolean onOptionsItemSelected(MenuItem menuItem) { - switch (menuItem.getItemId()) { - case R.id.menu_save_categories: - if (selectedCategories.size() > 0) { - //Some categories selected, proceed to submission - onCategoriesSaveHandler.onCategoriesSave(getStringList(selectedCategories)); - } else { - //No categories selected, prompt the user to select some - showConfirmationDialog(); - } - return true; - default: - return super.onOptionsItemSelected(menuItem); - } - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - setHasOptionsMenu(true); - onCategoriesSaveHandler = (OnCategoriesSaveHandler) getActivity(); - getActivity().setTitle(R.string.categories_activity_title); - } - - private void updateCategoryList(String filter) { - Observable.fromIterable(selectedCategories) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnSubscribe(disposable -> { - categoriesSearchInProgress.setVisibility(View.VISIBLE); - categoriesNotFoundView.setVisibility(View.GONE); - categoriesSkip.setVisibility(View.GONE); - categoriesAdapter.clear(); - }) - .observeOn(Schedulers.io()) - .concatWith( - searchAll(filter) - .mergeWith(searchCategories(filter)) - .concatWith(TextUtils.isEmpty(filter) - ? defaultCategories() : Observable.empty()) - ) - .filter(categoryItem -> !containsYear(categoryItem.getName())) - .distinct() - .sorted(sortBySimilarity(filter)) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - s -> categoriesAdapter.add(s), - Timber::e, - () -> { - categoriesAdapter.notifyDataSetChanged(); - categoriesSearchInProgress.setVisibility(View.GONE); - - if (categoriesAdapter.getItemCount() == selectedCategories.size()) { - // There are no suggestions - if (TextUtils.isEmpty(filter)) { - // Allow to send image with no categories - categoriesSkip.setVisibility(View.VISIBLE); - } else { - // Inform the user that the searched term matches no category - categoriesNotFoundView.setText(getString(R.string.categories_not_found, filter)); - categoriesNotFoundView.setVisibility(View.VISIBLE); - } - } - } - ); - } - - private Comparator sortBySimilarity(final String filter) { - Comparator stringSimilarityComparator = StringSortingUtils.sortBySimilarity(filter); - return (firstItem, secondItem) -> stringSimilarityComparator - .compare(firstItem.getName(), secondItem.getName()); - } - - private List getStringList(List input) { - List output = new ArrayList<>(); - for (CategoryItem item : input) { - output.add(item.getName()); - } - return output; - } - - private Observable defaultCategories() { - Observable directCat = directCategories(); - if (hasDirectCategories) { - Timber.d("Image has direct Cat"); - return directCat - .concatWith(gpsCategories()) - .concatWith(titleCategories()) - .concatWith(recentCategories()); - } - else { - Timber.d("Image has no direct Cat"); - return gpsCategories() - .concatWith(titleCategories()) - .concatWith(recentCategories()); - } - } - - private Observable directCategories() { - String directCategory = directPrefs.getString("Category", ""); - // Strip newlines to prevent blank categories, and to tidy existing categories - directCategory = directCategory.replace("\n", ""); - - List categoryList = new ArrayList<>(); - Timber.d("Direct category found: " + "'" + directCategory + "'"); - - if (!directCategory.equals("")) { - hasDirectCategories = true; - categoryList.add(directCategory); - Timber.d("DirectCat does not equal emptyString. Direct Cat list has " + categoryList); - } - return Observable.fromIterable(categoryList).map(name -> new CategoryItem(name, false)); - } - - private Observable gpsCategories() { - return Observable.fromIterable(gpsCategoryModel.getCategoryList()) - .map(name -> new CategoryItem(name, false)); - } - - private Observable titleCategories() { - //Retrieve the title that was saved when user tapped submit icon - String title = prefs.getString("Title", ""); - - return mwApi - .searchTitles(title, SEARCH_CATS_LIMIT) - .map(name -> new CategoryItem(name, false)); - } - - private Observable recentCategories() { - return Observable.fromIterable(categoryDao.recentCategories(SEARCH_CATS_LIMIT)) - .map(s -> new CategoryItem(s, false)); - } - - private Observable searchAll(String term) { - //If user hasn't typed anything in yet, get GPS and recent items - if (TextUtils.isEmpty(term)) { - return Observable.empty(); - } - - //if user types in something that is in cache, return cached category - if (categoriesCache.containsKey(term)) { - return Observable.fromIterable(categoriesCache.get(term)) - .map(name -> new CategoryItem(name, false)); - } - - //otherwise, search API for matching categories - return mwApi - .allCategories(term, SEARCH_CATS_LIMIT) - .map(name -> new CategoryItem(name, false)); - } - - private Observable searchCategories(String term) { - //If user hasn't typed anything in yet, get GPS and recent items - if (TextUtils.isEmpty(term)) { - return Observable.empty(); - } - - return mwApi - .searchCategories(term, SEARCH_CATS_LIMIT) - .map(s -> new CategoryItem(s, false)); - } - - private boolean containsYear(String item) { - //Check for current and previous year to exclude these categories from removal - Calendar now = Calendar.getInstance(); - int year = now.get(Calendar.YEAR); - String yearInString = String.valueOf(year); - - int prevYear = year - 1; - String prevYearInString = String.valueOf(prevYear); - Timber.d("Previous year: %s", prevYearInString); - - //Check if item contains a 4-digit word anywhere within the string (.* is wildcard) - //And that item does not equal the current year or previous year - //And if it is an irrelevant category such as Media_needing_categories_as_of_16_June_2017(Issue #750) - //Check if the year in the form of XX(X)0s is relevant, i.e. in the 2000s or 2010s as stated in Issue #1029 - return ((item.matches(".*(19|20)\\d{2}.*") && !item.contains(yearInString) && !item.contains(prevYearInString)) - || item.matches("(.*)needing(.*)") || item.matches("(.*)taken on(.*)") - || (item.matches(".*0s.*") && !item.matches(".*(200|201)0s.*"))); - } - - private void updateCategoryCount(CategoryItem item) { - Category category = categoryDao.find(item.getName()); - - // Newly used category... - if (category == null) { - category = new Category(null, item.getName(), new Date(), 0); - } - - category.incTimesUsed(); - categoryDao.save(category); - } - - public int getCurrentSelectedCount() { - return selectedCategories.size(); - } - - /** - * Show dialog asking for confirmation to leave without saving categories. - */ - public void showBackButtonDialog() { - new AlertDialog.Builder(getActivity()) - .setMessage("Are you sure you want to go back? The image will not " - + "have any categories saved.") - .setTitle("Warning") - .setPositiveButton(android.R.string.no, (dialog, id) -> { - //No need to do anything, user remains on categorization screen - }) - .setNegativeButton(android.R.string.yes, (dialog, id) -> getActivity().finish()) - .create() - .show(); - } - - private void showConfirmationDialog() { - new AlertDialog.Builder(getActivity()) - .setMessage("Images without categories are rarely usable. " - + "Are you sure you want to submit without selecting " - + "categories?") - .setTitle("No Categories Selected") - .setPositiveButton(android.R.string.no, (dialog, id) -> { - //Exit menuItem so user can select their categories - }) - .setNegativeButton(android.R.string.yes, (dialog, id) -> { - //Proceed to submission - onCategoriesSaveHandler.onCategoriesSave(getStringList(selectedCategories)); - }) - .create() - .show(); - } - - private class TitleTextWatcher implements TextWatcher { - @Override - public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) { - } - - @Override - public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) { - } - - @Override - public void afterTextChanged(Editable editable) { - if (getActivity() != null) { - getActivity().invalidateOptionsMenu(); - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java new file mode 100644 index 000000000..df99b4060 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.category; + +public interface CategoryClickedListener { + void categoryClicked(CategoryItem item); +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.java index f6bacfb51..f3ade09d8 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.java +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryItem.java @@ -3,7 +3,7 @@ package fr.free.nrw.commons.category; import android.os.Parcel; import android.os.Parcelable; -class CategoryItem implements Parcelable { +public class CategoryItem implements Parcelable { private final String name; private boolean selected; @@ -71,4 +71,9 @@ class CategoryItem implements Parcelable { public int hashCode() { return name.hashCode(); } + + @Override + public String toString() { + return "CategoryItem: '" + name + '\''; + } } diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryRendererAdapter.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryRendererAdapter.java new file mode 100644 index 000000000..887ad595f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryRendererAdapter.java @@ -0,0 +1,22 @@ +package fr.free.nrw.commons.category; + +import com.pedrogomez.renderers.AdapteeCollection; +import com.pedrogomez.renderers.RVRendererAdapter; +import com.pedrogomez.renderers.RendererBuilder; + +import java.util.ArrayList; + +public class CategoryRendererAdapter extends RVRendererAdapter { + CategoryRendererAdapter(RendererBuilder rendererBuilder, AdapteeCollection collection) { + super(rendererBuilder, collection); + } + + protected ArrayList allItems() { + int itemCount = getItemCount(); + ArrayList items = new ArrayList<>(itemCount); + for (int i = 0; i < itemCount; i++) { + items.add(getItem(i)); + } + return items; + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.java b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.java index 9f5084e6a..cdbc8c28d 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.java @@ -2,8 +2,11 @@ package fr.free.nrw.commons.contributions; import android.net.Uri; import android.os.Parcel; +import android.support.annotation.IntDef; import android.support.annotation.NonNull; +import android.support.annotation.StringDef; +import java.lang.annotation.Retention; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; @@ -13,6 +16,8 @@ import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.settings.Prefs; +import static java.lang.annotation.RetentionPolicy.SOURCE; + public class Contribution extends Media { public static Creator CREATOR = new Creator() { @@ -33,6 +38,10 @@ public class Contribution extends Media { public static final int STATE_QUEUED = 2; public static final int STATE_IN_PROGRESS = 3; + @Retention(SOURCE) + @StringDef({SOURCE_CAMERA, SOURCE_GALLERY, SOURCE_EXTERNAL}) + public @interface FileSource {} + public static final String SOURCE_CAMERA = "camera"; public static final String SOURCE_GALLERY = "gallery"; public static final String SOURCE_EXTERNAL = "external"; @@ -40,7 +49,6 @@ public class Contribution extends Media { private Uri contentUri; private String source; private String editSummary; - private Date timestamp; private int state; private long transferred; private String decimalCoords; @@ -48,14 +56,13 @@ public class Contribution extends Media { private String wikiDataEntityId; private Uri contentProviderUri; - public Contribution(Uri contentUri, String filename, Uri localUri, String imageUrl, Date timestamp, + public Contribution(Uri contentUri, String filename, Uri localUri, String imageUrl, Date dateCreated, int state, long dataLength, Date dateUploaded, long transferred, String source, String description, String creator, boolean isMultiple, int width, int height, String license) { - super(localUri, imageUrl, filename, description, dataLength, timestamp, dateUploaded, creator); + super(localUri, imageUrl, filename, description, dataLength, dateCreated, dateUploaded, creator); this.contentUri = contentUri; this.state = state; - this.timestamp = timestamp; this.transferred = transferred; this.source = source; this.isMultiple = isMultiple; @@ -69,14 +76,12 @@ public class Contribution extends Media { super(localUri, imageUrl, filename, description, dataLength, dateCreated, dateUploaded, creator); this.decimalCoords = decimalCoords; this.editSummary = editSummary; - timestamp = new Date(System.currentTimeMillis()); } public Contribution(Parcel in) { super(in); contentUri = in.readParcelable(Uri.class.getClassLoader()); source = in.readString(); - timestamp = (Date) in.readSerializable(); state = in.readInt(); transferred = in.readLong(); isMultiple = in.readInt() == 1; @@ -87,12 +92,13 @@ public class Contribution extends Media { super.writeToParcel(parcel, flags); parcel.writeParcelable(contentUri, flags); parcel.writeString(source); - parcel.writeSerializable(timestamp); parcel.writeInt(state); parcel.writeLong(transferred); parcel.writeInt(isMultiple ? 1 : 0); } + + public boolean getMultiple() { return isMultiple; } @@ -121,14 +127,6 @@ public class Contribution extends Media { this.contentUri = contentUri; } - public Date getTimestamp() { - return timestamp; - } - - public void setTimestamp(Date timestamp) { - this.timestamp = timestamp; - } - public int getState() { return state; } @@ -141,10 +139,6 @@ public class Contribution extends Media { this.dateUploaded = date; } - public String getTrackingTemplates() { - return "{{subst:unc}}"; // Remove when we have categorization - } - public String getPageContents() { StringBuilder buffer = new StringBuilder(); SimpleDateFormat isoFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH); @@ -169,8 +163,15 @@ public class Contribution extends Media { buffer.append("== {{int:license-header}} ==\n") .append(licenseTemplateFor(getLicense())).append("\n\n") - .append("{{Uploaded from Mobile|platform=Android|version=").append(BuildConfig.VERSION_NAME).append("}}\n") - .append(getTrackingTemplates()); + .append("{{Uploaded from Mobile|platform=Android|version=").append(BuildConfig.VERSION_NAME).append("}}\n"); + if(categories!=null&&categories.size()!=0) { + for (int i = 0; i < categories.size(); i++) { + String category = categories.get(i); + buffer.append("\n[[Category:").append(category).append("]]"); + } + } + else + buffer.append("{{subst:unc}}"); return buffer.toString(); } @@ -184,7 +185,7 @@ public class Contribution extends Media { } public Contribution() { - timestamp = new Date(System.currentTimeMillis()); + } public String getSource() { @@ -232,7 +233,7 @@ public class Contribution extends Media { /** * When the corresponding wikidata entity is known as in case of nearby uploads, it can be set * using the setter method - * @param wikiDataEntityId + * @param wikiDataEntityId wikiDataEntityId */ public void setWikiDataEntityId(String wikiDataEntityId) { this.wikiDataEntityId = wikiDataEntityId; diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java index 3aacb6155..c3a0f329b 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java @@ -5,22 +5,26 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.provider.MediaStore; import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentActivity; import android.support.v4.content.FileProvider; import java.io.File; +import java.util.ArrayList; import java.util.Date; import java.util.List; -import fr.free.nrw.commons.upload.ShareActivity; +import fr.free.nrw.commons.upload.UploadActivity; import timber.log.Timber; import static android.content.Intent.ACTION_GET_CONTENT; import static android.content.Intent.ACTION_SEND; +import static android.content.Intent.ACTION_SEND_MULTIPLE; import static android.content.Intent.EXTRA_STREAM; import static fr.free.nrw.commons.contributions.Contribution.SOURCE_CAMERA; import static fr.free.nrw.commons.contributions.Contribution.SOURCE_GALLERY; @@ -31,6 +35,7 @@ public class ContributionController { public static final int SELECT_FROM_GALLERY = 1; public static final int SELECT_FROM_CAMERA = 2; + public static final int PICK_IMAGE_MULTIPLE = 3; private Fragment fragment; @@ -79,6 +84,14 @@ public class ContributionController { } public void startGalleryPick() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + startMultipleGalleryPick(); + } else { + startSingleGalleryPick(); + } + } + + public void startSingleGalleryPick() { //FIXME: Starts gallery (opens Google Photos) Intent pickImageIntent = new Intent(ACTION_GET_CONTENT); pickImageIntent.setType("image/*"); @@ -87,15 +100,41 @@ public class ContributionController { Timber.d("Fragment is not added, startActivityForResult cannot be called"); return; } - Timber.d("startGalleryPick() called with pickImageIntent"); + Timber.d("startSingleGalleryPick() called with pickImageIntent"); fragment.startActivityForResult(pickImageIntent, SELECT_FROM_GALLERY); } + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) + public void startMultipleGalleryPick() { + Intent pickImageIntent = new Intent(ACTION_GET_CONTENT); + pickImageIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + pickImageIntent.setType("image/*"); + if (!fragment.isAdded()) { + Timber.d("Fragment is not added, startActivityForResult cannot be called"); + return; + } + Timber.d("startMultipleGalleryPick() called with pickImageIntent"); + + fragment.startActivityForResult(pickImageIntent, PICK_IMAGE_MULTIPLE); + } + + public void handleImagesPicked(int requestCode, @Nullable ArrayList uri) { + FragmentActivity activity = fragment.getActivity(); + Intent shareIntent = new Intent(activity, UploadActivity.class); + shareIntent.setAction(ACTION_SEND_MULTIPLE); + shareIntent.putExtra(EXTRA_SOURCE, SOURCE_GALLERY); + shareIntent.putExtra(EXTRA_STREAM, uri); + shareIntent.setType("image/jpeg"); + if (activity != null) { + activity.startActivity(shareIntent); + } + } + public void handleImagePicked(int requestCode, @Nullable Uri uri, boolean isDirectUpload, String wikiDataEntityId) { FragmentActivity activity = fragment.getActivity(); Timber.d("handleImagePicked() called with onActivityResult(). Boolean isDirectUpload: " + isDirectUpload + "String wikiDataEntityId: " + wikiDataEntityId); - Intent shareIntent = new Intent(activity, ShareActivity.class); + Intent shareIntent = new Intent(activity, UploadActivity.class); shareIntent.setAction(ACTION_SEND); switch (requestCode) { case SELECT_FROM_GALLERY: diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java index 6d290b1a5..4c83af393 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java @@ -98,7 +98,8 @@ public class ContributionDao { cv.put(Table.COLUMN_UPLOADED, contribution.getDateUploaded().getTime()); } cv.put(Table.COLUMN_LENGTH, contribution.getDataLength()); - cv.put(Table.COLUMN_TIMESTAMP, contribution.getTimestamp().getTime()); + //This was always meant to store the date created..If somehow date created is not fetched while actually saving the contribution, lets save today's date + cv.put(Table.COLUMN_TIMESTAMP, contribution.getDateCreated()==null?System.currentTimeMillis():contribution.getDateCreated().getTime()); cv.put(Table.COLUMN_STATE, contribution.getState()); cv.put(Table.COLUMN_TRANSFERRED, contribution.getTransferred()); cv.put(Table.COLUMN_SOURCE, contribution.getSource()); diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java index 7a4294863..b238c8b8b 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java @@ -1,9 +1,11 @@ package fr.free.nrw.commons.contributions; +import android.content.ClipData; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.support.annotation.NonNull; @@ -11,6 +13,7 @@ import android.support.annotation.Nullable; import android.support.design.widget.FloatingActionButton; import android.support.v4.content.ContextCompat; import android.support.v7.app.AlertDialog; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -20,9 +23,9 @@ import android.widget.AdapterView; import android.widget.GridView; import android.widget.ListAdapter; import android.widget.ProgressBar; -import static android.content.pm.PackageManager.PERMISSION_GRANTED; import android.widget.TextView; +import java.util.ArrayList; import java.util.Arrays; import javax.inject.Inject; @@ -39,7 +42,9 @@ import timber.log.Timber; import static android.Manifest.permission.READ_EXTERNAL_STORAGE; import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; import static android.app.Activity.RESULT_OK; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; import static android.view.View.GONE; +import static fr.free.nrw.commons.contributions.ContributionController.SELECT_FROM_GALLERY; /** * Created by root on 01.06.2018. @@ -168,7 +173,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment { fabGalery.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - //Gallery crashes before reach ShareActivity screen so must implement permissions check here + //Gallery crashes before reach ShareActivity screen so must implement permissions check here if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // Here, thisActivity is the current activity @@ -251,6 +256,8 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment { // If coming from camera, pass null as uri. Because camera photos get saved to a // fixed directory controller.handleImagePicked(requestCode, null, false, null); + } else if (requestCode == ContributionController.PICK_IMAGE_MULTIPLE) { + handleMultipleImages(requestCode, data); } else if (requestCode == ContributionController.SELECT_FROM_GALLERY){ controller.handleImagePicked(requestCode, data.getData(), false, null); } @@ -294,6 +301,28 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment { } } + private void handleMultipleImages(int requestCode, Intent data) { + if (getContext() == null) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN + && data.getClipData() != null) { + ClipData mClipData = data.getClipData(); + ArrayList mArrayUri = new ArrayList(); + for (int i = 0; i < mClipData.getItemCount(); i++) { + + ClipData.Item item = mClipData.getItemAt(i); + Uri uri = item.getUri(); + mArrayUri.add(uri); + } + Log.v("LOG_TAG", "Selected Images" + mArrayUri.size()); + controller.handleImagesPicked(requestCode, mArrayUri); + } else if(data.getData() != null) { + controller.handleImagePicked(SELECT_FROM_GALLERY, data.getData(), false, null); + } + } + /** * Responsible to set progress bar invisible and visible 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 19cb09dc6..6ae133fa3 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 @@ -15,8 +15,7 @@ import fr.free.nrw.commons.explore.SearchActivity; import fr.free.nrw.commons.notification.NotificationActivity; import fr.free.nrw.commons.settings.SettingsActivity; -import fr.free.nrw.commons.upload.MultipleShareActivity; -import fr.free.nrw.commons.upload.ShareActivity; +import fr.free.nrw.commons.upload.UploadActivity; @Module @SuppressWarnings({"WeakerAccess", "unused"}) @@ -28,12 +27,6 @@ public abstract class ActivityBuilderModule { @ContributesAndroidInjector abstract WelcomeActivity bindWelcomeActivity(); - @ContributesAndroidInjector - abstract ShareActivity bindShareActivity(); - - @ContributesAndroidInjector - abstract MultipleShareActivity bindMultipleShareActivity(); - @ContributesAndroidInjector abstract MainActivity bindContributionsActivity(); @@ -52,6 +45,9 @@ public abstract class ActivityBuilderModule { @ContributesAndroidInjector abstract CategoryImagesActivity bindFeaturedImagesActivity(); + @ContributesAndroidInjector + abstract UploadActivity bindUploadActivity(); + @ContributesAndroidInjector abstract SearchActivity bindSearchActivity(); 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 d54b556c4..8c019da5d 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 @@ -1,10 +1,17 @@ package fr.free.nrw.commons.di; +import android.app.Activity; import android.content.ContentProviderClient; import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; import android.support.v4.util.LruCache; +import android.view.inputmethod.InputMethodManager; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import javax.inject.Named; import javax.inject.Singleton; @@ -12,12 +19,14 @@ import javax.inject.Singleton; import dagger.Module; import dagger.Provides; import fr.free.nrw.commons.BuildConfig; +import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.AccountUtil; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.data.DBOpenHelper; import fr.free.nrw.commons.location.LocationServiceManager; import fr.free.nrw.commons.mwapi.MediaWikiApi; import fr.free.nrw.commons.nearby.NearbyPlaces; +import fr.free.nrw.commons.settings.Prefs; import fr.free.nrw.commons.upload.UploadController; import fr.free.nrw.commons.wikidata.WikidataEditListener; import fr.free.nrw.commons.wikidata.WikidataEditListenerImpl; @@ -38,6 +47,35 @@ public class CommonsApplicationModule { return this.applicationContext; } + @Provides + public InputMethodManager provideInputMethodManager() { + return (InputMethodManager) applicationContext.getSystemService(Activity.INPUT_METHOD_SERVICE); + } + + @Provides + @Named("licenses") + public List provideLicenses(Context context) { + List licenseItems = new ArrayList<>(); + licenseItems.add(context.getString(R.string.license_name_cc0)); + licenseItems.add(context.getString(R.string.license_name_cc_by)); + licenseItems.add(context.getString(R.string.license_name_cc_by_sa)); + licenseItems.add(context.getString(R.string.license_name_cc_by_four)); + licenseItems.add(context.getString(R.string.license_name_cc_by_sa_four)); + return licenseItems; + } + + @Provides + @Named("licenses_by_name") + public Map provideLicensesByName(Context context) { + Map byName = new HashMap<>(); + byName.put(context.getString(R.string.license_name_cc0), Prefs.Licenses.CC0); + byName.put(context.getString(R.string.license_name_cc_by), Prefs.Licenses.CC_BY_3); + byName.put(context.getString(R.string.license_name_cc_by_sa), Prefs.Licenses.CC_BY_SA_3); + byName.put(context.getString(R.string.license_name_cc_by_four), Prefs.Licenses.CC_BY_4); + byName.put(context.getString(R.string.license_name_cc_by_sa_four), Prefs.Licenses.CC_BY_SA_4); + return byName; + } + @Provides public AccountUtil providesAccountUtil(Context context) { return new AccountUtil(context); 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 04804dab0..b664d49e1 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 @@ -4,7 +4,6 @@ import dagger.Module; import dagger.android.ContributesAndroidInjector; import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment; import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment; -import fr.free.nrw.commons.category.CategorizationFragment; import fr.free.nrw.commons.category.CategoryImagesListFragment; import fr.free.nrw.commons.category.SubCategoryListFragment; import fr.free.nrw.commons.contributions.ContributionsFragment; @@ -19,16 +18,11 @@ import fr.free.nrw.commons.nearby.NearbyListFragment; import fr.free.nrw.commons.nearby.NearbyMapFragment; import fr.free.nrw.commons.nearby.NoPermissionsFragment; import fr.free.nrw.commons.settings.SettingsFragment; -import fr.free.nrw.commons.upload.MultipleUploadListFragment; -import fr.free.nrw.commons.upload.SingleUploadFragment; @Module @SuppressWarnings({"WeakerAccess", "unused"}) public abstract class FragmentBuilderModule { - @ContributesAndroidInjector - abstract CategorizationFragment bindCategorizationFragment(); - @ContributesAndroidInjector abstract ContributionsListFragment bindContributionsListFragment(); @@ -50,12 +44,6 @@ public abstract class FragmentBuilderModule { @ContributesAndroidInjector abstract SettingsFragment bindSettingsFragment(); - @ContributesAndroidInjector - abstract MultipleUploadListFragment bindMultipleUploadListFragment(); - - @ContributesAndroidInjector - abstract SingleUploadFragment bindSingleUploadFragment(); - @ContributesAndroidInjector abstract CategoryImagesListFragment bindFeaturedImagesListFragment(); 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 index 43fcf4460..8b28f50d5 100644 --- 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 @@ -1,6 +1,7 @@ package fr.free.nrw.commons.explore.images; +import android.annotation.SuppressLint; import android.content.SharedPreferences; import android.content.res.Configuration; import android.os.Bundle; @@ -123,6 +124,7 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment { * 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 */ + @SuppressLint("CheckResult") public void updateImageList(String query) { this.query = query; if (imagesNotFoundView != null) { @@ -146,6 +148,7 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment { /** * Adds more results to existing search results */ + @SuppressLint("CheckResult") public void addImagesToList(String query) { this.query = query; progressBar.setVisibility(View.VISIBLE); @@ -163,13 +166,11 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment { */ private void handlePaginationSuccess(List mediaList) { progressBar.setVisibility(View.GONE); - if (mediaList.size()!=0){ - if (!queryList.get(queryList.size()-1).getFilename().equals(mediaList.get(mediaList.size()-1).getFilename())) { - queryList.addAll(mediaList); - imagesAdapter.addAll(mediaList); - imagesAdapter.notifyDataSetChanged(); - ((SearchActivity)getContext()).viewPagerNotifyDataSetChanged(); - } + if (mediaList.size() != 0 || !queryList.get(queryList.size() - 1).getFilename().equals(mediaList.get(mediaList.size() - 1).getFilename())) { + queryList.addAll(mediaList); + imagesAdapter.addAll(mediaList); + imagesAdapter.notifyDataSetChanged(); + ((SearchActivity) getContext()).viewPagerNotifyDataSetChanged(); } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/DirectUpload.java b/app/src/main/java/fr/free/nrw/commons/nearby/DirectUpload.java index e7ccd97d5..5d82b5492 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/DirectUpload.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/DirectUpload.java @@ -47,11 +47,11 @@ class DirectUpload { fragment.getActivity().requestPermissions(new String[]{READ_EXTERNAL_STORAGE}, PermissionUtils.GALLERY_PERMISSION_FROM_NEARBY_MAP); } } else { - controller.startGalleryPick(); + controller.startSingleGalleryPick(); } } else { - controller.startGalleryPick(); + controller.startSingleGalleryPick(); } } diff --git a/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.java b/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.java index 8ef8e84a4..9668f11ee 100644 --- a/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/theme/BaseActivity.java @@ -8,7 +8,7 @@ import fr.free.nrw.commons.R; import fr.free.nrw.commons.di.CommonsDaggerAppCompatActivity; public abstract class BaseActivity extends CommonsDaggerAppCompatActivity { - boolean currentTheme; + protected boolean currentTheme; @Override protected void onCreate(Bundle savedInstanceState) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/Description.java b/app/src/main/java/fr/free/nrw/commons/upload/Description.java index b19992574..ae18d4adb 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/Description.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/Description.java @@ -1,56 +1,72 @@ package fr.free.nrw.commons.upload; -import android.text.TextUtils; +import java.util.List; +/** + * Holds a description of an item being uploaded by {@link UploadActivity} + */ class Description { - private String languageId; - private String languageDisplayText; + private String languageCode; private String descriptionText; - private boolean set; private int selectedLanguageIndex = -1; - public String getLanguageId() { - return languageId; + /** + * @return The language code ie. "en" or "fr" + */ + String getLanguageCode() { + return languageCode; } - public void setLanguageId(String languageId) { - this.languageId = languageId; + /** + * @param languageCode The language code ie. "en" or "fr" + */ + void setLanguageCode(String languageCode) { + this.languageCode = languageCode; } - public String getLanguageDisplayText() { - return languageDisplayText; - } - - public void setLanguageDisplayText(String languageDisplayText) { - this.languageDisplayText = languageDisplayText; - } - - public String getDescriptionText() { + String getDescriptionText() { return descriptionText; } - public void setDescriptionText(String descriptionText) { + void setDescriptionText(String descriptionText) { this.descriptionText = descriptionText; - - if (!TextUtils.isEmpty(descriptionText)) { - set = true; - } } - public boolean isSet() { - return set; - } - - public void setSet(boolean set) { - this.set = set; - } - - public int getSelectedLanguageIndex() { + /** + * @return the index of the language selected in a spinner with {@link SpinnerLanguagesAdapter} + */ + int getSelectedLanguageIndex() { return selectedLanguageIndex; } - public void setSelectedLanguageIndex(int selectedLanguageIndex) { + /** + * @param selectedLanguageIndex the index of the language selected in a spinner with {@link SpinnerLanguagesAdapter} + */ + void setSelectedLanguageIndex(int selectedLanguageIndex) { this.selectedLanguageIndex = selectedLanguageIndex; } + + + /** + * Formats the list of descriptions into the format Commons requires for uploads. + * + * @param descriptions the list of descriptions, description is ignored if text is null. + * @return a string with the pattern of {{en|1=descriptionText}} + */ + static String formatList(List descriptions) { + StringBuilder descListString = new StringBuilder(); + for (Description description : descriptions) { + if (!description.isEmpty()) { + String individualDescription = String.format("{{%s|1=%s}}", description.getLanguageCode(), + description.getDescriptionText()); + descListString.append(individualDescription); + } + } + return descListString.toString(); + } + + public boolean isEmpty() { + return descriptionText == null || descriptionText.isEmpty(); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/DescriptionsAdapter.java b/app/src/main/java/fr/free/nrw/commons/upload/DescriptionsAdapter.java index 5dfd02cae..8c2432ee1 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/DescriptionsAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/DescriptionsAdapter.java @@ -2,12 +2,12 @@ package fr.free.nrw.commons.upload; import android.content.Context; import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.view.ViewCompat; import android.support.v7.widget.AppCompatSpinner; import android.support.v7.widget.RecyclerView; -import android.text.Editable; import android.text.TextUtils; -import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; @@ -17,182 +17,241 @@ import android.widget.AdapterView.OnItemSelectedListener; import android.widget.EditText; import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.Locale; import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnTouch; +import butterknife.Optional; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.utils.AbstractTextWatcher; +import fr.free.nrw.commons.utils.BiMap; import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.subjects.BehaviorSubject; +import io.reactivex.subjects.Subject; +import timber.log.Timber; import static android.view.MotionEvent.ACTION_UP; class DescriptionsAdapter extends RecyclerView.Adapter { - List descriptions; - List languages; + private Title title; + private List descriptions; private Context context; private Callback callback; + private Subject titleChangedSubject; - public DescriptionsAdapter() { + private BiMap selectedLanguages; + private UploadView uploadView; + + DescriptionsAdapter(UploadView uploadView) { + title = new Title(); descriptions = new ArrayList<>(); - descriptions.add(new Description()); - languages = new ArrayList<>(); + titleChangedSubject = BehaviorSubject.create(); + selectedLanguages = new BiMap<>(); + this.uploadView = uploadView; } - public void setCallback(Callback callback) { + void setCallback(Callback callback) { this.callback = callback; } - public void setDescriptions(List descriptions) { + void setItems(Title title, List descriptions) { this.descriptions = descriptions; + this.title = title; + selectedLanguages = new BiMap<>(); notifyDataSetChanged(); } - public void setLanguages(List languages) { - this.languages = languages; + @Override + public int getItemViewType(int position) { + if (position == 0) return 1; + else return 2; } + @NonNull @Override - public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.row_item_description, parent, false); + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view; + if (viewType == 1) { + view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.row_item_title, parent, false); + } else { + view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.row_item_description, parent, false); + } context = parent.getContext(); - ViewHolder viewHolder = new ViewHolder(view); - return viewHolder; + return new ViewHolder(view); } @Override - public void onBindViewHolder(ViewHolder holder, int position) { + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { holder.init(position); } @Override public int getItemCount() { - return descriptions.size(); + return descriptions.size() + 1; } - public List getDescriptions() { + List getDescriptions() { return descriptions; } - public void addDescription(Description description) { + void addDescription(Description description) { this.descriptions.add(description); - notifyItemInserted(descriptions.size() - 1); + notifyItemInserted(descriptions.size() + 1); } + public Title getTitle() { + return title; + } + + public void setTitle(Title title) { + this.title = title; + notifyItemInserted(0); + } public class ViewHolder extends RecyclerView.ViewHolder { + @Nullable @BindView(R.id.spinner_description_languages) AppCompatSpinner spinnerDescriptionLanguages; - @BindView(R.id.et_description_text) - EditText etDescriptionText; - private View view; + @BindView(R.id.description_item_edit_text) + EditText descItemEditText; + + private View view; public ViewHolder(View itemView) { super(itemView); ButterKnife.bind(this, itemView); this.view = itemView; + Timber.i("descItemEditText:" + descItemEditText); } public void init(int position) { - Description description = descriptions.get(position); - if (!TextUtils.isEmpty(description.getDescriptionText())) { - etDescriptionText.setText(description.getDescriptionText()); - } else { - etDescriptionText.setText(""); - } - Drawable drawableRight = context.getResources() - .getDrawable(R.drawable.mapbox_info_icon_default); - if (position != 0) { - etDescriptionText.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); - } else { - etDescriptionText.setCompoundDrawablesWithIntrinsicBounds(null, null, drawableRight, null); - } - - etDescriptionText.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { - - } - - @Override - public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { - - } - - @Override - public void afterTextChanged(Editable editable) { - description.setDescriptionText(editable.toString()); - } - }); - - etDescriptionText.setOnFocusChangeListener((v, hasFocus) -> { - if (!hasFocus) { - ViewUtil.hideKeyboard(v); - } - }); - - SpinnerLanguagesAdapter languagesAdapter = new SpinnerLanguagesAdapter(context, - R.layout.row_item_languages_spinner); - Collections.sort(languages, (language, t1) -> language.getLocale().getDisplayLanguage() - .compareTo(t1.getLocale().getDisplayLanguage().toString())); - languagesAdapter.setLanguages(languages); - languagesAdapter.notifyDataSetChanged(); - spinnerDescriptionLanguages.setAdapter(languagesAdapter); - - if (description.getSelectedLanguageIndex() == -1) { - if (position == 0) { - int defaultLocaleIndex = getIndexOfUserDefaultLocale(); - spinnerDescriptionLanguages.setSelection(defaultLocaleIndex); + if (position == 0) { + Timber.d("Title is " + title); + if (!title.isEmpty()) { + descItemEditText.setText(title.toString()); } else { - spinnerDescriptionLanguages.setSelection(0); + descItemEditText.setText(""); } + + descItemEditText.setCompoundDrawablesWithIntrinsicBounds(null, null, getInfoIcon(), null); + + descItemEditText.addTextChangedListener(new AbstractTextWatcher(titleText ->{ + title.setTitleText(titleText); + titleChangedSubject.onNext(titleText); + })); + + descItemEditText.setOnFocusChangeListener((v, hasFocus) -> { + if (!hasFocus) { + ViewUtil.hideKeyboard(v); + } else { + uploadView.setTopCardState(false); + } + }); + } else { - spinnerDescriptionLanguages.setSelection(description.getSelectedLanguageIndex()); + Description description = descriptions.get(position - 1); + Timber.d("Description is " + description); + if (!TextUtils.isEmpty(description.getDescriptionText())) { + descItemEditText.setText(description.getDescriptionText()); + } else { + descItemEditText.setText(""); + } + if (position == 1) { + descItemEditText.setCompoundDrawablesWithIntrinsicBounds(null, null, getInfoIcon(), null); + } else { + descItemEditText.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + } + + descItemEditText.addTextChangedListener(new AbstractTextWatcher(descriptionText -> { + description.setDescriptionText(descriptionText); + })); + descItemEditText.setOnFocusChangeListener((v, hasFocus) -> { + if (!hasFocus) { + ViewUtil.hideKeyboard(v); + } else { + uploadView.setTopCardState(false); + } + }); + + SpinnerLanguagesAdapter languagesAdapter = new SpinnerLanguagesAdapter(context, + R.layout.row_item_languages_spinner, selectedLanguages); + languagesAdapter.notifyDataSetChanged(); + spinnerDescriptionLanguages.setAdapter(languagesAdapter); + + if (description.getSelectedLanguageIndex() == -1) { + if (position == 1) { + int defaultLocaleIndex = languagesAdapter.getIndexOfUserDefaultLocale(context); + spinnerDescriptionLanguages.setSelection(defaultLocaleIndex); + } else { + spinnerDescriptionLanguages.setSelection(0); + } + } else { + spinnerDescriptionLanguages.setSelection(description.getSelectedLanguageIndex()); + selectedLanguages.put(spinnerDescriptionLanguages, description.getLanguageCode()); + } + + //TODO do it the butterknife way + spinnerDescriptionLanguages.setOnItemSelectedListener(new OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView adapterView, View view, int position, + long l) { + description.setSelectedLanguageIndex(position); + String languageCode = ((SpinnerLanguagesAdapter) adapterView.getAdapter()).getLanguageCode(position); + description.setLanguageCode(languageCode); + selectedLanguages.remove(adapterView); + selectedLanguages.put(adapterView, languageCode); + ((SpinnerLanguagesAdapter) adapterView.getAdapter()).selectedLangCode = languageCode; + } + + @Override + public void onNothingSelected(AdapterView adapterView) { + + } + }); } - languages.get(spinnerDescriptionLanguages.getSelectedItemPosition()).setSet(true); - - //TODO do it the butterknife way - spinnerDescriptionLanguages.setOnItemSelectedListener(new OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView adapterView, View view, int position, - long l) { - //TODO handle case when user tries to select an already selected language - updateDescriptionBasedOnSelectedLanguageIndex(description, position); - } - - @Override - public void onNothingSelected(AdapterView adapterView) { - - } - }); - - } - @OnTouch(R.id.et_description_text) + @Optional + @OnTouch(R.id.description_item_edit_text) boolean descriptionInfo(View view, MotionEvent motionEvent) { - + //Title info is visible only for the title if (getAdapterPosition() == 0) { - //Description info is visible only for the first item + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + final int value = view.getRight() - descItemEditText + .getCompoundDrawables()[2] + .getBounds().width(); + if (motionEvent.getAction() == ACTION_UP && motionEvent.getRawX() >= value) { + callback.showAlert(R.string.media_detail_title, R.string.title_info); + return true; + } + } else { + final int value = descItemEditText.getLeft() + descItemEditText + .getCompoundDrawables()[0] + .getBounds().width(); + if (motionEvent.getAction() == ACTION_UP && motionEvent.getRawX() <= value) { + callback.showAlert(R.string.media_detail_title, R.string.title_info); + return true; + } + } + //Description info is visible only for the first description + } else if (getAdapterPosition() == 1) { final int value; if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { - value = etDescriptionText.getRight() - etDescriptionText - .getCompoundDrawables()[2] - .getBounds().width() - etDescriptionText.getPaddingRight(); - if (motionEvent.getAction() == ACTION_UP && motionEvent.getX() >= value) { + value = view.getRight() - descItemEditText.getCompoundDrawables()[2].getBounds().width(); + if (motionEvent.getAction() == ACTION_UP && motionEvent.getRawX() >= value) { callback.showAlert(R.string.media_detail_description, R.string.description_info); return true; } } else { - value = etDescriptionText.getLeft() + etDescriptionText + value = descItemEditText.getLeft() + descItemEditText .getCompoundDrawables()[0] .getBounds().width(); if (motionEvent.getAction() == ACTION_UP && motionEvent.getRawX() <= value) { @@ -206,27 +265,12 @@ class DescriptionsAdapter extends RecyclerView.AdapterResponsible for checking if the picture that the user is trying to upload is useful or not. Will attempt to filter - * away completely black,fuzzy/blurry pictures(for now). - * - *

todo: Detect selfies? - */ - -public class DetectUnwantedPicturesAsync extends AsyncTask { - - private final String imageMediaFilePath; - public final WeakReference activityWeakReference; - - DetectUnwantedPicturesAsync(WeakReference activityWeakReference, String imageMediaFilePath) { - //this.callback = callback; - this.imageMediaFilePath = imageMediaFilePath; - this.activityWeakReference = activityWeakReference; - } - - @Override - protected ImageUtils.Result doInBackground(Void... voids) { - try { - Timber.d("FilePath: " + imageMediaFilePath); - if (imageMediaFilePath == null) { - return ImageUtils.Result.IMAGE_OK; - } - - BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(imageMediaFilePath,false); - - return ImageUtils.checkIfImageIsTooDark(decoder); - } catch (IOException ioe) { - Timber.e(ioe, "IO Exception"); - return ImageUtils.Result.IMAGE_OK; - } - } - - @Override - protected void onPostExecute(ImageUtils.Result result) { - super.onPostExecute(result); - Activity activity = activityWeakReference.get(); - - if (result != ImageUtils.Result.IMAGE_OK) { - //show appropriate error message - String errorMessage = result == ImageUtils.Result.IMAGE_DARK ? activity.getString(R.string.upload_image_too_dark) : activity.getString(R.string.upload_image_blurry); - AlertDialog.Builder errorDialogBuilder = new AlertDialog.Builder(activity); - errorDialogBuilder.setMessage(errorMessage); - errorDialogBuilder.setTitle(activity.getString(R.string.warning)); - errorDialogBuilder.setPositiveButton(activity.getString(R.string.no), (dialogInterface, i) -> { - //user does not wish to upload the picture, take them back to MainActivity - Intent intent = new Intent(activity, MainActivity.class); - dialogInterface.dismiss(); - activity.startActivity(intent); - }); - errorDialogBuilder.setNegativeButton(activity.getString(R.string.yes), (dialogInterface, i) -> { - //user wishes to go ahead with the upload of this picture, just dismiss this dialog - dialogInterface.dismiss(); - }); - - AlertDialog errorDialog = errorDialogBuilder.create(); - if (!activity.isFinishing()) { - errorDialog.show(); - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/DexterPermissionObtainer.java b/app/src/main/java/fr/free/nrw/commons/upload/DexterPermissionObtainer.java new file mode 100644 index 000000000..64f483572 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/DexterPermissionObtainer.java @@ -0,0 +1,153 @@ +package fr.free.nrw.commons.upload; + +import android.app.Activity; + +import com.karumi.dexter.Dexter; +import com.karumi.dexter.DexterBuilder; +import com.karumi.dexter.listener.PermissionDeniedResponse; +import com.karumi.dexter.listener.PermissionGrantedResponse; +import com.karumi.dexter.listener.single.BasePermissionListener; + +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.utils.DialogUtil; +import fr.free.nrw.commons.utils.ExternalStorageUtils; +import fr.free.nrw.commons.utils.PermissionUtils; +import io.reactivex.Completable; +import io.reactivex.subjects.CompletableSubject; +import timber.log.Timber; + +public class DexterPermissionObtainer { + private final String requestedPermission; + private android.app.AlertDialog storagePermissionInfoDialog; + private DexterBuilder dexterStoragePermissionBuilder; + + private PermissionDeniedResponse permissionDeniedResponse; + + private boolean storagePromptInProgress; + + private final String rationaleTitle; + private final String rationaleText; + + private Activity activity; + + private CompletableSubject storagePromptObservable; + + /** + * @param activity The activity that is requesting the permission + * @param requestedPermission The permission being requested in the form of Manifest.permission.* + * @param rationaleTitle The title of the rationale dialog + * @param rationaleText The text inside the rationale dialog + */ + DexterPermissionObtainer(Activity activity, String requestedPermission, String rationaleTitle, String rationaleText) { + this.activity = activity; + this.rationaleTitle = rationaleTitle; + this.rationaleText = rationaleText; + this.requestedPermission = requestedPermission; + this.storagePromptObservable = CompletableSubject.create(); + initPermissionsRationaleDialog(); + } + + /** + * Checks if storage permissions are obtained, prompts the users to grant storage permissions if necessary. + * When storage permission is present, onPermissionObtained is called. + */ + Completable confirmStoragePermissions() { + if (ExternalStorageUtils.isStoragePermissionGranted(activity)) { + Timber.i("Storage permissions already granted."); + storagePromptObservable.onComplete(); + } else if (!storagePromptInProgress) { + if (storagePromptObservable.hasComplete()) { + storagePromptObservable = CompletableSubject.create(); + } + //If permission is not there, ask for it + storagePromptInProgress = true; + askDexterToHandleExternalStoragePermission(); + } + return storagePromptObservable; + } + + + /** + * To be called when the user returns to the original activity after manually enabling storage permissions. + */ + void onManualPermissionReturned() { + //OnActivity result, no matter what the result is, our function can handle that. + askDexterToHandleExternalStoragePermission(); + } + + /** + * This method initialised the Dexter's permission builder (if not already initialised). Also makes sure that the builder is initialised + * only once, otherwise we would'nt know on which instance of it, the user is working on. And after the builder is initialised, it checks + * for the required permission and then handles the permission status, thanks to Dexter's appropriate callbacks. + */ + private void askDexterToHandleExternalStoragePermission() { + Timber.d("External storage permission is being requested"); + if (null == dexterStoragePermissionBuilder) { + dexterStoragePermissionBuilder = Dexter.withActivity(activity) + .withPermission(requestedPermission) + .withListener(new BasePermissionListener() { + @Override + public void onPermissionGranted(PermissionGrantedResponse response) { + Timber.d("User has granted us the permission for writing the external storage"); + //If permission is granted, well and good + storagePromptInProgress = false; + storagePromptObservable.onComplete(); + //onPermissionObtained.run(); + } + + @Override + public void onPermissionDenied(PermissionDeniedResponse response) { + Timber.d("User has granted us the permission for writing the external storage"); + //If permission is not granted in whatsoever scenario, we show him a dialog stating why we need the permission + permissionDeniedResponse = response; + if (null != storagePermissionInfoDialog && !storagePermissionInfoDialog + .isShowing()) { + storagePermissionInfoDialog.show(); + } + } + }); + } + dexterStoragePermissionBuilder.check(); + } + + /** + * We have agreed to show a dialog showing why we need a particular permission. + * This method is used to initialise the dialog which is going to show the permission's rationale. + * The dialog is initialised along with a callback for positive and negative user actions. + */ + private void initPermissionsRationaleDialog() { + if (storagePermissionInfoDialog == null) { + storagePermissionInfoDialog = DialogUtil + .getAlertDialogWithPositiveAndNegativeCallbacks( + activity, + rationaleTitle, rationaleText, + R.drawable.ic_launcher, new DialogUtil.Callback() { + @Override + public void onPositiveButtonClicked() { + //If the user is willing to give us the permission + //But had somehow previously choose never ask again, we take him to app settings to manually enable permission + if (null == permissionDeniedResponse) { + //Dexter returned null, lets see if this ever happens + Timber.w("Dexter returned null as permissionDeniedResponse"); + } else if (permissionDeniedResponse.isPermanentlyDenied()) { + PermissionUtils.askUserToManuallyEnablePermissionFromSettings(activity); + Timber.i("Permission permanently denied."); + } else { + //or if we still have chance to show runtime permission dialog, we show him that. + askDexterToHandleExternalStoragePermission(); + Timber.d("Asking via Dexter for permission."); + } + } + + @Override + public void onNegativeButtonClicked() { + //This was the behaviour as of now, I was planning to maybe snack him with some message + //and then call finish after some time, or may be it could be associated with some action + // on the snack. If the user does not want us to give the permission, even after showing + // rationale dialog, lets not trouble him any more. + activity.finish(); + } + }); + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ExistingFileAsync.java b/app/src/main/java/fr/free/nrw/commons/upload/ExistingFileAsync.java deleted file mode 100644 index 08669ed9e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/ExistingFileAsync.java +++ /dev/null @@ -1,95 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; -import android.support.v7.app.AlertDialog; - -import java.io.IOException; -import java.lang.ref.WeakReference; - -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.contributions.MainActivity; -import fr.free.nrw.commons.mwapi.MediaWikiApi; -import timber.log.Timber; - -/** - * Sends asynchronous queries to the Commons MediaWiki API to check that file doesn't already exist - * Displays a warning to the user if the file already exists on Commons - */ -public class ExistingFileAsync extends AsyncTask { - - interface Callback { - void onResult(Result result); - } - - public enum Result { - NO_DUPLICATE, - DUPLICATE_PROCEED, - DUPLICATE_CANCELLED - } - - private final WeakReference activity; - private final MediaWikiApi api; - private final String fileSha1; - private final WeakReference context; - private final Callback callback; - - public ExistingFileAsync(WeakReference activity, String fileSha1, WeakReference context, Callback callback, MediaWikiApi mwApi) { - this.activity = activity; - this.fileSha1 = fileSha1; - this.context = context; - this.callback = callback; - this.api = mwApi; - } - - @Override - protected void onPreExecute() { - super.onPreExecute(); - } - - @Override - protected Boolean doInBackground(Void... voids) { - - // https://commons.wikimedia.org/w/api.php?action=query&list=allimages&format=xml&aisha1=801957214aba50cb63bb6eb1b0effa50188900ba - boolean fileExists; - try { - String fileSha1 = this.fileSha1; - fileExists = api.existingFile(fileSha1); - } catch (IOException e) { - Timber.e(e, "IO Exception: "); - return false; - } - - Timber.d("File already exists in Commons: %s", fileExists); - return fileExists; - } - - @Override - protected void onPostExecute(Boolean fileExists) { - super.onPostExecute(fileExists); - - // If file exists, display warning to user. - // Use soft warning for now (user able to choose to proceed) until have determined that implementation works without bugs - if (fileExists) { - AlertDialog.Builder builder = new AlertDialog.Builder(context.get()); - builder.setMessage(R.string.file_exists) - .setTitle(R.string.warning); - builder.setPositiveButton(R.string.no, (dialog, id) -> { - //Go back to MainActivity - Intent intent = new Intent(context.get(), MainActivity.class); - context.get().startActivity(intent); - callback.onResult(Result.DUPLICATE_CANCELLED); - }); - builder.setNegativeButton(R.string.yes, (dialog, id) -> callback.onResult(Result.DUPLICATE_PROCEED)); - - AlertDialog dialog = builder.create(); - if (!activity.get().isFinishing()) { - dialog.show(); - } - } else { - callback.onResult(Result.NO_DUPLICATE); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.java b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.java index b29d686f5..c5b6df227 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.java @@ -1,21 +1,21 @@ package fr.free.nrw.commons.upload; import android.annotation.SuppressLint; -import android.app.Activity; import android.content.ContentResolver; import android.content.Context; import android.content.SharedPreferences; +import android.media.ExifInterface; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.ParcelFileDescriptor; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.app.AppCompatActivity; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; -import java.lang.ref.WeakReference; import java.util.Date; import java.util.List; @@ -44,89 +44,44 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { @Inject @Named("default_preferences") SharedPreferences prefs; - private Uri mediaUri; + private String filePath; private ContentResolver contentResolver; private GPSExtractor imageObj; private Context context; private String decimalCoords; - private boolean haveCheckedForOtherImages = false; - private String filePath; + private ExifInterface exifInterface; private boolean useExtStorage; - private boolean cacheFound; + private boolean haveCheckedForOtherImages = false; private GPSExtractor tempImageObj; - FileProcessor(Uri mediaUri, ContentResolver contentResolver, Context context) { - this.mediaUri = mediaUri; + FileProcessor(@NonNull String filePath, ContentResolver contentResolver, Context context) { + this.filePath = filePath; this.contentResolver = contentResolver; this.context = context; ApplicationlessInjection.getInstance(context.getApplicationContext()).getCommonsApplicationComponent().inject(this); + try { + exifInterface=new ExifInterface(filePath); + } catch (IOException e) { + Timber.e(e); + } useExtStorage = prefs.getBoolean("useExternalStorage", true); } - /** - * Gets file path from media URI. - * In older devices getPath() may fail depending on the source URI, creating and using a copy of the file seems to work instead. - * - * @return file path of media - */ - @Nullable - private String getPathOfMediaOrCopy() { - filePath = FileUtils.getPath(context, mediaUri); - Timber.d("Filepath: " + filePath); - if (filePath == null) { - String copyPath = null; - try { - ParcelFileDescriptor descriptor = contentResolver.openFileDescriptor(mediaUri, "r"); - if (descriptor != null) { - if (useExtStorage) { - copyPath = FileUtils.createCopyPath(descriptor); - return copyPath; - } - copyPath = getApplicationContext().getCacheDir().getAbsolutePath() + "/" + new Date().getTime() + ".jpg"; - FileUtils.copy(descriptor.getFileDescriptor(), copyPath); - Timber.d("Filepath (copied): %s", copyPath); - return copyPath; - } - } catch (IOException e) { - Timber.w(e, "Error in file " + copyPath); - return null; - } - } - return filePath; - } - /** * Processes file coordinates, either from EXIF data or user location - * - * @param gpsEnabled if true use GPS */ - GPSExtractor processFileCoordinates(boolean gpsEnabled) { + GPSExtractor processFileCoordinates(SimilarImageInterface similarImageInterface) { Timber.d("Calling GPSExtractor"); - try { - ParcelFileDescriptor descriptor = contentResolver.openFileDescriptor(mediaUri, "r"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (descriptor != null) { - imageObj = new GPSExtractor(descriptor.getFileDescriptor()); - } - } else { - String filePath = getPathOfMediaOrCopy(); - if (filePath != null) { - imageObj = new GPSExtractor(filePath); - } - } - - decimalCoords = imageObj.getCoords(); - if (decimalCoords == null || !imageObj.imageCoordsExists) { - //Find other photos taken around the same time which has gps coordinates - if (!haveCheckedForOtherImages) - findOtherImages();// Do not do repeat the process - } else { - useImageCoords(); - } - - } catch (FileNotFoundException e) { - Timber.w("File not found: " + mediaUri, e); + imageObj = new GPSExtractor(exifInterface); + decimalCoords = imageObj.getCoords(); + if (decimalCoords == null || !imageObj.imageCoordsExists) { + //Find other photos taken around the same time which has gps coordinates + if (!haveCheckedForOtherImages) + findOtherImages(similarImageInterface);// Do not do repeat the process + } else { + useImageCoords(); } + return imageObj; } @@ -136,10 +91,10 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { /** * Find other images around the same location that were taken within the last 20 sec - * + * @param similarImageInterface */ - private void findOtherImages() { - Timber.d("filePath" + getPathOfMediaOrCopy()); + private void findOtherImages(SimilarImageInterface similarImageInterface) { + Timber.d("filePath" + filePath); long timeOfCreation = new File(filePath).lastModified();//Time when the original image was created File folder = new File(filePath.substring(0, filePath.lastIndexOf('/'))); @@ -154,7 +109,7 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { tempImageObj = null;//Temporary GPSExtractor to extract coords from these photos ParcelFileDescriptor descriptor = null; try { - descriptor = contentResolver.openFileDescriptor(Uri.parse(file.getAbsolutePath()), "r"); + descriptor = contentResolver.openFileDescriptor(Uri.fromFile(file), "r"); } catch (FileNotFoundException e) { e.printStackTrace(); } @@ -173,12 +128,7 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { if (tempImageObj.getCoords() != null && tempImageObj.imageCoordsExists) { // Current image has gps coordinates and it's not current gps locaiton Timber.d("This file has image coords:" + file.getAbsolutePath()); - SimilarImageDialogFragment newFragment = new SimilarImageDialogFragment(); - Bundle args = new Bundle(); - args.putString("originalImagePath", filePath); - args.putString("possibleImagePath", file.getAbsolutePath()); - newFragment.setArguments(args); - newFragment.show(((AppCompatActivity) context).getSupportFragmentManager(), "dialog"); + similarImageInterface.showSimilarImageFragment(filePath, file.getAbsolutePath()); break; } } @@ -210,7 +160,6 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { // If no categories found in cache, call MediaWiki API to match image coords with nearby Commons categories if (catListEmpty) { - cacheFound = false; apiCall.request(decimalCoords) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) @@ -223,7 +172,6 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { ); Timber.d("displayCatList size 0, calling MWAPI %s", displayCatList); } else { - cacheFound = true; Timber.d("Cache found, setting categoryList in model to %s", displayCatList); gpsCategoryModel.setCategoryList(displayCatList); } @@ -232,20 +180,6 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { } } - boolean isCacheFound() { - return cacheFound; - } - - /** - * Calls the async task that detects if image is fuzzy, too dark, etc - */ - void detectUnwantedPictures() { - String imageMediaFilePath = FileUtils.getPath(context, mediaUri); - DetectUnwantedPicturesAsync detectUnwantedPicturesAsync - = new DetectUnwantedPicturesAsync(new WeakReference((Activity) context), imageMediaFilePath); - detectUnwantedPicturesAsync.execute(); - } - @Override public void onPositiveResponse() { imageObj = tempImageObj; @@ -259,4 +193,4 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { Timber.d("EXIF from imageObj"); useImageCoords(); } -} +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java b/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java index 2536909b0..9401c941e 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileUtils.java @@ -1,6 +1,7 @@ package fr.free.nrw.commons.upload; import android.annotation.SuppressLint; +import android.content.ContentResolver; import android.content.ContentUris; import android.content.Context; import android.content.SharedPreferences; @@ -12,6 +13,7 @@ import android.os.ParcelFileDescriptor; import android.preference.PreferenceManager; import android.provider.DocumentsContract; import android.provider.MediaStore; +import android.provider.OpenableColumns; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -33,6 +35,8 @@ import java.util.Date; import timber.log.Timber; +import static com.mapbox.mapboxsdk.Mapbox.getApplicationContext; + public class FileUtils { /** @@ -76,21 +80,32 @@ public class FileUtils { /** * In older devices getPath() may fail depending on the source URI. Creating and using a copy of the file seems to work instead. + * * @return path of copy */ - @Nullable - static String createCopyPath(ParcelFileDescriptor descriptor) { - try { - String copyPath = Environment.getExternalStorageDirectory().toString() + "/CommonsApp/" + new Date().getTime() + ".jpg"; - File newFile = new File(Environment.getExternalStorageDirectory().toString() + "/CommonsApp"); - newFile.mkdir(); - FileUtils.copy(descriptor.getFileDescriptor(), copyPath); - Timber.d("Filepath (copied): %s", copyPath); - return copyPath; - } catch (IOException e) { - Timber.e(e); - return null; - } + @NonNull + static String createExternalCopyPathAndCopy(Uri uri, ContentResolver contentResolver) throws IOException { + FileDescriptor fileDescriptor = contentResolver.openFileDescriptor(uri, "r").getFileDescriptor(); + String copyPath = Environment.getExternalStorageDirectory().toString() + "/CommonsApp/" + new Date().getTime() + "." + getFileExt(uri, contentResolver); + File newFile = new File(Environment.getExternalStorageDirectory().toString() + "/CommonsApp"); + newFile.mkdir(); + FileUtils.copy(fileDescriptor, copyPath); + Timber.d("Filepath (copied): %s", copyPath); + return copyPath; + } + + /** + * In older devices getPath() may fail depending on the source URI. Creating and using a copy of the file seems to work instead. + * + * @return path of copy + */ + @NonNull + static String createCopyPathAndCopy(Uri uri, Context context) throws IOException { + FileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(uri, "r").getFileDescriptor(); + String copyPath = context.getCacheDir().getAbsolutePath() + "/" + new Date().getTime() + "." + getFileExt(uri, context.getContentResolver()); + FileUtils.copy(fileDescriptor, copyPath); + Timber.d("Filepath (copied): %s", copyPath); + return copyPath; } /** @@ -121,13 +136,13 @@ public class FileUtils { if ("primary".equalsIgnoreCase(type)) { returnPath = Environment.getExternalStorageDirectory() + "/" + split[1]; } - } else if (isDownloadsDocument(uri)) { // DownloadsProvider + } else if (isDownloadsDocument(uri)) { // DownloadsProvider final String id = DocumentsContract.getDocumentId(uri); final Uri contentUri = ContentUris.withAppendedId( Uri.parse("content://downloads/document"), Long.valueOf(id)); - returnPath = getDataColumn(context, contentUri, null, null); + returnPath = getDataColumn(context, contentUri, null, null); } else if (isMediaDocument(uri)) { // MediaProvider final String docId = DocumentsContract.getDocumentId(uri); @@ -304,6 +319,7 @@ public class FileUtils { /** * Read and return the content of a resource file as string. + * * @param fileName asset file's path (e.g. "/queries/nearby_query.rq") * @return the content of the file */ @@ -330,6 +346,7 @@ public class FileUtils { /** * Deletes files. + * * @param file context */ public static boolean deleteFile(File file) { @@ -355,7 +372,7 @@ public class FileUtils { commonsAppDirectory.mkdir(); } - File logsFile = new File(commonsAppDirectory,"logs.txt"); + File logsFile = new File(commonsAppDirectory, "logs.txt"); if (logsFile.exists()) { //old logs file is useless logsFile.delete(); @@ -377,4 +394,39 @@ public class FileUtils { } } -} + public static String getFilename(Uri uri, ContentResolver contentResolver) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.JELLY_BEAN) + return ""; + String result = null; + if (uri.getScheme().equals("content")) { + try (Cursor cursor = contentResolver.query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); + } + } + } + if (result == null) { + result = uri.getPath(); + int cut = result.lastIndexOf('/'); + if (cut != -1) { + result = result.substring(cut + 1); + } + } + return result; + } + + public static String getFileExt(String fileName){ + //Default file extension + String extension=".jpg"; + + int i = fileName.lastIndexOf('.'); + if (i > 0) { + extension = fileName.substring(i+1); + } + return extension; + } + + public static String getFileExt(Uri uri, ContentResolver contentResolver) { + return getFileExt(getFilename(uri, contentResolver)); + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/upload/GPSExtractor.java b/app/src/main/java/fr/free/nrw/commons/upload/GPSExtractor.java index e45b31f05..a6b150c42 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/GPSExtractor.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/GPSExtractor.java @@ -16,11 +16,22 @@ import timber.log.Timber; */ public class GPSExtractor { - private ExifInterface exif; + public static final GPSExtractor DUMMY= new GPSExtractor(); private double decLatitude; private double decLongitude; public boolean imageCoordsExists; + private String latitude; + private String longitude; + private String latitudeRef; + private String longitudeRef; + private String decimalCoords; + /** + * Dummy constructor. + */ + private GPSExtractor(){ + + } /** * Construct from the file descriptor of the image (only for API 24 or newer). * @param fileDescriptor the file descriptor of the image @@ -28,7 +39,8 @@ public class GPSExtractor { @RequiresApi(24) public GPSExtractor(@NonNull FileDescriptor fileDescriptor) { try { - exif = new ExifInterface(fileDescriptor); + ExifInterface exif = new ExifInterface(fileDescriptor); + processCoords(exif); } catch (IOException | IllegalArgumentException e) { Timber.w(e); } @@ -41,47 +53,53 @@ public class GPSExtractor { */ public GPSExtractor(@NonNull String path) { try { - exif = new ExifInterface(path); + ExifInterface exif = new ExifInterface(path); + processCoords(exif); } catch (IOException | IllegalArgumentException e) { Timber.w(e); } } + /** + * Construct from the file path of the image. + * @param exif exif interface of the image + * + */ + public GPSExtractor(@NonNull ExifInterface exif){ + processCoords(exif); + } + + private void processCoords(ExifInterface exif){ + //If image has no EXIF data and user has enabled GPS setting, get user's location + //Always return null as a temporary fix for #1599 + if (exif != null && exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE) != null) { + //If image has EXIF data, extract image coords + imageCoordsExists = true; + Timber.d("EXIF data has location info"); + + latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE); + latitudeRef = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE_REF); + longitude = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE); + longitudeRef = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF); + } + } + /** * Extracts geolocation (either of image from EXIF data, or of user) * @return coordinates as string (needs to be passed as a String in API query) */ @Nullable public String getCoords() { - String latitude; - String longitude; - String latitudeRef; - String longitudeRef; - String decimalCoords; + if(decimalCoords!=null){ + return decimalCoords; + }else if (latitude!=null && latitudeRef!=null && longitude!=null && longitudeRef!=null) { + Timber.d("Latitude: %s %s", latitude, latitudeRef); + Timber.d("Longitude: %s %s", longitude, longitudeRef); - //If image has no EXIF data and user has enabled GPS setting, get user's location - //TODO: Always return null as a temporary fix for #1599 - if (exif == null || exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE) == null) { - return null; + decimalCoords = getDecimalCoords(latitude, latitudeRef, longitude, longitudeRef); + return decimalCoords; } else { - //If image has EXIF data, extract image coords - imageCoordsExists = true; - Timber.d("EXIF data has location info"); - - latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE); - latitudeRef = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE_REF); - longitude = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE); - longitudeRef = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF); - - if (latitude!=null && latitudeRef!=null && longitude!=null && longitudeRef!=null) { - Timber.d("Latitude: %s %s", latitude, latitudeRef); - Timber.d("Longitude: %s %s", longitude, longitudeRef); - - decimalCoords = getDecimalCoords(latitude, latitudeRef, longitude, longitudeRef); - return decimalCoords; - } else { - return null; - } + return null; } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/HeightLimitedRecyclerView.java b/app/src/main/java/fr/free/nrw/commons/upload/HeightLimitedRecyclerView.java new file mode 100644 index 000000000..ff100e16e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/HeightLimitedRecyclerView.java @@ -0,0 +1,52 @@ +package fr.free.nrw.commons.upload; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Point; +import android.support.annotation.Nullable; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.Display; + +/** + * Created by Ilgaz Er on 8/7/2018. + */ +public class HeightLimitedRecyclerView extends RecyclerView { + + int height; + + + public HeightLimitedRecyclerView(Context context) { + super(context); + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Activity) getContext()).getWindowManager() + .getDefaultDisplay() + .getMetrics(displayMetrics); + height=displayMetrics.heightPixels; + } + + public HeightLimitedRecyclerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Activity) getContext()).getWindowManager() + .getDefaultDisplay() + .getMetrics(displayMetrics); + height=displayMetrics.heightPixels; + } + + public HeightLimitedRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + DisplayMetrics displayMetrics = new DisplayMetrics(); + ((Activity) getContext()).getWindowManager() + .getDefaultDisplay() + .getMetrics(displayMetrics); + height=displayMetrics.heightPixels; + } + + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + heightSpec = MeasureSpec.makeMeasureSpec((int) (height*0.3), MeasureSpec.AT_MOST); + super.onMeasure(widthSpec, heightSpec); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/Language.java b/app/src/main/java/fr/free/nrw/commons/upload/Language.java index 8d4b27239..ab03a4db7 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/Language.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/Language.java @@ -3,7 +3,6 @@ package fr.free.nrw.commons.upload; import java.util.Locale; class Language { - private Locale locale; private boolean isSet = false; diff --git a/app/src/main/java/fr/free/nrw/commons/upload/MultipleShareActivity.java b/app/src/main/java/fr/free/nrw/commons/upload/MultipleShareActivity.java deleted file mode 100644 index 703e26657..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/MultipleShareActivity.java +++ /dev/null @@ -1,493 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.Manifest; -import android.Manifest.permission; -import android.app.AlertDialog; -import android.app.ProgressDialog; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.database.DataSetObserver; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.ParcelFileDescriptor; -import android.support.annotation.Nullable; -import android.support.v4.app.ActivityCompat; -import android.support.v4.app.FragmentManager; -import android.support.v4.content.ContextCompat; -import android.view.MenuItem; -import android.view.View; -import android.view.inputmethod.InputMethodManager; -import android.widget.AdapterView; -import android.widget.Toast; - -import com.karumi.dexter.Dexter; -import com.karumi.dexter.DexterBuilder; -import com.karumi.dexter.listener.PermissionDeniedResponse; -import com.karumi.dexter.listener.PermissionGrantedResponse; -import com.karumi.dexter.listener.single.BasePermissionListener; - -import java.io.FileNotFoundException; -import java.util.ArrayList; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Named; - -import butterknife.ButterKnife; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.AuthenticatedActivity; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.category.CategorizationFragment; -import fr.free.nrw.commons.category.OnCategoriesSaveHandler; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.media.MediaDetailPagerFragment; -import fr.free.nrw.commons.modifications.CategoryModifier; -import fr.free.nrw.commons.modifications.ModifierSequence; -import fr.free.nrw.commons.modifications.ModifierSequenceDao; -import fr.free.nrw.commons.modifications.TemplateRemoveModifier; -import fr.free.nrw.commons.mwapi.MediaWikiApi; -import fr.free.nrw.commons.utils.ContributionUtils; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.DialogUtil.Callback; -import fr.free.nrw.commons.utils.ExternalStorageUtils; -import fr.free.nrw.commons.utils.PermissionUtils; -import timber.log.Timber; - -//TODO: We should use this class to see how multiple uploads are handled, and then REMOVE it. - -public class MultipleShareActivity extends AuthenticatedActivity - implements MediaDetailPagerFragment.MediaDetailProvider, - AdapterView.OnItemClickListener, - FragmentManager.OnBackStackChangedListener, - MultipleUploadListFragment.OnMultipleUploadInitiatedHandler, - OnCategoriesSaveHandler, - ActivityCompat.OnRequestPermissionsResultCallback{ - - @Inject - MediaWikiApi mwApi; - @Inject - SessionManager sessionManager; - @Inject - UploadController uploadController; - @Inject - ModifierSequenceDao modifierSequenceDao; - @Inject - @Named("default_preferences") - SharedPreferences prefs; - - private ArrayList photosList = null; - - private MultipleUploadListFragment uploadsList; - private MediaDetailPagerFragment mediaDetails; - private CategorizationFragment categorizationFragment; - - private boolean locationPermitted = false; - private boolean isMultipleUploadsPrepared = false; - private boolean isMultipleUploadsFinalised = false; // Checks is user clicked to upload button or regret before this phase - private final String TAG="#MultipleShareActivity#"; - private AlertDialog storagePermissionInfoDialog; - private DexterBuilder dexterStoragePermissionBuilder; - - private PermissionDeniedResponse permissionDeniedResponse; - - @Override - public Media getMediaAtPosition(int i) { - return photosList.get(i); - } - - @Override - public int getTotalMediaCount() { - if (photosList == null) { - return 0; - } - return photosList.size(); - } - - @Override - public void notifyDatasetChanged() { - if (uploadsList != null) { - uploadsList.notifyDatasetChanged(); - } - } - - @Override - public void registerDataSetObserver(DataSetObserver observer) { - // fixme implement me if needed - } - - @Override - public void unregisterDataSetObserver(DataSetObserver observer) { - // fixme implement me if needed - } - - @Override - public void onItemClick(AdapterView adapterView, View view, int index, long item) { - showDetail(index); - } - - @Override - public void OnMultipleUploadInitiated() { - // No need to request external permission here, because if user can reach this point, then she permission granted - Timber.d("OnMultipleUploadInitiated"); - multipleUploadBegins(); - } - - private void multipleUploadBegins() { - - Timber.d("Multiple upload begins"); - final ProgressDialog dialog = new ProgressDialog(this); - dialog.setIndeterminate(false); - dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); - dialog.setMax(photosList.size()); - dialog.setTitle(getResources().getQuantityString(R.plurals.starting_multiple_uploads, photosList.size(), photosList.size())); - dialog.show(); - - for (int i = 0; i < photosList.size(); i++) { - Contribution up = photosList.get(i); - final int uploadCount = i + 1; // Goddamn Java - - uploadController.startUpload(up, contribution -> { - dialog.setProgress(uploadCount); - if (uploadCount == photosList.size()) { - dialog.dismiss(); - Toast startingToast = Toast.makeText(this, R.string.uploading_started, Toast.LENGTH_LONG); - startingToast.show(); - } - }); - } - - uploadsList.setImageOnlyMode(true); - - categorizationFragment = (CategorizationFragment) getSupportFragmentManager().findFragmentByTag("categorization"); - if (categorizationFragment == null) { - categorizationFragment = new CategorizationFragment(); - } - // FIXME: Stops the keyboard from being shown 'stale' while moving out of this fragment into the next - View target = getCurrentFocus(); - if (target != null) { - InputMethodManager imm = (InputMethodManager) target.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - if (imm != null) - imm.hideSoftInputFromWindow(target.getWindowToken(), 0); - } - getSupportFragmentManager().beginTransaction() - .add(R.id.uploadsFragmentContainer, categorizationFragment, "categorization") - .commitAllowingStateLoss(); - isMultipleUploadsFinalised = true; - //See http://stackoverflow.com/questions/7469082/getting-exception-illegalstateexception-can-not-perform-this-action-after-onsa - } - - @Override - public void onCategoriesSave(List categories) { - if (categories.size() > 0) { - for (Contribution contribution : photosList) { - ModifierSequence categoriesSequence = new ModifierSequence(contribution.getContentUri()); - - categoriesSequence.queueModifier(new CategoryModifier(categories.toArray(new String[]{}))); - categoriesSequence.queueModifier(new TemplateRemoveModifier("Uncategorized")); - - modifierSequenceDao.save(categoriesSequence); - } - } - // FIXME: Make sure that the content provider is up - // This is the wrong place for it, but bleh - better than not having it turned on by default for people who don't go throughl ogin - ContentResolver.setSyncAutomatically(sessionManager.getCurrentAccount(), BuildConfig.MODIFICATION_AUTHORITY, true); // Enable sync by default! - finish(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - if (mediaDetails.isVisible()) { - getSupportFragmentManager().popBackStack(); - } - return true; - } - return super.onOptionsItemSelected(item); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setContentView(R.layout.activity_multiple_uploads); - ButterKnife.bind(this); - initDrawer(); - initPermissionsRationaleDialog(); - - if (savedInstanceState != null) { - photosList = savedInstanceState.getParcelableArrayList("uploadsList"); - } - - getSupportFragmentManager().addOnBackStackChangedListener(this); - requestAuthToken(); - - //TODO: 15/10/17 should location permission be explicitly requested if not provided? - //check if location permission is enabled - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && ContextCompat.checkSelfPermission(this,Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { - { - locationPermitted = true; - } - } - } - - - /** - * We have agreed to show a dialog showing why we need a particular permission. - * This method is used to initialise the dialog which is going to show the permission's rationale. - * The dialog is initialised along with a callback for positive and negative user actions. - */ - private void initPermissionsRationaleDialog() { - if (storagePermissionInfoDialog == null) { - storagePermissionInfoDialog = DialogUtil - .getAlertDialogWithPositiveAndNegativeCallbacks( - MultipleShareActivity.this, - getString(R.string.storage_permission), getString( - R.string.write_storage_permission_rationale_for_image_share), - R.drawable.ic_launcher, new Callback() { - @Override - public void onPositiveButtonClicked() { - //If the user is willing to give us the permission - //But had somehow previously choose never ask again, we take him to app settings to manually enable permission - if (null== permissionDeniedResponse){ - //Dexter returned null, lets see if this ever happens - return; - } - else if (permissionDeniedResponse.isPermanentlyDenied()) { - PermissionUtils.askUserToManuallyEnablePermissionFromSettings(MultipleShareActivity.this); - } else { - //or if we still have chance to show runtime permission dialog, we show him that. - askDexterToHandleExternalStoragePermission(); - } - } - - @Override - public void onNegativeButtonClicked() { - //This was the behaviour as of now, I was planning to maybe snack him with some message - //and then call finish after some time, or may be it could be associated with some action on the snack - //If the user does not want us to give the permission, even after showing rationale dialog, lets not trouble him anymore - finish(); - } - }); - } - } - - @Override - protected void onDestroy() { - super.onDestroy(); - getSupportFragmentManager().removeOnBackStackChangedListener(this); - uploadController.cleanup(); - } - - private void showDetail(int i) { - if (mediaDetails == null || !mediaDetails.isVisible()) { - mediaDetails = new MediaDetailPagerFragment(true, false); - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.uploadsFragmentContainer, mediaDetails) - .addToBackStack(null) - .commit(); - getSupportFragmentManager().executePendingTransactions(); - } - mediaDetails.showImage(i); - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - /* This will be true if permission request is granted before we request. Otherwise we will - * explicitly call operations under this method again. - */ - if (isMultipleUploadsPrepared) { - super.onSaveInstanceState(outState); - Timber.d("onSaveInstanceState multiple uploads is prepared, permission granted"); - outState.putParcelableArrayList("uploadsList", photosList); - } else { - Timber.d("onSaveInstanceState multiple uploads is not prepared, permission not granted"); - return; - } - } - - @Override - protected void onAuthCookieAcquired(String authCookie) { - // Multiple uploads prepared boolean is used to decide when to call multipleUploadsBegin() - isMultipleUploadsFinalised = false; - isMultipleUploadsPrepared = false; - mwApi.setAuthCookie(authCookie); - if (!ExternalStorageUtils.isStoragePermissionGranted(this)) { - //If permission is not there, handle the negative cases - askDexterToHandleExternalStoragePermission(); - isMultipleUploadsPrepared = false; - return; // Postpone operation to do after gettion permission - } else { - isMultipleUploadsPrepared = true; - prepareMultipleUploadList(); - } - } - - /** - * This method initialised the Dexter's permission builder (if not already initialised). Also makes sure that the builder is initialised - * only once, otherwise we would'nt know on which instance of it, the user is working on. And after the builder is initialised, it checks for the required - * permission and then handles the permission status, thanks to Dexter's appropriate callbacks. - */ - private void askDexterToHandleExternalStoragePermission() { - Timber.d(TAG, "External storage permission is being requested"); - if (null == dexterStoragePermissionBuilder) { - dexterStoragePermissionBuilder = Dexter.withActivity(this) - .withPermission(permission.WRITE_EXTERNAL_STORAGE) - .withListener(new BasePermissionListener() { - @Override - public void onPermissionGranted(PermissionGrantedResponse response) { - Timber.d(TAG,"User has granted us the permission for writing the external storage"); - //If permission is granted, well and good - prepareMultipleUploadList(); - } - - @Override - public void onPermissionDenied(PermissionDeniedResponse response) { - Timber.d(TAG,"User has granted us the permission for writing the external storage"); - //If permission is not granted in whatsoever scenario, we show him a dialog stating why we need the permission - permissionDeniedResponse=response; - if (null != storagePermissionInfoDialog && !storagePermissionInfoDialog - .isShowing()) { - storagePermissionInfoDialog.show(); - } - } - }); - } - dexterStoragePermissionBuilder.check(); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == CommonsApplication.OPEN_APPLICATION_DETAIL_SETTINGS) { - //OnActivity result, no matter what the result is, our function can handle that. - askDexterToHandleExternalStoragePermission(); - } - } - - /** - * Prepares a list from files will be uploaded. Saves these files temporarily to external - * storage. Adds them to uploads list - */ - private void prepareMultipleUploadList() { - Intent intent = getIntent(); - - if (Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction())) { - if (photosList == null) { - photosList = new ArrayList<>(); - ArrayList urisList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - for (int i = 0; i < urisList.size(); i++) { - Contribution up = new Contribution(); - Uri uri = urisList.get(i); - // Use temporarily saved file Uri instead - uri = ContributionUtils.saveFileBeingUploadedTemporarily(this, uri); - up.setLocalUri(uri); - up.setTag("mimeType", intent.getType()); - up.setTag("sequence", i); - up.setSource(Contribution.SOURCE_EXTERNAL); - up.setMultiple(true); - String imageGpsCoordinates = extractImageGpsData(uri); - if (imageGpsCoordinates != null) { - Timber.d("GPS data for image found!"); - up.setDecimalCoords(imageGpsCoordinates); - } - photosList.add(up); - } - } - - uploadsList = (MultipleUploadListFragment) getSupportFragmentManager().findFragmentByTag("uploadsList"); - if (uploadsList == null) { - uploadsList = new MultipleUploadListFragment(); - getSupportFragmentManager() - .beginTransaction() - .add(R.id.uploadsFragmentContainer, uploadsList, "uploadsList") - .commit(); - } - setTitle(getResources().getQuantityString(R.plurals.multiple_uploads_title, photosList.size(), photosList.size())); - uploadController.prepareService(); - } - } - - @Override - protected void onAuthFailure() { - Toast failureToast = Toast.makeText(this, R.string.authentication_failed, Toast.LENGTH_LONG); - failureToast.show(); - finish(); - } - - @Override - public void onBackStackChanged() { - getSupportActionBar().setDisplayHomeAsUpEnabled(mediaDetails != null && mediaDetails.isVisible()); - } - - /** - * Will attempt to extract the gps coordinates using exif data or by using the current - * location if available for the image who's imageUri has been provided. - * @param imageUri The uri of the image who's GPS coordinates data we wish to extract - * @return GPS coordinates as a String as is returned by {@link GPSExtractor} - */ - @Nullable - private String extractImageGpsData(Uri imageUri) { - Timber.d("Entering extractImagesGpsData"); - - if (imageUri == null) { - //now why would you do that??? - return null; - } - - GPSExtractor gpsExtractor = null; - - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - ParcelFileDescriptor fd = getContentResolver().openFileDescriptor(imageUri,"r"); - if (fd != null) { - gpsExtractor = new GPSExtractor(fd.getFileDescriptor()); - } - } else { - String filePath = FileUtils.getPath(this,imageUri); - if (filePath != null) { - gpsExtractor = new GPSExtractor(filePath); - } - } - - if (gpsExtractor != null) { - //get image coordinates from exif data or user location - return gpsExtractor.getCoords(); - } - - } catch (FileNotFoundException fnfe) { - Timber.w(fnfe); - return null; - } - - return null; - } - - // If on back pressed before sharing - @Override - public void onBackPressed() { - super.onBackPressed(); - } - - @Override - protected void onStop() { - // Remove saved files if activity is stopped before upload operation, ie user changed mind - if (!isMultipleUploadsFinalised) { - if (photosList != null) { - for (Contribution contribution : photosList) { - Timber.d("User changed mind, didn't click to upload button, deleted file: "+contribution.getLocalUri()); - ContributionUtils.removeTemporaryFile(contribution.getLocalUri()); - } - } - } - super.onStop(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/MultipleUploadListFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/MultipleUploadListFragment.java deleted file mode 100644 index 0dfdf5589..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/MultipleUploadListFragment.java +++ /dev/null @@ -1,254 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.content.Context; -import android.graphics.Point; -import android.net.Uri; -import android.os.Bundle; -import android.support.graphics.drawable.VectorDrawableCompat; -import android.support.v4.app.Fragment; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.DisplayMetrics; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.BaseAdapter; -import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.GridView; -import android.widget.RelativeLayout; -import android.widget.TextView; -import android.widget.Toast; - -import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; -import com.facebook.drawee.view.SimpleDraweeView; - -import butterknife.BindView; -import butterknife.ButterKnife; -import dagger.android.support.AndroidSupportInjection; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.media.MediaDetailPagerFragment; -import fr.free.nrw.commons.utils.ViewUtil; - -public class MultipleUploadListFragment extends Fragment { - - public interface OnMultipleUploadInitiatedHandler { - void OnMultipleUploadInitiated(); - } - - @BindView(R.id.multipleShareBackground) - GridView photosGrid; - - @BindView(R.id.multipleBaseTitle) - EditText baseTitle; - - private PhotoDisplayAdapter photosAdapter; - private TitleTextWatcher textWatcher = new TitleTextWatcher(); - - private Point photoSize; - private MediaDetailPagerFragment.MediaDetailProvider detailProvider; - private OnMultipleUploadInitiatedHandler multipleUploadInitiatedHandler; - - private boolean imageOnlyMode; - - private static class UploadHolderView { - private Uri imageUri; - private SimpleDraweeView image; - private TextView title; - private RelativeLayout overlay; - } - - @Override - public void onAttach(Context context) { - AndroidSupportInjection.inject(this); - super.onAttach(context); - } - - private class PhotoDisplayAdapter extends BaseAdapter { - - @Override - public int getCount() { - return detailProvider.getTotalMediaCount(); - } - - @Override - public Object getItem(int i) { - return detailProvider.getMediaAtPosition(i); - } - - @Override - public long getItemId(int i) { - return i; - } - - @Override - public View getView(int i, View view, ViewGroup viewGroup) { - UploadHolderView holder; - - if (view == null) { - view = LayoutInflater.from(getContext()).inflate(R.layout.layout_upload_item, viewGroup, false); - holder = new UploadHolderView(); - holder.image = view.findViewById(R.id.uploadImage); - holder.title = view.findViewById(R.id.uploadTitle); - holder.overlay = view.findViewById(R.id.uploadOverlay); - - holder.image.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, photoSize.y)); - holder.image.setHierarchy(GenericDraweeHierarchyBuilder - .newInstance(getResources()) - .setPlaceholderImage(VectorDrawableCompat.create(getResources(), - R.drawable.ic_image_black_24dp, getContext().getTheme())) - .setFailureImage(VectorDrawableCompat.create(getResources(), - R.drawable.ic_error_outline_black_24dp, getContext().getTheme())) - .build()); - view.setTag(holder); - } else { - holder = (UploadHolderView) view.getTag(); - } - - Contribution up = (Contribution) this.getItem(i); - - if (holder.imageUri == null || !holder.imageUri.equals(up.getLocalUri())) { - holder.image.setImageURI(up.getLocalUri().toString()); - holder.imageUri = up.getLocalUri(); - } - - if (!imageOnlyMode) { - holder.overlay.setVisibility(View.VISIBLE); - holder.title.setText(up.getFilename()); - } else { - holder.overlay.setVisibility(View.GONE); - } - - return view; - } - } - - @Override - public void onStop() { - super.onStop(); - - // FIXME: Stops the keyboard from being shown 'stale' while moving out of this fragment into the next - View target = getActivity().getCurrentFocus(); - ViewUtil.hideKeyboard(target); - } - - // FIXME: Wrong result type - private Point calculatePicDimension(int count) { - DisplayMetrics screenMetrics = getResources().getDisplayMetrics(); - int screenWidth = screenMetrics.widthPixels; - int screenHeight = screenMetrics.heightPixels; - - int picWidth = Math.min((int) Math.sqrt(screenWidth * screenHeight / count), screenWidth); - picWidth = Math.min((int) (192 * screenMetrics.density), Math.max((int) (120 * screenMetrics.density), picWidth / 48 * 48)); - int picHeight = Math.min(picWidth, (int) (192 * screenMetrics.density)); // Max Height is same as Contributions list - - return new Point(picWidth, picHeight); - } - - public void notifyDatasetChanged() { - if (photosAdapter != null) { - photosAdapter.notifyDataSetChanged(); - } - } - - public void setImageOnlyMode(boolean mode) { - imageOnlyMode = mode; - if (imageOnlyMode) { - baseTitle.setVisibility(View.GONE); - } else { - baseTitle.setVisibility(View.VISIBLE); - } - photosAdapter.notifyDataSetChanged(); - photosGrid.setEnabled(!mode); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_multiple_uploads_list, container, false); - ButterKnife.bind(this,view); - photosAdapter = new PhotoDisplayAdapter(); - photosGrid.setAdapter(photosAdapter); - photosGrid.setOnItemClickListener((AdapterView.OnItemClickListener) getActivity()); - photoSize = calculatePicDimension(detailProvider.getTotalMediaCount()); - photosGrid.setColumnWidth(photoSize.x); - - baseTitle.addTextChangedListener(textWatcher); - - baseTitle.setOnFocusChangeListener((v, hasFocus) -> { - if (!hasFocus) { - ViewUtil.hideKeyboard(v); - } - }); - - return view; - } - - @Override - public void onDestroyView() { - baseTitle.removeTextChangedListener(textWatcher); - super.onDestroyView(); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - menu.clear(); - inflater.inflate(R.menu.fragment_multiple_upload_list, menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_upload_multiple: - if (baseTitle.getText().toString().trim().isEmpty()) { - Toast.makeText(getContext(), R.string.add_set_name_toast, Toast.LENGTH_LONG).show(); - return false; - } - multipleUploadInitiatedHandler.OnMultipleUploadInitiated(); - return true; - } - return super.onOptionsItemSelected(item); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - detailProvider = (MediaDetailPagerFragment.MediaDetailProvider) getActivity(); - multipleUploadInitiatedHandler = (OnMultipleUploadInitiatedHandler) getActivity(); - - setHasOptionsMenu(true); - } - - private class TitleTextWatcher implements TextWatcher { - @Override - public void beforeTextChanged(CharSequence charSequence, int i1, int i2, int i3) { - } - - @Override - public void onTextChanged(CharSequence charSequence, int i1, int i2, int i3) { - for (int i = 0; i < detailProvider.getTotalMediaCount(); i++) { - Contribution up = (Contribution) detailProvider.getMediaAtPosition(i); - Boolean isDirty = (Boolean) up.getTag("isDirty"); - if (isDirty == null || !isDirty) { - if (!TextUtils.isEmpty(charSequence)) { - up.setFilename(charSequence.toString() + " - " + ((Integer) up.getTag("sequence") + 1)); - } else { - up.setFilename(""); - } - } - } - detailProvider.notifyDatasetChanged(); - } - - @Override - public void afterTextChanged(Editable editable) { - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ShareActivity.java b/app/src/main/java/fr/free/nrw/commons/upload/ShareActivity.java deleted file mode 100644 index 9da820a7e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/ShareActivity.java +++ /dev/null @@ -1,674 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.Manifest; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; -import android.app.Activity; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.graphics.Bitmap; -import android.graphics.Point; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Environment; -import android.support.annotation.NonNull; -import android.support.annotation.RequiresApi; -import android.support.design.widget.FloatingActionButton; -import android.support.graphics.drawable.VectorDrawableCompat; -import android.support.v4.app.ActivityCompat; -import android.support.v4.content.ContextCompat; -import android.view.KeyEvent; -import android.view.MenuItem; -import android.view.View; -import android.view.animation.DecelerateInterpolator; -import android.widget.FrameLayout; -import android.widget.Toast; - -import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; -import com.facebook.drawee.view.SimpleDraweeView; -import com.github.chrisbanes.photoview.PhotoView; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.lang.ref.WeakReference; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Named; - -import butterknife.BindView; -import butterknife.ButterKnife; -import butterknife.OnClick; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.AuthenticatedActivity; -import fr.free.nrw.commons.auth.LoginActivity; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.caching.CacheController; -import fr.free.nrw.commons.category.CategorizationFragment; -import fr.free.nrw.commons.category.OnCategoriesSaveHandler; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.modifications.CategoryModifier; -import fr.free.nrw.commons.modifications.ModifierSequence; -import fr.free.nrw.commons.modifications.ModifierSequenceDao; -import fr.free.nrw.commons.modifications.TemplateRemoveModifier; -import fr.free.nrw.commons.mwapi.CategoryApi; -import fr.free.nrw.commons.mwapi.MediaWikiApi; -import fr.free.nrw.commons.utils.ContributionUtils; -import fr.free.nrw.commons.utils.ExternalStorageUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import timber.log.Timber; - -import static fr.free.nrw.commons.upload.ExistingFileAsync.Result.DUPLICATE_PROCEED; -import static fr.free.nrw.commons.upload.ExistingFileAsync.Result.NO_DUPLICATE; -import static fr.free.nrw.commons.upload.FileUtils.getSHA1; -import static fr.free.nrw.commons.wikidata.WikidataConstants.WIKIDATA_ENTITY_ID_PREF; - -/** - * Activity for the title/desc screen after image is selected. Also starts processing image - * GPS coordinates or user location (if enabled in Settings) for category suggestions. - */ -public class ShareActivity - extends AuthenticatedActivity - implements SingleUploadFragment.OnUploadActionInitiated, - OnCategoriesSaveHandler, - ActivityCompat.OnRequestPermissionsResultCallback { - - private static final int REQUEST_PERM_ON_SUBMIT_STORAGE = 4; - //Had to make them class variables, to extract out the click listeners, also I see no harm in this - final Rect startBounds = new Rect(); - final Rect finalBounds = new Rect(); - final Point globalOffset = new Point(); - @Inject - MediaWikiApi mwApi; - @Inject - CacheController cacheController; - @Inject - SessionManager sessionManager; - @Inject - UploadController uploadController; - @Inject - ModifierSequenceDao modifierSequenceDao; - @Inject - CategoryApi apiCall; - @Inject @Named("application_preferences") SharedPreferences applicationPrefs; - @Inject - @Named("default_preferences") - SharedPreferences prefs; - @Inject - GpsCategoryModel gpsCategoryModel; - - @BindView(R.id.container) - FrameLayout flContainer; - @BindView(R.id.backgroundImage) - SimpleDraweeView backgroundImageView; - @BindView(R.id.media_map) - FloatingActionButton mapButton; - @BindView(R.id.media_upload_zoom_in) - FloatingActionButton zoomInButton; - @BindView(R.id.media_upload_zoom_out) - FloatingActionButton zoomOutButton; - @BindView(R.id.main_fab) - FloatingActionButton mainFab; - @BindView(R.id.expanded_image) - PhotoView expandedImageView; - - private String source; - private String mimeType; - private CategorizationFragment categorizationFragment; - private Uri mediaUri; - private Uri contentProviderUri; - private Contribution contribution; - private GPSExtractor gpsObj; - private String decimalCoords; - private FileProcessor fileObj; - private boolean useNewPermissions = false; - private boolean storagePermitted = false; - private boolean locationPermitted = false; - private String title; - private String description; - private String wikiDataEntityId; - private boolean duplicateCheckPassed = false; - private boolean isNearbyUpload = false; - private Animator CurrentAnimator; - private long ShortAnimationDuration; - private boolean isFABOpen = false; - private float startScaleFinal; - private Bundle savedInstanceState; - private boolean isUploadFinalised = false; // Checks is user clicked to upload button or regret before this phase - private boolean isZoom = false; - - - - /** - * Called when user taps the submit button. - * Requests Storage permission, if needed. - */ - - @Override - public void uploadActionInitiated(String title, String description) { - - this.title = title; - this.description = description; - - - if (sessionManager.getCurrentAccount() != null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - // Check for Storage permission that is required for upload. - // Do not allow user to proceed without permission, otherwise will crash - if (needsToRequestStoragePermission()) { - requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, - REQUEST_PERM_ON_SUBMIT_STORAGE); - } else { - uploadBegins(); - } - } else { - uploadBegins(); - } - } - else //Send user to login activity - { - Toast.makeText(this, "You need to login first!", Toast.LENGTH_SHORT).show(); - Intent loginIntent = new Intent(ShareActivity.this, LoginActivity.class); - startActivity(loginIntent); - } - } - - /** - * Checks whether storage permissions need to be requested. - * Permissions are needed if the file is not owned by this application, (e.g. shared from the Gallery) - * - * @return true if file is not owned by this application and permission hasn't been granted beforehand - */ - @RequiresApi(16) - private boolean needsToRequestStoragePermission() { - return !FileUtils.isSelfOwned(getApplicationContext(), mediaUri) - && (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED); - //return false; - } - - - /** - * Called after permission checks are done. - * Gets file metadata for category suggestions, displays toast, caches categories found, calls uploadController - */ - - private void uploadBegins() { - fileObj.processFileCoordinates(locationPermitted); - - Toast startingToast = Toast.makeText(this, R.string.uploading_started, Toast.LENGTH_LONG); - startingToast.show(); - - if (!fileObj.isCacheFound()) { - //Has to be called after apiCall.request() - cacheController.cacheCategory(); - Timber.d("Cache the categories found"); - } - - uploadController.startUpload(title, contentProviderUri, mediaUri, description, mimeType, source, decimalCoords, wikiDataEntityId, c -> { - ShareActivity.this.contribution = c; - showPostUpload(); - }); - isUploadFinalised = true; - } - - /** - * Starts CategorizationFragment after uploadBegins. - */ - - private void showPostUpload() { - if (categorizationFragment == null) { - categorizationFragment = new CategorizationFragment(); - } - getSupportFragmentManager().beginTransaction() - .replace(R.id.single_upload_fragment_container, categorizationFragment, "categorization") - .commit(); - } - - /** - * Send categories to modifications queue after they are selected - * - * @param categories categories selected - */ - @Override - public void onCategoriesSave(List categories) { - if (categories.size() > 0) { - ModifierSequence categoriesSequence = new ModifierSequence(contribution.getContentUri()); - - categoriesSequence.queueModifier(new CategoryModifier(categories.toArray(new String[]{}))); - categoriesSequence.queueModifier(new TemplateRemoveModifier("Uncategorized")); - modifierSequenceDao.save(categoriesSequence); - } - - // FIXME: Make sure that the content provider is up - // This is the wrong place for it, but bleh - better than not having it turned on by default for people who don't go throughl ogin - ContentResolver.setSyncAutomatically(sessionManager.getCurrentAccount(), BuildConfig.MODIFICATION_AUTHORITY, true); // Enable sync by default! - - finish(); - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - if (contribution != null) { - outState.putParcelable("contribution", contribution); - } - } - - @Override - protected void onAuthCookieAcquired(String authCookie) { - mwApi.setAuthCookie(authCookie); - } - - @Override - protected void onAuthFailure() { - Toast failureToast = Toast.makeText(this, R.string.authentication_failed, Toast.LENGTH_LONG); - failureToast.show(); - finish(); - } - - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - isUploadFinalised = false; - setContentView(R.layout.activity_share); - ButterKnife.bind(this); - initBack(); - backgroundImageView.setHierarchy(GenericDraweeHierarchyBuilder - .newInstance(getResources()) - .setPlaceholderImage(VectorDrawableCompat.create(getResources(), - R.drawable.ic_image_black_24dp, getTheme())) - .setFailureImage(VectorDrawableCompat.create(getResources(), - R.drawable.ic_error_outline_black_24dp, getTheme())) - .build()); - if (!ExternalStorageUtils.isStoragePermissionGranted(this)) { - this.savedInstanceState = savedInstanceState; - ExternalStorageUtils.requestExternalStoragePermission(this); - return; // Postpone operation to do after getting permission - } else { - receiveImageIntent(); - createContributionWithReceivedIntent(savedInstanceState); - } - } - - @Override - protected void onStop() { - // If upload is not finalised with failure or success, but contribution is created, - // we have to remove temp file, to prevent using unnecessary memory - if (!isUploadFinalised) { - if (mediaUri != null) { - ContributionUtils.removeTemporaryFile(mediaUri); - } - } - super.onStop(); - } - - private void createContributionWithReceivedIntent(Bundle savedInstanceState) { - if (savedInstanceState != null) { - contribution = savedInstanceState.getParcelable("contribution"); - } - - requestAuthToken(); - Timber.d("Uri: %s", mediaUri.toString()); - Timber.d("Ext storage dir: %s", Environment.getExternalStorageDirectory()); - - SingleUploadFragment shareView = (SingleUploadFragment) getSupportFragmentManager().findFragmentByTag("shareView"); - categorizationFragment = (CategorizationFragment) getSupportFragmentManager().findFragmentByTag("categorization"); - if (shareView == null && categorizationFragment == null) { - shareView = new SingleUploadFragment(); - getSupportFragmentManager() - .beginTransaction() - .add(R.id.single_upload_fragment_container, shareView, "shareView") - .commitAllowingStateLoss(); - } - uploadController.prepareService(); - - ContentResolver contentResolver = this.getContentResolver(); - fileObj = new FileProcessor(mediaUri, contentResolver, this); - checkIfFileExists(); - gpsObj = fileObj.processFileCoordinates(locationPermitted); - decimalCoords = fileObj.getDecimalCoords(); - if (sessionManager.getCurrentAccount() == null) { - Toast.makeText(this, getString(R.string.login_alert_message), Toast.LENGTH_SHORT).show(); - applicationPrefs.edit().putBoolean("login_skipped", false).apply(); - Intent loginIntent = new Intent(ShareActivity.this, LoginActivity.class); - startActivity(loginIntent); - } - } - - /** - * Receive intent from ContributionController.java when user selects picture to upload - */ - private void receiveImageIntent() { - Intent intent = getIntent(); - - if (Intent.ACTION_SEND.equals(intent.getAction())) { - mediaUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); - contentProviderUri = mediaUri; - mediaUri = ContributionUtils.saveFileBeingUploadedTemporarily(this, mediaUri); - - if (intent.hasExtra(UploadService.EXTRA_SOURCE)) { - source = intent.getStringExtra(UploadService.EXTRA_SOURCE); - } else { - source = Contribution.SOURCE_EXTERNAL; - } - - boolean isDirectUpload = intent.getBooleanExtra("isDirectUpload", false); - - if (isDirectUpload) { - Timber.d("This was initiated by a direct upload from Nearby"); - isNearbyUpload = true; - wikiDataEntityId = intent.getStringExtra(WIKIDATA_ENTITY_ID_PREF); - Timber.d("Received wikiDataEntityId from contribution controller %s", wikiDataEntityId); - } - mimeType = intent.getType(); - } - - if (mediaUri != null) { - backgroundImageView.setImageURI(mediaUri); - } - } - - /** - * Function to display the zoom and map FAB - */ - private void showFABMenu() { - isFABOpen = true; - - if (gpsObj != null && gpsObj.imageCoordsExists) - mapButton.setVisibility(View.VISIBLE); - zoomInButton.setVisibility(View.VISIBLE); - - mainFab.animate().rotationBy(180); - mapButton.animate().translationY(-getResources().getDimension(R.dimen.second_fab)); - zoomInButton.animate().translationY(-getResources().getDimension(R.dimen.first_fab)); - } - - /** - * Function to close the zoom and map FAB - */ - private void closeFABMenu() { - isFABOpen = false; - mainFab.animate().rotationBy(-180); - mapButton.animate().translationY(0); - zoomInButton.animate().translationY(0).setListener(new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animator) { - } - - @Override - public void onAnimationEnd(Animator animator) { - if (!isFABOpen) { - mapButton.setVisibility(View.GONE); - zoomInButton.setVisibility(View.GONE); - } - } - - @Override - public void onAnimationCancel(Animator animator) { - } - - @Override - public void onAnimationRepeat(Animator animator) { - } - }); - } - - /** - * Checks if upload was initiated via Nearby - * - * @return true if upload was initiated via Nearby - */ - protected boolean isNearbyUpload() { - return isNearbyUpload; - } - - /** - * Handles submit button permission request (for storage) - * - * @param requestCode type of request - * @param permissions permissions requested - * @param grantResults grant results - */ - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - if (requestCode == 1 && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Timber.d("onRequestPermissionsResult external storage permission granted"); - // You can receive image intent and save image to a temp file only if ext storage permission is granted - receiveImageIntent(); - createContributionWithReceivedIntent(savedInstanceState); - - if (requestCode == REQUEST_PERM_ON_SUBMIT_STORAGE) { - checkIfFileExists(); - //Uploading only begins if storage permission granted from arrow icon - uploadBegins(); - } - - } else { - finish(); - } - } - - /** - * Check if file user wants to upload already exists on Commons - */ - private void checkIfFileExists() { - if (!useNewPermissions || storagePermitted) { - if (!duplicateCheckPassed) { - //Test SHA1 of image to see if it matches SHA1 of a file on Commons - try { - InputStream inputStream = getContentResolver().openInputStream(mediaUri); - String fileSHA1 = getSHA1(inputStream); - Timber.d("Input stream created from %s", mediaUri.toString()); - Timber.d("File SHA1 is: %s", fileSHA1); - - ExistingFileAsync fileAsyncTask = - new ExistingFileAsync(new WeakReference(this), fileSHA1, new WeakReference(this), result -> { - Timber.d("%s duplicate check: %s", mediaUri.toString(), result); - duplicateCheckPassed = (result == DUPLICATE_PROCEED || result == NO_DUPLICATE); - if (duplicateCheckPassed) { - //image is not a duplicate, so now check if its a unwanted picture or not - fileObj.detectUnwantedPictures(); - } - }, mwApi); - fileAsyncTask.execute(); - } catch (IOException e) { - Timber.e(e, "IO Exception: "); - } - } - } else { - Timber.w("not ready for preprocessing: useNewPermissions=%s storage=%s location=%s", - useNewPermissions, storagePermitted, locationPermitted); - } - } - - @Override - public void onPause() { - super.onPause(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - uploadController.cleanup(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - if (categorizationFragment != null && categorizationFragment.isVisible()) { - categorizationFragment.showBackButtonDialog(); - } else { - onBackPressed(); - } - return true; - } - return super.onOptionsItemSelected(item); - } - - /** - * Allows zooming in to the image about to be uploaded. Called when zoom FAB is tapped - */ - private void zoomImageFromThumb(final View thumbView, Uri imageuri) { - // If there's an animation in progress, cancel it immediately and proceed with this one. - if (CurrentAnimator != null) { - CurrentAnimator.cancel(); - } - isZoom = true; - ViewUtil.hideKeyboard(ShareActivity.this.findViewById(R.id.titleEdit)); - closeFABMenu(); - mainFab.setVisibility(View.GONE); - - InputStream input = null; - try { - input = this.getContentResolver().openInputStream(imageuri); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } - - Zoom zoomObj = new Zoom(thumbView, flContainer, this.getContentResolver()); - Bitmap scaledImage = zoomObj.createScaledImage(input, imageuri); - - // Load the high-resolution "zoomed-in" image. - expandedImageView.setImageBitmap(scaledImage); - float startScale = zoomObj.adjustStartEndBounds(startBounds, finalBounds, globalOffset); - - // Hide the thumbnail and show the zoomed-in view. When the animation - // begins, it will position the zoomed-in view in the place of the - // thumbnail. - thumbView.setAlpha(0f); - expandedImageView.setVisibility(View.VISIBLE); - zoomOutButton.setVisibility(View.VISIBLE); - zoomInButton.setVisibility(View.GONE); - - // Set the pivot point for SCALE_X and SCALE_Y transformations - // to the top-left corner of the zoomed-in view (the default - // is the center of the view). - expandedImageView.setPivotX(0f); - expandedImageView.setPivotY(0f); - - // Construct and run the parallel animation of the four translation and - // scale properties (X, Y, SCALE_X, and SCALE_Y). - AnimatorSet set = new AnimatorSet(); - set.play(ObjectAnimator.ofFloat(expandedImageView, View.X, startBounds.left, finalBounds.left)) - .with(ObjectAnimator.ofFloat(expandedImageView, View.Y, startBounds.top, finalBounds.top)) - .with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_X, startScale, 1f)) - .with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_Y, startScale, 1f)); - set.setDuration(ShortAnimationDuration); - set.setInterpolator(new DecelerateInterpolator()); - set.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - CurrentAnimator = null; - } - - @Override - public void onAnimationCancel(Animator animation) { - CurrentAnimator = null; - } - }); - set.start(); - CurrentAnimator = set; - - // Upon clicking the zoomed-in image, it should zoom back down - // to the original bounds and show the thumbnail instead of - // the expanded image. - startScaleFinal = startScale; - } - - /** - * Called when user taps the ^ FAB button, expands to show Zoom and Map - */ - @OnClick(R.id.main_fab) - public void onMainFabClicked() { - if (!isFABOpen) { - showFABMenu(); - } else { - closeFABMenu(); - } - } - - @OnClick(R.id.media_upload_zoom_in) - public void onZoomInFabClicked() { - try { - zoomImageFromThumb(backgroundImageView, mediaUri); - } catch (Exception e) { - Timber.e(e); - } - } - - @OnClick(R.id.media_upload_zoom_out) - public void onZoomOutFabClicked() { - if (CurrentAnimator != null) { - CurrentAnimator.cancel(); - } - isZoom = false; - zoomOutButton.setVisibility(View.GONE); - mainFab.setVisibility(View.VISIBLE); - - // Animate the four positioning/sizing properties in parallel, - // back to their original values. - AnimatorSet set = new AnimatorSet(); - set.play(ObjectAnimator.ofFloat(expandedImageView, View.X, startBounds.left)) - .with(ObjectAnimator.ofFloat(expandedImageView, View.Y, startBounds.top)) - .with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_X, startScaleFinal)) - .with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_Y, startScaleFinal)); - - set.setDuration(ShortAnimationDuration); - set.setInterpolator(new DecelerateInterpolator()); - set.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - //background image view is thumbView - backgroundImageView.setAlpha(1f); - expandedImageView.setVisibility(View.GONE); - CurrentAnimator = null; - } - - @Override - public void onAnimationCancel(Animator animation) { - //background image view is thumbView - backgroundImageView.setAlpha(1f); - expandedImageView.setVisibility(View.GONE); - CurrentAnimator = null; - } - }); - set.start(); - CurrentAnimator = set; - } - - @OnClick(R.id.media_map) - public void onFabShowMapsClicked() { - if (gpsObj != null && gpsObj.imageCoordsExists) { - Uri gmmIntentUri = Uri.parse("google.streetview:cbll=" + gpsObj.getDecLatitude() + "," + gpsObj.getDecLongitude()); - Intent mapIntent = new Intent(Intent.ACTION_VIEW, gmmIntentUri); - mapIntent.setPackage("com.google.android.apps.maps"); - startActivity(mapIntent); - } - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - switch (keyCode) { - case KeyEvent.KEYCODE_BACK: - if (isZoom) { - onZoomOutFabClicked(); - return true; - } - } - return super.onKeyDown(keyCode,event); - - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.java b/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.java new file mode 100644 index 000000000..6436f621b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/SimilarImageInterface.java @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.upload; + +public interface SimilarImageInterface { + void showSimilarImageFragment(String originalFilePath, String possibleFilePath); +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/SingleUploadFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/SingleUploadFragment.java deleted file mode 100644 index 9ea2f23f4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/SingleUploadFragment.java +++ /dev/null @@ -1,389 +0,0 @@ -package fr.free.nrw.commons.upload; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.Color; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.support.annotation.NonNull; -import android.support.v4.view.ViewCompat; -import android.support.v7.app.AlertDialog; -import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.RecyclerView; -import android.text.Editable; -import android.text.Html; -import android.text.TextWatcher; -import android.text.method.LinkMovementMethod; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.EditText; -import android.widget.Spinner; -import android.widget.TextView; -import android.widget.Toast; - -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; - -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; - -import javax.inject.Inject; -import javax.inject.Named; - -import butterknife.BindView; -import butterknife.ButterKnife; -import butterknife.OnClick; -import butterknife.OnItemSelected; -import butterknife.OnTouch; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.settings.Prefs; -import fr.free.nrw.commons.utils.ViewUtil; -import timber.log.Timber; - -import static android.view.MotionEvent.ACTION_UP; - -public class SingleUploadFragment extends CommonsDaggerSupportFragment { - - @BindView(R.id.titleEdit) EditText titleEdit; - @BindView(R.id.rv_descriptions) RecyclerView rvDescriptions; - @BindView(R.id.titleDescButton) Button titleDescButton; - @BindView(R.id.share_license_summary) TextView licenseSummaryView; - @BindView(R.id.licenseSpinner) Spinner licenseSpinner; - - - @Inject @Named("default_preferences") SharedPreferences prefs; - @Inject @Named("direct_nearby_upload_prefs") SharedPreferences directPrefs; - - private String license; - private OnUploadActionInitiated uploadActionInitiatedHandler; - private TitleTextWatcher textWatcher = new TitleTextWatcher(); - private DescriptionsAdapter descriptionsAdapter; - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.activity_share, menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - //What happens when the 'submit' icon is tapped - case R.id.menu_upload_single: - - if (titleEdit.getText().toString().trim().isEmpty()) { - Toast.makeText(getContext(), R.string.add_title_toast, Toast.LENGTH_LONG).show(); - return false; - } - - String title = titleEdit.getText().toString(); - String descriptionsInVariousLanguages = getDescriptionsInAppropriateFormat(); - - //Save the title/desc in short-lived cache so next time this fragment is loaded, we can access these - prefs.edit() - .putString("Title", title) - .putString("Desc", new Gson().toJson(descriptionsAdapter - .getDescriptions()))//Description, now is not just a string, its a list of description objects - .apply(); - - uploadActionInitiatedHandler - .uploadActionInitiated(title, descriptionsInVariousLanguages); - return true; - } - return super.onOptionsItemSelected(item); - } - - private String getDescriptionsInAppropriateFormat() { - List descriptions = descriptionsAdapter.getDescriptions(); - StringBuilder descriptionsInAppropriateFormat = new StringBuilder(); - for (Description description : descriptions) { - String individualDescription = String.format("{{%s|1=%s}}", description.getLanguageId(), - description.getDescriptionText()); - descriptionsInAppropriateFormat.append(individualDescription); - } - return descriptionsInAppropriateFormat.toString(); - - } - - private List getDescriptions() { - List descriptions = descriptionsAdapter.getDescriptions(); - return descriptions; - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - View rootView = inflater.inflate(R.layout.fragment_single_upload, container, false); - ButterKnife.bind(this, rootView); - - initRecyclerView(); - - Intent activityIntent = getActivity().getIntent(); - if (activityIntent.hasExtra("title")) { - titleEdit.setText(activityIntent.getStringExtra("title")); - } - if (activityIntent.hasExtra("description") && descriptionsAdapter.getDescriptions() != null - && descriptionsAdapter.getDescriptions().size() > 0) { - descriptionsAdapter.getDescriptions().get(0) - .setDescriptionText(activityIntent.getStringExtra("description")); - descriptionsAdapter.notifyItemChanged(0); - } - - - ArrayList licenseItems = new ArrayList<>(); - licenseItems.add(getString(R.string.license_name_cc0)); - licenseItems.add(getString(R.string.license_name_cc_by)); - licenseItems.add(getString(R.string.license_name_cc_by_sa)); - licenseItems.add(getString(R.string.license_name_cc_by_four)); - licenseItems.add(getString(R.string.license_name_cc_by_sa_four)); - - license = prefs.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3); - - // If this is a direct upload from Nearby, autofill title and desc fields with the Place's values - boolean isNearbyUpload = ((ShareActivity) getActivity()).isNearbyUpload(); - - if (isNearbyUpload) { - String imageTitle = directPrefs.getString("Title", ""); - String imageDesc = directPrefs.getString("Desc", ""); - String imageCats = directPrefs.getString("Category", ""); - Timber.d("Image title: " + imageTitle + ", image desc: " + imageDesc + ", image categories: " + imageCats); - titleEdit.setText(imageTitle); - if (descriptionsAdapter.getDescriptions() != null - && descriptionsAdapter.getDescriptions().size() > 0) { - descriptionsAdapter.getDescriptions().get(0).setDescriptionText(imageDesc); - descriptionsAdapter.notifyItemChanged(0); - } - } - - // check if this is the first time we have uploaded - if (prefs.getString("Title", "").trim().length() == 0 - && prefs.getString("Desc", "").trim().length() == 0) { - titleDescButton.setVisibility(View.GONE); - } - - Timber.d(license); - - ArrayAdapter adapter; - if (PreferenceManager.getDefaultSharedPreferences(getActivity()).getBoolean("theme", false)) { - // dark theme - adapter = new ArrayAdapter<>(getActivity(), android.R.layout.simple_spinner_dropdown_item, licenseItems); - } else { - // light theme - adapter = new ArrayAdapter<>(getActivity(), R.layout.light_simple_spinner_dropdown_item, licenseItems); - } - - licenseSpinner.setAdapter(adapter); - - int position = licenseItems.indexOf(getString(Utils.licenseNameFor(license))); - - // Check position is valid - if (position < 0) { - Timber.d("Invalid position: %d. Using default license", position); - position = 4; - } - - Timber.d("Position: %d %s", position, getString(Utils.licenseNameFor(license))); - licenseSpinner.setSelection(position); - - titleEdit.addTextChangedListener(textWatcher); - - titleEdit.setOnFocusChangeListener((v, hasFocus) -> { - if (!hasFocus) { - ViewUtil.hideKeyboard(v); - } - }); - - setLicenseSummary(license); - - return rootView; - } - - private void initRecyclerView() { - descriptionsAdapter = new DescriptionsAdapter(); - descriptionsAdapter.setCallback(this::showInfoAlert); - descriptionsAdapter.setLanguages(getLocaleSupportedByDevice()); - rvDescriptions.setLayoutManager(new LinearLayoutManager(getContext())); - rvDescriptions.setAdapter(descriptionsAdapter); - } - - private List getLocaleSupportedByDevice() { - List languages = new ArrayList<>(); - Locale[] localesArray = Locale.getAvailableLocales(); - List locales = Arrays.asList(localesArray); - for (Locale locale : locales) { - languages.add(new Language(locale)); - } - return languages; - } - - @Override - public void onDestroyView() { - titleEdit.removeTextChangedListener(textWatcher); - super.onDestroyView(); - } - - @OnItemSelected(R.id.licenseSpinner) - void onLicenseSelected(AdapterView parent, View view, int position, long id) { - String licenseName = parent.getItemAtPosition(position).toString(); - - // Set selected color to white because it should be readable on random images. - TextView selectedText = (TextView) licenseSpinner.getChildAt(0); - if (selectedText != null) { - selectedText.setTextColor(Color.WHITE); - selectedText.setBackgroundColor(Color.TRANSPARENT); - } - - String license; - if (getString(R.string.license_name_cc0).equals(licenseName)) { - license = Prefs.Licenses.CC0; - } else if (getString(R.string.license_name_cc_by).equals(licenseName)) { - license = Prefs.Licenses.CC_BY_3; - } else if (getString(R.string.license_name_cc_by_sa).equals(licenseName)) { - license = Prefs.Licenses.CC_BY_SA_3; - } else if (getString(R.string.license_name_cc_by_four).equals(licenseName)) { - license = Prefs.Licenses.CC_BY_4; - } else if (getString(R.string.license_name_cc_by_sa_four).equals(licenseName)) { - license = Prefs.Licenses.CC_BY_SA_4; - } else { - throw new IllegalStateException("Unknown licenseName: " + licenseName); - } - - setLicenseSummary(license); - prefs.edit() - .putString(Prefs.DEFAULT_LICENSE, license) - .apply(); - } - - - @OnClick(R.id.titleDescButton) - void setTitleDescButton() { - //Retrieve last title and desc entered - String title = prefs.getString("Title", ""); - String descriptionJson = prefs.getString("Desc", ""); - Timber.d("Title: %s, Desc: %s", title, descriptionJson); - - titleEdit.setText(title); - Type typeOfDest = new TypeToken>() { - }.getType(); - - List descriptions = new Gson().fromJson(descriptionJson, typeOfDest); - descriptionsAdapter.setDescriptions(descriptions); - - } - - /** - * Copied from https://stackoverflow.com/a/26269435/8065933 - */ - @OnTouch(R.id.titleEdit) - boolean titleInfo(View view, MotionEvent motionEvent) { - final int value; - if (ViewCompat.getLayoutDirection(getView()) == ViewCompat.LAYOUT_DIRECTION_LTR) { - value = titleEdit.getRight() - titleEdit.getCompoundDrawables()[2].getBounds().width(); - if (motionEvent.getAction() == ACTION_UP && motionEvent.getRawX() >= value) { - showInfoAlert(R.string.media_detail_title, R.string.title_info); - return true; - } - } - else { - value = titleEdit.getLeft() + titleEdit.getCompoundDrawables()[0].getBounds().width(); - if (motionEvent.getAction() == ACTION_UP && motionEvent.getRawX() <= value) { - showInfoAlert(R.string.media_detail_title, R.string.title_info); - return true; - } - } - return false; - } - - @SuppressLint("StringFormatInvalid") - private void setLicenseSummary(String license) { - String licenseHyperLink = ""+ getString(Utils.licenseNameFor(license)) + "
"; - licenseSummaryView.setMovementMethod(LinkMovementMethod.getInstance()); - licenseSummaryView.setText(Html.fromHtml(getString(R.string.share_license_summary, licenseHyperLink))); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - setHasOptionsMenu(true); - uploadActionInitiatedHandler = (OnUploadActionInitiated) getActivity(); - } - - @Override - public void onStop() { - super.onStop(); - - // FIXME: Stops the keyboard from being shown 'stale' while moving out of this fragment into the next - View target = getActivity().getCurrentFocus(); - ViewUtil.hideKeyboard(target); - } - - @NonNull - private String licenseUrlFor(String license) { - switch (license) { - case Prefs.Licenses.CC_BY_3: - return "https://creativecommons.org/licenses/by/3.0/"; - case Prefs.Licenses.CC_BY_4: - return "https://creativecommons.org/licenses/by/4.0/"; - case Prefs.Licenses.CC_BY_SA_3: - return "https://creativecommons.org/licenses/by-sa/3.0/"; - case Prefs.Licenses.CC_BY_SA_4: - return "https://creativecommons.org/licenses/by-sa/4.0/"; - case Prefs.Licenses.CC0: - return "https://creativecommons.org/publicdomain/zero/1.0/"; - } - throw new RuntimeException("Unrecognized license value: " + license); - } - - public interface OnUploadActionInitiated { - - void uploadActionInitiated(String title, String description); - } - - private class TitleTextWatcher implements TextWatcher { - - @Override - public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) { - } - - @Override - public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) { - } - - @Override - public void afterTextChanged(Editable editable) { - if (getActivity() != null) { - getActivity().invalidateOptionsMenu(); - } - } - } - - - private void showInfoAlert (int titleStringID, int messageStringID){ - new AlertDialog.Builder(getContext()) - .setTitle(titleStringID) - .setMessage(messageStringID) - .setCancelable(true) - .setNeutralButton(android.R.string.ok, (dialog, id) -> dialog.cancel()) - .create() - .show(); - } - - @OnClick(R.id.ll_add_description) - public void onLLAddDescriptionClicked() { - descriptionsAdapter.addDescription(new Description()); - rvDescriptions.scrollToPosition(descriptionsAdapter.getItemCount() - 1); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/SpinnerLanguagesAdapter.java b/app/src/main/java/fr/free/nrw/commons/upload/SpinnerLanguagesAdapter.java index eebe6b4fa..97908fa67 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/SpinnerLanguagesAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/SpinnerLanguagesAdapter.java @@ -1,48 +1,88 @@ package fr.free.nrw.commons.upload; import android.content.Context; +import android.graphics.Color; +import android.os.Build; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.LinearLayout; import android.widget.TextView; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Locale; import butterknife.BindView; import butterknife.ButterKnife; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.utils.BiMap; public class SpinnerLanguagesAdapter extends ArrayAdapter { private final int resource; private final LayoutInflater layoutInflater; - List languages; + private List languageNamesList; + private List languageCodesList; + private final BiMap selectedLanguages; + public String selectedLangCode=""; + + public SpinnerLanguagesAdapter(@NonNull Context context, - int resource) { + int resource, BiMap selectedLanguages) { super(context, resource); this.resource = resource; this.layoutInflater = LayoutInflater.from(context); - languages = new ArrayList<>(); + languageNamesList = new ArrayList<>(); + languageCodesList = new ArrayList<>(); + prepareLanguages(); + this.selectedLanguages = selectedLanguages; } - public void setLanguages(List languages) { - this.languages = languages; + private void prepareLanguages() { + List languages = getLocaleSupportedByDevice(); + + for(Language language: languages) { + if(!languageCodesList.contains(language.getLocale().getLanguage())) { + languageNamesList.add(language.getLocale().getDisplayName()); + languageCodesList.add(language.getLocale().getLanguage()); + } + } + } + + private List getLocaleSupportedByDevice() { + List languages = new ArrayList<>(); + Locale[] localesArray = Locale.getAvailableLocales(); + for (Locale locale : localesArray) { + languages.add(new Language(locale)); + } + + Collections.sort(languages, (language, t1) -> language.getLocale().getDisplayName() + .compareTo(t1.getLocale().getDisplayName())); + return languages; + } + + @Override + public boolean isEnabled(int position) { + return !languageCodesList.get(position).isEmpty()&& + (!selectedLanguages.containsKey(languageCodesList.get(position)) || + languageCodesList.get(position).equals(selectedLangCode)); } @Override public int getCount() { - return languages.size(); + return languageNamesList.size(); } @Override public View getDropDownView(int position, @Nullable View convertView, - @NonNull ViewGroup parent) { + @NonNull ViewGroup parent) { View view = layoutInflater.inflate(resource, parent, false); ViewHolder holder = new ViewHolder(view); holder.init(position, true); @@ -75,19 +115,40 @@ public class SpinnerLanguagesAdapter extends ArrayAdapter { } public void init(int position, boolean isDropDownView) { - Language language = languages.get(position); if (!isDropDownView) { view.setVisibility(View.GONE); - tvLanguage.setText( - language.getLocale().getLanguage()); + if(languageCodesList.get(position).length()>2) + tvLanguage.setText(languageCodesList.get(position).subSequence(0,2)); + else + tvLanguage.setText(languageCodesList.get(position)); + } else { view.setVisibility(View.VISIBLE); - tvLanguage.setText( - String.format("%s [%s]", language.getLocale().getDisplayName(), - language.getLocale().getLanguage())); + if (languageCodesList.get(position).isEmpty()) { + tvLanguage.setText(languageNamesList.get(position)); + tvLanguage.setTextColor(Color.GRAY); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + tvLanguage.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); + } + } else { + tvLanguage.setText( + String.format("%s [%s]", languageNamesList.get(position), languageCodesList.get(position))); + if(selectedLanguages.containsKey(languageCodesList.get(position))&& + !languageCodesList.get(position).equals(selectedLangCode)) + tvLanguage.setTextColor(Color.GRAY); + else + tvLanguage.setTextColor(Color.BLACK); + } } - } } + String getLanguageCode(int position) { + return languageCodesList.get(position); + } + + int getIndexOfUserDefaultLocale(Context context) { + return languageCodesList.indexOf(context.getResources().getConfiguration().locale.getLanguage()); + } + } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailClickedListener.java b/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailClickedListener.java new file mode 100644 index 000000000..8963d0e25 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/ThumbnailClickedListener.java @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.upload; + +public interface ThumbnailClickedListener { + void thumbnailClicked(UploadModel.UploadItem content); +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/Title.java b/app/src/main/java/fr/free/nrw/commons/upload/Title.java new file mode 100644 index 000000000..e0781ce67 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/Title.java @@ -0,0 +1,37 @@ +package fr.free.nrw.commons.upload; + +import android.text.TextUtils; + +import io.reactivex.subjects.BehaviorSubject; +import timber.log.Timber; + +class Title{ + + private String titleText; + private boolean set; + + @Override + public String toString() { + return titleText; + } + + public void setTitleText(String titleText) { + this.titleText = titleText; + + if (!TextUtils.isEmpty(titleText)) { + set = true; + } + } + + public boolean isSet() { + return set; + } + + public void setSet(boolean set) { + this.set = set; + } + + public boolean isEmpty() { + return titleText==null || titleText.isEmpty(); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java new file mode 100644 index 000000000..2ea89e8e6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java @@ -0,0 +1,607 @@ +package fr.free.nrw.commons.upload; + +import android.Manifest; +import android.animation.LayoutTransition; +import android.annotation.SuppressLint; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.support.constraint.ConstraintLayout; +import android.support.design.widget.TextInputLayout; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.CardView; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.Html; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.ViewFlipper; + +import com.github.chrisbanes.photoview.PhotoView; +import com.jakewharton.rxbinding2.view.RxView; +import com.jakewharton.rxbinding2.widget.RxTextView; +import com.pedrogomez.renderers.RVRendererAdapter; + +import java.util.ArrayList; +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.CommonsApplication; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.Utils; +import fr.free.nrw.commons.auth.AuthenticatedActivity; +import fr.free.nrw.commons.auth.LoginActivity; +import fr.free.nrw.commons.category.CategoriesModel; +import fr.free.nrw.commons.category.CategoryItem; +import fr.free.nrw.commons.contributions.Contribution; +import fr.free.nrw.commons.mwapi.MediaWikiApi; +import fr.free.nrw.commons.utils.DialogUtil; +import fr.free.nrw.commons.utils.StringUtils; +import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +import static fr.free.nrw.commons.utils.ImageUtils.Result; +import static fr.free.nrw.commons.utils.ImageUtils.getErrorMessageForResult; +import static fr.free.nrw.commons.wikidata.WikidataConstants.WIKIDATA_ENTITY_ID_PREF; + +public class UploadActivity extends AuthenticatedActivity implements UploadView, SimilarImageInterface { + @Inject InputMethodManager inputMethodManager; + @Inject MediaWikiApi mwApi; + @Inject @Named("direct_nearby_upload_prefs") SharedPreferences directPrefs; + @Inject UploadPresenter presenter; + @Inject CategoriesModel categoriesModel; + + // Main GUI + @BindView(R.id.backgroundImage) PhotoView background; + @BindView(R.id.activity_upload_cards) ConstraintLayout cardLayout; + @BindView(R.id.view_flipper) ViewFlipper viewFlipper; + + // Top Card + @BindView(R.id.top_card) CardView topCard; + @BindView(R.id.top_card_expand_button) ImageView topCardExpandButton; + @BindView(R.id.top_card_title) TextView topCardTitle; + @BindView(R.id.top_card_thumbnails) RecyclerView topCardThumbnails; + + // Bottom Card + @BindView(R.id.bottom_card) CardView bottomCard; + @BindView(R.id.bottom_card_expand_button) ImageView bottomCardExpandButton; + @BindView(R.id.bottom_card_title) TextView bottomCardTitle; + @BindView(R.id.bottom_card_subtitle) TextView bottomCardSubtitle; + @BindView(R.id.bottom_card_next) Button next; + @BindView(R.id.bottom_card_previous) Button previous; + @BindView(R.id.bottom_card_add_desc) Button bottomCardAddDescription; + + //Right Card + @BindView(R.id.right_card) CardView rightCard; + @BindView(R.id.right_card_expand_button) ImageView rightCardExpandButton; + @BindView(R.id.right_card_map_button) View rightCardMapButton; + + // Category Search + @BindView(R.id.categories_title) TextView categoryTitle; + @BindView(R.id.category_next) Button categoryNext; + @BindView(R.id.category_previous) Button categoryPrevious; + @BindView(R.id.categoriesSearchInProgress) ProgressBar categoriesSearchInProgress; + @BindView(R.id.category_search) EditText categoriesSearch; + @BindView(R.id.category_search_container) TextInputLayout categoriesSearchContainer; + @BindView(R.id.categories) RecyclerView categoriesList; + + // Final Submission + @BindView(R.id.license_title) TextView licenseTitle; + @BindView(R.id.share_license_summary) TextView licenseSummary; + @BindView(R.id.media_upload_policy) TextView licensePolicy; + @BindView(R.id.license_list) Spinner licenseSpinner; + @BindView(R.id.submit) Button submit; + @BindView(R.id.license_previous) Button licensePrevious; + @BindView(R.id.rv_descriptions) RecyclerView rvDescriptions; + + private DescriptionsAdapter descriptionsAdapter; + private RVRendererAdapter categoriesAdapter; + private CompositeDisposable compositeDisposable; + + DexterPermissionObtainer dexterPermissionObtainer; + + + @SuppressLint("CheckResult") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_upload); + ButterKnife.bind(this); + compositeDisposable = new CompositeDisposable(); + + configureLayout(); + configureTopCard(); + configureBottomCard(); + initRecyclerView(); + configureRightCard(); + configureNavigationButtons(); + configureCategories(); + configureLicenses(); + + presenter.init(); + + dexterPermissionObtainer = new DexterPermissionObtainer(this, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + getString(R.string.storage_permission), + getString(R.string.write_storage_permission_rationale_for_image_share)); + + dexterPermissionObtainer.confirmStoragePermissions().subscribe(this::receiveSharedItems); + } + + @Override + public boolean checkIfLoggedIn() { + if (!sessionManager.isUserLoggedIn()) { + Timber.d("Current account is null"); + ViewUtil.showLongToast(this, getString(R.string.user_not_logged_in)); + Intent loginIntent = new Intent(UploadActivity.this, LoginActivity.class); + startActivity(loginIntent); + return false; + } + return true; + } + + @Override + protected void onDestroy() { + presenter.cleanup(); + super.onDestroy(); + } + + @Override + protected void onResume() { + super.onResume(); + checkIfLoggedIn(); + compositeDisposable.add( + dexterPermissionObtainer.confirmStoragePermissions() + .subscribe(() -> presenter.addView(this))); + compositeDisposable.add( + RxTextView.textChanges(categoriesSearch) + .doOnEach(v -> categoriesSearchContainer.setError(null)) + .takeUntil(RxView.detaches(categoriesSearch)) + .debounce(500, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(filter -> updateCategoryList(filter.toString()), Timber::e) + ); + } + + @Override + protected void onPause() { + presenter.removeView(); + compositeDisposable.dispose(); + compositeDisposable = new CompositeDisposable(); + super.onPause(); + } + + @Override + public void updateThumbnails(List uploads) { + int uploadCount = uploads.size(); + topCardThumbnails.setAdapter(new UploadThumbnailsAdapterFactory(presenter::thumbnailClicked).create(uploads)); + topCardTitle.setText(getResources().getQuantityString(R.plurals.upload_count_title, uploadCount, uploadCount)); + } + + @Override + public void updateRightCardContent(boolean gpsPresent) { + if(gpsPresent){ + rightCardMapButton.setVisibility(View.VISIBLE); + }else{ + rightCardMapButton.setVisibility(View.GONE); + } + //The card should be disabled if it has no buttons. + setRightCardVisibility(gpsPresent); + } + + @Override + public void updateBottomCardContent(int currentStep, + int stepCount, + UploadModel.UploadItem uploadItem, + boolean isShowingItem) { + String cardTitle = getResources().getString(R.string.step_count, currentStep, stepCount); + String cardSubTitle = getResources().getString(R.string.image_in_set_label, currentStep); + bottomCardTitle.setText(cardTitle); + bottomCardSubtitle.setText(cardSubTitle); + categoryTitle.setText(cardTitle); + licenseTitle.setText(cardTitle); + if(isShowingItem) { + descriptionsAdapter.setItems(uploadItem.title, uploadItem.descriptions); + rvDescriptions.setAdapter(descriptionsAdapter); + } + } + + @Override + public void updateLicenses(List licenses, String selectedLicense) { + ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, licenses); + licenseSpinner.setAdapter(adapter); + + int position = licenses.indexOf(getString(Utils.licenseNameFor(selectedLicense))); + + // Check position is valid + if (position < 0) { + Timber.d("Invalid position: %d. Using default license", position); + position = licenses.size() - 1; + } + + Timber.d("Position: %d %s", position, getString(Utils.licenseNameFor(selectedLicense))); + licenseSpinner.setSelection(position); + } + + @SuppressLint("StringFormatInvalid") + @Override + public void updateLicenseSummary(String selectedLicense) { + String licenseHyperLink = "" + + getString(Utils.licenseNameFor(selectedLicense)) + "
"; + licenseSummary.setMovementMethod(LinkMovementMethod.getInstance()); + licenseSummary.setText( + Html.fromHtml( + getString(R.string.share_license_summary, licenseHyperLink))); + } + + @Override + public void updateTopCardContent() { + RecyclerView.Adapter adapter = topCardThumbnails.getAdapter(); + if (adapter != null) { + adapter.notifyDataSetChanged(); + } + } + + @Override + public void setNextEnabled(boolean available) { + next.setEnabled(available); + categoryNext.setEnabled(available); + } + + @Override + public void setSubmitEnabled(boolean available) { + submit.setEnabled(available); + } + + @Override + public void setPreviousEnabled(boolean available) { + previous.setEnabled(available); + categoryPrevious.setEnabled(available); + licensePrevious.setEnabled(available); + } + + @Override + public void setTopCardState(boolean state) { + updateCardState(state, topCardExpandButton, topCardThumbnails); + } + + @Override + public void setTopCardVisibility(boolean visible) { + topCard.setVisibility(visible ? View.VISIBLE : View.GONE); + } + + @Override + public void setBottomCardVisibility(boolean visible) { + bottomCard.setVisibility(visible ? View.VISIBLE : View.GONE); + } + + @Override + public void setRightCardVisibility(boolean visible) { + rightCard.setVisibility(visible ? View.VISIBLE : View.GONE); + } + + @Override + public void setBottomCardVisibility(@UploadPage int page) { + if (page == TITLE_CARD) { + viewFlipper.setDisplayedChild(0); + } else if (page == CATEGORIES) { + viewFlipper.setDisplayedChild(1); + } else if (page == LICENSE) { + viewFlipper.setDisplayedChild(2); + dismissKeyboard(); + } else if (page == PLEASE_WAIT) { + viewFlipper.setDisplayedChild(3); + } + } + + @Override + public void setBottomCardState(boolean state) { + updateCardState(state, bottomCardExpandButton, rvDescriptions, previous, next, bottomCardAddDescription); + } + + @Override + public void setRightCardState(boolean state) { + rightCardExpandButton.animate().rotation(rightCardExpandButton.getRotation() + (state ? -180 : 180)).start(); + //Add all items in rightCard here + rightCardMapButton.setVisibility(state ? View.VISIBLE : View.GONE); + } + + @Override + public void setBackground(Uri mediaUri) { + background.setImageURI(mediaUri); + } + + + @Override + public void dismissKeyboard() { + InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); + + // verify if the soft keyboard is open + if (imm != null && imm.isAcceptingText() && getCurrentFocus() != null) { + imm.hideSoftInputFromWindow(getCurrentFocus().getWindowToken(), 0); + } + } + + @Override + public void showBadPicturePopup(@Result int result) { + String errorMessageForResult = getErrorMessageForResult(this, result); + if (StringUtils.isNullOrWhiteSpace(errorMessageForResult)) { + return; + } + + DialogUtil.showAlertDialog(this, + getString(R.string.warning), + errorMessageForResult, + () -> presenter.deletePicture(), + () -> presenter.keepPicture()); + } + + @Override + public void showDuplicatePicturePopup() { + DialogUtil.showAlertDialog(this, + getString(R.string.warning), + String.format(getString(R.string.upload_title_duplicate), presenter.getCurrentImageFileName()), + null, + () -> { + presenter.keepPicture(); + presenter.handleNext(descriptionsAdapter.getTitle(), getDescriptions()); + }); + } + + public void showNoCategorySelectedWarning() { + DialogUtil.showAlertDialog(this, + getString(R.string.no_categories_selected), + getString(R.string.no_categories_selected_warning_desc), + getString(R.string.no_go_back), + getString(R.string.yes_submit), + null, + () -> presenter.handleCategoryNext(categoriesModel, true)); + } + + @Override + public void launchMapActivity(String decCoords) { + Utils.handleGeoCoordinates(this, decCoords); + } + + @Override + public void showErrorMessage(int resourceId) { + ViewUtil.showShortToast(this, resourceId); + } + + @Override + public void initDefaultCategories() { + updateCategoryList(""); + } + + @Override + protected void onAuthCookieAcquired(String authCookie) { + mwApi.setAuthCookie(authCookie); + } + + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == CommonsApplication.OPEN_APPLICATION_DETAIL_SETTINGS) { + dexterPermissionObtainer.onManualPermissionReturned(); + } + } + + + @Override + protected void onAuthFailure() { + Toast.makeText(this, R.string.authentication_failed, Toast.LENGTH_LONG).show(); + finish(); + } + + private void configureLicenses() { + licenseSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + String licenseName = parent.getItemAtPosition(position).toString(); + presenter.selectLicense(licenseName); + } + + @Override + public void onNothingSelected(AdapterView parent) { + presenter.selectLicense(null); + } + }); + } + + private void configureLayout() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + cardLayout.getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING); + } + background.setScaleType(ImageView.ScaleType.CENTER_CROP); + background.setOnScaleChangeListener((scaleFactor, x, y) -> presenter.closeAllCards()); + } + + private void configureTopCard() { + topCardExpandButton.setOnClickListener(v -> presenter.toggleTopCardState()); + topCardThumbnails.setLayoutManager(new LinearLayoutManager(this, + LinearLayoutManager.HORIZONTAL, false)); + } + + private void configureBottomCard() { + bottomCardExpandButton.setOnClickListener(v -> presenter.toggleBottomCardState()); + bottomCardAddDescription.setOnClickListener(v -> addNewDescription()); + } + + private void addNewDescription() { + descriptionsAdapter.addDescription(new Description()); + rvDescriptions.scrollToPosition(descriptionsAdapter.getItemCount() - 1); + } + + private void configureRightCard() { + rightCardExpandButton.setOnClickListener(v -> presenter.toggleRightCardState()); + rightCardMapButton.setOnClickListener(v -> presenter.openCoordinateMap()); + } + + private void configureNavigationButtons() { + // Navigation next / previous for each image as we're collecting title + description + next.setOnClickListener(v -> { + setTitleAndDescriptions(); + presenter.handleNext(descriptionsAdapter.getTitle(), + descriptionsAdapter.getDescriptions()); + }); + previous.setOnClickListener(v -> presenter.handlePrevious()); + + // Next / previous for the category selection currentPage + categoryNext.setOnClickListener(v -> presenter.handleCategoryNext(categoriesModel, false)); + categoryPrevious.setOnClickListener(v -> presenter.handlePrevious()); + + // Finally, the previous / submit buttons on the final currentPage of the wizard + licensePrevious.setOnClickListener(v -> presenter.handlePrevious()); + submit.setOnClickListener(v -> { + Toast.makeText(this, R.string.uploading_started, Toast.LENGTH_LONG).show(); + presenter.handleSubmit(categoriesModel); + finish(); + }); + + } + + private void setTitleAndDescriptions() { + List descriptions = descriptionsAdapter.getDescriptions(); + Timber.d("Descriptions size is %d are %s", descriptions.size(), descriptions); + } + + private void configureCategories() { + categoriesAdapter = new UploadCategoriesAdapterFactory(categoriesModel).create(new ArrayList<>()); + categoriesList.setLayoutManager(new LinearLayoutManager(this)); + categoriesList.setAdapter(categoriesAdapter); + } + + @SuppressLint("CheckResult") + private void updateCategoryList(String filter) { + List imageTitleList = presenter.getImageTitleList(); + Observable.fromIterable(categoriesModel.getSelectedCategories()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnSubscribe(disposable -> { + categoriesSearchInProgress.setVisibility(View.VISIBLE); + categoriesSearchContainer.setError(null); + categoriesAdapter.clear(); + }) + .observeOn(Schedulers.io()) + .concatWith( + categoriesModel.searchAll(filter, imageTitleList) + .mergeWith(categoriesModel.searchCategories(filter, imageTitleList)) + .concatWith(TextUtils.isEmpty(filter) + ? categoriesModel.defaultCategories(imageTitleList) : Observable.empty()) + ) + .filter(categoryItem -> !categoriesModel.containsYear(categoryItem.getName())) + .distinct() + .sorted(categoriesModel.sortBySimilarity(filter)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + s -> categoriesAdapter.add(s), + Timber::e, + () -> { + categoriesAdapter.notifyDataSetChanged(); + categoriesSearchInProgress.setVisibility(View.GONE); + + if (categoriesAdapter.getItemCount() == categoriesModel.selectedCategoriesCount() + && !categoriesSearch.getText().toString().isEmpty()) { + categoriesSearchContainer.setError("No categories found"); + } + } + ); + } + + private void receiveSharedItems() { + Intent intent = getIntent(); + String mimeType = intent.getType(); + String source; + + if (intent.hasExtra(UploadService.EXTRA_SOURCE)) { + source = intent.getStringExtra(UploadService.EXTRA_SOURCE); + } else { + source = Contribution.SOURCE_EXTERNAL; + } + + if (Intent.ACTION_SEND.equals(intent.getAction())) { + Uri mediaUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (intent.getBooleanExtra("isDirectUpload", false)) { + String imageTitle = directPrefs.getString("Title", ""); + String imageDesc = directPrefs.getString("Desc", ""); + Timber.i("Received direct upload with title %s and description %s", imageTitle, imageDesc); + String wikidataEntityIdPref = intent.getStringExtra(WIKIDATA_ENTITY_ID_PREF); + presenter.receiveDirect(mediaUri, mimeType, source, wikidataEntityIdPref, imageTitle, imageDesc); + } else { + Timber.i("Received single upload"); + presenter.receive(mediaUri, mimeType, source); + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction())) { + ArrayList urisList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + Timber.i("Received multiple upload %s", urisList.size()); + presenter.receive(urisList, mimeType, source); + } + } + + private void updateCardState(boolean state, ImageView button, View... content) { + button.animate().rotation(button.getRotation() + (state ? 180 : -180)).start(); + if (content != null) { + for (View view : content) { + view.setVisibility(state ? View.VISIBLE : View.GONE); + } + } + } + + @Override + public List getDescriptions() { + return descriptionsAdapter.getDescriptions(); + } + + private void initRecyclerView() { + descriptionsAdapter = new DescriptionsAdapter(this); + descriptionsAdapter.setCallback(this::showInfoAlert); + rvDescriptions.setLayoutManager(new LinearLayoutManager(getApplicationContext())); + rvDescriptions.setAdapter(descriptionsAdapter); + addNewDescription(); + } + + + private void showInfoAlert(int titleStringID, int messageStringId, String... formatArgs) { + new AlertDialog.Builder(this) + .setTitle(titleStringID) + .setMessage(getString(messageStringId, (Object[]) formatArgs)) + .setCancelable(true) + .setNeutralButton(android.R.string.ok, (dialog, id) -> dialog.cancel()) + .create() + .show(); + } + + @Override + public void showSimilarImageFragment(String originalFilePath, String possibleFilePath) { + SimilarImageDialogFragment newFragment = new SimilarImageDialogFragment(); + Bundle args = new Bundle(); + args.putString("originalImagePath", originalFilePath); + args.putString("possibleImagePath", possibleFilePath); + newFragment.setArguments(args); + newFragment.show(getSupportFragmentManager(), "dialog"); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadCategoriesAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadCategoriesAdapterFactory.java new file mode 100644 index 000000000..1797cbe80 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadCategoriesAdapterFactory.java @@ -0,0 +1,27 @@ +package fr.free.nrw.commons.upload; + +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.category.CategoryClickedListener; +import fr.free.nrw.commons.category.CategoryItem; + +public class UploadCategoriesAdapterFactory { + private final CategoryClickedListener listener; + + public UploadCategoriesAdapterFactory(CategoryClickedListener listener) { + this.listener = listener; + } + + public RVRendererAdapter create(List placeList) { + RendererBuilder builder = new RendererBuilder() + .bind(CategoryItem.class, new UploadCategoriesRenderer(listener)); + ListAdapteeCollection collection = new ListAdapteeCollection<>( + placeList != null ? placeList : Collections.emptyList()); + return new RVRendererAdapter<>(builder, collection); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadCategoriesRenderer.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadCategoriesRenderer.java new file mode 100644 index 000000000..d0862b964 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadCategoriesRenderer.java @@ -0,0 +1,52 @@ +package fr.free.nrw.commons.upload; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; + +import com.pedrogomez.renderers.Renderer; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.category.CategoryClickedListener; +import fr.free.nrw.commons.category.CategoryItem; + +public class UploadCategoriesRenderer extends Renderer { + @BindView(R.id.tvName) CheckBox checkedView; + private final CategoryClickedListener listener; + + UploadCategoriesRenderer(CategoryClickedListener listener) { + this.listener = listener; + } + + @Override + protected View inflate(LayoutInflater layoutInflater, ViewGroup viewGroup) { + return layoutInflater.inflate(R.layout.layout_upload_categories_item, viewGroup, false); + } + + @Override + protected void setUpView(View view) { + ButterKnife.bind(this, view); + } + + @Override + protected void hookListeners(View view) { + view.setOnClickListener(v -> { + CategoryItem item = getContent(); + item.setSelected(!item.isSelected()); + checkedView.setChecked(item.isSelected()); + if (listener != null) { + listener.categoryClicked(item); + } + }); + } + + @Override + public void render() { + CategoryItem item = getContent(); + checkedView.setChecked(item.isSelected()); + checkedView.setText(item.getName()); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java index ce69110cc..fd0563ab3 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java @@ -23,7 +23,6 @@ import java.io.InputStream; import java.util.Date; import java.util.concurrent.Executors; -import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.HandlerService; import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.SessionManager; @@ -87,49 +86,11 @@ public class UploadController { /** * Starts a new upload task. - * @param title the title of the contribution - * @param mediaUri the media URI of the contribution - * @param description the description of the contribution - * @param mimeType the MIME type of the contribution - * @param source the source of the contribution - * @param decimalCoords the coordinates in decimal. (e.g. "37.51136|-77.602615") - * @param wikiDataEntityId - * @param onComplete the progress tracker + * + * @param contribution the contribution object */ - public void startUpload(String title, Uri contentProviderUri, Uri mediaUri, String description, String mimeType, String source, String decimalCoords, String wikiDataEntityId, ContributionUploadProgress onComplete) { - Contribution contribution; - - - //TODO: Modify this to include coords - contribution = new Contribution(mediaUri, null, title, description, -1, - null, null, sessionManager.getCurrentAccount().name, - CommonsApplication.DEFAULT_EDIT_SUMMARY, decimalCoords); - - - contribution.setTag("mimeType", mimeType); - contribution.setSource(source); - - Timber.d("Wikidata entity ID received from Share activity is %s", wikiDataEntityId); - //TODO: Modify this to include coords - Account currentAccount = sessionManager.getCurrentAccount(); - if (currentAccount == null) { - Timber.d("Current account is null"); - ViewUtil.showLongToast(context, context.getString(R.string.user_not_logged_in)); - sessionManager.forceLogin(context); - return; - } - contribution = new Contribution(mediaUri, null, title, description, -1, - null, null, currentAccount.name, - CommonsApplication.DEFAULT_EDIT_SUMMARY, decimalCoords); - - - contribution.setTag("mimeType", mimeType); - contribution.setSource(source); - contribution.setWikiDataEntityId(wikiDataEntityId); - contribution.setContentProviderUri(contentProviderUri); - - //Calls the next overloaded method - startUpload(contribution, onComplete); + public void startUpload(Contribution contribution) { + startUpload(contribution, c -> {}); } /** @@ -142,7 +103,14 @@ public class UploadController { public void startUpload(final Contribution contribution, final ContributionUploadProgress onComplete) { //Set creator, desc, and license if (TextUtils.isEmpty(contribution.getCreator())) { - contribution.setCreator(sessionManager.getCurrentAccount().name); + Account currentAccount = sessionManager.getCurrentAccount(); + if (currentAccount == null) { + Timber.d("Current account is null"); + ViewUtil.showLongToast(context, context.getString(R.string.user_not_logged_in)); + sessionManager.forceLogin(context); + return; + } + contribution.setCreator(currentAccount.name); } if (contribution.getDescription() == null) { @@ -163,8 +131,6 @@ public class UploadController { long length; ContentResolver contentResolver = context.getContentResolver(); try { - - //TODO: understand do we really need this code if (contribution.getDataLength() <= 0) { Timber.d("UploadController/doInBackground, contribution.getLocalUri():" + contribution.getLocalUri()); AssetFileDescriptor assetFileDescriptor = contentResolver @@ -218,7 +184,7 @@ public class UploadController { contribution.setDateCreated(new Date()); } } - return contribution; + return contribution; } @Override diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java new file mode 100644 index 000000000..46193e958 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java @@ -0,0 +1,400 @@ +package fr.free.nrw.commons.upload; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.graphics.BitmapRegionDecoder; +import android.net.Uri; +import android.support.annotation.Nullable; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; + +import fr.free.nrw.commons.CommonsApplication; +import fr.free.nrw.commons.auth.SessionManager; +import fr.free.nrw.commons.contributions.Contribution; +import fr.free.nrw.commons.mwapi.MediaWikiApi; +import fr.free.nrw.commons.settings.Prefs; +import fr.free.nrw.commons.utils.ImageUtils; +import io.reactivex.Observable; +import io.reactivex.Single; +import io.reactivex.disposables.Disposable; +import io.reactivex.functions.Consumer; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.subjects.BehaviorSubject; +import timber.log.Timber; + +public class UploadModel { + + private MediaWikiApi mwApi; + private static UploadItem DUMMY = new UploadItem(Uri.EMPTY, "", "", GPSExtractor.DUMMY, "", null,-1l) { + }; + private final SharedPreferences prefs; + private final List licenses; + private String license; + private final Map licensesByName; + private List items = new ArrayList<>(); + private boolean topCardState = true; + private boolean bottomCardState = true; + private boolean rightCardState = true; + private int currentStepIndex = 0; + private Context context; + private ContentResolver contentResolver; + private boolean useExtStorage; + private Disposable badImageSubscription; + + @Inject + SessionManager sessionManager; + private Uri currentMediaUri; + + @Inject + UploadModel(@Named("licenses") List licenses, + @Named("default_preferences") SharedPreferences prefs, + @Named("licenses_by_name") Map licensesByName, + Context context, + MediaWikiApi mwApi) { + this.licenses = licenses; + this.prefs = prefs; + this.license = Prefs.Licenses.CC_BY_SA_3; + this.licensesByName = licensesByName; + this.context = context; + this.mwApi = mwApi; + this.contentResolver = context.getContentResolver(); + useExtStorage = this.prefs.getBoolean("useExternalStorage", false); + } + + @SuppressLint("CheckResult") + void receive(List mediaUri, String mimeType, String source, SimilarImageInterface similarImageInterface) { + initDefaultValues(); + Observable itemObservable = Observable.fromIterable(mediaUri) + .map(media -> { + currentMediaUri=media; + return cacheFileUpload(media); + }) + .map(filePath -> { + long fileCreatedDate = getFileCreatedDate(currentMediaUri); + Uri uri = Uri.fromFile(new File(filePath)); + FileProcessor fp = new FileProcessor(filePath, context.getContentResolver(), context); + UploadItem item = new UploadItem(uri, mimeType, source, fp.processFileCoordinates(similarImageInterface), + FileUtils.getFileExt(filePath), null,fileCreatedDate); + Single.zip( + Single.fromCallable(() -> + new FileInputStream(filePath)) + .map(FileUtils::getSHA1) + .map(mwApi::existingFile) + .map(b -> b ? ImageUtils.IMAGE_DUPLICATE : ImageUtils.IMAGE_OK), + Single.fromCallable(() -> + new FileInputStream(filePath)) + .map(file -> BitmapRegionDecoder.newInstance(file, false)) + .map(ImageUtils::checkIfImageIsTooDark), //Returns IMAGE_DARK or IMAGE_OK + (dupe, dark) -> dupe | dark) + .observeOn(Schedulers.io()) + .subscribe(item.imageQuality::onNext, Timber::e); + return item; + }); + items = itemObservable.toList().blockingGet(); + items.get(0).selected = true; + items.get(0).first = true; + } + + @SuppressLint("CheckResult") + void receiveDirect(Uri media, String mimeType, String source, String wikidataEntityIdPref, String title, String desc, SimilarImageInterface similarImageInterface) { + initDefaultValues(); + long fileCreatedDate = getFileCreatedDate(media); + String filePath = this.cacheFileUpload(media); + Uri uri = Uri.fromFile(new File(filePath)); + FileProcessor fp = new FileProcessor(filePath, context.getContentResolver(), context); + UploadItem item = new UploadItem(uri, mimeType, source, fp.processFileCoordinates(similarImageInterface), + FileUtils.getFileExt(filePath), wikidataEntityIdPref,fileCreatedDate); + item.title.setTitleText(title); + item.descriptions.get(0).setDescriptionText(desc); + //TODO figure out if default descriptions in other languages exist + item.descriptions.get(0).setLanguageCode("en"); + Single.zip( + Single.fromCallable(() -> + new FileInputStream(filePath)) + .map(FileUtils::getSHA1) + .map(mwApi::existingFile) + .map(b -> b ? ImageUtils.IMAGE_DUPLICATE : ImageUtils.IMAGE_OK), + Single.fromCallable(() -> + new FileInputStream(filePath)) + .map(file -> BitmapRegionDecoder.newInstance(file, false)) + .map(ImageUtils::checkIfImageIsTooDark), //Returns IMAGE_DARK or IMAGE_OK + (dupe, dark) -> dupe | dark).subscribe(item.imageQuality::onNext); + items.add(item); + items.get(0).selected = true; + items.get(0).first = true; + } + + private void initDefaultValues() { + currentStepIndex = 0; + topCardState = true; + bottomCardState = true; + rightCardState = true; + items = new ArrayList<>(); + } + + /** + * Get file creation date from uri from all possible content providers + * @param media + * @return + */ + private long getFileCreatedDate(Uri media) { + try { + Cursor cursor = contentResolver.query(media, null, null, null, null); + if (cursor == null) { + return -1;//Could not fetch last_modified + } + //Content provider contracts for opening gallery from the app and that by sharing from gallery from outside are different and we need to handle both the cases + int lastModifiedColumnIndex = cursor.getColumnIndex("last_modified");//If gallery is opened from in app + if(lastModifiedColumnIndex==-1){ + lastModifiedColumnIndex=cursor.getColumnIndex("datetaken"); + } + //If both the content providers do not give the data, lets leave it to Jesus + if(lastModifiedColumnIndex==-1){ + return -1l; + } + cursor.moveToFirst(); + return cursor.getLong(lastModifiedColumnIndex); + } catch (Exception e) { + return -1;////Could not fetch last_modified + } + } + + boolean isPreviousAvailable() { + return currentStepIndex > 0; + } + + boolean isNextAvailable() { + return currentStepIndex < (items.size() + 1); + } + + boolean isSubmitAvailable() { + int count = items.size(); + boolean hasError = license == null; + for (int i = 0; i < count; i++) { + UploadItem item = items.get(i); + hasError |= item.error; + } + return !hasError; + } + + int getCurrentStep() { + return currentStepIndex + 1; + } + + int getStepCount() { + return items.size() + 2; + } + + public int getCount() { + return items.size(); + } + + public List getUploads() { + return items; + } + + boolean isTopCardState() { + return topCardState; + } + + void setTopCardState(boolean topCardState) { + this.topCardState = topCardState; + } + + boolean isBottomCardState() { + return bottomCardState; + } + + void setRightCardState(boolean rightCardState) { + this.rightCardState = rightCardState; + } + + boolean isRightCardState() { + return rightCardState; + } + + void setBottomCardState(boolean bottomCardState) { + this.bottomCardState = bottomCardState; + } + + public void next() { + if (badImageSubscription != null) + badImageSubscription.dispose(); + markCurrentUploadVisited(); + if (currentStepIndex < items.size() + 1) { + currentStepIndex++; + } + updateItemState(); + } + + public void setCurrentTitleAndDescriptions(Title title, List descriptions) { + setCurrentUploadTitle(title); + setCurrentUploadDescriptions(descriptions); + } + + private void setCurrentUploadTitle(Title title) { + if (currentStepIndex < items.size() && currentStepIndex >= 0) { + items.get(currentStepIndex).title = title; + } + } + + private void setCurrentUploadDescriptions(List descriptions) { + if (currentStepIndex < items.size() && currentStepIndex >= 0) { + items.get(currentStepIndex).descriptions = descriptions; + } + } + + public void previous() { + if (badImageSubscription != null) + badImageSubscription.dispose(); + markCurrentUploadVisited(); + if (currentStepIndex > 0) { + currentStepIndex--; + } + updateItemState(); + } + + void jumpTo(UploadItem item) { + currentStepIndex = items.indexOf(item); + item.visited = true; + updateItemState(); + } + + UploadItem getCurrentItem() { + return isShowingItem() ? items.get(currentStepIndex) : DUMMY; + } + + boolean isShowingItem() { + return currentStepIndex < items.size(); + } + + private void updateItemState() { + int count = items.size(); + for (int i = 0; i < count; i++) { + UploadItem item = items.get(i); + item.selected = (currentStepIndex >= count || i == currentStepIndex); + item.error = item.title == null || item.title.isEmpty(); + } + } + + private void markCurrentUploadVisited() { + if (currentStepIndex < items.size() && currentStepIndex >= 0) { + items.get(currentStepIndex).visited = true; + } + } + + public List getLicenses() { + return licenses; + } + + String getSelectedLicense() { + return license; + } + + void setSelectedLicense(String licenseName) { + this.license = licensesByName.get(licenseName); + } + + Observable buildContributions(List categoryStringList) { + return Observable.fromIterable(items).map(item -> + { + Contribution contribution = new Contribution(item.mediaUri, null, item.title + "." + item.fileExt, + Description.formatList(item.descriptions), -1, + null, null, sessionManager.getUserName(), + CommonsApplication.DEFAULT_EDIT_SUMMARY, item.gpsCoords.getCoords()); + contribution.setWikiDataEntityId(item.wikidataEntityId); + contribution.setCategories(categoryStringList); + contribution.setTag("mimeType", item.mimeType); + contribution.setSource(item.source); + contribution.setContentProviderUri(item.mediaUri); + if (item.createdTimestamp != -1l) { + contribution.setDateCreated(new Date(item.createdTimestamp)); + //Set the date only if you have it, else the upload service is gonna try it the other way + } + return contribution; + }); + } + + /** + * Copy files into local storage and return file path + * + * @param media Uri of the file + * @return path of the enw file + */ + private String cacheFileUpload(Uri media) { + try { + String copyPath; + if (useExtStorage) + copyPath = FileUtils.createExternalCopyPathAndCopy(media, contentResolver); + else + copyPath = FileUtils.createCopyPathAndCopy(media, context); + Timber.i("File path is " + copyPath); + return copyPath; + } catch (IOException e) { + Timber.w(e, "Error in copying URI " + media.getPath()); + return null; + } + } + + void keepPicture() { + items.get(currentStepIndex).imageQuality.onNext(ImageUtils.IMAGE_KEEP); + } + + void deletePicture() { + badImageSubscription.dispose(); + items.remove(currentStepIndex).imageQuality.onComplete(); + updateItemState(); + } + + void subscribeBadPicture(Consumer consumer) { + badImageSubscription = getCurrentItem().imageQuality.subscribe(consumer, Timber::e); + } + + + @SuppressWarnings("WeakerAccess") + static class UploadItem { + public final Uri mediaUri; + public final String mimeType; + public final String source; + public final GPSExtractor gpsCoords; + + public boolean selected = false; + public boolean first = false; + public String fileExt; + public BehaviorSubject imageQuality; + Title title; + List descriptions; + public String wikidataEntityId; + public boolean visited; + public boolean error; + public long createdTimestamp; + + @SuppressLint("CheckResult") + UploadItem(Uri mediaUri, String mimeType, String source, GPSExtractor gpsCoords, String fileExt, @Nullable String wikidataEntityId, long createdTimestamp) { + title = new Title(); + descriptions = new ArrayList<>(); + descriptions.add(new Description()); + this.wikidataEntityId = wikidataEntityId; + this.mediaUri = mediaUri; + this.mimeType = mimeType; + this.source = source; + this.gpsCoords = gpsCoords; + this.fileExt = fileExt; + imageQuality = BehaviorSubject.createDefault(ImageUtils.IMAGE_WAIT); + this.createdTimestamp=createdTimestamp; + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java new file mode 100644 index 000000000..74e3192bd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java @@ -0,0 +1,430 @@ +package fr.free.nrw.commons.upload; + +import android.annotation.SuppressLint; +import android.net.Uri; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.category.CategoriesModel; +import fr.free.nrw.commons.contributions.Contribution; +import fr.free.nrw.commons.mwapi.MediaWikiApi; +import fr.free.nrw.commons.utils.ImageUtils; +import io.reactivex.Completable; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +import static fr.free.nrw.commons.upload.UploadModel.UploadItem; +import static fr.free.nrw.commons.utils.ImageUtils.EMPTY_TITLE; +import static fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS; +import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_KEEP; +import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK; + +/** + * The MVP pattern presenter of Upload GUI + */ +@Singleton +public class UploadPresenter { + + private final UploadModel uploadModel; + private final UploadController uploadController; + private final MediaWikiApi mediaWikiApi; + + private static final UploadView DUMMY = (UploadView) Proxy.newProxyInstance(UploadView.class.getClassLoader(), + new Class[]{UploadView.class}, (proxy, method, methodArgs) -> null); + private UploadView view = DUMMY; + + private static final SimilarImageInterface SIMILAR_IMAGE = (SimilarImageInterface) Proxy.newProxyInstance(SimilarImageInterface.class.getClassLoader(), + new Class[]{SimilarImageInterface.class}, (proxy, method, methodArgs) -> null); + private SimilarImageInterface similarImageInterface = SIMILAR_IMAGE; + + @UploadView.UploadPage + private int currentPage = UploadView.PLEASE_WAIT; + + + @Inject + UploadPresenter(UploadModel uploadModel, + UploadController uploadController, + MediaWikiApi mediaWikiApi) { + this.uploadModel = uploadModel; + this.uploadController = uploadController; + this.mediaWikiApi = mediaWikiApi; + } + + void receive(Uri mediaUri, String mimeType, String source) { + receive(Collections.singletonList(mediaUri), mimeType, source); + } + + /** + * Passes the items received to {@link #uploadModel} and displays the items. + * + * @param media The Uri's of the media being uploaded. + * @param mimeType the mimeType of the files. + * @param source File source from {@link Contribution.FileSource} + */ + @SuppressLint("CheckResult") + void receive(List media, String mimeType, @Contribution.FileSource String source) { + Completable.fromRunnable(() -> uploadModel.receive(media, mimeType, source, similarImageInterface)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + updateCards(); + updateLicenses(); + updateContent(); + if (uploadModel.isShowingItem()) + uploadModel.subscribeBadPicture(this::handleBadPicture); + }, Timber::e); + } + + /** + * Passes the direct upload item received to {@link #uploadModel} and displays the items. + * + * @param media The Uri's of the media being uploaded. + * @param mimeType the mimeType of the files. + * @param source File source from {@link Contribution.FileSource} + */ + @SuppressLint("CheckResult") + void receiveDirect(Uri media, String mimeType, @Contribution.FileSource String source, String wikidataEntityIdPref, String title, String desc) { + Completable.fromRunnable(() -> uploadModel.receiveDirect(media, mimeType, source, wikidataEntityIdPref, title, desc, similarImageInterface)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + updateCards(); + updateLicenses(); + updateContent(); + if (uploadModel.isShowingItem()) + uploadModel.subscribeBadPicture(this::handleBadPicture); + }, Timber::e); + } + /** + * Sets the license to parameter and updates {@link UploadActivity} + * + * @param licenseName license name + */ + void selectLicense(String licenseName) { + uploadModel.setSelectedLicense(licenseName); + view.updateLicenseSummary(uploadModel.getSelectedLicense()); + } + + //region Wizard step management + + /** + * Called by the next button in {@link UploadActivity} + */ + @SuppressLint("CheckResult") + void handleNext(Title title, + List descriptions) { + validateCurrentItemTitle() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(errorCode -> handleImage(errorCode, title, descriptions)); + } + + /** + * Called by the next button in {@link UploadActivity} + */ + @SuppressLint("CheckResult") + void handleCategoryNext(CategoriesModel categoriesModel, + boolean noCategoryWarningShown) { + if (categoriesModel.selectedCategoriesCount() < 1 && !noCategoryWarningShown) { + view.showNoCategorySelectedWarning(); + } else { + nextUploadedItem(); + } + } + + private void handleImage(Integer errorCode, Title title, List descriptions) { + switch (errorCode) { + case EMPTY_TITLE: + view.showErrorMessage(R.string.add_title_toast); + break; + case FILE_NAME_EXISTS: + if(getCurrentItem().imageQuality.getValue().equals(IMAGE_KEEP)) { + setTitleAndDescription(title, descriptions); + nextUploadedItem(); + } else { + view.showDuplicatePicturePopup(); + } + break; + case IMAGE_OK: + default: + setTitleAndDescription(title, descriptions); + nextUploadedItem(); + } + } + + private void nextUploadedItem() { + uploadModel.next(); + updateContent(); + if (uploadModel.isShowingItem()) { + uploadModel.subscribeBadPicture(this::handleBadPicture); + } + view.dismissKeyboard(); + } + + private void setTitleAndDescription(Title title, List descriptions) { + uploadModel.setCurrentTitleAndDescriptions(title, descriptions); + } + + private Title getCurrentImageTitle() { + return getCurrentItem().title; + } + + String getCurrentImageFileName() { + UploadItem currentItem = getCurrentItem(); + return currentItem.title + "." + uploadModel.getCurrentItem().fileExt; + } + + @SuppressLint("CheckResult") + private Observable validateCurrentItemTitle() { + Title title = getCurrentImageTitle(); + if (title.isEmpty()) { + view.showErrorMessage(R.string.add_title_toast); + return Observable.just(EMPTY_TITLE); + } + + return Observable.fromCallable(() -> mediaWikiApi.fileExistsWithName(getCurrentImageFileName())) + .subscribeOn(Schedulers.io()) + .map(doesFileExist -> { + if (doesFileExist) { + return FILE_NAME_EXISTS; + } + return IMAGE_OK; + }); + } + + /** + * Called by the previous button in {@link UploadActivity} + */ + void handlePrevious() { + uploadModel.previous(); + updateContent(); + if (uploadModel.isShowingItem()) { + uploadModel.subscribeBadPicture(this::handleBadPicture); + } + view.dismissKeyboard(); + } + + /** + * Called when one of the pictures on the top card is clicked on in {@link UploadActivity} + */ + void thumbnailClicked(UploadItem item) { + uploadModel.jumpTo(item); + updateContent(); + } + + /** + * Called by the submit button in {@link UploadActivity} + */ + @SuppressLint("CheckResult") + void handleSubmit(CategoriesModel categoriesModel) { + if (view.checkIfLoggedIn()) + uploadModel.buildContributions(categoriesModel.getCategoryStringList()) + .observeOn(Schedulers.io()) + .subscribe(uploadController::startUpload); + } + + /** + * Called by the map button on the right card in {@link UploadActivity} + */ + void openCoordinateMap() { + GPSExtractor gpsObj = uploadModel.getCurrentItem().gpsCoords; + if (gpsObj != null && gpsObj.imageCoordsExists) { + view.launchMapActivity(gpsObj.getDecLatitude() + "," + gpsObj.getDecLongitude()); + } + } + + + /** + * Called by the image processors when a result is obtained. + * + * @param result the result returned by the image procesors. + */ + private void handleBadPicture(@ImageUtils.Result int result) { + view.showBadPicturePopup(result); + } + + void keepPicture() { + uploadModel.keepPicture(); + } + + void deletePicture() { + if (uploadModel.getCount() == 1) + view.finish(); + else { + uploadModel.deletePicture(); + updateCards(); + updateContent(); + if (uploadModel.isShowingItem()) + uploadModel.subscribeBadPicture(this::handleBadPicture); + view.dismissKeyboard(); + } + } + //endregion + + //region Top Bottom and Right card state management + + + /** + * Toggles the top card's state between open and closed. + */ + void toggleTopCardState() { + uploadModel.setTopCardState(!uploadModel.isTopCardState()); + view.setTopCardState(uploadModel.isTopCardState()); + } + + /** + * Toggles the bottom card's state between open and closed. + */ + void toggleBottomCardState() { + uploadModel.setBottomCardState(!uploadModel.isBottomCardState()); + view.setBottomCardState(uploadModel.isBottomCardState()); + } + + /** + * Toggles the right card's state between open and closed. + */ + void toggleRightCardState() { + uploadModel.setRightCardState(!uploadModel.isRightCardState()); + view.setRightCardState(uploadModel.isRightCardState()); + } + + /** + * Sets all the cards' states to closed. + */ + void closeAllCards() { + if (uploadModel.isTopCardState()) { + uploadModel.setTopCardState(false); + view.setTopCardState(false); + } + if (uploadModel.isRightCardState()) { + uploadModel.setRightCardState(false); + view.setRightCardState(false); + } + if (uploadModel.isBottomCardState()) { + uploadModel.setBottomCardState(false); + view.setBottomCardState(false); + } + } + //endregion + + //region View / Lifecycle management + public void init() { + uploadController.prepareService(); + } + + void cleanup() { + uploadController.cleanup(); + } + + void removeView() { + this.view = DUMMY; + } + + void addView(UploadView view) { + this.view = view; + + updateCards(); + updateLicenses(); + updateContent(); + } + + + /** + * Updates the cards for when there is a change to the amount of items being uploaded. + */ + private void updateCards() { + Timber.i("uploadModel.getCount():" + uploadModel.getCount()); + view.updateThumbnails(uploadModel.getUploads()); + view.setTopCardVisibility(uploadModel.getCount() > 1); + view.setBottomCardVisibility(uploadModel.getCount() > 0); + view.setTopCardState(uploadModel.isTopCardState()); + view.setBottomCardState(uploadModel.isBottomCardState()); + } + + /** + * Sets the list of licences and the default license. + */ + private void updateLicenses() { + String selectedLicense = uploadModel.getSelectedLicense(); + view.updateLicenses(uploadModel.getLicenses(), selectedLicense); + view.updateLicenseSummary(selectedLicense); + } + + /** + * Updates the cards and the background when a new currentPage is selected. + */ + private void updateContent() { + Timber.i("Updating content for currentPage" + uploadModel.getCurrentStep()); + view.setNextEnabled(uploadModel.isNextAvailable()); + view.setPreviousEnabled(uploadModel.isPreviousAvailable()); + view.setSubmitEnabled(uploadModel.isSubmitAvailable()); + + view.setBackground(uploadModel.getCurrentItem().mediaUri); + + view.updateBottomCardContent(uploadModel.getCurrentStep(), + uploadModel.getStepCount(), + uploadModel.getCurrentItem(), + uploadModel.isShowingItem()); + + view.updateTopCardContent(); + + GPSExtractor gpsObj = uploadModel.getCurrentItem().gpsCoords; + view.updateRightCardContent(gpsObj != null && gpsObj.imageCoordsExists); + + showCorrectCards(uploadModel.getCurrentStep(), uploadModel.getCount()); + } + + /** + * Updates the layout to show the correct bottom card. + * + * @param currentStep the current step + * @param uploadCount how many items are being uploaded + */ + private void showCorrectCards(int currentStep, int uploadCount) { + if (uploadCount == 0) { + currentPage = UploadView.PLEASE_WAIT; + } else if (currentStep <= uploadCount) { + currentPage = UploadView.TITLE_CARD; + view.setTopCardVisibility(uploadModel.getCount() > 1); + } else if (currentStep == uploadCount + 1) { + currentPage = UploadView.CATEGORIES; + view.setTopCardVisibility(false); + view.setRightCardVisibility(false); + view.initDefaultCategories(); + } else { + currentPage = UploadView.LICENSE; + view.setTopCardVisibility(false); + view.setRightCardVisibility(false); + } + view.setBottomCardVisibility(currentPage); + } + + //endregion + + /** + * @return the item currently being displayed + */ + private UploadItem getCurrentItem() { + return uploadModel.getCurrentItem(); + } + + List getImageTitleList() { + List titleList = new ArrayList<>(); + for (UploadItem item : uploadModel.getUploads()) { + if (item.title.isSet()) { + titleList.add(item.title.toString()); + } + } + return titleList; + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java index 823a4b91f..e7920f317 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadService.java @@ -62,7 +62,9 @@ public class UploadService extends HandlerService { private NotificationCompat.Builder curProgressNotification; private int toUpload; - // The file names of unfinished uploads, used to prevent overwriting + /** + * The file names of unfinished uploads, used to prevent overwriting + */ private Set unfinishedUploads = new HashSet<>(); // DO NOT HAVE NOTIFICATION ID OF 0 FOR ANYTHING @@ -314,6 +316,7 @@ public class UploadService extends HandlerService { } @SuppressLint("StringFormatInvalid") + @SuppressWarnings("deprecation") private void showFailedNotification(Contribution contribution) { Notification failureNotification = new NotificationCompat.Builder(this).setAutoCancel(true) .setSmallIcon(R.drawable.ic_launcher) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailRenderer.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailRenderer.java new file mode 100644 index 000000000..64b873da0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailRenderer.java @@ -0,0 +1,49 @@ +package fr.free.nrw.commons.upload; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.facebook.drawee.view.SimpleDraweeView; +import com.pedrogomez.renderers.Renderer; + +import fr.free.nrw.commons.R; + +class UploadThumbnailRenderer extends Renderer { + private ThumbnailClickedListener listener; + private SimpleDraweeView background; + private View space; + private ImageView error; + + public UploadThumbnailRenderer(ThumbnailClickedListener listener) { + this.listener = listener; + } + + @Override + protected View inflate(LayoutInflater inflater, ViewGroup parent) { + return inflater.inflate(R.layout.item_upload_thumbnail, parent, false); + } + + @Override + protected void setUpView(View rootView) { + error = rootView.findViewById(R.id.error); + space = rootView.findViewById(R.id.left_space); + background = rootView.findViewById(R.id.thumbnail); + } + + @Override + protected void hookListeners(View rootView) { + background.setOnClickListener(v -> listener.thumbnailClicked(getContent())); + } + + @Override + public void render() { + UploadModel.UploadItem content = getContent(); + background.setImageURI(content.mediaUri); + background.setAlpha(content.selected ? 1.0f : 0.5f); + space.setVisibility(content.first ? View.VISIBLE : View.GONE); + error.setVisibility(content.visited && content.error ? View.VISIBLE : View.GONE); + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailsAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailsAdapterFactory.java new file mode 100644 index 000000000..bc0a79c80 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadThumbnailsAdapterFactory.java @@ -0,0 +1,26 @@ +package fr.free.nrw.commons.upload; + +import com.pedrogomez.renderers.ListAdapteeCollection; +import com.pedrogomez.renderers.RVRendererAdapter; +import com.pedrogomez.renderers.RendererBuilder; + +import java.util.Collections; +import java.util.List; + +import javax.inject.Inject; + +public class UploadThumbnailsAdapterFactory { + private ThumbnailClickedListener listener; + + UploadThumbnailsAdapterFactory(ThumbnailClickedListener listener) { + this.listener = listener; + } + + public RVRendererAdapter create(List placeList) { + RendererBuilder builder = new RendererBuilder() + .bind(UploadModel.UploadItem.class, new UploadThumbnailRenderer(listener)); + ListAdapteeCollection collection = new ListAdapteeCollection<>( + placeList != null ? placeList : Collections.emptyList()); + return new RVRendererAdapter<>(builder, collection); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadView.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadView.java new file mode 100644 index 000000000..410914446 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadView.java @@ -0,0 +1,82 @@ +package fr.free.nrw.commons.upload; + +import android.net.Uri; +import android.support.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.util.List; + +import fr.free.nrw.commons.utils.ImageUtils; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +public interface UploadView { + // Dummy implementation of the view interface to allow us to have a 'null object pattern' + // in the presenter and avoid constant NULL checking. +// UploadView DUMMY = (UploadView) Proxy.newProxyInstance(UploadView.class.getClassLoader(), +// new Class[]{UploadView.class}, (proxy, method, methodArgs) -> null); + + List getDescriptions(); + + @Retention(SOURCE) + @IntDef({PLEASE_WAIT, TITLE_CARD, CATEGORIES, LICENSE}) + @interface UploadPage {} + + int PLEASE_WAIT = 0; + + int TITLE_CARD = 1; + int CATEGORIES = 2; + int LICENSE = 3; + + boolean checkIfLoggedIn(); + + void updateThumbnails(List uploads); + + void setNextEnabled(boolean available); + + void setSubmitEnabled(boolean available); + + void setPreviousEnabled(boolean available); + + void setTopCardState(boolean state); + + void setRightCardVisibility(boolean visible); + + void setBottomCardState(boolean state); + + void setRightCardState(boolean bottomCardState); + + void setBackground(Uri mediaUri); + + void setTopCardVisibility(boolean visible); + + void setBottomCardVisibility(boolean visible); + + void setBottomCardVisibility(@UploadPage int page); + + void updateRightCardContent(boolean gpsPresent); + + void updateBottomCardContent(int currentStep, int stepCount, UploadModel.UploadItem uploadItem, boolean isShowingItem); + + void updateLicenses(List licenses, String selectedLicense); + + void updateLicenseSummary(String selectedLicense); + + void updateTopCardContent(); + + void dismissKeyboard(); + + void showBadPicturePopup(@ImageUtils.Result int errorMessage); + + void showDuplicatePicturePopup(); + + void finish(); + + void launchMapActivity(String decCoords); + + void showErrorMessage(int resourceId); + + void initDefaultCategories(); + + void showNoCategorySelectedWarning(); +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UrlLicense.java b/app/src/main/java/fr/free/nrw/commons/upload/UrlLicense.java index 53aaaa106..a28fde579 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UrlLicense.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UrlLicense.java @@ -7,61 +7,61 @@ import java.util.HashMap; * info in the user language */ public class UrlLicense { - HashMap urlLicense = new HashMap(); - public void initialize(){ - urlLicense.put("en","https://commons.wikimedia.org/wiki/Commons:Licensing"); - urlLicense.put("ar","https://commons.wikimedia.org/wiki/Commons:Licensing/ar"); - urlLicense.put("ast","https://commons.wikimedia.org/wiki/Commons:Licensing/ast"); - urlLicense.put("az","https://commons.wikimedia.org/wiki/Commons:Licensing/az"); - urlLicense.put("be","https://commons.wikimedia.org/wiki/Commons:Licensing/be"); - urlLicense.put("bg","https://commons.wikimedia.org/wiki/Commons:Licensing/bg"); - urlLicense.put("bn","https://commons.wikimedia.org/wiki/Commons:Licensing/bn"); - urlLicense.put("ca","https://commons.wikimedia.org/wiki/Commons:Licensing/ca"); - urlLicense.put("cs","https://commons.wikimedia.org/wiki/Commons:Licensing/cs"); - urlLicense.put("da","https://commons.wikimedia.org/wiki/Commons:Licensing/da"); - urlLicense.put("de","https://commons.wikimedia.org/wiki/Commons:Licensing/de"); - urlLicense.put("el","https://commons.wikimedia.org/wiki/Commons:Licensing/el"); - urlLicense.put("eo","https://commons.wikimedia.org/wiki/Commons:Licensing/eo"); - urlLicense.put("es","https://commons.wikimedia.org/wiki/Commons:Licensing/es"); - urlLicense.put("eu","https://commons.wikimedia.org/wiki/Commons:Licensing/eu"); - urlLicense.put("fa","https://commons.wikimedia.org/wiki/Commons:Licensing/fa"); - urlLicense.put("fi","https://commons.wikimedia.org/wiki/Commons:Licensing/fi"); - urlLicense.put("fr","https://commons.wikimedia.org/wiki/Commons:Licensing/fr"); - urlLicense.put("gl","https://commons.wikimedia.org/wiki/Commons:Licensing/gl"); - urlLicense.put("gsw","https://commons.wikimedia.org/wiki/Commons:Licensing/gsw"); - urlLicense.put("he","https://commons.wikimedia.org/wiki/Commons:Licensing/he"); - urlLicense.put("hi","https://commons.wikimedia.org/wiki/Commons:Licensing/hi"); - urlLicense.put("hu","https://commons.wikimedia.org/wiki/Commons:Licensing/hu"); - urlLicense.put("id","https://commons.wikimedia.org/wiki/Commons:Licensing/id"); - urlLicense.put("is","https://commons.wikimedia.org/wiki/Commons:Licensing/is"); - urlLicense.put("it","https://commons.wikimedia.org/wiki/Commons:Licensing/it"); - urlLicense.put("ja","https://commons.wikimedia.org/wiki/Commons:Licensing/ja"); - urlLicense.put("ka","https://commons.wikimedia.org/wiki/Commons:Licensing/ka"); - urlLicense.put("km","https://commons.wikimedia.org/wiki/Commons:Licensing/km"); - urlLicense.put("ko","https://commons.wikimedia.org/wiki/Commons:Licensing/ko"); - urlLicense.put("ku","https://commons.wikimedia.org/wiki/Commons:Licensing/ku"); - urlLicense.put("mk","https://commons.wikimedia.org/wiki/Commons:Licensing/mk"); - urlLicense.put("mr","https://commons.wikimedia.org/wiki/Commons:Licensing/mr"); - urlLicense.put("ms","https://commons.wikimedia.org/wiki/Commons:Licensing/ms"); - urlLicense.put("my","https://commons.wikimedia.org/wiki/Commons:Licensing/my"); - urlLicense.put("nl","https://commons.wikimedia.org/wiki/Commons:Licensing/nl"); - urlLicense.put("oc","https://commons.wikimedia.org/wiki/Commons:Licensing/oc"); - urlLicense.put("pl","https://commons.wikimedia.org/wiki/Commons:Licensing/pl"); - urlLicense.put("pt","https://commons.wikimedia.org/wiki/Commons:Licensing/pt"); - urlLicense.put("pt-br","https://commons.wikimedia.org/wiki/Commons:Licensing/pt-br"); - urlLicense.put("ro","https://commons.wikimedia.org/wiki/Commons:Licensing/ro"); - urlLicense.put("ru","https://commons.wikimedia.org/wiki/Commons:Licensing/ru"); - urlLicense.put("scn","https://commons.wikimedia.org/wiki/Commons:Licensing/scn"); - urlLicense.put("sk","https://commons.wikimedia.org/wiki/Commons:Licensing/sk"); - urlLicense.put("sl","https://commons.wikimedia.org/wiki/Commons:Licensing/sl"); - urlLicense.put("sv","https://commons.wikimedia.org/wiki/Commons:Licensing/sv"); - urlLicense.put("tr","https://commons.wikimedia.org/wiki/Commons:Licensing/tr"); - urlLicense.put("uk","https://commons.wikimedia.org/wiki/Commons:Licensing/uk"); - urlLicense.put("ur","https://commons.wikimedia.org/wiki/Commons:Licensing/ur"); - urlLicense.put("vi","https://commons.wikimedia.org/wiki/Commons:Licensing/vi"); - urlLicense.put("zh","https://commons.wikimedia.org/wiki/Commons:Licensing/zh"); + public static HashMap urlLicense = new HashMap<>(); + static { + urlLicense.put("en", "https://commons.wikimedia.org/wiki/Commons:Licensing"); + urlLicense.put("ar", "https://commons.wikimedia.org/wiki/Commons:Licensing/ar"); + urlLicense.put("ast", "https://commons.wikimedia.org/wiki/Commons:Licensing/ast"); + urlLicense.put("az", "https://commons.wikimedia.org/wiki/Commons:Licensing/az"); + urlLicense.put("be", "https://commons.wikimedia.org/wiki/Commons:Licensing/be"); + urlLicense.put("bg", "https://commons.wikimedia.org/wiki/Commons:Licensing/bg"); + urlLicense.put("bn", "https://commons.wikimedia.org/wiki/Commons:Licensing/bn"); + urlLicense.put("ca", "https://commons.wikimedia.org/wiki/Commons:Licensing/ca"); + urlLicense.put("cs", "https://commons.wikimedia.org/wiki/Commons:Licensing/cs"); + urlLicense.put("da", "https://commons.wikimedia.org/wiki/Commons:Licensing/da"); + urlLicense.put("de", "https://commons.wikimedia.org/wiki/Commons:Licensing/de"); + urlLicense.put("el", "https://commons.wikimedia.org/wiki/Commons:Licensing/el"); + urlLicense.put("eo", "https://commons.wikimedia.org/wiki/Commons:Licensing/eo"); + urlLicense.put("es", "https://commons.wikimedia.org/wiki/Commons:Licensing/es"); + urlLicense.put("eu", "https://commons.wikimedia.org/wiki/Commons:Licensing/eu"); + urlLicense.put("fa", "https://commons.wikimedia.org/wiki/Commons:Licensing/fa"); + urlLicense.put("fi", "https://commons.wikimedia.org/wiki/Commons:Licensing/fi"); + urlLicense.put("fr", "https://commons.wikimedia.org/wiki/Commons:Licensing/fr"); + urlLicense.put("gl", "https://commons.wikimedia.org/wiki/Commons:Licensing/gl"); + urlLicense.put("gsw", "https://commons.wikimedia.org/wiki/Commons:Licensing/gsw"); + urlLicense.put("he", "https://commons.wikimedia.org/wiki/Commons:Licensing/he"); + urlLicense.put("hi", "https://commons.wikimedia.org/wiki/Commons:Licensing/hi"); + urlLicense.put("hu", "https://commons.wikimedia.org/wiki/Commons:Licensing/hu"); + urlLicense.put("id", "https://commons.wikimedia.org/wiki/Commons:Licensing/id"); + urlLicense.put("is", "https://commons.wikimedia.org/wiki/Commons:Licensing/is"); + urlLicense.put("it", "https://commons.wikimedia.org/wiki/Commons:Licensing/it"); + urlLicense.put("ja", "https://commons.wikimedia.org/wiki/Commons:Licensing/ja"); + urlLicense.put("ka", "https://commons.wikimedia.org/wiki/Commons:Licensing/ka"); + urlLicense.put("km", "https://commons.wikimedia.org/wiki/Commons:Licensing/km"); + urlLicense.put("ko", "https://commons.wikimedia.org/wiki/Commons:Licensing/ko"); + urlLicense.put("ku", "https://commons.wikimedia.org/wiki/Commons:Licensing/ku"); + urlLicense.put("mk", "https://commons.wikimedia.org/wiki/Commons:Licensing/mk"); + urlLicense.put("mr", "https://commons.wikimedia.org/wiki/Commons:Licensing/mr"); + urlLicense.put("ms", "https://commons.wikimedia.org/wiki/Commons:Licensing/ms"); + urlLicense.put("my", "https://commons.wikimedia.org/wiki/Commons:Licensing/my"); + urlLicense.put("nl", "https://commons.wikimedia.org/wiki/Commons:Licensing/nl"); + urlLicense.put("oc", "https://commons.wikimedia.org/wiki/Commons:Licensing/oc"); + urlLicense.put("pl", "https://commons.wikimedia.org/wiki/Commons:Licensing/pl"); + urlLicense.put("pt", "https://commons.wikimedia.org/wiki/Commons:Licensing/pt"); + urlLicense.put("pt-br", "https://commons.wikimedia.org/wiki/Commons:Licensing/pt-br"); + urlLicense.put("ro", "https://commons.wikimedia.org/wiki/Commons:Licensing/ro"); + urlLicense.put("ru", "https://commons.wikimedia.org/wiki/Commons:Licensing/ru"); + urlLicense.put("scn", "https://commons.wikimedia.org/wiki/Commons:Licensing/scn"); + urlLicense.put("sk", "https://commons.wikimedia.org/wiki/Commons:Licensing/sk"); + urlLicense.put("sl", "https://commons.wikimedia.org/wiki/Commons:Licensing/sl"); + urlLicense.put("sv", "https://commons.wikimedia.org/wiki/Commons:Licensing/sv"); + urlLicense.put("tr", "https://commons.wikimedia.org/wiki/Commons:Licensing/tr"); + urlLicense.put("uk", "https://commons.wikimedia.org/wiki/Commons:Licensing/uk"); + urlLicense.put("ur", "https://commons.wikimedia.org/wiki/Commons:Licensing/ur"); + urlLicense.put("vi", "https://commons.wikimedia.org/wiki/Commons:Licensing/vi"); + urlLicense.put("zh", "https://commons.wikimedia.org/wiki/Commons:Licensing/zh"); } - public String getLicenseUrl ( String language){ + public static String getLicenseUrl ( String language){ if (urlLicense.containsKey(language)) { return urlLicense.get(language); } else { diff --git a/app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.java b/app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.java new file mode 100644 index 000000000..92f9f1935 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/AbstractTextWatcher.java @@ -0,0 +1,30 @@ +package fr.free.nrw.commons.utils; + +import android.support.annotation.NonNull; +import android.text.Editable; +import android.text.TextWatcher; + +public class AbstractTextWatcher implements TextWatcher { + private final TextChange textChange; + + public AbstractTextWatcher(@NonNull TextChange textChange) { + this.textChange = textChange; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + textChange.onTextChanged(s.toString()); + } + + @Override + public void afterTextChanged(Editable s) { + } + + public interface TextChange { + void onTextChanged(String value); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/BiMap.java b/app/src/main/java/fr/free/nrw/commons/utils/BiMap.java new file mode 100644 index 000000000..227f5f024 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/BiMap.java @@ -0,0 +1,41 @@ +package fr.free.nrw.commons.utils; + +import java.util.HashMap; +import java.util.Set; + +/** + * HashMap that can be searched in both the forward and reverse directions. + */ +public class BiMap { + + private HashMap map = new HashMap(); + private HashMap inversedMap = new HashMap(); + + public void put(K k, V v) { + map.put(k, v); + inversedMap.put(v, k); + } + + public V get(K k) { + return map.get(k); + } + + public K getKey(V v) { + return inversedMap.get(v); + } + + public Set getEntrySet(){ + return inversedMap.keySet(); + } + + public void remove(K k){ + inversedMap.remove(map.remove(k)); + } + + + public boolean containsKey(V v){ + return inversedMap.containsKey(v); + } + +} + diff --git a/app/src/main/java/fr/free/nrw/commons/utils/DialogUtil.java b/app/src/main/java/fr/free/nrw/commons/utils/DialogUtil.java index 2e4592e40..f68037488 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/DialogUtil.java +++ b/app/src/main/java/fr/free/nrw/commons/utils/DialogUtil.java @@ -5,6 +5,7 @@ import android.app.AlertDialog; import android.app.AlertDialog.Builder; import android.app.Dialog; import android.content.Context; +import android.content.DialogInterface; import android.os.Build; import android.support.annotation.Nullable; import android.support.v4.app.DialogFragment; @@ -114,7 +115,49 @@ public class DialogUtil { .setIcon(iconResourceId).create(); return alertDialog; + } + public static void showAlertDialog(Activity activity, + String title, + String message, + final Runnable onPositiveBtnClick, + final Runnable onNegativeBtnClick) { + showAlertDialog(activity, + title, + message, + activity.getString(R.string.no), + activity.getString(R.string.yes), + onPositiveBtnClick, + onNegativeBtnClick); + } + + public static void showAlertDialog(Activity activity, + String title, + String message, + String positiveButtonText, + String negativeButtonText, + final Runnable onPositiveBtnClick, + final Runnable onNegativeBtnClick) { + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(title); + builder.setMessage(message); + + builder.setPositiveButton(positiveButtonText, (dialogInterface, i) -> { + dialogInterface.dismiss(); + if (onPositiveBtnClick != null) { + onPositiveBtnClick.run(); + } + }); + + builder.setNegativeButton(negativeButtonText, (DialogInterface dialogInterface, int i) -> { + dialogInterface.dismiss(); + if (onNegativeBtnClick != null) { + onNegativeBtnClick.run(); + } + }); + + AlertDialog dialog = builder.create(); + showSafely(activity, dialog); } public interface Callback { diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java index 460046bab..e6cc2fc5d 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java +++ b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java @@ -7,6 +7,7 @@ import android.graphics.BitmapRegionDecoder; import android.graphics.Color; import android.graphics.Rect; import android.net.Uri; +import android.support.annotation.IntDef; import android.support.annotation.Nullable; import com.facebook.common.executors.CallerThreadExecutor; @@ -20,6 +21,8 @@ import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import fr.free.nrw.commons.R; import timber.log.Timber; @@ -30,20 +33,44 @@ import timber.log.Timber; public class ImageUtils { - public enum Result { - IMAGE_DARK, - IMAGE_OK + public static final int IMAGE_DARK = 1; + public static final int IMAGE_BLURRY = 1 << 1; + public static final int IMAGE_DUPLICATE = 1 << 2; + public static final int IMAGE_OK = 0; + public static final int IMAGE_KEEP = -1; + public static final int IMAGE_WAIT = -2; + public static final int EMPTY_TITLE = -3; + public static final int FILE_NAME_EXISTS = -4; + public static final int NO_CATEGORY_SELECTED = -5; + + @IntDef( + flag = true, + value = { + IMAGE_DARK, + IMAGE_BLURRY, + IMAGE_DUPLICATE, + IMAGE_OK, + IMAGE_KEEP, + IMAGE_WAIT, + EMPTY_TITLE, + FILE_NAME_EXISTS, + NO_CATEGORY_SELECTED + } + ) + @Retention(RetentionPolicy.SOURCE) + public @interface Result { } /** * @param bitmapRegionDecoder BitmapRegionDecoder for the image we wish to process - * @return Result.IMAGE_OK if image is neither dark nor blurry or if the input bitmapRegionDecoder provided is null - * Result.IMAGE_DARK if image is too dark + * @return IMAGE_OK if image is neither dark nor blurry or if the input bitmapRegionDecoder provided is null + * IMAGE_DARK if image is too dark */ - public static Result checkIfImageIsTooDark(BitmapRegionDecoder bitmapRegionDecoder) { + public static @Result + int checkIfImageIsTooDark(BitmapRegionDecoder bitmapRegionDecoder) { if (bitmapRegionDecoder == null) { Timber.e("Expected bitmapRegionDecoder was null"); - return Result.IMAGE_OK; + return IMAGE_OK; } int loadImageHeight = bitmapRegionDecoder.getHeight(); @@ -59,10 +86,10 @@ public class ImageUtils { Bitmap processBitmap = bitmapRegionDecoder.decodeRegion(rect,null); if (checkIfImageIsDark(processBitmap)) { - return Result.IMAGE_DARK; + return IMAGE_DARK; } - return Result.IMAGE_OK; + return IMAGE_OK; } /** @@ -132,8 +159,9 @@ public class ImageUtils { /** * Downloads the image from the URL and sets it as the phone's wallpaper * Fails silently if download or setting wallpaper fails. - * @param context - * @param imageUrl + * + * @param context context + * @param imageUrl Url of the image */ public static void setWallpaperFromImageUrl(Context context, Uri imageUrl) { Timber.d("Trying to set wallpaper from url %s", imageUrl.toString()); @@ -150,7 +178,7 @@ public class ImageUtils { @Override public void onNewResultImpl(@Nullable Bitmap bitmap) { - if (dataSource.isFinished() && bitmap != null){ + if (dataSource.isFinished() && bitmap != null) { Timber.d("Bitmap loaded from url %s", imageUrl.toString()); setWallpaper(context, Bitmap.createBitmap(bitmap)); dataSource.close(); @@ -173,7 +201,29 @@ public class ImageUtils { wallpaperManager.setBitmap(bitmap); ViewUtil.showLongToast(context, context.getString(R.string.wallpaper_set_successfully)); } catch (IOException e) { - Timber.e(e,"Error setting wallpaper"); + Timber.e(e, "Error setting wallpaper"); } } + + public static String getErrorMessageForResult(Context context, @Result int result) { + String errorMessage; + if (result == ImageUtils.IMAGE_DARK) + errorMessage = context.getString(R.string.upload_image_problem_dark); + else if (result == ImageUtils.IMAGE_BLURRY) + errorMessage = context.getString(R.string.upload_image_problem_blurry); + else if (result == ImageUtils.IMAGE_DUPLICATE) + errorMessage = context.getString(R.string.upload_image_problem_duplicate); + else if (result == (ImageUtils.IMAGE_DARK|ImageUtils.IMAGE_BLURRY)) + errorMessage = context.getString(R.string.upload_image_problem_dark_blurry); + else if (result == (ImageUtils.IMAGE_DARK|ImageUtils.IMAGE_DUPLICATE)) + errorMessage = context.getString(R.string.upload_image_problem_dark_duplicate); + else if (result == (ImageUtils.IMAGE_BLURRY|ImageUtils.IMAGE_DUPLICATE)) + errorMessage = context.getString(R.string.upload_image_problem_blurry_duplicate); + else if (result == (ImageUtils.IMAGE_DARK|ImageUtils.IMAGE_BLURRY|ImageUtils.IMAGE_DUPLICATE)) + errorMessage = context.getString(R.string.upload_image_problem_dark_blurry_duplicate); + else + return ""; + + return errorMessage; + } } diff --git a/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java index 8595634d5..d76e2ff5f 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java +++ b/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java @@ -9,6 +9,7 @@ import android.support.v4.content.ContextCompat; import fr.free.nrw.commons.CommonsApplication; + public class PermissionUtils { public static final int CAMERA_PERMISSION_FROM_CONTRIBUTION_LIST = 100; diff --git a/app/src/main/java/fr/free/nrw/commons/utils/StringUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/StringUtils.java index 0eb8216e4..0f93e65ef 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/StringUtils.java +++ b/app/src/main/java/fr/free/nrw/commons/utils/StringUtils.java @@ -12,4 +12,8 @@ public class StringUtils { return Html.fromHtml(source).toString(); } } + + public static boolean isNullOrWhiteSpace(String value) { + return value == null || value.trim().isEmpty(); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.java b/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.java index a2c25c948..aef3dddb0 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.java +++ b/app/src/main/java/fr/free/nrw/commons/utils/ViewUtil.java @@ -2,6 +2,7 @@ package fr.free.nrw.commons.utils; import android.app.Activity; import android.content.Context; +import android.support.annotation.StringRes; import android.support.design.widget.Snackbar; import android.view.Display; import android.view.View; @@ -32,6 +33,30 @@ public class ViewUtil { ExecutorUtils.uiExecutor().execute(() -> Toast.makeText(context, text, Toast.LENGTH_LONG).show()); } + public static void showLongToast(Context context, @StringRes int stringResourceId) { + if (context == null) { + return; + } + + ExecutorUtils.uiExecutor().execute(() -> Toast.makeText(context, context.getString(stringResourceId), Toast.LENGTH_LONG).show()); + } + + public static void showShortToast(Context context, String text) { + if (context == null) { + return; + } + + ExecutorUtils.uiExecutor().execute(() -> Toast.makeText(context, text, Toast.LENGTH_SHORT).show()); + } + + public static void showShortToast(Context context, @StringRes int stringResourceId) { + if (context == null) { + return; + } + + ExecutorUtils.uiExecutor().execute(() -> Toast.makeText(context, context.getString(stringResourceId), Toast.LENGTH_SHORT).show()); + } + public static boolean isPortrait(Context context) { Display orientation = ((Activity)context).getWindowManager().getDefaultDisplay(); if (orientation.getWidth() < orientation.getHeight()){ diff --git a/app/src/main/res/drawable/ic_error_red_24dp.xml b/app/src/main/res/drawable/ic_error_red_24dp.xml new file mode 100644 index 000000000..e1569395b --- /dev/null +++ b/app/src/main/res/drawable/ic_error_red_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_less_black_24dp.xml b/app/src/main/res/drawable/ic_expand_less_black_24dp.xml new file mode 100644 index 000000000..3afdf9682 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_less_white_24dp.xml b/app/src/main/res/drawable/ic_expand_less_white_24dp.xml new file mode 100644 index 000000000..d58421a2f --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more_black_24dp.xml b/app/src/main/res/drawable/ic_expand_more_black_24dp.xml new file mode 100644 index 000000000..8d57dbc10 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more_white_24dp.xml b/app/src/main/res/drawable/ic_expand_more_white_24dp.xml new file mode 100644 index 000000000..fd3ce4a46 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_upload.xml b/app/src/main/res/layout/activity_upload.xml new file mode 100644 index 000000000..01414ff9f --- /dev/null +++ b/app/src/main/res/layout/activity_upload.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_upload_bottom_card.xml b/app/src/main/res/layout/activity_upload_bottom_card.xml new file mode 100644 index 000000000..273835b5d --- /dev/null +++ b/app/src/main/res/layout/activity_upload_bottom_card.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + +