#3756 Convert SearchDepictionsFragment to use Pagination (#3758)

This commit is contained in:
Seán Mac Gillicuddy 2020-05-28 12:10:04 +01:00 committed by GitHub
parent 0ebd59a223
commit c216fdf0d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1035 additions and 616 deletions

View file

@ -45,6 +45,10 @@ dependencies {
kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION"
implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-layoutcontainer:$ADAPTER_DELEGATES_VERSION"
implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION"
implementation "androidx.paging:paging-runtime-ktx:$PAGING_VERSION"
testImplementation "androidx.paging:paging-common-ktx:$PAGING_VERSION"
implementation "androidx.paging:paging-rxjava2-ktx:$PAGING_VERSION"
implementation "androidx.recyclerview:recyclerview:1.2.0-alpha02"
// Logging
implementation 'ch.acra:acra-dialog:5.3.0'

View file

@ -1,5 +1,7 @@
package fr.free.nrw.commons;
import androidx.annotation.NonNull;
/**
* Base presenter, enforcing contracts to atach and detach view
*/
@ -7,7 +9,7 @@ public interface BasePresenter<T> {
/**
* Until a view is attached, it is open to listen events from the presenter
*/
void onAttachView(T view);
void onAttachView(@NonNull T view);
/**
* Detaching a view makes sure that the view no more receives events from the presenter

View file

@ -0,0 +1,11 @@
package fr.free.nrw.commons.depictions.subClass
import fr.free.nrw.commons.explore.depictions.depictionDelegate
import fr.free.nrw.commons.upload.categories.BaseDelegateAdapter
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
class SubDepictionAdapter(clickListener: (DepictedItem) -> Unit) :
BaseDelegateAdapter<DepictedItem>(
depictionDelegate(clickListener),
areItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id }
)

View file

@ -1,10 +1,9 @@
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;
import java.io.IOException;
import java.util.List;
/**
* The contract with which SubDepictionListFragment and its presenter would talk to each other
@ -28,5 +27,6 @@ public interface SubDepictionListContract {
void initSubDepictionList(String qid, Boolean isParentClass) throws IOException;
String getQuery();
}
}

View file

@ -20,7 +20,6 @@ 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.DepictionAdapter;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.ViewUtil;
@ -47,7 +46,7 @@ public class SubDepictionListFragment extends DaggerFragment implements SubDepic
* Keeps a record of whether current instance of the fragment if of SubClass or ParentClass
*/
private boolean isParentClass = false;
private DepictionAdapter depictionsAdapter;
private SubDepictionAdapter depictionsAdapter;
RecyclerView.LayoutManager layoutManager;
/**
* Stores entityId for the depiction
@ -100,7 +99,7 @@ public class SubDepictionListFragment extends DaggerFragment implements SubDepic
}
initViews();
depictionsRecyclerView.setLayoutManager(layoutManager);
depictionsAdapter = new DepictionAdapter(depictedItem -> {
depictionsAdapter = new SubDepictionAdapter(depictedItem -> {
// Open SubDepiction Details page
getActivity().finish();
WikidataItemDetailsActivity.startYourself(getContext(), depictedItem);

View file

@ -117,7 +117,7 @@ public class SearchActivity extends NavigationBaseActivity
searchHistoryContainer.setVisibility(View.GONE);
if (FragmentUtils.isFragmentUIActive(searchDepictionsFragment)) {
searchDepictionsFragment.updateDepictionList(query.toString());
searchDepictionsFragment.onQueryUpdated(query.toString());
}
if (FragmentUtils.isFragmentUIActive(searchImageFragment)) {

View file

@ -2,6 +2,10 @@ package fr.free.nrw.commons.explore;
import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import fr.free.nrw.commons.explore.depictions.DepictsClient;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsDataSourceFactory;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsDataSourceFactoryFactory;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragmentContract;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragmentPresenter;
@ -16,4 +20,10 @@ public abstract class SearchModule {
SearchDepictionsFragmentPresenter
presenter
);
@Provides
static public SearchDepictionsDataSourceFactoryFactory providesSearchDepictionsFactoryFactory(
DepictsClient depictsClient){
return (query, loadingStates) -> new SearchDepictionsDataSourceFactory(depictsClient, query, loadingStates);
}
}

View file

@ -1,12 +1,47 @@
package fr.free.nrw.commons.explore.depictions
import fr.free.nrw.commons.upload.categories.BaseDelegateAdapter
import android.view.View
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.item_depictions.*
class DepictionAdapter(clickListener: (DepictedItem) -> Unit) : BaseDelegateAdapter<DepictedItem>(
depictionDelegate(clickListener),
areItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id }
)
class DepictionAdapter(val onDepictionClicked: (DepictedItem) -> Unit) :
PagedListAdapter<DepictedItem, DepictedItemViewHolder>(
object : DiffUtil.ItemCallback<DepictedItem>() {
override fun areItemsTheSame(oldItem: DepictedItem, newItem: DepictedItem) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: DepictedItem, newItem: DepictedItem) =
oldItem == newItem
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DepictedItemViewHolder {
return DepictedItemViewHolder(parent.inflate(R.layout.item_depictions))
}
override fun onBindViewHolder(holder: DepictedItemViewHolder, position: Int) {
holder.bind(getItem(position)!!, onDepictionClicked)
}
}
class DepictedItemViewHolder(override val containerView: View) :
RecyclerView.ViewHolder(containerView), LayoutContainer {
fun bind(item: DepictedItem, onDepictionClicked: (DepictedItem) -> Unit) {
containerView.setOnClickListener { onDepictionClicked(item) }
depicts_label.text = item.name
description.text = item.description
if (item.imageUrl?.isNotBlank() == true) {
depicts_image.setImageURI(item.imageUrl)
} else {
depicts_image.setActualImageResource(R.drawable.ic_wikidata_logo_24dp)
}
}
}

View file

@ -0,0 +1,54 @@
package fr.free.nrw.commons.explore.depictions
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.R
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.list_item_load_more.*
class FooterAdapter(private val onRefreshClicked: () -> Unit) :
ListAdapter<FooterItem, FooterViewHolder>(object :
DiffUtil.ItemCallback<FooterItem>() {
override fun areItemsTheSame(oldItem: FooterItem, newItem: FooterItem) = oldItem == newItem
override fun areContentsTheSame(oldItem: FooterItem, newItem: FooterItem) =
oldItem == newItem
}) {
override fun getItemViewType(position: Int): Int {
return getItem(position).ordinal
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (FooterItem.values()[viewType]) {
FooterItem.LoadingItem -> LoadingViewHolder(parent.inflate(R.layout.list_item_progress))
FooterItem.RefreshItem -> RefreshViewHolder(
parent.inflate(R.layout.list_item_load_more),
onRefreshClicked
)
}
override fun onBindViewHolder(holder: FooterViewHolder, position: Int) {}
}
open class FooterViewHolder(override val containerView: View) :
RecyclerView.ViewHolder(containerView),
LayoutContainer
class LoadingViewHolder(containerView: View) : FooterViewHolder(containerView)
class RefreshViewHolder(containerView: View, onRefreshClicked: () -> Unit) :
FooterViewHolder(containerView) {
init {
listItemLoadMoreButton.setOnClickListener { onRefreshClicked() }
}
}
enum class FooterItem { LoadingItem, RefreshItem }
fun ViewGroup.inflate(@LayoutRes layoutId: Int, attachToRoot: Boolean = false): View =
LayoutInflater.from(context).inflate(layoutId, this, attachToRoot)

View file

@ -0,0 +1,63 @@
package fr.free.nrw.commons.explore.depictions
import androidx.paging.PositionalDataSource
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Completable
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
data class SearchDepictionsDataSource constructor(
private val depictsClient: DepictsClient,
private val loadingStates: PublishProcessor<LoadingState>,
private val query: String
) : PositionalDataSource<DepictedItem>() {
private var lastExecutedRequest: (() -> Boolean)? = null
override fun loadInitial(
params: LoadInitialParams,
callback: LoadInitialCallback<DepictedItem>
) {
storeAndExecute {
loadingStates.offer(LoadingState.InitialLoad)
performWithTryCatch {
callback.onResult(
getItems(query, params.requestedLoadSize, params.requestedStartPosition),
params.requestedStartPosition
)
}
}
}
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<DepictedItem>) {
storeAndExecute {
loadingStates.offer(LoadingState.Loading)
performWithTryCatch {
callback.onResult(getItems(query, params.loadSize, params.startPosition))
}
}
}
fun retryFailedRequest() {
Completable.fromAction { lastExecutedRequest?.invoke() }
.subscribeOn(Schedulers.io())
.subscribe()
}
private fun getItems(query: String, limit: Int, offset: Int) =
depictsClient.searchForDepictions(query, limit, offset).blockingGet()
private fun storeAndExecute(function: () -> Boolean) {
function.also { lastExecutedRequest = it }.invoke()
}
private fun performWithTryCatch(function: () -> Unit) = try {
function.invoke()
loadingStates.offer(LoadingState.Complete)
} catch (e: Exception) {
Timber.e(e)
loadingStates.offer(LoadingState.Error)
}
}

View file

@ -1,200 +0,0 @@
package fr.free.nrw.commons.explore.depictions;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static fr.free.nrw.commons.explore.depictions.DepictionAdapterDelegatesKt.depictionDelegate;
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.DiffUtil.ItemCallback;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import butterknife.BindView;
import butterknife.ButterKnife;
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter;
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;
import kotlin.Unit;
/**
* 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;
private RecyclerView.LayoutManager layoutManager;
private boolean isLoading = true;
private final int PAGE_SIZE = 25;
@Inject
SearchDepictionsFragmentPresenter presenter;
private DepictionAdapter depictionsAdapter;
private boolean isLastPage;
@Override
public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) {
final 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 = new DepictionAdapter(
depictedItem -> {
WikidataItemDetailsActivity.startYourself(getContext(), depictedItem);
presenter.saveQuery();
return Unit.INSTANCE;
});
depictionsRecyclerView.setAdapter(depictionsAdapter);
depictionsRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) {
super.onScrollStateChanged(recyclerView, newState);
}
@Override
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy);
final int visibleItemCount = layoutManager.getChildCount();
final 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 hasnt 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(final boolean reInitialise) {
presenter.updateDepictionList(presenter.getQuery(),PAGE_SIZE, reInitialise);
}
@Override
public void onAttach(final 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(final 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);
final String no_depiction = getString(R.string.depictions_not_found);
depictionNotFound.setText(String.format(Locale.getDefault(), no_depiction, presenter.getQuery()));
}
/**
* 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(final List<DepictedItem> mediaList) {
isLoading = false;
progressBar.setVisibility(GONE);
depictionNotFound.setVisibility(GONE);
bottomProgressBar.setVisibility(GONE);
depictionsAdapter.addAll(mediaList);
}
@Override
public void loadingDepictions(final boolean isLoading) {
depictionNotFound.setVisibility(GONE);
bottomProgressBar.setVisibility(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);
}
/**
* 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(final boolean isLastPage) {
this.isLastPage=isLastPage;
progressBar.setVisibility(GONE);
}
}

View file

@ -0,0 +1,99 @@
package fr.free.nrw.commons.explore.depictions
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.MergeAdapter
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.ViewUtil
import kotlinx.android.synthetic.main.fragment_search_depictions.*
import javax.inject.Inject
/**
* Display depictions in search fragment
*/
class SearchDepictionsFragment : CommonsDaggerSupportFragment(),
SearchDepictionsFragmentContract.View {
@Inject
lateinit var presenter: SearchDepictionsFragmentContract.UserActionListener
private val depictionsAdapter by lazy {
DepictionAdapter { WikidataItemDetailsActivity.startYourself(context, it) }
}
private val loadingAdapter by lazy { FooterAdapter { presenter.retryFailedRequest() } }
private val mergeAdapter by lazy { MergeAdapter(depictionsAdapter, loadingAdapter) }
var searchResults: LiveData<PagedList<DepictedItem>>? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = inflater.inflate(R.layout.fragment_search_depictions, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
depictionsSearchResultsList.apply {
layoutManager = GridLayoutManager(context, if (isPortrait) 1 else 2)
adapter = mergeAdapter
}
presenter.listFooterData.observe(viewLifecycleOwner, Observer(loadingAdapter::submitList))
}
override fun onAttach(context: Context) {
super.onAttach(context)
presenter.onAttachView(this)
}
override fun onDetach() {
super.onDetach()
presenter.onDetachView()
}
override fun observeSearchResults(searchResults: LiveData<PagedList<DepictedItem>>) {
this.searchResults?.removeObservers(viewLifecycleOwner)
this.searchResults = searchResults
searchResults.observe(viewLifecycleOwner, Observer {
depictionsAdapter.submitList(it)
depictionNotFound.visibility = if (it.loadedCount == 0) VISIBLE else GONE
})
}
override fun setEmptyViewText(query: String) {
depictionNotFound.text = getString(R.string.depictions_not_found, query)
}
override fun hideInitialLoadProgress() {
depictionSearchInitialLoadProgress.visibility = GONE
}
override fun showInitialLoadInProgress() {
depictionSearchInitialLoadProgress.visibility = VISIBLE
}
override fun showSnackbar() {
ViewUtil.showShortSnackbar(depictionsSearchResultsList, R.string.error_loading_depictions)
}
fun onQueryUpdated(query: String) {
presenter.onQueryUpdated(query)
}
}
private val Fragment.isPortrait get() = orientation == Configuration.ORIENTATION_PORTRAIT
private val Fragment.orientation get() = activity!!.resources.configuration.orientation

View file

@ -1,79 +0,0 @@
package fr.free.nrw.commons.explore.depictions;
import fr.free.nrw.commons.BasePresenter;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import java.util.List;
/**
* 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<DepictedItem> mediaList);
/**
* load depictions
*/
void loadingDepictions(boolean isLoading);
/**
* clear adapter
*/
void clearAdapter();
/**
* show 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);
}
interface UserActionListener extends BasePresenter<View> {
/**
* 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();
}
}

View file

@ -0,0 +1,25 @@
package fr.free.nrw.commons.explore.depictions
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
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
*/
interface SearchDepictionsFragmentContract {
interface View {
fun showSnackbar()
fun observeSearchResults(searchResults: LiveData<PagedList<DepictedItem>>)
fun setEmptyViewText(query: String)
fun showInitialLoadInProgress()
fun hideInitialLoadProgress()
}
interface UserActionListener : BasePresenter<View?> {
val listFooterData: LiveData<List<FooterItem>>
fun onQueryUpdated(query: String)
fun retryFailedRequest()
}
}

View file

@ -1,158 +0,0 @@
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 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);
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<DepictedItem> 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)
.doOnSubscribe(disposable -> saveQuery())
.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<DepictedItem> 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();
}
}
}

View file

@ -0,0 +1,73 @@
package fr.free.nrw.commons.explore.depictions
import androidx.lifecycle.MutableLiveData
import fr.free.nrw.commons.di.CommonsApplicationModule
import fr.free.nrw.commons.upload.depicts.proxy
import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
/**
* The presenter class for SearchDepictionsFragment
*/
class SearchDepictionsFragmentPresenter @Inject constructor(
@param:Named(CommonsApplicationModule.MAIN_THREAD) private val mainThreadScheduler: Scheduler,
private val searchableDataSourceFactory: SearchableDepictionsDataSourceFactory
) : SearchDepictionsFragmentContract.UserActionListener {
private val compositeDisposable = CompositeDisposable()
private var view = DUMMY
private var currentQuery: String? = null
override val listFooterData = MutableLiveData<List<FooterItem>>().apply { value = emptyList() }
override fun onAttachView(view: SearchDepictionsFragmentContract.View) {
this.view = view
compositeDisposable.addAll(
searchableDataSourceFactory.searchResults.subscribe(view::observeSearchResults),
searchableDataSourceFactory.loadingStates
.observeOn(mainThreadScheduler)
.subscribe(::onLoadingState, Timber::e),
searchableDataSourceFactory.noItemsLoadedEvent.subscribe {
setEmptyViewText()
}
)
}
private fun onLoadingState(it: LoadingState) = when (it) {
LoadingState.Loading -> listFooterData.postValue(listOf(FooterItem.LoadingItem))
LoadingState.Complete -> {
listFooterData.postValue(emptyList())
view.hideInitialLoadProgress()
}
LoadingState.InitialLoad -> view.showInitialLoadInProgress()
LoadingState.Error -> {
setEmptyViewText()
view.showSnackbar()
view.hideInitialLoadProgress()
listFooterData.postValue(listOf(FooterItem.RefreshItem))
}
}
private fun setEmptyViewText() {
currentQuery?.let(view::setEmptyViewText)
}
override fun retryFailedRequest() {
searchableDataSourceFactory.retryFailedRequest()
}
override fun onDetachView() {
view = DUMMY
compositeDisposable.clear()
}
override fun onQueryUpdated(query: String) {
currentQuery = query
searchableDataSourceFactory.onQueryUpdated(query)
}
companion object {
private val DUMMY: SearchDepictionsFragmentContract.View = proxy()
}
}

View file

@ -0,0 +1,88 @@
package fr.free.nrw.commons.explore.depictions
import androidx.lifecycle.LiveData
import androidx.paging.Config
import androidx.paging.DataSource
import androidx.paging.PagedList
import androidx.paging.toLiveData
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Flowable
import io.reactivex.processors.PublishProcessor
import javax.inject.Inject
private const val PAGE_SIZE = 50
private const val INITIAL_LOAD_SIZE = 50
class SearchableDepictionsDataSourceFactory @Inject constructor(
val searchDepictionsDataSourceFactoryFactory: SearchDepictionsDataSourceFactoryFactory,
val liveDataConverter: LiveDataConverter
) {
private val _loadingStates = PublishProcessor.create<LoadingState>()
val loadingStates: Flowable<LoadingState> = _loadingStates
private val _searchResults = PublishProcessor.create<LiveData<PagedList<DepictedItem>>>()
val searchResults: Flowable<LiveData<PagedList<DepictedItem>>> = _searchResults
private val _noItemsLoadedEvent = PublishProcessor.create<Unit>()
val noItemsLoadedEvent: Flowable<Unit> = _noItemsLoadedEvent
private var currentFactory: SearchDepictionsDataSourceFactory? = null
fun onQueryUpdated(query: String) {
_searchResults.offer(
liveDataConverter.convert(
searchDepictionsDataSourceFactoryFactory.create(query, _loadingStates)
.also { currentFactory = it }
) { _noItemsLoadedEvent.offer(Unit) }
)
}
fun retryFailedRequest() {
currentFactory?.retryFailedRequest()
}
}
class LiveDataConverter @Inject constructor() {
fun convert(
dataSourceFactory: SearchDepictionsDataSourceFactory,
zeroItemsLoadedFunction: () -> Unit
): LiveData<PagedList<DepictedItem>> {
return dataSourceFactory.toLiveData(
Config(
pageSize = PAGE_SIZE,
initialLoadSizeHint = INITIAL_LOAD_SIZE,
enablePlaceholders = false
),
boundaryCallback = object : PagedList.BoundaryCallback<DepictedItem>() {
override fun onZeroItemsLoaded() {
zeroItemsLoadedFunction()
}
}
)
}
}
interface SearchDepictionsDataSourceFactoryFactory {
fun create(query: String, loadingStates: PublishProcessor<LoadingState>)
: SearchDepictionsDataSourceFactory
}
class SearchDepictionsDataSourceFactory constructor(
private val depictsClient: DepictsClient,
private val query: String,
private val loadingStates: PublishProcessor<LoadingState>
) : DataSource.Factory<Int, DepictedItem>() {
private var currentDataSource: SearchDepictionsDataSource? = null
override fun create() = SearchDepictionsDataSource(depictsClient, loadingStates, query)
.also { currentDataSource = it }
fun retryFailedRequest() {
currentDataSource?.retryFailedRequest()
}
}
sealed class LoadingState {
object InitialLoad : LoadingState()
object Loading : LoadingState()
object Complete : LoadingState()
object Error : LoadingState()
}

View file

@ -109,5 +109,11 @@ class DepictsPresenter @Inject constructor(
}
}
/**
* 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
*/
inline fun <reified T> proxy() = Proxy
.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, _, _ -> null } as T

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="@dimen/tiny_gap"
android:orientation="vertical">
<TextView
android:id="@+id/depictionNotFound"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:gravity="center"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/depictionsSearchResultsList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbarSize="@dimen/standard_gap"
android:scrollbars="vertical" />
<ProgressBar
android:id="@+id/depictionSearchInitialLoadProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"/>
</RelativeLayout>

View file

@ -1,37 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical" android:layout_width="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/tiny_gap">
<TextView
android:id="@+id/depicts_label"
android:textStyle="bold"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="@dimen/tiny_gap">
android:paddingTop="@dimen/tiny_gap"
android:text="Label"
android:textAppearance="?android:attr/textAppearanceMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/depicts_image"
app:layout_constraintTop_toTopOf="parent"
tools:text="Really really really really long long long label" />
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/depicts_image"
android:layout_width="70dp"
android:layout_height="70dp"
android:paddingRight="@dimen/tiny_gap"
app:placeholderImage="@drawable/ic_wikidata_logo_24dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="@dimen/tiny_gap"
android:text="Description"
tools:text="Really really really really long long long description description description"
app:layout_constraintStart_toEndOf="@+id/depicts_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/depicts_label" />
<TextView
android:id="@+id/depicts_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Label"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textStyle="bold"
android:paddingTop="@dimen/tiny_gap"
app:layout_constraintLeft_toRightOf="@+id/depicts_image"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Description"
android:paddingTop="@dimen/tiny_gap"
app:layout_constraintLeft_toRightOf="@+id/depicts_image"
app:layout_constraintTop_toBottomOf="@+id/depicts_label" />
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/depicts_image"
android:layout_width="70dp"
android:layout_height="70dp"
android:paddingRight="@dimen/tiny_gap"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:placeholderImage="@drawable/ic_wikidata_logo_24dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/small_gap">
<Button
android:id="@+id/listItemLoadMoreButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/load_more"
android:textColor="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -628,6 +628,6 @@ Upload your first media by tapping on the add button.</string>
<string name="ask_to_turn_location_on">Turn on location?</string>
<string name="nearby_needs_location">Nearby needs location enabled to work properly</string>
<string name="use_location_from_similar_image">Did you shoot these two pictures at the same place? Do you want to use the latitude/longitude of the picture on the right?</string>
<string name="load_more">Load More</string>
<string name="nearby_no_results">No places found, try changing your search criteria.</string>
</resources>

View file

@ -15,19 +15,19 @@ import kotlin.collections.ArrayList
class CampaignsPresenterTest {
@Mock
var okHttpJsonApiClient: OkHttpJsonApiClient? = null
lateinit var okHttpJsonApiClient: OkHttpJsonApiClient
lateinit var campaignsPresenter: CampaignsPresenter
@Mock
internal var view: ICampaignsView? = null
internal lateinit var view: ICampaignsView
@Mock
internal var campaignResponseDTO: CampaignResponseDTO? = null
internal lateinit var campaignResponseDTO: CampaignResponseDTO
lateinit var campaignsSingle: Single<CampaignResponseDTO>
@Mock
var campaign: Campaign? = null
lateinit var campaign: Campaign
lateinit var testScheduler: TestScheduler
@ -41,37 +41,37 @@ class CampaignsPresenterTest {
testScheduler=TestScheduler()
campaignsSingle= Single.just(campaignResponseDTO)
campaignsPresenter= CampaignsPresenter(okHttpJsonApiClient,testScheduler,testScheduler)
campaignsPresenter?.onAttachView(view)
Mockito.`when`(okHttpJsonApiClient?.campaigns).thenReturn(campaignsSingle)
campaignsPresenter.onAttachView(view)
Mockito.`when`(okHttpJsonApiClient.campaigns).thenReturn(campaignsSingle)
}
@Test
fun getCampaignsTestNoCampaigns() {
campaignsPresenter.getCampaigns()
verify(okHttpJsonApiClient)?.campaigns
verify(okHttpJsonApiClient).campaigns
testScheduler.triggerActions()
verify(view)?.showCampaigns(null)
verify(view).showCampaigns(null)
}
@Test
fun getCampaignsTestNonEmptyCampaigns() {
campaignsPresenter.getCampaigns()
var campaigns= ArrayList<Campaign>()
campaigns.add(campaign!!)
campaigns.add(campaign)
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC")
Mockito.`when`(campaignResponseDTO?.campaigns).thenReturn(campaigns)
Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns)
var calendar = Calendar.getInstance()
calendar.add(Calendar.DATE,-1)
val startDateString = simpleDateFormat.format(calendar.time).toString()
calendar= Calendar.getInstance()
calendar.add(Calendar.DATE,3)
val endDateString= simpleDateFormat.format(calendar.time).toString()
Mockito.`when`(campaign?.endDate).thenReturn(endDateString)
Mockito.`when`(campaign?.startDate).thenReturn(startDateString)
Mockito.`when`(campaignResponseDTO?.campaigns).thenReturn(campaigns)
verify(okHttpJsonApiClient)?.campaigns
Mockito.`when`(campaign.endDate).thenReturn(endDateString)
Mockito.`when`(campaign.startDate).thenReturn(startDateString)
Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns)
verify(okHttpJsonApiClient).campaigns
testScheduler.triggerActions()
verify(view)?.showCampaigns(campaign)
verify(view).showCampaigns(campaign)
}
}

View file

@ -1,6 +1,5 @@
package fr.free.nrw.commons.depictions
import org.mockito.Mockito.verify
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.depictions.Media.DepictedImagesFragment
import fr.free.nrw.commons.depictions.Media.DepictedImagesPresenter
@ -15,24 +14,26 @@ import org.junit.Test
import org.mockito.ArgumentMatchers
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
class DepictedImagesPresenterTest {
@Mock
internal var view: DepictedImagesFragment? = null
internal lateinit var view: DepictedImagesFragment
var depictedImagesPresenter: DepictedImagesPresenter? = null
var jsonKvStore: JsonKvStore? = null
lateinit var depictedImagesPresenter: DepictedImagesPresenter
@Mock
var depictsClient: DepictsClient? = null
lateinit var jsonKvStore: JsonKvStore
@Mock
var mediaClient: MediaClient? = null
lateinit var depictsClient: DepictsClient
var testScheduler: TestScheduler? = null
@Mock
lateinit var mediaClient: MediaClient
lateinit var testScheduler: TestScheduler
val mediaList: ArrayList<Media> = ArrayList()
@ -50,24 +51,26 @@ class DepictedImagesPresenterTest {
mediaList.add(mediaItem)
testObservable = Observable.just(mediaList)
depictedImagesPresenter = DepictedImagesPresenter(jsonKvStore, depictsClient, mediaClient, testScheduler, testScheduler)
depictedImagesPresenter?.onAttachView(view)
depictedImagesPresenter.onAttachView(view)
}
@Test
fun initList() {
Mockito.`when`(depictsClient?.fetchImagesForDepictedItem(ArgumentMatchers.anyString(),
ArgumentMatchers.anyInt())).thenReturn(testObservable)
depictedImagesPresenter?.initList("rabbit")
depictedImagesPresenter?.handleSuccess(mediaList)
Mockito.`when`(
depictsClient.fetchImagesForDepictedItem(ArgumentMatchers.anyString(),
ArgumentMatchers.anyInt())
).thenReturn(testObservable)
depictedImagesPresenter.initList("rabbit")
depictedImagesPresenter.handleSuccess(mediaList)
verify(view)?.handleSuccess(mediaList)
}
@Test
fun replaceTitlesWithCaptions() {
var stringObservable: Single<String>? = Single.just(String())
Mockito.`when`(mediaClient?.getCaptionByWikibaseIdentifier(ArgumentMatchers.anyString()))?.thenReturn(stringObservable)
depictedImagesPresenter?.replaceTitlesWithCaptions("File:rabbit.jpg", 0)
testScheduler?.triggerActions()
Mockito.`when`(mediaClient.getCaptionByWikibaseIdentifier(ArgumentMatchers.anyString()))?.thenReturn(stringObservable)
depictedImagesPresenter.replaceTitlesWithCaptions("File:rabbit.jpg", 0)
testScheduler.triggerActions()
verify(view)?.handleLabelforImage("", 0)
}
}

View file

@ -7,7 +7,6 @@ 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.Observable
import io.reactivex.Single
import io.reactivex.schedulers.TestScheduler
import org.junit.Before
import org.junit.Test
@ -20,19 +19,20 @@ import org.mockito.MockitoAnnotations
class SubDepictionListPresenterTest {
@Mock
internal var view: SubDepictionListContract.View? = null
internal lateinit var view: SubDepictionListContract.View
var subDepictionListPresenter: SubDepictionListPresenter? = null
lateinit var subDepictionListPresenter: SubDepictionListPresenter
var testScheduler: TestScheduler? = null
internal var recentSearchesDao: RecentSearchesDao? = null
lateinit var testScheduler: TestScheduler
@Mock
internal var depictsClient: DepictsClient? = null
internal lateinit var recentSearchesDao: RecentSearchesDao
@Mock
internal var okHttpJsonApiClient: OkHttpJsonApiClient? = null
internal lateinit var depictsClient: DepictsClient
@Mock
internal lateinit var okHttpJsonApiClient: OkHttpJsonApiClient
var testObservable: Observable<List<DepictedItem>>? = null
@ -49,22 +49,22 @@ class SubDepictionListPresenterTest {
depictedItems.add(depictedItem)
testObservable = Observable.just(depictedItems)
subDepictionListPresenter = SubDepictionListPresenter(recentSearchesDao, depictsClient, okHttpJsonApiClient, testScheduler, testScheduler)
subDepictionListPresenter?.onAttachView(view)
subDepictionListPresenter.onAttachView(view)
}
@Test
fun initSubDepictionListForParentClass() {
Mockito.`when`(okHttpJsonApiClient?.getParentQIDs(ArgumentMatchers.anyString())).thenReturn(testObservable)
subDepictionListPresenter?.initSubDepictionList("Q9394", true)
testScheduler?.triggerActions()
Mockito.`when`(okHttpJsonApiClient.getParentQIDs(ArgumentMatchers.anyString())).thenReturn(testObservable)
subDepictionListPresenter.initSubDepictionList("Q9394", true)
testScheduler.triggerActions()
verify(view)?.onSuccess(depictedItems)
}
@Test
fun initSubDepictionListForChildClass() {
Mockito.`when`(okHttpJsonApiClient?.getChildQIDs(ArgumentMatchers.anyString())).thenReturn(testObservable)
subDepictionListPresenter?.initSubDepictionList("Q9394", false)
testScheduler?.triggerActions()
Mockito.`when`(okHttpJsonApiClient.getChildQIDs(ArgumentMatchers.anyString())).thenReturn(testObservable)
subDepictionListPresenter.initSubDepictionList("Q9394", false)
testScheduler.triggerActions()
verify(view)?.onSuccess(depictedItems)
}
}

View file

@ -0,0 +1,53 @@
package fr.free.nrw.commons.explore.depictions
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.spy
import com.nhaarman.mockitokotlin2.verify
import io.reactivex.processors.PublishProcessor
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
class SearchDepictionsDataSourceFactoryTest {
@Mock
private lateinit var depictsClient: DepictsClient
@Mock
private lateinit var loadingStates: PublishProcessor<LoadingState>
private lateinit var factory: SearchDepictionsDataSourceFactory
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
factory = SearchDepictionsDataSourceFactory(depictsClient, "test", loadingStates)
}
@Test
fun `create returns a dataSource`() {
assertThat(
factory.create(),
`is`(SearchDepictionsDataSource(depictsClient, loadingStates, "test"))
)
}
@Test
@Ignore("Rewrite with Mockk constructor mocks")
fun `retryFailedRequest invokes method if not null`() {
val spyFactory = spy(factory)
val dataSource = mock<SearchDepictionsDataSource>()
Mockito.doReturn(dataSource).`when`(spyFactory).create()
factory.retryFailedRequest()
verify(dataSource).retryFailedRequest()
}
@Test
fun `retryFailedRequest does not invoke method if null`() {
factory.retryFailedRequest()
}
}

View file

@ -0,0 +1,106 @@
package fr.free.nrw.commons.explore.depictions
import androidx.paging.PositionalDataSource
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.explore.depictions.LoadingState.*
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Single
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.Schedulers
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
class SearchDepictionsDataSourceTest {
@Mock
private lateinit var depictsClient: DepictsClient
private lateinit var loadingStates: PublishProcessor<LoadingState>
private lateinit var searchDepictionsDataSource: SearchDepictionsDataSource
@Before
fun setUp() {
RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
MockitoAnnotations.initMocks(this)
loadingStates = PublishProcessor.create()
searchDepictionsDataSource =
SearchDepictionsDataSource(depictsClient, loadingStates, "test")
}
@After
fun tearDown() {
RxJavaPlugins.reset()
}
@Test
fun `loadInitial returns results and emits InitialLoad & Complete`() {
val params = PositionalDataSource.LoadInitialParams(0, 1, 2, false)
val callback = mock<PositionalDataSource.LoadInitialCallback<DepictedItem>>()
whenever(depictsClient.searchForDepictions("test", 1, 0))
.thenReturn(Single.just(emptyList()))
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadInitial(params, callback)
verify(callback).onResult(emptyList(), 0)
testSubscriber.assertValues(InitialLoad, Complete)
}
@Test
fun `loadInitial onError does not return results and emits InitialLoad & Error`() {
val params = PositionalDataSource.LoadInitialParams(0, 1, 2, false)
val callback = mock<PositionalDataSource.LoadInitialCallback<DepictedItem>>()
whenever(depictsClient.searchForDepictions("test", 1, 0))
.thenThrow(RuntimeException())
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadInitial(params, callback)
verify(callback, never()).onResult(any(), any())
testSubscriber.assertValues(InitialLoad, Error)
}
@Test
fun `loadRange returns results and emits Loading & Complete`() {
val callback: PositionalDataSource.LoadRangeCallback<DepictedItem> = mock()
val params = PositionalDataSource.LoadRangeParams(0, 1)
whenever(depictsClient.searchForDepictions("test", params.loadSize, params.startPosition))
.thenReturn(Single.just(emptyList()))
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadRange(params, callback)
verify(callback).onResult(emptyList())
testSubscriber.assertValues(Loading, Complete)
}
@Test
fun `loadRange onError does not return results and emits Loading & Error`() {
val callback: PositionalDataSource.LoadRangeCallback<DepictedItem> = mock()
val params = PositionalDataSource.LoadRangeParams(0, 1)
whenever(depictsClient.searchForDepictions("test", params.loadSize, params.startPosition))
.thenThrow(RuntimeException())
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadRange(params, callback)
verify(callback, never()).onResult(any())
testSubscriber.assertValues(Loading, Error)
}
@Test
fun `retryFailedRequest does nothing when null`() {
searchDepictionsDataSource.retryFailedRequest()
verifyNoMoreInteractions(depictsClient)
}
@Test
fun `retryFailedRequest retries last request`() {
val callback: PositionalDataSource.LoadRangeCallback<DepictedItem> = mock()
val params = PositionalDataSource.LoadRangeParams(0, 1)
whenever(depictsClient.searchForDepictions("test", params.loadSize, params.startPosition))
.thenThrow(RuntimeException()).thenReturn(Single.just(emptyList()))
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadRange(params, callback)
verify(callback, never()).onResult(any())
searchDepictionsDataSource.retryFailedRequest()
verify(callback).onResult(emptyList())
testSubscriber.assertValues(Loading, Error, Loading, Complete)
}
}

View file

@ -0,0 +1,136 @@
package fr.free.nrw.commons.explore.depictions
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import com.jraska.livedata.test
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.TestScheduler
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
class SearchDepictionsFragmentPresenterTest {
@Rule
@JvmField
var instantTaskExecutorRule = InstantTaskExecutorRule()
@Mock
internal lateinit var view: SearchDepictionsFragmentContract.View
private lateinit var searchDepictionsFragmentPresenter: SearchDepictionsFragmentPresenter
private lateinit var testScheduler: TestScheduler
@Mock
private lateinit var searchableDepictionsDataSourceFactory: SearchableDepictionsDataSourceFactory
private var loadingStates: PublishProcessor<LoadingState> = PublishProcessor.create()
private var searchResults: PublishProcessor<LiveData<PagedList<DepictedItem>>> =
PublishProcessor.create()
private var noItemLoadedEvent: PublishProcessor<Unit> = PublishProcessor.create()
@Before
@Throws(Exception::class)
fun setUp() {
MockitoAnnotations.initMocks(this)
whenever(searchableDepictionsDataSourceFactory.searchResults).thenReturn(searchResults)
whenever(searchableDepictionsDataSourceFactory.loadingStates).thenReturn(loadingStates)
whenever(searchableDepictionsDataSourceFactory.noItemsLoadedEvent)
.thenReturn(noItemLoadedEvent)
testScheduler = TestScheduler()
searchDepictionsFragmentPresenter = SearchDepictionsFragmentPresenter(
testScheduler,
searchableDepictionsDataSourceFactory
)
searchDepictionsFragmentPresenter.onAttachView(view)
}
@Test
fun `searchResults emission updates the view`() {
val pagedListLiveData = mock<LiveData<PagedList<DepictedItem>>>()
searchResults.offer(pagedListLiveData)
verify(view).observeSearchResults(pagedListLiveData)
}
@Test
fun `Loading offers a loading list item`() {
onLoadingState(LoadingState.Loading)
searchDepictionsFragmentPresenter.listFooterData.test()
.assertValue(listOf(FooterItem.LoadingItem))
}
@Test
fun `Complete offers an empty list item and hides initial loader`() {
onLoadingState(LoadingState.Complete)
searchDepictionsFragmentPresenter.listFooterData.test()
.assertValue(emptyList())
verify(view).hideInitialLoadProgress()
}
@Test
fun `InitialLoad shows initial loader`() {
onLoadingState(LoadingState.InitialLoad)
verify(view).showInitialLoadInProgress()
}
@Test
fun `Error offers a refresh list item, hides initial loader and shows error with a set text`() {
searchDepictionsFragmentPresenter.onQueryUpdated("test")
onLoadingState(LoadingState.Error)
verify(view).setEmptyViewText("test")
verify(view).showSnackbar()
verify(view).hideInitialLoadProgress()
searchDepictionsFragmentPresenter.listFooterData.test()
.assertValue(listOf(FooterItem.RefreshItem))
}
@Test
fun `Error offers a refresh list item, hides initial loader and shows error with a unset text`() {
onLoadingState(LoadingState.Error)
verify(view, never()).setEmptyViewText(any())
verify(view).showSnackbar()
verify(view).hideInitialLoadProgress()
searchDepictionsFragmentPresenter.listFooterData.test()
.assertValue(listOf(FooterItem.RefreshItem))
}
@Test
fun `no Items event sets empty view text`() {
searchDepictionsFragmentPresenter.onQueryUpdated("test")
noItemLoadedEvent.offer(Unit)
verify(view).setEmptyViewText("test")
}
@Test
fun `retryFailedRequest calls retry`() {
searchDepictionsFragmentPresenter.retryFailedRequest()
verify(searchableDepictionsDataSourceFactory).retryFailedRequest()
}
@Test
fun `onDetachView stops subscriptions`() {
searchDepictionsFragmentPresenter.onDetachView()
onLoadingState(LoadingState.Loading)
searchDepictionsFragmentPresenter.listFooterData.test()
.assertValue(emptyList())
}
@Test
fun `onQueryUpdated updates dataSourceFactory`() {
searchDepictionsFragmentPresenter.onQueryUpdated("test")
verify(searchableDepictionsDataSourceFactory).onQueryUpdated("test")
}
private fun onLoadingState(loadingState: LoadingState) {
loadingStates.offer(loadingState)
testScheduler.triggerActions()
}
}

View file

@ -1,57 +0,0 @@
package fr.free.nrw.commons.explore.depictions
import com.nhaarman.mockitokotlin2.whenever
import depictedItem
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao
import fr.free.nrw.commons.kvstore.JsonKvStore
import io.reactivex.Single
import io.reactivex.schedulers.TestScheduler
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
class SearchDepictionsPresenterTest {
@Mock
internal lateinit var view: SearchDepictionsFragmentContract.View
private lateinit var searchDepictionsFragmentPresenter: SearchDepictionsFragmentPresenter
private lateinit var testScheduler: TestScheduler
@Mock
private lateinit var jsonKvStore: JsonKvStore
@Mock
lateinit var recentSearchesDao: RecentSearchesDao
@Mock
lateinit var depictsClient: DepictsClient
@Before
@Throws(Exception::class)
fun setUp() {
MockitoAnnotations.initMocks(this)
testScheduler = TestScheduler()
searchDepictionsFragmentPresenter = SearchDepictionsFragmentPresenter(
jsonKvStore,
recentSearchesDao,
depictsClient,
testScheduler,
testScheduler
)
searchDepictionsFragmentPresenter.onAttachView(view)
}
@Test
fun updateDepictionList() {
val expectedList = listOf(depictedItem())
whenever(depictsClient.searchForDepictions("rabbit", 25, 0))
.thenReturn(Single.just(expectedList))
searchDepictionsFragmentPresenter.updateDepictionList("rabbit", 25, false)
testScheduler.triggerActions()
verify(view)?.onSuccess(expectedList)
}
}

View file

@ -0,0 +1,80 @@
package fr.free.nrw.commons.explore.depictions
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.processors.PublishProcessor
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
class SearchableDepictionsDataSourceFactoryTest {
@Mock
private lateinit var searchDepictionsDataSourceFactoryFactory: SearchDepictionsDataSourceFactoryFactory
@Mock
private lateinit var liveDataConverter: LiveDataConverter
private lateinit var factory: SearchableDepictionsDataSourceFactory
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
factory = SearchableDepictionsDataSourceFactory(
searchDepictionsDataSourceFactoryFactory,
liveDataConverter
)
}
@Test
fun `onQueryUpdated emits new liveData`() {
val (_, liveData) = expectNewLiveData()
factory.searchResults.test()
.also { factory.onQueryUpdated("test") }
.assertValue(liveData)
}
@Test
fun `onQueryUpdated invokes livedatconverter with no items emitter`() {
val (captor, _) = expectNewLiveData()
factory.onQueryUpdated("test")
factory.noItemsLoadedEvent.test()
.also { captor.firstValue.invoke() }
.assertValue(Unit)
}
/*
* Just for coverage, no way to really assert this
* */
@Test
fun `retryFailedRequest does nothing without a factory`() {
factory.retryFailedRequest()
}
@Test
fun `retryFailedRequest retries with a factory`() {
val (_, _, dataSourceFactory) = expectNewLiveData()
factory.onQueryUpdated("test")
factory.retryFailedRequest()
verify(dataSourceFactory).retryFailedRequest()
}
private fun expectNewLiveData(): Triple<KArgumentCaptor<() -> Unit>, LiveData<PagedList<DepictedItem>>, SearchDepictionsDataSourceFactory> {
val dataSourceFactory: SearchDepictionsDataSourceFactory = mock()
whenever(
searchDepictionsDataSourceFactoryFactory.create(
"test",
factory.loadingStates as PublishProcessor<LoadingState>
)
).thenReturn(dataSourceFactory)
val captor = argumentCaptor<() -> Unit>()
val liveData: LiveData<PagedList<DepictedItem>> = mock()
whenever(liveDataConverter.convert(eq(dataSourceFactory), captor.capture()))
.thenReturn(liveData)
return Triple(captor, liveData, dataSourceFactory)
}
}

View file

@ -12,7 +12,7 @@ import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import java.util.ArrayList
import java.util.*
/**
@ -21,11 +21,11 @@ import java.util.ArrayList
class UploadPresenterTest {
@Mock
internal var repository: UploadRepository? = null
internal lateinit var repository: UploadRepository
@Mock
internal var view: UploadContract.View? = null
internal lateinit var view: UploadContract.View
@Mock
var contribution: Contribution? = null
lateinit var contribution: Contribution
@Mock
private lateinit var uploadableFile: UploadableFile
@ -34,7 +34,7 @@ class UploadPresenterTest {
private lateinit var anotherUploadableFile: UploadableFile
@InjectMocks
var uploadPresenter: UploadPresenter? = null
lateinit var uploadPresenter: UploadPresenter
private var uploadableFiles: ArrayList<UploadableFile> = ArrayList()
@ -45,11 +45,11 @@ class UploadPresenterTest {
@Throws(Exception::class)
fun setUp() {
MockitoAnnotations.initMocks(this)
uploadPresenter?.onAttachView(view)
`when`(repository?.buildContributions()).thenReturn(Observable.just(contribution))
uploadPresenter.onAttachView(view)
`when`(repository.buildContributions()).thenReturn(Observable.just(contribution))
uploadableFiles.add(uploadableFile)
`when`(view?.uploadableFiles).thenReturn(uploadableFiles)
`when`(uploadableFile?.filePath).thenReturn("data://test")
`when`(view.uploadableFiles).thenReturn(uploadableFiles)
`when`(uploadableFile.filePath).thenReturn("data://test")
}
/**
@ -57,12 +57,12 @@ class UploadPresenterTest {
*/
@Test
fun handleSubmitTestUserLoggedIn() {
`when`(view?.isLoggedIn).thenReturn(true)
uploadPresenter?.handleSubmit()
verify(view)?.isLoggedIn
verify(view)?.showProgress(true)
verify(repository)?.buildContributions()
verify(repository)?.buildContributions()
`when`(view.isLoggedIn).thenReturn(true)
uploadPresenter.handleSubmit()
verify(view).isLoggedIn
verify(view).showProgress(true)
verify(repository).buildContributions()
verify(repository).buildContributions()
}
/**
@ -70,10 +70,10 @@ class UploadPresenterTest {
*/
@Test
fun handleSubmitTestUserNotLoggedIn() {
`when`(view?.isLoggedIn).thenReturn(false)
uploadPresenter?.handleSubmit()
verify(view)?.isLoggedIn
verify(view)?.askUserToLogIn()
`when`(view.isLoggedIn).thenReturn(false)
uploadPresenter.handleSubmit()
verify(view).isLoggedIn
verify(view).askUserToLogIn()
}
@ -88,9 +88,9 @@ class UploadPresenterTest {
fun hideTopCardWhenReachedTheLastFile(){
deletePictureBaseTest()
uploadableFiles.add(uploadableFile)
uploadPresenter?.deletePictureAtIndex(0)
verify(view)?.showHideTopCard(false)
verify(repository)?.deletePicture(ArgumentMatchers.anyString())
uploadPresenter.deletePictureAtIndex(0)
verify(view).showHideTopCard(false)
verify(repository).deletePicture(ArgumentMatchers.anyString())
}
/**
@ -100,11 +100,11 @@ class UploadPresenterTest {
fun testDeleteWhenSingleUpload(){
deletePictureBaseTest()
uploadableFiles.add(uploadableFile)
uploadPresenter?.deletePictureAtIndex(0)
verify(view)?.showHideTopCard(false)
verify(repository)?.deletePicture(ArgumentMatchers.anyString())
verify(view)?.showMessage(ArgumentMatchers.anyInt())//As there is only one while which we are asking for deletion, upload should be cancelled and this flow should be triggered
verify(view)?.finish()
uploadPresenter.deletePictureAtIndex(0)
verify(view).showHideTopCard(false)
verify(repository).deletePicture(ArgumentMatchers.anyString())
verify(view).showMessage(ArgumentMatchers.anyInt())//As there is only one while which we are asking for deletion, upload should be cancelled and this flow should be triggered
verify(view).finish()
}
/**
@ -115,8 +115,8 @@ class UploadPresenterTest {
deletePictureBaseTest()
uploadableFiles.add(uploadableFile)
uploadableFiles.add(anotherUploadableFile)
uploadPresenter?.deletePictureAtIndex(0)
verify(view)?.onUploadMediaDeleted(0)
verify(view)?.updateTopCardTitle()
uploadPresenter.deletePictureAtIndex(0)
verify(view).onUploadMediaDeleted(0)
verify(view).updateTopCardTitle()
}
}

View file

@ -24,6 +24,7 @@ ROOM_VERSION=2.2.3
PREFERENCE_VERSION=1.1.0
CORE_KTX_VERSION=1.2.0
ADAPTER_DELEGATES_VERSION=4.3.0
PAGING_VERSION=2.1.2
MULTIDEX_VERSION=2.0.1
systemProp.http.proxyPort=0