diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index d18f5be8cfe..936d6e5a6e5 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -7,6 +7,7 @@ + diff --git a/app/build.gradle b/app/build.gradle index 31dafb6d1f2..596560b4410 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,10 +19,9 @@ dependencies { implementation project(':wikimedia-data-client') // Utils - implementation 'com.github.nicolas-raoul:Quadtree:ac16ea8035bf07' implementation 'in.yuvi:http.fluent:1.3' implementation 'com.google.code.gson:gson:2.8.5' - implementation 'com.squareup.okhttp3:okhttp:4.2.0' + implementation 'com.squareup.okhttp3:okhttp:4.5.0' implementation 'com.squareup.okio:okio:2.2.2' implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' implementation 'io.reactivex.rxjava2:rxjava:2.2.3' @@ -44,6 +43,7 @@ dependencies { implementation 'com.dinuscxj:circleprogressbar:1.1.1' implementation 'com.karumi:dexter:5.0.0' implementation "com.jakewharton:butterknife:$BUTTERKNIFE_VERSION" + kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION" // Logging @@ -53,7 +53,7 @@ dependencies { api('com.github.tony19:logback-android-classic:1.1.1-6') { exclude group: 'com.google.android', module: 'android' } - implementation "com.squareup.okhttp3:logging-interceptor:4.2.0" + implementation "com.squareup.okhttp3:logging-interceptor:4.5.0" // Dependency injector implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION" @@ -65,7 +65,7 @@ dependencies { //Mocking testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0' - testImplementation 'org.mockito:mockito-inline:2.8.47' + testImplementation 'org.mockito:mockito-inline:2.13.0' testImplementation 'org.mockito:mockito-core:2.23.0' testImplementation "org.powermock:powermock-module-junit4:2.0.0-beta.5" testImplementation "org.powermock:powermock-api-mockito2:2.0.0-beta.5" @@ -108,9 +108,10 @@ dependencies { //Room implementation "androidx.room:room-runtime:$ROOM_VERSION" - kapt "androidx.room:room-compiler:$ROOM_VERSION" // For Kotlin use kapt instead of annotationProcessor - implementation 'com.squareup.retrofit2:retrofit:2.7.1' + implementation "androidx.room:room-ktx:$ROOM_VERSION" implementation "androidx.room:room-rxjava2:$ROOM_VERSION" + kapt "androidx.room:room-compiler:$ROOM_VERSION" // For Kotlin use kapt instead of annotationProcessor + implementation 'com.squareup.retrofit2:retrofit:2.8.1' testImplementation "androidx.arch.core:core-testing:2.1.0" // Pref @@ -208,6 +209,7 @@ android { buildConfigField "String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"" buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\"" buildConfigField "String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"" + buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\"" buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"" buildConfigField "String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns.json\"" buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\"" @@ -229,6 +231,7 @@ android { buildConfigField "String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"" buildConfigField "String", "TEST_USERNAME", "\"" + System.getenv("test_user_name") + "\"" buildConfigField "String", "TEST_PASSWORD", "\"" + System.getenv("test_user_password") + "\"" + buildConfigField "String", "DEPICTS_PROPERTY", "\"P180\"" dimension 'tier' } @@ -240,6 +243,7 @@ android { buildConfigField "String", "WIKIMEDIA_API_POTD", "\"https://commons.wikimedia.org/w/api.php?action=featuredfeed&feed=potd&feedformat=rss&language=en\"" buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\"" buildConfigField "String", "WIKIDATA_API_HOST", "\"https://www.wikidata.org/w/api.php\"" + buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\"" buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"" buildConfigField "String", "WIKIMEDIA_CAMPAIGNS_URL", "\"https://raw.githubusercontent.com/commons-app/campaigns/master/campaigns_beta_active.json\"" buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\"" @@ -261,6 +265,7 @@ android { buildConfigField "String", "COMMIT_SHA", "\"" + getBuildVersion().toString() + "\"" buildConfigField "String", "TEST_USERNAME", "\"" + System.getenv("test_user_name") + "\"" buildConfigField "String", "TEST_PASSWORD", "\"" + System.getenv("test_user_password") + "\"" + buildConfigField "String", "DEPICTS_PROPERTY", "\"P245962\"" dimension 'tier' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e0d15b7323c..e0cf4d03c92 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,9 @@ + + @@ -118,12 +120,12 @@ android:parentActivityName=".contributions.MainActivity" /> @@ -137,9 +139,8 @@ /> + android:name=".achievements.AchievementsActivity" + android:label="@string/Achievements" /> categories; // as loaded at runtime? + /** + * Depicts is a feature part of Structured data. Multiple Depictions can be added for an image just like categories. + * However unlike categories depictions is multi-lingual + */ + private Depictions depictions; + private boolean requestedDeletion; + @Nullable private LatLng coordinates; + + /** + * Provides local constructor + */ + public Media() { + } + + /** + * Provides a minimal constructor + * + * @param filename Media filename + */ + public Media(String filename) { + this.filename = filename; + } + + /** + * Provide Media constructor + * @param localUri Media URI + * @param imageUrl Media image URL + * @param filename Media filename + * @param description Media description + * @param dataLength Media date length + * @param dateCreated Media creation date + * @param dateUploaded Media date uploaded + * @param creator Media creator + */ + public Media(Uri localUri, String imageUrl, String filename, + String description, + long dataLength, Date dateCreated, Date dateUploaded, String creator) { + this.localUri = localUri; + this.thumbUrl = imageUrl; + this.imageUrl = imageUrl; + this.filename = filename; + this.description = description; + this.dataLength = dataLength; + this.dateCreated = dateCreated; + this.dateUploaded = dateUploaded; + this.creator = creator; + } + + public Media(Uri localUri, String filename, + String description, String creator, List categories) { + this(localUri,null, filename, + description, -1, null, new Date(), creator); + this.categories = categories; + } + + public Media(String title, Date date, String user) { + this(null, null, title, "", -1, date, date, user); + } + + /** + * Creating Media object from MWQueryPage. + * Earlier only basic details were set for the media object but going forward, + * a full media object(with categories, descriptions, coordinates etc) can be constructed using this method + * + * @param page response from the API + * @return Media object + */ + @Nullable + public static Media from(MwQueryPage page) { + ImageInfo imageInfo = page.imageInfo(); + if (imageInfo == null) { + return new Media(); // null is not allowed + } + ExtMetadata metadata = imageInfo.getMetadata(); + if (metadata == null) { + Media media = new Media(null, imageInfo.getOriginalUrl(), + page.title(), "", 0, null, null, null); + if (!StringUtils.isBlank(imageInfo.getThumbUrl())) { + media.setThumbUrl(imageInfo.getThumbUrl()); + } + return media; + } + + Media media = new Media(null, + imageInfo.getOriginalUrl(), + page.title(), + "", + 0, + safeParseDate(metadata.dateTime()), + safeParseDate(metadata.dateTime()), + getArtist(metadata) + ); + + if (!StringUtils.isBlank(imageInfo.getThumbUrl())) { + media.setThumbUrl(imageInfo.getThumbUrl()); + } + + media.setPageId(String.valueOf(page.pageId())); + + String language = Locale.getDefault().getLanguage(); + if (StringUtils.isBlank(language)) { + language = "default"; + } + + media.setDescription(metadata.imageDescription()); + media.setCategories(MediaDataExtractorUtil.extractCategoriesFromList(metadata.getCategories())); + String latitude = metadata.getGpsLatitude(); + String longitude = metadata.getGpsLongitude(); + + if (!StringUtils.isBlank(latitude) && !StringUtils.isBlank(longitude)) { + LatLng latLng = new LatLng(Double.parseDouble(latitude), Double.parseDouble(longitude), 0); + media.setCoordinates(latLng); + } + + media.setLicenseInformation(metadata.licenseShortName(), metadata.licenseUrl()); + return media; + } + + /** + * This method extracts the Commons Username from the artist HTML information + * @param metadata + * @return + */ + private static String getArtist(ExtMetadata metadata) { + try { + String artistHtml = metadata.artist(); + return artistHtml.substring(artistHtml.indexOf("title=\""), artistHtml.indexOf("\">")) + .replace("title=\"User:", ""); + } catch (Exception ex) { + return ""; + } + } + + /** + * @return pageId for the current media object*/ + public String getPageId() { + return pageId; + } + + /** + *sets pageId for the current media object + */ + public void setPageId(String pageId) { + this.pageId = pageId; + } + + public String getThumbUrl() { + return thumbUrl; + } + + /** + * Gets media display title + * @return Media title + */ + @NonNull public String getDisplayTitle() { + return filename != null ? getPageTitle().getDisplayTextWithoutNamespace().replaceFirst("[.][^.]+$", "") : ""; + } + + /** + * Set Caption(if available) as the thumbnail title of the image + */ + public void setThumbnailTitle(String title) { + this.thumbnailTitle = title; + } + + /** + * @return title to be shown on image thumbnail + * If caption is available for the image then it returns caption else filename + */ + public String getThumbnailTitle() { + return thumbnailTitle != null? thumbnailTitle : getDisplayTitle(); + } + + /** + * Gets file page title + * @return New media page title + */ + @NonNull public PageTitle getPageTitle() { + return Utils.getPageTitle(getFilename()); + } + + /** + * Gets local URI + * @return Media local URI + */ + public Uri getLocalUri() { + return localUri; + } + + /** + * Gets image URL + * can be null. + * @return Image URL + */ + @Nullable + public String getImageUrl() { + return imageUrl; + } + + /** + * Gets the name of the file. + * @return file name as a string + */ + public String getFilename() { + return filename; + } + + /** + * Sets the name of the file. + * @param filename the new name of the file + */ + public void setFilename(String filename) { + this.filename = filename; + } + + /** + * Sets the discussion of the file. + * @param discussion + */ + public void setDiscussion(String discussion) { + this.discussion = discussion; + } + + /** + * Gets the file discussion as a string. + * @return file discussion as a string + */ + public String getDiscussion() { + return discussion; + } + + /** + * Gets the file description. + * @return file description as a string + */ + public String getDescription() { + return description; + } + + /** + * Captions are a feature part of Structured data. They are meant to store short, multilingual descriptions about files + * This is a replacement of the previously used titles for images (titles were not multilingual) + * Also now captions replace the previous convention of using title for filename + * + * @return caption + */ + public String getCaption() { + return caption; + } + + /** + * @return depictions associated with the current media + */ + public Depictions getDepiction() { + return depictions; + } + + /** + * Sets the file description. + * @param description the new description of the file + */ + public void setDescription(String description) { + this.description = description; + } + + /** + * Gets the dataLength of the file. + * @return file dataLength as a long + */ + public long getDataLength() { + return dataLength; + } + + /** + * Sets the dataLength of the file. + * @param dataLength as a long + */ + public void setDataLength(long dataLength) { + this.dataLength = dataLength; + } + + /** + * Gets the creation date of the file. + * @return creation date as a Date + */ + public Date getDateCreated() { + return dateCreated; + } + + /** + * Sets the creation date of the file. + * @param date creation date as a Date + */ + public void setDateCreated(Date date) { + this.dateCreated = date; + } + + /** + * Gets the upload date of the file. + * Can be null. + * @return upload date as a Date + */ + public @Nullable + Date getDateUploaded() { + return dateUploaded; + } + + /** + * Gets the name of the creator of the file. + * @return creator name as a String + */ + public String getCreator() { + return creator; + } + + /** + * Sets the creator name of the file. + * @param creator creator name as a string + */ + public void setCreator(String creator) { + this.creator = creator; + } + + /** + * Gets the license name of the file. + * @return license as a String + */ + public String getLicense() { + return license; + } + + public void setThumbUrl(String thumbUrl) { + this.thumbUrl = thumbUrl; + } + + public String getLicenseUrl() { + return licenseUrl; + } + + /** + * Sets the license name of the file. + * @param license license name as a String + */ + public void setLicenseInformation(String license, String licenseUrl) { + this.license = license; + + if (!licenseUrl.startsWith("http://") && !licenseUrl.startsWith("https://")) { + licenseUrl = "https://" + licenseUrl; + } + this.licenseUrl = licenseUrl; + } + + /** + * Gets the coordinates of where the file was created. + * @return file coordinates as a LatLng + */ + public @Nullable + LatLng getCoordinates() { + return coordinates; + } + + /** + * Sets the coordinates of where the file was created. + * @param coordinates file coordinates as a LatLng + */ + public void setCoordinates(@Nullable LatLng coordinates) { + this.coordinates = coordinates; + } + + /** + * Gets the categories the file falls under. + * @return file categories as an ArrayList of Strings + */ + @SuppressWarnings("unchecked") + public List getCategories() { + return categories; + } + + /** + * Sets the categories the file falls under. + *

+ * Does not append: i.e. will clear the current categories + * and then add the specified ones. + * @param categories file categories as a list of Strings + */ + public void setCategories(List categories) { + this.categories = categories; + } + + @Nullable private static Date safeParseDate(String dateStr) { + try { + return CommonsDateUtil.getIso8601DateFormatShort().parse(dateStr); + } catch (ParseException e) { + return null; + } + } + + /** + * Set requested deletion to true + * @param requestedDeletion + */ + public void setRequestedDeletion(boolean requestedDeletion){ + this.requestedDeletion = requestedDeletion; + } + + /** + * Get the value of requested deletion + * @return boolean requestedDeletion + */ + public boolean isRequestedDeletion(){ + return requestedDeletion; + } + + /** + * Sets the license name of the file. + * + * @param license license name as a String + */ + public void setLicense(String license) { + this.license = license; + } + + /** + * Captions are a feature part of Structured data. They are meant to store short, multilingual descriptions about files + * This is a replacement of the previously used titles for images (titles were not multilingual) + * Also now captions replace the previous convention of using title for filename + * + * This function sets captions + * @param caption + */ + public void setCaption(String caption) { + this.caption = caption; + } + + /* Sets depictions for the current media obtained fro Wikibase API*/ + public void setDepictions(Depictions depictions) { + this.depictions = depictions; + } + + public void setLocalUri(@Nullable final Uri localUri) { + this.localUri = localUri; + } + + public void setImageUrl(final String imageUrl) { + this.imageUrl = imageUrl; + } + + public void setDateUploaded(@Nullable final Date dateUploaded) { + this.dateUploaded = dateUploaded; + } + + public void setLicenseUrl(final String licenseUrl) { + this.licenseUrl = licenseUrl; + } + + public Depictions getDepictions() { + return depictions; + } + + @Override + public int describeContents() { + return 0; + } + + /** + * Creates a way to transfer information between two or more + * activities. + * @param dest Instance of Parcel + * @param flags Parcel flag + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(this.localUri, flags); + dest.writeString(this.thumbUrl); + dest.writeString(this.imageUrl); + dest.writeString(this.filename); + dest.writeString(this.thumbnailTitle); + dest.writeString(this.caption); + dest.writeString(this.description); + dest.writeString(this.discussion); + dest.writeLong(this.dataLength); + dest.writeLong(this.dateCreated != null ? this.dateCreated.getTime() : -1); + dest.writeLong(this.dateUploaded != null ? this.dateUploaded.getTime() : -1); + dest.writeString(this.license); + dest.writeString(this.licenseUrl); + dest.writeString(this.creator); + dest.writeString(this.pageId); + dest.writeStringList(this.categories); + dest.writeParcelable(this.depictions, flags); + dest.writeByte(this.requestedDeletion ? (byte) 1 : (byte) 0); + dest.writeParcelable(this.coordinates, flags); + } + + protected Media(Parcel in) { + this.localUri = in.readParcelable(Uri.class.getClassLoader()); + this.thumbUrl = in.readString(); + this.imageUrl = in.readString(); + this.filename = in.readString(); + this.thumbnailTitle = in.readString(); + this.caption = in.readString(); + this.description = in.readString(); + this.discussion = in.readString(); + this.dataLength = in.readLong(); + long tmpDateCreated = in.readLong(); + this.dateCreated = tmpDateCreated == -1 ? null : new Date(tmpDateCreated); + long tmpDateUploaded = in.readLong(); + this.dateUploaded = tmpDateUploaded == -1 ? null : new Date(tmpDateUploaded); + this.license = in.readString(); + this.licenseUrl = in.readString(); + this.creator = in.readString(); + this.pageId = in.readString(); + final ArrayList list = new ArrayList<>(); + in.readStringList(list); + this.categories=list; + in.readParcelable(Depictions.class.getClassLoader()); + this.requestedDeletion = in.readByte() != 0; + this.coordinates = in.readParcelable(LatLng.class.getClassLoader()); + } + + public static final Creator CREATOR = new Creator() { + @Override + public Media createFromParcel(Parcel source) { + return new Media(source); + } + + @Override + public Media[] newArray(int size) { + return new Media[size]; + } + }; +} diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java new file mode 100644 index 00000000000..58b6be2c9e4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java @@ -0,0 +1,101 @@ +package fr.free.nrw.commons; + +import static fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX; + +import androidx.core.text.HtmlCompat; +import fr.free.nrw.commons.media.Depictions; +import fr.free.nrw.commons.media.MediaClient; +import io.reactivex.Single; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.jetbrains.annotations.NotNull; +import timber.log.Timber; + +/** + * Fetch additional media data from the network that we don't store locally. + * + * This includes things like category lists and multilingual descriptions, + * which are not intrinsic to the media and may change due to editing. + */ +@Singleton +public class MediaDataExtractor { + + private final MediaClient mediaClient; + + @Inject + public MediaDataExtractor(final MediaClient mediaClient) { + this.mediaClient = mediaClient; + } + + /** + * Simplified method to extract all details required to show media details. + * It fetches media object, deletion status, talk page and captions for the filename + * @param filename for which the details are to be fetched + * @return full Media object with all details including deletion status and talk page + */ + public Single fetchMediaDetails(final String filename, final String pageId) { + return Single.zip(getMediaFromFileName(filename), + mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + filename), + getDiscussion(filename), + pageId != null ? getCaption(PAGE_ID_PREFIX + pageId) + : Single.just(MediaClient.NO_CAPTION), + getDepictions(filename), + this::combineToMedia); + } + + @NotNull + private Media combineToMedia(final Media media, final Boolean deletionStatus, final String discussion, + final String caption, final Depictions depictions) { + media.setDiscussion(discussion); + media.setCaption(caption); + media.setDepictions(depictions); + if (deletionStatus) { + media.setRequestedDeletion(true); + } + return media; + } + + /** + * Obtains captions using filename + * @param wikibaseIdentifier + * + * @return caption for the image in user's locale + * Ex: "a nice painting" (english locale) and "No Caption" in case the caption is not available for the image + */ + private Single getCaption(final String wikibaseIdentifier) { + return mediaClient.getCaptionByWikibaseIdentifier(wikibaseIdentifier); + } + + /** + * Fetch depictions from the MediaWiki API + * @param filename the filename we will return the caption for + * @return Depictions + */ + private Single getDepictions(final String filename) { + return mediaClient.getDepictions(filename) + .doOnError(throwable -> Timber.e(throwable, "error while fetching depictions")); + } + + /** + * Method can be used to fetch media for a given filename + * @param filename Eg. File:Test.jpg + * @return return data rich Media object + */ + public Single getMediaFromFileName(final String filename) { + return mediaClient.getMedia(filename); + } + + /** + * Fetch talk page from the MediaWiki API + * @param filename + * @return + */ + private Single getDiscussion(final String filename) { + return mediaClient.getPageHtml(filename.replace("File", "File talk")) + .map(discussion -> HtmlCompat.fromHtml(discussion, HtmlCompat.FROM_HTML_MODE_LEGACY).toString()) + .onErrorReturn(throwable -> { + Timber.e(throwable, "Error occurred while fetching discussion"); + return ""; + }); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java b/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java index 9d3bde848ec..b9c10496ccb 100644 --- a/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java +++ b/app/src/main/java/fr/free/nrw/commons/OkHttpConnectionFactory.java @@ -1,6 +1,11 @@ package fr.free.nrw.commons; import androidx.annotation.NonNull; + +import okhttp3.logging.HttpLoggingInterceptor.Level; +import org.wikipedia.dataclient.SharedPreferenceCookieManager; +import org.wikipedia.dataclient.okhttp.HttpStatusException; + import java.io.File; import java.io.IOException; import okhttp3.Cache; diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.java index 7070fa40b60..e83edfac40b 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragment.java @@ -1,65 +1,71 @@ -package fr.free.nrw.commons.bookmarks.pictures; +package fr.free.nrw.commons.category; import static android.view.View.GONE; import static android.view.View.VISIBLE; +import static fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX; import android.annotation.SuppressLint; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.GridView; import android.widget.ListAdapter; import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.TextView; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import butterknife.BindView; import butterknife.ButterKnife; import dagger.android.support.DaggerFragment; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; -import fr.free.nrw.commons.bookmarks.BookmarksActivity; -import fr.free.nrw.commons.category.GridViewAdapter; +import fr.free.nrw.commons.kvstore.JsonKvStore; +import fr.free.nrw.commons.media.MediaClient; import fr.free.nrw.commons.utils.NetworkUtils; import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.schedulers.Schedulers; import java.util.List; +import java.util.concurrent.TimeUnit; import javax.inject.Inject; +import javax.inject.Named; import timber.log.Timber; -public class BookmarkPicturesFragment extends DaggerFragment { +/** + * Displays images for a particular category with load more on scrolling incorporated + */ +public class CategoryImagesListFragment extends DaggerFragment { + + private static int TIMEOUT_SECONDS = 15; + /** + * counts the total number of items loaded from the API + */ + private int mediaSize = 0; private GridViewAdapter gridAdapter; - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - @BindView(R.id.statusMessage) TextView statusTextView; + @BindView(R.id.statusMessage) + TextView statusTextView; @BindView(R.id.loadingImagesProgressBar) ProgressBar progressBar; - @BindView(R.id.bookmarkedPicturesList) GridView gridView; + @BindView(R.id.categoryImagesList) GridView gridView; @BindView(R.id.parentLayout) RelativeLayout parentLayout; + private CompositeDisposable compositeDisposable = new CompositeDisposable(); + private boolean hasMoreImages = true; + private boolean isLoading = true; + private String categoryName = null; + @Inject MediaClient mediaClient; @Inject - BookmarkPicturesController controller; - - /** - * Create an instance of the fragment with the right bundle parameters - * @return an instance of the fragment - */ - public static BookmarkPicturesFragment newInstance() { - return new BookmarkPicturesFragment(); - } + @Named("default_preferences") + JsonKvStore categoryKvStore; @Override - public View onCreateView( - @NonNull LayoutInflater inflater, - ViewGroup container, - Bundle savedInstanceState - ) { - View v = inflater.inflate(R.layout.fragment_bookmarks_pictures, container, false); + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.fragment_category_images, container, false); ButterKnife.bind(this, v); return v; } @@ -68,13 +74,7 @@ public View onCreateView( public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); gridView.setOnItemClickListener((AdapterView.OnItemClickListener) getActivity()); - initList(); - } - - @Override - public void onStop() { - super.onStop(); - controller.stop(); + initViews(); } @Override @@ -83,22 +83,31 @@ public void onDestroy() { compositeDisposable.clear(); } - @Override - public void onResume() { - super.onResume(); - if (controller.needRefreshBookmarkedPictures()) { - gridView.setVisibility(GONE); - if (gridAdapter != null) { - gridAdapter.clear(); - ((BookmarksActivity) getContext()).viewPagerNotifyDataSetChanged(); - } + /** + * Initializes the UI elements for the fragment + * Setup the grid view to and scroll listener for it + */ + private void initViews() { + String categoryName = getArguments().getString("categoryName"); + if (getArguments() != null && categoryName != null) { + this.categoryName = categoryName; + resetQueryContinueValues(categoryName); initList(); + setScrollListener(); } } /** - * Checks for internet connection and then initializes - * the recycler view with bookmarked pictures + * Query continue values determine the last page that was loaded for the particular keyword + * This method resets those values, so that the results can be queried from the first page itself + * @param keyword + */ + private void resetQueryContinueValues(String keyword) { + categoryKvStore.remove("query_continue_" + keyword); + } + + /** + * Checks for internet connection and then initializes the grid view with first 10 images of that category */ @SuppressLint("CheckResult") private void initList() { @@ -107,12 +116,12 @@ private void initList() { return; } + isLoading = true; progressBar.setVisibility(VISIBLE); - statusTextView.setVisibility(GONE); - - compositeDisposable.add(controller.loadBookmarkedPictures() + compositeDisposable.add(mediaClient.getMediaListFromCategory(categoryName) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) .subscribe(this::handleSuccess, this::handleError)); } @@ -141,6 +150,7 @@ private void handleError(Throwable throwable) { }catch (Exception e){ e.printStackTrace(); } + } /** @@ -157,16 +167,68 @@ private void initErrorView() { } /** - * Handles the UI updates when there is no bookmarks + * Initializes the adapter with a list of Media objects + * @param mediaList List of new Media to be displayed */ - private void initEmptyBookmarkListView() { - progressBar.setVisibility(GONE); - if (gridAdapter == null || gridAdapter.isEmpty()) { - statusTextView.setVisibility(VISIBLE); - statusTextView.setText(getString(R.string.bookmark_empty)); - } else { - statusTextView.setVisibility(GONE); + private void setAdapter(List mediaList) { + gridAdapter = new GridViewAdapter(this.getContext(), R.layout.layout_category_images, mediaList); + gridView.setAdapter(gridAdapter); + } + + /** + * Sets the scroll listener for the grid view so that more images are fetched when the user scrolls down + * Checks if the category has more images before loading + * Also checks whether images are currently being fetched before triggering another request + */ + private void setScrollListener() { + gridView.setOnScrollListener(new AbsListView.OnScrollListener() { + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + if (hasMoreImages && !isLoading && (firstVisibleItem + visibleItemCount + 1 >= totalItemCount)) { + isLoading = true; + fetchMoreImages(); + } + if (!hasMoreImages){ + progressBar.setVisibility(GONE); + } + } + }); + } + + /** + * This method is called when viewPager has reached its end. + * Fetches more images for the category and adds it to the grid view and viewpager adapter + */ + public void fetchMoreImagesViewPager(){ + if (hasMoreImages && !isLoading) { + isLoading = true; + fetchMoreImages(); } + if (!hasMoreImages){ + progressBar.setVisibility(GONE); + } + } + + /** + * Fetches more images for the category and adds it to the grid view adapter + */ + @SuppressLint("CheckResult") + private void fetchMoreImages() { + if (!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + return; + } + + progressBar.setVisibility(VISIBLE); + compositeDisposable.add(mediaClient.getMediaListFromCategory(categoryName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(this::handleSuccess, this::handleError)); } /** @@ -175,12 +237,9 @@ private void initEmptyBookmarkListView() { * @param collection List of new Media to be displayed */ private void handleSuccess(List collection) { - if (collection == null) { + if (collection == null || collection.isEmpty()) { initErrorView(); - return; - } - if (collection.isEmpty()) { - initEmptyBookmarkListView(); + hasMoreImages = false; return; } @@ -188,27 +247,48 @@ private void handleSuccess(List collection) { setAdapter(collection); } else { if (gridAdapter.containsAll(collection)) { + hasMoreImages = false; return; } gridAdapter.addItems(collection); - ((BookmarksActivity) getContext()).viewPagerNotifyDataSetChanged(); + ((CategoryImagesCallback) getContext()).viewPagerNotifyDataSetChanged(); } + progressBar.setVisibility(GONE); + isLoading = false; statusTextView.setVisibility(GONE); - gridView.setVisibility(VISIBLE); + for (Media m : collection) { + final String pageId = m.getPageId(); + if (pageId != null) { + replaceTitlesWithCaptions(PAGE_ID_PREFIX + pageId, mediaSize++); + } + } } /** - * Initializes the adapter with a list of Media objects - * @param mediaList List of new Media to be displayed + * fetch captions for the image using filename and replace title of on the image thumbnail(if captions are available) + * else show filename */ - private void setAdapter(List mediaList) { - gridAdapter = new GridViewAdapter( - this.getContext(), - R.layout.layout_category_images, - mediaList - ); - gridView.setAdapter(gridAdapter); + public void replaceTitlesWithCaptions(String wikibaseIdentifier, int i) { + compositeDisposable.add(mediaClient.getCaptionByWikibaseIdentifier(wikibaseIdentifier) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(subscriber -> { + handleLabelforImage(subscriber, i); + })); + + } + + /** + * If caption is available for the image, then modify grid adapter + * to show captions + */ + private void handleLabelforImage(String s, int position) { + if (!s.trim().equals(getString(R.string.detail_caption_empty))) { + gridAdapter.getItem(position).setThumbnailTitle(s); + gridAdapter.notifyDataSetChanged(); + } } /** @@ -217,6 +297,7 @@ private void setAdapter(List mediaList) { * @return GridView Adapter */ public ListAdapter getAdapter() { - return gridView.getAdapter(); + return gridAdapter; } + } 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 00000000000..d8f9eb45e7d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java @@ -0,0 +1,242 @@ +package fr.free.nrw.commons.category; + +import android.text.TextUtils; +import fr.free.nrw.commons.kvstore.JsonKvStore; +import fr.free.nrw.commons.upload.GpsCategoryModel; +import fr.free.nrw.commons.utils.StringSortingUtils; +import io.reactivex.Observable; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; +import timber.log.Timber; + +/** + * The model class for categories in upload + */ +public class CategoriesModel{ + private static final int SEARCH_CATS_LIMIT = 25; + + private final CategoryClient categoryClient; + private final CategoryDao categoryDao; + private final JsonKvStore directKvStore; + private final GpsCategoryModel gpsCategoryModel; + + private List selectedCategories; + + @Inject + public CategoriesModel(CategoryClient categoryClient, + CategoryDao categoryDao, + @Named("default_preferences") JsonKvStore directKvStore, + final GpsCategoryModel gpsCategoryModel) { + this.categoryClient = categoryClient; + this.categoryDao = categoryDao; + this.directKvStore = directKvStore; + this.gpsCategoryModel = gpsCategoryModel; + this.selectedCategories = new ArrayList<>(); + } + + /** + * Sorts CategoryItem by similarity + * @param filter + * @return + */ + public Comparator sortBySimilarity(final String filter) { + Comparator stringSimilarityComparator = StringSortingUtils.sortBySimilarity(filter); + return (firstItem, secondItem) -> stringSimilarityComparator + .compare(firstItem.getName(), secondItem.getName()); + } + + /** + * Returns if the item contains an year + * @param item + * @return + */ + 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.*"))); + } + + /** + * Updates category count in category dao + * @param item + */ + 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); + } + + + /** + * Regional category search + * @param term + * @param imageTitleList + * @return + */ + public Observable searchAll(String term, List imageTitleList) { + //If query text is empty, show him category based on gps and title and recent searches + if (TextUtils.isEmpty(term)) { + Observable categoryItemObservable = + Observable.concat(gpsCategories(), titleCategories(imageTitleList)); + if (hasDirectCategories()) { + return Observable.concat( + categoryItemObservable, + directCategories(), + recentCategories() + ); + } + return categoryItemObservable; + } + + //otherwise, search API for matching categories + //term passed as lower case to make search case-insensitive(taking only lower case for everything) + return categoryClient + .searchCategoriesForPrefix(term.toLowerCase(), SEARCH_CATS_LIMIT) + .map(name -> new CategoryItem(name, false)); + } + + + /** + * Returns if we have a category in DirectKV Store + * @return + */ + private boolean hasDirectCategories() { + return !directKvStore.getString("Category", "").equals(""); + } + + /** + * Returns categories in DirectKVStore + * @return + */ + private Observable directCategories() { + String directCategory = directKvStore.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)); + } + + /** + * Returns GPS categories + * @return + */ + Observable gpsCategories() { + return Observable.fromIterable(gpsCategoryModel.getCategoryList()) + .map(name -> new CategoryItem(name, false)); + } + + /** + * Returns title based categories + * @param titleList + * @return + */ + private Observable titleCategories(List titleList) { + return Observable.fromIterable(titleList) + .concatMap(this::getTitleCategories); + } + + /** + * Return category for single title + * title is converted to lower case to make search case-insensitive + * @param title + * @return + */ + private Observable getTitleCategories(String title) { + return categoryClient.searchCategories(title.toLowerCase(), SEARCH_CATS_LIMIT) + .map(name -> new CategoryItem(name, false)); + } + + /** + * Returns recent categories + * @return + */ + private Observable recentCategories() { + return Observable.fromIterable(categoryDao.recentCategories(SEARCH_CATS_LIMIT)) + .map(s -> new CategoryItem(s, false)); + } + + /** + * Handles category item selection + * @param item + */ + public void onCategoryItemClicked(CategoryItem item) { + if (item.isSelected()) { + selectCategory(item); + updateCategoryCount(item); + } else { + unselectCategory(item); + } + } + + /** + * Select's category + * @param item + */ + public void selectCategory(CategoryItem item) { + selectedCategories.add(item); + } + + /** + * Unselect Category + * @param item + */ + public void unselectCategory(CategoryItem item) { + selectedCategories.remove(item); + } + + + /** + * Get Selected Categories + * @return + */ + public List getSelectedCategories() { + return selectedCategories; + } + + /** + * Get Categories String List + * @return + */ + public List getCategoryStringList() { + List output = new ArrayList<>(); + for (CategoryItem item : selectedCategories) { + output.add(item.getName()); + } + return output; + } + + /** + * Cleanup the existing in memory cache's + */ + public void cleanUp() { + this.selectedCategories.clear(); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java index e8aab51bc3e..c983af03afb 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java @@ -88,7 +88,7 @@ public View getView(int position, View convertView, ViewGroup parent) { SimpleDraweeView imageView = convertView.findViewById(R.id.categoryImageView); TextView fileName = convertView.findViewById(R.id.categoryImageTitle); TextView author = convertView.findViewById(R.id.categoryImageAuthor); - fileName.setText(item.getMostRelevantCaption()); + fileName.setText(item.getThumbnailTitle()); setAuthorView(item, author); imageView.setImageURI(item.getThumbUrl()); return convertView; 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 c67070acbb5..80c378137a1 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 @@ -1,36 +1,22 @@ package fr.free.nrw.commons.contributions; -import android.content.Context; -import android.net.Uri; import android.os.Parcel; - -import androidx.annotation.NonNull; -import androidx.annotation.StringDef; import androidx.room.Entity; import androidx.room.PrimaryKey; - -import org.apache.commons.lang3.StringUtils; - -import java.lang.annotation.Retention; -import java.util.Date; -import java.util.Locale; - -import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.filepicker.UploadableFile; -import fr.free.nrw.commons.settings.Prefs; -import fr.free.nrw.commons.utils.ConfigUtils; - -import static java.lang.annotation.RetentionPolicy.SOURCE; +import fr.free.nrw.commons.auth.SessionManager; +import fr.free.nrw.commons.upload.UploadMediaDetail; +import fr.free.nrw.commons.upload.UploadModel.UploadItem; +import fr.free.nrw.commons.upload.WikidataPlace; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.wikipedia.dataclient.mwapi.MwQueryLogEvent; @Entity(tableName = "contribution") -public class Contribution extends Media { - - //{{According to Exif data|2009-01-09}} - private static final String TEMPLATE_DATE_ACC_TO_EXIF = "{{According to Exif data|%s}}"; - - //2009-01-09 → 9 January 2009 - private static final String TEMPLATE_DATA_OTHER_SOURCE = "%s"; +public class Contribution extends Media { // No need to be bitwise - they're mutually exclusive public static final int STATE_COMPLETED = -1; @@ -38,219 +24,133 @@ 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"; @PrimaryKey (autoGenerate = true) - @NonNull - public long _id; - public Uri contentUri; - public String source; - public String editSummary; - public int state; - public long transferred; - public String decimalCoords; - public boolean isMultiple; - public String wikiDataEntityId; - public String wikiItemName; - private String p18Value; - public Uri contentProviderUri; - public String dateCreatedSource; + private long _id; + private int state; + private long transferred; + private String decimalCoords; + private String dateCreatedSource; + private WikidataPlace wikidataPlace; + /** + * Each depiction loaded in depictions activity is associated with a wikidata entity id, + * this Id is in turn used to upload depictions to wikibase + */ + private List depictedItems = new ArrayList<>(); + private String mimeType; + /** + * This hasmap stores the list of multilingual captions, where + * key of the HashMap is the language and value is the caption in the corresponding language + * Ex: key = "en", value: "" + * key = "de" , value: "" + */ + private Map captions = new HashMap<>(); - public Contribution(Uri localUri, String imageUrl, String filename, String description, long dataLength, - Date dateCreated, Date dateUploaded, String creator, String editSummary, String decimalCoords) { - super(localUri, imageUrl, filename, description, dataLength, dateCreated, dateUploaded, creator); - this.decimalCoords = decimalCoords; - this.editSummary = editSummary; - this.dateCreatedSource = ""; + public Contribution() { } - public Contribution(Uri localUri, String imageUrl, String filename, String description, long dataLength, - Date dateCreated, Date dateUploaded, String creator, String editSummary, String decimalCoords, int state) { - super(localUri, imageUrl, filename, description, dataLength, dateCreated, dateUploaded, creator); - this.decimalCoords = decimalCoords; - this.editSummary = editSummary; - this.dateCreatedSource = ""; - this.state=state; + public Contribution(final UploadItem item, final SessionManager sessionManager, + final List depictedItems, final List categories) { + super(item.getMediaUri(), + item.getFileName(), + UploadMediaDetail.formatList(item.getUploadMediaDetails()), + sessionManager.getAuthorName(), + categories); + captions = UploadMediaDetail.formatCaptions(item.getUploadMediaDetails()); + decimalCoords = item.getGpsCoords().getDecimalCoords(); + dateCreatedSource = ""; + this.depictedItems = depictedItems; + wikidataPlace = WikidataPlace.from(item.getPlace()); } - - - public void setDateCreatedSource(String dateCreatedSource) { - this.dateCreatedSource = dateCreatedSource; + public Contribution(final MwQueryLogEvent queryLogEvent, final String user) { + super(queryLogEvent.title(),queryLogEvent.date(), user); + decimalCoords = ""; + dateCreatedSource = ""; + state = STATE_COMPLETED; } - public boolean getMultiple() { - return isMultiple; + public void setDateCreatedSource(final String dateCreatedSource) { + this.dateCreatedSource = dateCreatedSource; } - public void setMultiple(boolean multiple) { - isMultiple = multiple; + public String getDateCreatedSource() { + return dateCreatedSource; } public long getTransferred() { return transferred; } - public void setTransferred(long transferred) { + public void setTransferred(final long transferred) { this.transferred = transferred; } - public String getEditSummary() { - return editSummary != null ? editSummary : CommonsApplication.DEFAULT_EDIT_SUMMARY; - } - - public Uri getContentUri() { - return contentUri; - } - - public void setContentUri(Uri contentUri) { - this.contentUri = contentUri; - } - public int getState() { return state; } - public void setState(int state) { + public void setState(final int state) { this.state = state; } - public void setDateUploaded(Date date) { - this.dateUploaded = date; - } - - public String getPageContents(Context applicationContext) { - StringBuilder buffer = new StringBuilder(); - buffer - .append("== {{int:filedesc}} ==\n") - .append("{{Information\n") - .append("|description=").append(getDescription()).append("\n") - .append("|source=").append("{{own}}\n") - .append("|author=[[User:").append(creator).append("|").append(creator).append("]]\n"); - - String templatizedCreatedDate = getTemplatizedCreatedDate(); - if (!StringUtils.isBlank(templatizedCreatedDate)) { - buffer.append("|date=").append(templatizedCreatedDate); - } - - buffer.append("}}").append("\n"); - - //Only add Location template (e.g. {{Location|37.51136|-77.602615}} ) if coords is not null - if (decimalCoords != null) { - buffer.append("{{Location|").append(decimalCoords).append("}}").append("\n"); - } - - buffer.append("== {{int:license-header}} ==\n") - .append(licenseTemplateFor(getLicense())).append("\n\n") - .append("{{Uploaded from Mobile|platform=Android|version=") - .append(ConfigUtils.getVersionNameWithSha(applicationContext)).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(); - } - /** - * Returns upload date in either TEMPLATE_DATE_ACC_TO_EXIF or TEMPLATE_DATA_OTHER_SOURCE - * @return + * @return array list of entityids for the depictions */ - private String getTemplatizedCreatedDate() { - if (dateCreated != null) { - java.text.SimpleDateFormat dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd"); - if (UploadableFile.DateTimeWithSource.EXIF_SOURCE.equals(dateCreatedSource)) { - return String.format(Locale.ENGLISH, TEMPLATE_DATE_ACC_TO_EXIF, dateFormat.format(dateCreated)) + "\n"; - } else { - return String.format(Locale.ENGLISH, TEMPLATE_DATA_OTHER_SOURCE, dateFormat.format(dateCreated)) + "\n"; - } - } - return ""; + public List getDepictedItems() { + return depictedItems; } - @Override - public void setFilename(String filename) { - this.filename = filename; - } - - public void setImageUrl(String imageUrl) { - this.imageUrl = imageUrl; - } - - public Contribution() { - + public void setWikidataPlace(final WikidataPlace wikidataPlace) { + this.wikidataPlace = wikidataPlace; } - public String getSource() { - return source; + public WikidataPlace getWikidataPlace() { + return wikidataPlace; } - public void setSource(String source) { - this.source = source; + public long get_id() { + return _id; } - @NonNull - private String licenseTemplateFor(String license) { - switch (license) { - case Prefs.Licenses.CC_BY_3: - return "{{self|cc-by-3.0}}"; - case Prefs.Licenses.CC_BY_4: - return "{{self|cc-by-4.0}}"; - case Prefs.Licenses.CC_BY_SA_3: - return "{{self|cc-by-sa-3.0}}"; - case Prefs.Licenses.CC_BY_SA_4: - return "{{self|cc-by-sa-4.0}}"; - case Prefs.Licenses.CC0: - return "{{self|cc-zero}}"; - } - - throw new RuntimeException("Unrecognized license value: " + license); + public void set_id(final long _id) { + this._id = _id; } - public String getWikiDataEntityId() { - return wikiDataEntityId; + public String getDecimalCoords() { + return decimalCoords; } - public String getWikiItemName() { - return wikiItemName; + public void setDecimalCoords(final String decimalCoords) { + this.decimalCoords = decimalCoords; } - /** - * When the corresponding wikidata entity is known as in case of nearby uploads, it can be set - * using the setter method - * @param wikiDataEntityId wikiDataEntityId - */ - public void setWikiDataEntityId(String wikiDataEntityId) { - this.wikiDataEntityId = wikiDataEntityId; + public void setDepictedItems(final List depictedItems) { + this.depictedItems = depictedItems; } - public void setWikiItemName(String wikiItemName) { - this.wikiItemName = wikiItemName; + public void setMimeType(String mimeType) { + this.mimeType = mimeType; } - public String getP18Value() { - return p18Value; + public String getMimeType() { + return mimeType; } /** - * When the corresponding image property of wiki entity is known as in case of nearby uploads, - * it can be set using the setter method - * @param p18Value p18 value, image property of the wikidata item + * Captions are a feature part of Structured data. They are meant to store short, multilingual descriptions about files + * This is a replacement of the previously used titles for images (titles were not multilingual) + * Also now captions replace the previous convention of using title for filename + * + * key of the HashMap is the language and value is the caption in the corresponding language + * + * returns list of captions stored in hashmap */ - public void setP18Value(String p18Value) { - this.p18Value = p18Value; + public Map getCaptions() { + return captions; } - public void setContentProviderUri(Uri contentProviderUri) { - this.contentProviderUri = contentProviderUri; + public void setCaptions(Map captions) { + this.captions = captions; } @Override @@ -259,48 +159,34 @@ public int describeContents() { } @Override - public void writeToParcel(Parcel dest, int flags) { + public void writeToParcel(final Parcel dest, final int flags) { super.writeToParcel(dest, flags); - dest.writeLong(this._id); - dest.writeParcelable(this.contentUri, flags); - dest.writeString(this.source); - dest.writeString(this.editSummary); - dest.writeInt(this.state); - dest.writeLong(this.transferred); - dest.writeString(this.decimalCoords); - dest.writeByte(this.isMultiple ? (byte) 1 : (byte) 0); - dest.writeString(this.wikiDataEntityId); - dest.writeString(this.wikiItemName); - dest.writeString(this.p18Value); - dest.writeParcelable(this.contentProviderUri, flags); - dest.writeString(this.dateCreatedSource); + dest.writeLong(_id); + dest.writeInt(state); + dest.writeLong(transferred); + dest.writeString(decimalCoords); + dest.writeString(dateCreatedSource); + dest.writeSerializable((HashMap) captions); } - protected Contribution(Parcel in) { + protected Contribution(final Parcel in) { super(in); - this._id = in.readLong(); - this.contentUri = in.readParcelable(Uri.class.getClassLoader()); - this.source = in.readString(); - this.editSummary = in.readString(); - this.state = in.readInt(); - this.transferred = in.readLong(); - this.decimalCoords = in.readString(); - this.isMultiple = in.readByte() != 0; - this.wikiDataEntityId = in.readString(); - this.wikiItemName = in.readString(); - this.p18Value = in.readString(); - this.contentProviderUri = in.readParcelable(Uri.class.getClassLoader()); - this.dateCreatedSource = in.readString(); + _id = in.readLong(); + state = in.readInt(); + transferred = in.readLong(); + decimalCoords = in.readString(); + dateCreatedSource = in.readString(); + captions = (HashMap) in.readSerializable(); } public static final Creator CREATOR = new Creator() { @Override - public Contribution createFromParcel(Parcel source) { + public Contribution createFromParcel(final Parcel source) { return new Contribution(source); } @Override - public Contribution[] newArray(int size) { + public Contribution[] newArray(final int size) { return new Contribution[size]; } }; 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 510d63949a5..3c0ac925f8a 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 @@ -1,6 +1,6 @@ package fr.free.nrw.commons.contributions; -import androidx.paging.DataSource; +import androidx.lifecycle.LiveData; import androidx.room.Dao; import androidx.room.Delete; import androidx.room.Insert; @@ -10,74 +10,45 @@ import androidx.room.Update; import io.reactivex.Completable; import io.reactivex.Single; -import java.util.Calendar; -import java.util.Date; import java.util.List; @Dao public abstract class ContributionDao { - @Query("SELECT * FROM contribution order by media_dateUploaded DESC") - abstract DataSource.Factory fetchContributions(); + @Query("SELECT * FROM contribution order by dateUploaded DESC") + abstract LiveData> fetchContributions(); - @Insert(onConflict = OnConflictStrategy.REPLACE) - public abstract void saveSynchronous(Contribution contribution); + @Insert(onConflict = OnConflictStrategy.REPLACE) + public abstract Single save(Contribution contribution); - public Completable save(final Contribution contribution) { - return Completable - .fromAction(() -> { - contribution.setDateModified(Calendar.getInstance().getTime()); - saveSynchronous(contribution); - }); - } + public Completable deleteAllAndSave(List contributions){ + return Completable.fromAction(() -> deleteAllAndSaveTransaction(contributions)); + } - @Transaction - public void deleteAndSaveContribution(final Contribution oldContribution, - final Contribution newContribution) { - deleteSynchronous(oldContribution); - saveSynchronous(newContribution); - } + @Transaction + public void deleteAllAndSaveTransaction(List contributions){ + deleteAll(Contribution.STATE_COMPLETED); + save(contributions); + } - public Completable saveAndDelete(final Contribution oldContribution, - final Contribution newContribution) { - return Completable - .fromAction(() -> deleteAndSaveContribution(oldContribution, newContribution)); - } + @Insert + public abstract void save(List contribution); - @Insert(onConflict = OnConflictStrategy.REPLACE) - public abstract Single> save(List contribution); + @Delete + public abstract Single delete(Contribution contribution); - @Delete - public abstract void deleteSynchronous(Contribution contribution); + @Query("SELECT * from contribution WHERE filename=:fileName") + public abstract List getContributionWithTitle(String fileName); - public Completable delete(final Contribution contribution) { - return Completable - .fromAction(() -> deleteSynchronous(contribution)); - } + @Query("UPDATE contribution SET state=:state WHERE state in (:toUpdateStates)") + public abstract Single updateStates(int state, int[] toUpdateStates); - @Query("SELECT * from contribution WHERE media_filename=:fileName") - public abstract List getContributionWithTitle(String fileName); + @Query("Delete FROM contribution") + public abstract void deleteAll(); - @Query("SELECT * from contribution WHERE pageId=:pageId") - public abstract Contribution getContribution(String pageId); + @Query("Delete FROM contribution WHERE state = :state") + public abstract void deleteAll(int state); - @Query("SELECT * from contribution WHERE state=:state") - public abstract Single> getContribution(int state); - - @Query("UPDATE contribution SET state=:state WHERE state in (:toUpdateStates)") - public abstract Single updateStates(int state, int[] toUpdateStates); - - @Query("Delete FROM contribution") - public abstract void deleteAll(); - - @Update - public abstract void updateSynchronous(Contribution contribution); - - public Completable update(final Contribution contribution) { - return Completable - .fromAction(() -> { - contribution.setDateModified(Calendar.getInstance().getTime()); - updateSynchronous(contribution); - }); - } + @Update + public abstract Single update(Contribution contribution); } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java index 3c21c67f189..824953650ff 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java @@ -1,11 +1,12 @@ package fr.free.nrw.commons.contributions; +import static fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX; + import android.net.Uri; import android.text.TextUtils; import android.view.View; -import android.widget.ImageButton; +import android.widget.LinearLayout; import android.widget.ProgressBar; -import android.widget.RelativeLayout; import android.widget.TextView; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; @@ -21,227 +22,152 @@ import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.schedulers.Schedulers; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import timber.log.Timber; public class ContributionViewHolder extends RecyclerView.ViewHolder { - private final Callback callback; - @BindView(R.id.contributionImage) - SimpleDraweeView imageView; - @BindView(R.id.contributionTitle) - TextView titleView; - @BindView(R.id.contributionState) - TextView stateView; - @BindView(R.id.contributionSequenceNumber) - TextView seqNumView; - @BindView(R.id.contributionProgress) - ProgressBar progressView; - @BindView(R.id.image_options) - RelativeLayout imageOptions; - @BindView(R.id.wikipediaButton) - ImageButton addToWikipediaButton; - @BindView(R.id.retryButton) - ImageButton retryButton; - @BindView(R.id.cancelButton) - ImageButton cancelButton; - @BindView(R.id.pauseResumeButton) - ImageButton pauseResumeButton; - - - private int position; - private Contribution contribution; - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - private final MediaClient mediaClient; - private boolean isWikipediaButtonDisplayed; - - ContributionViewHolder(final View parent, final Callback callback, - final MediaClient mediaClient) { - super(parent); - this.mediaClient = mediaClient; - ButterKnife.bind(this, parent); - this.callback = callback; - } - - public void init(final int position, final Contribution contribution) { - this.contribution = contribution; - this.position = position; - titleView.setText(contribution.getMedia().getMostRelevantCaption()); - - imageView.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder); - imageView.getHierarchy().setFailureImage(R.drawable.image_placeholder); + private static final long TIMEOUT_SECONDS = 15; + private final Callback callback; + @BindView(R.id.contributionImage) + SimpleDraweeView imageView; + @BindView(R.id.contributionTitle) TextView titleView; + @BindView(R.id.contributionState) TextView stateView; + @BindView(R.id.contributionSequenceNumber) TextView seqNumView; + @BindView(R.id.contributionProgress) ProgressBar progressView; + @BindView(R.id.failed_image_options) LinearLayout failedImageOptions; + + + private int position; + private Contribution contribution; + private Random random = new Random(); + private CompositeDisposable compositeDisposable = new CompositeDisposable(); + private final MediaClient mediaClient; + + ContributionViewHolder(View parent, Callback callback, + MediaClient mediaClient) { + super(parent); + this.mediaClient = mediaClient; + ButterKnife.bind(this, parent); + this.callback=callback; + } + public void init(int position, Contribution contribution) { + this.contribution = contribution; + fetchAndDisplayCaption(contribution); + this.position = position; + String imageSource = chooseImageSource(contribution.getThumbUrl(), contribution.getLocalUri()); + if (!TextUtils.isEmpty(imageSource)) { + final ImageRequest imageRequest = + ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource)) + .setProgressiveRenderingEnabled(true) + .build(); + imageView.setImageRequest(imageRequest); + } - final String imageSource = chooseImageSource(contribution.getMedia().getThumbUrl(), - contribution.getLocalUri()); - if (!TextUtils.isEmpty(imageSource)) { - final ImageRequest imageRequest = - ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource)) - .setProgressiveRenderingEnabled(true) - .build(); - imageView.setImageRequest(imageRequest); + seqNumView.setText(String.valueOf(position + 1)); + seqNumView.setVisibility(View.VISIBLE); + + switch (contribution.getState()) { + case Contribution.STATE_COMPLETED: + stateView.setVisibility(View.GONE); + progressView.setVisibility(View.GONE); + failedImageOptions.setVisibility(View.GONE); + stateView.setText(""); + break; + case Contribution.STATE_QUEUED: + stateView.setVisibility(View.VISIBLE); + progressView.setVisibility(View.GONE); + stateView.setText(R.string.contribution_state_queued); + failedImageOptions.setVisibility(View.GONE); + break; + case Contribution.STATE_IN_PROGRESS: + stateView.setVisibility(View.GONE); + progressView.setVisibility(View.VISIBLE); + failedImageOptions.setVisibility(View.GONE); + long total = contribution.getDataLength(); + long transferred = contribution.getTransferred(); + if (transferred == 0 || transferred >= total) { + progressView.setIndeterminate(true); + } else { + progressView.setProgress((int)(((double)transferred / (double)total) * 100)); + } + break; + case Contribution.STATE_FAILED: + stateView.setVisibility(View.VISIBLE); + stateView.setText(R.string.contribution_state_failed); + progressView.setVisibility(View.GONE); + failedImageOptions.setVisibility(View.VISIBLE); + break; + } } - seqNumView.setText(String.valueOf(position + 1)); - seqNumView.setVisibility(View.VISIBLE); - - addToWikipediaButton.setVisibility(View.GONE); - switch (contribution.getState()) { - case Contribution.STATE_COMPLETED: - stateView.setVisibility(View.GONE); - progressView.setVisibility(View.GONE); - imageOptions.setVisibility(View.GONE); - stateView.setText(""); - checkIfMediaExistsOnWikipediaPage(contribution); - break; - case Contribution.STATE_QUEUED: - case Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE: - stateView.setVisibility(View.VISIBLE); - progressView.setVisibility(View.GONE); - stateView.setText(R.string.contribution_state_queued); - imageOptions.setVisibility(View.GONE); - break; - case Contribution.STATE_IN_PROGRESS: - stateView.setVisibility(View.GONE); - progressView.setVisibility(View.VISIBLE); - addToWikipediaButton.setVisibility(View.GONE); - pauseResumeButton.setVisibility(View.VISIBLE); - cancelButton.setVisibility(View.GONE); - retryButton.setVisibility(View.GONE); - imageOptions.setVisibility(View.VISIBLE); - final long total = contribution.getDataLength(); - final long transferred = contribution.getTransferred(); - if (transferred == 0 || transferred >= total) { - progressView.setIndeterminate(true); + /** + * In contributions first we show the title for the image stored in cache, + * then we fetch captions associated with the image and replace title on the thumbnail with caption + * + * @param contribution + */ + private void fetchAndDisplayCaption(Contribution contribution) { + if ((contribution.getState() != Contribution.STATE_COMPLETED)) { + titleView.setText(contribution.getDisplayTitle()); } else { - progressView.setProgress((int) (((double) transferred / (double) total) * 100)); + final String pageId = contribution.getPageId(); + if (pageId != null) { + Timber.d("Fetching caption for %s", contribution.getFilename()); + String wikibaseMediaId = PAGE_ID_PREFIX + + pageId; // Create Wikibase media id from the page id. Example media id: M80618155 for https://commons.wikimedia.org/wiki/File:Tantanmen.jpeg with has the pageid 80618155 + compositeDisposable.add(mediaClient.getCaptionByWikibaseIdentifier(wikibaseMediaId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(subscriber -> { + if (!subscriber.trim().equals(MediaClient.NO_CAPTION)) { + titleView.setText(subscriber); + } else { + titleView.setText(contribution.getDisplayTitle()); + } + })); + } else { + titleView.setText(contribution.getDisplayTitle()); + } } - break; - case Contribution.STATE_PAUSED: - stateView.setVisibility(View.VISIBLE); - stateView.setText(R.string.paused); - setResume(); - progressView.setVisibility(View.GONE); - cancelButton.setVisibility(View.GONE); - retryButton.setVisibility(View.GONE); - pauseResumeButton.setVisibility(View.VISIBLE); - imageOptions.setVisibility(View.VISIBLE); - break; - case Contribution.STATE_FAILED: - stateView.setVisibility(View.VISIBLE); - stateView.setText(R.string.contribution_state_failed); - progressView.setVisibility(View.GONE); - cancelButton.setVisibility(View.VISIBLE); - retryButton.setVisibility(View.VISIBLE); - pauseResumeButton.setVisibility(View.GONE); - imageOptions.setVisibility(View.VISIBLE); - break; } - } - /** - * Checks if a media exists on the corresponding Wikipedia article Currently the check is made for - * the device's current language Wikipedia - * - * @param contribution - */ - private void checkIfMediaExistsOnWikipediaPage(final Contribution contribution) { - if (contribution.getWikidataPlace() == null - || contribution.getWikidataPlace().getWikipediaArticle() == null) { - return; + /** + * Returns the image source for the image view, first preference is given to thumbUrl if that is + * null, moves to local uri and if both are null return null + * + * @param thumbUrl + * @param localUri + * @return + */ + @Nullable + private String chooseImageSource(String thumbUrl, Uri localUri) { + return !TextUtils.isEmpty(thumbUrl) ? thumbUrl : + localUri != null ? localUri.toString() : + null; } - final String wikipediaArticle = contribution.getWikidataPlace().getWikipediaPageTitle(); - compositeDisposable.add(mediaClient.doesPageContainMedia(wikipediaArticle) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(mediaExists -> { - displayWikipediaButton(mediaExists); - })); - } - /** - * Handle action buttons visibility if the corresponding wikipedia page doesn't contain any media. - * This method needs to control the state of just the scenario where media does not exists as - * other scenarios are already handled in the init method. - * - * @param mediaExists - */ - private void displayWikipediaButton(Boolean mediaExists) { - if (!mediaExists) { - addToWikipediaButton.setVisibility(View.VISIBLE); - isWikipediaButtonDisplayed = true; - cancelButton.setVisibility(View.GONE); - retryButton.setVisibility(View.GONE); - imageOptions.setVisibility(View.VISIBLE); + /** + * Retry upload when it is failed + */ + @OnClick(R.id.retryButton) + public void retryUpload() { + callback.retryUpload(contribution); } - } - - /** - * Returns the image source for the image view, first preference is given to thumbUrl if that is - * null, moves to local uri and if both are null return null - * - * @param thumbUrl - * @param localUri - * @return - */ - @Nullable - private String chooseImageSource(final String thumbUrl, final Uri localUri) { - return !TextUtils.isEmpty(thumbUrl) ? thumbUrl : - localUri != null ? localUri.toString() : - null; - } - - /** - * Retry upload when it is failed - */ - @OnClick(R.id.retryButton) - public void retryUpload() { - callback.retryUpload(contribution); - } - - /** - * Delete a failed upload attempt - */ - @OnClick(R.id.cancelButton) - public void deleteUpload() { - callback.deleteUpload(contribution); - } - @OnClick(R.id.contributionImage) - public void imageClicked() { - callback.openMediaDetail(position, isWikipediaButtonDisplayed); - } - - @OnClick(R.id.wikipediaButton) - public void wikipediaButtonClicked() { - callback.addImageToWikipedia(contribution); - } - - /** - * Triggers a callback for pause/resume - */ - @OnClick(R.id.pauseResumeButton) - public void onPauseResumeButtonClicked() { - if (pauseResumeButton.getTag().toString().equals("pause")) { - callback.pauseUpload(contribution); - setResume(); - } else { - callback.resumeUpload(contribution); - setPaused(); + /** + * Delete a failed upload attempt + */ + @OnClick(R.id.cancelButton) + public void deleteUpload() { + callback.deleteUpload(contribution); } - } - /** - * Update pause/resume button to show pause state - */ - private void setPaused() { - pauseResumeButton.setImageResource(R.drawable.pause_icon); - pauseResumeButton.setTag(R.string.pause); - } - - /** - * Update pause/resume button to show resume state - */ - private void setResume() { - pauseResumeButton.setImageResource(R.drawable.play_icon); - pauseResumeButton.setTag(R.string.resume); - } + @OnClick(R.id.contributionImage) + public void imageClicked(){ + callback.openMediaDetail(position); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java index bb93b5777e6..64b836c39d4 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java @@ -1,7 +1,6 @@ package fr.free.nrw.commons.contributions; import static fr.free.nrw.commons.contributions.Contribution.STATE_FAILED; -import static fr.free.nrw.commons.contributions.Contribution.STATE_PAUSED; import static fr.free.nrw.commons.contributions.MainActivity.CONTRIBUTIONS_TAB_POSITION; import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween; @@ -20,26 +19,20 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager.OnBackStackChangedListener; import androidx.fragment.app.FragmentTransaction; - -import fr.free.nrw.commons.MediaDataExtractor; -import fr.free.nrw.commons.auth.SessionManager; -import io.reactivex.disposables.Disposable; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Named; - import butterknife.BindView; import butterknife.ButterKnife; +import fr.free.nrw.commons.HandlerService; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; import fr.free.nrw.commons.campaigns.Campaign; import fr.free.nrw.commons.campaigns.CampaignView; import fr.free.nrw.commons.campaigns.CampaignsPresenter; import fr.free.nrw.commons.campaigns.ICampaignsView; -import fr.free.nrw.commons.contributions.ContributionsListFragment.Callback; +import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback; +import fr.free.nrw.commons.contributions.ContributionsListFragment.SourceRefresher; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.location.LatLng; @@ -51,6 +44,7 @@ import fr.free.nrw.commons.nearby.NearbyController; import fr.free.nrw.commons.nearby.NearbyNotificationCardView; import fr.free.nrw.commons.nearby.Place; +import fr.free.nrw.commons.settings.Prefs; import fr.free.nrw.commons.upload.UploadService; import fr.free.nrw.commons.utils.ConfigUtils; import fr.free.nrw.commons.utils.DialogUtil; @@ -61,6 +55,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.schedulers.Schedulers; +import java.util.List; import javax.inject.Inject; import javax.inject.Named; import timber.log.Timber; @@ -68,10 +63,11 @@ public class ContributionsFragment extends CommonsDaggerSupportFragment implements + MediaDetailProvider, OnBackStackChangedListener, + SourceRefresher, LocationUpdateListener, - MediaDetailProvider, - ICampaignsView, ContributionsContract.View, Callback { + ICampaignsView, ContributionsContract.View { @Inject @Named("default_preferences") JsonKvStore store; @Inject NearbyController nearbyController; @Inject OkHttpJsonApiClient okHttpJsonApiClient; @@ -83,8 +79,8 @@ public class ContributionsFragment private CompositeDisposable compositeDisposable = new CompositeDisposable(); private ContributionsListFragment contributionsListFragment; - private static final String CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag"; private MediaDetailPagerFragment mediaDetailPagerFragment; + private static final String CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag"; static final String MEDIA_DETAIL_PAGER_FRAGMENT_TAG = "MediaDetailFragmentTag"; @BindView(R.id.card_view_nearby) public NearbyNotificationCardView nearbyNotificationCardView; @@ -92,9 +88,6 @@ public class ContributionsFragment @Inject ContributionsPresenter contributionsPresenter; - @Inject - SessionManager sessionManager; - private LatLng curLatLng; private boolean firstLocationUpdate = true; @@ -109,7 +102,7 @@ public class ContributionsFragment private ServiceConnection uploadServiceConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName componentName, IBinder binder) { - uploadService = (UploadService) ((UploadService.UploadServiceLocalBinder) binder) + uploadService = (UploadService) ((HandlerService.HandlerServiceLocalBinder) binder) .getService(); isUploadServiceConnected = true; } @@ -121,6 +114,7 @@ public void onServiceDisconnected(ComponentName componentName) { } }; private boolean shouldShowMediaDetailsFragment; + private int numberOfContributions; private boolean isAuthCookieAcquired; @Override @@ -135,6 +129,7 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, ButterKnife.bind(this, view); presenter.onAttachView(this); contributionsPresenter.onAttachView(this); + contributionsPresenter.setLifeCycleOwner(this.getViewLifecycleOwner()); campaignView.setVisibility(View.GONE); checkBoxView = View.inflate(getActivity(), R.layout.nearby_permission_dialog, null); checkBox = (CheckBox) checkBoxView.findViewById(R.id.never_ask_again); @@ -147,9 +142,9 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, if (savedInstanceState != null) { mediaDetailPagerFragment = (MediaDetailPagerFragment) getChildFragmentManager() - .findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG); + .findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG); contributionsListFragment = (ContributionsListFragment) getChildFragmentManager() - .findFragmentByTag(CONTRIBUTION_LIST_FRAGMENT_TAG); + .findFragmentByTag(CONTRIBUTION_LIST_FRAGMENT_TAG); shouldShowMediaDetailsFragment = savedInstanceState.getBoolean("mediaDetailsVisible"); } @@ -161,14 +156,89 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, showContributionsListFragment(); } - if (!ConfigUtils.isBetaFlavour() && sessionManager.isUserLoggedIn() - && sessionManager.getCurrentAccount() != null) { + if (!ConfigUtils.isBetaFlavour()) { setUploadCount(); } + getChildFragmentManager().registerFragmentLifecycleCallbacks( + new FragmentManager.FragmentLifecycleCallbacks() { + @Override public void onFragmentResumed(FragmentManager fm, Fragment f) { + super.onFragmentResumed(fm, f); + //If media detail pager fragment is visible, hide the campaigns view [might not be the best way to do, this but yeah, this proves to work for now] + Timber.e("onFragmentResumed %s", f.getClass().getName()); + if (f instanceof MediaDetailPagerFragment) { + campaignView.setVisibility(View.GONE); + } + } + + @Override public void onFragmentDetached(FragmentManager fm, Fragment f) { + super.onFragmentDetached(fm, f); + Timber.e("onFragmentDetached %s", f.getClass().getName()); + //If media detail pager fragment is detached, ContributionsList fragment is gonna be visible, [becomes tightly coupled though] + if (f instanceof MediaDetailPagerFragment) { + fetchCampaigns(); + } + } + }, true); + return view; } + /** + * Initialose the ContributionsListFragment and MediaDetailPagerFragment fragment + */ + private void initFragments() { + if (null == contributionsListFragment) { + contributionsListFragment = new ContributionsListFragment(); + } + + contributionsListFragment.setCallback(new Callback() { + @Override + public void retryUpload(Contribution contribution) { + ContributionsFragment.this.retryUpload(contribution); + } + + @Override + public void deleteUpload(Contribution contribution) { + contributionsPresenter.deleteUpload(contribution); + } + + @Override + public void openMediaDetail(int position) { + showDetail(position); + } + + @Override + public Contribution getContributionForPosition(int position) { + return (Contribution) contributionsPresenter.getItemAtPosition(position); + } + + @Override + public void fetchMediaUriFor(Contribution contribution) { + Timber.d("Fetching thumbnail for %s", contribution.getFilename()); + contributionsPresenter.fetchMediaDetails(contribution); + } + }); + + if(null==mediaDetailPagerFragment){ + mediaDetailPagerFragment=new MediaDetailPagerFragment(); + } + } + + + /** + * Replaces the root frame layout with the given fragment + * @param fragment + * @param tag + */ + private void showFragment(Fragment fragment, String tag) { + FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); + transaction.replace(R.id.root_frame, fragment, tag); + transaction.addToBackStack(CONTRIBUTION_LIST_FRAGMENT_TAG); + transaction.commit(); + getChildFragmentManager().executePendingTransactions(); + } + @Override public void onAttach(Context context) { super.onAttach(context); @@ -196,7 +266,7 @@ private void showContributionsListFragment() { if (nearbyNotificationCardView != null) { if (store.getBoolean("displayNearbyCardView", true)) { if (nearbyNotificationCardView.cardViewVisibilityState - == NearbyNotificationCardView.CardViewVisibilityState.READY) { + == NearbyNotificationCardView.CardViewVisibilityState.READY) { nearbyNotificationCardView.setVisibility(View.VISIBLE); } } else { @@ -206,20 +276,18 @@ private void showContributionsListFragment() { showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG); } + /** + * Replace FrameLayout with MediaDetailPagerFragment, user will see details of selected media. + * Creates new one if null. + */ private void showMediaDetailPagerFragment() { // hide tabs on media detail view is visible - ((MainActivity) getActivity()).hideTabs(); + ((MainActivity)getActivity()).hideTabs(); // hide nearby card view on media detail is visible nearbyNotificationCardView.setVisibility(View.GONE); - showFragment(mediaDetailPagerFragment, MEDIA_DETAIL_PAGER_FRAGMENT_TAG); - - } + showFragment(mediaDetailPagerFragment,MEDIA_DETAIL_PAGER_FRAGMENT_TAG); - private void setupViewForMediaDetails() { - campaignView.setVisibility(View.GONE); - nearbyNotificationCardView.setVisibility(View.GONE); - ((MainActivity)getActivity()).hideTabs(); } @Override @@ -240,42 +308,43 @@ void onAuthCookieAcquired() { } - private void initFragments() { - if (null == contributionsListFragment) { - contributionsListFragment = new ContributionsListFragment(); - } + public Intent getUploadServiceIntent(){ + Intent intent = new Intent(getActivity(), UploadService.class); + intent.setAction(UploadService.ACTION_START_SERVICE); + return intent; + } - if (shouldShowMediaDetailsFragment) { + /** + * Replace whatever is in the current contributionsFragmentContainer view with + * mediaDetailPagerFragment, and preserve previous state in back stack. + * Called when user selects a contribution. + */ + private void showDetail(int i) { + if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment.isVisible()) { + mediaDetailPagerFragment = new MediaDetailPagerFragment(); showMediaDetailPagerFragment(); - } else { - showContributionsListFragment(); } + mediaDetailPagerFragment.showImage(i); + } - showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG); + @Override + public void refreshSource() { + contributionsPresenter.fetchContributions(); } - /** - * Replaces the root frame layout with the given fragment - * - * @param fragment - * @param tag - */ - private void showFragment(Fragment fragment, String tag) { - FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); - transaction.replace(R.id.root_frame, fragment, tag); - transaction.addToBackStack(CONTRIBUTION_LIST_FRAGMENT_TAG); - transaction.commit(); - getChildFragmentManager().executePendingTransactions(); + @Override + public Media getMediaAtPosition(int i) { + return contributionsPresenter.getItemAtPosition(i); } - public Intent getUploadServiceIntent(){ - Intent intent = new Intent(getActivity(), UploadService.class); - intent.setAction(UploadService.ACTION_START_SERVICE); - return intent; + @Override + public int getTotalMediaCount() { + return numberOfContributions; } @SuppressWarnings("ConstantConditions") private void setUploadCount() { + compositeDisposable.add(okHttpJsonApiClient .getUploadCount(((MainActivity)getActivity()).sessionManager.getCurrentAccount().name) .subscribeOn(Schedulers.io()) @@ -305,6 +374,8 @@ public void onPause() { @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); + boolean mediaDetailsVisible = mediaDetailPagerFragment != null && mediaDetailPagerFragment.isVisible(); + outState.putBoolean("mediaDetailsVisible", mediaDetailsVisible); } @Override @@ -314,6 +385,13 @@ public void onResume() { firstLocationUpdate = true; locationManager.addLocationListener(this); + boolean isSettingsChanged = store.getBoolean(Prefs.IS_CONTRIBUTION_COUNT_CHANGED, false); + store.putBoolean(Prefs.IS_CONTRIBUTION_COUNT_CHANGED, false); + if (isSettingsChanged) { + refreshSource(); + } + + if (store.getBoolean("displayNearbyCardView", true)) { checkPermissionsAndShowNearbyCardView(); if (nearbyNotificationCardView.cardViewVisibilityState == NearbyNotificationCardView.CardViewVisibilityState.READY) { @@ -326,6 +404,10 @@ public void onResume() { } fetchCampaigns(); + if(isAuthCookieAcquired){ + contributionsPresenter.fetchContributions(); + } + } private void checkPermissionsAndShowNearbyCardView() { @@ -382,11 +464,17 @@ private void updateClosestNearbyCardViewInfo() { } private void updateNearbyNotification(@Nullable NearbyController.NearbyPlacesInfo nearbyPlacesInfo) { + if (nearbyPlacesInfo != null && nearbyPlacesInfo.placeList != null && nearbyPlacesInfo.placeList.size() > 0) { Place closestNearbyPlace = nearbyPlacesInfo.placeList.get(0); String distance = formatDistanceBetween(curLatLng, closestNearbyPlace.location); closestNearbyPlace.setDistance(distance); nearbyNotificationCardView.updateContent(closestNearbyPlace); + if (mediaDetailPagerFragment != null && mediaDetailPagerFragment.isVisible()) { + nearbyNotificationCardView.setVisibility(View.GONE); + }else { + nearbyNotificationCardView.setVisibility(View.VISIBLE); + } } else { // Means that no close nearby place is found nearbyNotificationCardView.setVisibility(View.GONE); @@ -466,62 +554,48 @@ private void fetchCampaigns() { presenter.onDetachView(); } - /** - * Retry upload when it is failed - * - * @param contribution contribution to be retried - */ @Override - public void retryUpload(Contribution contribution) { - if (NetworkUtils.isInternetConnectionEstablished(getContext())) { - if (contribution.getState() == STATE_FAILED || contribution.getState() == STATE_PAUSED || contribution.getState()==Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE && null != uploadService) { - uploadService.queue(contribution); - Timber.d("Restarting for %s", contribution.toString()); - } else { - Timber.d("Skipping re-upload for non-failed %s", contribution.toString()); - } - } else { - ViewUtil.showLongToast(getContext(), R.string.this_function_needs_network_connection); - } - + public void showWelcomeTip(boolean shouldShow) { + contributionsListFragment.showWelcomeTip(shouldShow); } - /** - * Pauses the upload - * @param contribution - */ @Override - public void pauseUpload(Contribution contribution) { - uploadService.pauseUpload(contribution); + public void showProgress(boolean shouldShow) { + contributionsListFragment.showProgress(shouldShow); } - /** - * Replace whatever is in the current contributionsFragmentContainer view with - * mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects a - * contribution. - */ @Override - public void showDetail(int position, boolean isWikipediaButtonDisplayed) { - if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment.isVisible()) { - mediaDetailPagerFragment = new MediaDetailPagerFragment(); - showMediaDetailPagerFragment(); - } - mediaDetailPagerFragment.showImage(position, isWikipediaButtonDisplayed); + public void showNoContributionsUI(boolean shouldShow) { + contributionsListFragment.showNoContributionsUI(shouldShow); } @Override - public Media getMediaAtPosition(int i) { - return contributionsListFragment.getMediaAtPosition(i); + public void setUploadCount(int count) { + this.numberOfContributions=count; } @Override - public int getTotalMediaCount() { - return contributionsListFragment.getTotalMediaCount(); + public void showContributions(List contributionList) { + contributionsListFragment.setContributions(contributionList); } - @Override - public Integer getContributionStateAt(int position) { - return contributionsListFragment.getContributionStateAt(position); + /** + * Retry upload when it is failed + * + * @param contribution contribution to be retried + */ + private void retryUpload(Contribution contribution) { + if (NetworkUtils.isInternetConnectionEstablished(getContext())) { + if (contribution.getState() == STATE_FAILED && null != uploadService) { + uploadService.queue(UploadService.ACTION_UPLOAD_FILE, contribution); + Timber.d("Restarting for %s", contribution.toString()); + } else { + Timber.d("Skipping re-upload for non-failed %s", contribution.toString()); + } + } else { + ViewUtil.showLongToast(getContext(), R.string.this_function_needs_network_connection); + } + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java index e6db7e3de28..82daf97f4d1 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java @@ -1,71 +1,68 @@ - package fr.free.nrw.commons.contributions; +package fr.free.nrw.commons.contributions; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; -import androidx.paging.PagedListAdapter; -import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; import fr.free.nrw.commons.R; import fr.free.nrw.commons.media.MediaClient; +import java.util.ArrayList; +import java.util.List; - /** - * Represents The View Adapter for the List of Contributions +/** + * Represents The View Adapter for the List of Contributions */ -public class ContributionsListAdapter extends - PagedListAdapter { +public class ContributionsListAdapter extends RecyclerView.Adapter { - private final Callback callback; + private Callback callback; private final MediaClient mediaClient; + private List contributions; - ContributionsListAdapter(final Callback callback, - final MediaClient mediaClient) { - super(DIFF_CALLBACK); + public ContributionsListAdapter(Callback callback, + MediaClient mediaClient) { this.callback = callback; this.mediaClient = mediaClient; + contributions = new ArrayList<>(); } /** - * Uses DiffUtil to calculate the changes in the list - * It has methods that check ID and the content of the items to determine if its a new item + * Creates the new View Holder which will be used to display items(contributions) + * using the onBindViewHolder(viewHolder,position) */ - private static final DiffUtil.ItemCallback DIFF_CALLBACK = - new DiffUtil.ItemCallback() { - @Override - public boolean areItemsTheSame(final Contribution oldContribution, final Contribution newContribution) { - return oldContribution.getPageId().equals(newContribution.getPageId()); - } - - @Override - public boolean areContentsTheSame(final Contribution oldContribution, final Contribution newContribution) { - return oldContribution.equals(newContribution); - } - }; + @NonNull + @Override + public ContributionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + ContributionViewHolder viewHolder = new ContributionViewHolder( + LayoutInflater.from(parent.getContext()) + .inflate(R.layout.layout_contribution, parent, false), callback, mediaClient); + return viewHolder; + } - /** - * Initializes the view holder with contribution data - */ @Override public void onBindViewHolder(@NonNull ContributionViewHolder holder, int position) { - holder.init(position, getItem(position)); + final Contribution contribution = contributions.get(position); + if (TextUtils.isEmpty(contribution.getThumbUrl()) + && contribution.getState() == Contribution.STATE_COMPLETED) { + callback.fetchMediaUriFor(contribution); + } + + holder.init(position, contribution); } - Contribution getContributionForPosition(final int position) { - return getItem(position); + @Override + public int getItemCount() { + return contributions.size(); + } + + public void setContributions(@NonNull List contributionList) { + contributions = contributionList; + notifyDataSetChanged(); } - /** - * Creates the new View Holder which will be used to display items(contributions) using the - * onBindViewHolder(viewHolder,position) - */ - @NonNull @Override - public ContributionViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, - final int viewType) { - final ContributionViewHolder viewHolder = new ContributionViewHolder( - LayoutInflater.from(parent.getContext()) - .inflate(R.layout.layout_contribution, parent, false), - callback, mediaClient); - return viewHolder; + public long getItemId(int position) { + return contributions.get(position).get_id(); } public interface Callback { @@ -74,12 +71,10 @@ public interface Callback { void deleteUpload(Contribution contribution); - void openMediaDetail(int contribution, boolean isWikipediaPageExists); - - void addImageToWikipedia(Contribution contribution); + void openMediaDetail(int contribution); - void pauseUpload(Contribution contribution); + Contribution getContributionForPosition(int position); - void resumeUpload(Contribution contribution); + void fetchMediaUriFor(Contribution contribution); } } 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 4a435b28f34..d7f813c6db3 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 @@ -2,13 +2,9 @@ import static android.view.View.GONE; import static android.view.View.VISIBLE; -import static fr.free.nrw.commons.di.NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE; -import android.content.Context; import android.content.res.Configuration; -import android.net.Uri; import android.os.Bundle; -import android.os.Parcelable; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -19,322 +15,219 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.LayoutManager; import butterknife.BindView; import butterknife.ButterKnife; import com.google.android.material.floatingactionbutton.FloatingActionButton; -import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; +import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.utils.DialogUtil; +import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.media.MediaClient; -import java.util.Locale; +import java.util.ArrayList; +import java.util.List; import javax.inject.Inject; import javax.inject.Named; -import org.wikipedia.dataclient.WikiSite; -import timber.log.Timber; /** * Created by root on 01.06.2018. */ -public class ContributionsListFragment extends CommonsDaggerSupportFragment implements - ContributionsListContract.View, ContributionsListAdapter.Callback, - WikipediaInstructionsDialogFragment.Callback { - - private static final String RV_STATE = "rv_scroll_state"; - - @BindView(R.id.contributionsList) - RecyclerView rvContributionsList; - @BindView(R.id.loadingContributionsProgressBar) - ProgressBar progressBar; - @BindView(R.id.fab_plus) - FloatingActionButton fabPlus; - @BindView(R.id.fab_camera) - FloatingActionButton fabCamera; - @BindView(R.id.fab_gallery) - FloatingActionButton fabGallery; - @BindView(R.id.noContributionsYet) - TextView noContributionsYet; - @BindView(R.id.fab_layout) - LinearLayout fab_layout; - - @Inject - ContributionController controller; - @Inject - MediaClient mediaClient; - - @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) - @Inject - WikiSite languageWikipediaSite; - - @Inject - ContributionsListPresenter contributionsListPresenter; - - private Animation fab_close; - private Animation fab_open; - private Animation rotate_forward; - private Animation rotate_backward; - - - private boolean isFabOpen; - - private ContributionsListAdapter adapter; - - private Callback callback; - - private final int SPAN_COUNT_LANDSCAPE = 3; - private final int SPAN_COUNT_PORTRAIT = 1; - - - @Override - public View onCreateView( - final LayoutInflater inflater, @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - final View view = inflater.inflate(R.layout.fragment_contributions_list, container, false); - ButterKnife.bind(this, view); - contributionsListPresenter.onAttachView(this); - initAdapter(); - return view; - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - if (getParentFragment() != null && getParentFragment() instanceof ContributionsFragment) { - callback = ((ContributionsFragment) getParentFragment()); +public class ContributionsListFragment extends CommonsDaggerSupportFragment { + + private static final String VISIBLE_ITEM_ID = "visible_item_id"; + @BindView(R.id.contributionsList) + RecyclerView rvContributionsList; + @BindView(R.id.loadingContributionsProgressBar) + ProgressBar progressBar; + @BindView(R.id.fab_plus) + FloatingActionButton fabPlus; + @BindView(R.id.fab_camera) + FloatingActionButton fabCamera; + @BindView(R.id.fab_gallery) + FloatingActionButton fabGallery; + @BindView(R.id.noContributionsYet) + TextView noContributionsYet; + @BindView(R.id.fab_layout) + LinearLayout fab_layout; + + @Inject @Named("default_preferences") JsonKvStore kvStore; + @Inject ContributionController controller; + @Inject MediaClient mediaClient; + + private Animation fab_close; + private Animation fab_open; + private Animation rotate_forward; + private Animation rotate_backward; + + + private boolean isFabOpen = false; + + private ContributionsListAdapter adapter; + + private Callback callback; + private String lastVisibleItemID; + + private int SPAN_COUNT=3; + private List contributions=new ArrayList<>(); + + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_contributions_list, container, false); + ButterKnife.bind(this, view); + initAdapter(); + return view; } - } - - @Override - public void onDetach() { - super.onDetach(); - callback = null;//To avoid possible memory leak - } - - private void initAdapter() { - adapter = new ContributionsListAdapter(this, mediaClient); - } - - @Override - public void onViewCreated(final View view, @Nullable final Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - initRecyclerView(); - initializeAnimations(); - setListeners(); - } - - private void initRecyclerView() { - final GridLayoutManager layoutManager = new GridLayoutManager(getContext(), - getSpanCount(getResources().getConfiguration().orientation)); - rvContributionsList.setLayoutManager(layoutManager); - contributionsListPresenter.setup(); - contributionsListPresenter.contributionList.observe(this.getViewLifecycleOwner(), adapter::submitList); - rvContributionsList.setAdapter(adapter); - } - - private int getSpanCount(final int orientation) { - return orientation == Configuration.ORIENTATION_LANDSCAPE ? - SPAN_COUNT_LANDSCAPE : SPAN_COUNT_PORTRAIT; - } - - @Override - public void onConfigurationChanged(final Configuration newConfig) { - super.onConfigurationChanged(newConfig); - // check orientation - fab_layout.setOrientation(newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ? - LinearLayout.HORIZONTAL : LinearLayout.VERTICAL); - rvContributionsList - .setLayoutManager(new GridLayoutManager(getContext(), getSpanCount(newConfig.orientation))); - } - - private void initializeAnimations() { - fab_open = AnimationUtils.loadAnimation(getActivity(), R.anim.fab_open); - fab_close = AnimationUtils.loadAnimation(getActivity(), R.anim.fab_close); - rotate_forward = AnimationUtils.loadAnimation(getActivity(), R.anim.rotate_forward); - rotate_backward = AnimationUtils.loadAnimation(getActivity(), R.anim.rotate_backward); - } - - private void setListeners() { - fabPlus.setOnClickListener(view -> animateFAB(isFabOpen)); - fabCamera.setOnClickListener(view -> { - controller.initiateCameraPick(getActivity()); - animateFAB(isFabOpen); - }); - fabGallery.setOnClickListener(view -> { - controller.initiateGalleryPick(getActivity(), true); - animateFAB(isFabOpen); - }); - } - - private void animateFAB(final boolean isFabOpen) { - this.isFabOpen = !isFabOpen; - if (fabPlus.isShown()) { - if (isFabOpen) { - fabPlus.startAnimation(rotate_backward); - fabCamera.startAnimation(fab_close); - fabGallery.startAnimation(fab_close); - fabCamera.hide(); - fabGallery.hide(); - } else { - fabPlus.startAnimation(rotate_forward); - fabCamera.startAnimation(fab_open); - fabGallery.startAnimation(fab_open); - fabCamera.show(); - fabGallery.show(); - } - this.isFabOpen = !isFabOpen; + + public void setCallback(Callback callback) { + this.callback = callback; + } + + private void initAdapter() { + adapter = new ContributionsListAdapter(callback, mediaClient); + adapter.setHasStableIds(true); } - } - - /** - * Shows welcome message if user has no contributions yet i.e. new user. - */ - @Override - public void showWelcomeTip(final boolean shouldShow) { - noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE); - } - - /** - * Responsible to set progress bar invisible and visible - * - * @param shouldShow True when contributions list should be hidden. - */ - @Override - public void showProgress(final boolean shouldShow) { - progressBar.setVisibility(shouldShow ? VISIBLE : GONE); - } - - @Override - public void showNoContributionsUI(final boolean shouldShow) { - noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE); - } - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - final GridLayoutManager layoutManager = (GridLayoutManager) rvContributionsList - .getLayoutManager(); - outState.putParcelable(RV_STATE, layoutManager.onSaveInstanceState()); - } - - @Override - public void onViewStateRestored(@Nullable Bundle savedInstanceState) { - super.onViewStateRestored(savedInstanceState); - if (null != savedInstanceState) { - final Parcelable savedRecyclerLayoutState = savedInstanceState.getParcelable(RV_STATE); - rvContributionsList.getLayoutManager().onRestoreInstanceState(savedRecyclerLayoutState); + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + initRecyclerView(); + initializeAnimations(); + setListeners(); } - } - @Override - public void retryUpload(final Contribution contribution) { - if (null != callback) {//Just being safe, ideally they won't be called when detached - callback.retryUpload(contribution); + private void initRecyclerView() { + if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { + rvContributionsList.setLayoutManager(new GridLayoutManager(getContext(),SPAN_COUNT)); + } else { + rvContributionsList.setLayoutManager(new LinearLayoutManager(getContext())); + } + + rvContributionsList.setAdapter(adapter); + adapter.setContributions(contributions); } - } - @Override - public void deleteUpload(final Contribution contribution) { - contributionsListPresenter.deleteUpload(contribution); - } + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + // check orientation + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { + fab_layout.setOrientation(LinearLayout.HORIZONTAL); + rvContributionsList.setLayoutManager(new GridLayoutManager(getContext(),SPAN_COUNT)); + } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + fab_layout.setOrientation(LinearLayout.VERTICAL); + rvContributionsList.setLayoutManager(new LinearLayoutManager(getContext())); + } + } - @Override - public void openMediaDetail(final int position, boolean isWikipediaButtonDisplayed) { - if (null != callback) {//Just being safe, ideally they won't be called when detached - callback.showDetail(position, isWikipediaButtonDisplayed); + private void initializeAnimations() { + fab_open = AnimationUtils.loadAnimation(getActivity(), R.anim.fab_open); + fab_close = AnimationUtils.loadAnimation(getActivity(), R.anim.fab_close); + rotate_forward = AnimationUtils.loadAnimation(getActivity(), R.anim.rotate_forward); + rotate_backward = AnimationUtils.loadAnimation(getActivity(), R.anim.rotate_backward); } - } - - /** - * Handle callback for wikipedia icon clicked - * - * @param contribution - */ - @Override - public void addImageToWikipedia(Contribution contribution) { - DialogUtil.showAlertDialog(getActivity(), - getString(R.string.add_picture_to_wikipedia_article_title), - String.format(getString(R.string.add_picture_to_wikipedia_article_desc), - Locale.getDefault().getDisplayLanguage()), - () -> { - showAddImageToWikipediaInstructions(contribution); - }, () -> { - // do nothing + + private void setListeners() { + fabPlus.setOnClickListener(view -> animateFAB(isFabOpen)); + fabCamera.setOnClickListener(view -> { + controller.initiateCameraPick(getActivity()); + animateFAB(isFabOpen); + }); + fabGallery.setOnClickListener(view -> { + controller.initiateGalleryPick(getActivity(), true); + animateFAB(isFabOpen); }); - } - - /** - * Pauses the current upload - * @param contribution - */ - @Override - public void pauseUpload(Contribution contribution) { - callback.pauseUpload(contribution); - } - - /** - * Resumes the current upload - * @param contribution - */ - @Override - public void resumeUpload(Contribution contribution) { - callback.retryUpload(contribution); - } - - /** - * Display confirmation dialog with instructions when the user tries to add image to wikipedia - * - * @param contribution - */ - private void showAddImageToWikipediaInstructions(Contribution contribution) { - FragmentManager fragmentManager = getFragmentManager(); - WikipediaInstructionsDialogFragment fragment = WikipediaInstructionsDialogFragment - .newInstance(contribution); - fragment.setCallback(this::onConfirmClicked); - fragment.show(fragmentManager, "WikimediaFragment"); - } - - - public Media getMediaAtPosition(final int i) { - return adapter.getContributionForPosition(i).getMedia(); - } - - public int getTotalMediaCount() { - return adapter.getItemCount(); - } - - /** - * Open the editor for the language Wikipedia - * - * @param contribution - */ - @Override - public void onConfirmClicked(@Nullable Contribution contribution, boolean copyWikicode) { - if (copyWikicode) { - String wikicode = contribution.getMedia().getWikiCode(); - Utils.copy("wikicode", wikicode, getContext()); } - final String url = - languageWikipediaSite.mobileUrl() + "/wiki/" + contribution.getWikidataPlace() - .getWikipediaPageTitle(); - Utils.handleWebUrl(getContext(), Uri.parse(url)); - } + private void animateFAB(boolean isFabOpen) { + this.isFabOpen = !isFabOpen; + if (fabPlus.isShown()){ + if (isFabOpen) { + fabPlus.startAnimation(rotate_backward); + fabCamera.startAnimation(fab_close); + fabGallery.startAnimation(fab_close); + fabCamera.hide(); + fabGallery.hide(); + } else { + fabPlus.startAnimation(rotate_forward); + fabCamera.startAnimation(fab_open); + fabGallery.startAnimation(fab_open); + fabCamera.show(); + fabGallery.show(); + } + this.isFabOpen=!isFabOpen; + } + } + + /** + * Shows welcome message if user has no contributions yet i.e. new user. + */ + public void showWelcomeTip(boolean shouldShow) { + noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE); + } + + /** + * Responsible to set progress bar invisible and visible + * + * @param shouldShow True when contributions list should be hidden. + */ + public void showProgress(boolean shouldShow) { + progressBar.setVisibility(shouldShow ? VISIBLE : GONE); + } - public Integer getContributionStateAt(int position) { - return adapter.getContributionForPosition(position).getState(); - } + public void showNoContributionsUI(boolean shouldShow) { + noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE); + } - public interface Callback { + public void setContributions(List contributionList) { + this.contributions.clear(); + this.contributions.addAll(contributionList); + adapter.setContributions(contributions); + } - void retryUpload(Contribution contribution); + public interface SourceRefresher { + void refreshSource(); + } - void showDetail(int position, boolean isWikipediaButtonDisplayed); + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + LayoutManager layoutManager = rvContributionsList.getLayoutManager(); + int lastVisibleItemPosition=0; + if(layoutManager instanceof LinearLayoutManager){ + lastVisibleItemPosition= ((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition(); + }else if(layoutManager instanceof GridLayoutManager){ + lastVisibleItemPosition=((GridLayoutManager)layoutManager).findLastCompletelyVisibleItemPosition(); + } + String idOfItemWithPosition = findIdOfItemWithPosition(lastVisibleItemPosition); + if (null != idOfItemWithPosition) { + outState.putString(VISIBLE_ITEM_ID, idOfItemWithPosition); + } + } + + @Override + public void onViewStateRestored(@Nullable Bundle savedInstanceState) { + super.onViewStateRestored(savedInstanceState); + if(null!=savedInstanceState){ + lastVisibleItemID =savedInstanceState.getString(VISIBLE_ITEM_ID, null); + } + } + + + /** + * Gets the id of the contribution from the db + * @param position + * @return + */ + @Nullable + private String findIdOfItemWithPosition(int position) { + Contribution contributionForPosition = callback.getContributionForPosition(position); + if (null != contributionForPosition) { + return contributionForPosition.getFilename(); + } + return null; + } - void pauseUpload(Contribution contribution); - } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java index 2cae4f04c5f..310699cd158 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java @@ -1,12 +1,29 @@ package fr.free.nrw.commons.contributions; +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import fr.free.nrw.commons.CommonsApplication; +import fr.free.nrw.commons.Media; import fr.free.nrw.commons.MediaDataExtractor; +import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.contributions.ContributionsContract.UserActionListener; +import fr.free.nrw.commons.db.AppDatabase; import fr.free.nrw.commons.di.CommonsApplicationModule; +import fr.free.nrw.commons.mwapi.UserClient; +import fr.free.nrw.commons.utils.NetworkUtils; import io.reactivex.Scheduler; +import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import javax.inject.Inject; import javax.inject.Named; +import timber.log.Timber; /** * The presenter class for Contributions @@ -18,10 +35,25 @@ public class ContributionsPresenter implements UserActionListener { private final Scheduler ioThreadScheduler; private CompositeDisposable compositeDisposable; private ContributionsContract.View view; + private List contributionList=new ArrayList<>(); + + @Inject + Context context; + + @Inject + UserClient userClient; + + @Inject + AppDatabase appDatabase; + + @Inject + SessionManager sessionManager; @Inject MediaDataExtractor mediaDataExtractor; + private LifecycleOwner lifeCycleOwner; + @Inject ContributionsPresenter(ContributionsRepository repository, @Named(CommonsApplicationModule.MAIN_THREAD) Scheduler mainThreadScheduler,@Named(CommonsApplicationModule.IO_THREAD) Scheduler ioThreadScheduler) { this.repository = repository; @@ -29,12 +61,74 @@ public class ContributionsPresenter implements UserActionListener { this.ioThreadScheduler=ioThreadScheduler; } + private String user; + @Override public void onAttachView(ContributionsContract.View view) { this.view = view; compositeDisposable=new CompositeDisposable(); } + public void setLifeCycleOwner(LifecycleOwner lifeCycleOwner){ + this.lifeCycleOwner=lifeCycleOwner; + } + + public void fetchContributions() { + Timber.d("fetch Contributions"); + LiveData> liveDataContributions = repository.fetchContributions(); + if(null!=lifeCycleOwner) { + liveDataContributions.observe(lifeCycleOwner, this::showContributions); + } + + if (NetworkUtils.isInternetConnectionEstablished(CommonsApplication.getInstance()) && shouldFetchContributions()) { + Timber.d("fetching contributions: "); + view.showProgress(true); + this.user = sessionManager.getUserName(); + view.showContributions(Collections.emptyList()); + compositeDisposable.add(userClient.logEvents(user) + .subscribeOn(ioThreadScheduler) + .observeOn(mainThreadScheduler) + .doOnNext(mwQueryLogEvent -> Timber.d("Received image %s", mwQueryLogEvent.title())) + .filter(mwQueryLogEvent -> !mwQueryLogEvent.isDeleted()).doOnNext(mwQueryLogEvent -> Timber.d("Image %s passed filters", mwQueryLogEvent.title())) + .map(image -> new Contribution(image, user)) + .toList() + .subscribe(this::saveContributionsToDB, error -> { + Timber.e("Failed to fetch contributions: %s", error.getMessage()); + })); + } + } + + private void showContributions(@NonNull List contributions) { + view.showProgress(false); + if (contributions.isEmpty()) { + view.showWelcomeTip(true); + view.showNoContributionsUI(true); + } else { + view.showWelcomeTip(false); + view.showNoContributionsUI(false); + view.setUploadCount(contributions.size()); + view.showContributions(contributions); + this.contributionList.clear(); + this.contributionList.addAll(contributions); + } + } + + private void saveContributionsToDB(List contributions) { + Timber.e("Fetched: "+contributions.size()+" contributions "+" saving to db"); + repository.save(contributions).subscribeOn(ioThreadScheduler).subscribe(); + repository.set("last_fetch_timestamp",System.currentTimeMillis()); + } + + private boolean shouldFetchContributions() { + long lastFetchTimestamp = repository.getLong("last_fetch_timestamp"); + Timber.d("last fetch timestamp: %s", lastFetchTimestamp); + if(lastFetchTimestamp!=0){ + return System.currentTimeMillis()-lastFetchTimestamp>15*60*100; + } + Timber.d("should fetch contributions: %s", true); + return true; + } + @Override public void onDetachView() { this.view = null; @@ -52,9 +146,43 @@ public Contribution getContributionsWithTitle(String title) { */ @Override public void deleteUpload(Contribution contribution) { + compositeDisposable.add(repository.deleteContributionFromDB(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe()); + } + + /** + * Returns a contribution at the specified cursor position + * + * @param i + * @return + */ + @Nullable + @Override + public Media getItemAtPosition(int i) { + if (i == -1 || contributionList.size() < i+1) { + return null; + } + return contributionList.get(i); + } + + @Override + public void updateContribution(Contribution contribution) { compositeDisposable.add(repository - .deleteContributionFromDB(contribution) + .updateContribution(contribution) .subscribeOn(ioThreadScheduler) .subscribe()); } + + @Override + public void fetchMediaDetails(Contribution contribution) { + compositeDisposable.add(mediaDataExtractor + .getMediaFromFileName(contribution.getFilename()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(media -> { + contribution.setThumbUrl(media.getThumbUrl()); + updateContribution(contribution); + })); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt index 02975f3e175..01095dd7ceb 100644 --- a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt +++ b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt @@ -10,7 +10,7 @@ import fr.free.nrw.commons.contributions.ContributionDao * The database for accessing the respective DAOs * */ -@Database(entities = [Contribution::class], version = 5, exportSchema = false) +@Database(entities = [Contribution::class], version = 1, exportSchema = false) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun contributionDao(): ContributionDao diff --git a/app/src/main/java/fr/free/nrw/commons/db/Converters.java b/app/src/main/java/fr/free/nrw/commons/db/Converters.java index 3156f5e2dca..94311c67c57 100644 --- a/app/src/main/java/fr/free/nrw/commons/db/Converters.java +++ b/app/src/main/java/fr/free/nrw/commons/db/Converters.java @@ -5,9 +5,9 @@ import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.contributions.ChunkInfo; import fr.free.nrw.commons.di.ApplicationlessInjection; import fr.free.nrw.commons.location.LatLng; +import fr.free.nrw.commons.media.Depictions; import fr.free.nrw.commons.upload.WikidataPlace; import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; import java.util.Date; @@ -84,23 +84,23 @@ public static WikidataPlace stringToWikidataPlace(String wikidataPlace) { } @TypeConverter - public static String chunkInfoToString(ChunkInfo chunkInfo) { - return writeObjectToString(chunkInfo); + public static String depictionListToString(List depictedItems) { + return writeObjectToString(depictedItems); } @TypeConverter - public static ChunkInfo stringToChunkInfo(String chunkInfo) { - return readObjectFromString(chunkInfo, ChunkInfo.class); + public static List stringToList(String depictedItems) { + return readObjectWithTypeToken(depictedItems, new TypeToken>() {}); } @TypeConverter - public static String depictionListToString(List depictedItems) { + public static String depictionsToString(Depictions depictedItems) { return writeObjectToString(depictedItems); } @TypeConverter - public static List stringToList(String depictedItems) { - return readObjectWithTypeToken(depictedItems, new TypeToken>() {}); + public static Depictions stringToDepictions(String depictedItems) { + return readObjectFromString(depictedItems, Depictions.class); } private static String writeObjectToString(Object object) { diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/DepictionModule.java b/app/src/main/java/fr/free/nrw/commons/depictions/DepictionModule.java new file mode 100644 index 00000000000..a64d829670d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/DepictionModule.java @@ -0,0 +1,27 @@ +package fr.free.nrw.commons.depictions; + +import dagger.Binds; +import dagger.Module; +import fr.free.nrw.commons.depictions.Media.DepictedImagesContract; +import fr.free.nrw.commons.depictions.Media.DepictedImagesPresenter; +import fr.free.nrw.commons.depictions.subClass.SubDepictionListContract; +import fr.free.nrw.commons.depictions.subClass.SubDepictionListPresenter; + +/** + * The Dagger Module for explore:depictions related presenters and (some other objects maybe in future) + */ +@Module +public abstract class DepictionModule { + + @Binds + public abstract DepictedImagesContract.UserActionListener bindsDepictedImagesPresenter( + DepictedImagesPresenter + presenter + ); + + @Binds + public abstract SubDepictionListContract.UserActionListener bindsSubDepictionListPresenter( + SubDepictionListPresenter + presenter + ); +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/GridViewAdapter.java b/app/src/main/java/fr/free/nrw/commons/depictions/GridViewAdapter.java new file mode 100644 index 00000000000..56cb7372860 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/GridViewAdapter.java @@ -0,0 +1,119 @@ +package fr.free.nrw.commons.depictions; + +import android.content.Context; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import com.facebook.drawee.view.SimpleDraweeView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.R; + +/** + * Adapter for Items in DepictionDetailsActivity + */ +public class GridViewAdapter extends ArrayAdapter { + + private List data; + + public GridViewAdapter(Context context, int layoutResourceId, List data) { + super(context, layoutResourceId, data); + this.data = data; + } + + /** + * Adds more item to the list + * Its triggered on scrolling down in the list + * @param images + */ + public void addItems(List images) { + if (data == null) { + data = new ArrayList<>(); + } + data.addAll(images); + notifyDataSetChanged(); + } + + /** + * Check the first item in the new list with old list and returns true if they are same + * Its triggered on successful response of the fetch images API. + * @param images + */ + public boolean containsAll(List images){ + if (images == null || images.isEmpty()) { + return false; + } + if (data == null) { + data = new ArrayList<>(); + return false; + } + if (data.size() == 0) { + return false; + } + String fileName = data.get(0).getFilename(); + String imageName = images.get(0).getFilename(); + return imageName.equals(fileName); + } + + @Override + public boolean isEmpty() { + return data == null || data.isEmpty(); + } + + /** + * Sets up the UI for the depicted image item + * @param position + * @param convertView + * @param parent + * @return + */ + @Override + public View getView(int position, View convertView, ViewGroup parent) { + + if (convertView == null) { + convertView = LayoutInflater.from(getContext()).inflate(R.layout.layout_depict_image, null); + } + + Media item = data.get(position); + SimpleDraweeView imageView = convertView.findViewById(R.id.depict_image_view); + TextView fileName = convertView.findViewById(R.id.depict_image_title); + TextView author = convertView.findViewById(R.id.depict_image_author); + fileName.setText(item.getThumbnailTitle()); + setAuthorView(item, author); + imageView.setImageURI(item.getThumbUrl()); + return convertView; + } + + @Nullable + @Override + public Media getItem(int position) { + return data.get(position); + } + + /** + * Shows author information if its present + * @param item + * @param author + */ + private void setAuthorView(Media item, TextView author) { + if (!TextUtils.isEmpty(item.getCreator())) { + String uploadedByTemplate = getContext().getString(R.string.image_uploaded_by); + + String uploadedBy = String.format(Locale.getDefault(), uploadedByTemplate, item.getCreator()); + author.setText(uploadedBy); + } else { + author.setVisibility(View.GONE); + } + } + + } diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/Media/DepictedImagesContract.java b/app/src/main/java/fr/free/nrw/commons/depictions/Media/DepictedImagesContract.java new file mode 100644 index 00000000000..67e59390c86 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/Media/DepictedImagesContract.java @@ -0,0 +1,107 @@ +package fr.free.nrw.commons.depictions.Media; + +import android.widget.ListAdapter; + +import java.util.List; + +import fr.free.nrw.commons.BasePresenter; +import fr.free.nrw.commons.Media; + +/** + * Contract with which DepictedImagesFragment and its presenter will talk to each other + */ +public interface DepictedImagesContract { + + interface View { + + /** + * Handles the UI updates for no internet scenario + */ + void handleNoInternet(); + + /** + * Handles the UI updates for a error scenario + */ + void initErrorView(); + + /** + * Initializes the adapter with a list of Media objects + * + * @param mediaList List of new Media to be displayed + */ + void setAdapter(List mediaList); + + /** + * Seat caption to the image at the given position + */ + void handleLabelforImage(String caption, int position); + + /** + * Display snackbar + */ + void showSnackBar(); + + /** + * Inform the view that there are no more items to be loaded for this search query + * or reset the isLastPage for the current query + * @param isLastPage + */ + void setIsLastPage(boolean isLastPage); + + /** + * Set visibility of progressbar depending on the boolean value + */ + void progressBarVisible(Boolean value); + + /** + * It return an instance of gridView adapter which helps in extracting media details + * used by the gridView + * + * @return GridView Adapter + */ + ListAdapter getAdapter(); + + /** + * adds list to adapter + */ + void addItemsToAdapter(List media); + + /** + * Sets loading status depending on the boolean value + */ + void setLoadingStatus(Boolean value); + + /** + * Handles the success scenario + * On first load, it initializes the grid view. On subsequent loads, it adds items to the adapter + * + * @param collection List of new Media to be displayed + */ + void handleSuccess(List collection); + + } + + interface UserActionListener extends BasePresenter { + + /** + * Checks for internet connection and then initializes the grid view with first 10 images of that depiction + */ + void initList(String entityId); + + /** + * Fetches more images for the item and adds it to the grid view adapter + */ + void fetchMoreImages(); + + /** + * fetch captions for the image using filename and replace title of on the image thumbnail(if captions are available) + * else show filename + */ + void replaceTitlesWithCaptions(String title, int position); + + /** + * add items to query list + */ + void addItemsToQueryList(List collection); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/Media/DepictedImagesFragment.java b/app/src/main/java/fr/free/nrw/commons/depictions/Media/DepictedImagesFragment.java new file mode 100644 index 00000000000..1c1aeaef333 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/Media/DepictedImagesFragment.java @@ -0,0 +1,267 @@ +package fr.free.nrw.commons.depictions.Media; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.GridView; +import android.widget.ListAdapter; +import android.widget.ProgressBar; +import android.widget.RelativeLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import butterknife.BindView; +import butterknife.ButterKnife; +import dagger.android.support.DaggerFragment; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.depictions.GridViewAdapter; +import fr.free.nrw.commons.depictions.WikidataItemDetailsActivity; +import fr.free.nrw.commons.utils.NetworkUtils; +import fr.free.nrw.commons.utils.ViewUtil; +import java.util.List; +import javax.inject.Inject; +import timber.log.Timber; + +/** + * Fragment for showing image list after selected an item from SearchActivity In Explore + */ +public class DepictedImagesFragment extends DaggerFragment implements DepictedImagesContract.View { + + + public static final String PAGE_ID_PREFIX = "M"; + @BindView(R.id.statusMessage) + TextView statusTextView; + @BindView(R.id.loadingImagesProgressBar) + ProgressBar progressBar; + @BindView(R.id.depicts_image_list) + GridView gridView; + @BindView(R.id.parentLayout) + RelativeLayout parentLayout; + @Inject + DepictedImagesPresenter presenter; + private GridViewAdapter gridAdapter; + private String entityId = null; + private boolean isLastPage; + private boolean isLoading = true; + private int mediaSize = 0; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.fragment_depict_image, container, false); + ButterKnife.bind(this, v); + presenter.onAttachView(this); + return v; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + gridView.setOnItemClickListener((AdapterView.OnItemClickListener) getActivity()); + initViews(); + } + + /** + * Initializes the UI elements for the fragment + * Setup the grid view to and scroll listener for it + */ + private void initViews() { + String depictsName = getArguments().getString("wikidataItemName"); + entityId = getArguments().getString("entityId"); + if (getArguments() != null && depictsName != null) { + initList(); + setScrollListener(); + } + } + + private void initList() { + presenter.initList(entityId); + if (!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + } else { + presenter.initList(entityId); + } + } + + /** + * Handles the UI updates for no internet scenario + */ + @Override + public void handleNoInternet() { + progressBar.setVisibility(GONE); + if (gridAdapter == null || gridAdapter.isEmpty()) { + statusTextView.setVisibility(VISIBLE); + statusTextView.setText(getString(R.string.no_internet)); + } else { + ViewUtil.showShortSnackbar(parentLayout, R.string.no_internet); + } + } + + /** + * Handles the UI updates for a error scenario + */ + @Override + public void initErrorView() { + progressBar.setVisibility(GONE); + if (gridAdapter == null || gridAdapter.isEmpty()) { + statusTextView.setVisibility(VISIBLE); + statusTextView.setText(getString(R.string.no_images_found)); + } else { + statusTextView.setVisibility(GONE); + } + } + + /** + * Sets the scroll listener for the grid view so that more images are fetched when the user scrolls down + * Checks if the item has more images before loading + * Also checks whether images are currently being fetched before triggering another request + */ + private void setScrollListener() { + gridView.setOnScrollListener(new AbsListView.OnScrollListener() { + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + if (!isLastPage && !isLoading && (firstVisibleItem + visibleItemCount >= totalItemCount)) { + isLoading = true; + if (!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + } else { + presenter.fetchMoreImages(); + } + } + if (isLastPage) { + progressBar.setVisibility(GONE); + } + } + }); + } + + /** + * Seat caption to the image at the given position + */ + @Override + public void handleLabelforImage(String caption, int position) { + if (!caption.trim().equals(getString(R.string.detail_caption_empty))) { + gridAdapter.getItem(position).setThumbnailTitle(caption); + gridAdapter.notifyDataSetChanged(); + } + } + + /** + * Display snackbar + */ + @Override + public void showSnackBar() { + ViewUtil.showShortSnackbar(parentLayout, R.string.error_loading_images); + } + + /** + * Set visibility of progressbar depending on the boolean value + */ + @Override + public void progressBarVisible(Boolean value) { + if (value) { + progressBar.setVisibility(VISIBLE); + } else { + progressBar.setVisibility(GONE); + } + } + + /** + * It return an instance of gridView adapter which helps in extracting media details + * used by the gridView + * + * @return GridView Adapter + */ + @Override + public ListAdapter getAdapter() { + return gridAdapter; + } + + /** + * Initializes the adapter with a list of Media objects + * + * @param mediaList List of new Media to be displayed + */ + @Override + public void setAdapter(List mediaList) { + gridAdapter = new fr.free.nrw.commons.depictions.GridViewAdapter(getContext(), R.layout.layout_depict_image, mediaList); + gridView.setAdapter(gridAdapter); + } + + /** + * adds list to adapter + */ + @Override + public void addItemsToAdapter(List media) { + gridAdapter.addAll(media); + gridAdapter.notifyDataSetChanged(); + } + + /** + * Sets loading status depending on the boolean value + */ + @Override + public void setLoadingStatus(Boolean value) { + if (!value) { + statusTextView.setVisibility(GONE); + } + isLoading = value; + } + + /** + * Inform the view that there are no more items to be loaded for this search query + * or reset the isLastPage for the current query + * @param isLastPage + */ + @Override + public void setIsLastPage(boolean isLastPage) { + this.isLastPage=isLastPage; + progressBar.setVisibility(GONE); + } + + + /** + * Handles the success scenario + * On first load, it initializes the grid view. On subsequent loads, it adds items to the adapter + * + * @param collection List of new Media to be displayed + */ + @Override + public void handleSuccess(List collection) { + presenter.addItemsToQueryList(collection); + if (gridAdapter == null) { + setAdapter(collection); + } else { + if (gridAdapter.containsAll(collection)) { + return; + } + gridAdapter.addItems(collection); + + try { + ((WikidataItemDetailsActivity) getContext()).viewPagerNotifyDataSetChanged(); + } catch (RuntimeException e) { + Timber.e(e); + } + } + progressBar.setVisibility(GONE); + isLoading = false; + statusTextView.setVisibility(GONE); + for (Media media : collection) { + final String pageId = media.getPageId(); + if (pageId != null) { + presenter.replaceTitlesWithCaptions(PAGE_ID_PREFIX + pageId, mediaSize++); + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/Media/DepictedImagesPresenter.java b/app/src/main/java/fr/free/nrw/commons/depictions/Media/DepictedImagesPresenter.java new file mode 100644 index 00000000000..372aa60a435 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/Media/DepictedImagesPresenter.java @@ -0,0 +1,159 @@ +package fr.free.nrw.commons.depictions.Media; + +import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; +import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD; + +import android.annotation.SuppressLint; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.explore.depictions.DepictsClient; +import fr.free.nrw.commons.kvstore.JsonKvStore; +import fr.free.nrw.commons.media.MediaClient; +import io.reactivex.Scheduler; +import io.reactivex.disposables.CompositeDisposable; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; +import timber.log.Timber; + +/** + * Presenter for DepictedImagesFragment + */ +public class DepictedImagesPresenter implements DepictedImagesContract.UserActionListener { + + private static final DepictedImagesContract.View DUMMY = (DepictedImagesContract.View) Proxy + .newProxyInstance( + DepictedImagesContract.View.class.getClassLoader(), + new Class[]{DepictedImagesContract.View.class}, + (proxy, method, methodArgs) -> null); + DepictsClient depictsClient; + MediaClient mediaClient; + @Named("default_preferences") + JsonKvStore depictionKvStore; + private final Scheduler ioScheduler; + private final Scheduler mainThreadScheduler; + private DepictedImagesContract.View view = DUMMY; + private CompositeDisposable compositeDisposable = new CompositeDisposable(); + /** + * Wikibase enitityId for the depicted Item + * Ex: Q9394 + */ + private String entityId = null; + private List queryList = new ArrayList<>(); + + @Inject + public DepictedImagesPresenter(@Named("default_preferences") JsonKvStore depictionKvStore, DepictsClient depictsClient, MediaClient mediaClient, @Named(IO_THREAD) Scheduler ioScheduler, + @Named(MAIN_THREAD) Scheduler mainThreadScheduler) { + this.depictionKvStore = depictionKvStore; + this.depictsClient = depictsClient; + this.ioScheduler = ioScheduler; + this.mainThreadScheduler = mainThreadScheduler; + this.mediaClient = mediaClient; + } + + @Override + public void onAttachView(DepictedImagesContract.View view) { + this.view = view; + } + + @Override + public void onDetachView() { + this.view = DUMMY; + } + + /** + * Checks for internet connection and then initializes the grid view with first 10 images of that depiction + */ + @SuppressLint("CheckResult") + @Override + public void initList(String entityId) { + view.setLoadingStatus(true); + view.progressBarVisible(true); + view.setIsLastPage(false); + compositeDisposable.add(depictsClient.fetchImagesForDepictedItem(entityId, 0) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .subscribe(this::handleSuccess, this::handleError)); + } + + /** + * Fetches more images for the item and adds it to the grid view adapter + */ + @SuppressLint("CheckResult") + @Override + public void fetchMoreImages() { + view.progressBarVisible(true); + compositeDisposable.add(depictsClient.fetchImagesForDepictedItem(entityId, queryList.size()) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .subscribe(this::handlePaginationSuccess, this::handleError)); + } + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + */ + private void handlePaginationSuccess(List media) { + queryList.addAll(media); + view.progressBarVisible(false); + view.addItemsToAdapter(media); + } + + /** + * Logs and handles API error scenario + * + * @param throwable + */ + public void handleError(Throwable throwable) { + Timber.e(throwable, "Error occurred while loading images inside items"); + try { + view.initErrorView(); + view.showSnackBar(); + } catch (Exception e) { + e.printStackTrace(); + } + + } + + /** + * Handles the success scenario + * On first load, it initializes the grid view. On subsequent loads, it adds items to the adapter + * @param collection List of new Media to be displayed + */ + public void handleSuccess(List collection) { + if (collection == null || collection.isEmpty()) { + if (queryList.isEmpty()) { + view.initErrorView(); + } else { + view.setIsLastPage(true); + } + } else { + this.queryList.addAll(collection); + view.handleSuccess(collection); + } + } + + /** + * fetch captions for the image using filename and replace title of on the image thumbnail(if captions are available) + * else show filename + */ + @Override + public void replaceTitlesWithCaptions(String wikibaseIdentifier, int position) { + compositeDisposable.add(mediaClient.getCaptionByWikibaseIdentifier(wikibaseIdentifier) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .subscribe(caption -> { + view.handleLabelforImage(caption, position); + })); + + } + + /** + * add items to query list + */ + @Override + public void addItemsToQueryList(List collection) { + queryList.addAll(collection); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/WikidataItemDetailsActivity.java b/app/src/main/java/fr/free/nrw/commons/depictions/WikidataItemDetailsActivity.java new file mode 100644 index 00000000000..40ea07995f6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/WikidataItemDetailsActivity.java @@ -0,0 +1,204 @@ +package fr.free.nrw.commons.depictions; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.AdapterView; +import android.widget.FrameLayout; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.viewpager.widget.ViewPager; + +import com.google.android.material.tabs.TabLayout; + +import java.util.ArrayList; +import java.util.List; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.depictions.Media.DepictedImagesFragment; +import fr.free.nrw.commons.depictions.subClass.SubDepictionListFragment; +import fr.free.nrw.commons.explore.ViewPagerAdapter; +import fr.free.nrw.commons.media.MediaDetailPagerFragment; +import fr.free.nrw.commons.theme.NavigationBaseActivity; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; + +/** + * Activity to show depiction media, parent classes and child classes of depicted items in Explore + */ +public class WikidataItemDetailsActivity extends NavigationBaseActivity implements MediaDetailPagerFragment.MediaDetailProvider, AdapterView.OnItemClickListener { + private FragmentManager supportFragmentManager; + private DepictedImagesFragment depictionImagesListFragment; + private MediaDetailPagerFragment mediaDetailPagerFragment; + /** + * Name of the depicted item + * Ex: Rabbit + */ + private String wikidataItemName; + @BindView(R.id.mediaContainer) + FrameLayout mediaContainer; + @BindView(R.id.tab_layout) + TabLayout tabLayout; + @BindView(R.id.viewPager) + ViewPager viewPager; + + ViewPagerAdapter viewPagerAdapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_wikidata_item_details); + ButterKnife.bind(this); + supportFragmentManager = getSupportFragmentManager(); + viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager()); + viewPager.setAdapter(viewPagerAdapter); + viewPager.setOffscreenPageLimit(2); + tabLayout.setupWithViewPager(viewPager); + setTabs(); + setPageTitle(); + initDrawer(); + forceInitBackButton(); + } + + /** + * Gets the passed wikidataItemName from the intents and displays it as the page title + */ + private void setPageTitle() { + if (getIntent() != null && getIntent().getStringExtra("wikidataItemName") != null) { + setTitle(getIntent().getStringExtra("wikidataItemName")); + } + } + + /** + * This method is called on success of API call for featured Images. + * The viewpager will notified that number of items have changed. + */ + public void viewPagerNotifyDataSetChanged() { + if (mediaDetailPagerFragment !=null){ + mediaDetailPagerFragment.notifyDataSetChanged(); + } + } + + + /** + * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab, + * Set the fragments according to the tab selected in the viewPager. + */ + private void setTabs() { + List fragmentList = new ArrayList<>(); + List titleList = new ArrayList<>(); + depictionImagesListFragment = new DepictedImagesFragment(); + SubDepictionListFragment subDepictionListFragment = new SubDepictionListFragment(); + SubDepictionListFragment parentDepictionListFragment = new SubDepictionListFragment(); + wikidataItemName = getIntent().getStringExtra("wikidataItemName"); + String entityId = getIntent().getStringExtra("entityId"); + if (getIntent() != null && wikidataItemName != null) { + Bundle arguments = new Bundle(); + arguments.putString("wikidataItemName", wikidataItemName); + arguments.putString("entityId", entityId); + arguments.putBoolean("isParentClass", false); + depictionImagesListFragment.setArguments(arguments); + subDepictionListFragment.setArguments(arguments); + Bundle parentClassArguments = new Bundle(); + parentClassArguments.putString("wikidataItemName", wikidataItemName); + parentClassArguments.putString("entityId", entityId); + parentClassArguments.putBoolean("isParentClass", true); + parentDepictionListFragment.setArguments(parentClassArguments); + } + fragmentList.add(depictionImagesListFragment); + titleList.add(getResources().getString(R.string.title_for_media)); + fragmentList.add(subDepictionListFragment); + titleList.add(getResources().getString(R.string.title_for_child_classes)); + fragmentList.add(parentDepictionListFragment); + titleList.add(getResources().getString(R.string.title_for_parent_classes)); + viewPagerAdapter.setTabData(fragmentList, titleList); + viewPager.setOffscreenPageLimit(2); + viewPagerAdapter.notifyDataSetChanged(); + + } + + /** + * Shows media detail fragment when user clicks on any image in the list + */ + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + tabLayout.setVisibility(View.GONE); + viewPager.setVisibility(View.GONE); + mediaContainer.setVisibility(View.VISIBLE); + if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment.isVisible()) { + // set isFeaturedImage true for featured images, to include author field on media detail + mediaDetailPagerFragment = new MediaDetailPagerFragment(false, true); + FragmentManager supportFragmentManager = getSupportFragmentManager(); + supportFragmentManager + .beginTransaction() + .replace(R.id.mediaContainer, mediaDetailPagerFragment) + .addToBackStack(null) + .commit(); + supportFragmentManager.executePendingTransactions(); + } + mediaDetailPagerFragment.showImage(position); + forceInitBackButton(); + } + + /** + * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index + * @param i It is the index of which media object is to be returned which is same as + * current index of viewPager. + * @return Media Object + */ + @Override + public Media getMediaAtPosition(int i) { + if (depictionImagesListFragment.getAdapter() == null) { + // not yet ready to return data + return null; + } else { + return (Media) depictionImagesListFragment.getAdapter().getItem(i); + } + } + + /** + * This method is called on backPressed of anyFragment in the activity. + * If condition is called when mediaDetailFragment is opened. + */ + @Override + public void onBackPressed() { + if (supportFragmentManager.getBackStackEntryCount() == 1){ + // back to search so show search toolbar and hide navigation toolbar + tabLayout.setVisibility(View.VISIBLE); + viewPager.setVisibility(View.VISIBLE); + mediaContainer.setVisibility(View.GONE); + } + super.onBackPressed(); + } + + /** + * This method is called on from getCount of MediaDetailPagerFragment + * The viewpager will contain same number of media items as that of media elements in adapter. + * @return Total Media count in the adapter + */ + @Override + public int getTotalMediaCount() { + if (depictionImagesListFragment.getAdapter() == null) { + return 0; + } + return depictionImagesListFragment.getAdapter().getCount(); + } + + /** + * Consumers should be simply using this method to use this activity. + * + * @param context A Context of the application package implementing this class. + * @param depictedItem Name of the depicts for displaying its details + */ + public static void startYourself(Context context, DepictedItem depictedItem) { + Intent intent = new Intent(context, WikidataItemDetailsActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP); + intent.putExtra("wikidataItemName", depictedItem.getName()); + intent.putExtra("entityId", depictedItem.getId()); + context.startActivity(intent); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/models/Continue.java b/app/src/main/java/fr/free/nrw/commons/depictions/models/Continue.java new file mode 100644 index 00000000000..2365391a567 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/models/Continue.java @@ -0,0 +1,57 @@ +package fr.free.nrw.commons.depictions.models; +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +/** + * Model class for object obtained while parsing depiction response + */ +public class Continue { + + @SerializedName("sroffset") + @Expose + private Integer sroffset; + @SerializedName("continue") + @Expose + private String _continue; + + /** + * No args constructor for use in serialization + * + */ + public Continue() { + } + + /** + * + * @param sroffset + * @param _continue + */ + public Continue(Integer sroffset, String _continue) { + super(); + this.sroffset = sroffset; + this._continue = _continue; + } + + /** + * gets sroffset from Continue object + */ + public Integer getSroffset() { + return sroffset; + } + + public void setSroffset(Integer sroffset) { + this.sroffset = sroffset; + } + + /** + * gets continue string from Continue object + */ + public String getContinue() { + return _continue; + } + + public void setContinue(String _continue) { + this._continue = _continue; + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/models/DepictionResponse.java b/app/src/main/java/fr/free/nrw/commons/depictions/models/DepictionResponse.java new file mode 100644 index 00000000000..15ea61d50dd --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/models/DepictionResponse.java @@ -0,0 +1,73 @@ +package fr.free.nrw.commons.depictions.models; +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +/** + * Model class for list of depicted images obtained by fetching using depiction entity + */ +public class DepictionResponse { + + @SerializedName("batchcomplete") + @Expose + private String batchcomplete; + @SerializedName("continue") + @Expose + private Continue _continue; + @SerializedName("query") + @Expose + private Query query; + + /** + * No args constructor for use in serialization + * + */ + public DepictionResponse() { + } + + /** + * + * @param query + * @param batchcomplete + * @param _continue + */ + public DepictionResponse(String batchcomplete, Continue _continue, Query query) { + super(); + this.batchcomplete = batchcomplete; + this._continue = _continue; + this.query = query; + } + + /** + * returns batchcomplete string from DepictionResponse object + */ + public String getBatchcomplete() { + return batchcomplete; + } + + public void setBatchcomplete(String batchcomplete) { + this.batchcomplete = batchcomplete; + } + + /** + * returns continue object from DepictionResponse object + */ + public Continue getContinue() { + return _continue; + } + + public void setContinue(Continue _continue) { + this._continue = _continue; + } + + /** + * returns query object from DepictionResponse object + */ + public Query getQuery() { + return query; + } + + public void setQuery(Query query) { + this.query = query; + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/models/Query.java b/app/src/main/java/fr/free/nrw/commons/depictions/models/Query.java new file mode 100644 index 00000000000..358527fe762 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/models/Query.java @@ -0,0 +1,60 @@ +package fr.free.nrw.commons.depictions.models; +import java.util.List; +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +/** + * Model class for object obtained while parsing depiction response + * + * the getSearch() function is used to parse media + */ +public class Query { + + @SerializedName("searchinfo") + @Expose + private Searchinfo searchinfo; + @SerializedName("search") + @Expose + private List search = null; + + /** + * No args constructor for use in serialization + * + */ + public Query() { + } + + /** + * + * @param search + * @param searchinfo + */ + public Query(Searchinfo searchinfo, List search) { + super(); + this.searchinfo = searchinfo; + this.search = search; + } + + /** + * return searchInfo + */ + public Searchinfo getSearchinfo() { + return searchinfo; + } + + public void setSearchinfo(Searchinfo searchinfo) { + this.searchinfo = searchinfo; + } + + /** + * the getSearch() function is used to parse media + */ + public List getSearch() { + return search; + } + + public void setSearch(List search) { + this.search = search; + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/models/Search.java b/app/src/main/java/fr/free/nrw/commons/depictions/models/Search.java new file mode 100644 index 00000000000..fbff3616ae7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/models/Search.java @@ -0,0 +1,140 @@ +package fr.free.nrw.commons.depictions.models; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +/** + * Model class for object obtained while parsing depiction response + * this class contains all the details of for the media object + */ + +public class Search { + + @SerializedName("ns") + @Expose + private Integer ns; + @SerializedName("title") + @Expose + private String title; + @SerializedName("pageid") + @Expose + private Integer pageid; + @SerializedName("size") + @Expose + private Integer size; + @SerializedName("wordcount") + @Expose + private Integer wordcount; + @SerializedName("snippet") + @Expose + private String snippet; + @SerializedName("timestamp") + @Expose + private String timestamp; + + /** + * No args constructor for use in serialization + * + */ + public Search() { + } + + /** + * + * @param timestamp + * @param title + * @param ns + * @param snippet + * @param wordcount + * @param size + * @param pageid + */ + public Search(Integer ns, String title, Integer pageid, Integer size, Integer wordcount, String snippet, String timestamp) { + super(); + this.ns = ns; + this.title = title; + this.pageid = pageid; + this.size = size; + this.wordcount = wordcount; + this.snippet = snippet; + this.timestamp = timestamp; + } + + /** + * returns ns int from Search object + */ + public Integer getNs() { + return ns; + } + + public void setNs(Integer ns) { + this.ns = ns; + } + + /** + * returns title string from Search object + */ + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + /** + * returns pageid int from Search object + */ + public Integer getPageid() { + return pageid; + } + + public void setPageid(Integer pageid) { + this.pageid = pageid; + } + + /** + * returns size int from Search object + */ + public Integer getSize() { + return size; + } + + public void setSize(Integer size) { + this.size = size; + } + + /** + * returns wordcount int from Search object + */ + public Integer getWordcount() { + return wordcount; + } + + public void setWordcount(Integer wordcount) { + this.wordcount = wordcount; + } + + /** + * returns snippet String from Search object + */ + public String getSnippet() { + return snippet; + } + + public void setSnippet(String snippet) { + this.snippet = snippet; + } + + /** + * returns ns int from Search object + */ + public String getTimestamp() { + return timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/models/Searchinfo.java b/app/src/main/java/fr/free/nrw/commons/depictions/models/Searchinfo.java new file mode 100644 index 00000000000..f04f62d0eb1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/models/Searchinfo.java @@ -0,0 +1,42 @@ +package fr.free.nrw.commons.depictions.models; +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +/** + * Model class for object obtained while parsing query object + */ + +public class Searchinfo { + + @SerializedName("totalhits") + @Expose + private Integer totalhits; + + /** + * No args constructor for use in serialization + * + */ + public Searchinfo() { + } + + /** + * + * @param totalhits + */ + public Searchinfo(Integer totalhits) { + super(); + this.totalhits = totalhits; + } + + /** + * returns "totalhint" integer in SearchInfo object + */ + public Integer getTotalhits() { + return totalhits; + } + + public void setTotalhits(Integer totalhits) { + this.totalhits = totalhits; + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListContract.java b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListContract.java new file mode 100644 index 00000000000..0973149cc02 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListContract.java @@ -0,0 +1,39 @@ +package fr.free.nrw.commons.depictions.subClass; + +import java.io.IOException; +import java.util.List; + +import fr.free.nrw.commons.BasePresenter; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; + +/** + * The contract with which SubDepictionListFragment and its presenter would talk to each other + */ +public interface SubDepictionListContract { + + interface View { + + void onImageUrlFetched(String response, int position); + + void onSuccess(List mediaList); + + void initErrorView(); + + void showSnackbar(); + + void setIsLastPage(boolean b); + + boolean isParentClass(); + } + + interface UserActionListener extends BasePresenter { + + void saveQuery(); + + void fetchThumbnailForEntityId(String entityId, int position); + + void initSubDepictionList(String qid, Boolean isParentClass) throws IOException; + + String getQuery(); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListFragment.java b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListFragment.java new file mode 100644 index 00000000000..5c8a3c045d4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListFragment.java @@ -0,0 +1,190 @@ +package fr.free.nrw.commons.depictions.subClass; + +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.pedrogomez.renderers.RVRendererAdapter; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +import javax.inject.Inject; + +import butterknife.BindView; +import butterknife.ButterKnife; +import dagger.android.support.DaggerFragment; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.depictions.WikidataItemDetailsActivity; +import fr.free.nrw.commons.explore.depictions.SearchDepictionsAdapterFactory; +import fr.free.nrw.commons.explore.depictions.SearchDepictionsRenderer; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import fr.free.nrw.commons.utils.NetworkUtils; +import fr.free.nrw.commons.utils.ViewUtil; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +/** + * Fragment for parent classes and child classes of Depicted items in Explore + */ +public class SubDepictionListFragment extends DaggerFragment implements SubDepictionListContract.View { + + @BindView(R.id.imagesListBox) + RecyclerView depictionsRecyclerView; + @BindView(R.id.imageSearchInProgress) + ProgressBar progressBar; + @BindView(R.id.imagesNotFound) + TextView depictionNotFound; + @BindView(R.id.bottomProgressBar) + ProgressBar bottomProgressBar; + /** + * Keeps a record of whether current instance of the fragment if of SubClass or ParentClass + */ + private boolean isParentClass = false; + private RVRendererAdapter depictionsAdapter; + /** + * Used by scroll state listener, when hasMoreImages is false scrolling does not fetches any more images + */ + private boolean hasMoreImages = true; + RecyclerView.LayoutManager layoutManager; + /** + * Stores entityId for the depiction + */ + private String entityId; + /** + * Stores name of the depiction searched + */ + private String depictsName; + + @Inject SubDepictionListPresenter presenter; + + private final SearchDepictionsAdapterFactory adapterFactory = new SearchDepictionsAdapterFactory(new SearchDepictionsRenderer.DepictCallback() { + @Override + public void depictsClicked(DepictedItem item) { + // Open SubDepiction Details page + getActivity().finish(); + WikidataItemDetailsActivity.startYourself(getContext(), item); + } + + @Override + public void fetchThumbnailUrlForEntity(String entityId, int position) { + presenter.fetchThumbnailForEntityId(entityId, position); + } + + }); + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + } + + private void initViews() { + if (getArguments() != null) { + depictsName = getArguments().getString("wikidataItemName"); + entityId = getArguments().getString("entityId"); + isParentClass = getArguments().getBoolean("isParentClass"); + if (entityId != null) { + initList(entityId, isParentClass); + } + } + } + + private void initList(String qid, Boolean isParentClass) { + if (!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + } else { + progressBar.setVisibility(View.VISIBLE); + try { + presenter.initSubDepictionList(qid, isParentClass); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.fragment_browse_image, container, false); + ButterKnife.bind(this, v); + presenter.onAttachView(this); + isParentClass = false; + depictionNotFound.setVisibility(GONE); + if (getActivity().getResources().getConfiguration().orientation + == Configuration.ORIENTATION_PORTRAIT) { + layoutManager = new LinearLayoutManager(getContext()); + } else { + layoutManager = new GridLayoutManager(getContext(), 2); + } + initViews(); + depictionsRecyclerView.setLayoutManager(layoutManager); + depictionsAdapter = adapterFactory.create(); + depictionsRecyclerView.setAdapter(depictionsAdapter); + return v; + } + + private void handleNoInternet() { + progressBar.setVisibility(GONE); + ViewUtil.showShortSnackbar(depictionsRecyclerView, R.string.no_internet); + } + + @Override + public void onImageUrlFetched(String response, int position) { + depictionsAdapter.getItem(position).setImageUrl(response); + depictionsAdapter.notifyItemChanged(position); + } + + @Override + public void onSuccess(List mediaList) { + hasMoreImages = false; + progressBar.setVisibility(View.GONE); + depictionNotFound.setVisibility(GONE); + bottomProgressBar.setVisibility(GONE); + int itemCount=layoutManager.getItemCount(); + depictionsAdapter.addAll(mediaList); + depictionsRecyclerView.getRecycledViewPool().clear(); + if(itemCount!=0) { + depictionsAdapter.notifyItemRangeInserted(itemCount, mediaList.size()-1); + }else{ + depictionsAdapter.notifyDataSetChanged(); + } + } + + @Override + public void initErrorView() { + hasMoreImages = false; + progressBar.setVisibility(GONE); + bottomProgressBar.setVisibility(GONE); + depictionNotFound.setVisibility(VISIBLE); + String no_depiction = getString(isParentClass? R.string.no_parent_classes: R.string.no_child_classes); + depictionNotFound.setText(String.format(Locale.getDefault(), no_depiction, depictsName)); + + } + + @Override + public void showSnackbar() { + ViewUtil.showShortSnackbar(depictionsRecyclerView, R.string.error_loading_depictions); + } + + @Override + public void setIsLastPage(boolean b) { + hasMoreImages = !b; + } + + @Override + public boolean isParentClass() { + return isParentClass; + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListPresenter.java b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListPresenter.java new file mode 100644 index 00000000000..1f9f4fc7656 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListPresenter.java @@ -0,0 +1,158 @@ +package fr.free.nrw.commons.depictions.subClass; + +import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; +import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD; + +import fr.free.nrw.commons.explore.depictions.DepictsClient; +import fr.free.nrw.commons.explore.recentsearches.RecentSearch; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao; +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import io.reactivex.Scheduler; +import io.reactivex.disposables.CompositeDisposable; +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Named; +import timber.log.Timber; + +/** +* Presenter for parent classes and child classes of Depicted items in Explore + */ +public class SubDepictionListPresenter implements SubDepictionListContract.UserActionListener { + + /** + * This creates a dynamic proxy instance of the class, + * proxy is to control access to the target object + * here our target object is the view. + * Thus we when onDettach method of fragment is called we replace the binding of view to our object with the proxy instance + */ + private static final SubDepictionListContract.View DUMMY = (SubDepictionListContract.View) Proxy + .newProxyInstance( + SubDepictionListContract.View.class.getClassLoader(), + new Class[]{SubDepictionListContract.View.class}, + (proxy, method, methodArgs) -> null); + + private final Scheduler ioScheduler; + private final Scheduler mainThreadScheduler; + private SubDepictionListContract.View view = DUMMY; + RecentSearchesDao recentSearchesDao; + /** + * Value of the search query + */ + public String query; + protected CompositeDisposable compositeDisposable = new CompositeDisposable(); + DepictsClient depictsClient; + private static int TIMEOUT_SECONDS = 15; + private List queryList = new ArrayList<>(); + OkHttpJsonApiClient okHttpJsonApiClient; + /** + * variable used to record the number of API calls already made for fetching Thumbnails + */ + private int size = 0; + + @Inject + public SubDepictionListPresenter(RecentSearchesDao recentSearchesDao, DepictsClient depictsClient, OkHttpJsonApiClient okHttpJsonApiClient, @Named(IO_THREAD) Scheduler ioScheduler, + @Named(MAIN_THREAD) Scheduler mainThreadScheduler) { + this.recentSearchesDao = recentSearchesDao; + this.ioScheduler = ioScheduler; + this.mainThreadScheduler = mainThreadScheduler; + this.depictsClient = depictsClient; + this.okHttpJsonApiClient = okHttpJsonApiClient; + } + @Override + public void onAttachView(SubDepictionListContract.View view) { + this.view = view; + } + + @Override + public void onDetachView() { + this.view = DUMMY; + } + + /** + * Store the current query in Recent searches + */ + @Override + public void saveQuery() { + RecentSearch recentSearch = recentSearchesDao.find(query); + + // Newly searched query... + if (recentSearch == null) { + recentSearch = new RecentSearch(null, query, new Date()); + } else { + recentSearch.setLastSearched(new Date()); + } + recentSearchesDao.save(recentSearch); + } + + /** + * Calls Wikibase APIs to fetch Thumbnail image for a given wikidata item + */ + @Override + public void fetchThumbnailForEntityId(String entityId, int position) { + compositeDisposable.add(depictsClient.getP18ForItem(entityId) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(response -> { + view.onImageUrlFetched(response,position); + })); + } + + @Override + public void initSubDepictionList(String qid, Boolean isParentClass) throws IOException { + size = 0; + if (isParentClass) { + compositeDisposable.add(okHttpJsonApiClient.getParentQIDs(qid) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .subscribe(this::handleSuccess, this::handleError)); + } else { + compositeDisposable.add(okHttpJsonApiClient.getChildQIDs(qid) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .subscribe(this::handleSuccess, this::handleError)); + } + + } + + @Override + public String getQuery() { + return query; + } + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + */ + public void handleSuccess(List mediaList) { + if (mediaList == null || mediaList.isEmpty()) { + if(queryList.isEmpty()){ + view.initErrorView(); + }else{ + view.setIsLastPage(true); + } + } else { + this.queryList.addAll(mediaList); + view.onSuccess(mediaList); + for (DepictedItem m : mediaList) { + fetchThumbnailForEntityId(m.getId(), size++); + } + } + } + + /** + * Logs and handles API error scenario + */ + private void handleError(Throwable throwable) { + Timber.e(throwable, "Error occurred while loading queried depictions"); + view.initErrorView(); + view.showSnackbar(); + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/subClass/models/SparqlResponses.kt b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/models/SparqlResponses.kt new file mode 100644 index 00000000000..da9781c06ae --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/models/SparqlResponses.kt @@ -0,0 +1,26 @@ +package fr.free.nrw.commons.depictions.subClass.models + +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem + +data class SparqlResponse(val results: Result) { + fun toDepictedItems() = + results.bindings.map { + DepictedItem( + it.itemLabel.value, + it.itemDescription?.value ?: "", + "", + false, + it.item.value.substringAfterLast("/") + ) + } +} + +data class Result(val bindings: List) + +data class Binding( + val item: SparqInfo, + val itemLabel: SparqInfo, + val itemDescription: SparqInfo? = null +) + +data class SparqInfo(val type: String, val value: String) 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 1686dba3e24..db7c7fd26ba 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 @@ -4,17 +4,17 @@ import dagger.android.ContributesAndroidInjector; import fr.free.nrw.commons.AboutActivity; import fr.free.nrw.commons.WelcomeActivity; +import fr.free.nrw.commons.achievements.AchievementsActivity; import fr.free.nrw.commons.auth.LoginActivity; import fr.free.nrw.commons.auth.SignupActivity; import fr.free.nrw.commons.bookmarks.BookmarksActivity; import fr.free.nrw.commons.category.CategoryDetailsActivity; import fr.free.nrw.commons.category.CategoryImagesActivity; import fr.free.nrw.commons.contributions.MainActivity; -import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity; +import fr.free.nrw.commons.depictions.WikidataItemDetailsActivity; import fr.free.nrw.commons.explore.SearchActivity; -import fr.free.nrw.commons.explore.ExploreActivity; +import fr.free.nrw.commons.explore.categories.ExploreActivity; import fr.free.nrw.commons.notification.NotificationActivity; -import fr.free.nrw.commons.profile.ProfileActivity; import fr.free.nrw.commons.review.ReviewActivity; import fr.free.nrw.commons.settings.SettingsActivity; import fr.free.nrw.commons.upload.UploadActivity; @@ -68,7 +68,7 @@ public abstract class ActivityBuilderModule { abstract ExploreActivity bindExploreActivity(); @ContributesAndroidInjector - abstract ProfileActivity bindAchievementsActivity(); + abstract AchievementsActivity bindAchievementsActivity(); @ContributesAndroidInjector abstract BookmarksActivity bindBookmarksActivity(); diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java index 7fc66e507bf..f813de7629b 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.java @@ -2,7 +2,6 @@ import com.google.gson.Gson; -import fr.free.nrw.commons.explore.categories.CategoriesModule; import javax.inject.Singleton; import dagger.Component; @@ -11,9 +10,11 @@ import dagger.android.support.AndroidSupportInjectionModule; import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.auth.LoginActivity; +import fr.free.nrw.commons.contributions.ContributionViewHolder; import fr.free.nrw.commons.contributions.ContributionsModule; -import fr.free.nrw.commons.explore.depictions.DepictionModule; +import fr.free.nrw.commons.depictions.DepictionModule; import fr.free.nrw.commons.explore.SearchModule; +import fr.free.nrw.commons.nearby.PlaceRenderer; import fr.free.nrw.commons.review.ReviewController; import fr.free.nrw.commons.settings.SettingsFragment; import fr.free.nrw.commons.upload.FileProcessor; @@ -34,12 +35,7 @@ ActivityBuilderModule.class, FragmentBuilderModule.class, ServiceBuilderModule.class, - ContentProviderBuilderModule.class, - UploadModule.class, - ContributionsModule.class, - SearchModule.class, - DepictionModule.class, - CategoriesModule.class + ContentProviderBuilderModule.class, UploadModule.class, ContributionsModule.class, SearchModule.class, DepictionModule.class }) public interface CommonsApplicationComponent extends AndroidInjector { void inject(CommonsApplication application); @@ -53,6 +49,8 @@ public interface CommonsApplicationComponent extends AndroidInjector { Timber.tag("OkHttp").v(message); }); - httpLoggingInterceptor.setLevel(BuildConfig.DEBUG ? Level.BODY: Level.BASIC); + httpLoggingInterceptor.level(BuildConfig.DEBUG ? Level.BODY: Level.BASIC); return httpLoggingInterceptor; } @Provides @Singleton public OkHttpJsonApiClient provideOkHttpJsonApiClient(OkHttpClient okHttpClient, - DepictsClient depictsClient, @Named("tools_forge") HttpUrl toolsForgeUrl, - @Named("test_tools_forge") HttpUrl testToolsForgeUrl, @Named("default_preferences") JsonKvStore defaultKvStore, Gson gson) { return new OkHttpJsonApiClient(okHttpClient, - depictsClient, toolsForgeUrl, - testToolsForgeUrl, WIKIDATA_SPARQL_QUERY_URL, BuildConfig.WIKIMEDIA_CAMPAIGNS_URL, gson); @@ -126,14 +116,6 @@ public HttpUrl provideToolsForgeUrl() { return HttpUrl.parse(TOOLS_FORGE_URL); } - @Provides - @Named("test_tools_forge") - @NonNull - @SuppressWarnings("ConstantConditions") - public HttpUrl provideTestToolsForgeUrl() { - return HttpUrl.parse(TEST_TOOLS_FORGE_URL); - } - @Provides @Singleton @Named(NAMED_COMMONS_WIKI_SITE) @@ -249,21 +231,4 @@ public UserInterface provideUserInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSi public WikidataInterface provideWikidataInterface(@Named(NAMED_WIKI_DATA_WIKI_SITE) WikiSite wikiDataWikiSite) { return ServiceFactory.get(wikiDataWikiSite, BuildConfig.WIKIDATA_URL, WikidataInterface.class); } - - /** - * Add provider for PageMediaInterface - * It creates a retrofit service for the wiki site using device's current language - */ - @Provides - @Singleton - public PageMediaInterface providePageMediaInterface(@Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) WikiSite wikiSite) { - return ServiceFactory.get(wikiSite, wikiSite.url(), PageMediaInterface.class); - } - - @Provides - @Singleton - @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) - public WikiSite provideLanguageWikipediaSite() { - return WikiSite.forLanguageCode(Locale.getDefault().getLanguage()); - } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java index e86b06a7148..4d18c3a1984 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java @@ -5,37 +5,35 @@ import android.view.View; import android.widget.FrameLayout; import android.widget.SearchView; -import androidx.annotation.NonNull; + import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.viewpager.widget.ViewPager; -import butterknife.BindView; -import butterknife.ButterKnife; + import com.google.android.material.tabs.TabLayout; import com.jakewharton.rxbinding2.view.RxView; import com.jakewharton.rxbinding2.widget.RxSearchView; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import butterknife.BindView; +import butterknife.ButterKnife; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; import fr.free.nrw.commons.category.CategoryImagesCallback; -import fr.free.nrw.commons.explore.categories.search.SearchCategoryFragment; -import fr.free.nrw.commons.explore.depictions.search.SearchDepictionsFragment; -import fr.free.nrw.commons.explore.media.SearchMediaFragment; -import fr.free.nrw.commons.explore.recentsearches.RecentSearch; -import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao; +import fr.free.nrw.commons.explore.categories.SearchCategoryFragment; +import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragment; +import fr.free.nrw.commons.explore.images.SearchImageFragment; import fr.free.nrw.commons.explore.recentsearches.RecentSearchesFragment; import fr.free.nrw.commons.media.MediaDetailPagerFragment; import fr.free.nrw.commons.theme.NavigationBaseActivity; import fr.free.nrw.commons.utils.FragmentUtils; import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.android.schedulers.AndroidSchedulers; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.concurrent.TimeUnit; -import javax.inject.Inject; -import timber.log.Timber; /** * Represents search screen of this app @@ -51,16 +49,14 @@ public class SearchActivity extends NavigationBaseActivity @BindView(R.id.tab_layout) TabLayout tabLayout; @BindView(R.id.viewPager) ViewPager viewPager; - @Inject - RecentSearchesDao recentSearchesDao; - - private SearchMediaFragment searchMediaFragment; + private SearchImageFragment searchImageFragment; private SearchCategoryFragment searchCategoryFragment; private SearchDepictionsFragment searchDepictionsFragment; private RecentSearchesFragment recentSearchesFragment; private FragmentManager supportFragmentManager; private MediaDetailPagerFragment mediaDetails; ViewPagerAdapter viewPagerAdapter; + private String query; @Override protected void onCreate(Bundle savedInstanceState) { @@ -99,10 +95,10 @@ private void setSearchHistoryFragment() { public void setTabs() { List fragmentList = new ArrayList<>(); List titleList = new ArrayList<>(); - searchMediaFragment = new SearchMediaFragment(); + searchImageFragment = new SearchImageFragment(); searchDepictionsFragment = new SearchDepictionsFragment(); searchCategoryFragment= new SearchCategoryFragment(); - fragmentList.add(searchMediaFragment); + fragmentList.add(searchImageFragment); titleList.add(getResources().getString(R.string.search_tab_title_media).toUpperCase()); fragmentList.add(searchCategoryFragment); titleList.add(getResources().getString(R.string.search_tab_title_categories).toUpperCase()); @@ -115,57 +111,45 @@ public void setTabs() { .takeUntil(RxView.detaches(searchView)) .debounce(500, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(query -> { - //update image list - if (!TextUtils.isEmpty(query)) { - saveRecentSearch(query.toString()); - viewPager.setVisibility(View.VISIBLE); - tabLayout.setVisibility(View.VISIBLE); - searchHistoryContainer.setVisibility(View.GONE); + .subscribe( query -> { + this.query = query.toString(); + //update image list + if (!TextUtils.isEmpty(query)) { + viewPager.setVisibility(View.VISIBLE); + tabLayout.setVisibility(View.VISIBLE); + searchHistoryContainer.setVisibility(View.GONE); - if (FragmentUtils.isFragmentUIActive(searchDepictionsFragment)) { - searchDepictionsFragment.onQueryUpdated(query.toString()); - } + if (FragmentUtils.isFragmentUIActive(searchDepictionsFragment)) { + searchDepictionsFragment.updateDepictionList(query.toString()); + } - if (FragmentUtils.isFragmentUIActive(searchMediaFragment)) { - searchMediaFragment.onQueryUpdated(query.toString()); - } + if (FragmentUtils.isFragmentUIActive(searchImageFragment)) { + searchImageFragment.updateImageList(query.toString()); + } - if (FragmentUtils.isFragmentUIActive(searchCategoryFragment)) { - searchCategoryFragment.onQueryUpdated(query.toString()); - } + if (FragmentUtils.isFragmentUIActive(searchCategoryFragment)) { + searchCategoryFragment.updateCategoryList(query.toString()); + } - } else { - //Open RecentSearchesFragment - recentSearchesFragment.updateRecentSearches(); - viewPager.setVisibility(View.GONE); - tabLayout.setVisibility(View.GONE); - setSearchHistoryFragment(); - searchHistoryContainer.setVisibility(View.VISIBLE); + }else { + //Open RecentSearchesFragment + recentSearchesFragment.updateRecentSearches(); + viewPager.setVisibility(View.GONE); + tabLayout.setVisibility(View.GONE); + setSearchHistoryFragment(); + searchHistoryContainer.setVisibility(View.VISIBLE); + } } - }, Timber::e )); } - private void saveRecentSearch(@NonNull final String query) { - final RecentSearch recentSearch = recentSearchesDao.find(query); - // Newly searched query... - if (recentSearch == null) { - recentSearchesDao.save(new RecentSearch(null, query, new Date())); - } - else { - recentSearch.setLastSearched(new Date()); - recentSearchesDao.save(recentSearch); - } - } - /** * returns Media Object at position * @param i position of Media in the imagesRecyclerView adapter. */ @Override public Media getMediaAtPosition(int i) { - return searchMediaFragment.getMediaAtPosition(i); + return searchImageFragment.getImageAtPosition(i); } /** @@ -173,12 +157,7 @@ public Media getMediaAtPosition(int i) { */ @Override public int getTotalMediaCount() { - return searchMediaFragment.getTotalMediaCount(); - } - - @Override - public Integer getContributionStateAt(int position) { - return null; + return searchImageFragment.getTotalImagesCount(); } /** @@ -196,8 +175,7 @@ public void viewPagerNotifyDataSetChanged() { * Open media detail pager fragment on click of image in search results * @param index item index that should be opened */ - @Override - public void onMediaClicked(int index) { + public void onSearchImageClicked(int index) { ViewUtil.hideKeyboard(this.findViewById(R.id.searchBox)); toolbar.setVisibility(View.GONE); tabLayout.setVisibility(View.GONE); @@ -232,7 +210,7 @@ protected void onResume() { //FIXME: Temporary fix for screen rotation inside media details. If we don't call onBackPressed then fragment stack is increasing every time. //FIXME: Similar issue like this https://github.com/commons-app/apps-android-commons/issues/894 // This is called on screen rotation when user is inside media details. Ideally it should show Media Details but since we are not saving the state now. We are throwing the user to search screen otherwise the app was crashing. - // + // onBackPressed(); } super.onResume(); @@ -269,6 +247,17 @@ public void updateText(String query) { viewPager.requestFocus(); } + /** + * This method is called when viewPager has reached its end. + * Fetches more images using search query and adds it to the recycler view and viewpager adapter + */ + @Override + public void requestMoreImages() { + if (searchImageFragment!=null){ + searchImageFragment.addImagesToList(query); + } + } + @Override protected void onDestroy() { super.onDestroy(); //Dispose the disposables when the activity is destroyed diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java b/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java new file mode 100644 index 00000000000..f9894645109 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java @@ -0,0 +1,19 @@ +package fr.free.nrw.commons.explore; + +import dagger.Binds; +import dagger.Module; +import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragmentContract; +import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragmentPresenter; + +/** + * The Dagger Module for explore:depictions related presenters and (some other objects maybe in future) + */ +@Module +public abstract class SearchModule { + + @Binds + public abstract SearchDepictionsFragmentContract.UserActionListener bindsSearchDepictionsFragmentPresenter( + SearchDepictionsFragmentPresenter + presenter + ); +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java new file mode 100644 index 00000000000..da1c447a431 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java @@ -0,0 +1,230 @@ +package fr.free.nrw.commons.explore.categories; + + +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.pedrogomez.renderers.RVRendererAdapter; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; +import javax.inject.Named; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.category.CategoryClient; +import fr.free.nrw.commons.category.CategoryDetailsActivity; +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; +import fr.free.nrw.commons.explore.recentsearches.RecentSearch; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao; +import fr.free.nrw.commons.kvstore.JsonKvStore; +import fr.free.nrw.commons.utils.NetworkUtils; +import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +/** + * Displays the category search screen. + */ + +public class SearchCategoryFragment extends CommonsDaggerSupportFragment { + + private static int TIMEOUT_SECONDS = 15; + + @BindView(R.id.imagesListBox) + RecyclerView categoriesRecyclerView; + @BindView(R.id.imageSearchInProgress) + ProgressBar progressBar; + @BindView(R.id.imagesNotFound) + TextView categoriesNotFoundView; + String query; + @BindView(R.id.bottomProgressBar) + ProgressBar bottomProgressBar; + boolean isLoadingCategories; + + @Inject RecentSearchesDao recentSearchesDao; + @Inject CategoryClient categoryClient; + + @Inject + @Named("default_preferences") + JsonKvStore basicKvStore; + + private RVRendererAdapter categoriesAdapter; + private List queryList = new ArrayList<>(); + + private final SearchCategoriesAdapterFactory adapterFactory = new SearchCategoriesAdapterFactory(item -> { + // Called on Click of a individual category Item + // Open Category Details activity + CategoryDetailsActivity.startYourself(getContext(), item); + saveQuery(query); + }); + + /** + * This method saves Search Query in the Recent Searches Database. + * @param query + */ + private void saveQuery(String query) { + RecentSearch recentSearch = recentSearchesDao.find(query); + + // Newly searched query... + if (recentSearch == null) { + recentSearch = new RecentSearch(null, query, new Date()); + } + else { + recentSearch.setLastSearched(new Date()); + } + recentSearchesDao.save(recentSearch); + + } + + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_browse_image, container, false); + ButterKnife.bind(this, rootView); + if (getActivity().getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT){ + categoriesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + } + else{ + categoriesRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2)); + } + ArrayList items = new ArrayList<>(); + categoriesAdapter = adapterFactory.create(items); + categoriesRecyclerView.setAdapter(categoriesAdapter); + categoriesRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + // check if end of recycler view is reached, if yes then add more results to existing results + if (!recyclerView.canScrollVertically(1)) { + addCategoriesToList(query); + } + } + }); + return rootView; + } + + /** + * Checks for internet connection and then initializes the recycler view with 25 categories of the searched query + * Clearing categoryAdapter every time new keyword is searched so that user can see only new results + */ + public void updateCategoryList(String query) { + this.query = query; + categoriesNotFoundView.setVisibility(GONE); + if (!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + return; + } + bottomProgressBar.setVisibility(View.VISIBLE); + progressBar.setVisibility(GONE); + queryList.clear(); + categoriesAdapter.clear(); + compositeDisposable.add(categoryClient.searchCategories(query,25) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .doOnSubscribe(disposable -> saveQuery(query)) + .collect(ArrayList::new, ArrayList::add) + .subscribe(this::handleSuccess, this::handleError)); + } + + + /** + * Adds 25 more results to existing search results + */ + public void addCategoriesToList(String query) { + if(isLoadingCategories) return; + isLoadingCategories=true; + this.query = query; + bottomProgressBar.setVisibility(View.VISIBLE); + progressBar.setVisibility(GONE); + compositeDisposable.add(categoryClient.searchCategories(query,25, queryList.size()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .collect(ArrayList::new, ArrayList::add) + .subscribe(this::handlePaginationSuccess, this::handleError)); + } + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + */ + private void handlePaginationSuccess(List mediaList) { + queryList.addAll(mediaList); + progressBar.setVisibility(View.GONE); + bottomProgressBar.setVisibility(GONE); + categoriesAdapter.addAll(mediaList); + categoriesAdapter.notifyDataSetChanged(); + isLoadingCategories=false; + } + + + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + */ + private void handleSuccess(List mediaList) { + queryList = mediaList; + if (mediaList == null || mediaList.isEmpty()) { + initErrorView(); + } + else { + + bottomProgressBar.setVisibility(View.GONE); + progressBar.setVisibility(GONE); + categoriesAdapter.addAll(mediaList); + categoriesAdapter.notifyDataSetChanged(); + } + } + + /** + * Logs and handles API error scenario + */ + private void handleError(Throwable throwable) { + Timber.e(throwable, "Error occurred while loading queried categories"); + try { + initErrorView(); + ViewUtil.showShortSnackbar(categoriesRecyclerView, R.string.error_loading_categories); + }catch (Exception e){ + e.printStackTrace(); + } + + } + + /** + * Handles the UI updates for a error scenario + */ + private void initErrorView() { + progressBar.setVisibility(GONE); + categoriesNotFoundView.setVisibility(VISIBLE); + categoriesNotFoundView.setText(getString(R.string.categories_not_found,query)); + } + + /** + * Handles the UI updates for no internet scenario + */ + private void handleNoInternet() { + progressBar.setVisibility(GONE); + ViewUtil.showShortSnackbar(categoriesRecyclerView, R.string.no_internet); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictsClient.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictsClient.java new file mode 100644 index 00000000000..dcbf69a5f5c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictsClient.java @@ -0,0 +1,175 @@ +package fr.free.nrw.commons.explore.depictions; + +import androidx.annotation.Nullable; +import fr.free.nrw.commons.BuildConfig; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.depictions.models.Search; +import fr.free.nrw.commons.media.MediaInterface; +import fr.free.nrw.commons.upload.depicts.DepictsInterface; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import fr.free.nrw.commons.utils.CommonsDateUtil; +import fr.free.nrw.commons.wikidata.WikidataProperties; +import io.reactivex.Observable; +import io.reactivex.Single; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.wikipedia.wikidata.DataValue.DataValueString; +import org.wikipedia.wikidata.Statement_partial; + +/** + * Depicts Client to handle custom calls to Commons Wikibase APIs + */ +@Singleton +public class DepictsClient { + + private final DepictsInterface depictsInterface; + private final MediaInterface mediaInterface; + private static final String NO_DEPICTED_IMAGE = "No Image for Depiction"; + + @Inject + public DepictsClient(DepictsInterface depictsInterface, MediaInterface mediaInterface) { + this.depictsInterface = depictsInterface; + this.mediaInterface = mediaInterface; + } + + /** + * Search for depictions using the search item + * @return list of depicted items + */ + public Observable searchForDepictions(String query, int limit, int offset) { + return depictsInterface.searchForDepicts( + query, + String.valueOf(limit), + Locale.getDefault().getLanguage(), + Locale.getDefault().getLanguage(), + String.valueOf(offset) + ) + .flatMap(depictSearchResponse ->Observable.fromIterable(depictSearchResponse.getSearch())) + .map(DepictedItem::new); + } + + /** + * Get URL for image using image name + * Ex: title = Guion Bluford + * Url = https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Guion_Bluford.jpg/70px-Guion_Bluford.jpg + */ + private String getThumbnailUrl(String title) { + String baseUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/"; + title = title.replace(" ", "_"); + String MD5Hash = getMd5(title); + /** + * We use 70 pixels as the size of our Thumbnail (as it is the perfect fits our UI) + */ + return baseUrl + MD5Hash.charAt(0) + '/' + MD5Hash.charAt(0) + MD5Hash.charAt(1) + '/' + title + "/70px-" + title; + } + + /** + * Ex: entityId = Q357458 + * value returned = Elgin Baylor Night program.jpeg + */ + public Single getP18ForItem(String entityId) { + return depictsInterface.getImageForEntity(entityId) + .map(claimsResponse -> { + final List imageClaim = claimsResponse.getClaims() + .get(WikidataProperties.IMAGE.getPropertyName()); + if (imageClaim != null) { + final DataValueString dataValue = (DataValueString) imageClaim + .get(0) + .getMainSnak() + .getDataValue(); + return getThumbnailUrl((dataValue.getValue())); + } + return NO_DEPICTED_IMAGE; + }) + .singleOrError(); + } + + /** + * @return list of images for a particular depict entity + */ + public Observable> fetchImagesForDepictedItem(String query, int sroffset) { + return mediaInterface.fetchImagesForDepictedItem("haswbstatement:" + BuildConfig.DEPICTS_PROPERTY + "=" + query, String.valueOf(sroffset)) + .map(mwQueryResponse -> { + List mediaList = new ArrayList<>(); + for (Search s: mwQueryResponse.getQuery().getSearch()) { + Media media = new Media(null, + getUrl(s.getTitle()), + s.getTitle(), + "", + 0, + safeParseDate(s.getTimestamp()), + safeParseDate(s.getTimestamp()), + "" + ); + mediaList.add(media); + } + return mediaList; + }); + } + + /** + * Get url for the image from media of depictions + * Ex: Tiger_Woods + * Value: https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/Tiger_Woods.jpg/70px-Tiger_Woods.jpg + */ + private String getUrl(String title) { + String baseUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/"; + title = title.substring(title.indexOf(':')+1); + title = title.replace(" ", "_"); + String MD5Hash = getMd5(title); + return baseUrl + MD5Hash.charAt(0) + '/' + MD5Hash.charAt(0) + MD5Hash.charAt(1) + '/' + title + "/640px-" + title; + } + + /** + * Generates MD5 hash for the filename + */ + public String getMd5(String input) + { + try { + + // Static getInstance method is called with hashing MD5 + MessageDigest md = MessageDigest.getInstance("MD5"); + + // digest() method is called to calculate message digest + // of an input digest() return array of byte + byte[] messageDigest = md.digest(input.getBytes()); + + // Convert byte array into signum representation + BigInteger no = new BigInteger(1, messageDigest); + + // Convert message digest into hex value + String hashtext = no.toString(16); + while (hashtext.length() < 32) { + hashtext = "0" + hashtext; + } + return hashtext; + } + + // For specifying wrong message digest algorithms + catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + /** + * Parse the date string into the required format + * @param dateStr + * @return date in the required format + */ + @Nullable + private static Date safeParseDate(String dateStr) { + try { + return CommonsDateUtil.getIso8601DateFormatShort().parse(dateStr); + } catch (ParseException e) { + return null; + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsAdapterFactory.java new file mode 100644 index 00000000000..71d64b0ea16 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsAdapterFactory.java @@ -0,0 +1,31 @@ +package fr.free.nrw.commons.explore.depictions; + +import com.pedrogomez.renderers.ListAdapteeCollection; +import com.pedrogomez.renderers.RVRendererAdapter; +import com.pedrogomez.renderers.RendererBuilder; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; + +/** + * Adapter factory for Items in Explore + */ + +public class SearchDepictionsAdapterFactory { + private final SearchDepictionsRenderer.DepictCallback listener; + + public SearchDepictionsAdapterFactory(SearchDepictionsRenderer.DepictCallback listener) { + this.listener = listener; + } + + public RVRendererAdapter create() { + List searchImageItemList = new ArrayList<>(); + RendererBuilder builder = new RendererBuilder().bind(DepictedItem.class, new SearchDepictionsRenderer(listener)); + ListAdapteeCollection collection = new ListAdapteeCollection<>( + searchImageItemList != null ? searchImageItemList : Collections.emptyList()); + return new RVRendererAdapter<>(builder, collection); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.java new file mode 100644 index 00000000000..10ccf4ad4b5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.java @@ -0,0 +1,237 @@ +package fr.free.nrw.commons.explore.depictions; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.content.Context; +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import butterknife.BindView; +import butterknife.ButterKnife; +import com.pedrogomez.renderers.RVRendererAdapter; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.depictions.WikidataItemDetailsActivity; +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import fr.free.nrw.commons.utils.NetworkUtils; +import fr.free.nrw.commons.utils.ViewUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.inject.Inject; + +/** + * Display depictions in search fragment + */ +public class SearchDepictionsFragment extends CommonsDaggerSupportFragment implements SearchDepictionsFragmentContract.View { + + @BindView(R.id.imagesListBox) + RecyclerView depictionsRecyclerView; + @BindView(R.id.imageSearchInProgress) + ProgressBar progressBar; + @BindView(R.id.imagesNotFound) + TextView depictionNotFound; + @BindView(R.id.bottomProgressBar) + ProgressBar bottomProgressBar; + RecyclerView.LayoutManager layoutManager; + private boolean isLoading = true; + private int PAGE_SIZE = 25; + @Inject + SearchDepictionsFragmentPresenter presenter; + private final SearchDepictionsAdapterFactory adapterFactory = new SearchDepictionsAdapterFactory(new SearchDepictionsRenderer.DepictCallback() { + @Override + public void depictsClicked(DepictedItem item) { + WikidataItemDetailsActivity.startYourself(getContext(), item); + presenter.saveQuery(); + } + + /** + *fetch thumbnail image for all the depicted items (if available) + */ + @Override + public void fetchThumbnailUrlForEntity(String entityId, int position) { + presenter.fetchThumbnailForEntityId(entityId,position); + } + + }); + private RVRendererAdapter depictionsAdapter; + private boolean isLastPage; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_browse_image, container, false); + ButterKnife.bind(this, rootView); + if (getActivity().getResources().getConfiguration().orientation + == Configuration.ORIENTATION_PORTRAIT) { + layoutManager = new LinearLayoutManager(getContext()); + } else { + layoutManager = new GridLayoutManager(getContext(), 2); + } + depictionsRecyclerView.setLayoutManager(layoutManager); + depictionsAdapter = adapterFactory.create(); + depictionsRecyclerView.setAdapter(depictionsAdapter); + depictionsRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + } + + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + + int visibleItemCount = layoutManager.getChildCount(); + int totalItemCount = layoutManager.getItemCount(); + int firstVisibleItemPosition=0; + if(layoutManager instanceof GridLayoutManager){ + firstVisibleItemPosition=((GridLayoutManager) layoutManager).findFirstVisibleItemPosition(); + } else { + firstVisibleItemPosition=((LinearLayoutManager)layoutManager).findFirstVisibleItemPosition(); + } + + /** + * If the user isn't currently loading items and the last page hasn’t been reached, + * then it checks against the current position in view to decide whether or not to load more items. + */ + if (!isLoading && !isLastPage) { + if ((visibleItemCount + firstVisibleItemPosition) >= totalItemCount + && firstVisibleItemPosition >= 0 + && totalItemCount >= PAGE_SIZE) { + loadMoreItems(false); + } + } + } + }); + return rootView; + } + + /** + * Fetch PAGE_SIZE number of items + */ + private void loadMoreItems(boolean reInitialise) { + presenter.updateDepictionList(presenter.getQuery(),PAGE_SIZE, reInitialise); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + presenter.onAttachView(this); + } + + /** + * Called when user selects "Items" from Search Activity + * to load the list of depictions from API + * + * @param query string searched in the Explore Activity + */ + public void updateDepictionList(String query) { + presenter.initializeQuery(query); + if (!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + return; + } + loadMoreItems(true); + } + + /** + * Handles the UI updates for a error scenario + */ + @Override + public void initErrorView() { + isLoading = false; + progressBar.setVisibility(GONE); + bottomProgressBar.setVisibility(GONE); + depictionNotFound.setVisibility(VISIBLE); + String no_depiction = getString(R.string.depictions_not_found); + depictionNotFound.setText(String.format(Locale.getDefault(), no_depiction, presenter.getQuery())); + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + @Override + public void onResume() { + super.onResume(); + depictionsAdapter.clear(); + depictionsRecyclerView.cancelPendingInputEvents(); + } + + /** + * Handles the UI updates for no internet scenario + */ + @Override + public void handleNoInternet() { + progressBar.setVisibility(GONE); + ViewUtil.showShortSnackbar(depictionsRecyclerView, R.string.no_internet); + } + + /** + * If a non empty list is successfully returned from the api then modify the view + * like hiding empty labels, hiding progressbar and notifying the apdapter that list of items has been fetched from the API + */ + @Override + public void onSuccess(List mediaList) { + isLoading = false; + progressBar.setVisibility(View.GONE); + depictionNotFound.setVisibility(GONE); + bottomProgressBar.setVisibility(GONE); + int itemCount = layoutManager.getItemCount(); + depictionsAdapter.addAll(mediaList); + if(itemCount!=0) { + depictionsAdapter.notifyItemRangeInserted(itemCount, mediaList.size()-1); + }else{ + depictionsAdapter.notifyDataSetChanged(); + } + } + + @Override + public void loadingDepictions(boolean isLoading) { + depictionNotFound.setVisibility(GONE); + bottomProgressBar.setVisibility(View.VISIBLE); + progressBar.setVisibility(GONE); + this.isLoading = isLoading; + } + + @Override + public void clearAdapter() { + depictionsAdapter.clear(); + } + + @Override + public void showSnackbar() { + ViewUtil.showShortSnackbar(depictionsRecyclerView, R.string.error_loading_depictions); + } + + @Override + public RVRendererAdapter getAdapter() { + return depictionsAdapter; + } + + @Override + public void onImageUrlFetched(String response, int position) { + depictionsAdapter.getItem(position).setImageUrl(response); + depictionsAdapter.notifyItemChanged(position); + } + + /** + * Inform the view that there are no more items to be loaded for this search query + * or reset the isLastPage for the current query + * @param isLastPage + */ + @Override + public void setIsLastPage(boolean isLastPage) { + this.isLastPage=isLastPage; + progressBar.setVisibility(GONE); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.java new file mode 100644 index 00000000000..49ce776b31c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.java @@ -0,0 +1,94 @@ +package fr.free.nrw.commons.explore.depictions; + +import com.pedrogomez.renderers.RVRendererAdapter; + +import java.util.List; + +import fr.free.nrw.commons.BasePresenter; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; + +/** + * The contract with with SearchDepictionsFragment and its presenter would talk to each other + */ +public interface SearchDepictionsFragmentContract { + + interface View { + /** + * Handles the UI updates for a error scenario + */ + void initErrorView(); + + /** + * Handles the UI updates for no internet scenario + */ + void handleNoInternet(); + + /** + * If a non empty list is successfully returned from the api then modify the view + * like hiding empty labels, hiding progressbar and notifying the apdapter that list of items has been fetched from the API + */ + void onSuccess(List mediaList); + + /** + * load depictions + */ + void loadingDepictions(boolean isLoading); + + /** + * clear adapter + */ + void clearAdapter(); + + /** + * show snackbar + */ + void showSnackbar(); + + /** + * @return adapter + */ + RVRendererAdapter getAdapter(); + + void onImageUrlFetched(String response, int position); + + /** + * Inform the view that there are no more items to be loaded for this search query + * or reset the isLastPage for the current query + * @param isLastPage + */ + void setIsLastPage(boolean isLastPage); + } + + interface UserActionListener extends BasePresenter { + + /** + * Called when user selects "Items" from Search Activity + * to load the list of depictions from API + * + * @param query string searched in the Explore Activity + * @param reInitialise + */ + void updateDepictionList(String query, int pageSize, boolean reInitialise); + + /** + * This method saves Search Query in the Recent Searches Database. + */ + void saveQuery(); + + /** + * Whenever a new query is initiated from the search activity clear the previous adapter + * and add new value of the query + */ + void initializeQuery(String query); + + /** + * @return query + */ + String getQuery(); + + /** + * After all the depicted items are loaded fetch thumbnail image for all the depicted items (if available) + */ + void fetchThumbnailForEntityId(String entityId,int position); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.java new file mode 100644 index 00000000000..780fe06228a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.java @@ -0,0 +1,180 @@ +package fr.free.nrw.commons.explore.depictions; + +import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; +import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD; + +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; +import fr.free.nrw.commons.explore.recentsearches.RecentSearch; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao; +import fr.free.nrw.commons.kvstore.JsonKvStore; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import io.reactivex.Scheduler; +import io.reactivex.disposables.CompositeDisposable; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Named; +import timber.log.Timber; + +/** + * The presenter class for SearchDepictionsFragment + */ +public class SearchDepictionsFragmentPresenter extends CommonsDaggerSupportFragment implements SearchDepictionsFragmentContract.UserActionListener { + + /** + * This creates a dynamic proxy instance of the class, + * proxy is to control access to the target object + * here our target object is the view. + * Thus we when onDettach method of fragment is called we replace the binding of view to our object with the proxy instance + */ + private static final SearchDepictionsFragmentContract.View DUMMY = (SearchDepictionsFragmentContract.View) Proxy + .newProxyInstance( + SearchDepictionsFragmentContract.View.class.getClassLoader(), + new Class[]{SearchDepictionsFragmentContract.View.class}, + (proxy, method, methodArgs) -> null); + private static int TIMEOUT_SECONDS = 15; + protected CompositeDisposable compositeDisposable = new CompositeDisposable(); + private final Scheduler ioScheduler; + private final Scheduler mainThreadScheduler; + + boolean isLoadingDepictions; + String query; + RecentSearchesDao recentSearchesDao; + DepictsClient depictsClient; + JsonKvStore basicKvStore; + private SearchDepictionsFragmentContract.View view = DUMMY; + private List queryList = new ArrayList<>(); + int offset=0; + int size = 0; + + @Inject + public SearchDepictionsFragmentPresenter(@Named("default_preferences") JsonKvStore basicKvStore, + RecentSearchesDao recentSearchesDao, + DepictsClient depictsClient, + @Named(IO_THREAD) Scheduler ioScheduler, + @Named(MAIN_THREAD) Scheduler mainThreadScheduler) { + this.basicKvStore = basicKvStore; + this.recentSearchesDao = recentSearchesDao; + this.depictsClient = depictsClient; + this.ioScheduler = ioScheduler; + this.mainThreadScheduler = mainThreadScheduler; + } + + @Override + public void onAttachView(SearchDepictionsFragmentContract.View view) { + this.view = view; + } + + @Override + public void onDetachView() { + this.view = DUMMY; + } + + /** + * Called when user selects "Items" from Search Activity + * to load the list of depictions from API + * + * @param query string searched in the Explore Activity + * @param reInitialise + */ + @Override + public void updateDepictionList(String query, int pageSize, boolean reInitialise) { + this.query = query; + view.loadingDepictions(true); + if (reInitialise) { + size = 0; + } + saveQuery(); + compositeDisposable.add(depictsClient.searchForDepictions(query, 25, offset) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .doOnSubscribe(disposable -> saveQuery()) + .collect(ArrayList::new, ArrayList::add) + .subscribe(this::handleSuccess, this::handleError)); + } + + /** + * Logs and handles API error scenario + */ + private void handleError(Throwable throwable) { + Timber.e(throwable, "Error occurred while loading queried depictions"); + view.initErrorView(); + view.showSnackbar(); + } + + /** + * This method saves Search Query in the Recent Searches Database. + */ + @Override + public void saveQuery() { + RecentSearch recentSearch = recentSearchesDao.find(query); + + // Newly searched query... + if (recentSearch == null) { + recentSearch = new RecentSearch(null, query, new Date()); + } else { + recentSearch.setLastSearched(new Date()); + } + recentSearchesDao.save(recentSearch); + + } + + /** + * Whenever a new query is initiated from the search activity clear the previous adapter + * and add new value of the query + */ + @Override + public void initializeQuery(String query) { + this.query = query; + this.queryList.clear(); + offset = 0;//Reset the offset on query change + compositeDisposable.clear(); + view.setIsLastPage(false); + view.clearAdapter(); + } + + @Override + public String getQuery() { + return query; + } + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + */ + public void handleSuccess(List mediaList) { + if (mediaList == null || mediaList.isEmpty()) { + if(queryList.isEmpty()){ + view.initErrorView(); + }else{ + view.setIsLastPage(true); + } + } else { + this.queryList.addAll(mediaList); + view.onSuccess(mediaList); + offset=queryList.size(); + for (DepictedItem m : mediaList) { + fetchThumbnailForEntityId(m.getId(), size++); + } + } + } + + /** + * After all the depicted items are loaded fetch thumbnail image for all the depicted items (if available) + */ + @Override + public void fetchThumbnailForEntityId(String entityId,int position) { + compositeDisposable.add(depictsClient.getP18ForItem(entityId) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(response -> { + view.onImageUrlFetched(response,position); + })); + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsRenderer.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsRenderer.java new file mode 100644 index 00000000000..581858ef974 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsRenderer.java @@ -0,0 +1,127 @@ +package fr.free.nrw.commons.explore.depictions; + +import android.graphics.Bitmap; +import android.net.Uri; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import butterknife.BindView; +import butterknife.ButterKnife; + +import com.facebook.common.executors.CallerThreadExecutor; +import com.facebook.common.references.CloseableReference; +import com.facebook.datasource.DataSource; +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.imagepipeline.core.ImagePipeline; +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; +import com.facebook.imagepipeline.image.CloseableImage; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; +import com.pedrogomez.renderers.Renderer; + +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import timber.log.Timber; + +/** + * Renderer for DepictedItem + */ +public class SearchDepictionsRenderer extends Renderer { + + @BindView(R.id.depicts_label) + TextView tvDepictionLabel; + + @BindView(R.id.description) + TextView tvDepictionDesc; + + @BindView(R.id.depicts_image) + ImageView imageView; + + private DepictCallback listener; + + int size = 0; + private final static String NO_IMAGE_FOR_DEPICTION = "No Image for Depiction"; + + public SearchDepictionsRenderer(DepictCallback listener) { + this.listener = listener; + } + + @Override + protected void setUpView(View rootView) { + ButterKnife.bind(this, rootView); + } + + @Override + protected void hookListeners(View rootView) { + rootView.setOnClickListener(v -> { + DepictedItem item = getContent(); + if (listener != null) { + listener.depictsClicked(item); + } + }); + } + + @Override + protected View inflate(LayoutInflater inflater, ViewGroup parent) { + return inflater.inflate(R.layout.item_depictions, parent, false); + } + + /** + * Render value to all the items in the search depictions list + */ + @Override + public void render() { + DepictedItem item = getContent(); + tvDepictionLabel.setText(item.getName()); + tvDepictionDesc.setText(item.getDescription()); + imageView.setImageDrawable(getContext().getResources().getDrawable(R.drawable.ic_wikidata_logo_24dp)); + + Timber.e("line86"+item.getImageUrl()); + if (!TextUtils.isEmpty(item.getImageUrl())) { + if (!item.getImageUrl().equals(NO_IMAGE_FOR_DEPICTION) && !item.getImageUrl().equals("")) + { + ImageRequest imageRequest = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(item.getImageUrl())) + .setAutoRotateEnabled(true) + .build(); + + ImagePipeline imagePipeline = Fresco.getImagePipeline(); + final DataSource> + dataSource = imagePipeline.fetchDecodedImage(imageRequest, getContext()); + + dataSource.subscribe(new BaseBitmapDataSubscriber() { + + @Override + public void onNewResultImpl(@Nullable Bitmap bitmap) { + if (dataSource.isFinished() && bitmap != null) { + Timber.d("Bitmap loaded from url %s", item.getImageUrl()); + //imageView.setImageBitmap(Bitmap.createBitmap(bitmap)); + imageView.post(() -> imageView.setImageBitmap(Bitmap.createBitmap(bitmap))); + dataSource.close(); + } + } + + @Override + public void onFailureImpl(DataSource dataSource) { + Timber.d("Error getting bitmap from image url %s", item.getImageUrl()); + if (dataSource != null) { + dataSource.close(); + } + } + }, CallerThreadExecutor.getInstance()); + } + } + } + + public interface DepictCallback { + void depictsClicked(DepictedItem item); + + void fetchThumbnailUrlForEntity(String entityId,int position); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java new file mode 100644 index 00000000000..28099f8ce66 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java @@ -0,0 +1,302 @@ +package fr.free.nrw.commons.explore.images; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX; + +import android.annotation.SuppressLint; +import android.content.res.Configuration; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import butterknife.BindView; +import butterknife.ButterKnife; +import com.pedrogomez.renderers.RVRendererAdapter; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; +import fr.free.nrw.commons.explore.SearchActivity; +import fr.free.nrw.commons.explore.recentsearches.RecentSearch; +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao; +import fr.free.nrw.commons.kvstore.JsonKvStore; +import fr.free.nrw.commons.media.MediaClient; +import fr.free.nrw.commons.utils.NetworkUtils; +import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Named; +import timber.log.Timber; + +/** + * Displays the image search screen. + */ + +public class SearchImageFragment extends CommonsDaggerSupportFragment { + + private static int TIMEOUT_SECONDS = 15; + + @BindView(R.id.imagesListBox) + RecyclerView imagesRecyclerView; + @BindView(R.id.imageSearchInProgress) + ProgressBar progressBar; + @BindView(R.id.imagesNotFound) + TextView imagesNotFoundView; + String query; + @BindView(R.id.bottomProgressBar) + ProgressBar bottomProgressBar; + + @Inject RecentSearchesDao recentSearchesDao; + @Inject + MediaClient mediaClient; + @Inject + @Named("default_preferences") + JsonKvStore defaultKvStore; + + /** + * A variable to store number of list items for whom API has been called to fetch captions + */ + private int mediaSize = 0; + + private RVRendererAdapter imagesAdapter; + private List queryList = new ArrayList<>(); + + private final SearchImagesAdapterFactory adapterFactory = new SearchImagesAdapterFactory(item -> { + // Called on Click of a individual media Item + int index = queryList.indexOf(item); + ((SearchActivity)getContext()).onSearchImageClicked(index); + saveQuery(query); + }); + + /** + * This method saves Search Query in the Recent Searches Database. + * @param query + */ + private void saveQuery(String query) { + RecentSearch recentSearch = recentSearchesDao.find(query); + + // Newly searched query... + if (recentSearch == null) { + recentSearch = new RecentSearch(null, query, new Date()); + } + else { + recentSearch.setLastSearched(new Date()); + } + + recentSearchesDao.save(recentSearch); + + } + + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_browse_image, container, false); + ButterKnife.bind(this, rootView); + if (getContext().getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT){ + imagesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + } + else{ + imagesRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2)); + } + ArrayList items = new ArrayList<>(); + imagesAdapter = adapterFactory.create(items); + imagesRecyclerView.setAdapter(imagesAdapter); + imagesRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + // check if end of recycler view is reached, if yes then add more results to existing results + if (!recyclerView.canScrollVertically(1)) { + addImagesToList(query); + } + } + }); + return rootView; + } + + /** + * Checks for internet connection and then initializes the recycler view with 25 images of the searched query + * Clearing imageAdapter every time new keyword is searched so that user can see only new results + */ + @SuppressLint("CheckResult") + public void updateImageList(String query) { + this.query = query; + if (imagesNotFoundView != null) { + imagesNotFoundView.setVisibility(GONE); + } + if (!NetworkUtils.isInternetConnectionEstablished(getContext())) { + handleNoInternet(); + return; + } + progressBar.setVisibility(View.VISIBLE); + bottomProgressBar.setVisibility(GONE); + queryList.clear(); + imagesAdapter.clear(); + compositeDisposable.add(mediaClient.getMediaListFromSearch(query) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .doOnSubscribe(disposable -> saveQuery(query)) + .subscribe(this::handleSuccess, this::handleError)); + } + + + /** + * Adds more results to existing search results + */ + @SuppressLint("CheckResult") + public void addImagesToList(String query) { + this.query = query; + bottomProgressBar.setVisibility(View.VISIBLE); + progressBar.setVisibility(GONE); + compositeDisposable.add(mediaClient.getMediaListFromSearch(query) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(this::handlePaginationSuccess, this::handleError)); + } + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + * @param mediaList List of media to be added + */ + private void handlePaginationSuccess(List mediaList) { + progressBar.setVisibility(View.GONE); + bottomProgressBar.setVisibility(GONE); + 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(); + } + } + + + + /** + * Handles the success scenario + * it initializes the recycler view by adding items to the adapter + * @param mediaList List of media to be shown + */ + private void handleSuccess(List mediaList) { + queryList = mediaList; + if (mediaList == null || mediaList.isEmpty()) { + initErrorView(); + } + else { + bottomProgressBar.setVisibility(View.GONE); + progressBar.setVisibility(GONE); + imagesAdapter.addAll(mediaList); + imagesAdapter.notifyDataSetChanged(); + ((SearchActivity)getContext()).viewPagerNotifyDataSetChanged(); + for (Media m : mediaList) { + final String pageId = m.getPageId(); + if (pageId != null) { + replaceTitlesWithCaptions(PAGE_ID_PREFIX + pageId, mediaSize++); + } + } + } + } + + /** + * In explore we first show title and simultaneously call the API to retrieve captions + * When captions are retrieved they replace title + */ + + public void replaceTitlesWithCaptions(String wikibaseIdentifier, int position) { + compositeDisposable.add(mediaClient.getCaptionByWikibaseIdentifier(wikibaseIdentifier) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(subscriber -> { + handleLabelforImage(subscriber, position); + })); + + } + + private void handleLabelforImage(String s, int position) { + if (!s.trim().equals(getString(R.string.detail_caption_empty))) { + imagesAdapter.getItem(position).setThumbnailTitle(s); + imagesAdapter.notifyDataSetChanged(); + } + } + + /** + * Logs and handles API error scenario + * @param throwable + */ + private void handleError(Throwable throwable) { + Timber.e(throwable, "Error occurred while loading queried images"); + try { + ViewUtil.showShortSnackbar(imagesRecyclerView, R.string.error_loading_images); + }catch (Exception e){ + e.printStackTrace(); + } + } + + /** + * Handles the UI updates for a error scenario + */ + private void initErrorView() { + progressBar.setVisibility(GONE); + imagesNotFoundView.setVisibility(VISIBLE); + imagesNotFoundView.setText(getString(R.string.images_not_found,query)); + } + + /** + * Handles the UI updates for no internet scenario + */ + private void handleNoInternet() { + if (null + != getView()) {//We have exposed public methods to update our ui, we will have to add null checks until we make this lifecycle aware + if (null != progressBar) { + progressBar.setVisibility(GONE); + } + ViewUtil.showShortSnackbar(imagesRecyclerView, R.string.no_internet); + } else { + Timber.d("Attempt to update fragment ui after its view was destroyed"); + } + } + + /** + * returns total number of images present in the recyclerview adapter. + */ + public int getTotalImagesCount(){ + if (imagesAdapter == null) { + return 0; + } + else { + return imagesAdapter.getItemCount(); + } + } + + /** + * returns Media Object at position + * @param i position of Media in the recyclerview adapter. + */ + public Media getImageAtPosition(int i) { + if (imagesAdapter.getItem(i).getFilename() == null) { + // not yet ready to return data + return null; + } + else { + return imagesAdapter.getItem(i); + } + } + + @Override public void onDestroyView() { + super.onDestroyView(); + compositeDisposable.clear(); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesRenderer.java b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesRenderer.java new file mode 100644 index 00000000000..8985a84845d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImagesRenderer.java @@ -0,0 +1,74 @@ +package fr.free.nrw.commons.explore.images; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.facebook.drawee.view.SimpleDraweeView; +import com.pedrogomez.renderers.Renderer; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.R; + +/** + * presentation logic of individual image in search is handled here + **/ +class SearchImagesRenderer extends Renderer { + @BindView(R.id.categoryImageTitle) TextView tvImageName; + @BindView(R.id.categoryImageAuthor) TextView categoryImageAuthor; + @BindView(R.id.categoryImageView) SimpleDraweeView browseImage; + + private final ImageClickedListener listener; + + SearchImagesRenderer(ImageClickedListener listener) { + this.listener = listener; + } + + @Override + protected View inflate(LayoutInflater layoutInflater, ViewGroup viewGroup) { + return layoutInflater.inflate(R.layout.layout_category_images, viewGroup, false); + } + + @Override + protected void setUpView(View view) { + ButterKnife.bind(this, view); + } + + @Override + protected void hookListeners(View view) { + view.setOnClickListener(v -> { + Media item = getContent(); + if (listener != null) { + listener.imageClicked(item); + } + }); + } + + @Override + public void render() { + Media item = getContent(); + tvImageName.setText(item.getThumbnailTitle()); + browseImage.setImageURI(item.getThumbUrl()); + setAuthorView(item, categoryImageAuthor); + } + + interface ImageClickedListener { + void imageClicked(Media item); + } + + /** + * formats author name as "Uploaded by: authorName" and sets it in textview + */ + private void setAuthorView(Media item, TextView author) { + if (item.getCreator() != null && !item.getCreator().equals("")) { + author.setVisibility(View.VISIBLE); + String uploadedByTemplate = getContext().getString(R.string.image_uploaded_by); + author.setText(String.format(uploadedByTemplate, item.getCreator())); + } else { + author.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/Depictions.kt b/app/src/main/java/fr/free/nrw/commons/media/Depictions.kt new file mode 100644 index 00000000000..ae4191d0697 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/Depictions.kt @@ -0,0 +1,30 @@ +package fr.free.nrw.commons.media + +import android.os.Parcelable +import androidx.annotation.WorkerThread +import fr.free.nrw.commons.wikidata.WikidataProperties.DEPICTS +import kotlinx.android.parcel.Parcelize +import org.wikipedia.wikidata.DataValue.DataValueEntityId +import org.wikipedia.wikidata.Entities +import java.util.* + +@Parcelize +data class Depictions(val depictions: List) : Parcelable { + companion object { + @JvmStatic + @WorkerThread + fun from(entities: Entities, mediaClient: MediaClient) = + Depictions( + entities.first?.statements + ?.getOrElse(DEPICTS.propertyName, { emptyList() }) + ?.map { statement -> + (statement.mainSnak.dataValue as DataValueEntityId).value.id + } + ?.map { id -> IdAndLabel(id, fetchLabel(mediaClient, id)) } + ?: emptyList() + ) + + private fun fetchLabel(mediaClient: MediaClient, id: String) = + mediaClient.getLabelForDepiction(id, Locale.getDefault().language).blockingGet() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/IdAndLabel.kt b/app/src/main/java/fr/free/nrw/commons/media/IdAndLabel.kt new file mode 100644 index 00000000000..00ca69f0825 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/IdAndLabel.kt @@ -0,0 +1,14 @@ +package fr.free.nrw.commons.media + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize +import org.wikipedia.wikidata.Entities + +@Parcelize +data class IdAndLabel(val entityId: String, val entityLabel: String) : Parcelable { + constructor(entityId: String, entities: MutableMap) : this( + entityId, + entities.values.first().labels().values.first().value() + ) +} + diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaClient.java b/app/src/main/java/fr/free/nrw/commons/media/MediaClient.java new file mode 100644 index 00000000000..a6ba1897e8f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaClient.java @@ -0,0 +1,226 @@ +package fr.free.nrw.commons.media; + + +import androidx.annotation.NonNull; +import fr.free.nrw.commons.Media; +import fr.free.nrw.commons.utils.CommonsDateUtil; +import io.reactivex.Observable; +import io.reactivex.Single; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.wikipedia.dataclient.mwapi.MwQueryResponse; +import org.wikipedia.wikidata.Entities; +import org.wikipedia.wikidata.Entities.Entity; +import org.wikipedia.wikidata.Entities.Label; +import timber.log.Timber; + +/** + * Media Client to handle custom calls to Commons MediaWiki APIs + */ +@Singleton +public class MediaClient { + + private final MediaInterface mediaInterface; + private final MediaDetailInterface mediaDetailInterface; + + //OkHttpJsonApiClient used JsonKvStore for this. I don't know why. + private Map> continuationStore; + public static final String NO_CAPTION = "No caption"; + private static final String NO_DEPICTION = "No depiction"; + + @Inject + public MediaClient(MediaInterface mediaInterface, MediaDetailInterface mediaDetailInterface) { + this.mediaInterface = mediaInterface; + this.mediaDetailInterface = mediaDetailInterface; + this.continuationStore = new HashMap<>(); + } + + /** + * Checks if a page exists on Commons + * The same method can be used to check for file or talk page + * + * @param title File:Test.jpg or Commons:Deletion_requests/File:Test1.jpeg + */ + public Single checkPageExistsUsingTitle(String title) { + return mediaInterface.checkPageExistsUsingTitle(title) + .map(mwQueryResponse -> mwQueryResponse + .query().firstPage().pageId() > 0) + .singleOrError(); + } + + /** + * Take the fileSha and returns whether a file with a matching SHA exists or not + * + * @param fileSha SHA of the file to be checked + */ + public Single checkFileExistsUsingSha(String fileSha) { + return mediaInterface.checkFileExistsUsingSha(fileSha) + .map(mwQueryResponse -> mwQueryResponse + .query().allImages().size() > 0) + .singleOrError(); + } + + /** + * This method takes the category as input and returns a list of Media objects filtered using image generator query + * It uses the generator query API to get the images searched using a query, 10 at a time. + * + * @param category the search category. Must start with "Category:" + * @return + */ + public Single> getMediaListFromCategory(String category) { + return responseToMediaList( + continuationStore.containsKey("category_" + category) ? + mediaInterface.getMediaListFromCategory(category, 10, continuationStore.get("category_" + category)) : //if true + mediaInterface.getMediaListFromCategory(category, 10, Collections.emptyMap()), + "category_" + category); //if false + + } + + /** + * This method takes a keyword as input and returns a list of Media objects filtered using image generator query + * It uses the generator query API to get the images searched using a query, 10 at a time. + * + * @param keyword the search keyword + * @return + */ + public Single> getMediaListFromSearch(String keyword) { + return responseToMediaList( + continuationStore.containsKey("search_" + keyword) && (continuationStore.get("search_" + keyword) != null) ? + mediaInterface.getMediaListFromSearch(keyword, 10, continuationStore.get("search_" + keyword)) : //if true + mediaInterface.getMediaListFromSearch(keyword, 10, Collections.emptyMap()), //if false + "search_" + keyword); + + } + + private Single> responseToMediaList(Observable response, String key) { + return response.flatMap(mwQueryResponse -> { + if (null == mwQueryResponse + || null == mwQueryResponse.query() + || null == mwQueryResponse.query().pages()) { + return Observable.empty(); + } + continuationStore.put(key, mwQueryResponse.continuation()); + return Observable.fromIterable(mwQueryResponse.query().pages()); + }) + .map(Media::from) + .collect(ArrayList::new, List::add); + } + + /** + * Fetches Media object from the imageInfo API + * + * @param titles the tiles to be searched for. Can be filename or template name + * @return + */ + public Single getMedia(String titles) { + return mediaInterface.getMedia(titles) + .flatMap(mwQueryResponse -> { + if (null == mwQueryResponse + || null == mwQueryResponse.query() + || null == mwQueryResponse.query().firstPage()) { + return Observable.empty(); + } + return Observable.just(mwQueryResponse.query().firstPage()); + }) + .map(Media::from) + .single(Media.EMPTY); + } + + /** + * The method returns the picture of the day + * + * @return Media object corresponding to the picture of the day + */ + @NonNull + public Single getPictureOfTheDay() { + String date = CommonsDateUtil.getIso8601DateFormatShort().format(new Date()); + Timber.d("Current date is %s", date); + String template = "Template:Potd/" + date; + return mediaInterface.getMediaWithGenerator(template) + .flatMap(mwQueryResponse -> { + if (null == mwQueryResponse + || null == mwQueryResponse.query() + || null == mwQueryResponse.query().firstPage()) { + return Observable.empty(); + } + return Observable.just(mwQueryResponse.query().firstPage()); + }) + .map(Media::from) + .single(Media.EMPTY); + } + + + @NonNull + public Single getPageHtml(String title){ + return mediaInterface.getPageHtml(title) + .filter(MwParseResponse::success) + .map(MwParseResponse::parse) + .map(MwParseResult::text) + .first(""); + } + + + /** + * @return caption for image using wikibaseIdentifier + */ + public Single getCaptionByWikibaseIdentifier(String wikibaseIdentifier) { + return mediaDetailInterface.getCaptionForImage(Locale.getDefault().getLanguage(), wikibaseIdentifier) + .map(mediaDetailResponse -> { + if (isSuccess(mediaDetailResponse)) { + for (Entity wikibaseItem : mediaDetailResponse.entities().values()) { + for (Label label : wikibaseItem.labels().values()) { + return label.value(); + } + } + } + return NO_CAPTION; + }) + .singleOrError(); + } + + private boolean isSuccess(Entities response) { + return response != null && response.getSuccess() == 1 && response.entities() != null; + } + + /** + * Fetches Structured data from API + * + * @param filename + * @return a map containing caption and depictions (empty string in the map if no caption/depictions) + */ + public Single getDepictions(String filename) { + return mediaDetailInterface.fetchEntitiesByFileName(Locale.getDefault().getLanguage(), filename) + .map(entities -> Depictions.from(entities, this)) + .singleOrError(); + } + + /** + * Gets labels for Depictions using Entity Id from MediaWikiAPI + * + * @param entityId EntityId (Ex: Q81566) of the depict entity + * @return label + */ + public Single getLabelForDepiction(String entityId, String language) { + return mediaDetailInterface.getEntity(entityId, language) + .map(entities -> { + if (isSuccess(entities)) { + for (Entity entity : entities.entities().values()) { + for (Label label : entity.labels().values()) { + return label.value(); + } + } + } + throw new RuntimeException("failed getEntities"); + }) + .singleOrError(); + } + + } + diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java index baf6cac8b27..a9a402b6c72 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java @@ -1,10 +1,13 @@ package fr.free.nrw.commons.media; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + import android.annotation.SuppressLint; -import android.graphics.drawable.Animatable; import android.app.AlertDialog; -import android.content.Intent; import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Animatable; import android.net.Uri; import android.os.Bundle; import android.text.Editable; @@ -22,28 +25,17 @@ import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; - +import androidx.annotation.Nullable; +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.drawee.interfaces.DraweeController; import com.facebook.drawee.controller.BaseControllerListener; import com.facebook.drawee.controller.ControllerListener; +import com.facebook.drawee.interfaces.DraweeController; import com.facebook.drawee.view.SimpleDraweeView; import com.facebook.imagepipeline.image.ImageInfo; import com.facebook.imagepipeline.request.ImageRequest; - -import org.apache.commons.lang3.StringUtils; -import org.wikipedia.util.DateUtil; - -import java.util.ArrayList; -import java.util.Date; -import java.util.Locale; - -import javax.inject.Inject; - -import butterknife.BindView; -import butterknife.ButterKnife; -import butterknife.OnClick; -import androidx.annotation.Nullable; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.MediaDataExtractor; import fr.free.nrw.commons.R; @@ -53,19 +45,24 @@ import fr.free.nrw.commons.contributions.ContributionsFragment; import fr.free.nrw.commons.delete.DeleteHelper; import fr.free.nrw.commons.delete.ReasonBuilder; +import fr.free.nrw.commons.depictions.WikidataItemDetailsActivity; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.ui.widget.CompatTextView; import fr.free.nrw.commons.ui.widget.HtmlTextView; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; import fr.free.nrw.commons.utils.ViewUtilWrapper; import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; +import java.util.ArrayList; +import java.util.Date; +import java.util.Locale; +import javax.inject.Inject; +import org.apache.commons.lang3.StringUtils; +import org.wikipedia.util.DateUtil; import timber.log.Timber; -import static android.view.View.GONE; -import static android.view.View.VISIBLE; - public class MediaDetailFragment extends CommonsDaggerSupportFragment { private boolean editable; @@ -108,6 +105,12 @@ public static MediaDetailFragment forMedia(int index, boolean editable, boolean LinearLayout imageSpacer; @BindView(R.id.mediaDetailTitle) TextView title; + @BindView(R.id.caption_layout) + LinearLayout captionLayout; + @BindView(R.id.depicts_layout) + LinearLayout depictsLayout; + @BindView(R.id.media_detail_caption) + TextView mediaCaption; @BindView(R.id.mediaDetailDesc) HtmlTextView desc; @BindView(R.id.mediaDetailAuthor) @@ -126,6 +129,8 @@ public static MediaDetailFragment forMedia(int index, boolean editable, boolean LinearLayout nominatedForDeletion; @BindView(R.id.mediaDetailCategoryContainer) LinearLayout categoryContainer; + @BindView(R.id.media_detail_depiction_container) + LinearLayout depictionContainer; @BindView(R.id.authorLinearLayout) LinearLayout authorLayout; @BindView(R.id.nominateDeletion) @@ -134,8 +139,15 @@ public static MediaDetailFragment forMedia(int index, boolean editable, boolean ScrollView scrollView; private ArrayList categoryNames; + /** + * Depicts is a feature part of Structured data. Multiple Depictions can be added for an image just like categories. + * However unlike categories depictions is multi-lingual + * Ex: key: en value: monument + */ + private Depictions depictions; private boolean categoriesLoaded = false; private boolean categoriesPresent = false; + private boolean depictionLoaded = false; private ViewTreeObserver.OnGlobalLayoutListener layoutListener; // for layout stuff, only used once! private ViewTreeObserver.OnScrollChangedListener scrollListener; @@ -243,7 +255,7 @@ private void displayMediaDetails() { desc.setHtmlText(media.getDescription()); license.setText(media.getLicense()); - Disposable disposable = mediaDataExtractor.fetchMediaDetails(media.getFilename()) + Disposable disposable = mediaDataExtractor.fetchMediaDetails(media.getFilename(), media.getPageId()) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::setTextFields); @@ -318,18 +330,32 @@ private void setTextFields(Media media) { coordinates.setText(prettyCoordinates(media)); uploadedDate.setText(prettyUploadedDate(media)); mediaDiscussion.setText(prettyDiscussion(media)); + if (prettyCaption(media).equals(getContext().getString(R.string.detail_caption_empty))) { + captionLayout.setVisibility(GONE); + } else mediaCaption.setText(prettyCaption(media)); + categoryNames.clear(); categoryNames.addAll(media.getCategories()); + depictions=media.getDepiction(); + + depictionLoaded = true; + categoriesLoaded = true; categoriesPresent = (categoryNames.size() > 0); if (!categoriesPresent) { // Stick in a filler element. categoryNames.add(getString(R.string.detail_panel_cats_none)); } + rebuildCatList(); + if(depictions != null) { + rebuildDepictionList(); + } + else depictsLayout.setVisibility(GONE); + if (media.getCreator() == null || media.getCreator().equals("")) { authorLayout.setVisibility(GONE); } else { @@ -339,6 +365,21 @@ private void setTextFields(Media media) { checkDeletion(media); } + /** + * Populates media details fragment with depiction list + */ + private void rebuildDepictionList() { + depictionContainer.removeAllViews(); + for (IdAndLabel depiction : depictions.getDepictions()) { + depictionContainer.addView( + buildDepictLabel( + depiction.getEntityLabel(), + depiction.getEntityId(), + depictionContainer + )); + } + } + @OnClick(R.id.mediaDetailLicense) public void onMediaDetailLicenceClicked(){ String url = media.getLicenseUrl(); @@ -505,6 +546,26 @@ private void rebuildCatList() { } } + /** + * Add view to depictions obtained also tapping on depictions should open the url + */ + private View buildDepictLabel(String depictionName, String entityId, LinearLayout depictionContainer) { + final View item = LayoutInflater.from(getContext()).inflate(R.layout.detail_category_item, depictionContainer, false); + final CompatTextView textView = item.findViewById(R.id.mediaDetailCategoryItemText); + + textView.setText(depictionName); + if (depictionLoaded) { + item.setOnClickListener(view -> { + DepictedItem depictedItem = new DepictedItem(depictionName, "", "", false, entityId); + Intent intent = new Intent(getContext(), WikidataItemDetailsActivity.class); + intent.putExtra("wikidataItemName", depictedItem.getName()); + intent.putExtra("entityId", depictedItem.getId()); + getContext().startActivity(intent); + }); + } + return item; + } + private View buildCatLabel(final String catName, ViewGroup categoryContainer) { final View item = LayoutInflater.from(getContext()).inflate(R.layout.detail_category_item, categoryContainer, false); final CompatTextView textView = item.findViewById(R.id.mediaDetailCategoryItemText); @@ -534,9 +595,24 @@ private void updateTheDarkness() { image.setAlpha(1.0f - scrollPercentage); } + /** + * Returns captions for media details + * + * @param media object of class media + * @return caption as string + */ + private String prettyCaption(Media media) { + String caption = media.getCaption().trim(); + if (caption.equals("")) { + return getString(R.string.detail_caption_empty); + } else { + return caption; + } + } + private String prettyDescription(Media media) { // @todo use UI language when multilingual descs are available - String desc = media.getDescription(locale.getLanguage()).trim(); + String desc = media.getDescription(); if (desc.equals("")) { return getString(R.string.detail_description_empty); } else { @@ -582,7 +658,7 @@ private String prettyCoordinates(Media media) { } private void checkDeletion(Media media){ - if (media.getRequestedDeletion()){ + if (media.isRequestedDeletion()){ delete.setVisibility(GONE); nominatedForDeletion.setVisibility(VISIBLE); } else if (!isCategoryImage) { diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailInterface.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailInterface.java index ef9559c29ab..c972ccc12cc 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailInterface.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailInterface.java @@ -1,7 +1,6 @@ package fr.free.nrw.commons.media; import io.reactivex.Observable; -import io.reactivex.Single; import org.wikipedia.wikidata.Entities; import retrofit2.http.GET; import retrofit2.http.Query; @@ -21,11 +20,12 @@ public interface MediaDetailInterface { /** * Gets labels for Depictions using Entity Id from MediaWikiAPI - * @param entityId EntityId (Ex: Q81566) of the depict entity * + * @param entityId EntityId (Ex: Q81566) of the depict entity + * @param language user's locale */ @GET("/w/api.php?format=json&action=wbgetentities&props=labels&languagefallback=1") - Single getEntity(@Query("ids") String entityId); + Observable getEntity(@Query("ids") String entityId, @Query("languages") String language); /** * Fetches caption using wikibaseIdentifier @@ -33,5 +33,5 @@ public interface MediaDetailInterface { * @param wikibaseIdentifier pageId for the media */ @GET("/w/api.php?action=wbgetentities&props=labels&format=json&languagefallback=1&sites=commonswiki") - Observable getEntityForImage(@Query("languages") String language, @Query("ids") String wikibaseIdentifier); + Observable getCaptionForImage(@Query("languages") String language, @Query("ids") String wikibaseIdentifier); } diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java index 59f779b4cdb..39c81386e39 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java @@ -13,6 +13,7 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.widget.Toast; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentStatePagerAdapter; diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaInterface.java b/app/src/main/java/fr/free/nrw/commons/media/MediaInterface.java index c104235a987..10dd02a8323 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaInterface.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaInterface.java @@ -1,8 +1,11 @@ package fr.free.nrw.commons.media; -import io.reactivex.Single; -import java.util.Map; import org.wikipedia.dataclient.mwapi.MwQueryResponse; + +import java.util.Map; + +import fr.free.nrw.commons.depictions.models.DepictionResponse; +import io.reactivex.Observable; import retrofit2.http.GET; import retrofit2.http.Query; import retrofit2.http.QueryMap; @@ -14,7 +17,6 @@ public interface MediaInterface { String MEDIA_PARAMS="&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=640" + "&iiextmetadatafilter=DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal" + "|Artist|LicenseShortName|LicenseUrl"; - /** * Checks if a page exists or not. * @@ -22,7 +24,7 @@ public interface MediaInterface { * @return */ @GET("w/api.php?action=query&format=json&formatversion=2") - Single checkPageExistsUsingTitle(@Query("titles") String title); + Observable checkPageExistsUsingTitle(@Query("titles") String title); /** * Check if file exists @@ -31,7 +33,7 @@ public interface MediaInterface { * @return */ @GET("w/api.php?action=query&format=json&formatversion=2&list=allimages") - Single checkFileExistsUsingSha(@Query("aisha1") String aisha1); + Observable checkFileExistsUsingSha(@Query("aisha1") String aisha1); /** * This method retrieves a list of Media objects filtered using image generator query @@ -44,34 +46,20 @@ public interface MediaInterface { @GET("w/api.php?action=query&format=json&formatversion=2" + //Basic parameters "&generator=categorymembers&gcmtype=file&gcmsort=timestamp&gcmdir=desc" + //Category parameters MEDIA_PARAMS) - Single getMediaListFromCategory(@Query("gcmtitle") String category, @Query("gcmlimit") int itemLimit, @QueryMap Map continuation); - - - /** - * This method retrieves a list of Media objects for a given user name - * - * @param username user's Wikimedia Commons username. - * @param itemLimit how many images are returned - * @param continuation the continuation string from the previous query or empty map - * @return - */ - @GET("w/api.php?action=query&format=json&formatversion=2" + //Basic parameters - "&generator=allimages&gaisort=timestamp&gaidir=older" + MEDIA_PARAMS) - Single getMediaListForUser(@Query("gaiuser") String username, - @Query("gailimit") int itemLimit, @QueryMap(encoded = true) Map continuation); + Observable getMediaListFromCategory(@Query("gcmtitle") String category, @Query("gcmlimit") int itemLimit, @QueryMap Map continuation); /** * This method retrieves a list of Media objects filtered using image generator query * * @param keyword the searched keyword * @param itemLimit how many images are returned - * @param offset the offset in the result set + * @param continuation the continuation string from the previous query * @return */ @GET("w/api.php?action=query&format=json&formatversion=2" + //Basic parameters "&generator=search&gsrwhat=text&gsrnamespace=6" + //Search parameters MEDIA_PARAMS) - Single getMediaListFromSearch(@Query("gsrsearch") String keyword, @Query("gsrlimit") int itemLimit, @Query("gsroffset") int offset); + Observable getMediaListFromSearch(@Query("gsrsearch") String keyword, @Query("gsrlimit") int itemLimit, @QueryMap Map continuation); /** * Fetches Media object from the imageInfo API @@ -81,17 +69,7 @@ Single getMediaListForUser(@Query("gaiuser") String username, */ @GET("w/api.php?action=query&format=json&formatversion=2" + MEDIA_PARAMS) - Single getMedia(@Query("titles") String title); - - /** - * Fetches Media object from the imageInfo API - * - * @param pageIds the ids to be searched for - * @return - */ - @GET("w/api.php?action=query&format=json&formatversion=2" + - MEDIA_PARAMS) - Single getMediaById(@Query("pageids") String pageIds); + Observable getMedia(@Query("titles") String title); /** * Fetches Media object from the imageInfo API @@ -102,10 +80,10 @@ Single getMediaListForUser(@Query("gaiuser") String username, */ @GET("w/api.php?action=query&format=json&formatversion=2&generator=images" + MEDIA_PARAMS) - Single getMediaWithGenerator(@Query("titles") String title); + Observable getMediaWithGenerator(@Query("titles") String title); @GET("w/api.php?format=json&action=parse&prop=text") - Single getPageHtml(@Query("page") String title); + Observable getPageHtml(@Query("page") String title); /** * Fetches caption using file name @@ -113,18 +91,16 @@ Single getMediaListForUser(@Query("gaiuser") String username, * @param filename name of the file to be used for fetching captions * */ @GET("w/api.php?action=wbgetentities&props=labels&format=json&languagefallback=1") - Single fetchCaptionByFilename(@Query("language") String language, @Query("titles") String filename); + Observable fetchCaptionByFilename(@Query("language") String language, @Query("titles") String filename); /** * Fetches list of images from a depiction entity - * @param query depictionEntityId - * @param srlimit the number of items to fetch + * + * @param query depictionEntityId * @param sroffset number od depictions already fetched, this is useful in implementing pagination */ - @GET("w/api.php?action=query&format=json&formatversion=2" + //Basic parameters - "&generator=search&gsrnamespace=6" + //Search parameters - MEDIA_PARAMS) - Single fetchImagesForDepictedItem(@Query("gsrsearch") String query, - @Query("gsrlimit")String srlimit, @Query("gsroffset") String sroffset); + + @GET("w/api.php?action=query&list=search&format=json&srnamespace=6") + Observable fetchImagesForDepictedItem(@Query("srsearch") String query, @Query("sroffset") String sroffset); } diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java index 6b306690b74..c3fcf9b089b 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java @@ -1,21 +1,16 @@ package fr.free.nrw.commons.mwapi; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LEADERBOARD_END_POINT; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.UPDATE_AVATAR_END_POINT; - import android.text.TextUtils; import androidx.annotation.NonNull; import com.google.gson.Gson; +import fr.free.nrw.commons.achievements.FeaturedImages; +import fr.free.nrw.commons.achievements.FeedbackResponse; import fr.free.nrw.commons.campaigns.CampaignResponseDTO; -import fr.free.nrw.commons.explore.depictions.DepictsClient; +import fr.free.nrw.commons.depictions.subClass.models.SparqlResponse; import fr.free.nrw.commons.location.LatLng; import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.nearby.model.NearbyResponse; import fr.free.nrw.commons.nearby.model.NearbyResultItem; -import fr.free.nrw.commons.profile.achievements.FeaturedImages; -import fr.free.nrw.commons.profile.achievements.FeedbackResponse; -import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse; -import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse; import fr.free.nrw.commons.upload.FileUtils; import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; import fr.free.nrw.commons.utils.ConfigUtils; @@ -43,9 +38,7 @@ public class OkHttpJsonApiClient { private final OkHttpClient okHttpClient; - private final DepictsClient depictsClient; private final HttpUrl wikiMediaToolforgeUrl; - private final HttpUrl wikiMediaTestToolforgeUrl; private final String sparqlQueryUrl; private final String campaignsUrl; private final Gson gson; @@ -53,107 +46,17 @@ public class OkHttpJsonApiClient { @Inject public OkHttpJsonApiClient(OkHttpClient okHttpClient, - DepictsClient depictsClient, HttpUrl wikiMediaToolforgeUrl, - HttpUrl wikiMediaTestToolforgeUrl, String sparqlQueryUrl, String campaignsUrl, Gson gson) { this.okHttpClient = okHttpClient; - this.depictsClient = depictsClient; this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl; - this.wikiMediaTestToolforgeUrl = wikiMediaTestToolforgeUrl; this.sparqlQueryUrl = sparqlQueryUrl; this.campaignsUrl = campaignsUrl; this.gson = gson; } - /** - * The method will gradually calls the leaderboard API and fetches the leaderboard - * @param userName username of leaderboard user - * @param duration duration for leaderboard - * @param category category for leaderboard - * @param limit page size limit for list - * @param offset offset for the list - * @return LeaderboardResponse object - */ - @NonNull - public Observable getLeaderboard(String userName, String duration, String category, String limit, String offset) { - final String fetchLeaderboardUrlTemplate = wikiMediaTestToolforgeUrl - + LEADERBOARD_END_POINT; - String url = String.format(Locale.ENGLISH, - fetchLeaderboardUrlTemplate, - userName, - duration, - category, - limit, - offset); - HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); - urlBuilder.addQueryParameter("user", userName); - urlBuilder.addQueryParameter("duration", duration); - urlBuilder.addQueryParameter("category", category); - urlBuilder.addQueryParameter("limit", limit); - urlBuilder.addQueryParameter("offset", offset); - Timber.i("Url %s", urlBuilder.toString()); - Request request = new Request.Builder() - .url(urlBuilder.toString()) - .build(); - return Observable.fromCallable(() -> { - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return new LeaderboardResponse(); - } - Timber.d("Response for leaderboard is %s", json); - try { - return gson.fromJson(json, LeaderboardResponse.class); - } catch (Exception e) { - return new LeaderboardResponse(); - } - } - return new LeaderboardResponse(); - }); - } - - /** - * This method will update the leaderboard user avatar - * @param username username to update - * @param avatar url of the new avatar - * @return UpdateAvatarResponse object - */ - @NonNull - public Single setAvatar(String username, String avatar) { - final String urlTemplate = wikiMediaTestToolforgeUrl - + UPDATE_AVATAR_END_POINT; - return Single.fromCallable(() -> { - String url = String.format(Locale.ENGLISH, - urlTemplate, - username, - avatar); - HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); - urlBuilder.addQueryParameter("user", username); - urlBuilder.addQueryParameter("avatar", avatar); - Timber.i("Url %s", urlBuilder.toString()); - Request request = new Request.Builder() - .url(urlBuilder.toString()) - .build(); - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return null; - } - try { - return gson.fromJson(json, UpdateAvatarResponse.class); - } catch (Exception e) { - return new UpdateAvatarResponse(); - } - } - return null; - }); - } - @NonNull public Single getUploadCount(String userName) { HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder(); @@ -239,6 +142,7 @@ public Single getAchievements(String userName) { userName); HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); urlBuilder.addQueryParameter("user", userName); + Timber.i("Url %s", urlBuilder.toString()); Request request = new Request.Builder() .url(urlBuilder.toString()) .build(); @@ -302,36 +206,34 @@ public Observable> getNearbyPlaces(LatLng cur, String language, doub * Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example: * bridge -> suspended bridge, aqueduct, etc */ - public Single> getChildDepictions(String qid, int startPosition, - int limit) throws IOException { - return depictedItemsFrom(sparqlQuery(qid, startPosition, limit,"/queries/subclasses_query.rq")); + public Observable> getChildQIDs(String qid) throws IOException { + return depictedItemsFrom(sparqlQuery(qid, "/queries/subclasses_query.rq")); } /** * Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example: * bridge -> suspended bridge, aqueduct, etc */ - public Single> getParentDepictions(String qid, int startPosition, - int limit) throws IOException { - return depictedItemsFrom(sparqlQuery(qid, startPosition, limit, - "/queries/parentclasses_query.rq")); + public Observable> getParentQIDs(String qid) throws IOException { + return depictedItemsFrom(sparqlQuery(qid, "/queries/parentclasses_query.rq")); } - private Single> depictedItemsFrom(Request request) { - return depictsClient.toDepictions(Single.fromCallable(() -> { + private Observable> depictedItemsFrom(Request request) { + return Observable.fromCallable(() -> { try (ResponseBody body = okHttpClient.newCall(request).execute().body()) { - return gson.fromJson(body.string(), SparqlResponse.class); + return gson.fromJson(body.string(), SparqlResponse.class).toDepictedItems(); + }catch (Exception e) { + Timber.e(e); + return new ArrayList(); } - }).doOnError(Timber::e)); + }).doOnError(Timber::e); } @NotNull - private Request sparqlQuery(String qid, int startPosition, int limit, String fileName) throws IOException { - String query = FileUtils.readFromResource(fileName) - .replace("${QID}", qid) - .replace("${LANG}", "\"" + Locale.getDefault().getLanguage() + "\"") - .replace("${LIMIT}",""+ limit) - .replace("${OFFSET}",""+ startPosition); + private Request sparqlQuery(String qid, String fileName) throws IOException { + String query = FileUtils.readFromResource(fileName). + replace("${QID}", qid) + .replace("${LANG}", "\"" + Locale.getDefault().getLanguage() + "\""); HttpUrl.Builder urlBuilder = HttpUrl .parse(sparqlQueryUrl) .newBuilder() diff --git a/app/src/main/java/fr/free/nrw/commons/repository/UploadRemoteDataSource.java b/app/src/main/java/fr/free/nrw/commons/repository/UploadRemoteDataSource.java new file mode 100644 index 00000000000..7ea44ca68d2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/repository/UploadRemoteDataSource.java @@ -0,0 +1,232 @@ +package fr.free.nrw.commons.repository; + +import fr.free.nrw.commons.upload.ImageCoordinates; +import java.io.IOException; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import javax.inject.Inject; +import javax.inject.Singleton; + +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.filepicker.UploadableFile; +import fr.free.nrw.commons.location.LatLng; +import fr.free.nrw.commons.nearby.NearbyPlaces; +import fr.free.nrw.commons.nearby.Place; +import fr.free.nrw.commons.upload.SimilarImageInterface; +import fr.free.nrw.commons.upload.UploadController; +import fr.free.nrw.commons.upload.UploadModel; +import fr.free.nrw.commons.upload.UploadModel.UploadItem; +import fr.free.nrw.commons.upload.structure.depictions.DepictModel; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import io.reactivex.Observable; +import io.reactivex.Single; + +/** + * This class would act as the data source for remote operations for UploadActivity + */ +@Singleton +public class UploadRemoteDataSource { + + private static final double NEARBY_RADIUS_IN_KILO_METERS = 0.1; //100 meters + + private UploadModel uploadModel; + private UploadController uploadController; + private CategoriesModel categoriesModel; + private DepictModel depictModel; + private NearbyPlaces nearbyPlaces; + + @Inject + public UploadRemoteDataSource(UploadModel uploadModel, UploadController uploadController, + CategoriesModel categoriesModel, NearbyPlaces nearbyPlaces, DepictModel depictModel) { + this.uploadModel = uploadModel; + this.uploadController = uploadController; + this.categoriesModel = categoriesModel; + this.nearbyPlaces = nearbyPlaces; + this.depictModel = depictModel; + } + + /** + * asks the UploadModel to build the contributions + * + * @return + */ + public Observable buildContributions() { + return uploadModel.buildContributions(); + } + + /** + * asks the UploadService to star the uplaod for + * + * @param contribution + */ + public void startUpload(Contribution contribution) { + uploadController.startUpload(contribution); + } + + /** + * returns the list of UploadItem from the UploadModel + * + * @return + */ + public List getUploads() { + return uploadModel.getUploads(); + } + + /** + * Prepare the UploadService for the upload + */ + public void prepareService() { + uploadController.prepareService(); + } + + /** + * Clean up the selected categories + */ + public void cleanUp(){ + //This needs further refactoring, this should not be here, right now the structure wont suppoort rhis + categoriesModel.cleanUp(); + depictModel.cleanUp(); + } + + /** + * returnt the list of selected categories + * + * @return + */ + public List getSelectedCategories() { + return categoriesModel.getSelectedCategories(); + } + + /** + * all categories from MWApi + * + * @param query + * @param imageTitleList + * @return + */ + public Observable searchAll(String query, List imageTitleList) { + return categoriesModel.searchAll(query, imageTitleList); + } + + /** + * returns the string list of categories + * + * @return + */ + public List getCategoryStringList() { + return categoriesModel.getCategoryStringList(); + } + + /** + * sets the selected categories in the UploadModel + * + * @param categoryStringList + */ + public void setSelectedCategories(List categoryStringList) { + uploadModel.setSelectedCategories(categoryStringList); + } + + /** + * handles category selection/unselection + * + * @param categoryItem + */ + public void onCategoryClicked(CategoryItem categoryItem) { + categoriesModel.onCategoryItemClicked(categoryItem); + } + + /** + * returns category sorted based on similarity with query + * + * @param query + * @return + */ + public Comparator sortBySimilarity(String query) { + return categoriesModel.sortBySimilarity(query); + } + + /** + * prunes the category list for irrelevant categories see #750 + * + * @param name + * @return + */ + public boolean containsYear(String name) { + return categoriesModel.containsYear(name); + } + + /** + * pre process the UploadableFile + * + * @param uploadableFile + * @param place + * @param similarImageInterface + * @return + */ + public Observable preProcessImage(UploadableFile uploadableFile, Place place, + SimilarImageInterface similarImageInterface) { + return uploadModel.preProcessImage(uploadableFile, place, similarImageInterface); + } + + /** + * ask the UplaodModel for the image quality of the UploadItem + * + * @param uploadItem + * @return + */ + public Single getImageQuality(UploadItem uploadItem) { + return uploadModel.getImageQuality(uploadItem); + } + + /** + * gets nearby places matching with upload item's GPS location + * + * @param latitude + * @param longitude + * @return + */ + public Place getNearbyPlaces(double latitude, double longitude) { + try { + List fromWikidataQuery = nearbyPlaces.getFromWikidataQuery(new LatLng(latitude, longitude, 0.0f), + Locale.getDefault().getLanguage(), + NEARBY_RADIUS_IN_KILO_METERS); + return fromWikidataQuery.size() > 0 ? fromWikidataQuery.get(0) : null; + } catch (IOException e) { + return null; + } + } + + /** + * handles category selection/unselection + * @param depictedItem + */ + + public void onDepictedItemClicked(DepictedItem depictedItem) { + uploadModel.onDepictItemClicked(depictedItem); + } + + /** + * returns the list of selected depictions + * @return + */ + + public List getSelectedDepictions() { + return uploadModel.getSelectedDepictions(); + } + + /** + * get all depictions + */ + + public Observable searchAllEntities(String query) { + return depictModel.searchAllEntities(query); + } + + public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex) { + uploadModel.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java index 1e7090b189a..71d5ba50699 100644 --- a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java +++ b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.java @@ -1,27 +1,22 @@ package fr.free.nrw.commons.repository; -import fr.free.nrw.commons.category.CategoriesModel; +import fr.free.nrw.commons.upload.ImageCoordinates; +import java.util.Comparator; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + import fr.free.nrw.commons.category.CategoryItem; import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.filepicker.UploadableFile; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.nearby.NearbyPlaces; import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.upload.ImageCoordinates; import fr.free.nrw.commons.upload.SimilarImageInterface; -import fr.free.nrw.commons.upload.UploadController; -import fr.free.nrw.commons.upload.UploadItem; -import fr.free.nrw.commons.upload.UploadModel; -import fr.free.nrw.commons.upload.structure.depictions.DepictModel; +import fr.free.nrw.commons.upload.UploadModel.UploadItem; import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; -import io.reactivex.Flowable; + import io.reactivex.Observable; import io.reactivex.Single; -import java.io.IOException; -import java.util.List; -import java.util.Locale; -import javax.inject.Inject; -import javax.inject.Singleton; /** * The repository class for UploadActivity @@ -29,25 +24,14 @@ @Singleton public class UploadRepository { - private final UploadModel uploadModel; - private final UploadController uploadController; - private final CategoriesModel categoriesModel; - private final NearbyPlaces nearbyPlaces; - private final DepictModel depictModel; - - private static final double NEARBY_RADIUS_IN_KILO_METERS = 0.1; //100 meters + private UploadLocalDataSource localDataSource; + private UploadRemoteDataSource remoteDataSource; @Inject - public UploadRepository(UploadModel uploadModel, - UploadController uploadController, - CategoriesModel categoriesModel, - NearbyPlaces nearbyPlaces, - DepictModel depictModel) { - this.uploadModel = uploadModel; - this.uploadController = uploadController; - this.categoriesModel = categoriesModel; - this.nearbyPlaces = nearbyPlaces; - this.depictModel = depictModel; + public UploadRepository(UploadLocalDataSource localDataSource, + UploadRemoteDataSource remoteDataSource) { + this.localDataSource = localDataSource; + this.remoteDataSource = remoteDataSource; } /** @@ -56,7 +40,7 @@ public UploadRepository(UploadModel uploadModel, * @return */ public Observable buildContributions() { - return uploadModel.buildContributions(); + return remoteDataSource.buildContributions(); } /** @@ -65,7 +49,7 @@ public Observable buildContributions() { * @param contribution */ public void startUpload(Contribution contribution) { - uploadController.startUpload(contribution); + remoteDataSource.startUpload(contribution); } /** @@ -74,24 +58,22 @@ public void startUpload(Contribution contribution) { * @return */ public List getUploads() { - return uploadModel.getUploads(); + return remoteDataSource.getUploads(); } /** * asks the RemoteDataSource to prepare the Upload Service */ public void prepareService() { - uploadController.prepareService(); + remoteDataSource.prepareService(); } /** *Prepare for a fresh upload */ public void cleanup() { - uploadModel.cleanUp(); - //This needs further refactoring, this should not be here, right now the structure wont suppoort rhis - categoriesModel.cleanUp(); - depictModel.cleanUp(); + localDataSource.cleanUp(); + remoteDataSource.cleanUp(); } /** @@ -100,7 +82,7 @@ public void cleanup() { * @return */ public List getSelectedCategories() { - return categoriesModel.getSelectedCategories(); + return remoteDataSource.getSelectedCategories(); } /** @@ -108,12 +90,20 @@ public List getSelectedCategories() { * * @param query * @param imageTitleList - * @param selectedDepictions * @return */ - public Observable> searchAll(String query, List imageTitleList, - List selectedDepictions) { - return categoriesModel.searchAll(query, imageTitleList, selectedDepictions); + public Observable searchAll(String query, List imageTitleList) { + return remoteDataSource.searchAll(query, imageTitleList); + } + + /** + * returns the string list of categories + * + * @return + */ + + public List getCategoryStringList() { + return remoteDataSource.getCategoryStringList(); } /** @@ -122,7 +112,7 @@ public Observable> searchAll(String query, List image * @param categoryStringList */ public void setSelectedCategories(List categoryStringList) { - uploadModel.setSelectedCategories(categoryStringList); + remoteDataSource.setSelectedCategories(categoryStringList); } /** @@ -131,7 +121,17 @@ public void setSelectedCategories(List categoryStringList) { * @param categoryItem */ public void onCategoryClicked(CategoryItem categoryItem) { - categoriesModel.onCategoryItemClicked(categoryItem); + remoteDataSource.onCategoryClicked(categoryItem); + } + + /** + * returns category sorted based on similarity with query + * + * @param query + * @return + */ + public Comparator sortBySimilarity(String query) { + return remoteDataSource.sortBySimilarity(query); } /** @@ -141,7 +141,7 @@ public void onCategoryClicked(CategoryItem categoryItem) { * @return */ public boolean containsYear(String name) { - return categoriesModel.containsYear(name); + return remoteDataSource.containsYear(name); } /** @@ -150,7 +150,7 @@ public boolean containsYear(String name) { * @return */ public List getLicenses() { - return uploadModel.getLicenses(); + return localDataSource.getLicenses(); } /** @@ -159,7 +159,7 @@ public List getLicenses() { * @return */ public String getSelectedLicense() { - return uploadModel.getSelectedLicense(); + return localDataSource.getSelectedLicense(); } /** @@ -168,7 +168,7 @@ public String getSelectedLicense() { * @return */ public int getCount() { - return uploadModel.getCount(); + return localDataSource.getCount(); } /** @@ -181,8 +181,7 @@ public int getCount() { */ public Observable preProcessImage(UploadableFile uploadableFile, Place place, SimilarImageInterface similarImageInterface) { - return uploadModel.preProcessImage(uploadableFile, place, - similarImageInterface); + return remoteDataSource.preProcessImage(uploadableFile, place, similarImageInterface); } /** @@ -192,7 +191,17 @@ public Observable preProcessImage(UploadableFile uploadableFile, Pla * @return */ public Single getImageQuality(UploadItem uploadItem) { - return uploadModel.getImageQuality(uploadItem); + return remoteDataSource.getImageQuality(uploadItem); + } + + /** + * asks the LocalDataSource to update the Upload Item + * + * @param index + * @param uploadItem + */ + public void updateUploadItem(int index, UploadItem uploadItem) { + localDataSource.updateUploadItem(index, uploadItem); } /** @@ -201,7 +210,7 @@ public Single getImageQuality(UploadItem uploadItem) { * @param filePath */ public void deletePicture(String filePath) { - uploadModel.deletePicture(filePath); + localDataSource.deletePicture(filePath); } /** @@ -211,10 +220,38 @@ public void deletePicture(String filePath) { * @return */ public UploadItem getPreviousUploadItem(int index) { - if (index - 1 >= 0) { - return uploadModel.getItems().get(index - 1); - } - return null; //There is no previous item to copy details + return localDataSource.getPreviousUploadItem(index); + } + + /** + * Save boolean value locally + * + * @param key + * @param value + */ + public void saveValue(String key, boolean value) { + localDataSource.saveValue(key, value); + } + + /** + * save string value locally + * + * @param key + * @param value + */ + public void saveValue(String key, String value) { + localDataSource.saveValue(key, value); + } + + /** + * fetch the string value for the associated key + * + * @param key + * @param value + * @return + */ + public String getValue(String key, String value) { + return localDataSource.getValue(key, value); } /** @@ -223,11 +260,11 @@ public UploadItem getPreviousUploadItem(int index) { * @param licenseName */ public void setSelectedLicense(String licenseName) { - uploadModel.setSelectedLicense(licenseName); + localDataSource.setSelectedLicense(licenseName); } public void onDepictItemClicked(DepictedItem depictedItem) { - uploadModel.onDepictItemClicked(depictedItem); + remoteDataSource.onDepictedItemClicked(depictedItem); } /** @@ -237,7 +274,7 @@ public void onDepictItemClicked(DepictedItem depictedItem) { */ public List getSelectedDepictions() { - return uploadModel.getSelectedDepictions(); + return remoteDataSource.getSelectedDepictions(); } /** @@ -247,8 +284,8 @@ public List getSelectedDepictions() { * @return */ - public Flowable> searchAllEntities(String query) { - return depictModel.searchAllEntities(query); + public Observable searchAllEntities(String query) { + return remoteDataSource.searchAllEntities(query); } /** @@ -258,18 +295,10 @@ public Flowable> searchAllEntities(String query) { * @return */ public Place checkNearbyPlaces(double decLatitude, double decLongitude) { - try { - List fromWikidataQuery = nearbyPlaces.getFromWikidataQuery(new LatLng( - decLatitude, decLongitude, 0.0f), - Locale.getDefault().getLanguage(), - NEARBY_RADIUS_IN_KILO_METERS); - return fromWikidataQuery.size() > 0 ? fromWikidataQuery.get(0) : null; - } catch (IOException e) { - return null; - } + return remoteDataSource.getNearbyPlaces(decLatitude, decLongitude); } public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex) { - uploadModel.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex); + remoteDataSource.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex); } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt index ff3f63eb86a..14ff8b12600 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt @@ -180,7 +180,7 @@ class FileProcessor @Inject constructor( .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) .subscribe( - gpsCategoryModel::setCategoriesFromLocation, + { gpsCategoryModel.categoryList = it }, { Timber.e(it) gpsCategoryModel.clear() @@ -209,7 +209,7 @@ class FileProcessor @Inject constructor( .filter { it.size >= MIN_NEARBY_RESULTS } .take(1) .subscribe( - { depictsModel.nearbyPlaces.offer(it) }, + { depictsModel.nearbyPlaces = it }, { Timber.e(it) } ) } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/GpsCategoryModel.java b/app/src/main/java/fr/free/nrw/commons/upload/GpsCategoryModel.java new file mode 100644 index 00000000000..78eece3b33b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/GpsCategoryModel.java @@ -0,0 +1,36 @@ +package fr.free.nrw.commons.upload; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class GpsCategoryModel { + private Set categorySet; + + @Inject + public GpsCategoryModel() { + clear(); + } + + public void clear() { + categorySet = new HashSet<>(); + } + + public boolean getGpsCatExists() { + return !categorySet.isEmpty(); + } + + public List getCategoryList() { + return new ArrayList<>(categorySet); + } + + public void setCategoryList(List categoryList) { + clear(); + categorySet.addAll(categoryList != null ? categoryList : new ArrayList<>()); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java b/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java index e04afe9792f..7bcec4fb0d6 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.java @@ -45,7 +45,7 @@ public ImageProcessingService(FileUtilsWrapper fileUtilsWrapper, * Check image quality before upload - checks duplicate image - checks dark image - checks * geolocation for image - check for valid title */ - Single validateImage(UploadItem uploadItem) { + Single validateImage(UploadModel.UploadItem uploadItem) { int currentImageQuality = uploadItem.getImageQuality(); Timber.d("Current image quality is %d", currentImageQuality); if (currentImageQuality == ImageUtils.IMAGE_KEEP) { @@ -97,7 +97,7 @@ private Single checkEXIF(String filepath) { * @param uploadItem * @return */ - private Single validateItemTitle(UploadItem uploadItem) { + private Single validateItemTitle(UploadModel.UploadItem uploadItem) { Timber.d("Checking for image title %s", uploadItem.getUploadMediaDetails()); List captions = uploadItem.getUploadMediaDetails(); if (captions.isEmpty()) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.java b/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.java index d9c0f94b8a4..6079ddb1199 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.java @@ -2,7 +2,6 @@ import android.content.Context; import androidx.annotation.NonNull; -import fr.free.nrw.commons.Media; import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.filepicker.UploadableFile.DateTimeWithSource; import fr.free.nrw.commons.settings.Prefs.Licenses; @@ -31,14 +30,13 @@ public PageContentsCreator(Context context) { public String createFrom(Contribution contribution) { StringBuilder buffer = new StringBuilder(); - final Media media = contribution.getMedia(); buffer .append("== {{int:filedesc}} ==\n") .append("{{Information\n") - .append("|description=").append(media.getFallbackDescription()).append("\n") + .append("|description=").append(contribution.getDescription()).append("\n") .append("|source=").append("{{own}}\n") - .append("|author=[[User:").append(media.getCreator()).append("|") - .append(media.getCreator()).append("]]\n"); + .append("|author=[[User:").append(contribution.getCreator()).append("|") + .append(contribution.getCreator()).append("]]\n"); String templatizedCreatedDate = getTemplatizedCreatedDate( contribution.getDateCreated(), contribution.getDateCreatedSource()); @@ -55,10 +53,10 @@ public String createFrom(Contribution contribution) { } buffer.append("== {{int:license-header}} ==\n") - .append(licenseTemplateFor(media.getLicense())).append("\n\n") + .append(licenseTemplateFor(contribution.getLicense())).append("\n\n") .append("{{Uploaded from Mobile|platform=Android|version=") .append(ConfigUtils.getVersionNameWithSha(context)).append("}}\n"); - final List categories = media.getCategories(); + final List categories = contribution.getCategories(); if (categories != null && categories.size() != 0) { for (int i = 0; i < categories.size(); i++) { buffer.append("\n[[Category:").append(categories.get(i)).append("]]"); 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 index 16e43715439..535dbb25f8a 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java @@ -30,6 +30,7 @@ import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.LoginActivity; import fr.free.nrw.commons.auth.SessionManager; +import fr.free.nrw.commons.category.CategoriesModel; import fr.free.nrw.commons.contributions.ContributionController; import fr.free.nrw.commons.filepicker.UploadableFile; import fr.free.nrw.commons.kvstore.JsonKvStore; @@ -62,6 +63,8 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, @Inject UploadContract.UserActionListener presenter; @Inject + CategoriesModel categoriesModel; + @Inject SessionManager sessionManager; @Inject UserClient userClient; @@ -93,7 +96,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, private CompositeDisposable compositeDisposable; private ProgressDialog progressDialog; private UploadImageAdapter uploadImagesAdapter; - private List fragments; + private List fragments; private UploadCategoriesFragment uploadCategoriesFragment; private DepictsFragment depictsFragment; private MediaLicenseFragment mediaLicenseFragment; @@ -413,7 +416,6 @@ private void showInfoAlert(int titleStringID, int messageStringId, Runnable posi public void onNextButtonClicked(int index) { if (index < fragments.size() - 1) { vpUpload.setCurrentItem(index + 1, false); - fragments.get(index + 1).onBecameVisible(); } else { presenter.handleSubmit(); } @@ -423,7 +425,6 @@ public void onNextButtonClicked(int index) { public void onPreviousButtonClicked(int index) { if (index != 0) { vpUpload.setCurrentItem(index - 1, true); - fragments.get(index - 1).onBecameVisible(); } } @@ -432,14 +433,14 @@ public void onPreviousButtonClicked(int index) { */ private class UploadImageAdapter extends FragmentStatePagerAdapter { - List fragments; + List fragments; public UploadImageAdapter(FragmentManager fragmentManager) { super(fragmentManager); this.fragments = new ArrayList<>(); } - public void setFragments(List fragments) { + public void setFragments(List fragments) { this.fragments = fragments; notifyDataSetChanged(); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java index 370dce9601b..39c42cc1d81 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadClient.java @@ -4,19 +4,11 @@ import android.content.Context; import android.net.Uri; -import androidx.annotation.Nullable; -import com.google.gson.Gson; import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.contributions.ChunkInfo; import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.upload.UploadService.NotificationUpdateProgressListener; import io.reactivex.Observable; -import io.reactivex.disposables.CompositeDisposable; import java.io.File; -import java.io.IOException; -import java.util.Date; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; @@ -24,187 +16,59 @@ import okhttp3.MultipartBody; import okhttp3.RequestBody; import org.wikipedia.csrf.CsrfTokenClient; -import org.wikipedia.dataclient.mwapi.MwException; -import timber.log.Timber; @Singleton public class UploadClient { - private final int CHUNK_SIZE = 256 * 1024; // 256 KB - - //This is maximum duration for which a stash is persisted on MediaWiki - // https://www.mediawiki.org/wiki/Manual:$wgUploadStashMaxAge - private final int MAX_CHUNK_AGE = 6 * 3600 * 1000; // 6 hours - - private final UploadInterface uploadInterface; - private final CsrfTokenClient csrfTokenClient; - private final PageContentsCreator pageContentsCreator; - private final FileUtilsWrapper fileUtilsWrapper; - private final Gson gson; - private boolean pauseUploads = false; - - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - - @Inject - public UploadClient(final UploadInterface uploadInterface, - @Named(NAMED_COMMONS_CSRF) final CsrfTokenClient csrfTokenClient, - final PageContentsCreator pageContentsCreator, - final FileUtilsWrapper fileUtilsWrapper, final Gson gson) { - this.uploadInterface = uploadInterface; - this.csrfTokenClient = csrfTokenClient; - this.pageContentsCreator = pageContentsCreator; - this.fileUtilsWrapper = fileUtilsWrapper; - this.gson = gson; - } - - /** - * Upload file to stash in chunks of specified size. Uploading files in chunks will make handling - * of large files easier. Also, it will be useful in supporting pause/resume of uploads - */ - Observable uploadFileToStash( - final Context context, final String filename, final Contribution contribution, - final NotificationUpdateProgressListener notificationUpdater) throws IOException { - if (contribution.getChunkInfo() != null && contribution.getChunkInfo().isLastChunkUploaded()) { - return Observable.just(new StashUploadResult(StashUploadState.SUCCESS, - contribution.getChunkInfo().getUploadResult().getFilekey())); - } - pauseUploads = false; - File file = new File(contribution.getLocalUri().getPath()); - final Observable fileChunks = fileUtilsWrapper.getFileChunks(context, file, CHUNK_SIZE); - final MediaType mediaType = MediaType - .parse(FileUtils.getMimeType(context, Uri.parse(file.getPath()))); - - final AtomicInteger index = new AtomicInteger(); - final AtomicReference chunkInfo = new AtomicReference<>(); - Timber.d("Chunk info"); - if (contribution.getChunkInfo() != null && isStashValid(contribution)) { - chunkInfo.set(contribution.getChunkInfo()); - } - compositeDisposable.add(fileChunks.forEach(chunkFile -> { - if (pauseUploads) { - return; - } - if (chunkInfo.get() != null && index.get() < chunkInfo.get().getLastChunkIndex()) { - index.getAndIncrement(); - return; - } - final int offset = - chunkInfo.get() != null ? chunkInfo.get().getUploadResult().getOffset() : 0; - final String filekey = - chunkInfo.get() != null ? chunkInfo.get().getUploadResult().getFilekey() : null; - - final RequestBody requestBody = RequestBody - .create(mediaType, chunkFile); - final CountingRequestBody countingRequestBody = new CountingRequestBody(requestBody, - notificationUpdater::onProgress, offset, - file.length()); - - compositeDisposable.add(uploadChunkToStash(filename, - file.length(), - offset, - filekey, - countingRequestBody).subscribe(uploadResult -> { - chunkInfo.set(new ChunkInfo(uploadResult, index.incrementAndGet(), false)); - notificationUpdater.onChunkUploaded(contribution, chunkInfo.get()); - }, throwable -> { - Timber.e(throwable, "Error occurred in uploading chunk"); - })); - })); - - chunkInfo.get().setLastChunkUploaded(true); - notificationUpdater.onChunkUploaded(contribution, chunkInfo.get()); - if (pauseUploads) { - return Observable.just(new StashUploadResult(StashUploadState.PAUSED, null)); - } else if (chunkInfo.get() != null) { - return Observable.just(new StashUploadResult(StashUploadState.SUCCESS, - chunkInfo.get().getUploadResult().getFilekey())); - } else { - return Observable.just(new StashUploadResult(StashUploadState.FAILED, null)); - } - } - - /** - * Stash is valid for 6 hours. This function checks the validity of stash - * @param contribution - * @return - */ - private boolean isStashValid(Contribution contribution) { - return contribution.getDateModified() - .after(new Date(System.currentTimeMillis() - MAX_CHUNK_AGE)); - } - - /** - * Uploads a file chunk to stash - * - * @param filename The name of the file being uploaded - * @param fileSize The total size of the file - * @param offset The offset returned by the previous chunk upload - * @param fileKey The filekey returned by the previous chunk upload - * @param countingRequestBody Request body with chunk file - * @return - */ - Observable uploadChunkToStash(final String filename, - final long fileSize, - final long offset, - final String fileKey, - final CountingRequestBody countingRequestBody) { - final MultipartBody.Part filePart = MultipartBody.Part - .createFormData("chunk", filename, countingRequestBody); - try { - return uploadInterface.uploadFileToStash(toRequestBody(filename), - toRequestBody(String.valueOf(fileSize)), - toRequestBody(String.valueOf(offset)), - toRequestBody(fileKey), - toRequestBody(csrfTokenClient.getTokenBlocking()), - filePart) - .map(UploadResponse::getUpload); - } catch (final Throwable throwable) { - Timber.e(throwable, "Failed to upload chunk to stash"); - return Observable.error(throwable); + private final UploadInterface uploadInterface; + private final CsrfTokenClient csrfTokenClient; + private final PageContentsCreator pageContentsCreator; + + @Inject + public UploadClient(UploadInterface uploadInterface, + @Named(NAMED_COMMONS_CSRF) CsrfTokenClient csrfTokenClient, + PageContentsCreator pageContentsCreator) { + this.uploadInterface = uploadInterface; + this.csrfTokenClient = csrfTokenClient; + this.pageContentsCreator = pageContentsCreator; } - } - /** - * Dispose the active disposable and sets the pause variable - */ - public void pauseUpload() { - pauseUploads = true; - if (!compositeDisposable.isDisposed()) { - compositeDisposable.dispose(); + Observable uploadFileToStash(Context context, String filename, File file, + NotificationUpdateProgressListener notificationUpdater) { + RequestBody requestBody = RequestBody + .create(MediaType.parse(FileUtils.getMimeType(context, Uri.parse(file.getPath()))), file); + + CountingRequestBody countingRequestBody = new CountingRequestBody(requestBody, + (bytesWritten, contentLength) -> notificationUpdater + .onProgress(bytesWritten, contentLength)); + + MultipartBody.Part filePart = MultipartBody.Part.createFormData("file", filename, countingRequestBody); + RequestBody fileNameRequestBody = RequestBody.create(okhttp3.MultipartBody.FORM, filename); + RequestBody tokenRequestBody; + try { + tokenRequestBody = RequestBody.create(MultipartBody.FORM, csrfTokenClient.getTokenBlocking()); + return uploadInterface.uploadFileToStash(fileNameRequestBody, tokenRequestBody, filePart) + .map(stashUploadResponse -> stashUploadResponse.getUpload()); + } catch (Throwable throwable) { + throwable.printStackTrace(); + return Observable.error(throwable); + } } - compositeDisposable.clear(); - } - - /** - * Converts string value to request body - */ - @Nullable - private RequestBody toRequestBody(@Nullable final String value) { - return value == null ? null : RequestBody.create(okhttp3.MultipartBody.FORM, value); - } - - Observable uploadFileFromStash(final Context context, - final Contribution contribution, - final String uniqueFileName, - final String fileKey) { - try { - return uploadInterface - .uploadFileFromStash(csrfTokenClient.getTokenBlocking(), - pageContentsCreator.createFrom(contribution), - CommonsApplication.DEFAULT_EDIT_SUMMARY, - uniqueFileName, - fileKey).map(uploadResponse -> { - UploadResponse uploadResult = gson.fromJson(uploadResponse, UploadResponse.class); - if (uploadResult.getUpload() == null) { - final MwException exception = gson.fromJson(uploadResponse, MwException.class); - throw new RuntimeException(exception.getErrorCode()); - } - return uploadResult.getUpload(); - }); - } catch (final Throwable throwable) { - Timber.e(throwable, "Exception occurred in uploading file from stash"); - return Observable.error(throwable); + Observable uploadFileFromStash(Context context, + Contribution contribution, + String uniqueFileName, + String fileKey) { + try { + return uploadInterface + .uploadFileFromStash(csrfTokenClient.getTokenBlocking(), + pageContentsCreator.createFrom(contribution), + CommonsApplication.DEFAULT_EDIT_SUMMARY, + uniqueFileName, + fileKey).map(uploadResponse -> uploadResponse.getUpload()); + } catch (Throwable throwable) { + throwable.printStackTrace(); + return Observable.error(throwable); + } } - } } 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 86ba2124dd3..e28978ff87b 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 @@ -13,6 +13,7 @@ import android.os.IBinder; import android.provider.MediaStore; import android.text.TextUtils; +import fr.free.nrw.commons.HandlerService; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; import fr.free.nrw.commons.auth.SessionManager; @@ -53,7 +54,7 @@ public UploadController(final SessionManager sessionManager, public ServiceConnection uploadServiceConnection = new ServiceConnection() { @Override public void onServiceConnected(final ComponentName componentName, final IBinder binder) { - uploadService = ((UploadService.UploadServiceLocalBinder) binder).getService(); + uploadService = (UploadService) ((HandlerService.HandlerServiceLocalBinder) binder).getService(); isUploadServiceConnected = true; } @@ -94,13 +95,12 @@ public void startUpload(final Contribution contribution) { //Set creator, desc, and license // If author name is enabled and set, use it - final Media media = contribution.getMedia(); if (store.getBoolean("useAuthorName", false)) { final String authorName = store.getString("authorName", ""); - media.setCreator(authorName); + contribution.setCreator(authorName); } - if (TextUtils.isEmpty(media.getCreator())) { + if (TextUtils.isEmpty(contribution.getCreator())) { final Account currentAccount = sessionManager.getCurrentAccount(); if (currentAccount == null) { Timber.d("Current account is null"); @@ -108,15 +108,15 @@ public void startUpload(final Contribution contribution) { sessionManager.forceLogin(context); return; } - media.setCreator(sessionManager.getAuthorName()); + contribution.setCreator(sessionManager.getAuthorName()); } - if (media.getFallbackDescription() == null) { - media.setFallbackDescription(""); + if (contribution.getDescription() == null) { + contribution.setDescription(""); } final String license = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3); - media.setLicense(license); + contribution.setLicense(license); uploadTask(contribution); } @@ -165,7 +165,7 @@ private String resolveMimeType(final ContentResolver contentResolver, final Cont return mimeType; } - private long resolveDataLength(final ContentResolver contentResolver, final Contribution contribution) { + private long resolveDataLength(final ContentResolver contentResolver, final Media contribution) { try { if (contribution.getDataLength() <= 0) { Timber.d("UploadController/doInBackground, contribution.getLocalUri():%s", contribution.getLocalUri()); @@ -183,7 +183,7 @@ private long resolveDataLength(final ContentResolver contentResolver, final Cont return contribution.getDataLength(); } - private Date resolveDateTakenOrNow(final ContentResolver contentResolver, final Contribution contribution) { + private Date resolveDateTakenOrNow(final ContentResolver contentResolver, final Media contribution) { Timber.d("local uri %s", contribution.getLocalUri()); try(final Cursor cursor = dateTakenCursor(contentResolver, contribution)) { if (cursor != null && cursor.getCount() != 0 && cursor.getColumnCount() != 0) { @@ -197,7 +197,7 @@ private Date resolveDateTakenOrNow(final ContentResolver contentResolver, final } } - private Cursor dateTakenCursor(final ContentResolver contentResolver, final Contribution contribution) { + private Cursor dateTakenCursor(final ContentResolver contentResolver, final Media contribution) { return contentResolver.query(contribution.getLocalUri(), new String[]{MediaStore.Images.ImageColumns.DATE_TAKEN}, null, null, null); } @@ -208,7 +208,7 @@ private Cursor dateTakenCursor(final ContentResolver contentResolver, final Cont */ private void upload(final Contribution contribution) { //Starts the upload. If commented out, user can proceed to next Fragment but upload doesn't happen - uploadService.queue(contribution); + uploadService.queue(UploadService.ACTION_UPLOAD_FILE, contribution); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadDepictsAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadDepictsAdapterFactory.java new file mode 100644 index 00000000000..ca871ad41c6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadDepictsAdapterFactory.java @@ -0,0 +1,31 @@ +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.upload.structure.depictions.DepictedItem; +import fr.free.nrw.commons.upload.structure.depictions.UploadDepictsCallback; + +/** + * Adapter Factory for DepictsClicked Listener + */ + +public class UploadDepictsAdapterFactory { + private final UploadDepictsCallback listener; + + public UploadDepictsAdapterFactory(UploadDepictsCallback listener) { + this.listener = listener; + } + + public RVRendererAdapter create(List itemList) { + RendererBuilder builder = new RendererBuilder() + .bind(DepictedItem.class, new UploadDepictsRenderer(listener)); + ListAdapteeCollection collection = new ListAdapteeCollection<>( + itemList != null ? itemList : Collections.emptyList()); + return new RVRendererAdapter<>(builder, collection); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadDepictsRenderer.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadDepictsRenderer.java new file mode 100644 index 00000000000..cb5b8986c15 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadDepictsRenderer.java @@ -0,0 +1,135 @@ +package fr.free.nrw.commons.upload; + +import android.graphics.Bitmap; +import android.net.Uri; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import com.facebook.common.executors.CallerThreadExecutor; +import com.facebook.common.references.CloseableReference; +import com.facebook.datasource.DataSource; +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.imagepipeline.core.ImagePipeline; +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; +import com.facebook.imagepipeline.image.CloseableImage; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; +import com.pedrogomez.renderers.Renderer; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import fr.free.nrw.commons.upload.structure.depictions.UploadDepictsCallback; +import timber.log.Timber; + +/** + * Depicts Renderer for setting up inflating layout, + * and setting views for the layout of each depicted Item + */ +public class UploadDepictsRenderer extends Renderer { + private final UploadDepictsCallback listener; + @BindView(R.id.depict_checkbox) + CheckBox checkedView; + @BindView(R.id.depicts_label) + TextView depictsLabel; + @BindView(R.id.description) TextView description; + @BindView(R.id.depicted_image) + ImageView imageView; + private final static String NO_IMAGE_FOR_DEPICTION="No Image for Depiction"; + + public UploadDepictsRenderer(UploadDepictsCallback listener) { + this.listener = listener; + } + + @Override + protected void setUpView(View rootView) { + ButterKnife.bind(this, rootView); + } + + /** + * Setup OnClicklisteners on the views + */ + @Override + protected void hookListeners(View rootView) { + rootView.setOnClickListener(v -> { + DepictedItem item = getContent(); + item.setSelected(!item.isSelected()); + checkedView.setChecked(item.isSelected()); + if (listener != null) { + listener.depictsClicked(item); + } + }); + checkedView.setOnClickListener(v -> { + DepictedItem item = getContent(); + item.setSelected(!item.isSelected()); + checkedView.setChecked(item.isSelected()); + if (listener != null) { + listener.depictsClicked(item); + } + }); + } + + @Override + protected View inflate(LayoutInflater inflater, ViewGroup parent) { + return inflater.inflate(R.layout.layout_upload_depicts_item, parent, false); + } + + /** + * initialise views for every item in the adapter + */ + @Override + public void render() { + DepictedItem item = getContent(); + checkedView.setChecked(item.isSelected()); + depictsLabel.setText(item.getName()); + description.setText(item.getDescription()); + if (!TextUtils.isEmpty(item.getImageUrl())) { + if (!item.getImageUrl().equals(NO_IMAGE_FOR_DEPICTION)) + setImageView(Uri.parse(item.getImageUrl()), imageView); + }else{ + listener.fetchThumbnailUrlForEntity(item.getId(),item.getPosition()); + } + } + + /** + * Set thumbnail for the depicted item + */ + private void setImageView(Uri imageUrl, ImageView imageView) { + ImageRequest imageRequest = ImageRequestBuilder + .newBuilderWithSource(imageUrl) + .setAutoRotateEnabled(true) + .build(); + + ImagePipeline imagePipeline = Fresco.getImagePipeline(); + final DataSource> + dataSource = imagePipeline.fetchDecodedImage(imageRequest, getContext()); + + dataSource.subscribe(new BaseBitmapDataSubscriber() { + + @Override + public void onNewResultImpl(@Nullable Bitmap bitmap) { + if (dataSource.isFinished() && bitmap != null) { + Timber.d("Bitmap loaded from url %s", imageUrl.toString()); + imageView.post(() -> imageView.setImageBitmap(Bitmap.createBitmap(bitmap))); + dataSource.close(); + } + } + + @Override + public void onFailureImpl(DataSource dataSource) { + Timber.d("Error getting bitmap from image url %s", imageUrl.toString()); + if (dataSource != null) { + dataSource.close(); + } + } + }, CallerThreadExecutor.getInstance()); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetail.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetail.kt index bfc65567341..2617ff90312 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetail.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetail.kt @@ -17,8 +17,6 @@ data class UploadMediaDetail constructor( var descriptionText: String = "", var captionText: String = "" ) { - fun javaCopy() = copy() - constructor(place: Place) : this( Locale.getDefault().language, place.longDescription, @@ -41,4 +39,24 @@ data class UploadMediaDetail constructor( */ var isManuallyAdded: Boolean = false + companion object { + /** + * Formatting captions to the Wikibase format for sending labels + * @param uploadMediaDetails list of media Details + */ + @JvmStatic + fun formatCaptions(uploadMediaDetails: List) = + uploadMediaDetails.associate { it.languageCode to it.captionText }.filter { it.value.isNotBlank() } + + /** + * 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}} + */ + @JvmStatic + fun formatList(descriptions: List) = + descriptions.filter { it.descriptionText.isNotEmpty() } + .joinToString { "{{${it.languageCode}|1=${it.descriptionText}}}" } + } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java index 2e0a289f7c9..1a95c0e948e 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java @@ -1,7 +1,11 @@ package fr.free.nrw.commons.upload; +import android.content.Context; +import android.graphics.drawable.Drawable; import android.text.TextUtils; +import android.util.DisplayMetrics; import android.view.LayoutInflater; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; @@ -13,7 +17,6 @@ import androidx.recyclerview.widget.RecyclerView; import butterknife.BindView; import butterknife.ButterKnife; -import com.google.android.material.textfield.TextInputLayout; import fr.free.nrw.commons.R; import fr.free.nrw.commons.utils.AbstractTextWatcher; import java.util.ArrayList; @@ -67,6 +70,15 @@ public int getItemCount() { return uploadMediaDetails.size(); } + /** + * Gets descriptions + * + * @return List of descriptions + */ + public List getUploadMediaDetails() { + return uploadMediaDetails; + } + public void addDescription(UploadMediaDetail uploadMediaDetail) { this.uploadMediaDetails.add(uploadMediaDetail); notifyItemInserted(uploadMediaDetails.size()); @@ -81,15 +93,9 @@ public class ViewHolder extends RecyclerView.ViewHolder { @BindView(R.id.description_item_edit_text) AppCompatEditText descItemEditText; - @BindView(R.id.description_item_edit_text_input_layout) - TextInputLayout descInputLayout; - @BindView(R.id.caption_item_edit_text) AppCompatEditText captionItemEditText; - @BindView(R.id.caption_item_edit_text_input_layout) - TextInputLayout captionInputLayout; - public ViewHolder(View itemView) { super(itemView); ButterKnife.bind(this, itemView); @@ -99,6 +105,8 @@ public ViewHolder(View itemView) { public void bind(int position) { UploadMediaDetail uploadMediaDetail = uploadMediaDetails.get(position); Timber.d("UploadMediaDetail is " + uploadMediaDetail); + captionItemEditText.setText(uploadMediaDetail.getCaptionText()); + descItemEditText.setText(uploadMediaDetail.getDescriptionText()); captionItemEditText.addTextChangedListener(new AbstractTextWatcher( value -> { @@ -106,23 +114,40 @@ public void bind(int position) { eventListener.onPrimaryCaptionTextChange(value.length() != 0); } })); - captionItemEditText.setText(uploadMediaDetail.getCaptionText()); - descItemEditText.setText(uploadMediaDetail.getDescriptionText()); if (position == 0) { - captionInputLayout.setEndIconMode(TextInputLayout.END_ICON_CUSTOM); - captionInputLayout.setEndIconDrawable(R.drawable.mapbox_info_icon_default); - captionInputLayout.setEndIconOnClickListener(v -> - callback.showAlert(R.string.media_detail_caption, R.string.caption_info)); - - descInputLayout.setEndIconMode(TextInputLayout.END_ICON_CUSTOM); - descInputLayout.setEndIconDrawable(R.drawable.mapbox_info_icon_default); - descInputLayout.setEndIconOnClickListener(v -> - callback.showAlert(R.string.media_detail_description, R.string.description_info)); + captionItemEditText.setCompoundDrawablesWithIntrinsicBounds(null, null, getInfoIcon(), + null); + captionItemEditText.setOnTouchListener((v, event) -> { + //2 is for drawable right + if (event.getAction() == MotionEvent.ACTION_UP && (event.getRawX() >= (captionItemEditText.getRight() - captionItemEditText.getCompoundDrawables()[2].getBounds().width()))) { + if (getAdapterPosition() == 0) { + callback.showAlert(R.string.media_detail_caption, + R.string.caption_info); + } + return true; + } + return false; + }); + + descItemEditText.setCompoundDrawablesWithIntrinsicBounds(null, null, getInfoIcon(), + null); + descItemEditText.setOnTouchListener((v, event) -> { + //2 is for drawable right + float twelveDpInPixels = convertDpToPixel(12, descItemEditText.getContext()); + if (event.getAction() == MotionEvent.ACTION_UP && descItemEditText.getCompoundDrawables()[2].getBounds().contains((int)(descItemEditText.getWidth()-(event.getX()+twelveDpInPixels)),(int)(event.getY()-twelveDpInPixels))){ + if (getAdapterPosition() == 0) { + callback.showAlert(R.string.media_detail_description, + R.string.description_info); + } + return true; + } + return false; + }); } else { - captionInputLayout.setEndIconDrawable(null); - descInputLayout.setEndIconDrawable(null); + captionItemEditText.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + descItemEditText.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); } captionItemEditText.addTextChangedListener(new AbstractTextWatcher( @@ -193,6 +218,15 @@ public void onNothingSelected(AdapterView adapterView) { selectedLanguages.put(spinnerDescriptionLanguages, description.getLanguageCode()); } } + + /** + * Extracted out the method to get the icon drawable + */ + private Drawable getInfoIcon() { + return descItemEditText.getContext() + .getResources() + .getDrawable(R.drawable.mapbox_info_icon_default); + } } public interface Callback { @@ -204,4 +238,13 @@ public interface EventListener { void onPrimaryCaptionTextChange(boolean isNotEmpty); } + /** + * converts dp to pixel + * @param dp + * @param context + * @return + */ + private float convertDpToPixel(float dp, Context context) { + return dp * ((float) context.getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT); + } } 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 index b15e9ffabf7..29abc0254dd 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java @@ -1,7 +1,9 @@ package fr.free.nrw.commons.upload; +import android.annotation.SuppressLint; import android.content.Context; import android.net.Uri; +import androidx.annotation.Nullable; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.filepicker.UploadableFile; @@ -9,10 +11,13 @@ import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.settings.Prefs; import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import fr.free.nrw.commons.utils.ImageUtils; import io.reactivex.Observable; import io.reactivex.Single; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.subjects.BehaviorSubject; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.Iterator; import java.util.List; @@ -37,8 +42,8 @@ public class UploadModel { private final SessionManager sessionManager; private final FileProcessor fileProcessor; private final ImageProcessingService imageProcessingService; - private final List selectedCategories = new ArrayList<>(); - private final List selectedDepictions = new ArrayList<>(); + private List selectedCategories = new ArrayList<>(); + private List selectedDepictions = new ArrayList<>(); @Inject UploadModel(@Named("licenses") final List licenses, @@ -70,8 +75,7 @@ public void cleanUp() { } public void setSelectedCategories(List selectedCategories) { - this.selectedCategories.clear(); - this.selectedCategories.addAll(selectedCategories); + this.selectedCategories = selectedCategories; } /** @@ -102,8 +106,8 @@ private UploadItem createAndAddUploadItem(final UploadableFile uploadableFile, Timber.d("File created date is %d", fileCreatedDate); final ImageCoordinates imageCoordinates = fileProcessor .processFileCoordinates(similarImageInterface, uploadableFile.getFilePath()); - final UploadItem uploadItem = new UploadItem( - Uri.parse(uploadableFile.getFilePath()), + final UploadItem uploadItem = new UploadItem(uploadableFile.getContentUri(), + Uri.parse(uploadableFile.getFilePath()), uploadableFile.getMimeType(context), imageCoordinates, place, fileCreatedDate, createdTimestampSource); if (place != null) { @@ -141,14 +145,10 @@ public Observable buildContributions() { { final Contribution contribution = new Contribution( item, sessionManager, newListOf(selectedDepictions), newListOf(selectedCategories)); - - contribution.setHasInvalidLocation(item.hasInvalidLocation()); - Timber.d("Created timestamp while building contribution is %s, %s", - item.getCreatedTimestamp(), - new Date(item.getCreatedTimestamp())); - - if (item.getCreatedTimestamp() != -1L) { + item.getCreatedTimestamp(), + new Date(item.getCreatedTimestamp())); + if (item.createdTimestamp != -1L) { contribution.setDateCreated(new Date(item.getCreatedTimestamp())); contribution.setDateCreatedSource(item.getCreatedTimestampSource()); //Set the date only if you have it, else the upload service is gonna try it the other way @@ -160,7 +160,7 @@ public Observable buildContributions() { public void deletePicture(final String filePath) { final Iterator iterator = items.iterator(); while (iterator.hasNext()) { - if (iterator.next().getMediaUri().toString().contains(filePath)) { + if (iterator.next().mediaUri.toString().contains(filePath)) { iterator.remove(); break; } @@ -174,6 +174,11 @@ public List getItems() { return items; } + public void updateUploadItem(final int index, final UploadItem uploadItem) { + final UploadItem uploadItem1 = items.get(index); + uploadItem1.setMediaDetails(uploadItem.uploadMediaDetails); + } + public void onDepictItemClicked(DepictedItem depictedItem) { if (depictedItem.isSelected()) { selectedDepictions.add(depictedItem); @@ -196,4 +201,104 @@ public List getSelectedDepictions() { return selectedDepictions; } + @SuppressWarnings("WeakerAccess") + public static class UploadItem { + + private final Uri originalContentUri; + private final Uri mediaUri; + private final String mimeType; + private ImageCoordinates gpsCoords; + private List uploadMediaDetails; + private final Place place; + private final long createdTimestamp; + private final String createdTimestampSource; + private final BehaviorSubject imageQuality; + @SuppressLint("CheckResult") + UploadItem(final Uri originalContentUri, + final Uri mediaUri, final String mimeType, + final ImageCoordinates gpsCoords, + final Place place, + final long createdTimestamp, + final String createdTimestampSource) { + this.originalContentUri = originalContentUri; + this.createdTimestampSource = createdTimestampSource; + uploadMediaDetails = new ArrayList<>(Arrays.asList(new UploadMediaDetail())); + this.place = place; + this.mediaUri = mediaUri; + this.mimeType = mimeType; + this.gpsCoords = gpsCoords; + this.createdTimestamp = createdTimestamp; + imageQuality = BehaviorSubject.createDefault(ImageUtils.IMAGE_WAIT); + } + + public String getCreatedTimestampSource() { + return createdTimestampSource; + } + + public ImageCoordinates getGpsCoords() { + return gpsCoords; + } + + public List getUploadMediaDetails() { + return uploadMediaDetails; + } + + public long getCreatedTimestamp() { + return createdTimestamp; + } + + public Uri getMediaUri() { + return mediaUri; + } + + public int getImageQuality() { + return this.imageQuality.getValue(); + } + + public void setImageQuality(final int imageQuality) { + this.imageQuality.onNext(imageQuality); + } + + public Place getPlace() { + return place; + } + + public void setMediaDetails(final List uploadMediaDetails) { + this.uploadMediaDetails = uploadMediaDetails; + } + + public Uri getContentUri() { + return originalContentUri; + } + + @Override + public boolean equals(@Nullable final Object obj) { + if (!(obj instanceof UploadItem)) { + return false; + } + return this.mediaUri.toString().contains(((UploadItem) (obj)).mediaUri.toString()); + + } + + @Override + public int hashCode() { + return mediaUri.hashCode(); + } + + /** + * Choose a filename for the media. + * Currently, the caption is used as a filename. If several languages have been entered, the first language is used. + */ + public String getFileName() { + return uploadMediaDetails.get(0).getCaptionText(); + } + + public void setGpsCoords(final ImageCoordinates gpsCoords) { + this.gpsCoords = gpsCoords; + } + + public String getMimeType() { + return mimeType; + } + } } 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 index ea3600e289f..5e2dec54a5e 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.java @@ -1,21 +1,22 @@ package fr.free.nrw.commons.upload; import android.annotation.SuppressLint; -import fr.free.nrw.commons.CommonsApplication; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + import fr.free.nrw.commons.R; import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.filepicker.UploadableFile; -import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.repository.UploadRepository; import io.reactivex.Observer; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; -import java.lang.reflect.Proxy; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; import timber.log.Timber; /** @@ -28,16 +29,13 @@ public class UploadPresenter implements UploadContract.UserActionListener { UploadContract.View.class.getClassLoader(), new Class[]{UploadContract.View.class}, (proxy, method, methodArgs) -> null); private final UploadRepository repository; - private final JsonKvStore defaultKvStore; private UploadContract.View view = DUMMY; private CompositeDisposable compositeDisposable; @Inject - UploadPresenter(UploadRepository uploadRepository, - @Named("default_preferences") JsonKvStore defaultKvStore) { + UploadPresenter(UploadRepository uploadRepository) { this.repository = uploadRepository; - this.defaultKvStore = defaultKvStore; compositeDisposable = new CompositeDisposable(); } @@ -56,14 +54,7 @@ public void handleSubmit() { @Override public void onSubscribe(Disposable d) { view.showProgress(false); - if (defaultKvStore - .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, - false)) { - view.showMessage(R.string.uploading_queued); - } else { - view.showMessage(R.string.uploading_started); - } - + view.showMessage(R.string.uploading_started); compositeDisposable.add(d); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.kt index 0a45999d78c..d3ec0cc647c 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadResult.kt @@ -1,47 +1,17 @@ package fr.free.nrw.commons.upload -import android.os.Parcel -import android.os.Parcelable import org.wikipedia.gallery.ImageInfo private const val RESULT_SUCCESS = "Success" - data class UploadResult( val result: String, val filekey: String, - val offset: Int, - val filename: String -) : Parcelable { - constructor(parcel: Parcel) : this( - parcel.readString(), - parcel.readString(), - parcel.readInt(), - parcel.readString() - ) { - } - + val filename: String, + val sessionkey: String, + val imageinfo: ImageInfo +) { fun isSuccessful(): Boolean = result == RESULT_SUCCESS fun createCanonicalFileName() = "File:$filename" - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeString(result) - parcel.writeString(filekey) - parcel.writeInt(offset) - parcel.writeString(filename) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): UploadResult { - return UploadResult(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } } 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 2553a1796c6..2dd9b6f1d70 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 @@ -7,20 +7,8 @@ import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Bundle; - import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; - -import java.io.File; -import java.io.IOException; -import java.util.HashSet; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import javax.inject.Inject; -import javax.inject.Named; - import fr.free.nrw.commons.BuildConfig; import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.HandlerService; @@ -35,10 +23,17 @@ import fr.free.nrw.commons.wikidata.WikidataEditService; import io.reactivex.Observable; import io.reactivex.Scheduler; -import io.reactivex.SingleObserver; import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; +import java.io.File; +import java.io.IOException; +import java.text.ParseException; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.inject.Inject; +import javax.inject.Named; import timber.log.Timber; public class UploadService extends HandlerService { @@ -48,7 +43,6 @@ public class UploadService extends HandlerService { public static final int ACTION_UPLOAD_FILE = 1; public static final String ACTION_START_SERVICE = EXTRA_PREFIX + ".upload"; - public static final String EXTRA_SOURCE = EXTRA_PREFIX + ".source"; public static final String EXTRA_FILES = EXTRA_PREFIX + ".files"; @Inject WikidataEditService wikidataEditService; @Inject SessionManager sessionManager; @@ -152,7 +146,6 @@ protected void handle(int what, Contribution contribution) { @Override public void queue(int what, Contribution contribution) { - Timber.d("Upload service queue has contribution with wiki data entity id as %s", contribution.getWikiDataEntityId()); switch (what) { case ACTION_UPLOAD_FILE: @@ -169,7 +162,7 @@ public void queue(int what, Contribution contribution) { .subscribeOn(ioThreadScheduler) .observeOn(mainThreadScheduler) .subscribe(aLong->{ - contribution._id = aLong; + contribution.set_id(aLong); UploadService.super.queue(what, contribution); }, Throwable::printStackTrace)); break; @@ -252,55 +245,68 @@ private void uploadContribution(Contribution contribution) { Timber.d("Stash upload response 1 is %s", uploadStash.toString()); - String resultStatus = uploadStash.getResult(); - if (!resultStatus.equals("Success")) { - Timber.d("Contribution upload failed. Wikidata entity won't be edited"); - showFailedNotification(contribution); - return Observable.never(); - } else { + if (uploadStash.isSuccessful()) { Timber.d("making sure of uniqueness of name: %s", filename); String uniqueFilename = findUniqueFilename(filename); unfinishedUploads.add(uniqueFilename); return uploadClient.uploadFileFromStash( - getApplicationContext(), - contribution, - uniqueFilename, - uploadStash.getFilekey()); - } - }) - .subscribe(uploadResult -> { - Timber.d("Stash upload response 2 is %s", uploadResult.toString()); - - notificationManager.cancel(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS); - - String resultStatus = uploadResult.getResult(); - if (!resultStatus.equals("Success")) { + getApplicationContext(), + contribution, + uniqueFilename, + uploadStash.getFilekey()); + } else { Timber.d("Contribution upload failed. Wikidata entity won't be edited"); showFailedNotification(contribution); - } else { - String canonicalFilename = "File:" + uploadResult.getFilename(); - Timber.d("Contribution upload success. Initiating Wikidata edit for" - + " entity id %s if necessary (if P18 is null). P18 value is %s", - contribution.getWikiDataEntityId(), contribution.getP18Value()); - wikidataEditService.createClaimWithLogging(contribution.getWikiDataEntityId(), contribution.getWikiItemName(), canonicalFilename, contribution.getP18Value()); - contribution.setFilename(canonicalFilename); - contribution.setImageUrl(uploadResult.getImageinfo().getOriginalUrl()); - contribution.setState(Contribution.STATE_COMPLETED); - contribution.setDateUploaded(CommonsDateUtil.getIso8601DateFormatTimestamp() - .parse(uploadResult.getImageinfo().getTimestamp())); - compositeDisposable.add(contributionDao - .save(contribution) - .subscribeOn(ioThreadScheduler) - .observeOn(mainThreadScheduler) - .subscribe()); + return Observable.never(); } - }, throwable -> { + }) + .subscribe( + uploadResult -> onUpload(contribution, notificationTag, uploadResult), + throwable -> { Timber.w(throwable, "Exception during upload"); notificationManager.cancel(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS); showFailedNotification(contribution); }); } + private void onUpload(Contribution contribution, String notificationTag, + UploadResult uploadResult) throws ParseException { + Timber.d("Stash upload response 2 is %s", uploadResult.toString()); + + notificationManager.cancel(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS); + + if (uploadResult.isSuccessful()) { + onSuccessfulUpload(contribution, uploadResult); + } else { + Timber.d("Contribution upload failed. Wikidata entity won't be edited"); + showFailedNotification(contribution); + } + } + + private void onSuccessfulUpload(Contribution contribution, UploadResult uploadResult) + throws ParseException { + compositeDisposable + .add(wikidataEditService.addDepictionsAndCaptions(uploadResult, contribution)); + WikidataPlace wikidataPlace = contribution.getWikidataPlace(); + if (wikidataPlace != null && wikidataPlace.getImageValue() == null) { + wikidataEditService.createImageClaim(wikidataPlace, uploadResult); + } + saveCompletedContribution(contribution, uploadResult); + } + + private void saveCompletedContribution(Contribution contribution, UploadResult uploadResult) throws ParseException { + contribution.setFilename(uploadResult.createCanonicalFileName()); + contribution.setImageUrl(uploadResult.getImageinfo().getOriginalUrl()); + contribution.setState(Contribution.STATE_COMPLETED); + contribution.setDateUploaded(CommonsDateUtil.getIso8601DateFormatTimestamp() + .parse(uploadResult.getImageinfo().getTimestamp())); + compositeDisposable.add(contributionDao + .save(contribution) + .subscribeOn(ioThreadScheduler) + .observeOn(mainThreadScheduler) + .subscribe()); + } + @SuppressLint("StringFormatInvalid") @SuppressWarnings("deprecation") private void showFailedNotification(Contribution contribution) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/WikidataPlace.kt b/app/src/main/java/fr/free/nrw/commons/upload/WikidataPlace.kt index 0081399bc58..4f11d0000e2 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/WikidataPlace.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/WikidataPlace.kt @@ -5,19 +5,12 @@ import fr.free.nrw.commons.nearby.Place import kotlinx.android.parcel.Parcelize @Parcelize -data class WikidataPlace( - override val id: String, - override val name: String, - val imageValue: String?, - val wikipediaArticle: String? -) : - WikidataItem, Parcelable { +internal data class WikidataPlace(override val id: String, override val name: String, val imageValue: String?) : + WikidataItem,Parcelable { constructor(place: Place) : this( place.wikiDataEntityId!!, place.name, - place.pic.takeIf { it.isNotBlank() }, - place.siteLinks.wikipediaLink?.toString() ?: "" - ) + place.pic.takeIf { it.isNotBlank() }) companion object { @JvmStatic @@ -25,8 +18,4 @@ data class WikidataPlace( return place?.let { WikidataPlace(it) } } } - - fun getWikipediaPageTitle(): String? { - return wikipediaArticle?.substringAfterLast("/") - } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.java new file mode 100644 index 00000000000..aaa52a85ca2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.java @@ -0,0 +1,146 @@ +package fr.free.nrw.commons.upload.categories; + +import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; +import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD; + +import android.text.TextUtils; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.category.CategoryItem; +import fr.free.nrw.commons.repository.UploadRepository; +import fr.free.nrw.commons.upload.UploadModel.UploadItem; +import io.reactivex.Observable; +import io.reactivex.Scheduler; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import timber.log.Timber; + +/** + * The presenter class for UploadCategoriesFragment + */ +@Singleton +public class CategoriesPresenter implements CategoriesContract.UserActionListener { + + private static final CategoriesContract.View DUMMY = (CategoriesContract.View) Proxy + .newProxyInstance( + CategoriesContract.View.class.getClassLoader(), + new Class[]{CategoriesContract.View.class}, + (proxy, method, methodArgs) -> null); + private final Scheduler ioScheduler; + private final Scheduler mainThreadScheduler; + + CategoriesContract.View view = DUMMY; + private UploadRepository repository; + + private CompositeDisposable compositeDisposable; + + @Inject + public CategoriesPresenter(UploadRepository repository, @Named(IO_THREAD) Scheduler ioScheduler, + @Named(MAIN_THREAD) Scheduler mainThreadScheduler) { + this.repository = repository; + this.ioScheduler = ioScheduler; + this.mainThreadScheduler = mainThreadScheduler; + compositeDisposable = new CompositeDisposable(); + } + + @Override + public void onAttachView(CategoriesContract.View view) { + this.view = view; + } + + @Override + public void onDetachView() { + this.view = DUMMY; + compositeDisposable.clear(); + } + + /** + * asks the repository to fetch categories for the query + * @param query + * + */ + @Override + public void searchForCategories(String query) { + List categoryItems = new ArrayList<>(); + List imageTitleList = getImageTitleList(); + Observable distinctCategoriesObservable = Observable + .fromIterable(repository.getSelectedCategories()) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .doOnSubscribe(disposable -> { + view.showProgress(true); + view.showError(null); + view.setCategories(null); + }) + .observeOn(ioScheduler) + .concatWith( + repository.searchAll(query, imageTitleList) + ) + .filter(categoryItem -> !repository.containsYear(categoryItem.getName())) + .distinct(); + + if(!TextUtils.isEmpty(query)) { + distinctCategoriesObservable=distinctCategoriesObservable.sorted(repository.sortBySimilarity(query)); + } + Disposable searchCategoriesDisposable = distinctCategoriesObservable + .observeOn(mainThreadScheduler) + .subscribe( + s -> categoryItems.add(s), + Timber::e, + () -> { + view.setCategories(categoryItems); + view.showProgress(false); + + if (categoryItems.isEmpty()) { + view.showError(R.string.no_categories_found); + } + } + ); + + compositeDisposable.add(searchCategoriesDisposable); + } + + /** + * Returns image title list from UploadItem + * @return + */ + private List getImageTitleList() { + List titleList = new ArrayList<>(); + for (UploadItem item : repository.getUploads()) { + final String captionText = item.getUploadMediaDetails().get(0).getCaptionText(); + if (!TextUtils.isEmpty(captionText)) { + titleList.add(captionText); + } + } + return titleList; + } + + /** + * Verifies the number of categories selected, prompts the user if none selected + */ + @Override + public void verifyCategories() { + List selectedCategories = repository.getSelectedCategories(); + if (selectedCategories != null && !selectedCategories.isEmpty()) { + repository.setSelectedCategories(repository.getCategoryStringList()); + view.goToNextScreen(); + } else { + view.showNoCategorySelected(); + } + } + + /** + * ask repository to handle category clicked + * + * @param categoryItem + */ + @Override + public void onCategoryItemClicked(CategoryItem categoryItem) { + repository.onCategoryClicked(categoryItem); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java index 3d3becd01d7..be2244b76ce 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.java @@ -1,7 +1,6 @@ package fr.free.nrw.commons.upload.categories; import android.os.Bundle; -import android.text.Editable; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -18,19 +17,23 @@ import com.google.android.material.textfield.TextInputLayout; import com.jakewharton.rxbinding2.view.RxView; import com.jakewharton.rxbinding2.widget.RxTextView; +import com.pedrogomez.renderers.RVRendererAdapter; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.category.CategoryClickedListener; import fr.free.nrw.commons.category.CategoryItem; import fr.free.nrw.commons.upload.UploadBaseFragment; +import fr.free.nrw.commons.upload.UploadCategoriesAdapterFactory; import fr.free.nrw.commons.utils.DialogUtil; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import javax.inject.Inject; -import kotlin.Unit; import timber.log.Timber; -public class UploadCategoriesFragment extends UploadBaseFragment implements CategoriesContract.View { +public class UploadCategoriesFragment extends UploadBaseFragment implements CategoriesContract.View, + CategoryClickedListener { @BindView(R.id.tv_title) TextView tvTitle; @@ -45,8 +48,15 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate @Inject CategoriesContract.UserActionListener presenter; - private UploadCategoryAdapter adapter; + private RVRendererAdapter adapter; private Disposable subscribe; + private List categories; + private boolean isVisible; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } @Nullable @Override @@ -68,6 +78,15 @@ private void init() { presenter.onAttachView(this); initRecyclerView(); addTextChangeListenerToEtSearch(); + //get default categories for empty query + } + + @Override + public void onResume() { + super.onResume(); + if (presenter != null && isVisible && (categories == null || categories.isEmpty())) { + presenter.searchForCategories(null); + } } private void addTextChangeListenerToEtSearch() { @@ -84,10 +103,8 @@ private void searchForCategory(String query) { } private void initRecyclerView() { - adapter = new UploadCategoryAdapter(categoryItem -> { - presenter.onCategoryItemClicked(categoryItem); - return Unit.INSTANCE; - }); + adapter = new UploadCategoriesAdapterFactory(this) + .create(new ArrayList<>()); rvCategories.setLayoutManager(new LinearLayoutManager(getContext())); rvCategories.setAdapter(adapter); } @@ -116,11 +133,11 @@ public void showError(int stringResourceId) { @Override public void setCategories(List categories) { - if(categories==null) { - adapter.clear(); - } - else{ - adapter.setItems(categories); + adapter.clear(); + if (categories != null) { + this.categories = categories; + adapter.addAll(categories); + adapter.notifyDataSetChanged(); } } @@ -134,8 +151,8 @@ public void showNoCategorySelected() { DialogUtil.showAlertDialog(getActivity(), getString(R.string.no_categories_selected), getString(R.string.no_categories_selected_warning_desc), - getString(R.string.continue_message), - getString(R.string.cancel), + getString(R.string.yes_submit), + getString(R.string.no_go_back), () -> goToNextScreen(), null); @@ -152,11 +169,17 @@ public void onPreviousButtonClicked() { } @Override - protected void onBecameVisible() { - super.onBecameVisible(); - final Editable text = etSearch.getText(); - if (text != null) { - presenter.searchForCategories(text.toString()); + public void categoryClicked(CategoryItem item) { + presenter.onCategoryItemClicked(item); + } + + @Override + public void setUserVisibleHint(boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + isVisible = isVisibleToUser; + + if (presenter != null && isResumed() && (categories == null || categories.isEmpty())) { + presenter.searchForCategories(null); } } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.java b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.java index 5f215c3adca..269f721aeb1 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.java @@ -1,9 +1,9 @@ package fr.free.nrw.commons.upload.depicts; -import androidx.lifecycle.LiveData; +import java.util.List; + import fr.free.nrw.commons.BasePresenter; import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; -import java.util.List; /** * The contract with which DepictsFragment and its presenter would talk to each other @@ -40,6 +40,11 @@ interface View { * add depictions to list */ void setDepictsList(List depictedItemList); + + /** + * Set thumbnail image for depicted item + */ + void onImageUrlFetched(String response, int position); } interface UserActionListener extends BasePresenter { @@ -66,6 +71,11 @@ interface UserActionListener extends BasePresenter { */ void verifyDepictions(); - LiveData> getDepictedItems(); + /** + * Fetch thumbnail for the Wikidata Item + * @param entityId entityId of the item + * @param position position of the item + */ + void fetchThumbnailForEntityId(String entityId, int position); } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java index 50eca58f638..4ac5e772779 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java @@ -17,23 +17,26 @@ import com.google.android.material.textfield.TextInputLayout; import com.jakewharton.rxbinding2.view.RxView; import com.jakewharton.rxbinding2.widget.RxTextView; +import com.pedrogomez.renderers.RVRendererAdapter; import fr.free.nrw.commons.R; import fr.free.nrw.commons.upload.UploadBaseFragment; +import fr.free.nrw.commons.upload.UploadDepictsAdapterFactory; import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import fr.free.nrw.commons.upload.structure.depictions.UploadDepictsCallback; import fr.free.nrw.commons.utils.DialogUtil; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; import javax.inject.Inject; -import kotlin.Unit; import timber.log.Timber; /** * Fragment for showing depicted items list in Upload activity after media details */ -public class DepictsFragment extends UploadBaseFragment implements DepictsContract.View { +public class DepictsFragment extends UploadBaseFragment implements DepictsContract.View, UploadDepictsCallback { @BindView(R.id.depicts_title) TextView depictsTitle; @@ -48,7 +51,7 @@ public class DepictsFragment extends UploadBaseFragment implements DepictsContra @Inject DepictsContract.UserActionListener presenter; - private UploadDepictsAdapter adapter; + private RVRendererAdapter adapter; private Disposable subscribe; @Nullable @@ -63,7 +66,6 @@ public void onViewCreated(@NonNull android.view.View view, @Nullable Bundle save super.onViewCreated(view, savedInstanceState); ButterKnife.bind(this, view); init(); - presenter.getDepictedItems().observe(getViewLifecycleOwner(), this::setDepictsList); } /** @@ -81,10 +83,8 @@ private void init() { * Initialise recyclerView and set adapter */ private void initRecyclerView() { - adapter = new UploadDepictsAdapter(item -> { - presenter.onDepictItemClicked(item); - return Unit.INSTANCE; - }); + adapter = new UploadDepictsAdapterFactory(this) + .create(new ArrayList<>()); depictsRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); depictsRecyclerView.setAdapter(adapter); } @@ -104,8 +104,8 @@ public void noDepictionSelected() { DialogUtil.showAlertDialog(getActivity(), getString(R.string.no_depictions_selected), getString(R.string.no_depictions_selected_warning_desc), - getString(R.string.continue_message), - getString(R.string.cancel), + getString(R.string.yes_submit), + getString(R.string.no_go_back), this::goToNextScreen, null ); @@ -125,16 +125,27 @@ public void showProgress(boolean shouldShow) { @Override public void showError(Boolean value) { - if (value) { - depictsSearchContainer.setError(getString(R.string.no_depiction_found)); - } else { - depictsSearchContainer.setErrorEnabled(false); - } + if (value) + depictsSearchContainer.setError(getString(R.string.no_depiction_found)); + else depictsSearchContainer.setErrorEnabled(false); } @Override public void setDepictsList(List depictedItemList) { - adapter.setItems(depictedItemList); + adapter.clear(); + if (depictedItemList != null) { + adapter.addAll(depictedItemList); + adapter.notifyDataSetChanged(); + } + } + + /** + * Set thumbnail image for depicted item + */ + @Override + public void onImageUrlFetched(String response, int position) { + adapter.getItem(position).setImageUrl(response); + adapter.notifyItemChanged(position); } @OnClick(R.id.depicts_next) @@ -147,6 +158,19 @@ public void onPreviousButtonClicked() { callback.onPreviousButtonClicked(callback.getIndexInViewFlipper(this)); } + @Override + public void depictsClicked(DepictedItem item) { + presenter.onDepictItemClicked(item); + } + + /** + * Fetch thumbnail for the given entityId at the given position + */ + @Override + public void fetchThumbnailUrlForEntity(String entityId, int position) { + presenter.fetchThumbnailForEntityId(entityId,position); + } + /** * Text change listener for the edit text view of depicts */ diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsInterface.java b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsInterface.java index 00aa81287b6..e6df617b2ac 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsInterface.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsInterface.java @@ -1,8 +1,8 @@ package fr.free.nrw.commons.upload.depicts; import fr.free.nrw.commons.wikidata.model.DepictSearchResponse; -import io.reactivex.Single; -import org.wikipedia.wikidata.Entities; +import io.reactivex.Observable; +import org.wikipedia.wikidata.ClaimsResponse; import retrofit2.http.GET; import retrofit2.http.Query; @@ -21,8 +21,8 @@ public interface DepictsInterface { * @param offset number of depictions already fetched useful in implementing pagination */ @GET("/w/api.php?action=wbsearchentities&format=json&type=item&uselang=en") - Single searchForDepicts(@Query("search") String query, @Query("limit") String limit, @Query("language") String language, @Query("uselang") String uselang, @Query("continue") String offset); + Observable searchForDepicts(@Query("search") String query, @Query("limit") String limit, @Query("language") String language, @Query("uselang") String uselang, @Query("continue") String offset); - @GET("/w/api.php?format=json&action=wbgetentities") - Single getEntities(@Query("ids") String ids); + @GET("/w/api.php?action=wbgetclaims&format=json&property=P18") + Observable getImageForEntity(@Query("entity") String entityId); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.java new file mode 100644 index 00000000000..756a6527044 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.java @@ -0,0 +1,152 @@ +package fr.free.nrw.commons.upload.depicts; + +import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; +import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD; + +import fr.free.nrw.commons.explore.depictions.DepictsClient; +import fr.free.nrw.commons.repository.UploadRepository; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; +import io.reactivex.Observable; +import io.reactivex.Scheduler; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import timber.log.Timber; + +/** + * presenter for DepictsFragment + */ +@Singleton +public class DepictsPresenter implements DepictsContract.UserActionListener { + + private static final DepictsContract.View DUMMY = (DepictsContract.View) Proxy + .newProxyInstance( + DepictsContract.View.class.getClassLoader(), + new Class[]{DepictsContract.View.class}, + (proxy, method, methodArgs) -> null); + + + private final Scheduler ioScheduler; + private final Scheduler mainThreadScheduler; + private DepictsContract.View view = DUMMY; + private UploadRepository repository; + private DepictsClient depictsClient; + private static int TIMEOUT_SECONDS = 15; + + private CompositeDisposable compositeDisposable; + + @Inject + public DepictsPresenter(UploadRepository uploadRepository, @Named(IO_THREAD) Scheduler ioScheduler, + @Named(MAIN_THREAD) Scheduler mainThreadScheduler, DepictsClient depictsClient) { + this.repository = uploadRepository; + this.ioScheduler = ioScheduler; + this.mainThreadScheduler = mainThreadScheduler; + this.depictsClient = depictsClient; + compositeDisposable = new CompositeDisposable(); + } + + @Override + public void onAttachView(DepictsContract.View view) { + this.view = view; + } + + @Override + public void onDetachView() { + this.view = DUMMY; + } + + @Override + public void onPreviousButtonClicked() { + view.goToPreviousScreen(); + } + + @Override + public void onDepictItemClicked(DepictedItem depictedItem) { + repository.onDepictItemClicked(depictedItem); + } + + /** + * asks the repository to fetch depictions for the query + * @param query + */ + @Override + public void searchForDepictions(String query) { + List depictedItemList = new ArrayList<>(); + Observable distinctDepictsObservable = Observable + .fromIterable(repository.getSelectedDepictions()) + .subscribeOn(ioScheduler) + .observeOn(mainThreadScheduler) + .doOnSubscribe(disposable -> { + view.showProgress(true); + view.setDepictsList(null); + }) + .observeOn(ioScheduler) + .concatWith( + repository.searchAllEntities(query) + ) + .distinct(); + + Disposable searchDepictsDisposable = distinctDepictsObservable + .observeOn(mainThreadScheduler) + .subscribe( + e -> { + depictedItemList.add(e); + }, + t -> { + view.showProgress(false); + view.showError(true); + Timber.e(t); + }, + () -> { + view.showProgress(false); + + if (depictedItemList.isEmpty()) { + view.showError(true); + } else { + view.showError(false); + view.setDepictsList(depictedItemList); + } + } + ); + compositeDisposable.add(searchDepictsDisposable); + view.setDepictsList(depictedItemList); + } + + /** + * Check if depictions were selected + * from the depiction list + */ + @Override + public void verifyDepictions() { + List selectedDepictions = repository.getSelectedDepictions(); + if (selectedDepictions != null && !selectedDepictions.isEmpty()) { + view.goToNextScreen(); + } else { + view.noDepictionSelected(); + } + } + + /** + * Fetch thumbnail for the Wikidata Item + * @param entityId entityId of the item + * @param position position of the item + */ + @Override + public void fetchThumbnailForEntityId(String entityId, int position) { + compositeDisposable.add(depictsClient.getP18ForItem(entityId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .subscribe(response -> { + view.onImageUrlFetched(response,position); + })); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java index 0ffa54eeaac..a2c3d99d2b4 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java @@ -31,7 +31,8 @@ import fr.free.nrw.commons.upload.UploadBaseFragment; import fr.free.nrw.commons.upload.UploadMediaDetail; import fr.free.nrw.commons.upload.UploadMediaDetailAdapter; -import fr.free.nrw.commons.upload.UploadItem; +import fr.free.nrw.commons.upload.UploadModel; +import fr.free.nrw.commons.upload.UploadModel.UploadItem; import fr.free.nrw.commons.utils.DialogUtil; import fr.free.nrw.commons.utils.ImageUtils; import fr.free.nrw.commons.utils.ViewUtil; @@ -44,8 +45,10 @@ import org.apache.commons.lang3.StringUtils; import timber.log.Timber; +//import fr.free.nrw.commons.upload.DescriptionsAdapter; + public class UploadMediaDetailFragment extends UploadBaseFragment implements - UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener { + UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener { @BindView(R.id.tv_title) TextView tvTitle; @@ -67,6 +70,9 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements @BindView(R.id.btn_copy_prev_title_desc) AppCompatButton btnCopyPreviousTitleDesc; + private UploadModel.UploadItem uploadItem; + private List descriptions; + @Inject UploadMediaDetailsContract.UserActionListener presenter; @@ -98,7 +104,7 @@ public void setImageTobeUploaded(UploadableFile uploadableFile, Place place) { @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { + @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_upload_media_detail_fragment, container, false); } @@ -111,7 +117,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat private void init() { tvTitle.setText(getString(R.string.step_count, callback.getIndexInViewFlipper(this) + 1, - callback.getTotalNumberOfSteps())); + callback.getTotalNumberOfSteps())); initRecyclerView(); initPresenter(); presenter.receiveImage(uploadableFile, place); @@ -140,10 +146,10 @@ private void init() { */ private void attachImageViewScaleChangeListener() { photoViewBackgroundImage.setOnScaleChangeListener( - (scaleFactor, focusX, focusY) -> { - //Whenever the uses plays with the image, lets collapse the media detail container - expandCollapseLlMediaDetail(false); - }); + (scaleFactor, focusX, focusY) -> { + //Whenever the uses plays with the image, lets collapse the media detail container + expandCollapseLlMediaDetail(false); + }); } /** @@ -175,7 +181,8 @@ private void showInfoAlert(int titleStringID, int messageStringId) { @OnClick(R.id.btn_next) public void onNextButtonClicked() { - presenter.verifyImageQuality(callback.getIndexInViewFlipper(this)); + uploadItem.setMediaDetails(uploadMediaDetailAdapter.getUploadMediaDetails()); + presenter.verifyImageQuality(uploadItem); } @OnClick(R.id.btn_previous) @@ -188,7 +195,6 @@ public void onButtonAddDescriptionClicked() { UploadMediaDetail uploadMediaDetail = new UploadMediaDetail(); uploadMediaDetail.setManuallyAdded(true);//This was manually added by the user uploadMediaDetailAdapter.addDescription(uploadMediaDetail); - rvDescriptions.scrollToPosition(uploadMediaDetailAdapter.getItemCount()-1); } @Override @@ -216,7 +222,10 @@ public void onNegativeResponse() { @Override public void onImageProcessed(UploadItem uploadItem, Place place) { + this.uploadItem = uploadItem; + descriptions = uploadItem.getUploadMediaDetails(); photoViewBackgroundImage.setImageURI(uploadItem.getMediaUri()); + setDescriptionsInAdapter(descriptions); } /** @@ -228,16 +237,17 @@ public void onImageProcessed(UploadItem uploadItem, Place place) { @Override public void onNearbyPlaceFound(UploadItem uploadItem, Place place) { DialogUtil.showAlertDialog(getActivity(), - getString(R.string.upload_nearby_place_found_title), - String.format(Locale.getDefault(), - getString(R.string.upload_nearby_place_found_description), - place.getName()), - () -> { - presenter.onUserConfirmedUploadIsOfPlace(place, callback.getIndexInViewFlipper(this)); - }, - () -> { + getString(R.string.upload_nearby_place_found_title), + String.format(Locale.getDefault(), + getString(R.string.upload_nearby_place_found_description), + place.getName()), + () -> { + descriptions = new ArrayList<>(Arrays.asList(new UploadMediaDetail(place))); + setDescriptionsInAdapter(descriptions); + }, + () -> { - }); + }); } @Override @@ -247,6 +257,7 @@ public void showProgress(boolean shouldShow) { @Override public void onImageValidationSuccess() { + presenter.setUploadItem(callback.getIndexInViewFlipper(this), uploadItem); callback.onNextButtonClicked(callback.getIndexInViewFlipper(this)); } @@ -261,38 +272,37 @@ public void showMessage(String message, int colorResourceId) { } @Override - public void showDuplicatePicturePopup(UploadItem uploadItem) { + public void showDuplicatePicturePopup() { String uploadTitleFormat = getString(R.string.upload_title_duplicate); DialogUtil.showAlertDialog(getActivity(), - getString(R.string.duplicate_image_found), - String.format(Locale.getDefault(), - uploadTitleFormat, - uploadItem.getFileName()), - getString(R.string.upload), - getString(R.string.cancel), - () -> { - uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP); - onNextButtonClicked(); - }, null); + getString(R.string.duplicate_image_found), + String.format(Locale.getDefault(), + uploadTitleFormat, + uploadItem.getFileName()), + getString(R.string.upload), + getString(R.string.cancel), + () -> { + uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP); + onNextButtonClicked(); + }, null); } @Override - public void showBadImagePopup(Integer errorCode, - UploadItem uploadItem) { + public void showBadImagePopup(Integer errorCode) { String errorMessageForResult = getErrorMessageForResult(getContext(), errorCode); if (!StringUtils.isBlank(errorMessageForResult)) { DialogUtil.showAlertDialog(getActivity(), - getString(R.string.upload_problem_image), - errorMessageForResult, - getString(R.string.upload), - getString(R.string.cancel), + getString(R.string.upload_problem_image), + errorMessageForResult, + getString(R.string.upload), + getString(R.string.cancel), () -> { - uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP); - onNextButtonClicked(); - }, - () -> deleteThisPicture() - ); + uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP); + onNextButtonClicked(); + }, + () -> deleteThisPicture() + ); } //If the error message is null, we will probably not show anything } @@ -302,15 +312,8 @@ public void showBadImagePopup(Integer errorCode, } @Override - public void showExternalMap(UploadItem uploadItem) { - Utils.handleGeoCoordinates(getContext(), - new LatLng(uploadItem.getGpsCoords().getDecLatitude(), - uploadItem.getGpsCoords().getDecLongitude(), 0.0f)); - } - - @Override - public void updateMediaDetails(List uploadMediaDetails) { - uploadMediaDetailAdapter.setItems(uploadMediaDetails); + public void setCaptionsAndDescriptions(List uploadMediaDetails) { + setDescriptionsInAdapter(uploadMediaDetails); } private void deleteThisPicture() { @@ -339,7 +342,9 @@ private void expandCollapseLlMediaDetail(boolean shouldExpand){ } @OnClick(R.id.ib_map) public void onIbMapClicked() { - presenter.onMapIconClicked(callback.getIndexInViewFlipper(this)); + Utils.handleGeoCoordinates(getContext(), + new LatLng(uploadItem.getGpsCoords().getDecLatitude(), + uploadItem.getGpsCoords().getDecLongitude(), 0.0f)); } @Override @@ -349,6 +354,7 @@ public void onPrimaryCaptionTextChange(boolean isNotEmpty) { btnNext.setAlpha(isNotEmpty ? 1.0f: 0.5f); } + public interface UploadMediaDetailFragmentCallback extends Callback { void deletePictureAtIndex(int index); @@ -360,4 +366,7 @@ public void onButtonCopyPreviousTitleDesc(){ presenter.fetchPreviousTitleAndDescription(callback.getIndexInViewFlipper(this)); } + private void setDescriptionsInAdapter(List uploadMediaDetails){ + uploadMediaDetailAdapter.setItems(uploadMediaDetails); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java index 495a8b40e71..47a807b7ea3 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.java @@ -6,7 +6,7 @@ import fr.free.nrw.commons.upload.ImageCoordinates; import fr.free.nrw.commons.upload.SimilarImageInterface; import fr.free.nrw.commons.upload.UploadMediaDetail; -import fr.free.nrw.commons.upload.UploadItem; +import fr.free.nrw.commons.upload.UploadModel.UploadItem; import java.util.List; /** @@ -28,30 +28,27 @@ interface View extends SimilarImageInterface { void showMessage(String message, int colorResourceId); - void showDuplicatePicturePopup(UploadItem uploadItem); + void showDuplicatePicturePopup(); - void showBadImagePopup(Integer errorCode, UploadItem uploadItem); + void showBadImagePopup(Integer errorCode); void showMapWithImageCoordinates(boolean shouldShow); - void showExternalMap(UploadItem uploadItem); - - void updateMediaDetails(List uploadMediaDetails); + void setCaptionsAndDescriptions(List uploadMediaDetails); } interface UserActionListener extends BasePresenter { void receiveImage(UploadableFile uploadableFile, Place place); - void verifyImageQuality(int uploadItemIndex); + void verifyImageQuality(UploadItem uploadItem); + + void setUploadItem(int index, UploadItem uploadItem); void fetchPreviousTitleAndDescription(int indexInViewFlipper); void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex); - void onMapIconClicked(int indexInViewFlipper); - - void onUserConfirmedUploadIsOfPlace(Place place, int uploadItemPosition); } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java index 69ba6b02596..59cff4c8a51 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java @@ -9,25 +9,20 @@ import fr.free.nrw.commons.R; import fr.free.nrw.commons.filepicker.UploadableFile; -import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.repository.UploadRepository; import fr.free.nrw.commons.upload.ImageCoordinates; import fr.free.nrw.commons.upload.SimilarImageInterface; -import fr.free.nrw.commons.upload.UploadItem; -import fr.free.nrw.commons.upload.UploadMediaDetail; +import fr.free.nrw.commons.upload.UploadModel.UploadItem; import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract.UserActionListener; import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract.View; -import io.reactivex.Maybe; +import io.reactivex.Observable; import io.reactivex.Scheduler; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import java.lang.reflect.Proxy; -import java.util.ArrayList; -import java.util.List; import javax.inject.Inject; import javax.inject.Named; -import org.jetbrains.annotations.NotNull; import timber.log.Timber; public class UploadMediaPresenter implements UserActionListener, SimilarImageInterface { @@ -43,17 +38,14 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt private CompositeDisposable compositeDisposable; - private final JsonKvStore defaultKVStore; private Scheduler ioScheduler; private Scheduler mainThreadScheduler; @Inject public UploadMediaPresenter(UploadRepository uploadRepository, - @Named("default_preferences") JsonKvStore defaultKVStore, @Named(IO_THREAD) Scheduler ioScheduler, @Named(MAIN_THREAD) Scheduler mainThreadScheduler) { this.repository = uploadRepository; - this.defaultKVStore = defaultKVStore; this.ioScheduler = ioScheduler; this.mainThreadScheduler = mainThreadScheduler; compositeDisposable = new CompositeDisposable(); @@ -78,25 +70,22 @@ public void onDetachView() { @Override public void receiveImage(UploadableFile uploadableFile, Place place) { view.showProgress(true); - compositeDisposable.add( - repository + Disposable uploadItemDisposable = repository .preProcessImage(uploadableFile, place, this) .subscribeOn(ioScheduler) .observeOn(mainThreadScheduler) .subscribe(uploadItem -> - { - view.onImageProcessed(uploadItem, place); - view.updateMediaDetails(uploadItem.getUploadMediaDetails()); - ImageCoordinates gpsCoords = uploadItem.getGpsCoords(); - final boolean hasImageCoordinates = - gpsCoords != null && gpsCoords.getImageCoordsExists(); - view.showMapWithImageCoordinates(hasImageCoordinates); - view.showProgress(false); - if (hasImageCoordinates) { - checkNearbyPlaces(uploadItem); - } - }, - throwable -> Timber.e(throwable, "Error occurred in processing images"))); + { + view.onImageProcessed(uploadItem, place); + ImageCoordinates gpsCoords = uploadItem.getGpsCoords(); + view.showMapWithImageCoordinates(gpsCoords != null && gpsCoords.getImageCoordsExists()); + view.showProgress(false); + if (gpsCoords != null && gpsCoords.getImageCoordsExists()) { + checkNearbyPlaces(uploadItem); + } + }, + throwable -> Timber.e(throwable, "Error occurred in processing images")); + compositeDisposable.add(uploadItemDisposable); } /** @@ -104,37 +93,32 @@ public void receiveImage(UploadableFile uploadableFile, Place place) { * @param uploadItem */ private void checkNearbyPlaces(UploadItem uploadItem) { - Disposable checkNearbyPlaces = Maybe.fromCallable(() -> repository + Disposable checkNearbyPlaces = Observable.fromCallable(() -> repository .checkNearbyPlaces(uploadItem.getGpsCoords().getDecLatitude(), uploadItem.getGpsCoords().getDecLongitude())) .subscribeOn(ioScheduler) .observeOn(mainThreadScheduler) - .subscribe(place -> { - if (place != null) { - view.onNearbyPlaceFound(uploadItem, place); - } - }, - throwable -> Timber.e(throwable, "Error occurred in processing images")); - compositeDisposable.add(checkNearbyPlaces); + .subscribe(place -> view.onNearbyPlaceFound(uploadItem, place), + throwable -> Timber.e(throwable, "Error occurred in processing images")); + compositeDisposable.add(checkNearbyPlaces); } /** * asks the repository to verify image quality * - * @param uploadItemIndex + * @param uploadItem */ @Override - public void verifyImageQuality(int uploadItemIndex) { + public void verifyImageQuality(UploadItem uploadItem) { view.showProgress(true); - final UploadItem uploadItem = repository.getUploads().get(uploadItemIndex); - compositeDisposable.add( + compositeDisposable.add( repository .getImageQuality(uploadItem) .observeOn(mainThreadScheduler) .subscribe(imageResult -> { view.showProgress(false); - handleImageResult(imageResult, uploadItem); + handleImageResult(imageResult); }, throwable -> { view.showProgress(false); @@ -145,6 +129,16 @@ public void verifyImageQuality(int uploadItemIndex) { ); } + /** + * Adds the corresponding upload item to the repository + * + * @param index + * @param uploadItem + */ + @Override + public void setUploadItem(int index, UploadItem uploadItem) { + repository.updateUploadItem(index, uploadItem); + } /** * Fetches and sets the caption and desctiption of the previous item @@ -154,56 +148,28 @@ public void verifyImageQuality(int uploadItemIndex) { @Override public void fetchPreviousTitleAndDescription(int indexInViewFlipper) { UploadItem previousUploadItem = repository.getPreviousUploadItem(indexInViewFlipper); - if (null != previousUploadItem) { - final UploadItem currentUploadItem = repository.getUploads().get(indexInViewFlipper); - currentUploadItem.setMediaDetails(deepCopy(previousUploadItem.getUploadMediaDetails())); - view.updateMediaDetails(currentUploadItem.getUploadMediaDetails()); + if (null != previousUploadItem) { + view.setCaptionsAndDescriptions(previousUploadItem.getUploadMediaDetails()); } else { view.showMessage(R.string.previous_image_title_description_not_found, R.color.color_error); } } - @NotNull - private List deepCopy(List uploadMediaDetails) { - final ArrayList newList = new ArrayList<>(); - for (UploadMediaDetail uploadMediaDetail : uploadMediaDetails) { - newList.add(uploadMediaDetail.javaCopy()); - } - return newList; - } - @Override public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex) { repository.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex); } - @Override - public void onMapIconClicked(int indexInViewFlipper) { - view.showExternalMap(repository.getUploads().get(indexInViewFlipper)); - } - - @Override - public void onUserConfirmedUploadIsOfPlace(Place place, int uploadItemPosition) { - final List uploadMediaDetails = repository.getUploads() - .get(uploadItemPosition) - .getUploadMediaDetails(); - uploadMediaDetails.set(0, new UploadMediaDetail(place)); - view.updateMediaDetails(uploadMediaDetails); - } - /** * handles image quality verifications * - * @param imageResult - * @param uploadItem - */ - public void handleImageResult(Integer imageResult, - UploadItem uploadItem) { + * @param imageResult + */ + public void handleImageResult(Integer imageResult) { if (imageResult == IMAGE_KEEP || imageResult == IMAGE_OK) { view.onImageValidationSuccess(); - uploadItem.setHasInvalidLocation(false); } else { - handleBadImage(imageResult, uploadItem); + handleBadImage(imageResult); } } @@ -211,14 +177,12 @@ public void handleImageResult(Integer imageResult, * Handle images, say empty caption, duplicate file name, bad picture(in all other cases) * * @param errorCode - * @param uploadItem */ - public void handleBadImage(Integer errorCode, - UploadItem uploadItem) { + public void handleBadImage(Integer errorCode) { Timber.d("Handle bad picture with error code %d", errorCode); if (errorCode - >= 8) { // If location of image and nearby does not match - uploadItem.setHasInvalidLocation(true); + >= 8) { // If location of image and nearby does not match, then set shared preferences to disable wikidata edits + repository.saveValue("Picture_Has_Correct_Location", false); } switch (errorCode) { @@ -228,10 +192,10 @@ public void handleBadImage(Integer errorCode, break; case FILE_NAME_EXISTS: Timber.d("Trying to show duplicate picture popup"); - view.showDuplicatePicturePopup(uploadItem); + view.showDuplicatePicturePopup(); break; default: - view.showBadImagePopup(errorCode, uploadItem); + view.showBadImagePopup(errorCode); } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictModel.kt b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictModel.kt index f2d48d5d616..0d5604c5a9c 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictModel.kt @@ -1,11 +1,9 @@ package fr.free.nrw.commons.upload.structure.depictions -import fr.free.nrw.commons.explore.depictions.DepictsClient import fr.free.nrw.commons.nearby.Place -import io.reactivex.Flowable -import io.reactivex.Single -import io.reactivex.processors.BehaviorProcessor -import timber.log.Timber +import fr.free.nrw.commons.upload.depicts.DepictsInterface +import io.reactivex.Observable +import java.util.* import javax.inject.Inject import javax.inject.Singleton @@ -13,47 +11,35 @@ import javax.inject.Singleton * The model class for depictions in upload */ @Singleton -class DepictModel @Inject constructor(private val depictsClient: DepictsClient) { +class DepictModel @Inject constructor(private val depictsInterface: DepictsInterface) { - val nearbyPlaces: BehaviorProcessor> = BehaviorProcessor.createDefault(emptyList()) + var nearbyPlaces: MutableList? = null - companion object { - private const val SEARCH_DEPICTS_LIMIT = 25 - } - - /** - * Search for depictions - */ - fun searchAllEntities(query: String): Flowable> { - return if (query.isBlank()) - nearbyPlaces.switchMap { places: List -> - depictsClient.getEntities(places.toIds()) - .map { - it.entities() - .values - .mapIndexed { index, entity -> DepictedItem(entity, places[index]) } - } - .onErrorResumeWithEmptyList() - .toFlowable() - } - else - networkItems(query) - } + companion object { + private const val SEARCH_DEPICTS_LIMIT = 25 + } - private fun networkItems(query: String): Flowable> { - return depictsClient.searchForDepictions(query, SEARCH_DEPICTS_LIMIT, 0) - .onErrorResumeWithEmptyList() - .toFlowable() + /** + * Search for depictions + */ + fun searchAllEntities(query: String): Observable { + if(query.isBlank()){ + return Observable.fromIterable(nearbyPlaces?.map { DepictedItem(it) } ?: emptyList()) } + return networkItems(query) + } + + private fun networkItems(query: String): Observable { + val language = Locale.getDefault().language + return depictsInterface.searchForDepicts( + query, "$SEARCH_DEPICTS_LIMIT", language, language, "0" + ) + .flatMap { Observable.fromIterable(it.search) } + .map(::DepictedItem) + } fun cleanUp() { - nearbyPlaces.offer(emptyList()) + nearbyPlaces = null } } - -private fun List.toIds() = mapNotNull { it.wikiDataEntityId }.joinToString("|") - -private fun Single>.onErrorResumeWithEmptyList() = onErrorResumeNext { t: Throwable -> - Single.just(emptyList()).also { Timber.e(t) } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictedItem.kt b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictedItem.kt index e732a0dd117..13d4efcba20 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictedItem.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictedItem.kt @@ -1,121 +1,45 @@ package fr.free.nrw.commons.upload.structure.depictions -import android.os.Parcelable import fr.free.nrw.commons.nearby.Place import fr.free.nrw.commons.upload.WikidataItem -import fr.free.nrw.commons.wikidata.WikidataProperties -import fr.free.nrw.commons.wikidata.WikidataProperties.* -import kotlinx.android.parcel.Parcelize -import org.wikipedia.wikidata.DataValue -import org.wikipedia.wikidata.Entities -import org.wikipedia.wikidata.Statement_partial -import java.math.BigInteger -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException -import java.util.* - -const val THUMB_IMAGE_SIZE = "70px" +import fr.free.nrw.commons.wikidata.model.DepictSearchItem /** * Model class for Depicted Item in Upload and Explore */ -@Parcelize data class DepictedItem constructor( override val name: String, val description: String?, - val imageUrl: String?, - val instanceOfs: List, - val commonsCategories: List, + var imageUrl: String, var isSelected: Boolean, override val id: String -) : WikidataItem, Parcelable { - - constructor(entity: Entities.Entity) : this( - entity, - entity.labels().byLanguageOrFirstOrEmpty(), - entity.descriptions().byLanguageOrFirstOrEmpty() +) : WikidataItem { + constructor(depictSearchItem: DepictSearchItem) : this( + depictSearchItem.label, + depictSearchItem.description, + "", + false, + depictSearchItem.id ) - constructor(entity: Entities.Entity, place: Place) : this( - entity, + constructor(place: Place) : this( place.name, - place.longDescription - ) - - private constructor(entity: Entities.Entity, name: String, description: String) : this( - name, - description, - entity[IMAGE].primaryImageValue?.let { - getImageUrl(it.value, THUMB_IMAGE_SIZE) - }, - entity[INSTANCE_OF].toIds(), - entity[COMMONS_CATEGORY]?.map { (it.mainSnak.dataValue as DataValue.ValueString).value } - ?: emptyList(), + place.longDescription, + "", false, - entity.id() + place.wikiDataEntityId!! ) - override fun equals(other: Any?) = when { - this === other -> true - other is DepictedItem -> name == other.name + var position = 0 + + override fun equals(o: Any?) = when { + this === o -> true + o is DepictedItem -> name == o.name else -> false } override fun hashCode(): Int { - return name.hashCode() + return name?.hashCode() ?: 0 } } - -private fun List?.toIds(): List { - return this?.map { it.mainSnak.dataValue } - ?.filterIsInstance() - ?.map { it.value.id } - ?: emptyList() -} - -private val List?.primaryImageValue: DataValue.ValueString? - get() = this?.firstOrNull()?.mainSnak?.dataValue as? DataValue.ValueString - -operator fun Entities.Entity.get(property: WikidataProperties) = - statements?.get(property.propertyName) - -private fun Map.byLanguageOrFirstOrEmpty() = - let { it[Locale.getDefault().language] ?: it.values.firstOrNull() }?.value() ?: "" - -private fun getImageUrl(title: String, size: String): String { - return title.substringAfter(":") - .replace(" ", "_") - .let { - val MD5Hash = getMd5(it) - "https://upload.wikimedia.org/wikipedia/commons/thumb/${MD5Hash[0]}/${MD5Hash[0]}${MD5Hash[1]}/$it/$size-$it" - } -} - -/** - * Generates MD5 hash for the filename - */ -private fun getMd5(input: String): String { - return try { - - // Static getInstance method is called with hashing MD5 - val md = MessageDigest.getInstance("MD5") - - // digest() method is called to calculate message digest - // of an input digest() return array of byte - val messageDigest = md.digest(input.toByteArray()) - - // Convert byte array into signum representation - val no = BigInteger(1, messageDigest) - - // Convert message digest into hex value - var hashtext = no.toString(16) - while (hashtext.length < 32) { - hashtext = "0$hashtext" - } - hashtext - } // For specifying wrong message digest algorithms - catch (e: NoSuchAlgorithmException) { - throw RuntimeException(e) - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictionRenderer.java b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictionRenderer.java new file mode 100644 index 00000000000..42e99eef42a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictionRenderer.java @@ -0,0 +1,56 @@ +package fr.free.nrw.commons.upload.structure.depictions; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckedTextView; +import android.widget.TextView; + +import com.pedrogomez.renderers.Renderer; + +import butterknife.BindView; +import butterknife.ButterKnife; +import fr.free.nrw.commons.R; + +public class DepictionRenderer extends Renderer { + @BindView(R.id.depict_checkbox) + CheckedTextView checkedView; + private final UploadDepictsCallback listener; + @BindView(R.id.depicts_label) + TextView depictsLabel; + @BindView(R.id.description) TextView description; + + public DepictionRenderer(UploadDepictsCallback listener) { + this.listener = listener; + } + + @Override + protected void setUpView(View rootView) { + ButterKnife.bind(this, rootView); + } + + @Override + protected void hookListeners(View rootView) { + rootView.setOnClickListener( v -> { + DepictedItem item = getContent(); + item.setSelected(true); + checkedView.setChecked(item.isSelected()); + if (listener != null) { + listener.depictsClicked(item); + } + }); + } + + @Override + protected View inflate(LayoutInflater inflater, ViewGroup parent) { + return inflater.inflate(R.layout.layout_upload_depicts_item, parent, false); + } + + @Override + public void render() { + DepictedItem item = getContent(); + checkedView.setChecked(item.isSelected()); + depictsLabel.setText(item.getName()); + description.setText(item.getDescription()); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/UploadDepictsCallback.java b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/UploadDepictsCallback.java index d176f9c18b2..35ea4992a7b 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/UploadDepictsCallback.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/UploadDepictsCallback.java @@ -5,4 +5,6 @@ */ public interface UploadDepictsCallback { void depictsClicked(DepictedItem item); + + void fetchThumbnailUrlForEntity(String entityId,int position); } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikiBaseClient.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikiBaseClient.java index 0208c3d57c9..739981c8ae4 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikiBaseClient.java +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikiBaseClient.java @@ -1,6 +1,6 @@ package fr.free.nrw.commons.wikidata; -import static fr.free.nrw.commons.media.MediaClientKt.PAGE_ID_PREFIX; +import static fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX; import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF; import fr.free.nrw.commons.upload.UploadResult; diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataClient.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataClient.java index ebee708e99f..24f518b5255 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataClient.java +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataClient.java @@ -1,6 +1,6 @@ package fr.free.nrw.commons.wikidata; -import com.google.gson.Gson; +import fr.free.nrw.commons.upload.WikidataItem; import org.jetbrains.annotations.NotNull; import javax.inject.Inject; @@ -9,38 +9,64 @@ import fr.free.nrw.commons.wikidata.model.AddEditTagResponse; import io.reactivex.Observable; import io.reactivex.ObservableSource; -import org.wikipedia.wikidata.Statement_partial; +import okhttp3.MediaType; +import okhttp3.RequestBody; @Singleton public class WikidataClient { - private final WikidataInterface wikidataInterface; - private final Gson gson; - - @Inject - public WikidataClient(WikidataInterface wikidataInterface, final Gson gson) { - this.wikidataInterface = wikidataInterface; - this.gson = gson; - } - - /** - * Create wikidata claim to add P18 value - * - * @return revisionID of the edit - */ - Observable setClaim(Statement_partial claim, String tags) { - return getCsrfToken() - .flatMap(csrfToken -> wikidataInterface.postSetClaim(gson.toJson(claim), tags, csrfToken)) - .map(mwPostResponse -> mwPostResponse.getPageinfo().getLastrevid()); - } - - /** - * Get csrf token for wikidata edit - */ - @NotNull - private Observable getCsrfToken() { - return wikidataInterface.getCsrfToken() - .map(mwQueryResponse -> mwQueryResponse.query().csrfToken()); - } + private final WikidataInterface wikidataInterface; + + @Inject + public WikidataClient(WikidataInterface wikidataInterface) { + this.wikidataInterface = wikidataInterface; + } + + /** + * Create wikidata claim to add P18 value + * @param entity wikidata entity ID + * @param value value of the P18 edit + * @return revisionID of the edit + */ + Observable createImageClaim(WikidataItem entity, String value) { + return getCsrfToken() + .flatMap(csrfToken -> wikidataInterface.postCreateClaim( + toRequestBody(entity.getId()), + toRequestBody("value"), + toRequestBody(WikidataProperties.IMAGE.getPropertyName()), + toRequestBody(value), + toRequestBody("en"), + toRequestBody(csrfToken))) + .map(mwPostResponse -> mwPostResponse.getPageinfo().getLastrevid()); + } + + /** + * Converts string value to RequestBody for multipart request + */ + private RequestBody toRequestBody(String value) { + return RequestBody.create(MediaType.parse("text/plain"), value); + } + + /** + * Get csrf token for wikidata edit + */ + @NotNull + private Observable getCsrfToken() { + return wikidataInterface.getCsrfToken().map(mwQueryResponse -> mwQueryResponse.query().csrfToken()); + } + + /** + * Add edit tag for a given revision ID. The app currently uses this to tag P18 edits + * @param revisionId revision ID of the page edited + * @param tag to be added + * @param reason to be mentioned + */ + ObservableSource addEditTag(Long revisionId, String tag, String reason) { + return getCsrfToken() + .flatMap(csrfToken -> wikidataInterface.addEditTag(String.valueOf(revisionId), + tag, + reason, + csrfToken)); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java index 3f5a84cf30d..7fedfc49ec7 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java @@ -1,7 +1,6 @@ package fr.free.nrw.commons.wikidata; - -import static fr.free.nrw.commons.media.MediaClientKt.PAGE_ID_PREFIX; +import static fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX; import android.annotation.SuppressLint; import android.content.Context; @@ -20,58 +19,49 @@ import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; import java.util.Locale; -import java.util.Map; -import java.util.UUID; import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; import org.wikipedia.dataclient.mwapi.MwPostResponse; -import org.wikipedia.wikidata.DataValue; -import org.wikipedia.wikidata.DataValue.ValueString; import org.wikipedia.wikidata.EditClaim; -import org.wikipedia.wikidata.Snak_partial; -import org.wikipedia.wikidata.Statement_partial; -import org.wikipedia.wikidata.WikiBaseMonolingualTextValue; import timber.log.Timber; /** - * This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki - * Apis to make the necessary calls, log the edits and fire listeners on successful edits + * This class is meant to handle the Wikidata edits made through the app + * It will talk with MediaWiki Apis to make the necessary calls, log the edits and fire listeners + * on successful edits */ @Singleton public class WikidataEditService { - public static final String COMMONS_APP_TAG = "wikimedia-commons-app"; + private static final String COMMONS_APP_TAG = "wikimedia-commons-app"; + private static final String COMMONS_APP_EDIT_REASON = "Add tag for edits made using Android Commons app"; - private final Context context; - private final WikidataEditListener wikidataEditListener; - private final JsonKvStore directKvStore; - private final WikiBaseClient wikiBaseClient; - private final WikidataClient wikidataClient; - private final Gson gson; + private final Context context; + private final WikidataEditListener wikidataEditListener; + private final JsonKvStore directKvStore; + private final WikiBaseClient wikiBaseClient; + private final WikidataClient wikidataClient; + private final Gson gson; @Inject - public WikidataEditService(final Context context, + public WikidataEditService(final Context context, final WikidataEditListener wikidataEditListener, @Named("default_preferences") final JsonKvStore directKvStore, final WikiBaseClient wikiBaseClient, final WikidataClient wikidataClient, final Gson gson) { - this.context = context; - this.wikidataEditListener = wikidataEditListener; - this.directKvStore = directKvStore; - this.wikiBaseClient = wikiBaseClient; - this.wikidataClient = wikidataClient; + this.context = context; + this.wikidataEditListener = wikidataEditListener; + this.directKvStore = directKvStore; + this.wikiBaseClient = wikiBaseClient; + this.wikidataClient = wikidataClient; this.gson = gson; } /** - * Edits the wikibase entity by adding DEPICTS property. Adding DEPICTS property requires call to - * the wikibase API to set tag against the entity. + * Edits the wikibase entity by adding DEPICTS property. + * Adding DEPICTS property requires call to the wikibase API to set tag against the entity. */ @SuppressLint("CheckResult") private Observable addDepictsProperty(final String fileEntityId, @@ -79,7 +69,7 @@ private Observable addDepictsProperty(final String fileEntityId, final EditClaim data = editClaim( ConfigUtils.isBetaFlavour() ? "Q10" // Wikipedia:Sandbox (Q10) - : depictedItem.getId() + : depictedItem.getId() ); return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) @@ -90,46 +80,44 @@ private Observable addDepictsProperty(final String fileEntityId, Timber.d("Unable to set DEPICTS property for %s", fileEntityId); } }) - .doOnError(throwable -> { + .doOnError( throwable -> { Timber.e(throwable, "Error occurred while setting DEPICTS property"); ViewUtil.showLongToast(context, throwable.toString()); }) .subscribeOn(Schedulers.io()); - } + } private EditClaim editClaim(final String entityId) { return EditClaim.from(entityId, WikidataProperties.DEPICTS.getPropertyName()); } /** - * Show a success toast when the edit is made successfully - */ - private void showSuccessToast(final String wikiItemName) { - final String successStringTemplate = context.getString(R.string.successful_wikidata_edit); - final String successMessage = String - .format(Locale.getDefault(), successStringTemplate, wikiItemName); - ViewUtil.showLongToast(context, successMessage); - } + * Show a success toast when the edit is made successfully + */ + private void showSuccessToast(final String wikiItemName) { + final String successStringTemplate = context.getString(R.string.successful_wikidata_edit); + final String successMessage = String.format(Locale.getDefault(), successStringTemplate, wikiItemName); + ViewUtil.showLongToast(context, successMessage); + } /** - * Adds label to Wikidata using the fileEntityId and the edit token, obtained from - * csrfTokenClient - * - * @param fileEntityId - * @return - */ - - @SuppressLint("CheckResult") - private Observable addCaption(final long fileEntityId, final String languageCode, - final String captionValue) { - return wikiBaseClient.addLabelstoWikidata(fileEntityId, languageCode, captionValue) - .doOnNext(mwPostResponse -> onAddCaptionResponse(fileEntityId, mwPostResponse)) - .doOnError(throwable -> { - Timber.e(throwable, "Error occurred while setting Captions"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }) - .map(mwPostResponse -> mwPostResponse != null); - } + * Adds label to Wikidata using the fileEntityId and the edit token, obtained from csrfTokenClient + * + * @param fileEntityId + * @return + */ + + @SuppressLint("CheckResult") + private Observable addCaption(final long fileEntityId, final String languageCode, + final String captionValue) { + return wikiBaseClient.addLabelstoWikidata(fileEntityId, languageCode, captionValue) + .doOnNext(mwPostResponse -> onAddCaptionResponse(fileEntityId, mwPostResponse) ) + .doOnError(throwable -> { + Timber.e(throwable, "Error occurred while setting Captions"); + ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); + }) + .map(mwPostResponse -> mwPostResponse != null); + } private void onAddCaptionResponse(Long fileEntityId, MwPostResponse response) { if (response != null) { @@ -139,41 +127,29 @@ private void onAddCaptionResponse(Long fileEntityId, MwPostResponse response) { } } - public void createClaim(@Nullable final WikidataPlace wikidataPlace, final String fileName, final - Map captions) { + public void createImageClaim(@Nullable final WikidataPlace wikidataPlace, final UploadResult imageUpload) { if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { - Timber - .d("Image location and nearby place location mismatched, so Wikidata item won't be edited"); + Timber.d("Image location and nearby place location mismatched, so Wikidata item won't be edited"); return; } - addImageAndMediaLegends(wikidataPlace, fileName, captions); + editWikidataImageProperty(wikidataPlace, imageUpload); } - public void addImageAndMediaLegends(final WikidataItem wikidataItem, final String fileName, - final Map captions) { - final Snak_partial p18 = new Snak_partial("value", WikidataProperties.IMAGE.getPropertyName(), - new ValueString(fileName.replace("File:", ""))); - - final List snaks = new ArrayList<>(); - for (final Map.Entry entry : captions.entrySet()) { - snaks.add(new Snak_partial("value", - WikidataProperties.MEDIA_LEGENDS.getPropertyName(), new DataValue.MonoLingualText( - new WikiBaseMonolingualTextValue(entry.getValue(), entry.getKey())))); - } - - final String id = wikidataItem.getId() + "$" + UUID.randomUUID().toString(); - final Statement_partial claim = new Statement_partial(p18, "statement", "normal", id, - Collections.singletonMap(WikidataProperties.MEDIA_LEGENDS.getPropertyName(), snaks), - Arrays.asList(WikidataProperties.MEDIA_LEGENDS.getPropertyName())); - - wikidataClient.setClaim(claim, COMMONS_APP_TAG).subscribeOn(Schedulers.io()) + @SuppressLint("CheckResult") + private void editWikidataImageProperty(final WikidataItem wikidataItem, final UploadResult imageUpload) { + wikidataClient.createImageClaim(wikidataItem, String.format("\"%s\"", imageUpload.getFilename())) + .flatMap(revisionId -> { + if (revisionId != -1) { + return wikidataClient.addEditTag(revisionId, COMMONS_APP_TAG, COMMONS_APP_EDIT_REASON); + } + throw new RuntimeException("Unable to edit wikidata item"); + }) + .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(revisionId -> handleImageClaimResult(wikidataItem, String.valueOf(revisionId)), - throwable -> { - Timber.e(throwable, "Error occurred while making claim"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }); - ; + .subscribe(revisionId -> handleImageClaimResult(wikidataItem, String.valueOf(revisionId)), throwable -> { + Timber.e(throwable, "Error occurred while making claim"); + ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); + }); } private void handleImageClaimResult(final WikidataItem wikidataItem, final String revisionId) { @@ -208,13 +184,13 @@ public Disposable addDepictionsAndCaptions(UploadResult uploadResult, Contributi } } ).subscribe( - success -> Timber.d("edit response: %s", success), - throwable -> Timber.e(throwable, "posting edits failed") - ); + success -> Timber.d("edit response: %s", success), + throwable -> Timber.e(throwable, "posting edits failed") + ); } private Observable captionEdits(Contribution contribution, Long fileEntityId) { - return Observable.fromIterable(contribution.getMedia().getCaptions().entrySet()) + return Observable.fromIterable(contribution.getCaptions().entrySet()) .concatMap(entry -> addCaption(fileEntityId, entry.getKey(), entry.getValue())); } @@ -225,6 +201,6 @@ private Observable depictionEdits(Contribution contribution, Long fileE depictedItems.add(wikidataPlace); } return Observable.fromIterable(depictedItems) - .concatMap(wikidataItem -> addDepictsProperty(fileEntityId.toString(), wikidataItem)); + .concatMap( wikidataItem -> addDepictsProperty(fileEntityId.toString(), wikidataItem)); } } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataProperties.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataProperties.kt index dbeb0dc7665..30a11f92c93 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataProperties.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataProperties.kt @@ -3,9 +3,6 @@ package fr.free.nrw.commons.wikidata import fr.free.nrw.commons.BuildConfig enum class WikidataProperties(val propertyName: String) { - IMAGE("P18"), - DEPICTS(BuildConfig.DEPICTS_PROPERTY), - COMMONS_CATEGORY("P373"), - INSTANCE_OF("P31"), - MEDIA_LEGENDS("P2096"); + IMAGE("P18"), DEPICTS(BuildConfig.DEPICTS_PROPERTY); + } diff --git a/app/src/main/res/layout/fragment_depict_image.xml b/app/src/main/res/layout/fragment_depict_image.xml new file mode 100644 index 00000000000..9ec5ca0ea0e --- /dev/null +++ b/app/src/main/res/layout/fragment_depict_image.xml @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_media_detail.xml b/app/src/main/res/layout/fragment_media_detail.xml index 1e238f377fd..65d2e3cc587 100644 --- a/app/src/main/res/layout/fragment_media_detail.xml +++ b/app/src/main/res/layout/fragment_media_detail.xml @@ -54,6 +54,8 @@ android:background="?attr/mainBackground" android:orientation="vertical"> + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml b/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml index 2ab3ddc10a8..c3b9ba7020c 100644 --- a/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml +++ b/app/src/main/res/layout/fragment_upload_media_detail_fragment.xml @@ -1,139 +1,146 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/backgroundImage" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:actualImageScaleType="fitXY" /> - - - - - - - - - - - + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentBottom="true" + android:layout_margin="@dimen/dimen_10" + android:elevation="@dimen/cardview_default_elevation"> + + - - - - - + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_margin="@dimen/dimen_10" + android:orientation="vertical"> + android:id="@+id/rl_container_title" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + + + + + + - + - + - - - + + + + + + + + + + + + + - + + diff --git a/app/src/main/res/layout/item_depictions.xml b/app/src/main/res/layout/item_depictions.xml index cfae437c584..8261c8b5286 100644 --- a/app/src/main/res/layout/item_depictions.xml +++ b/app/src/main/res/layout/item_depictions.xml @@ -1,43 +1,37 @@ - - + android:padding="@dimen/tiny_gap"> - + + + - + - + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_depict_image.xml b/app/src/main/res/layout/layout_depict_image.xml new file mode 100644 index 00000000000..8f0ddd87450 --- /dev/null +++ b/app/src/main/res/layout/layout_depict_image.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_upload_depicts_item.xml b/app/src/main/res/layout/layout_upload_depicts_item.xml index 9a0549236dc..723c21ca536 100644 --- a/app/src/main/res/layout/layout_upload_depicts_item.xml +++ b/app/src/main/res/layout/layout_upload_depicts_item.xml @@ -17,14 +17,14 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toTopOf="parent" /> - + app:srcCompat="@drawable/ic_wikidata_logo_24dp"/> - + \ No newline at end of file diff --git a/app/src/main/res/layout/row_item_description.xml b/app/src/main/res/layout/row_item_description.xml index ae2e66b1923..e9c8e98b204 100644 --- a/app/src/main/res/layout/row_item_description.xml +++ b/app/src/main/res/layout/row_item_description.xml @@ -19,7 +19,6 @@ android:orientation="vertical" android:layout_weight="8"> @@ -34,7 +33,6 @@ @@ -49,4 +47,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/layout/upload_depicts_fragment.xml b/app/src/main/res/layout/upload_depicts_fragment.xml index 15b159b5c1d..de2a2e7aa85 100644 --- a/app/src/main/res/layout/upload_depicts_fragment.xml +++ b/app/src/main/res/layout/upload_depicts_fragment.xml @@ -103,29 +103,29 @@ android:background="@color/divider_grey" />