#3813 Convert MediaClient to Kotlin (#3814)

* #3813 Convert MediaClient to Kotlin - convert

* #3813 Convert MediaClient to Kotlin - update tests

* #3813 Convert MediaClient to Kotlin - fix List typing

* #3813 Convert MediaClient to Kotlin - fix mock injecting
This commit is contained in:
Seán Mac Gillicuddy 2020-06-17 17:38:17 +01:00 committed by GitHub
parent e3213aa5bd
commit 422890cac4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 319 additions and 302 deletions

View file

@ -8,7 +8,6 @@ import fr.free.nrw.commons.media.MediaClient
import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
import java.util.*
import javax.inject.Inject
import javax.inject.Named
@ -52,11 +51,11 @@ class ContributionBoundaryCallback @Inject constructor(
* Fetches contributions using the MediaWiki API
*/
fun fetchContributions() {
if (mediaClient.doesMediaListForUserHaveMorePages(sessionManager.userName).not()) {
if (mediaClient.doesMediaListForUserHaveMorePages(sessionManager.userName!!).not()) {
return
}
compositeDisposable.add(
mediaClient.getMediaListForUser(sessionManager.userName)
mediaClient.getMediaListForUser(sessionManager.userName!!)
.map { mediaList: List<Media?> ->
mediaList.map {
Contribution(it, Contribution.STATE_COMPLETED)

View file

@ -6,7 +6,7 @@ import fr.free.nrw.commons.explore.LiveDataConverter
import fr.free.nrw.commons.explore.PageableDataSource
import fr.free.nrw.commons.explore.depictions.LoadFunction
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.media.MediaClient.NO_CAPTION
import fr.free.nrw.commons.media.MediaClient.Companion.NO_CAPTION
import javax.inject.Inject
class PageableMediaDataSource @Inject constructor(

View file

@ -1,277 +0,0 @@
package fr.free.nrw.commons.media;
import androidx.annotation.NonNull;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import io.reactivex.Observable;
import io.reactivex.Single;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import org.wikipedia.wikidata.Entities;
import org.wikipedia.wikidata.Entities.Entity;
import org.wikipedia.wikidata.Entities.Label;
import timber.log.Timber;
/**
* Media Client to handle custom calls to Commons MediaWiki APIs
*/
@Singleton
public class MediaClient {
private final MediaInterface mediaInterface;
private final PageMediaInterface pageMediaInterface;
private final MediaDetailInterface mediaDetailInterface;
//OkHttpJsonApiClient used JsonKvStore for this. I don't know why.
private Map<String, Map<String, String>> continuationStore;
private Map<String, Boolean> continuationExists;
public static final String NO_CAPTION = "No caption";
private static final String NO_DEPICTION = "No depiction";
@Inject
public MediaClient(MediaInterface mediaInterface,
PageMediaInterface pageMediaInterface,
MediaDetailInterface mediaDetailInterface) {
this.mediaInterface = mediaInterface;
this.pageMediaInterface = pageMediaInterface;
this.mediaDetailInterface = mediaDetailInterface;
this.continuationStore = new HashMap<>();
this.continuationExists = new HashMap<>();
}
/**
* Checks if a page exists on Commons
* The same method can be used to check for file or talk page
*
* @param title File:Test.jpg or Commons:Deletion_requests/File:Test1.jpeg
*/
public Single<Boolean> checkPageExistsUsingTitle(String title) {
return mediaInterface.checkPageExistsUsingTitle(title)
.map(mwQueryResponse -> mwQueryResponse
.query().firstPage().pageId() > 0)
.singleOrError();
}
/**
* Take the fileSha and returns whether a file with a matching SHA exists or not
*
* @param fileSha SHA of the file to be checked
*/
public Single<Boolean> checkFileExistsUsingSha(String fileSha) {
return mediaInterface.checkFileExistsUsingSha(fileSha)
.map(mwQueryResponse -> mwQueryResponse
.query().allImages().size() > 0)
.singleOrError();
}
/**
* This method takes the category as input and returns a list of Media objects filtered using image generator query
* It uses the generator query API to get the images searched using a query, 10 at a time.
*
* @param category the search category. Must start with "Category:"
* @return
*/
public Single<List<Media>> getMediaListFromCategory(String category) {
return responseToMediaList(
continuationStore.containsKey("category_" + category) ?
mediaInterface.getMediaListFromCategory(category, 10, continuationStore.get("category_" + category)) : //if true
mediaInterface.getMediaListFromCategory(category, 10, Collections.emptyMap()),
"category_" + category); //if false
}
/**
* This method takes the userName as input and returns a list of Media objects filtered using
* allimages query It uses the allimages query API to get the images contributed by the userName,
* 10 at a time.
*
* @param userName the username
* @return
*/
public Single<List<Media>> getMediaListForUser(String userName) {
Map<String, String> continuation =
continuationStore.containsKey("user_" + userName)
? continuationStore.get("user_" + userName)
: Collections.emptyMap();
return responseToMediaList(mediaInterface
.getMediaListForUser(userName, 10, continuation), "user_" + userName);
}
/**
* Check if media for user has reached the end of the list.
* @param userName
* @return
*/
public boolean doesMediaListForUserHaveMorePages(String userName) {
final String key = "user_" + userName;
if(continuationExists.containsKey(key)) {
return continuationExists.get(key);
}
return true;
}
/**
* This method takes a keyword as input and returns a list of Media objects filtered using image generator query
* It uses the generator query API to get the images searched using a query, 10 at a time.
*
* @param keyword the search keyword
* @param limit
* @param offset
* @return
*/
public Single<MwQueryResponse> getMediaListFromSearch(String keyword, int limit, int offset) {
return mediaInterface.getMediaListFromSearch(keyword, limit, offset);
}
private Single<List<Media>> responseToMediaList(Observable<MwQueryResponse> response, String key) {
return response.flatMap(mwQueryResponse -> {
if (null == mwQueryResponse
|| null == mwQueryResponse.query()
|| null == mwQueryResponse.query().pages()) {
return Observable.empty();
}
if(mwQueryResponse.continuation() != null) {
continuationStore.put(key, mwQueryResponse.continuation());
continuationExists.put(key, true);
} else {
continuationExists.put(key, false);
}
return Observable.fromIterable(mwQueryResponse.query().pages());
})
.map(Media::from)
.collect(ArrayList<Media>::new, List::add);
}
/**
* Fetches Media object from the imageInfo API
*
* @param titles the tiles to be searched for. Can be filename or template name
* @return
*/
public Single<Media> getMedia(String titles) {
return mediaInterface.getMedia(titles)
.flatMap(mwQueryResponse -> {
if (null == mwQueryResponse
|| null == mwQueryResponse.query()
|| null == mwQueryResponse.query().firstPage()) {
return Observable.empty();
}
return Observable.just(mwQueryResponse.query().firstPage());
})
.map(Media::from)
.single(Media.EMPTY);
}
/**
* The method returns the picture of the day
*
* @return Media object corresponding to the picture of the day
*/
@NonNull
public Single<Media> getPictureOfTheDay() {
String date = CommonsDateUtil.getIso8601DateFormatShort().format(new Date());
Timber.d("Current date is %s", date);
String template = "Template:Potd/" + date;
return mediaInterface.getMediaWithGenerator(template)
.flatMap(mwQueryResponse -> {
if (null == mwQueryResponse
|| null == mwQueryResponse.query()
|| null == mwQueryResponse.query().firstPage()) {
return Observable.empty();
}
return Observable.just(mwQueryResponse.query().firstPage());
})
.map(Media::from)
.single(Media.EMPTY);
}
@NonNull
public Single<String> getPageHtml(String title){
return mediaInterface.getPageHtml(title)
.filter(MwParseResponse::success)
.map(MwParseResponse::parse)
.map(MwParseResult::text)
.first("");
}
/**
* @return caption for image using wikibaseIdentifier
*/
public Single<String> getCaptionByWikibaseIdentifier(String wikibaseIdentifier) {
return mediaDetailInterface.getEntityForImage(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();
}
public Single<Boolean> doesPageContainMedia(String title) {
return pageMediaInterface.getMediaList(title)
.map(pageMediaListResponse -> {
return pageMediaListResponse.getItems().size() > 0;
}).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<Depictions> 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<String> getLabelForDepiction(String entityId, String language) {
return mediaDetailInterface.getEntity(entityId)
.map(entities -> {
if (isSuccess(entities)) {
for (Entity entity : entities.entities().values()) {
final Map<String, Label> languageToLabelMap = entity.labels();
if (languageToLabelMap.containsKey(language)) {
return languageToLabelMap.get(language).value();
}
for (Label label : languageToLabelMap.values()) {
return label.value();
}
}
}
throw new RuntimeException("failed getEntities");
});
}
public Single<Entities> getEntities(String entityId) {
return mediaDetailInterface.getEntity(entityId);
}
}

View file

@ -0,0 +1,300 @@
package fr.free.nrw.commons.media
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.media.Depictions.Companion.from
import fr.free.nrw.commons.utils.CommonsDateUtil
import io.reactivex.Observable
import io.reactivex.Single
import org.wikipedia.dataclient.mwapi.MwQueryPage
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import org.wikipedia.wikidata.Entities
import timber.log.Timber
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.ArrayList
/**
* Media Client to handle custom calls to Commons MediaWiki APIs
*/
@Singleton
class MediaClient @Inject constructor(
private val mediaInterface: MediaInterface,
private val pageMediaInterface: PageMediaInterface,
private val mediaDetailInterface: MediaDetailInterface
) {
//OkHttpJsonApiClient used JsonKvStore for this. I don't know why.
private val continuationStore: MutableMap<String, Map<String, String>?>
private val continuationExists: MutableMap<String, Boolean>
/**
* Checks if a page exists on Commons
* The same method can be used to check for file or talk page
*
* @param title File:Test.jpg or Commons:Deletion_requests/File:Test1.jpeg
*/
fun checkPageExistsUsingTitle(title: String?): Single<Boolean> {
return mediaInterface.checkPageExistsUsingTitle(title)
.map { mwQueryResponse: MwQueryResponse ->
mwQueryResponse
.query()!!.firstPage()!!.pageId() > 0
}
.singleOrError()
}
/**
* Take the fileSha and returns whether a file with a matching SHA exists or not
*
* @param fileSha SHA of the file to be checked
*/
fun checkFileExistsUsingSha(fileSha: String?): Single<Boolean> {
return mediaInterface.checkFileExistsUsingSha(fileSha)
.map { mwQueryResponse: MwQueryResponse ->
mwQueryResponse
.query()!!.allImages().size > 0
}
.singleOrError()
}
/**
* This method takes the category as input and returns a list of Media objects filtered using image generator query
* It uses the generator query API to get the images searched using a query, 10 at a time.
*
* @param category the search category. Must start with "Category:"
* @return
*/
fun getMediaListFromCategory(category: String): Single<List<Media>> {
return responseToMediaList(
if (continuationStore.containsKey("category_$category")) mediaInterface.getMediaListFromCategory(
category,
10,
continuationStore["category_$category"]
) else //if true
mediaInterface.getMediaListFromCategory(
category,
10,
emptyMap()
),
"category_$category"
) //if false
}
/**
* This method takes the userName as input and returns a list of Media objects filtered using
* allimages query It uses the allimages query API to get the images contributed by the userName,
* 10 at a time.
*
* @param userName the username
* @return
*/
fun getMediaListForUser(userName: String): Single<List<Media>> {
val continuation =
if (continuationStore.containsKey("user_$userName")) continuationStore["user_$userName"] else emptyMap()
return responseToMediaList(
mediaInterface
.getMediaListForUser(userName, 10, continuation), "user_$userName"
)
}
/**
* Check if media for user has reached the end of the list.
* @param userName
* @return
*/
fun doesMediaListForUserHaveMorePages(userName: String): Boolean {
val key = "user_$userName"
return if (continuationExists.containsKey(key)) {
continuationExists[key]!!
} else true
}
/**
* This method takes a keyword as input and returns a list of Media objects filtered using image generator query
* It uses the generator query API to get the images searched using a query, 10 at a time.
*
* @param keyword the search keyword
* @param limit
* @param offset
* @return
*/
fun getMediaListFromSearch(
keyword: String?,
limit: Int,
offset: Int
): Single<MwQueryResponse> {
return mediaInterface.getMediaListFromSearch(keyword, limit, offset)
}
private fun responseToMediaList(
response: Observable<MwQueryResponse>,
key: String
): Single<List<Media>> {
return response.flatMap { mwQueryResponse: MwQueryResponse? ->
if (null == mwQueryResponse || null == mwQueryResponse.query() || null == mwQueryResponse.query()!!
.pages()
) {
return@flatMap Observable.empty<MwQueryPage>()
}
if (mwQueryResponse.continuation() != null) {
continuationStore[key] = mwQueryResponse.continuation()
continuationExists[key] = true
} else {
continuationExists[key] = false
}
Observable.fromIterable(mwQueryResponse.query()!!.pages())
}
.map { page: MwQueryPage? -> Media.from(page) }
.collect(
{ ArrayList() }
) { obj: MutableList<Media>, e: Media ->
obj.add(
e
)
}.map { it.toList() }
}
/**
* Fetches Media object from the imageInfo API
*
* @param titles the tiles to be searched for. Can be filename or template name
* @return
*/
fun getMedia(titles: String?): Single<Media> {
return mediaInterface.getMedia(titles)
.flatMap { mwQueryResponse: MwQueryResponse? ->
if (null == mwQueryResponse || null == mwQueryResponse.query() || null == mwQueryResponse.query()!!
.firstPage()
) {
return@flatMap Observable.empty<MwQueryPage>()
}
Observable.just(mwQueryResponse.query()!!.firstPage())
}
.map { page: MwQueryPage? -> Media.from(page) }
.single(Media.EMPTY)
}
/**
* The method returns the picture of the day
*
* @return Media object corresponding to the picture of the day
*/
val pictureOfTheDay: Single<Media>
get() {
val date =
CommonsDateUtil.getIso8601DateFormatShort().format(Date())
Timber.d("Current date is %s", date)
val template = "Template:Potd/$date"
return mediaInterface.getMediaWithGenerator(template)
.flatMap { mwQueryResponse: MwQueryResponse? ->
if (null == mwQueryResponse || null == mwQueryResponse.query() || null == mwQueryResponse.query()!!
.firstPage()
) {
return@flatMap Observable.empty<MwQueryPage>()
}
Observable.just(mwQueryResponse.query()!!.firstPage())
}
.map { page: MwQueryPage? -> Media.from(page) }
.single(Media.EMPTY)
}
fun getPageHtml(title: String?): Single<String> {
return mediaInterface.getPageHtml(title)
.filter { obj: MwParseResponse -> obj.success() }
.map { obj: MwParseResponse -> obj.parse() }
.map { obj: MwParseResult? -> obj!!.text() }
.first("")
}
/**
* @return caption for image using wikibaseIdentifier
*/
fun getCaptionByWikibaseIdentifier(wikibaseIdentifier: String?): Single<String> {
return mediaDetailInterface.getEntityForImage(
Locale.getDefault().language,
wikibaseIdentifier
)
.map { mediaDetailResponse: Entities ->
if (isSuccess(mediaDetailResponse)) {
for (wikibaseItem in mediaDetailResponse.entities().values) {
for (label in wikibaseItem.labels().values) {
return@map label.value()
}
}
}
NO_CAPTION
}
.singleOrError()
}
fun doesPageContainMedia(title: String?): Single<Boolean> {
return pageMediaInterface.getMediaList(title)
.map { it.items.isNotEmpty() }
}
private fun isSuccess(response: Entities?): Boolean {
return response != null && response.success == 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)
*/
fun getDepictions(filename: String?): Single<Depictions> {
return mediaDetailInterface.fetchEntitiesByFileName(
Locale.getDefault().language, filename
)
.map { entities: Entities? ->
from(
entities!!,
this
)
}
.singleOrError()
}
/**
* Gets labels for Depictions using Entity Id from MediaWikiAPI
*
* @param entityId EntityId (Ex: Q81566) of the depict entity
* @return label
*/
fun getLabelForDepiction(
entityId: String?,
language: String
): Single<String> {
return mediaDetailInterface.getEntity(entityId)
.map { entities: Entities ->
if (isSuccess(entities)) {
for (entity in entities.entities().values) {
val languageToLabelMap =
entity.labels()
if (languageToLabelMap.containsKey(language)) {
return@map languageToLabelMap[language]!!.value()
}
for (label in languageToLabelMap.values) {
return@map label.value()
}
}
}
throw RuntimeException("failed getEntities")
}
}
fun getEntities(entityId: String?): Single<Entities> {
return mediaDetailInterface.getEntity(entityId)
}
companion object {
const val NO_CAPTION = "No caption"
private const val NO_DEPICTION = "No depiction"
}
init {
continuationStore =
HashMap()
continuationExists = HashMap()
}
}

View file

@ -1,7 +1,7 @@
package fr.free.nrw.commons.media
import fr.free.nrw.commons.media.model.PageMediaListResponse
import io.reactivex.Observable
import io.reactivex.Single
import retrofit2.http.GET
import retrofit2.http.Path
@ -15,5 +15,5 @@ interface PageMediaInterface {
* @param title the title of the page
*/
@GET("api/rest_v1/page/media-list/{title}")
fun getMediaList(@Path("title") title: String?): Observable<PageMediaListResponse?>?
fun getMediaList(@Path("title") title: String?): Single<PageMediaListResponse>
}

View file

@ -1,27 +1,21 @@
package fr.free.nrw.commons.contributions
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.utils.NetworkUtilsTest
import fr.free.nrw.commons.utils.createMockDataSourceFactory
import io.reactivex.Scheduler
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import io.reactivex.schedulers.TestScheduler
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.ArgumentMatchers.*
import org.mockito.InjectMocks
import org.mockito.ArgumentMatchers.anyList
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations
import java.lang.RuntimeException
import java.util.*
/**
* The unit test class for ContributionBoundaryCallbackTest

View file

@ -6,20 +6,18 @@ import fr.free.nrw.commons.media.model.PageMediaListItem
import fr.free.nrw.commons.media.model.PageMediaListResponse
import fr.free.nrw.commons.utils.CommonsDateUtil
import io.reactivex.Observable
import io.reactivex.Single
import junit.framework.Assert.*
import org.junit.Before
import org.junit.Test
import org.mockito.*
import org.mockito.Mockito.*
import org.wikipedia.dataclient.mwapi.ImageDetails
import org.wikipedia.dataclient.mwapi.MwQueryPage
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import org.wikipedia.dataclient.mwapi.MwQueryResult
import org.wikipedia.gallery.ImageInfo
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.*
import java.util.*
import org.mockito.Captor
import org.mockito.Mockito.*
class MediaClientTest {
@ -30,6 +28,9 @@ class MediaClientTest {
@Mock
internal var pageMediaInterface: PageMediaInterface? = null
@Mock
internal var mediaDetailInterface: MediaDetailInterface? = null
@InjectMocks
var mediaClient: MediaClient? = null
@ -168,7 +169,7 @@ class MediaClientTest {
`when`(mediaInterface!!.getMediaWithGenerator(filenameCaptor!!.capture()))
.thenReturn(Observable.just(mockResponse))
assertEquals("Test", mediaClient!!.getPictureOfTheDay().blockingGet().filename)
assertEquals("Test", mediaClient!!.pictureOfTheDay.blockingGet().filename)
assertEquals(template, filenameCaptor.value);
}
@ -260,7 +261,7 @@ class MediaClientTest {
val mock = mock(PageMediaListResponse::class.java)
whenever(mock.items).thenReturn(listOf<PageMediaListItem>(mock(PageMediaListItem::class.java)))
`when`(pageMediaInterface!!.getMediaList(ArgumentMatchers.anyString()))
.thenReturn(Observable.just(mock))
.thenReturn(Single.just(mock))
mediaClient!!.doesPageContainMedia("Test").test().assertValue(true)
}
@ -270,7 +271,7 @@ class MediaClientTest {
val mock = mock(PageMediaListResponse::class.java)
whenever(mock.items).thenReturn(listOf<PageMediaListItem>())
`when`(pageMediaInterface!!.getMediaList(ArgumentMatchers.anyString()))
.thenReturn(Observable.just(mock))
.thenReturn(Single.just(mock))
mediaClient!!.doesPageContainMedia("Test").test().assertValue(false)
}