diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index d18f5be8c..936d6e5a6 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 31dafb6d1..596560b44 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/androidTest/java/fr/free/nrw/commons/DepictionSearchTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/DepictionSearchTest.kt new file mode 100644 index 000000000..b62b04349 --- /dev/null +++ b/app/src/androidTest/java/fr/free/nrw/commons/DepictionSearchTest.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons + +import androidx.test.runner.AndroidJUnit4 +import org.junit.Rule +import org.junit.runner.RunWith +import android.net.Uri +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.rule.ActivityTestRule +import fr.free.nrw.commons.upload.UploadActivity +import org.hamcrest.Matchers +import org.hamcrest.core.AllOf +import org.junit.Test + +@RunWith(AndroidJUnit4::class) +class DepictionSearchTest { + @get:Rule + var activityRule = ActivityTestRule(UploadActivity::class.java) + + @Test + fun TestForCaptionsAndDepictions() { + val imageUri = Uri.parse("file://mnt/sdcard/image.jpg") + + // Build a result to return from the Camera app + + + // Stub out the File picker. When an intent is sent to the File picker, this tells + // Espresso to respond with the ActivityResult we just created + + Espresso.onView(ViewMatchers.withId(R.id.caption_item_edit_text)) + .perform(ViewActions.typeText("caption in english")) + Espresso.onView(ViewMatchers.withId(R.id.description_item_edit_text)) + .perform(ViewActions.typeText("description in english")) + Espresso.onView(ViewMatchers.withId(R.id.spinner_description_languages)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.spinner_description_languages)).perform(ViewActions.click()); + Espresso.onData(AllOf.allOf(Matchers.anything("spinner text"))).atPosition(1).perform(ViewActions.click()); + Espresso.onView(ViewMatchers.withId(R.id.caption_item_edit_text)) + .perform(ViewActions.typeText("caption in some other language")) + Espresso.onView(ViewMatchers.withId(R.id.description_item_edit_text)) + .perform(ViewActions.typeText("description in some other language")) + Espresso.onView(ViewMatchers.withId(R.id.btn_next)) + .perform(ViewActions.click()) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt index b7f89e54e..fcd311db5 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/UploadActivityTest.kt @@ -1,8 +1,17 @@ package fr.free.nrw.commons +import android.net.Uri +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers import androidx.test.rule.ActivityTestRule import androidx.test.runner.AndroidJUnit4 import fr.free.nrw.commons.upload.UploadActivity +import fr.free.nrw.commons.upload.depicts.DepictsFragment +import org.hamcrest.Matchers +import org.hamcrest.core.AllOf import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -16,4 +25,25 @@ class UploadActivityTest { fun orientationChange() { UITestHelper.changeOrientation(activityRule) } -} \ No newline at end of file + + @Test + fun TestForCaptionsAndDepictions() { + val imageUri = Uri.parse("file://mnt/sdcard/image.jpg") + + Espresso.onView(ViewMatchers.withId(R.id.caption_item_edit_text)) + .perform(ViewActions.typeText("caption in english")) + Espresso.onView(ViewMatchers.withId(R.id.description_item_edit_text)) + .perform(ViewActions.typeText("description in english")) + Espresso.onView(ViewMatchers.withId(R.id.spinner_description_languages)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.spinner_description_languages)).perform(ViewActions.click()); + Espresso.onData(AllOf.allOf(Matchers.anything("spinner text"))).atPosition(1).perform(ViewActions.click()); + Espresso.onView(ViewMatchers.withId(R.id.caption_item_edit_text)) + .perform(ViewActions.typeText("caption in some other language")) + Espresso.onView(ViewMatchers.withId(R.id.description_item_edit_text)) + .perform(ViewActions.typeText("description in some other language")) + Espresso.onView(ViewMatchers.withId(R.id.btn_next)) + .perform(ViewActions.click()) + Intents.intended(IntentMatchers.hasComponent(DepictsFragment::class.java.name)) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d904a5ecc..e0cf4d03c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -119,6 +119,11 @@ android:label="@string/title_activity_featured_images" android:parentActivityName=".contributions.MainActivity" /> + + + android:syncable="false" /> categories; // as loaded at runtime? - public boolean requestedDeletion; - public HashMap descriptions; // multilingual descriptions as loaded - public HashMap tags = new HashMap<>(); - @Nullable public LatLng coordinates; + private Uri localUri; + private String thumbUrl; + private String imageUrl; + private String filename; + private String thumbnailTitle; + /* + * 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 + */ + private String caption; + private String description; // monolingual description on input... + private String discussion; + private long dataLength; + private Date dateCreated; + @Nullable private Date dateUploaded; + private String license; + private String licenseUrl; + private String creator; + /** + * Wikibase Identifier associated with media files + */ + private String pageId; + private List 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 */ - protected Media() { - this.categories = new ArrayList<>(); - this.descriptions = new HashMap<>(); + public Media() { } /** @@ -69,7 +72,6 @@ public class Media implements Parcelable { * @param filename Media filename */ public Media(String filename) { - this(); this.filename = filename; } @@ -84,9 +86,9 @@ public class Media implements Parcelable { * @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(); + 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; @@ -96,8 +98,17 @@ public class Media implements Parcelable { this.dateCreated = dateCreated; this.dateUploaded = dateUploaded; this.creator = creator; - this.categories = new ArrayList<>(); - this.descriptions = new HashMap<>(); + } + + 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); } /** @@ -112,7 +123,7 @@ public class Media implements Parcelable { public static Media from(MwQueryPage page) { ImageInfo imageInfo = page.imageInfo(); if (imageInfo == null) { - return null; + return new Media(); // null is not allowed } ExtMetadata metadata = imageInfo.getMetadata(); if (metadata == null) { @@ -127,7 +138,7 @@ public class Media implements Parcelable { Media media = new Media(null, imageInfo.getOriginalUrl(), page.title(), - "", + "", 0, safeParseDate(metadata.dateTime()), safeParseDate(metadata.dateTime()), @@ -138,12 +149,14 @@ public class Media implements Parcelable { media.setThumbUrl(imageInfo.getThumbUrl()); } + media.setPageId(String.valueOf(page.pageId())); + String language = Locale.getDefault().getLanguage(); if (StringUtils.isBlank(language)) { language = "default"; } - media.setDescriptions(Collections.singletonMap(language, metadata.imageDescription())); + media.setDescription(metadata.imageDescription()); media.setCategories(MediaDataExtractorUtil.extractCategoriesFromList(metadata.getCategories())); String latitude = metadata.getGpsLatitude(); String longitude = metadata.getGpsLongitude(); @@ -172,28 +185,23 @@ public class Media implements Parcelable { } } + /** + * @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 tag of media - * @param key Media key - * @return Media tag - */ - public Object getTag(String key) { - return tags.get(key); - } - - /** - * Modifies( or creates a) tag of media - * @param key Media key - * @param value Media value - */ - public void setTag(String key, String value) { - tags.put(key, value); - } - /** * Gets media display title * @return Media title @@ -202,6 +210,21 @@ public class Media implements Parcelable { 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 @@ -268,6 +291,24 @@ public class Media implements Parcelable { 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 @@ -334,38 +375,6 @@ public class Media implements Parcelable { this.creator = creator; } - /** - * Gets the width of the media. - * @return file width as an int - */ - public int getWidth() { - return width; - } - - /** - * Sets the width of the media. - * @param width file width as an int - */ - public void setWidth(int width) { - this.width = width; - } - - /** - * Gets the height of the media. - * @return file height as an int - */ - public int getHeight() { - return height; - } - - /** - * Sets the height of the media. - * @param height file height as an int - */ - public void setHeight(int height) { - this.height = height; - } - /** * Gets the license name of the file. * @return license as a String @@ -417,8 +426,8 @@ public class Media implements Parcelable { * @return file categories as an ArrayList of Strings */ @SuppressWarnings("unchecked") - public ArrayList getCategories() { - return (ArrayList) categories.clone(); // feels dirty + public List getCategories() { + return categories; } /** @@ -429,38 +438,7 @@ public class Media implements Parcelable { * @param categories file categories as a list of Strings */ public void setCategories(List categories) { - this.categories.clear(); - this.categories.addAll(categories); - } - - /** - * Modifies (or sets) media descriptions - * @param descriptions Media descriptions - */ - void setDescriptions(Map descriptions) { - this.descriptions.clear(); - this.descriptions.putAll(descriptions); - } - - /** - * Gets media description in preferred language - * @param preferredLanguage Language preferred - * @return Description in preferred language - */ - public String getDescription(String preferredLanguage) { - if (descriptions.containsKey(preferredLanguage)) { - // See if the requested language is there. - return descriptions.get(preferredLanguage); - } else if (descriptions.containsKey("en")) { - // Ah, English. Language of the world, until the Chinese crush us. - return descriptions.get("en"); - } else if (descriptions.containsKey("default")) { - // No languages marked... - return descriptions.get("default"); - } else { - // FIXME: return the first available non-English description? - return ""; - } + this.categories = categories; } @Nullable private static Date safeParseDate(String dateStr) { @@ -473,16 +451,17 @@ public class Media implements Parcelable { /** * Set requested deletion to true + * @param requestedDeletion */ - public void setRequestedDeletion(){ - requestedDeletion = true; + public void setRequestedDeletion(boolean requestedDeletion){ + this.requestedDeletion = requestedDeletion; } /** * Get the value of requested deletion * @return boolean requestedDeletion */ - public boolean getRequestedDeletion(){ + public boolean isRequestedDeletion(){ return requestedDeletion; } @@ -495,6 +474,42 @@ public class Media implements Parcelable { 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() { @@ -513,20 +528,20 @@ public class Media implements Parcelable { 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.writeInt(this.width); - dest.writeInt(this.height); 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.writeSerializable(this.descriptions); - dest.writeSerializable(this.tags); dest.writeParcelable(this.coordinates, flags); } @@ -535,6 +550,8 @@ public class Media implements Parcelable { 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(); @@ -542,15 +559,15 @@ public class Media implements Parcelable { this.dateCreated = tmpDateCreated == -1 ? null : new Date(tmpDateCreated); long tmpDateUploaded = in.readLong(); this.dateUploaded = tmpDateUploaded == -1 ? null : new Date(tmpDateUploaded); - this.width = in.readInt(); - this.height = in.readInt(); this.license = in.readString(); this.licenseUrl = in.readString(); this.creator = in.readString(); - this.categories = in.createStringArrayList(); + 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.descriptions = (HashMap) in.readSerializable(); - this.tags = (HashMap) in.readSerializable(); this.coordinates = in.readParcelable(LatLng.class.getClassLoader()); } diff --git a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java index 0564626da..58b6be2c9 100644 --- a/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java +++ b/app/src/main/java/fr/free/nrw/commons/MediaDataExtractor.java @@ -1,12 +1,14 @@ package fr.free.nrw.commons; +import static fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX; + import androidx.core.text.HtmlCompat; - -import javax.inject.Inject; -import javax.inject.Singleton; - +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; /** @@ -17,30 +19,61 @@ import timber.log.Timber; */ @Singleton public class MediaDataExtractor { - private final MediaClient mediaClient; + + private final MediaClient mediaClient; @Inject - public MediaDataExtractor(MediaClient mediaClient) { + 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 and talk page for the filename + * 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(String filename) { - Single mediaSingle = getMediaFromFileName(filename); - Single pageExistsSingle = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + filename); - Single discussionSingle = getDiscussion(filename); - return Single.zip(mediaSingle, pageExistsSingle, discussionSingle, (media, deletionStatus, discussion) -> { - media.setDiscussion(discussion); - if (deletionStatus) { - media.setRequestedDeletion(); - } - return media; - }); + 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")); } /** @@ -48,7 +81,7 @@ public class MediaDataExtractor { * @param filename Eg. File:Test.jpg * @return return data rich Media object */ - public Single getMediaFromFileName(String filename) { + public Single getMediaFromFileName(final String filename) { return mediaClient.getMedia(filename); } @@ -57,7 +90,7 @@ public class MediaDataExtractor { * @param filename * @return */ - private Single getDiscussion(String filename) { + 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 -> { 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 9d3bde848..b9c10496c 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/caching/CacheController.java b/app/src/main/java/fr/free/nrw/commons/caching/CacheController.java deleted file mode 100644 index 8820101e8..000000000 --- a/app/src/main/java/fr/free/nrw/commons/caching/CacheController.java +++ /dev/null @@ -1,81 +0,0 @@ -package fr.free.nrw.commons.caching; - -import com.github.varunpant.quadtree.Point; -import com.github.varunpant.quadtree.QuadTree; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import timber.log.Timber; - -@Singleton -public class CacheController { - - private final QuadTree> quadTree; - private double x, y; - private double xMinus, xPlus, yMinus, yPlus; - - private static final int EARTH_RADIUS = 6378137; - - @Inject - public CacheController(QuadTree quadTree) { - this.quadTree = quadTree; - } - - public void setQtPoint(double decLongitude, double decLatitude) { - x = decLongitude; - y = decLatitude; - Timber.d("New QuadTree created"); - Timber.d("X (longitude) value: %f, Y (latitude) value: %f", x, y); - } - - public List findCategory() { - Point>[] pointsFound; - //Convert decLatitude and decLongitude to a coordinate offset range - convertCoordRange(); - pointsFound = quadTree.searchWithin(xMinus, yMinus, xPlus, yPlus); - List displayCatList = new ArrayList<>(); - Timber.d("Points found in quadtree: %s", Arrays.toString(pointsFound)); - - if (pointsFound.length != 0) { - Timber.d("Entering for loop"); - - for (Point> point : pointsFound) { - Timber.d("Nearby point: %s", point); - displayCatList = point.getValue(); - Timber.d("Nearby cat: %s", point.getValue()); - } - - Timber.d("Categories found in cache: %s", displayCatList); - } else { - Timber.d("No categories found in cache"); - } - return displayCatList; - } - - //Based on algorithm at http://gis.stackexchange.com/questions/2951/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters - private void convertCoordRange() { - //Position, decimal degrees - double lat = y; - double lon = x; - - //offsets in meters - double offset = 100; - - //Coordinate offsets in radians - double dLat = offset / EARTH_RADIUS; - double dLon = offset / (EARTH_RADIUS * Math.cos(Math.PI * lat / 180)); - - //OffsetPosition, decimal degrees - yPlus = lat + dLat * 180 / Math.PI; - yMinus = lat - dLat * 180 / Math.PI; - xPlus = lon + dLon * 180 / Math.PI; - xMinus = lon - dLon * 180 / Math.PI; - Timber.d("Search within: xMinus=%s, yMinus=%s, xPlus=%s, yPlus=%s", - xMinus, yMinus, xPlus, yPlus); - } -} 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 index caa66e433..d8f9eb45e 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.java @@ -1,21 +1,17 @@ package fr.free.nrw.commons.category; import android.text.TextUtils; - -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Comparator; -import java.util.Date; -import java.util.HashMap; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Named; - import fr.free.nrw.commons.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; /** @@ -27,19 +23,19 @@ public class CategoriesModel{ private final CategoryClient categoryClient; private final CategoryDao categoryDao; private final JsonKvStore directKvStore; + private final GpsCategoryModel gpsCategoryModel; - private HashMap> categoriesCache; private List selectedCategories; - @Inject GpsCategoryModel gpsCategoryModel; @Inject public CategoriesModel(CategoryClient categoryClient, - CategoryDao categoryDao, - @Named("default_preferences") JsonKvStore directKvStore) { + CategoryDao categoryDao, + @Named("default_preferences") JsonKvStore directKvStore, + final GpsCategoryModel gpsCategoryModel) { this.categoryClient = categoryClient; this.categoryDao = categoryDao; this.directKvStore = directKvStore; - this.categoriesCache = new HashMap<>(); + this.gpsCategoryModel = gpsCategoryModel; this.selectedCategories = new ArrayList<>(); } @@ -94,10 +90,6 @@ public class CategoriesModel{ categoryDao.save(category); } - boolean cacheContainsKey(String term) { - return categoriesCache.containsKey(term); - } - //endregion /** * Regional category search @@ -108,20 +100,18 @@ public class CategoriesModel{ 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 = gpsCategories() - .concatWith(titleCategories(imageTitleList)); + Observable categoryItemObservable = + Observable.concat(gpsCategories(), titleCategories(imageTitleList)); if (hasDirectCategories()) { - categoryItemObservable.concatWith(directCategories().concatWith(recentCategories())); + return Observable.concat( + categoryItemObservable, + directCategories(), + recentCategories() + ); } return categoryItemObservable; } - //if user types in something that is in cache, return cached category - if (cacheContainsKey(term)) { - return Observable.fromIterable(getCachedCategories(term)) - .map(name -> new CategoryItem(name, false)); - } - //otherwise, search API for matching categories //term passed as lower case to make search case-insensitive(taking only lower case for everything) return categoryClient @@ -130,15 +120,6 @@ public class CategoriesModel{ } - /** - * Returns cached categories - * @param term - * @return - */ - private ArrayList getCachedCategories(String term) { - return categoriesCache.get(term); - } - /** * Returns if we have a category in DirectKV Store * @return @@ -256,7 +237,6 @@ public class CategoriesModel{ * Cleanup the existing in memory cache's */ public void cleanUp() { - this.categoriesCache.clear(); this.selectedCategories.clear(); } } diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java index 8280e6b25..e83edfac4 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesListFragment.java @@ -1,5 +1,9 @@ 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; @@ -12,15 +16,7 @@ import android.widget.ListAdapter; import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.TextView; - import androidx.annotation.Nullable; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; -import javax.inject.Named; - import butterknife.BindView; import butterknife.ButterKnife; import dagger.android.support.DaggerFragment; @@ -33,17 +29,22 @@ 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; -import static android.view.View.GONE; -import static android.view.View.VISIBLE; - /** * 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; @@ -256,6 +257,38 @@ public class CategoryImagesListFragment extends DaggerFragment { progressBar.setVisibility(GONE); isLoading = false; statusTextView.setVisibility(GONE); + for (Media m : collection) { + final String pageId = m.getPageId(); + if (pageId != null) { + replaceTitlesWithCaptions(PAGE_ID_PREFIX + pageId, mediaSize++); + } + } + } + + /** + * fetch captions for the image using filename and replace title of on the image thumbnail(if captions are available) + * else show filename + */ + 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(); + } } /** 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 03b33b11c..c983af03a 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 @@ -8,6 +8,8 @@ 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; @@ -55,7 +57,7 @@ public class GridViewAdapter extends ArrayAdapter { data = new ArrayList<>(); return false; } - if (data.size() <= 0) { + if (data.isEmpty()) { return false; } String fileName = data.get(0).getFilename(); @@ -86,12 +88,22 @@ public class GridViewAdapter extends ArrayAdapter { SimpleDraweeView imageView = convertView.findViewById(R.id.categoryImageView); TextView fileName = convertView.findViewById(R.id.categoryImageTitle); TextView author = convertView.findViewById(R.id.categoryImageAuthor); - fileName.setText(item.getDisplayTitle()); + fileName.setText(item.getThumbnailTitle()); setAuthorView(item, author); imageView.setImageURI(item.getThumbUrl()); return convertView; } + /** + * @return the Media item at the given position + */ + @Nullable + @Override + public Media getItem(int position) { + return data.get(position); + } + + /** * Shows author information if its present * @param item 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 c67070acb..80c378137 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 Contribution(final MwQueryLogEvent queryLogEvent, final String user) { + super(queryLogEvent.title(),queryLogEvent.date(), user); + decimalCoords = ""; + dateCreatedSource = ""; + state = STATE_COMPLETED; + } - - public void setDateCreatedSource(String dateCreatedSource) { + public void setDateCreatedSource(final String dateCreatedSource) { this.dateCreatedSource = dateCreatedSource; } - public boolean getMultiple() { - return isMultiple; - } - - 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; + /** + * @return array list of entityids for the depictions + */ + public List getDepictedItems() { + return depictedItems; } - 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"); + public void setWikidataPlace(final WikidataPlace wikidataPlace) { + this.wikidataPlace = wikidataPlace; + } - String templatizedCreatedDate = getTemplatizedCreatedDate(); - if (!StringUtils.isBlank(templatizedCreatedDate)) { - buffer.append("|date=").append(templatizedCreatedDate); - } + public WikidataPlace getWikidataPlace() { + return wikidataPlace; + } - buffer.append("}}").append("\n"); + public long get_id() { + return _id; + } - //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"); - } + public void set_id(final long _id) { + this._id = _id; + } - 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(); + public String getDecimalCoords() { + return decimalCoords; + } + + public void setDecimalCoords(final String decimalCoords) { + this.decimalCoords = decimalCoords; + } + + public void setDepictedItems(final List depictedItems) { + this.depictedItems = depictedItems; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public String getMimeType() { + return mimeType; } /** - * Returns upload date in either TEMPLATE_DATE_ACC_TO_EXIF or TEMPLATE_DATA_OTHER_SOURCE - * @return + * 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 */ - 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 Map getCaptions() { + return captions; } - @Override - public void setFilename(String filename) { - this.filename = filename; - } - - public void setImageUrl(String imageUrl) { - this.imageUrl = imageUrl; - } - - public Contribution() { - - } - - public String getSource() { - return source; - } - - public void setSource(String source) { - this.source = source; - } - - @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 String getWikiDataEntityId() { - return wikiDataEntityId; - } - - public String getWikiItemName() { - return wikiItemName; - } - - /** - * 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 setWikiItemName(String wikiItemName) { - this.wikiItemName = wikiItemName; - } - - public String getP18Value() { - return p18Value; - } - - /** - * 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 - */ - public void setP18Value(String p18Value) { - this.p18Value = p18Value; - } - - public void setContentProviderUri(Uri contentProviderUri) { - this.contentProviderUri = contentProviderUri; + public void setCaptions(Map captions) { + this.captions = captions; } @Override @@ -259,48 +159,34 @@ public class Contribution extends Media { } @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/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java index eb4d5711b..9e1de9736 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java @@ -1,19 +1,13 @@ package fr.free.nrw.commons.contributions; +import static fr.free.nrw.commons.upload.UploadService.EXTRA_FILES; +import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; + import android.Manifest; import android.app.Activity; import android.content.Context; import android.content.Intent; - import androidx.annotation.NonNull; - -import java.util.ArrayList; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - import fr.free.nrw.commons.R; import fr.free.nrw.commons.filepicker.DefaultCallback; import fr.free.nrw.commons.filepicker.FilePicker; @@ -23,12 +17,11 @@ import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.upload.UploadActivity; import fr.free.nrw.commons.utils.PermissionUtils; import fr.free.nrw.commons.utils.ViewUtil; - -import static fr.free.nrw.commons.contributions.Contribution.SOURCE_CAMERA; -import static fr.free.nrw.commons.contributions.Contribution.SOURCE_GALLERY; -import static fr.free.nrw.commons.upload.UploadService.EXTRA_FILES; -import static fr.free.nrw.commons.upload.UploadService.EXTRA_SOURCE; -import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; @Singleton public class ContributionController { @@ -109,7 +102,7 @@ public class ContributionController { @Override public void onImagesPicked(@NonNull List imagesFiles, FilePicker.ImageSource source, int type) { - Intent intent = handleImagesPicked(activity, imagesFiles, getSourceFromImageSource(source)); + Intent intent = handleImagesPicked(activity, imagesFiles); activity.startActivity(intent); } }); @@ -125,11 +118,9 @@ public class ContributionController { * Attaches place object for nearby uploads */ private Intent handleImagesPicked(Context context, - List imagesFiles, - String source) { + List imagesFiles) { Intent shareIntent = new Intent(context, UploadActivity.class); shareIntent.setAction(ACTION_INTERNAL_UPLOADS); - shareIntent.putExtra(EXTRA_SOURCE, source); shareIntent.putParcelableArrayListExtra(EXTRA_FILES, new ArrayList<>(imagesFiles)); Place place = defaultKvStore.getJson(PLACE_OBJECT, Place.class); if (place != null) { @@ -139,13 +130,4 @@ public class ContributionController { return shareIntent; } - /** - * Get image upload source - */ - private String getSourceFromImageSource(FilePicker.ImageSource source) { - if (source.equals(FilePicker.ImageSource.CAMERA_IMAGE)) { - return SOURCE_CAMERA; - } - return SOURCE_GALLERY; - } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java index 261784b52..3c0ac925f 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 @@ -7,13 +7,10 @@ import androidx.room.Insert; import androidx.room.OnConflictStrategy; import androidx.room.Query; import androidx.room.Transaction; - import androidx.room.Update; -import io.reactivex.disposables.Disposable; -import java.util.List; - import io.reactivex.Completable; import io.reactivex.Single; +import java.util.List; @Dao public abstract class ContributionDao { @@ -40,9 +37,6 @@ public abstract class ContributionDao { @Delete public abstract Single delete(Contribution contribution); - @Query("SELECT * from contribution WHERE contentProviderUri=:uri") - public abstract List getContributionWithUri(String uri); - @Query("SELECT * from contribution WHERE filename=:fileName") public abstract List getContributionWithTitle(String fileName); 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 457c31fa5..824953650 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,33 +1,34 @@ package fr.free.nrw.commons.contributions; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; +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.LinearLayout; import android.widget.ProgressBar; import android.widget.TextView; - import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; - -import com.facebook.drawee.view.SimpleDraweeView; - -import com.facebook.imagepipeline.request.ImageRequest; -import com.facebook.imagepipeline.request.ImageRequestBuilder; - - import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; +import com.facebook.drawee.view.SimpleDraweeView; +import com.facebook.imagepipeline.request.ImageRequest; +import com.facebook.imagepipeline.request.ImageRequestBuilder; import fr.free.nrw.commons.R; import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback; -import java.util.HashMap; +import fr.free.nrw.commons.media.MediaClient; +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 static final long TIMEOUT_SECONDS = 15; private final Callback callback; @BindView(R.id.contributionImage) SimpleDraweeView imageView; @@ -37,20 +38,26 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder { @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) { + 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.thumbUrl, contribution.getLocalUri()); + String imageSource = chooseImageSource(contribution.getThumbUrl(), contribution.getLocalUri()); if (!TextUtils.isEmpty(imageSource)) { final ImageRequest imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource)) @@ -58,7 +65,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder { .build(); imageView.setImageRequest(imageRequest); } - titleView.setText(contribution.getDisplayTitle()); seqNumView.setText(String.valueOf(position + 1)); seqNumView.setVisibility(View.VISIBLE); @@ -97,6 +103,38 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder { } } + /** + * 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 { + 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()); + } + } + } + /** * 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 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 ab49e5993..64b836c39 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,5 +1,9 @@ package fr.free.nrw.commons.contributions; +import static fr.free.nrw.commons.contributions.Contribution.STATE_FAILED; +import static fr.free.nrw.commons.contributions.MainActivity.CONTRIBUTIONS_TAB_POSITION; +import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween; + import android.Manifest; import android.content.ComponentName; import android.content.Context; @@ -12,21 +16,12 @@ import android.view.View; import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.Toast; - 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 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; @@ -60,12 +55,11 @@ import io.reactivex.Observable; 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; -import static fr.free.nrw.commons.contributions.Contribution.STATE_FAILED; -import static fr.free.nrw.commons.contributions.MainActivity.CONTRIBUTIONS_TAB_POSITION; -import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween; - public class ContributionsFragment extends CommonsDaggerSupportFragment implements @@ -221,7 +215,7 @@ public class ContributionsFragment @Override public void fetchMediaUriFor(Contribution contribution) { - Timber.d("Fetching thumbnail for %s", contribution.filename); + Timber.d("Fetching thumbnail for %s", contribution.getFilename()); contributionsPresenter.fetchMediaDetails(contribution); } }); 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 8b8d2fff1..82daf97f4 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,29 +1,28 @@ package fr.free.nrw.commons.contributions; -import android.os.Handler; -import android.os.Looper; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.ViewGroup; - import androidx.annotation.NonNull; 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; -import fr.free.nrw.commons.R; - /** * Represents The View Adapter for the List of Contributions */ public class ContributionsListAdapter extends RecyclerView.Adapter { private Callback callback; + private final MediaClient mediaClient; private List contributions; - public ContributionsListAdapter(Callback callback) { + public ContributionsListAdapter(Callback callback, + MediaClient mediaClient) { this.callback = callback; + this.mediaClient = mediaClient; contributions = new ArrayList<>(); } @@ -36,7 +35,7 @@ public class ContributionsListAdapter extends RecyclerView.Adapter Timber.d("Received image %s", mwQueryLogEvent.title())) .filter(mwQueryLogEvent -> !mwQueryLogEvent.isDeleted()).doOnNext(mwQueryLogEvent -> Timber.d("Image %s passed filters", mwQueryLogEvent.title())) - .map(image -> { - Contribution contribution = new Contribution(null, null, image.title(), - "", -1, image.date(), image.date(), user, - "", "", STATE_COMPLETED); - return contribution; - }) + .map(image -> new Contribution(image, user)) .toList() .subscribe(this::saveContributionsToDB, error -> { Timber.e("Failed to fetch contributions: %s", error.getMessage()); @@ -197,11 +177,11 @@ public class ContributionsPresenter implements UserActionListener { @Override public void fetchMediaDetails(Contribution contribution) { compositeDisposable.add(mediaDataExtractor - .getMediaFromFileName(contribution.filename) + .getMediaFromFileName(contribution.getFilename()) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(media -> { - contribution.thumbUrl=media.thumbUrl; + contribution.setThumbUrl(media.getThumbUrl()); updateContribution(contribution); })); } diff --git a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.java b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.java deleted file mode 100644 index 61097fa6f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.java +++ /dev/null @@ -1,14 +0,0 @@ -package fr.free.nrw.commons.db; - -import androidx.room.Database; -import androidx.room.RoomDatabase; -import androidx.room.TypeConverters; - -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.contributions.ContributionDao; - -@Database(entities = {Contribution.class}, version = 1, exportSchema = false) -@TypeConverters({Converters.class}) -abstract public class AppDatabase extends RoomDatabase { - public abstract ContributionDao getContributionDao(); -} 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 new file mode 100644 index 000000000..01095dd7c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt @@ -0,0 +1,17 @@ +package fr.free.nrw.commons.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.contributions.ContributionDao + +/** + * The database for accessing the respective DAOs + * + */ +@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 bea973612..94311c67c 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 @@ -1,22 +1,22 @@ package fr.free.nrw.commons.db; import android.net.Uri; - import androidx.room.TypeConverter; - import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; - -import org.wikipedia.json.GsonUtil; - -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; - import fr.free.nrw.commons.CommonsApplication; 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; +import java.util.List; +import java.util.Map; +/** + * This class supplies converters to write/read types to/from the database. + */ public class Converters { public static Gson getGson() { @@ -44,33 +44,74 @@ public class Converters { } @TypeConverter - public static String listObjectToString(ArrayList objectList) { - return objectList == null ? null : getGson().toJson(objectList); + public static String listObjectToString(List objectList) { + return writeObjectToString(objectList); } @TypeConverter - public static ArrayList stringToArrayListObject(String objectList) { - return objectList == null ? null : getGson().fromJson(objectList,new TypeToken>(){}.getType()); + public static List stringToListObject(String objectList) { + return readObjectWithTypeToken(objectList, new TypeToken>() {}); } @TypeConverter - public static String mapObjectToString(HashMap objectList) { - return objectList == null ? null : getGson().toJson(objectList); + public static String mapObjectToString(Map objectList) { + return writeObjectToString(objectList); } @TypeConverter - public static HashMap stringToMap(String objectList) { - return objectList == null ? null : getGson().fromJson(objectList,new TypeToken>(){}.getType()); + public static Map stringToMap(String objectList) { + return readObjectWithTypeToken(objectList, new TypeToken>(){}); } @TypeConverter public static String latlngObjectToString(LatLng latlng) { - return latlng == null ? null : getGson().toJson(latlng); + return writeObjectToString(latlng); } @TypeConverter public static LatLng stringToLatLng(String objectList) { - return objectList == null ? null : getGson().fromJson(objectList,LatLng.class); + return readObjectFromString(objectList,LatLng.class); } + @TypeConverter + public static String wikidataPlaceToString(WikidataPlace wikidataPlace) { + return writeObjectToString(wikidataPlace); + } + + @TypeConverter + public static WikidataPlace stringToWikidataPlace(String wikidataPlace) { + return readObjectFromString(wikidataPlace, WikidataPlace.class); + } + + @TypeConverter + public static String depictionListToString(List depictedItems) { + return writeObjectToString(depictedItems); + } + + @TypeConverter + public static List stringToList(String depictedItems) { + return readObjectWithTypeToken(depictedItems, new TypeToken>() {}); + } + + @TypeConverter + public static String depictionsToString(Depictions depictedItems) { + return writeObjectToString(depictedItems); + } + + @TypeConverter + public static Depictions stringToDepictions(String depictedItems) { + return readObjectFromString(depictedItems, Depictions.class); + } + + private static String writeObjectToString(Object object) { + return object == null ? null : getGson().toJson(object); + } + + private static T readObjectFromString(String objectAsString, Class clazz) { + return objectAsString == null ? null : getGson().fromJson(objectAsString, clazz); + } + + private static T readObjectWithTypeToken(String objectList, TypeToken typeToken) { + return objectList == null ? null : getGson().fromJson(objectList, typeToken.getType()); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java b/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java index 5253f0dbd..3eac4908e 100644 --- a/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java +++ b/app/src/main/java/fr/free/nrw/commons/delete/DeleteHelper.java @@ -1,22 +1,12 @@ package fr.free.nrw.commons.delete; +import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_DELETE; + import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.net.Uri; - import androidx.appcompat.app.AlertDialog; - -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Locale; -import java.util.concurrent.Callable; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - import fr.free.nrw.commons.BuildConfig; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; @@ -29,10 +19,16 @@ import io.reactivex.Single; import io.reactivex.SingleSource; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Locale; +import java.util.concurrent.Callable; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; import timber.log.Timber; -import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_DELETE; - /** * Refactored async task to Rx */ @@ -104,7 +100,7 @@ public class DeleteHelper { } String creatorName = creator.replace(" (page does not exist)", ""); - return pageEditClient.prependEdit(media.filename, fileDeleteString + "\n", summary) + return pageEditClient.prependEdit(media.getFilename(), fileDeleteString + "\n", summary) .flatMap(result -> { if (result) { return pageEditClient.edit("Commons:Deletion_requests/" + media.getFilename(), subpageString + "\n", summary); 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 000000000..a64d82967 --- /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 000000000..56cb73728 --- /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 000000000..67e59390c --- /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 000000000..1c1aeaef3 --- /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 000000000..372aa60a4 --- /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 000000000..40ea07995 --- /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 000000000..2365391a5 --- /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 000000000..15ea61d50 --- /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 000000000..358527fe7 --- /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 000000000..fbff3616a --- /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 000000000..f04f62d0e --- /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 000000000..0973149cc --- /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 000000000..5c8a3c045 --- /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 000000000..1f9f4fc76 --- /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 000000000..da9781c06 --- /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 e6416c4d1..db7c7fd26 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 @@ -11,6 +11,7 @@ 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.depictions.WikidataItemDetailsActivity; import fr.free.nrw.commons.explore.SearchActivity; import fr.free.nrw.commons.explore.categories.ExploreActivity; import fr.free.nrw.commons.notification.NotificationActivity; @@ -21,7 +22,7 @@ import fr.free.nrw.commons.upload.UploadActivity; /** * This Class handles the dependency injection (using dagger) * so, if a developer needs to add a new activity to the commons app - * then that must be mentioned here to inject the dependencies + * then that must be mentioned here to inject the dependencies */ @Module @SuppressWarnings({"WeakerAccess", "unused"}) @@ -60,6 +61,9 @@ public abstract class ActivityBuilderModule { @ContributesAndroidInjector abstract CategoryDetailsActivity bindCategoryDetailsActivity(); + @ContributesAndroidInjector + abstract WikidataItemDetailsActivity bindDepictionDetailsActivity(); + @ContributesAndroidInjector abstract ExploreActivity bindExploreActivity(); 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 6efd3eb05..f813de762 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 @@ -12,6 +12,8 @@ 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.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; @@ -33,7 +35,7 @@ import fr.free.nrw.commons.widget.PicOfDayAppWidget; ActivityBuilderModule.class, FragmentBuilderModule.class, ServiceBuilderModule.class, - ContentProviderBuilderModule.class, UploadModule.class, ContributionsModule.class + ContentProviderBuilderModule.class, UploadModule.class, ContributionsModule.class, SearchModule.class, DepictionModule.class }) public interface CommonsApplicationComponent extends AndroidInjector { void inject(CommonsApplication application); diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java index 8ed13d4df..f19ab4f3a 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java @@ -7,7 +7,6 @@ import android.content.Context; import android.view.inputmethod.InputMethodManager; import androidx.collection.LruCache; import androidx.room.Room; -import com.github.varunpant.quadtree.QuadTree; import com.google.gson.Gson; import dagger.Module; import dagger.Provides; @@ -50,7 +49,6 @@ public class CommonsApplicationModule { private Context applicationContext; public static final String IO_THREAD="io_thread"; public static final String MAIN_THREAD="main_thread"; - private AppDatabase appDatabase; public CommonsApplicationModule(Context applicationContext) { this.applicationContext = applicationContext; @@ -105,6 +103,11 @@ public class CommonsApplicationModule { return context.getContentResolver().acquireContentProviderClient(BuildConfig.CATEGORY_AUTHORITY); } + /** + * This method is used to provide instance of DepictsContentProviderClient + * @param context context + * @return DepictsContentProviderClient*/ + /** * This method is used to provide instance of RecentSearchContentProviderClient * which provides content of Recent Searches from database @@ -218,26 +221,15 @@ public class CommonsApplicationModule { return Objects.toString(AppAdapter.get().getUserName(), ""); } - /** - * Provides quad tree - * - * @return - */ - @Provides - public QuadTree providesQuadTres() { - return new QuadTree<>(-180, -90, +180, +90); - } - @Provides @Singleton public AppDatabase provideAppDataBase() { - appDatabase=Room.databaseBuilder(applicationContext, AppDatabase.class, "commons_room.db").build(); - return appDatabase; + return Room.databaseBuilder(applicationContext, AppDatabase.class, "commons_room.db").build(); } @Provides - public ContributionDao providesContributionsDao() { - return appDatabase.getContributionDao(); + public ContributionDao providesContributionsDao(AppDatabase appDatabase) { + return appDatabase.contributionDao(); } @Provides diff --git a/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java index 687ef36a0..6619f8381 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.java @@ -10,22 +10,21 @@ import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider; /** * This Class Represents the Module for dependency injection (using dagger) * so, if a developer needs to add a new ContentProvider to the commons app - * then that must be mentioned here to inject the dependencies + * then that must be mentioned here to inject the dependencies */ @Module -@SuppressWarnings({"WeakerAccess", "unused"}) +@SuppressWarnings({ "WeakerAccess", "unused" }) public abstract class ContentProviderBuilderModule { - @ContributesAndroidInjector - abstract CategoryContentProvider bindCategoryContentProvider(); + @ContributesAndroidInjector + abstract CategoryContentProvider bindCategoryContentProvider(); - @ContributesAndroidInjector - abstract RecentSearchesContentProvider bindRecentSearchesContentProvider(); + @ContributesAndroidInjector + abstract RecentSearchesContentProvider bindRecentSearchesContentProvider(); - @ContributesAndroidInjector - abstract BookmarkPicturesContentProvider bindBookmarkContentProvider(); - - @ContributesAndroidInjector - abstract BookmarkLocationsContentProvider bindBookmarkLocationContentProvider(); + @ContributesAndroidInjector + abstract BookmarkPicturesContentProvider bindBookmarkContentProvider(); + @ContributesAndroidInjector + abstract BookmarkLocationsContentProvider bindBookmarkLocationContentProvider(); } diff --git a/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java b/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java index e2be74d0f..a294ca1cf 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.java @@ -8,7 +8,10 @@ import fr.free.nrw.commons.category.CategoryImagesListFragment; import fr.free.nrw.commons.category.SubCategoryListFragment; import fr.free.nrw.commons.contributions.ContributionsFragment; import fr.free.nrw.commons.contributions.ContributionsListFragment; +import fr.free.nrw.commons.depictions.Media.DepictedImagesFragment; +import fr.free.nrw.commons.depictions.subClass.SubDepictionListFragment; 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.MediaDetailFragment; @@ -17,6 +20,7 @@ import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment; import fr.free.nrw.commons.review.ReviewImageFragment; import fr.free.nrw.commons.settings.SettingsFragment; import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment; +import fr.free.nrw.commons.upload.depicts.DepictsFragment; import fr.free.nrw.commons.upload.license.MediaLicenseFragment; import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment; @@ -44,6 +48,12 @@ public abstract class FragmentBuilderModule { @ContributesAndroidInjector abstract CategoryImagesListFragment bindFeaturedImagesListFragment(); + @ContributesAndroidInjector + abstract DepictedImagesFragment bindDepictedImagesFragment(); + + @ContributesAndroidInjector + abstract SubDepictionListFragment bindSubDepictionListFragment(); + @ContributesAndroidInjector abstract SubCategoryListFragment bindSubCategoryListFragment(); @@ -53,6 +63,9 @@ public abstract class FragmentBuilderModule { @ContributesAndroidInjector abstract SearchCategoryFragment bindSearchCategoryListFragment(); + @ContributesAndroidInjector + abstract SearchDepictionsFragment bindSearchDepictionListFragment(); + @ContributesAndroidInjector abstract RecentSearchesFragment bindRecentSearchesFragment(); @@ -77,6 +90,9 @@ public abstract class FragmentBuilderModule { @ContributesAndroidInjector abstract UploadCategoriesFragment bindUploadCategoriesFragment(); + @ContributesAndroidInjector + abstract DepictsFragment bindDepictsFragment(); + @ContributesAndroidInjector abstract MediaLicenseFragment bindMediaLicenseFragment(); } diff --git a/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.java b/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.java index c6e6033f5..9f8df9de5 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.java @@ -1,24 +1,8 @@ package fr.free.nrw.commons.di; import android.content.Context; - import androidx.annotation.NonNull; - import com.google.gson.Gson; - -import org.wikipedia.csrf.CsrfTokenClient; -import org.wikipedia.dataclient.Service; -import org.wikipedia.dataclient.ServiceFactory; -import org.wikipedia.dataclient.WikiSite; -import org.wikipedia.json.GsonUtil; -import org.wikipedia.login.LoginClient; - -import java.io.File; -import java.util.concurrent.TimeUnit; - -import javax.inject.Named; -import javax.inject.Singleton; - import dagger.Module; import dagger.Provides; import fr.free.nrw.commons.BuildConfig; @@ -26,16 +10,30 @@ import fr.free.nrw.commons.actions.PageEditClient; import fr.free.nrw.commons.actions.PageEditInterface; import fr.free.nrw.commons.category.CategoryInterface; import fr.free.nrw.commons.kvstore.JsonKvStore; +import fr.free.nrw.commons.media.MediaDetailInterface; import fr.free.nrw.commons.media.MediaInterface; import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; import fr.free.nrw.commons.mwapi.UserInterface; import fr.free.nrw.commons.review.ReviewInterface; import fr.free.nrw.commons.upload.UploadInterface; +import fr.free.nrw.commons.upload.WikiBaseInterface; +import fr.free.nrw.commons.upload.depicts.DepictsInterface; import fr.free.nrw.commons.wikidata.WikidataInterface; +import java.io.File; +import java.util.concurrent.TimeUnit; +import javax.inject.Named; +import javax.inject.Singleton; import okhttp3.Cache; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; +import okhttp3.logging.HttpLoggingInterceptor.Level; +import org.wikipedia.csrf.CsrfTokenClient; +import org.wikipedia.dataclient.Service; +import org.wikipedia.dataclient.ServiceFactory; +import org.wikipedia.dataclient.WikiSite; +import org.wikipedia.json.GsonUtil; +import org.wikipedia.login.LoginClient; import timber.log.Timber; @Module @@ -72,7 +70,7 @@ public class NetworkingModule { HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor(message -> { Timber.tag("OkHttp").v(message); }); - httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY); + httpLoggingInterceptor.level(BuildConfig.DEBUG ? Level.BODY: Level.BASIC); return httpLoggingInterceptor; } @@ -86,8 +84,7 @@ public class NetworkingModule { toolsForgeUrl, WIKIDATA_SPARQL_QUERY_URL, BuildConfig.WIKIMEDIA_CAMPAIGNS_URL, - BuildConfig.WIKIMEDIA_API_HOST, - gson); + gson); } @Named(NAMED_COMMONS_CSRF) @@ -133,6 +130,7 @@ public class NetworkingModule { return new WikiSite(BuildConfig.WIKIDATA_URL); } + /** * Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere. * @return returns a singleton Gson instance @@ -163,6 +161,18 @@ public class NetworkingModule { return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, ReviewInterface.class); } + @Provides + @Singleton + public DepictsInterface provideDepictsInterface(@Named(NAMED_WIKI_DATA_WIKI_SITE) WikiSite wikidataWikiSite) { + return ServiceFactory.get(wikidataWikiSite, BuildConfig.WIKIDATA_URL, DepictsInterface.class); + } + + @Provides + @Singleton + public WikiBaseInterface provideWikiBaseInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) { + return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, WikiBaseInterface.class); + } + @Provides @Singleton public UploadInterface provideUploadInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) { @@ -198,6 +208,12 @@ public class NetworkingModule { return ServiceFactory.get(commonsWikiSite, BuildConfig.COMMONS_URL, MediaInterface.class); } + @Provides + @Singleton + public MediaDetailInterface providesMediaDetailInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikisite) { + return ServiceFactory.get(commonsWikisite, BuildConfig.COMMONS_URL, MediaDetailInterface.class); + } + @Provides @Singleton public CategoryInterface provideCategoryInterface(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) { 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 708ac8c5f..4d18c3a19 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 @@ -26,6 +26,7 @@ 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.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; @@ -50,6 +51,7 @@ public class SearchActivity extends NavigationBaseActivity private SearchImageFragment searchImageFragment; private SearchCategoryFragment searchCategoryFragment; + private SearchDepictionsFragment searchDepictionsFragment; private RecentSearchesFragment recentSearchesFragment; private FragmentManager supportFragmentManager; private MediaDetailPagerFragment mediaDetails; @@ -68,6 +70,7 @@ public class SearchActivity extends NavigationBaseActivity setSearchHistoryFragment(); viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager()); viewPager.setAdapter(viewPagerAdapter); + viewPager.setOffscreenPageLimit(2); // Because we want all the fragments to be alive tabLayout.setupWithViewPager(viewPager); setTabs(); searchView.setQueryHint(getString(R.string.search_commons)); @@ -93,11 +96,14 @@ public class SearchActivity extends NavigationBaseActivity List fragmentList = new ArrayList<>(); List titleList = new ArrayList<>(); searchImageFragment = new SearchImageFragment(); + searchDepictionsFragment = new SearchDepictionsFragment(); searchCategoryFragment= new SearchCategoryFragment(); 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()); + fragmentList.add(searchDepictionsFragment); + titleList.add(getResources().getString(R.string.search_tab_title_depictions).toUpperCase()); viewPagerAdapter.setTabData(fragmentList, titleList); viewPagerAdapter.notifyDataSetChanged(); @@ -112,6 +118,11 @@ public class SearchActivity extends NavigationBaseActivity viewPager.setVisibility(View.VISIBLE); tabLayout.setVisibility(View.VISIBLE); searchHistoryContainer.setVisibility(View.GONE); + + if (FragmentUtils.isFragmentUIActive(searchDepictionsFragment)) { + searchDepictionsFragment.updateDepictionList(query.toString()); + } + if (FragmentUtils.isFragmentUIActive(searchImageFragment)) { searchImageFragment.updateImageList(query.toString()); } @@ -119,6 +130,7 @@ public class SearchActivity extends NavigationBaseActivity if (FragmentUtils.isFragmentUIActive(searchCategoryFragment)) { searchCategoryFragment.updateCategoryList(query.toString()); } + }else { //Open RecentSearchesFragment recentSearchesFragment.updateRecentSearches(); 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 000000000..f98946451 --- /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 index 988e0a21a..da1c447a4 100644 --- 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 @@ -217,7 +217,7 @@ public class SearchCategoryFragment extends CommonsDaggerSupportFragment { private void initErrorView() { progressBar.setVisibility(GONE); categoriesNotFoundView.setVisibility(VISIBLE); - categoriesNotFoundView.setText(getString(R.string.categories_not_found)); + categoriesNotFoundView.setText(getString(R.string.categories_not_found,query)); } /** 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 000000000..dcbf69a5f --- /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 000000000..71d64b0ea --- /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 000000000..10ccf4ad4 --- /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 000000000..49ce776b3 --- /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 000000000..780fe0622 --- /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 000000000..581858ef9 --- /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 index 0b5b40074..28099f8ce 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/images/SearchImageFragment.java @@ -1,5 +1,9 @@ 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; @@ -8,23 +12,12 @@ 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 com.pedrogomez.renderers.RVRendererAdapter; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; @@ -37,11 +30,14 @@ 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; -import static android.view.View.GONE; -import static android.view.View.VISIBLE; - /** * Displays the image search screen. */ @@ -67,6 +63,11 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment { @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<>(); @@ -101,7 +102,7 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment { 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){ + if (getContext().getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT){ imagesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); } else{ @@ -198,10 +199,39 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment { progressBar.setVisibility(GONE); imagesAdapter.addAll(mediaList); imagesAdapter.notifyDataSetChanged(); - ((SearchActivity) getContext()).viewPagerNotifyDataSetChanged(); + ((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 @@ -221,7 +251,7 @@ public class SearchImageFragment extends CommonsDaggerSupportFragment { private void initErrorView() { progressBar.setVisibility(GONE); imagesNotFoundView.setVisibility(VISIBLE); - imagesNotFoundView.setText(getString(R.string.images_not_found)); + imagesNotFoundView.setText(getString(R.string.images_not_found,query)); } /** 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 index 617ea46cc..8985a8484 100644 --- 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 @@ -50,7 +50,7 @@ class SearchImagesRenderer extends Renderer { @Override public void render() { Media item = getContent(); - tvImageName.setText(item.getDisplayTitle()); + tvImageName.setText(item.getThumbnailTitle()); browseImage.setImageURI(item.getThumbUrl()); setAuthorView(item, categoryImageAuthor); } diff --git a/app/src/main/java/fr/free/nrw/commons/media/Caption.java b/app/src/main/java/fr/free/nrw/commons/media/Caption.java new file mode 100644 index 000000000..2cb3b86e1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/Caption.java @@ -0,0 +1,43 @@ +package fr.free.nrw.commons.media; + +import com.google.gson.annotations.SerializedName; + +/** + * Model class for parsing Captions when fetching captions using filename in MediaClient + */ +public class Caption { + + /** + * users language in which caption is written + */ + @SerializedName("language") + private String language; + @SerializedName("value") + private String value; + + /** + * No args constructor for use in serialization + */ + public Caption() { + } + + /** + * @param value + * @param language + */ + public Caption(String language, String value) { + super(); + this.language = language; + this.value = value; + } + + @SerializedName("language") + public String getLanguage() { + return language; + } + + @SerializedName("value") + public String getValue() { + return value; + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/CommonsWikibaseItem.java b/app/src/main/java/fr/free/nrw/commons/media/CommonsWikibaseItem.java new file mode 100644 index 000000000..af26f0c72 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/CommonsWikibaseItem.java @@ -0,0 +1,76 @@ +package fr.free.nrw.commons.media; + +import com.google.gson.annotations.SerializedName; + +import java.util.Map; + + +/** + * Represents the Wikibase item associated with a Wikimedia Commons file. + * For instance the Wikibase item M63996 represents the Commons file "Paul Cézanne - The Pigeon Tower at Bellevue - 1936.19 - Cleveland Museum of Art.jpg" + */ +public class CommonsWikibaseItem { + + @SerializedName("type") + private String type; + @SerializedName("id") + private String id; + @SerializedName("labels") + private Map labels; + @SerializedName("statements") + private Object statements = null; + + /** + * No args constructor for use in serialization + */ + public CommonsWikibaseItem() { + } + + /** + * @param id + * @param statements + * @param labels + * @param type + */ + public CommonsWikibaseItem(String type, String id, Map labels, Object statements) { + super(); + this.type = type; + this.id = id; + this.labels = labels; + this.statements = statements; + } + + /** + * Ex: "mediainfo + */ + @SerializedName("type") + public String getType() { + return type; + } + + /** + * @return Wikibase Id + */ + @SerializedName("id") + public String getId() { + return id; + } + + /** + * @return value of captions + */ + @SerializedName("labels") + public Map getLabels() { + return labels; + } + + /** + * Contains the Depicts item + */ + @SerializedName("statements") + public Object getStatements() { + return statements; + } + + +} 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 000000000..ae4191d06 --- /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 000000000..00ca69f08 --- /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 index f93996a6e..a6ba1897e 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaClient.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaClient.java @@ -2,23 +2,23 @@ package fr.free.nrw.commons.media; import androidx.annotation.NonNull; - -import org.wikipedia.dataclient.mwapi.MwQueryResponse; - +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 fr.free.nrw.commons.Media; -import fr.free.nrw.commons.utils.CommonsDateUtil; -import io.reactivex.Observable; -import io.reactivex.Single; +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; /** @@ -28,13 +28,17 @@ import timber.log.Timber; 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) { + public MediaClient(MediaInterface mediaInterface, MediaDetailInterface mediaDetailInterface) { this.mediaInterface = mediaInterface; + this.mediaDetailInterface = mediaDetailInterface; this.continuationStore = new HashMap<>(); } @@ -88,7 +92,7 @@ public class MediaClient { */ public Single> getMediaListFromSearch(String keyword) { return responseToMediaList( - continuationStore.containsKey("search_" + keyword) ? + 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); @@ -108,7 +112,7 @@ public class MediaClient { .map(Media::from) .collect(ArrayList::new, List::add); } - + /** * Fetches Media object from the imageInfo API * @@ -152,6 +156,7 @@ public class MediaClient { .single(Media.EMPTY); } + @NonNull public Single getPageHtml(String title){ return mediaInterface.getPageHtml(title) @@ -160,4 +165,62 @@ public class MediaClient { .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 baf6cac8b..a9a402b6c 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.ScrollView; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; - -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.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 androidx.annotation.Nullable; import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; -import androidx.annotation.Nullable; +import com.facebook.drawee.backends.pipeline.Fresco; +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 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.category.CategoryDetailsActivity; 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 class MediaDetailFragment extends CommonsDaggerSupportFragment { 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 class MediaDetailFragment extends CommonsDaggerSupportFragment { 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 class MediaDetailFragment extends CommonsDaggerSupportFragment { 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 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { 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 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { 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 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { 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 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { } } + /** + * 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 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { 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 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment { } 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 new file mode 100644 index 000000000..c972ccc12 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailInterface.java @@ -0,0 +1,37 @@ +package fr.free.nrw.commons.media; + +import io.reactivex.Observable; +import org.wikipedia.wikidata.Entities; +import retrofit2.http.GET; +import retrofit2.http.Query; + +/** + * Interface for interacting with Commons Structured Data related APIs + */ +public interface MediaDetailInterface { + + /** + * Fetches entity using file name + * + * @param filename name of the file to be used for fetching captions + */ + @GET("w/api.php?action=wbgetentities&props=labels&format=json&languagefallback=1&sites=commonswiki") + Observable fetchEntitiesByFileName(@Query("languages") String language, @Query("titles") String filename); + + /** + * Gets labels for Depictions using Entity Id from MediaWikiAPI + * + * @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") + Observable getEntity(@Query("ids") String entityId, @Query("languages") String language); + + /** + * Fetches caption using wikibaseIdentifier + * + * @param wikibaseIdentifier pageId for the media + */ + @GET("/w/api.php?action=wbgetentities&props=labels&format=json&languagefallback=1&sites=commonswiki") + 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 59f779b4c..39c81386e 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.MenuInflater; 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 fef70874a..10dd02a83 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 @@ -4,6 +4,7 @@ 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; @@ -83,4 +84,23 @@ public interface MediaInterface { @GET("w/api.php?format=json&action=parse&prop=text") Observable getPageHtml(@Query("page") String title); + + /** + * Fetches caption using file name + * + * @param filename name of the file to be used for fetching captions + * */ + @GET("w/api.php?action=wbgetentities&props=labels&format=json&languagefallback=1") + Observable fetchCaptionByFilename(@Query("language") String language, @Query("titles") String filename); + + /** + * Fetches list of images from a depiction entity + * + * @param query depictionEntityId + * @param sroffset number od depictions already fetched, this is useful in implementing pagination + */ + + @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 598b9260c..c3fcf9b08 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,38 +1,34 @@ package fr.free.nrw.commons.mwapi; import android.text.TextUtils; - import androidx.annotation.NonNull; - import com.google.gson.Gson; - -import org.apache.commons.lang3.StringUtils; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -import javax.inject.Inject; -import javax.inject.Singleton; - 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.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.upload.FileUtils; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; import fr.free.nrw.commons.utils.ConfigUtils; import fr.free.nrw.commons.wikidata.model.GetWikidataEditCountResponse; import io.reactivex.Observable; import io.reactivex.Single; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.inject.Inject; +import javax.inject.Singleton; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; +import org.jetbrains.annotations.NotNull; import timber.log.Timber; /** @@ -40,208 +36,227 @@ import timber.log.Timber; */ @Singleton public class OkHttpJsonApiClient { - private static final String THUMB_SIZE = "640"; - private final OkHttpClient okHttpClient; - private final HttpUrl wikiMediaToolforgeUrl; - private final String sparqlQueryUrl; - private final String campaignsUrl; - private final String commonsBaseUrl; - private Gson gson; + private final OkHttpClient okHttpClient; + private final HttpUrl wikiMediaToolforgeUrl; + private final String sparqlQueryUrl; + private final String campaignsUrl; + private final Gson gson; - @Inject - public OkHttpJsonApiClient(OkHttpClient okHttpClient, - HttpUrl wikiMediaToolforgeUrl, - String sparqlQueryUrl, - String campaignsUrl, - String commonsBaseUrl, - Gson gson) { - this.okHttpClient = okHttpClient; - this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl; - this.sparqlQueryUrl = sparqlQueryUrl; - this.campaignsUrl = campaignsUrl; - this.commonsBaseUrl = commonsBaseUrl; - this.gson = gson; + @Inject + public OkHttpJsonApiClient(OkHttpClient okHttpClient, + HttpUrl wikiMediaToolforgeUrl, + String sparqlQueryUrl, + String campaignsUrl, + Gson gson) { + this.okHttpClient = okHttpClient; + this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl; + this.sparqlQueryUrl = sparqlQueryUrl; + this.campaignsUrl = campaignsUrl; + this.gson = gson; + } + + @NonNull + public Single getUploadCount(String userName) { + HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder(); + urlBuilder + .addPathSegments("uploadsbyuser.py") + .addQueryParameter("user", userName); + + if (ConfigUtils.isBetaFlavour()) { + urlBuilder.addQueryParameter("labs", "commonswiki"); } - @NonNull - public Single getUploadCount(String userName) { - HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder(); - urlBuilder - .addPathSegments("uploadsbyuser.py") - .addQueryParameter("user", userName); + Request request = new Request.Builder() + .url(urlBuilder.build()) + .build(); - if (ConfigUtils.isBetaFlavour()) { - urlBuilder.addQueryParameter("labs", "commonswiki"); + return Single.fromCallable(() -> { + Response response = okHttpClient.newCall(request).execute(); + if (response != null && response.isSuccessful()) { + ResponseBody responseBody = response.body(); + if (null != responseBody) { + String responseBodyString = responseBody.string().trim(); + if (!TextUtils.isEmpty(responseBodyString)) { + try { + return Integer.parseInt(responseBodyString); + } catch (NumberFormatException e) { + Timber.e(e); + } + } + } + } + return 0; + }); + } + + @NonNull + public Single getWikidataEdits(String userName) { + HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder(); + urlBuilder + .addPathSegments("wikidataedits.py") + .addQueryParameter("user", userName); + + if (ConfigUtils.isBetaFlavour()) { + urlBuilder.addQueryParameter("labs", "commonswiki"); + } + + Request request = new Request.Builder() + .url(urlBuilder.build()) + .build(); + + return Single.fromCallable(() -> { + Response response = okHttpClient.newCall(request).execute(); + if (response != null && + response.isSuccessful() && response.body() != null) { + String json = response.body().string(); + if (json == null) { + return 0; + } + GetWikidataEditCountResponse countResponse = gson + .fromJson(json, GetWikidataEditCountResponse.class); + if (null != countResponse) { + return countResponse.getWikidataEditCount(); + } + } + return 0; + }); + } + + /** + * This takes userName as input, which is then used to fetch the feedback/achievements statistics + * using OkHttp and JavaRx. This function return JSONObject + * + * @param userName MediaWiki user name + * @return + */ + public Single getAchievements(String userName) { + final String fetchAchievementUrlTemplate = + wikiMediaToolforgeUrl + (ConfigUtils.isBetaFlavour() ? "/feedback.py?labs=commonswiki" + : "/feedback.py"); + return Single.fromCallable(() -> { + String url = String.format( + Locale.ENGLISH, + fetchAchievementUrlTemplate, + 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(); + Response response = okHttpClient.newCall(request).execute(); + if (response != null && response.body() != null && response.isSuccessful()) { + String json = response.body().string(); + if (json == null) { + return null; + } + Timber.d("Response for achievements is %s", json); + try { + return gson.fromJson(json, FeedbackResponse.class); + } catch (Exception e) { + return new FeedbackResponse(0, 0, 0, new FeaturedImages(0, 0), 0, ""); } - Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - return Single.fromCallable(() -> { - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.isSuccessful()) { - ResponseBody responseBody = response.body(); - if (null != responseBody) { - String responseBodyString = responseBody.string().trim(); - if (!TextUtils.isEmpty(responseBodyString)) { - try { - return Integer.parseInt(responseBodyString); - } catch (NumberFormatException e) { - Timber.e(e); - } - } - } - } - return 0; - }); - } + } + return null; + }); + } - @NonNull - public Single getWikidataEdits(String userName) { - HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder(); - urlBuilder - .addPathSegments("wikidataedits.py") - .addQueryParameter("user", userName); - - if (ConfigUtils.isBetaFlavour()) { - urlBuilder.addQueryParameter("labs", "commonswiki"); - } - - Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - return Single.fromCallable(() -> { - Response response = okHttpClient.newCall(request).execute(); - if (response != null && - response.isSuccessful() && response.body() != null) { - String json = response.body().string(); - if (json == null) { - return 0; - } - GetWikidataEditCountResponse countResponse = gson.fromJson(json, GetWikidataEditCountResponse.class); - if (null != countResponse) { - return countResponse.getWikidataEditCount(); - } - } - return 0; - }); - } - - /** - * This takes userName as input, which is then used to fetch the feedback/achievements - * statistics using OkHttp and JavaRx. This function return JSONObject - * - * @param userName MediaWiki user name - * @return - */ - public Single getAchievements(String userName) { - final String fetchAchievementUrlTemplate = - wikiMediaToolforgeUrl + (ConfigUtils.isBetaFlavour() ? "/feedback.py?labs=commonswiki" : "/feedback.py"); - return Single.fromCallable(() -> { - String url = String.format( - Locale.ENGLISH, - fetchAchievementUrlTemplate, - 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(); - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return null; - } - Timber.d("Response for achievements is %s", json); - try { - return gson.fromJson(json, FeedbackResponse.class); - } catch (Exception e) { - return new FeedbackResponse(0, 0, 0, new FeaturedImages(0, 0), 0, ""); - } - - - } - return null; - }); - } - - public Observable> getNearbyPlaces(LatLng cur, String lang, double radius) throws IOException { + public Observable> getNearbyPlaces(LatLng cur, String language, double radius) throws IOException { String wikidataQuery = FileUtils.readFromResource("/queries/nearby_query.rq"); String query = wikidataQuery .replace("${RAD}", String.format(Locale.ROOT, "%.2f", radius)) .replace("${LAT}", String.format(Locale.ROOT, "%.4f", cur.getLatitude())) .replace("${LONG}", String.format(Locale.ROOT, "%.4f", cur.getLongitude())) - .replace("${LANG}", lang); + .replace("${LANG}", language); - HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); + HttpUrl.Builder urlBuilder = HttpUrl + .parse(sparqlQueryUrl) + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json"); - Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); + Request request = new Request.Builder() + .url(urlBuilder.build()) + .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 ArrayList<>(); - } - NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class); - List bindings = nearbyResponse.getResults().getBindings(); - List places = new ArrayList<>(); - for (NearbyResultItem item : bindings) { - places.add(Place.from(item)); - } - return places; - } - return new ArrayList<>(); - }); - } - - public Single getCampaigns() { - return Single.fromCallable(() -> { - Request request = new Request.Builder().url(campaignsUrl) - .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; - } - return gson.fromJson(json, CampaignResponseDTO.class); - } - return null; - }); - } - - /** - * Whenever imageInfo is fetched, these common properties can be specified for the API call - * https://www.mediawiki.org/wiki/API:Imageinfo - * - * @param builder - * @return - */ - private HttpUrl.Builder appendMediaProperties(HttpUrl.Builder builder) { - builder.addQueryParameter("prop", "imageinfo") - .addQueryParameter("iiprop", "url|extmetadata") - .addQueryParameter("iiurlwidth", THUMB_SIZE) - .addQueryParameter("iiextmetadatafilter", "DateTime|Categories|GPSLatitude|GPSLongitude|ImageDescription|DateTimeOriginal|Artist|LicenseShortName|LicenseUrl"); - - String language = Locale.getDefault().getLanguage(); - if (!StringUtils.isBlank(language)) { - builder.addQueryParameter("iiextmetadatalanguage", language); + 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 ArrayList<>(); } + NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class); + List bindings = nearbyResponse.getResults().getBindings(); + List places = new ArrayList<>(); + for (NearbyResultItem item : bindings) { + places.add(Place.from(item)); + } + return places; + } + return new ArrayList<>(); + }); + } - return builder; - } + /** + * Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example: + * bridge -> suspended bridge, aqueduct, etc + */ + 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 Observable> getParentQIDs(String qid) throws IOException { + return depictedItemsFrom(sparqlQuery(qid, "/queries/parentclasses_query.rq")); + } + + private Observable> depictedItemsFrom(Request request) { + return Observable.fromCallable(() -> { + try (ResponseBody body = okHttpClient.newCall(request).execute().body()) { + return gson.fromJson(body.string(), SparqlResponse.class).toDepictedItems(); + }catch (Exception e) { + Timber.e(e); + return new ArrayList(); + } + }).doOnError(Timber::e); + } + + @NotNull + 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() + .addQueryParameter("query", query) + .addQueryParameter("format", "json"); + return new Request.Builder() + .url(urlBuilder.build()) + .build(); + } + + public Single getCampaigns() { + return Single.fromCallable(() -> { + Request request = new Request.Builder().url(campaignsUrl) + .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; + } + return gson.fromJson(json, CampaignResponseDTO.class); + } + return null; + }); + } } 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 index cf5aa9c05..7ea44ca68 100644 --- a/app/src/main/java/fr/free/nrw/commons/repository/UploadRemoteDataSource.java +++ b/app/src/main/java/fr/free/nrw/commons/repository/UploadRemoteDataSource.java @@ -20,6 +20,8 @@ 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; @@ -34,16 +36,17 @@ public class UploadRemoteDataSource { 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) { + CategoriesModel categoriesModel, NearbyPlaces nearbyPlaces, DepictModel depictModel) { this.uploadModel = uploadModel; this.uploadController = uploadController; this.categoriesModel = categoriesModel; this.nearbyPlaces = nearbyPlaces; + this.depictModel = depictModel; } /** @@ -80,19 +83,13 @@ public class UploadRemoteDataSource { uploadController.prepareService(); } - /** - * Clean up the UploadController - */ - public void cleanup() { - uploadController.cleanup(); - } - /** * Clean up the selected categories */ - public void clearSelectedCategories(){ + public void cleanUp(){ //This needs further refactoring, this should not be here, right now the structure wont suppoort rhis categoriesModel.cleanUp(); + depictModel.cleanUp(); } /** @@ -167,13 +164,12 @@ public class UploadRemoteDataSource { * * @param uploadableFile * @param place - * @param source * @param similarImageInterface * @return */ public Observable preProcessImage(UploadableFile uploadableFile, Place place, - String source, SimilarImageInterface similarImageInterface) { - return uploadModel.preProcessImage(uploadableFile, place, source, similarImageInterface); + SimilarImageInterface similarImageInterface) { + return uploadModel.preProcessImage(uploadableFile, place, similarImageInterface); } /** @@ -204,7 +200,33 @@ public class UploadRemoteDataSource { } } - public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex) { - uploadModel.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex); - } + /** + * 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 3d08a48bb..71d5ba506 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 @@ -13,6 +13,8 @@ import fr.free.nrw.commons.filepicker.UploadableFile; import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.upload.SimilarImageInterface; import fr.free.nrw.commons.upload.UploadModel.UploadItem; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; + import io.reactivex.Observable; import io.reactivex.Single; @@ -71,7 +73,7 @@ public class UploadRepository { */ public void cleanup() { localDataSource.cleanUp(); - remoteDataSource.clearSelectedCategories(); + remoteDataSource.cleanUp(); } /** @@ -174,14 +176,12 @@ public class UploadRepository { * * @param uploadableFile * @param place - * @param source * @param similarImageInterface * @return */ public Observable preProcessImage(UploadableFile uploadableFile, Place place, - String source, SimilarImageInterface similarImageInterface) { - return remoteDataSource - .preProcessImage(uploadableFile, place, source, similarImageInterface); + SimilarImageInterface similarImageInterface) { + return remoteDataSource.preProcessImage(uploadableFile, place, similarImageInterface); } /** @@ -263,6 +263,31 @@ public class UploadRepository { localDataSource.setSelectedLicense(licenseName); } + public void onDepictItemClicked(DepictedItem depictedItem) { + remoteDataSource.onDepictedItemClicked(depictedItem); + } + + /** + * Fetches and returns the selected depictions for the current upload + * + * @return + */ + + public List getSelectedDepictions() { + return remoteDataSource.getSelectedDepictions(); + } + + /** + * Search all depictions from + * + * @param query + * @return + */ + + public Observable searchAllEntities(String query) { + return remoteDataSource.searchAllEntities(query); + } + /** * Returns nearest place matching the passed latitude and longitude * @param decLatitude @@ -273,7 +298,7 @@ public class UploadRepository { return remoteDataSource.getNearbyPlaces(decLatitude, decLongitude); } - public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex) { - remoteDataSource.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex); - } + public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int 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 1d9931701..14ff8b126 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 @@ -5,29 +5,39 @@ import android.content.Context import android.net.Uri import androidx.exifinterface.media.ExifInterface import fr.free.nrw.commons.R -import fr.free.nrw.commons.caching.CacheController import fr.free.nrw.commons.kvstore.JsonKvStore import fr.free.nrw.commons.mwapi.CategoryApi +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient import fr.free.nrw.commons.settings.Prefs +import fr.free.nrw.commons.upload.structure.depictions.DepictModel import io.reactivex.Observable import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import timber.log.Timber import java.io.File import java.io.IOException +import java.util.* import javax.inject.Inject import javax.inject.Named /** * Processing of the image filePath that is about to be uploaded via ShareActivity is done here */ + +private const val DEFAULT_SUGGESTION_RADIUS_IN_METRES = 100 +private const val MAX_SUGGESTION_RADIUS_IN_METRES = 1000 +private const val RADIUS_STEP_SIZE_IN_METRES = 100 +private const val MIN_NEARBY_RESULTS = 5 + class FileProcessor @Inject constructor( private val context: Context, private val contentResolver: ContentResolver, - private val cacheController: CacheController, private val gpsCategoryModel: GpsCategoryModel, + private val depictsModel: DepictModel, @param:Named("default_preferences") private val defaultKvStore: JsonKvStore, - private val apiCall: CategoryApi + private val apiCall: CategoryApi, + private val okHttpJsonApiClient: OkHttpJsonApiClient ) { private val compositeDisposable = CompositeDisposable() @@ -57,7 +67,7 @@ class FileProcessor @Inject constructor( similarImageInterface ) } else { - useImageCoords(originalImageCoordinates) + prePopulateCategoriesAndDepictionsBy(originalImageCoordinates) } return originalImageCoordinates } @@ -146,7 +156,7 @@ class FileProcessor @Inject constructor( private fun readImageCoordinates(file: File) = try { - ImageCoordinates(contentResolver.openInputStream(Uri.fromFile(file))) + ImageCoordinates(contentResolver.openInputStream(Uri.fromFile(file))!!) } catch (e: IOException) { Timber.e(e) try { @@ -163,29 +173,44 @@ class FileProcessor @Inject constructor( * * @param imageCoordinates */ - fun useImageCoords(imageCoordinates: ImageCoordinates) { + fun prePopulateCategoriesAndDepictionsBy(imageCoordinates: ImageCoordinates) { requireNotNull(imageCoordinates.decimalCoords) - cacheController.setQtPoint(imageCoordinates.decLongitude, imageCoordinates.decLatitude) - val displayCatList = cacheController.findCategory() + compositeDisposable.add( + apiCall.request(imageCoordinates.decimalCoords) + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .subscribe( + { gpsCategoryModel.categoryList = it }, + { + Timber.e(it) + gpsCategoryModel.clear() + } + ) + ) - // If no categories found in cache, call MediaWiki API to match image coords with nearby Commons categories - if (displayCatList.isEmpty()) { - compositeDisposable.add( - apiCall.request(imageCoordinates.decimalCoords) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .subscribe( - { gpsCategoryModel.categoryList = it }, - { - Timber.e(it) - gpsCategoryModel.clear() - } - ) + compositeDisposable.add( + suggestNearbyDepictions(imageCoordinates) + ) + } + + private val radiiProgressionInMetres = + (DEFAULT_SUGGESTION_RADIUS_IN_METRES..MAX_SUGGESTION_RADIUS_IN_METRES step RADIUS_STEP_SIZE_IN_METRES) + + private fun suggestNearbyDepictions(imageCoordinates: ImageCoordinates): Disposable { + return Observable.fromIterable(radiiProgressionInMetres.map { it / 1000.0 }) + .concatMap { + okHttpJsonApiClient.getNearbyPlaces( + imageCoordinates.latLng, + Locale.getDefault().language, + it + ) + } + .subscribeOn(Schedulers.io()) + .filter { it.size >= MIN_NEARBY_RESULTS } + .take(1) + .subscribe( + { depictsModel.nearbyPlaces = it }, + { Timber.e(it) } ) - Timber.d("displayCatList size 0, calling MWAPI %s", displayCatList) - } else { - Timber.d("Cache found, setting categoryList in model to %s", displayCatList) - gpsCategoryModel.categoryList = displayCatList - } } } 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 index 841210453..78eece3b3 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/GpsCategoryModel.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/GpsCategoryModel.java @@ -33,8 +33,4 @@ public class GpsCategoryModel { clear(); categorySet.addAll(categoryList != null ? categoryList : new ArrayList<>()); } - - public void add(String categoryString) { - categorySet.add(categoryString); - } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ImageCoordinates.kt b/app/src/main/java/fr/free/nrw/commons/upload/ImageCoordinates.kt index dc79f1673..2beab1680 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/ImageCoordinates.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/ImageCoordinates.kt @@ -1,6 +1,7 @@ package fr.free.nrw.commons.upload import androidx.exifinterface.media.ExifInterface +import fr.free.nrw.commons.location.LatLng import timber.log.Timber import java.io.IOException import java.io.InputStream @@ -22,7 +23,6 @@ class ImageCoordinates internal constructor(exif: ExifInterface?) { * Construct from a stream. */ internal constructor(stream: InputStream) : this(ExifInterface(stream)) - /** * Construct from the file path of the image. * @param path file path of the image @@ -30,8 +30,6 @@ class ImageCoordinates internal constructor(exif: ExifInterface?) { @Throws(IOException::class) internal constructor(path: String) : this(ExifInterface(path)) - - init { //If image has no EXIF data and user has enabled GPS setting, get user's location //Always return null as a temporary fix for #1599 @@ -55,6 +53,8 @@ class ImageCoordinates internal constructor(exif: ExifInterface?) { } } + val latLng: LatLng? get() = LatLng(decLatitude, decLongitude, -1.0f) + /** * Convert a string to an accurate Degree * 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 d9fd8a034..7bcec4fb0 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 @@ -1,6 +1,6 @@ package fr.free.nrw.commons.upload; -import static fr.free.nrw.commons.utils.ImageUtils.EMPTY_TITLE; +import static fr.free.nrw.commons.utils.ImageUtils.EMPTY_CAPTION; import static fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS; import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK; @@ -11,6 +11,7 @@ import fr.free.nrw.commons.utils.ImageUtils; import fr.free.nrw.commons.utils.ImageUtilsWrapper; import io.reactivex.Single; import io.reactivex.schedulers.Schedulers; +import java.util.List; import javax.inject.Inject; import javax.inject.Singleton; import org.apache.commons.lang3.StringUtils; @@ -39,6 +40,7 @@ public class ImageProcessingService { this.mediaClient = mediaClient; } + /** * Check image quality before upload - checks duplicate image - checks dark image - checks * geolocation for image - check for valid title @@ -88,18 +90,18 @@ public class ImageProcessingService { /** - * Checks item title - * - empty title - * - existing title + * Checks item caption + * - empty caption + * - existing caption * * @param uploadItem * @return */ private Single validateItemTitle(UploadModel.UploadItem uploadItem) { - Timber.d("Checking for image title %s", uploadItem.getTitle()); - Title title = uploadItem.getTitle(); - if (title.isEmpty()) { - return Single.just(EMPTY_TITLE); + Timber.d("Checking for image title %s", uploadItem.getUploadMediaDetails()); + List captions = uploadItem.getUploadMediaDetails(); + if (captions.isEmpty()) { + return Single.just(EMPTY_CAPTION); } return mediaClient.checkPageExistsUsingTitle("File:" + uploadItem.getFileName()) 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 new file mode 100644 index 000000000..6079ddb11 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/PageContentsCreator.java @@ -0,0 +1,109 @@ +package fr.free.nrw.commons.upload; + +import android.content.Context; +import androidx.annotation.NonNull; +import fr.free.nrw.commons.contributions.Contribution; +import fr.free.nrw.commons.filepicker.UploadableFile.DateTimeWithSource; +import fr.free.nrw.commons.settings.Prefs.Licenses; +import fr.free.nrw.commons.utils.ConfigUtils; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import javax.inject.Inject; +import org.apache.commons.lang3.StringUtils; + +class PageContentsCreator { + + //{{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"; + + private final Context context; + + @Inject + public PageContentsCreator(Context context) { + this.context = context; + } + + public String createFrom(Contribution contribution) { + StringBuilder buffer = new StringBuilder(); + buffer + .append("== {{int:filedesc}} ==\n") + .append("{{Information\n") + .append("|description=").append(contribution.getDescription()).append("\n") + .append("|source=").append("{{own}}\n") + .append("|author=[[User:").append(contribution.getCreator()).append("|") + .append(contribution.getCreator()).append("]]\n"); + + String templatizedCreatedDate = getTemplatizedCreatedDate( + contribution.getDateCreated(), contribution.getDateCreatedSource()); + 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 + final String decimalCoords = contribution.getDecimalCoords(); + if (decimalCoords != null) { + buffer.append("{{Location|").append(decimalCoords).append("}}").append("\n"); + } + + buffer.append("== {{int:license-header}} ==\n") + .append(licenseTemplateFor(contribution.getLicense())).append("\n\n") + .append("{{Uploaded from Mobile|platform=Android|version=") + .append(ConfigUtils.getVersionNameWithSha(context)).append("}}\n"); + 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("]]"); + } + } else { + buffer.append("{{subst:unc}}"); + } + return buffer.toString(); + } + + /** + * Returns upload date in either TEMPLATE_DATE_ACC_TO_EXIF or TEMPLATE_DATA_OTHER_SOURCE + * + * @param dateCreated + * @param dateCreatedSource + * @return + */ + private String getTemplatizedCreatedDate(Date dateCreated, String dateCreatedSource) { + if (dateCreated != null) { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + return String.format(Locale.ENGLISH, + isExif(dateCreatedSource) ? TEMPLATE_DATE_ACC_TO_EXIF : TEMPLATE_DATA_OTHER_SOURCE, + dateFormat.format(dateCreated) + ) + "\n"; + } + return ""; + } + + private boolean isExif(String dateCreatedSource) { + return DateTimeWithSource.EXIF_SOURCE.equals(dateCreatedSource); + } + + @NonNull + private String licenseTemplateFor(String license) { + switch (license) { + case Licenses.CC_BY_3: + return "{{self|cc-by-3.0}}"; + case Licenses.CC_BY_4: + return "{{self|cc-by-4.0}}"; + case Licenses.CC_BY_SA_3: + return "{{self|cc-by-sa-3.0}}"; + case Licenses.CC_BY_SA_4: + return "{{self|cc-by-sa-4.0}}"; + case Licenses.CC0: + return "{{self|cc-zero}}"; + } + + throw new RuntimeException("Unrecognized license value: " + license); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/Title.kt b/app/src/main/java/fr/free/nrw/commons/upload/Title.kt deleted file mode 100644 index 2dc9b319b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/Title.kt +++ /dev/null @@ -1,29 +0,0 @@ -package fr.free.nrw.commons.upload - -import android.text.TextUtils - -class Title { - private var titleText: String? = null - var isSet = false - override fun toString(): String { - if (titleText == null) { - return "" - } else { - return titleText!! - } - } - - fun setTitleText(titleText: String?) { - this.titleText=titleText?.trim() - if (!TextUtils.isEmpty(titleText)) { - isSet = true - } - } - - val isEmpty: Boolean - get() = titleText == null || titleText!!.isEmpty() - - fun getTitleText(): String? { - return titleText - } -} 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 cff677e32..535dbb25f 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 @@ -1,5 +1,9 @@ package fr.free.nrw.commons.upload; +import static fr.free.nrw.commons.contributions.ContributionController.ACTION_INTERNAL_UPLOADS; +import static fr.free.nrw.commons.upload.UploadService.EXTRA_FILES; +import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; + import android.Manifest; import android.annotation.SuppressLint; import android.app.ProgressDialog; @@ -10,7 +14,6 @@ import android.widget.ImageButton; import android.widget.LinearLayout; import android.widget.RelativeLayout; import android.widget.TextView; - import androidx.appcompat.app.AlertDialog; import androidx.cardview.widget.CardView; import androidx.fragment.app.Fragment; @@ -20,14 +23,6 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Named; - import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; @@ -36,7 +31,6 @@ 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.Contribution; import fr.free.nrw.commons.contributions.ContributionController; import fr.free.nrw.commons.filepicker.UploadableFile; import fr.free.nrw.commons.kvstore.JsonKvStore; @@ -44,6 +38,7 @@ import fr.free.nrw.commons.mwapi.UserClient; import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.theme.BaseActivity; import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment; +import fr.free.nrw.commons.upload.depicts.DepictsFragment; import fr.free.nrw.commons.upload.license.MediaLicenseFragment; import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment; import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback; @@ -52,12 +47,13 @@ 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.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; import timber.log.Timber; -import static fr.free.nrw.commons.contributions.ContributionController.ACTION_INTERNAL_UPLOADS; -import static fr.free.nrw.commons.upload.UploadService.EXTRA_FILES; -import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; - public class UploadActivity extends BaseActivity implements UploadContract.View, UploadBaseFragment.Callback { @Inject ContributionController contributionController; @@ -102,11 +98,10 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, private UploadImageAdapter uploadImagesAdapter; private List fragments; private UploadCategoriesFragment uploadCategoriesFragment; + private DepictsFragment depictsFragment; private MediaLicenseFragment mediaLicenseFragment; private ThumbnailsAdapter thumbnailsAdapter; - - private String source; private Place place; private List uploadableFiles = Collections.emptyList(); private int currentSelectedPosition = 0; @@ -288,6 +283,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); @@ -321,7 +317,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, fragments = new ArrayList<>(); for (UploadableFile uploadableFile : uploadableFiles) { UploadMediaDetailFragment uploadMediaDetailFragment = new UploadMediaDetailFragment(); - uploadMediaDetailFragment.setImageTobeUploaded(uploadableFile, source, place); + uploadMediaDetailFragment.setImageTobeUploaded(uploadableFile, place); uploadMediaDetailFragment.setCallback(new UploadMediaDetailFragmentCallback() { @Override public void deletePictureAtIndex(int index) { @@ -359,10 +355,13 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, uploadCategoriesFragment = new UploadCategoriesFragment(); uploadCategoriesFragment.setCallback(this); + depictsFragment = new DepictsFragment(); + depictsFragment.setCallback(this); + mediaLicenseFragment = new MediaLicenseFragment(); mediaLicenseFragment.setCallback(this); - + fragments.add(depictsFragment); fragments.add(uploadCategoriesFragment); fragments.add(mediaLicenseFragment); @@ -378,16 +377,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View, private void receiveInternalSharedItems() { Intent intent = getIntent(); - if (intent.hasExtra(UploadService.EXTRA_SOURCE)) { - source = intent.getStringExtra(UploadService.EXTRA_SOURCE); - } else { - source = Contribution.SOURCE_EXTERNAL; - } - - Timber.d("Received intent %s with action %s and from source %s", - intent.toString(), - intent.getAction(), - source); + Timber.d("Received intent %s with action %s", intent.toString(), intent.getAction()); uploadableFiles = intent.getParcelableArrayListExtra(EXTRA_FILES); Timber.i("Received multiple upload %s", uploadableFiles.size()); 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 936e27305..39c42cc1d 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 @@ -1,35 +1,36 @@ package fr.free.nrw.commons.upload; +import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF; + import android.content.Context; import android.net.Uri; - -import org.wikipedia.csrf.CsrfTokenClient; - -import java.io.File; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - +import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.upload.UploadService.NotificationUpdateProgressListener; import io.reactivex.Observable; +import java.io.File; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; import okhttp3.MediaType; import okhttp3.MultipartBody; import okhttp3.RequestBody; - -import static fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF; +import org.wikipedia.csrf.CsrfTokenClient; @Singleton public class UploadClient { private final UploadInterface uploadInterface; private final CsrfTokenClient csrfTokenClient; + private final PageContentsCreator pageContentsCreator; @Inject - public UploadClient(UploadInterface uploadInterface, @Named(NAMED_COMMONS_CSRF) CsrfTokenClient csrfTokenClient) { + public UploadClient(UploadInterface uploadInterface, + @Named(NAMED_COMMONS_CSRF) CsrfTokenClient csrfTokenClient, + PageContentsCreator pageContentsCreator) { this.uploadInterface = uploadInterface; this.csrfTokenClient = csrfTokenClient; + this.pageContentsCreator = pageContentsCreator; } Observable uploadFileToStash(Context context, String filename, File file, @@ -61,8 +62,8 @@ public class UploadClient { try { return uploadInterface .uploadFileFromStash(csrfTokenClient.getTokenBlocking(), - contribution.getPageContents(context), - contribution.getEditSummary(), + pageContentsCreator.createFrom(contribution), + CommonsApplication.DEFAULT_EDIT_SUMMARY, uniqueFileName, fileKey).map(uploadResponse -> uploadResponse.getUpload()); } catch (Throwable 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 1a602a001..e28978ff8 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,17 +13,8 @@ import android.net.Uri; import android.os.IBinder; import android.provider.MediaStore; import android.text.TextUtils; - -import java.io.BufferedInputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.util.Date; - -import javax.inject.Inject; -import javax.inject.Singleton; - 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; import fr.free.nrw.commons.contributions.Contribution; @@ -34,23 +25,26 @@ import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; +import javax.inject.Inject; +import javax.inject.Singleton; import timber.log.Timber; @Singleton public class UploadController { private UploadService uploadService; - private SessionManager sessionManager; - private Context context; - private JsonKvStore store; - - public interface ContributionUploadProgress { - void onUploadStarted(Contribution contribution); - } + private final SessionManager sessionManager; + private final Context context; + private final JsonKvStore store; @Inject - public UploadController(SessionManager sessionManager, - Context context, - JsonKvStore store) { + public UploadController(final SessionManager sessionManager, + final Context context, + final JsonKvStore store) { this.sessionManager = sessionManager; this.context = context; this.store = store; @@ -59,13 +53,13 @@ public class UploadController { private boolean isUploadServiceConnected; public ServiceConnection uploadServiceConnection = new ServiceConnection() { @Override - public void onServiceConnected(ComponentName componentName, IBinder binder) { + public void onServiceConnected(final ComponentName componentName, final IBinder binder) { uploadService = (UploadService) ((HandlerService.HandlerServiceLocalBinder) binder).getService(); isUploadServiceConnected = true; } @Override - public void onServiceDisconnected(ComponentName componentName) { + public void onServiceDisconnected(final ComponentName componentName) { // this should never happen isUploadServiceConnected = false; Timber.e(new RuntimeException("UploadService died but the rest of the process did not!")); @@ -76,7 +70,7 @@ public class UploadController { * Prepares the upload service. */ public void prepareService() { - Intent uploadServiceIntent = new Intent(context, UploadService.class); + final Intent uploadServiceIntent = new Intent(context, UploadService.class); uploadServiceIntent.setAction(UploadService.ACTION_START_SERVICE); context.startService(uploadServiceIntent); context.bindService(uploadServiceIntent, uploadServiceConnection, Context.BIND_AUTO_CREATE); @@ -96,28 +90,18 @@ public class UploadController { * * @param contribution the contribution object */ - public void startUpload(Contribution contribution) { - startUpload(contribution, c -> {}); - } - - /** - * Starts a new upload task. - * - * @param contribution the contribution object - * @param onComplete the progress tracker - */ @SuppressLint("StaticFieldLeak") - private void startUpload(final Contribution contribution, final ContributionUploadProgress onComplete) { + public void startUpload(final Contribution contribution) { //Set creator, desc, and license // If author name is enabled and set, use it if (store.getBoolean("useAuthorName", false)) { - String authorName = store.getString("authorName", ""); + final String authorName = store.getString("authorName", ""); contribution.setCreator(authorName); } if (TextUtils.isEmpty(contribution.getCreator())) { - Account currentAccount = sessionManager.getCurrentAccount(); + final Account currentAccount = sessionManager.getCurrentAccount(); if (currentAccount == null) { Timber.d("Current account is null"); ViewUtil.showLongToast(context, context.getString(R.string.user_not_logged_in)); @@ -131,23 +115,23 @@ public class UploadController { contribution.setDescription(""); } - String license = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3); + final String license = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3); contribution.setLicense(license); - uploadTask(contribution, onComplete); + uploadTask(contribution); } /** * Initiates the upload task * @param contribution - * @param onComplete * @return */ - private Disposable uploadTask(Contribution contribution, ContributionUploadProgress onComplete) { - return Single.fromCallable(() -> makeUpload(contribution)) + private Disposable uploadTask(final Contribution contribution) { + return Single.just(contribution) + .map(this::buildUpload) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(finalContribution -> onUploadCompleted(finalContribution, onComplete)); + .subscribe(this::upload); } /** @@ -155,71 +139,76 @@ public class UploadController { * @param contribution * @return */ - private Contribution makeUpload(Contribution contribution) { - long length; - ContentResolver contentResolver = context.getContentResolver(); + private Contribution buildUpload(final Contribution contribution) { + final ContentResolver contentResolver = context.getContentResolver(); + + contribution.setDataLength(resolveDataLength(contentResolver, contribution)); + + final String mimeType = resolveMimeType(contentResolver, contribution); + + if (mimeType != null) { + Timber.d("MimeType is: %s", mimeType); + contribution.setMimeType(mimeType); + if(mimeType.startsWith("image/") && contribution.getDateCreated() == null){ + contribution.setDateCreated(resolveDateTakenOrNow(contentResolver, contribution)); + } + } + + return contribution; + } + + private String resolveMimeType(final ContentResolver contentResolver, final Contribution contribution) { + final String mimeType = contribution.getMimeType(); + if (mimeType == null || TextUtils.isEmpty(mimeType) || mimeType.endsWith("*")) { + return contentResolver.getType(contribution.getLocalUri()); + } + return mimeType; + } + + private long resolveDataLength(final ContentResolver contentResolver, final Media contribution) { try { if (contribution.getDataLength() <= 0) { Timber.d("UploadController/doInBackground, contribution.getLocalUri():%s", contribution.getLocalUri()); - AssetFileDescriptor assetFileDescriptor = contentResolver - .openAssetFileDescriptor(Uri.fromFile(new File(contribution.getLocalUri().getPath())), "r"); + final AssetFileDescriptor assetFileDescriptor = contentResolver + .openAssetFileDescriptor(Uri.fromFile(new File(contribution.getLocalUri().getPath())), "r"); if (assetFileDescriptor != null) { - length = assetFileDescriptor.getLength(); - if (length == -1) { - // Let us find out the long way! - length = countBytes(contentResolver - .openInputStream(contribution.getLocalUri())); - } - contribution.setDataLength(length); + final long length = assetFileDescriptor.getLength(); + return length != -1 ? length + : countBytes(contentResolver.openInputStream(contribution.getLocalUri())); } } - } catch (IOException | NullPointerException | SecurityException e) { + } catch (final IOException | NullPointerException | SecurityException e) { Timber.e(e, "Exception occurred while uploading image"); } + return contribution.getDataLength(); + } - String mimeType = (String) contribution.getTag("mimeType"); - boolean imagePrefix = false; - - if (mimeType == null || TextUtils.isEmpty(mimeType) || mimeType.endsWith("*")) { - mimeType = contentResolver.getType(contribution.getLocalUri()); - } - - if (mimeType != null) { - contribution.setTag("mimeType", mimeType); - imagePrefix = mimeType.startsWith("image/"); - Timber.d("MimeType is: %s", mimeType); - } - - if (imagePrefix && contribution.getDateCreated() == null) { - Timber.d("local uri %s", contribution.getLocalUri()); - Cursor cursor = contentResolver.query(contribution.getLocalUri(), - new String[]{MediaStore.Images.ImageColumns.DATE_TAKEN}, null, null, null); + 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) { cursor.moveToFirst(); - Date dateCreated = new Date(cursor.getLong(0)); - Date epochStart = new Date(0); - if (dateCreated.equals(epochStart) || dateCreated.before(epochStart)) { - // If date is incorrect (1st second of unix time) then set it to the current date - dateCreated = new Date(); + final Date dateCreated = new Date(cursor.getLong(0)); + if (dateCreated.after(new Date(0))) { + return dateCreated; } - contribution.setDateCreated(dateCreated); - cursor.close(); - } else { - contribution.setDateCreated(new Date()); } + return new Date(); } - return 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); } /** * When the contribution object is completely formed, the item is queued to the upload service * @param contribution - * @param onComplete */ - private void onUploadCompleted(Contribution contribution, ContributionUploadProgress onComplete) { + 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(UploadService.ACTION_UPLOAD_FILE, contribution); - onComplete.onUploadStarted(contribution); } @@ -230,9 +219,9 @@ public class UploadController { * @return the number of bytes in {@code stream} * @throws IOException if an I/O error occurs */ - private long countBytes(InputStream stream) throws IOException { + private long countBytes(final InputStream stream) throws IOException { long count = 0; - BufferedInputStream bis = new BufferedInputStream(stream); + final BufferedInputStream bis = new BufferedInputStream(stream); while (bis.read() != -1) { count++; } 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 000000000..ca871ad41 --- /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 000000000..cb5b8986c --- /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 new file mode 100644 index 000000000..2617ff903 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetail.kt @@ -0,0 +1,62 @@ +package fr.free.nrw.commons.upload + +import fr.free.nrw.commons.nearby.Place +import java.util.* + +/** + * Holds a description of an item being uploaded by [UploadActivity] + */ +data class UploadMediaDetail constructor( + /** + * @return The language code ie. "en" or "fr" + */ + /** + * @param languageCode The language code ie. "en" or "fr" + */ + var languageCode: String? = null, + var descriptionText: String = "", + var captionText: String = "" +) { + constructor(place: Place) : this( + Locale.getDefault().language, + place.longDescription, + place.name + ) + /** + * @return the index of the language selected in a spinner with [SpinnerLanguagesAdapter] + */ + /** + * @param selectedLanguageIndex the index of the language selected in a spinner with [SpinnerLanguagesAdapter] + */ + var selectedLanguageIndex: Int = -1 + /** + * returns if the description was added manually (by the user, or we have added it programaticallly) + * @return + */ + /** + * sets to true if the description was manually added by the user + * @param manuallyAdded + */ + 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/DescriptionsAdapter.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java similarity index 67% rename from app/src/main/java/fr/free/nrw/commons/upload/DescriptionsAdapter.java rename to app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java index 0ca9a75ed..1a95c0e94 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/DescriptionsAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java @@ -10,33 +10,31 @@ import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.AdapterView.OnItemSelectedListener; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatEditText; import androidx.appcompat.widget.AppCompatSpinner; import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; - import butterknife.BindView; import butterknife.ButterKnife; import fr.free.nrw.commons.R; import fr.free.nrw.commons.utils.AbstractTextWatcher; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; import timber.log.Timber; -public class DescriptionsAdapter extends RecyclerView.Adapter { +public class UploadMediaDetailAdapter extends RecyclerView.Adapter { - private List descriptions; + private List uploadMediaDetails; private Callback callback; + private EventListener eventListener; private HashMap selectedLanguages; - private String savedLanguageValue; + private final String savedLanguageValue; - public DescriptionsAdapter(String savedLanguageValue) { - descriptions = new ArrayList<>(); + public UploadMediaDetailAdapter(String savedLanguageValue) { + uploadMediaDetails = new ArrayList<>(); selectedLanguages = new HashMap<>(); this.savedLanguageValue = savedLanguageValue; } @@ -45,8 +43,12 @@ public class DescriptionsAdapter extends RecyclerView.Adapter descriptions) { - this.descriptions = descriptions; + public void setEventListener(EventListener eventListener) { + this.eventListener = eventListener; + } + + public void setItems(List uploadMediaDetails) { + this.uploadMediaDetails = uploadMediaDetails; selectedLanguages = new HashMap<>(); notifyDataSetChanged(); } @@ -60,12 +62,12 @@ public class DescriptionsAdapter extends RecyclerView.Adapter getDescriptions() { - return descriptions; + public List getUploadMediaDetails() { + return uploadMediaDetails; } - public void addDescription(Description description) { - this.descriptions.add(description); - notifyItemInserted(descriptions.size()); + public void addDescription(UploadMediaDetail uploadMediaDetail) { + this.uploadMediaDetails.add(uploadMediaDetail); + notifyItemInserted(uploadMediaDetails.size()); } public class ViewHolder extends RecyclerView.ViewHolder { @@ -91,21 +93,43 @@ public class DescriptionsAdapter extends RecyclerView.Adapter { + if (position == 0) { + eventListener.onPrimaryCaptionTextChange(value.length() != 0); + } + })); + if (position == 0) { + 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) -> { @@ -122,18 +146,23 @@ public class DescriptionsAdapter extends RecyclerView.Adapter uploadMediaDetails.get(position).setCaptionText(captionText))); + initLanguageSpinner(position, uploadMediaDetail); + descItemEditText.addTextChangedListener(new AbstractTextWatcher( - descriptionText -> descriptions.get(position).setDescriptionText(descriptionText))); - initLanguageSpinner(position, description); + descriptionText -> uploadMediaDetails.get(position).setDescriptionText(descriptionText))); + initLanguageSpinner(position, uploadMediaDetail); //If the description was manually added by the user, it deserves focus, if not, let the user decide - if (description.isManuallyAdded()) { - descItemEditText.requestFocus(); + if (uploadMediaDetail.isManuallyAdded()) { + captionItemEditText.requestFocus(); } else { - descItemEditText.clearFocus(); + captionItemEditText.clearFocus(); } } @@ -142,7 +171,7 @@ public class DescriptionsAdapter extends RecyclerView.Adapter licensesByName; - private List items = new ArrayList<>(); - private CompositeDisposable compositeDisposable = new CompositeDisposable(); + private final List items = new ArrayList<>(); + private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - private SessionManager sessionManager; - private FileProcessor fileProcessor; + private final SessionManager sessionManager; + private final FileProcessor fileProcessor; private final ImageProcessingService imageProcessingService; - private List selectedCategories; + private List selectedCategories = new ArrayList<>(); + private List selectedDepictions = new ArrayList<>(); @Inject - UploadModel(@Named("licenses") List licenses, - @Named("default_preferences") JsonKvStore store, - @Named("licenses_by_name") Map licensesByName, - Context context, - SessionManager sessionManager, - FileProcessor fileProcessor, - ImageProcessingService imageProcessingService) { + UploadModel(@Named("licenses") final List licenses, + @Named("default_preferences") final JsonKvStore store, + @Named("licenses_by_name") final Map licensesByName, + final Context context, + final SessionManager sessionManager, + final FileProcessor fileProcessor, + final ImageProcessingService imageProcessingService) { this.licenses = licenses; this.store = store; this.license = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3); @@ -68,39 +69,33 @@ public class UploadModel { public void cleanUp() { compositeDisposable.clear(); fileProcessor.cleanup(); - this.items.clear(); - if (this.selectedCategories != null) { - this.selectedCategories.clear(); - } + items.clear(); + selectedCategories.clear(); + selectedDepictions.clear(); } public void setSelectedCategories(List selectedCategories) { - if (null == selectedCategories) { - selectedCategories = new ArrayList<>(); - } this.selectedCategories = selectedCategories; } - /** * pre process a one item at a time */ - public Observable preProcessImage(UploadableFile uploadableFile, - Place place, - String source, - SimilarImageInterface similarImageInterface) { - return Observable.just(getUploadItem(uploadableFile, place, source, similarImageInterface)); + public Observable preProcessImage(final UploadableFile uploadableFile, + final Place place, + final SimilarImageInterface similarImageInterface) { + return Observable.just( + createAndAddUploadItem(uploadableFile, place, similarImageInterface)); } - public Single getImageQuality(UploadItem uploadItem) { + public Single getImageQuality(final UploadItem uploadItem) { return imageProcessingService.validateImage(uploadItem); } - private UploadItem getUploadItem(UploadableFile uploadableFile, - Place place, - String source, - SimilarImageInterface similarImageInterface) { - UploadableFile.DateTimeWithSource dateTimeWithSource = uploadableFile + private UploadItem createAndAddUploadItem(final UploadableFile uploadableFile, + final Place place, + final SimilarImageInterface similarImageInterface) { + final UploadableFile.DateTimeWithSource dateTimeWithSource = uploadableFile .getFileCreatedDate(context); long fileCreatedDate = -1; String createdTimestampSource = ""; @@ -109,19 +104,14 @@ public class UploadModel { createdTimestampSource = dateTimeWithSource.getSource(); } Timber.d("File created date is %d", fileCreatedDate); - ImageCoordinates imageCoordinates = fileProcessor + final ImageCoordinates imageCoordinates = fileProcessor .processFileCoordinates(similarImageInterface, uploadableFile.getFilePath()); - UploadItem uploadItem = new UploadItem(uploadableFile.getContentUri(), + final UploadItem uploadItem = new UploadItem(uploadableFile.getContentUri(), Uri.parse(uploadableFile.getFilePath()), - uploadableFile.getMimeType(context), source, imageCoordinates, place, fileCreatedDate, + uploadableFile.getMimeType(context), imageCoordinates, place, fileCreatedDate, createdTimestampSource); if (place != null) { - uploadItem.title.setTitleText(place.name); - if(uploadItem.descriptions.isEmpty()) { - uploadItem.descriptions.add(new Description()); - } - uploadItem.descriptions.get(0).setDescriptionText(place.getLongDescription()); - uploadItem.descriptions.get(0).setLanguageCode("en"); + uploadItem.getUploadMediaDetails().set(0, new UploadMediaDetail(place)); } if (!items.contains(uploadItem)) { items.add(uploadItem); @@ -145,7 +135,7 @@ public class UploadModel { return license; } - public void setSelectedLicense(String licenseName) { + public void setSelectedLicense(final String licenseName) { this.license = licensesByName.get(licenseName); store.putString(Prefs.DEFAULT_LICENSE, license); } @@ -153,26 +143,8 @@ public class UploadModel { public Observable buildContributions() { return Observable.fromIterable(items).map(item -> { - Contribution contribution = new Contribution(item.mediaUri, null, - item.getFileName(), - Description.formatList(item.descriptions), -1, - null, null, sessionManager.getAuthorName(), - CommonsApplication.DEFAULT_EDIT_SUMMARY, item.gpsCoords.getDecimalCoords()); - if (item.place != null) { - contribution.setWikiDataEntityId(item.place.getWikiDataEntityId()); - contribution.setWikiItemName(item.place.getName()); - // If item already has an image, we need to know it. We don't want to override existing image later - contribution.setP18Value(item.place.pic); - } - if (null == selectedCategories) {//Just a fail safe, this should never be null - selectedCategories = new ArrayList<>(); - } - contribution.setCategories(selectedCategories); - contribution.setTag("mimeType", item.mimeType); - contribution.setSource(item.source); - contribution.setContentProviderUri(item.mediaUri); - contribution.setDateUploaded(new Date()); - + final Contribution contribution = new Contribution( + item, sessionManager, newListOf(selectedDepictions), newListOf(selectedCategories)); Timber.d("Created timestamp while building contribution is %s, %s", item.getCreatedTimestamp(), new Date(item.getCreatedTimestamp())); @@ -185,8 +157,8 @@ public class UploadModel { }); } - public void deletePicture(String filePath) { - Iterator iterator = items.iterator(); + public void deletePicture(final String filePath) { + final Iterator iterator = items.iterator(); while (iterator.hasNext()) { if (iterator.next().mediaUri.toString().contains(filePath)) { iterator.remove(); @@ -202,51 +174,58 @@ public class UploadModel { return items; } - public void updateUploadItem(int index, UploadItem uploadItem) { - UploadItem uploadItem1 = items.get(index); - uploadItem1.setDescriptions(uploadItem.descriptions); - uploadItem1.setTitle(uploadItem.title); + public void updateUploadItem(final int index, final UploadItem uploadItem) { + final UploadItem uploadItem1 = items.get(index); + uploadItem1.setMediaDetails(uploadItem.uploadMediaDetails); } - public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex) { - fileProcessor.useImageCoords(imageCoordinates); + public void onDepictItemClicked(DepictedItem depictedItem) { + if (depictedItem.isSelected()) { + selectedDepictions.add(depictedItem); + } else { + selectedDepictions.remove(depictedItem); + } + } + + @NotNull + private List newListOf(final List items) { + return items != null ? new ArrayList<>(items) : new ArrayList<>(); + } + + public void useSimilarPictureCoordinates(final ImageCoordinates imageCoordinates, final int uploadItemIndex) { + fileProcessor.prePopulateCategoriesAndDepictionsBy(imageCoordinates); items.get(uploadItemIndex).setGpsCoords(imageCoordinates); } + public List getSelectedDepictions() { + return selectedDepictions; + } + @SuppressWarnings("WeakerAccess") public static class UploadItem { private final Uri originalContentUri; private final Uri mediaUri; private final String mimeType; - private final String source; private ImageCoordinates gpsCoords; - - public void setGpsCoords(ImageCoordinates gpsCoords) { - this.gpsCoords = gpsCoords; - } - - private Title title; - private List descriptions; - private Place place; - private long createdTimestamp; - private String createdTimestampSource; - private BehaviorSubject imageQuality; - + private List uploadMediaDetails; + private final Place place; + private final long createdTimestamp; + private final String createdTimestampSource; + private final BehaviorSubject imageQuality; @SuppressLint("CheckResult") - UploadItem(Uri originalContentUri, - Uri mediaUri, String mimeType, String source, ImageCoordinates gpsCoords, - Place place, - long createdTimestamp, - String createdTimestampSource) { + 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; - title = new Title(); - descriptions = new ArrayList<>(); + uploadMediaDetails = new ArrayList<>(Arrays.asList(new UploadMediaDetail())); this.place = place; this.mediaUri = mediaUri; this.mimeType = mimeType; - this.source = source; this.gpsCoords = gpsCoords; this.createdTimestamp = createdTimestamp; imageQuality = BehaviorSubject.createDefault(ImageUtils.IMAGE_WAIT); @@ -256,26 +235,18 @@ public class UploadModel { return createdTimestampSource; } - public String getSource() { - return source; - } - public ImageCoordinates getGpsCoords() { return gpsCoords; } - public List getDescriptions() { - return descriptions; + public List getUploadMediaDetails() { + return uploadMediaDetails; } public long getCreatedTimestamp() { return createdTimestamp; } - public Title getTitle() { - return title; - } - public Uri getMediaUri() { return mediaUri; } @@ -284,29 +255,16 @@ public class UploadModel { return this.imageQuality.getValue(); } - public void setImageQuality(int imageQuality) { + public void setImageQuality(final int imageQuality) { this.imageQuality.onNext(imageQuality); } - public String getFileExt() { - return MimeTypeMapWrapper.getExtensionFromMimeType(mimeType); - } - - public String getFileName() { - return title - != null ? Utils.fixExtension(title.toString(), getFileExt()) : null; - } - public Place getPlace() { return place; } - public void setTitle(Title title) { - this.title = title; - } - - public void setDescriptions(List descriptions) { - this.descriptions = descriptions; + public void setMediaDetails(final List uploadMediaDetails) { + this.uploadMediaDetails = uploadMediaDetails; } public Uri getContentUri() { @@ -314,7 +272,7 @@ public class UploadModel { } @Override - public boolean equals(@Nullable Object obj) { + public boolean equals(@Nullable final Object obj) { if (!(obj instanceof UploadItem)) { return false; } @@ -326,6 +284,21 @@ public class UploadModel { 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/UploadModule.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadModule.java index 9e4f572a7..746bda0d1 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadModule.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadModule.java @@ -4,6 +4,8 @@ import dagger.Binds; import dagger.Module; import fr.free.nrw.commons.upload.categories.CategoriesContract; import fr.free.nrw.commons.upload.categories.CategoriesPresenter; +import fr.free.nrw.commons.upload.depicts.DepictsContract; +import fr.free.nrw.commons.upload.depicts.DepictsPresenter; import fr.free.nrw.commons.upload.license.MediaLicenseContract; import fr.free.nrw.commons.upload.license.MediaLicensePresenter; import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract; @@ -33,4 +35,9 @@ public abstract class UploadModule { UploadMediaPresenter presenter); + @Binds + public abstract DepictsContract.UserActionListener bindsDepictsPresenter( + DepictsPresenter + presenter + ); } 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 5f1829550..5e2dec54a 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 @@ -3,6 +3,7 @@ package fr.free.nrw.commons.upload; import android.annotation.SuppressLint; import java.lang.reflect.Proxy; +import java.util.ArrayList; import java.util.List; import javax.inject.Inject; @@ -89,7 +90,6 @@ public class UploadPresenter implements UploadContract.UserActionListener { if (index == uploadableFiles.size() - 1) {//If the next fragment to be shown is not one of the MediaDetailsFragment, lets hide the top card view.showHideTopCard(false); } - //Ask the repository to delete the picture repository.deletePicture(uploadableFiles.get(index).getFilePath()); if (uploadableFiles.size() == 1) { view.showMessage(R.string.upload_cancelled); @@ -120,4 +120,4 @@ public class UploadPresenter implements UploadContract.UserActionListener { repository.cleanup(); } -} \ No newline at end of file +} 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 ab6940193..d3ec0cc64 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 @@ -2,4 +2,16 @@ package fr.free.nrw.commons.upload import org.wikipedia.gallery.ImageInfo -class UploadResult(val result: String, val filekey: String, val filename: String, val sessionkey: String, val imageinfo: ImageInfo) \ No newline at end of file +private const val RESULT_SUCCESS = "Success" + +data class UploadResult( + val result: String, + val filekey: String, + val filename: String, + val sessionkey: String, + val imageinfo: ImageInfo +) { + fun isSuccessful(): Boolean = result == RESULT_SUCCESS + + fun createCanonicalFileName() = "File:$filename" +} 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 2553a1796..2dd9b6f1d 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.content.Intent; 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.utils.CommonsDateUtil; 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 @@ public class UploadService extends HandlerService { @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 class UploadService extends HandlerService { .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 @@ public class UploadService extends HandlerService { 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/WikiBaseInterface.java b/app/src/main/java/fr/free/nrw/commons/upload/WikiBaseInterface.java new file mode 100644 index 000000000..79ba57bc7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/WikiBaseInterface.java @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.upload; + +import static org.wikipedia.dataclient.Service.MW_API_PREFIX; + +import androidx.annotation.NonNull; +import io.reactivex.Observable; +import org.wikipedia.dataclient.mwapi.MwPostResponse; +import org.wikipedia.dataclient.mwapi.MwQueryResponse; +import retrofit2.http.Field; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.GET; +import retrofit2.http.Headers; +import retrofit2.http.POST; +import retrofit2.http.Query; + +/** + * Retrofit calls for managing responses network calls of entity ids required for uploading depictions + */ + +public interface WikiBaseInterface { + + @Headers("Cache-Control: no-cache") + @FormUrlEncoded + @POST(MW_API_PREFIX + "action=wbeditentity") + Observable postEditEntity(@NonNull @Field("id") String fileEntityId, + @NonNull @Field("token") String editToken, + @NonNull @Field("data") String data); + + @GET(MW_API_PREFIX + "action=query&prop=info") + Observable getFileEntityId(@Query("titles") String fileName); + + /** + * Upload Captions for the image when upload is successful + * + * @param fileEntityId enityId for the uploaded file + * @param editToken editToken for the file + * @param captionValue value of the caption to be uploaded + */ + @FormUrlEncoded + @POST(MW_API_PREFIX + "action=wbsetlabel") + Observable addLabelstoWikidata(@Field("id") String fileEntityId, + @Field("token") String editToken, + @Field("language") String language, + @Field("value") String captionValue); + +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/WikidataItem.kt b/app/src/main/java/fr/free/nrw/commons/upload/WikidataItem.kt new file mode 100644 index 000000000..5c8a639df --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/WikidataItem.kt @@ -0,0 +1,6 @@ +package fr.free.nrw.commons.upload + +interface WikidataItem { +val id:String +val name:String +} 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 new file mode 100644 index 000000000..4f11d0000 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/WikidataPlace.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.upload + +import android.os.Parcelable +import fr.free.nrw.commons.nearby.Place +import kotlinx.android.parcel.Parcelize + +@Parcelize +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() }) + + companion object { + @JvmStatic + fun from(place: Place?): WikidataPlace? { + return place?.let { WikidataPlace(it) } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.java b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.java index 0c73cba40..5056e2cfd 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesContract.java @@ -10,7 +10,7 @@ import fr.free.nrw.commons.category.CategoryItem; */ public interface CategoriesContract { - public interface View { + interface View { void showProgress(boolean shouldShow); @@ -20,16 +20,13 @@ public interface CategoriesContract { void setCategories(List categories); - void addCategory(CategoryItem category); - void goToNextScreen(); void showNoCategorySelected(); - void setSelectedCategories(List selectedCategories); } - public interface UserActionListener extends BasePresenter { + interface UserActionListener extends BasePresenter { void searchForCategories(String query); 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 index 7c5f253e7..aaa52a85c 100644 --- 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 @@ -1,15 +1,9 @@ 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 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 fr.free.nrw.commons.R; import fr.free.nrw.commons.category.CategoryItem; import fr.free.nrw.commons.repository.UploadRepository; @@ -18,11 +12,14 @@ 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; -import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; -import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD; - /** * The presenter class for UploadCategoriesFragment */ @@ -86,9 +83,10 @@ public class CategoriesPresenter implements CategoriesContract.UserActionListene ) .filter(categoryItem -> !repository.containsYear(categoryItem.getName())) .distinct(); - if(!TextUtils.isEmpty(query)) { - distinctCategoriesObservable=distinctCategoriesObservable.sorted(repository.sortBySimilarity(query)); - } + + if(!TextUtils.isEmpty(query)) { + distinctCategoriesObservable=distinctCategoriesObservable.sorted(repository.sortBySimilarity(query)); + } Disposable searchCategoriesDisposable = distinctCategoriesObservable .observeOn(mainThreadScheduler) .subscribe( @@ -114,8 +112,9 @@ public class CategoriesPresenter implements CategoriesContract.UserActionListene private List getImageTitleList() { List titleList = new ArrayList<>(); for (UploadItem item : repository.getUploads()) { - if (item.getTitle().isSet()) { - titleList.add(item.getTitle().toString()); + final String captionText = item.getUploadMediaDetails().get(0).getCaptionText(); + if (!TextUtils.isEmpty(captionText)) { + titleList.add(captionText); } } return titleList; 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 0e4b94f9e..be2244b76 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 @@ -6,27 +6,18 @@ 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.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; - +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; import com.google.android.material.textfield.TextInputEditText; 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 java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -import butterknife.BindView; -import butterknife.ButterKnife; -import butterknife.OnClick; import fr.free.nrw.commons.R; import fr.free.nrw.commons.category.CategoryClickedListener; import fr.free.nrw.commons.category.CategoryItem; @@ -35,6 +26,10 @@ 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 timber.log.Timber; public class UploadCategoriesFragment extends UploadBaseFragment implements CategoriesContract.View, @@ -54,7 +49,6 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate @Inject CategoriesContract.UserActionListener presenter; private RVRendererAdapter adapter; - private List mediaTitleList=new ArrayList<>(); private Disposable subscribe; private List categories; private boolean isVisible; @@ -64,10 +58,6 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate super.onCreate(savedInstanceState); } - public void setMediaTitleList(List mediaTitleList) { - this.mediaTitleList = mediaTitleList; - } - @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @@ -151,12 +141,6 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate } } - @Override - public void addCategory(CategoryItem category) { - adapter.add(category); - adapter.notifyItemInserted(adapter.getItemCount()); - } - @Override public void goToNextScreen() { callback.onNextButtonClicked(callback.getIndexInViewFlipper(this)); @@ -174,11 +158,6 @@ public class UploadCategoriesFragment extends UploadBaseFragment implements Cate } - @Override - public void setSelectedCategories(List selectedCategories) { - - } - @OnClick(R.id.btn_next) public void onNextButtonClicked() { presenter.verifyCategories(); 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 new file mode 100644 index 000000000..269f721ae --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsContract.java @@ -0,0 +1,81 @@ +package fr.free.nrw.commons.upload.depicts; + +import java.util.List; + +import fr.free.nrw.commons.BasePresenter; +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; + +/** + * The contract with which DepictsFragment and its presenter would talk to each other + */ +public interface DepictsContract { + + interface View { + /** + * Go to category screen + */ + void goToNextScreen(); + + /** + * Go to media detail screen + */ + void goToPreviousScreen(); + + /** + * show error in case of no depiction selected + */ + void noDepictionSelected(); + + /** + * Show progress/Hide progress depending on the boolean value + */ + void showProgress(boolean shouldShow); + + /** + * decides whether to show error values or not depending on the boolean value + */ + void showError(Boolean value); + + /** + * add depictions to list + */ + void setDepictsList(List depictedItemList); + + /** + * Set thumbnail image for depicted item + */ + void onImageUrlFetched(String response, int position); + } + + interface UserActionListener extends BasePresenter { + + /** + * Takes to previous screen + */ + void onPreviousButtonClicked(); + + /** + * Listener for the depicted items selected from the list + */ + void onDepictItemClicked(DepictedItem depictedItem); + + /** + * asks the repository to fetch depictions for the query + * @param query + */ + void searchForDepictions(String query); + + /** + * Check if depictions were selected + * from the depiction list + */ + void verifyDepictions(); + + /** + * 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 new file mode 100644 index 000000000..4ac5e7727 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.java @@ -0,0 +1,195 @@ +package fr.free.nrw.commons.upload.depicts; + +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.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; +import com.google.android.material.textfield.TextInputEditText; +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 timber.log.Timber; + + +/** + * Fragment for showing depicted items list in Upload activity after media details + */ +public class DepictsFragment extends UploadBaseFragment implements DepictsContract.View, UploadDepictsCallback { + + @BindView(R.id.depicts_title) + TextView depictsTitle; + @BindView(R.id.depicts_search_container) + TextInputLayout depictsSearchContainer; + @BindView(R.id.depicts_search) + TextInputEditText depictsSearch; + @BindView(R.id.depictsSearchInProgress) + ProgressBar depictsSearchInProgress; + @BindView(R.id.depicts_recycler_view) + RecyclerView depictsRecyclerView; + + @Inject + DepictsContract.UserActionListener presenter; + private RVRendererAdapter adapter; + private Disposable subscribe; + + @Nullable + @Override + public android.view.View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.upload_depicts_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull android.view.View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + ButterKnife.bind(this, view); + init(); + } + + /** + * Initialize presenter and views + */ + private void init() { + depictsTitle.setText(getString(R.string.step_count, callback.getIndexInViewFlipper(this) + 1, + callback.getTotalNumberOfSteps())); + presenter.onAttachView(this); + initRecyclerView(); + addTextChangeListenerToSearchBox(); + } + + /** + * Initialise recyclerView and set adapter + */ + private void initRecyclerView() { + adapter = new UploadDepictsAdapterFactory(this) + .create(new ArrayList<>()); + depictsRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + depictsRecyclerView.setAdapter(adapter); + } + + @Override + public void goToNextScreen() { + callback.onNextButtonClicked(callback.getIndexInViewFlipper(this)); + } + + @Override + public void goToPreviousScreen() { + callback.onPreviousButtonClicked(callback.getIndexInViewFlipper(this)); + } + + @Override + public void noDepictionSelected() { + DialogUtil.showAlertDialog(getActivity(), + getString(R.string.no_depictions_selected), + getString(R.string.no_depictions_selected_warning_desc), + getString(R.string.yes_submit), + getString(R.string.no_go_back), + this::goToNextScreen, + null + ); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + presenter.onDetachView(); + subscribe.dispose(); + } + + @Override + public void showProgress(boolean shouldShow) { + depictsSearchInProgress.setVisibility(shouldShow ? View.VISIBLE : View.GONE); + } + + @Override + public void showError(Boolean value) { + if (value) + depictsSearchContainer.setError(getString(R.string.no_depiction_found)); + else depictsSearchContainer.setErrorEnabled(false); + } + + @Override + public void setDepictsList(List 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) + public void onNextButtonClicked() { + presenter.verifyDepictions(); + } + + @OnClick(R.id.depicts_previous) + 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 + */ + private void addTextChangeListenerToSearchBox() { + subscribe = RxTextView.textChanges(depictsSearch) + .doOnEach(v -> depictsSearchContainer.setError(null)) + .takeUntil(RxView.detaches(depictsSearch)) + .debounce(500, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(filter -> searchForDepictions(filter.toString()), Timber::e); + } + + /** + * Search for depictions for the following query + * + * @param query query string + */ + private void searchForDepictions(String query) { + presenter.searchForDepictions(query); + } + +} 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 new file mode 100644 index 000000000..e6df617b2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsInterface.java @@ -0,0 +1,28 @@ +package fr.free.nrw.commons.upload.depicts; + +import fr.free.nrw.commons.wikidata.model.DepictSearchResponse; +import io.reactivex.Observable; +import org.wikipedia.wikidata.ClaimsResponse; +import retrofit2.http.GET; +import retrofit2.http.Query; + +/** + * Manges retrofit calls for Searching of depicts from DepictsFragment + */ + +public interface DepictsInterface { + + /** + * Search for depictions using the wbsearchentities API + * @param query search for depictions based on user query + * @param limit number of depictions to be retrieved + * @param language current locale of the phone + * @param uselang current locale of the phone + * @param offset number of depictions already fetched useful in implementing pagination + */ + @GET("/w/api.php?action=wbsearchentities&format=json&type=item&uselang=en") + 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?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 000000000..756a65270 --- /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 a595f5c75..a2c3d99d2 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 @@ -3,15 +3,10 @@ package fr.free.nrw.commons.upload.mediaDetails; import static fr.free.nrw.commons.utils.ImageUtils.getErrorMessageForResult; import android.annotation.SuppressLint; -import android.content.Context; import android.os.Bundle; -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.EditText; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.NonNull; @@ -24,7 +19,6 @@ import butterknife.BindView; import butterknife.ButterKnife; import butterknife.OnClick; import com.github.chrisbanes.photoview.PhotoView; -import com.jakewharton.rxbinding2.widget.RxTextView; import fr.free.nrw.commons.R; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.filepicker.UploadableFile; @@ -32,18 +26,16 @@ import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.location.LatLng; import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.settings.Prefs; -import fr.free.nrw.commons.upload.Description; -import fr.free.nrw.commons.upload.DescriptionsAdapter; import fr.free.nrw.commons.upload.ImageCoordinates; import fr.free.nrw.commons.upload.SimilarImageDialogFragment; -import fr.free.nrw.commons.upload.Title; 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.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; -import io.reactivex.disposables.Disposable; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -53,8 +45,10 @@ import javax.inject.Named; 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 { + UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener { @BindView(R.id.tv_title) TextView tvTitle; @@ -64,8 +58,6 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements AppCompatImageButton ibExpandCollapse; @BindView(R.id.ll_container_media_detail) LinearLayout llContainerMediaDetail; - @BindView(R.id.et_title) - EditText etTitle; @BindView(R.id.rv_descriptions) RecyclerView rvDescriptions; @BindView(R.id.backgroundImage) @@ -74,12 +66,12 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements AppCompatButton btnNext; @BindView(R.id.btn_previous) AppCompatButton btnPrevious; - private DescriptionsAdapter descriptionsAdapter; + private UploadMediaDetailAdapter uploadMediaDetailAdapter; @BindView(R.id.btn_copy_prev_title_desc) AppCompatButton btnCopyPreviousTitleDesc; private UploadModel.UploadItem uploadItem; - private List descriptions; + private List descriptions; @Inject UploadMediaDetailsContract.UserActionListener presenter; @@ -89,10 +81,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements JsonKvStore defaultKvStore; private UploadableFile uploadableFile; - private String source; private Place place; - private Title title; private boolean isExpanded = true; private UploadMediaDetailFragmentCallback callback; @@ -106,9 +96,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements super.onCreate(savedInstanceState); } - public void setImageTobeUploaded(UploadableFile uploadableFile, String source, Place place) { + public void setImageTobeUploaded(UploadableFile uploadableFile, Place place) { this.uploadableFile = uploadableFile; - this.source = source; this.place = place; } @@ -129,25 +118,9 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements private void init() { tvTitle.setText(getString(R.string.step_count, callback.getIndexInViewFlipper(this) + 1, callback.getTotalNumberOfSteps())); - title = new Title(); initRecyclerView(); initPresenter(); - Disposable disposable = RxTextView.textChanges(etTitle) - .subscribe(text -> { - if (!TextUtils.isEmpty(text)) { - btnNext.setEnabled(true); - btnNext.setClickable(true); - btnNext.setAlpha(1.0f); - title.setTitleText(text.toString()); - uploadItem.setTitle(title); - } else { - btnNext.setAlpha(0.5f); - btnNext.setEnabled(false); - btnNext.setClickable(false); - } - }); - compositeDisposable.add(disposable); - presenter.receiveImage(uploadableFile, source, place); + presenter.receiveImage(uploadableFile, place); if (callback.getIndexInViewFlipper(this) == 0) { btnPrevious.setEnabled(false); @@ -166,36 +139,6 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements attachImageViewScaleChangeListener(); - addEtTitleTouchListener(); - } - - /** - * Handles the drawable click listener for Edit Text - */ - private void addEtTitleTouchListener() { - etTitle.setOnTouchListener((v, event) -> { - //2 is for drawable right - float twelveDpInPixels = convertDpToPixel(12, getContext()); - if (event.getAction() == MotionEvent.ACTION_UP && etTitle.getCompoundDrawables() != null - && etTitle.getCompoundDrawables().length > 2 && etTitle - .getCompoundDrawables()[2].getBounds() - .contains((int) (etTitle.getWidth() - (event.getX() + twelveDpInPixels)), - (int) (event.getY() - twelveDpInPixels))) { - showInfoAlert(R.string.media_detail_title, R.string.title_info); - return true; - } - return false; - }); - } - - /** - * 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); } /** @@ -217,13 +160,14 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements } /** - * init the recycler veiw + * init the description recycler veiw and caption recyclerview */ private void initRecyclerView() { - descriptionsAdapter = new DescriptionsAdapter(defaultKvStore.getString(Prefs.KEY_LANGUAGE_VALUE, "")); - descriptionsAdapter.setCallback(this::showInfoAlert); + uploadMediaDetailAdapter = new UploadMediaDetailAdapter(defaultKvStore.getString(Prefs.KEY_LANGUAGE_VALUE, "")); + uploadMediaDetailAdapter.setCallback(this::showInfoAlert); + uploadMediaDetailAdapter.setEventListener(this); rvDescriptions.setLayoutManager(new LinearLayoutManager(getContext())); - rvDescriptions.setAdapter(descriptionsAdapter); + rvDescriptions.setAdapter(uploadMediaDetailAdapter); } /** @@ -237,7 +181,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements @OnClick(R.id.btn_next) public void onNextButtonClicked() { - uploadItem.setDescriptions(descriptionsAdapter.getDescriptions()); + uploadItem.setMediaDetails(uploadMediaDetailAdapter.getUploadMediaDetails()); presenter.verifyImageQuality(uploadItem); } @@ -248,9 +192,9 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements @OnClick(R.id.btn_add_description) public void onButtonAddDescriptionClicked() { - Description description = new Description(); - description.setManuallyAdded(true);//This was manually added by the user - descriptionsAdapter.addDescription(description); + UploadMediaDetail uploadMediaDetail = new UploadMediaDetail(); + uploadMediaDetail.setManuallyAdded(true);//This was manually added by the user + uploadMediaDetailAdapter.addDescription(uploadMediaDetail); } @Override @@ -279,11 +223,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements @Override public void onImageProcessed(UploadItem uploadItem, Place place) { this.uploadItem = uploadItem; - if (uploadItem.getTitle() != null) { - etTitle.setText(uploadItem.getTitle().toString()); - } - - descriptions = uploadItem.getDescriptions(); + descriptions = uploadItem.getUploadMediaDetails(); photoViewBackgroundImage.setImageURI(uploadItem.getMediaUri()); setDescriptionsInAdapter(descriptions); } @@ -302,11 +242,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements getString(R.string.upload_nearby_place_found_description), place.getName()), () -> { - etTitle.setText(place.getName()); - Description description = new Description(); - description.setLanguageCode("en"); - description.setDescriptionText(place.getLongDescription()); - descriptions = Arrays.asList(description); + descriptions = new ArrayList<>(Arrays.asList(new UploadMediaDetail(place))); setDescriptionsInAdapter(descriptions); }, () -> { @@ -376,9 +312,8 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements } @Override - public void setTitleAndDescription(String title, List descriptions) { - etTitle.setText(title); - setDescriptionsInAdapter(descriptions); + public void setCaptionsAndDescriptions(List uploadMediaDetails) { + setDescriptionsInAdapter(uploadMediaDetails); } private void deleteThisPicture() { @@ -412,6 +347,13 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements uploadItem.getGpsCoords().getDecLongitude(), 0.0f)); } + @Override + public void onPrimaryCaptionTextChange(boolean isNotEmpty) { + btnNext.setEnabled(isNotEmpty); + btnNext.setClickable(isNotEmpty); + btnNext.setAlpha(isNotEmpty ? 1.0f: 0.5f); + } + public interface UploadMediaDetailFragmentCallback extends Callback { @@ -424,15 +366,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements presenter.fetchPreviousTitleAndDescription(callback.getIndexInViewFlipper(this)); } - private void setDescriptionsInAdapter(List descriptions) { - if (descriptions == null) { - descriptions = new ArrayList<>(); - } - if (descriptions.size() == 0) { - descriptionsAdapter.addDescription(new Description()); - } else { - descriptionsAdapter.setItems(descriptions); - } + 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 a04bcae32..47a807b7e 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 @@ -1,15 +1,13 @@ package fr.free.nrw.commons.upload.mediaDetails; -import fr.free.nrw.commons.upload.ImageCoordinates; -import java.util.List; - import fr.free.nrw.commons.BasePresenter; -import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.filepicker.UploadableFile; import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.upload.Description; +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.UploadModel.UploadItem; +import java.util.List; /** * The contract with with UploadMediaDetails and its presenter would talk to each other @@ -36,13 +34,12 @@ public interface UploadMediaDetailsContract { void showMapWithImageCoordinates(boolean shouldShow); - void setTitleAndDescription(String title, List descriptions); + void setCaptionsAndDescriptions(List uploadMediaDetails); } interface UserActionListener extends BasePresenter { - void receiveImage(UploadableFile uploadableFile, @Contribution.FileSource String source, - Place place); + void receiveImage(UploadableFile uploadableFile, Place place); void verifyImageQuality(UploadItem uploadItem); 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 c36d1b781..59cff4c8a 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 @@ -2,7 +2,7 @@ package fr.free.nrw.commons.upload.mediaDetails; import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD; -import static fr.free.nrw.commons.utils.ImageUtils.EMPTY_TITLE; +import static fr.free.nrw.commons.utils.ImageUtils.EMPTY_CAPTION; import static fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS; import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_KEEP; import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK; @@ -64,16 +64,14 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt /** * Receives the corresponding uploadable file, processes it and return the view with and uplaod item - * - * @param uploadableFile - * @param source + * @param uploadableFile * @param place */ @Override - public void receiveImage(UploadableFile uploadableFile, String source, Place place) { + public void receiveImage(UploadableFile uploadableFile, Place place) { view.showProgress(true); Disposable uploadItemDisposable = repository - .preProcessImage(uploadableFile, place, source, this) + .preProcessImage(uploadableFile, place, this) .subscribeOn(ioScheduler) .observeOn(mainThreadScheduler) .subscribe(uploadItem -> @@ -143,7 +141,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt } /** - * Fetches and sets the title and desctiption of the previous item + * Fetches and sets the caption and desctiption of the previous item * * @param indexInViewFlipper */ @@ -151,7 +149,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt public void fetchPreviousTitleAndDescription(int indexInViewFlipper) { UploadItem previousUploadItem = repository.getPreviousUploadItem(indexInViewFlipper); if (null != previousUploadItem) { - view.setTitleAndDescription(previousUploadItem.getTitle().getTitleText(), previousUploadItem.getDescriptions()); + view.setCaptionsAndDescriptions(previousUploadItem.getUploadMediaDetails()); } else { view.showMessage(R.string.previous_image_title_description_not_found, R.color.color_error); } @@ -176,7 +174,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt } /** - * Handle images, say empty title, duplicate file name, bad picture(in all other cases) + * Handle images, say empty caption, duplicate file name, bad picture(in all other cases) * * @param errorCode */ @@ -188,9 +186,9 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt } switch (errorCode) { - case EMPTY_TITLE: - Timber.d("Title is empty. Showing toast"); - view.showMessage(R.string.add_title_toast, R.color.color_error); + case EMPTY_CAPTION: + Timber.d("Captions are empty. Showing toast"); + view.showMessage(R.string.add_caption_toast, R.color.color_error); break; case FILE_NAME_EXISTS: Timber.d("Trying to show duplicate picture popup"); 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 new file mode 100644 index 000000000..0d5604c5a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictModel.kt @@ -0,0 +1,45 @@ +package fr.free.nrw.commons.upload.structure.depictions + +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.upload.depicts.DepictsInterface +import io.reactivex.Observable +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The model class for depictions in upload + */ +@Singleton +class DepictModel @Inject constructor(private val depictsInterface: DepictsInterface) { + + var nearbyPlaces: MutableList? = null + + companion object { + private const val SEARCH_DEPICTS_LIMIT = 25 + } + + /** + * 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 = null + } + +} 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 new file mode 100644 index 000000000..13d4efcba --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/DepictedItem.kt @@ -0,0 +1,45 @@ +package fr.free.nrw.commons.upload.structure.depictions + +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.upload.WikidataItem +import fr.free.nrw.commons.wikidata.model.DepictSearchItem + +/** + * Model class for Depicted Item in Upload and Explore + */ +data class DepictedItem constructor( + override val name: String, + val description: String?, + var imageUrl: String, + var isSelected: Boolean, + override val id: String +) : WikidataItem { + constructor(depictSearchItem: DepictSearchItem) : this( + depictSearchItem.label, + depictSearchItem.description, + "", + false, + depictSearchItem.id + ) + + constructor(place: Place) : this( + place.name, + place.longDescription, + "", + false, + place.wikiDataEntityId!! + ) + + 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() ?: 0 + } + +} 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 000000000..42e99eef4 --- /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 new file mode 100644 index 000000000..35ea4992a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/structure/depictions/UploadDepictsCallback.java @@ -0,0 +1,10 @@ +package fr.free.nrw.commons.upload.structure.depictions; + +/** + * Listener to trigger callback whenever a depicts item is clicked + */ +public interface UploadDepictsCallback { + void depictsClicked(DepictedItem item); + + void fetchThumbnailUrlForEntity(String entityId,int position); +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java index 6caa0c226..f118164bf 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java +++ b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java @@ -65,7 +65,7 @@ public class ImageUtils { public static final int IMAGE_OK = 0; public static final int IMAGE_KEEP = -1; public static final int IMAGE_WAIT = -2; - public static final int EMPTY_TITLE = -3; + public static final int EMPTY_CAPTION = -3; public static final int FILE_NAME_EXISTS = -4; static final int NO_CATEGORY_SELECTED = -5; @@ -80,7 +80,7 @@ public class ImageUtils { IMAGE_OK, IMAGE_KEEP, IMAGE_WAIT, - EMPTY_TITLE, + EMPTY_CAPTION, FILE_NAME_EXISTS, NO_CATEGORY_SELECTED, IMAGE_GEOLOCATION_DIFFERENT 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 new file mode 100644 index 000000000..739981c8a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikiBaseClient.java @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.wikidata; + +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; +import fr.free.nrw.commons.upload.WikiBaseInterface; +import io.reactivex.Observable; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import org.wikipedia.csrf.CsrfTokenClient; +import org.wikipedia.dataclient.mwapi.MwPostResponse; +import timber.log.Timber; + +/** + * Wikibase Client for calling WikiBase APIs + */ +@Singleton +public class WikiBaseClient { + + private final WikiBaseInterface wikiBaseInterface; + private final CsrfTokenClient csrfTokenClient; + + @Inject + public WikiBaseClient(WikiBaseInterface wikiBaseInterface, + @Named(NAMED_COMMONS_CSRF) CsrfTokenClient csrfTokenClient) { + this.wikiBaseInterface = wikiBaseInterface; + this.csrfTokenClient = csrfTokenClient; + } + + public Observable postEditEntity(String fileEntityId, String data) { + return csrfToken() + .switchMap(editToken -> wikiBaseInterface.postEditEntity(fileEntityId, editToken, data) + .map(response -> (response.getSuccessVal() == 1))); + } + + public Observable getFileEntityId(UploadResult uploadResult) { + return wikiBaseInterface.getFileEntityId(uploadResult.createCanonicalFileName()) + .map(response -> (long) (response.query().pages().get(0).pageId())); + } + + public Observable addLabelstoWikidata(long fileEntityId, + String languageCode, String captionValue) { + return csrfToken() + .switchMap(editToken -> wikiBaseInterface + .addLabelstoWikidata(PAGE_ID_PREFIX + fileEntityId, editToken, languageCode, captionValue)); + + } + + private Observable csrfToken() { + return Observable.fromCallable(() -> { + try { + return csrfTokenClient.getTokenBlocking(); + } catch (Throwable throwable) { + Timber.e(throwable); + return ""; + } + }); + } +} 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 542eb58ed..24f518b52 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,5 +1,6 @@ package fr.free.nrw.commons.wikidata; +import fr.free.nrw.commons.upload.WikidataItem; import org.jetbrains.annotations.NotNull; import javax.inject.Inject; @@ -24,15 +25,16 @@ public class WikidataClient { /** * Create wikidata claim to add P18 value - * @param entityId wikidata entity ID + * @param entity wikidata entity ID * @param value value of the P18 edit * @return revisionID of the edit */ - Observable createClaim(String entityId, String value) { + Observable createImageClaim(WikidataItem entity, String value) { return getCsrfToken() - .flatMap(csrfToken -> wikidataInterface.postCreateClaim(toRequestBody(entityId), + .flatMap(csrfToken -> wikidataInterface.postCreateClaim( + toRequestBody(entity.getId()), toRequestBody("value"), - toRequestBody("P18"), + toRequestBody(WikidataProperties.IMAGE.getPropertyName()), toRequestBody(value), toRequestBody("en"), toRequestBody(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 25cc020fb..7fedfc49e 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,20 +1,30 @@ package fr.free.nrw.commons.wikidata; +import static fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX; + import android.annotation.SuppressLint; import android.content.Context; - -import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.gson.Gson; +import fr.free.nrw.commons.R; +import fr.free.nrw.commons.contributions.Contribution; +import fr.free.nrw.commons.kvstore.JsonKvStore; +import fr.free.nrw.commons.upload.UploadResult; +import fr.free.nrw.commons.upload.WikidataItem; +import fr.free.nrw.commons.upload.WikidataPlace; +import fr.free.nrw.commons.utils.ConfigUtils; +import fr.free.nrw.commons.utils.ViewUtil; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import java.util.ArrayList; import java.util.Locale; - import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; - -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.utils.ViewUtil; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; +import org.wikipedia.dataclient.mwapi.MwPostResponse; +import org.wikipedia.wikidata.EditClaim; import timber.log.Timber; /** @@ -25,116 +35,172 @@ import timber.log.Timber; @Singleton public class WikidataEditService { - private final static String COMMONS_APP_TAG = "wikimedia-commons-app"; - private final static String COMMONS_APP_EDIT_REASON = "Add tag for edits made using Android 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; - @Inject - WikidataEditService(Context context, - WikidataEditListener wikidataEditListener, - @Named("default_preferences") JsonKvStore directKvStore, - WikidataClient wikidataClient) { + @Inject + 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.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. + */ + @SuppressLint("CheckResult") + private Observable addDepictsProperty(final String fileEntityId, + final WikidataItem depictedItem) { + + final EditClaim data = editClaim( + ConfigUtils.isBetaFlavour() ? "Q10" // Wikipedia:Sandbox (Q10) + : depictedItem.getId() + ); + + return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) + .doOnNext(success -> { + if (success) { + Timber.d("DEPICTS property was set successfully for %s", fileEntityId); + } else { + Timber.d("Unable to set DEPICTS property for %s", fileEntityId); + } + }) + .doOnError( throwable -> { + Timber.e(throwable, "Error occurred while setting DEPICTS property"); + ViewUtil.showLongToast(context, throwable.toString()); + }) + .subscribeOn(Schedulers.io()); } - /** - * Create a P18 claim and log the edit with custom tag - * @param wikidataEntityId a unique id of each Wikidata items - * @param fileName name of the file we will upload - * @param p18Value pic attribute of Wikidata item - */ - public void createClaimWithLogging(String wikidataEntityId, String wikiItemName, String fileName, String p18Value) { - if (wikidataEntityId == null) { - Timber.d("Skipping creation of claim as Wikidata entity ID is null"); - return; - } + private EditClaim editClaim(final String entityId) { + return EditClaim.from(entityId, WikidataProperties.DEPICTS.getPropertyName()); + } - if (fileName == null) { - Timber.d("Skipping creation of claim as fileName entity ID is null"); - return; - } - - if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { - Timber.d("Image location and nearby place location mismatched, so Wikidata item won't be edited"); - return; - } - - if (p18Value != null && !p18Value.trim().isEmpty()) { - Timber.d("Skipping creation of claim as p18Value is not empty, we won't override existing image"); - return; - } - - editWikidataProperty(wikidataEntityId, wikiItemName, fileName); - } - - /** - * Edits the wikidata entity by adding the P18 property to it. - * Adding the P18 edit requires calling the wikidata API to create a claim against the entity - * - * @param wikidataEntityId - * @param fileName - */ - @SuppressLint("CheckResult") - private void editWikidataProperty(String wikidataEntityId, String wikiItemName, String fileName) { - Timber.d("Upload successful with wiki data entity id as %s", wikidataEntityId); - Timber.d("Attempting to edit Wikidata property %s", wikidataEntityId); - - String propertyValue = getFileName(fileName); - - Timber.d("Entity id is %s and property value is %s", wikidataEntityId, propertyValue); - wikidataClient.createClaim(wikidataEntityId, propertyValue) - .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 -> handleClaimResult(wikidataEntityId, wikiItemName, String.valueOf(revisionId)), throwable -> { - Timber.e(throwable, "Error occurred while making claim"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }); - } - - private void handleClaimResult(String wikidataEntityId, String wikiItemName, String revisionId) { - if (revisionId != null) { - if (wikidataEditListener != null) { - wikidataEditListener.onSuccessfulWikidataEdit(); - } - showSuccessToast(wikiItemName); - } else { - Timber.d("Unable to make wiki data edit for entity %s", wikidataEntityId); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - } - } - - /** + /** * Show a success toast when the edit is made successfully */ - private void showSuccessToast(String wikiItemName) { - String successStringTemplate = context.getString(R.string.successful_wikidata_edit); - String successMessage = String.format(Locale.getDefault(), successStringTemplate, wikiItemName); + 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); } - /** - * Formats and returns the filename as accepted by the wiki base API - * https://www.mediawiki.org/wiki/Wikibase/API#wbcreateclaim + /** + * Adds label to Wikidata using the fileEntityId and the edit token, obtained from csrfTokenClient * - * @param fileName + * @param fileEntityId * @return */ - private String getFileName(String fileName) { - fileName = String.format("\"%s\"", fileName.replace("File:", "")); - Timber.d("Wikidata property name is %s", fileName); - return fileName; + + @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) { + Timber.d("Caption successfully set, revision id = %s", response); + } else { + Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId); + } + } + + 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"); + return; + } + editWikidataImageProperty(wikidataPlace, imageUpload); + } + + @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)); + }); + } + + private void handleImageClaimResult(final WikidataItem wikidataItem, final String revisionId) { + if (revisionId != null) { + if (wikidataEditListener != null) { + wikidataEditListener.onSuccessfulWikidataEdit(); + } + showSuccessToast(wikidataItem.getName()); + } else { + Timber.d("Unable to make wiki data edit for entity %s", wikidataItem); + ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); + } + } + + public Disposable addDepictionsAndCaptions(UploadResult uploadResult, Contribution contribution) { + return wikiBaseClient.getFileEntityId(uploadResult) + .doOnError(throwable -> { + Timber.e(throwable, "Error occurred while getting EntityID to set DEPICTS property"); + ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); + }) + .subscribeOn(Schedulers.io()) + .switchMap(fileEntityId -> { + if (fileEntityId != null) { + Timber.d("EntityId for image was received successfully: %s", fileEntityId); + return Observable.concat( + depictionEdits(contribution, fileEntityId), + captionEdits(contribution, fileEntityId) + ); + } else { + Timber.d("Error acquiring EntityId for image: %s", uploadResult); + return Observable.empty(); + } + } + ).subscribe( + 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.getCaptions().entrySet()) + .concatMap(entry -> addCaption(fileEntityId, entry.getKey(), entry.getValue())); + } + + private Observable depictionEdits(Contribution contribution, Long fileEntityId) { + final ArrayList depictedItems = new ArrayList<>(contribution.getDepictedItems()); + final WikidataPlace wikidataPlace = contribution.getWikidataPlace(); + if (wikidataPlace != null) { + depictedItems.add(wikidataPlace); + } + return Observable.fromIterable(depictedItems) + .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 new file mode 100644 index 000000000..30a11f92c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataProperties.kt @@ -0,0 +1,8 @@ +package fr.free.nrw.commons.wikidata + +import fr.free.nrw.commons.BuildConfig + +enum class WikidataProperties(val propertyName: String) { + IMAGE("P18"), DEPICTS(BuildConfig.DEPICTS_PROPERTY); + +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchItem.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchItem.kt new file mode 100644 index 000000000..596426f50 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchItem.kt @@ -0,0 +1,27 @@ +package fr.free.nrw.commons.wikidata.model + +/** + * Model class for Depiction item returned from API after calling searchForDepicts +"search": [ +{ +"repository": "local", +"id": "Q23444", +"concepturi": "http://www.wikidata.org/entity/Q23444", +"title": "Q23444", +"pageid": 26835, +"url": "//www.wikidata.org/wiki/Q23444", +"label": "white", +"description": "color", +"match": { +"type": "label", +"language": "en", +"text": "white" +} +}*/ +class DepictSearchItem( + val id: String, + val pageid: String, + val url: String, + val label: String, + val description: String? +) diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java new file mode 100644 index 000000000..8ea2fa1ed --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java @@ -0,0 +1,24 @@ +package fr.free.nrw.commons.wikidata.model; + +import java.util.List; + +/** + * Model class for API response obtained from search for depictions + */ +public class DepictSearchResponse { + private final List search; + + /** + * Constructor to initialise value of the search object + */ + public DepictSearchResponse(List search) { + this.search = search; + } + + /** + * @return List for the DepictSearchResponse + */ + public List getSearch() { + return search; + } +} diff --git a/app/src/main/res/layout/activity_wikidata_item_details.xml b/app/src/main/res/layout/activity_wikidata_item_details.xml new file mode 100644 index 000000000..8f20ff463 --- /dev/null +++ b/app/src/main/res/layout/activity_wikidata_item_details.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 000000000..9ec5ca0ea --- /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 1e238f377..65d2e3cc5 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 d3495d760..c3b9ba702 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 @@ -82,22 +82,6 @@ android:layout_marginTop="@dimen/standard_gap" android:orientation="vertical"> - - - - - diff --git a/app/src/main/res/layout/item_depictions.xml b/app/src/main/res/layout/item_depictions.xml new file mode 100644 index 000000000..8261c8b52 --- /dev/null +++ b/app/src/main/res/layout/item_depictions.xml @@ -0,0 +1,37 @@ + + + + + + + + + + \ 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 000000000..8f0ddd874 --- /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 new file mode 100644 index 000000000..723c21ca5 --- /dev/null +++ b/app/src/main/res/layout/layout_upload_depicts_item.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/row_item_caption.xml b/app/src/main/res/layout/row_item_caption.xml new file mode 100644 index 000000000..bb5b73284 --- /dev/null +++ b/app/src/main/res/layout/row_item_caption.xml @@ -0,0 +1,18 @@ + + + + + + + \ 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 650e3daca..e9c8e98b2 100644 --- a/app/src/main/res/layout/row_item_description.xml +++ b/app/src/main/res/layout/row_item_description.xml @@ -13,11 +13,29 @@ android:minWidth="@dimen/tiny_height" android:padding="@dimen/dimen_0" android:spinnerMode="dialog" /> + + + + + + > - + \ 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 new file mode 100644 index 000000000..de2a2e7aa --- /dev/null +++ b/app/src/main/res/layout/upload_depicts_fragment.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + +