Merge pull request #1449 from psh/consolidate-networking-libraries-step-1

Code cleanup + automated testing around GPS Categories
This commit is contained in:
neslihanturan 2018-05-27 08:41:03 +03:00 committed by GitHub
commit f0b8039778
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 619 additions and 313 deletions

View file

@ -11,7 +11,6 @@ dependencies {
implementation 'fr.avianey.com.viewpagerindicator:library:2.4.1.1@aar' implementation 'fr.avianey.com.viewpagerindicator:library:2.4.1.1@aar'
implementation 'in.yuvi:http.fluent:1.3' implementation 'in.yuvi:http.fluent:1.3'
implementation 'com.github.chrisbanes:PhotoView:2.0.0' implementation 'com.github.chrisbanes:PhotoView:2.0.0'
implementation 'com.android.volley:volley:1.0.0'
implementation 'ch.acra:acra:4.9.2' implementation 'ch.acra:acra:4.9.2'
implementation 'org.mediawiki:api:1.3' implementation 'org.mediawiki:api:1.3'
implementation 'commons-codec:commons-codec:1.10' implementation 'commons-codec:commons-codec:1.10'

View file

@ -1,5 +1,4 @@
-dontobfuscate -dontobfuscate
-keep class org.apache.http.** { *; } -keep class org.apache.http.** { *; }
-dontwarn org.apache.http.** -dontwarn org.apache.http.**
-keep class fr.free.nrw.commons.upload.MwVolleyApi$Page {*;}
-keep class android.support.v7.widget.ShareActionProvider { *; } -keep class android.support.v7.widget.ShareActionProvider { *; }

View file

@ -7,18 +7,25 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import fr.free.nrw.commons.upload.MwVolleyApi; import javax.inject.Inject;
import javax.inject.Singleton;
import fr.free.nrw.commons.upload.GpsCategoryModel;
import timber.log.Timber; import timber.log.Timber;
@Singleton
public class CacheController { public class CacheController {
private final GpsCategoryModel gpsCategoryModel;
private final QuadTree<List<String>> quadTree;
private double x, y; private double x, y;
private QuadTree<List<String>> quadTree;
private double xMinus, xPlus, yMinus, yPlus; private double xMinus, xPlus, yMinus, yPlus;
private static final int EARTH_RADIUS = 6378137; private static final int EARTH_RADIUS = 6378137;
public CacheController() { @Inject
CacheController(GpsCategoryModel gpsCategoryModel) {
this.gpsCategoryModel = gpsCategoryModel;
quadTree = new QuadTree<>(-180, -90, +180, +90); quadTree = new QuadTree<>(-180, -90, +180, +90);
} }
@ -31,8 +38,8 @@ public class CacheController {
public void cacheCategory() { public void cacheCategory() {
List<String> pointCatList = new ArrayList<>(); List<String> pointCatList = new ArrayList<>();
if (MwVolleyApi.GpsCatExists.getGpsCatExists()) { if (gpsCategoryModel.getGpsCatExists()) {
pointCatList.addAll(MwVolleyApi.getGpsCat()); pointCatList.addAll(gpsCategoryModel.getCategoryList());
Timber.d("Categories being cached: %s", pointCatList); Timber.d("Categories being cached: %s", pointCatList);
} else { } else {
Timber.d("No categories found, so no categories cached"); Timber.d("No categories found, so no categories cached");
@ -65,7 +72,7 @@ public class CacheController {
} }
//Based on algorithm at http://gis.stackexchange.com/questions/2951/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters //Based on algorithm at http://gis.stackexchange.com/questions/2951/algorithm-for-offsetting-a-latitude-longitude-by-some-amount-of-meters
public void convertCoordRange() { private void convertCoordRange() {
//Position, decimal degrees //Position, decimal degrees
double lat = y; double lat = y;
double lon = x; double lon = x;

View file

@ -39,7 +39,7 @@ import butterknife.ButterKnife;
import fr.free.nrw.commons.R; import fr.free.nrw.commons.R;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.mwapi.MediaWikiApi; import fr.free.nrw.commons.mwapi.MediaWikiApi;
import fr.free.nrw.commons.upload.MwVolleyApi; import fr.free.nrw.commons.upload.GpsCategoryModel;
import fr.free.nrw.commons.utils.StringSortingUtils; import fr.free.nrw.commons.utils.StringSortingUtils;
import fr.free.nrw.commons.utils.ViewUtil; import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.Observable; import io.reactivex.Observable;
@ -73,6 +73,7 @@ public class CategorizationFragment extends CommonsDaggerSupportFragment {
@Inject @Named("prefs") SharedPreferences prefsPrefs; @Inject @Named("prefs") SharedPreferences prefsPrefs;
@Inject @Named("direct_nearby_upload_prefs") SharedPreferences directPrefs; @Inject @Named("direct_nearby_upload_prefs") SharedPreferences directPrefs;
@Inject CategoryDao categoryDao; @Inject CategoryDao categoryDao;
@Inject GpsCategoryModel gpsCategoryModel;
private RVRendererAdapter<CategoryItem> categoriesAdapter; private RVRendererAdapter<CategoryItem> categoriesAdapter;
private OnCategoriesSaveHandler onCategoriesSaveHandler; private OnCategoriesSaveHandler onCategoriesSaveHandler;
@ -253,7 +254,6 @@ public class CategorizationFragment extends CommonsDaggerSupportFragment {
} }
private Observable<CategoryItem> defaultCategories() { private Observable<CategoryItem> defaultCategories() {
Observable<CategoryItem> directCat = directCategories(); Observable<CategoryItem> directCat = directCategories();
if (hasDirectCategories) { if (hasDirectCategories) {
Timber.d("Image has direct Cat"); Timber.d("Image has direct Cat");
@ -287,9 +287,7 @@ public class CategorizationFragment extends CommonsDaggerSupportFragment {
} }
private Observable<CategoryItem> gpsCategories() { private Observable<CategoryItem> gpsCategories() {
return Observable.fromIterable( return Observable.fromIterable(gpsCategoryModel.getCategoryList())
MwVolleyApi.GpsCatExists.getGpsCatExists()
? MwVolleyApi.getGpsCat() : new ArrayList<>())
.map(name -> new CategoryItem(name, false)); .map(name -> new CategoryItem(name, false));
} }

View file

@ -9,17 +9,16 @@ import dagger.android.support.AndroidSupportInjectionModule;
import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.MediaWikiImageView; import fr.free.nrw.commons.MediaWikiImageView;
import fr.free.nrw.commons.auth.LoginActivity; import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.contributions.ContributionsActivity;
import fr.free.nrw.commons.contributions.ContributionsSyncAdapter; import fr.free.nrw.commons.contributions.ContributionsSyncAdapter;
import fr.free.nrw.commons.delete.DeleteTask; import fr.free.nrw.commons.delete.DeleteTask;
import fr.free.nrw.commons.modifications.ModificationsSyncAdapter; import fr.free.nrw.commons.modifications.ModificationsSyncAdapter;
import fr.free.nrw.commons.settings.SettingsFragment;
import fr.free.nrw.commons.nearby.PlaceRenderer; import fr.free.nrw.commons.nearby.PlaceRenderer;
import fr.free.nrw.commons.settings.SettingsFragment;
@Singleton @Singleton
@Component(modules = { @Component(modules = {
CommonsApplicationModule.class, CommonsApplicationModule.class,
NetworkingModule.class,
AndroidInjectionModule.class, AndroidInjectionModule.class,
AndroidSupportInjectionModule.class, AndroidSupportInjectionModule.class,
ActivityBuilderModule.class, ActivityBuilderModule.class,

View file

@ -6,21 +6,15 @@ import android.content.SharedPreferences;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import android.support.v4.util.LruCache; import android.support.v4.util.LruCache;
import com.google.gson.Gson;
import javax.inject.Named; import javax.inject.Named;
import javax.inject.Singleton; import javax.inject.Singleton;
import dagger.Module; import dagger.Module;
import dagger.Provides; import dagger.Provides;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.auth.AccountUtil; import fr.free.nrw.commons.auth.AccountUtil;
import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.caching.CacheController;
import fr.free.nrw.commons.data.DBOpenHelper; import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.location.LocationServiceManager; import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.mwapi.ApacheHttpClientMediaWikiApi;
import fr.free.nrw.commons.mwapi.MediaWikiApi; import fr.free.nrw.commons.mwapi.MediaWikiApi;
import fr.free.nrw.commons.nearby.NearbyPlaces; import fr.free.nrw.commons.nearby.NearbyPlaces;
import fr.free.nrw.commons.upload.UploadController; import fr.free.nrw.commons.upload.UploadController;
@ -33,7 +27,6 @@ import static fr.free.nrw.commons.modifications.ModificationsContentProvider.MOD
@SuppressWarnings({"WeakerAccess", "unused"}) @SuppressWarnings({"WeakerAccess", "unused"})
public class CommonsApplicationModule { public class CommonsApplicationModule {
public static final String CATEGORY_AUTHORITY = "fr.free.nrw.commons.categories.contentprovider"; public static final String CATEGORY_AUTHORITY = "fr.free.nrw.commons.categories.contentprovider";
public static final long OK_HTTP_CACHE_SIZE = 10 * 1024 * 1024;
private Context applicationContext; private Context applicationContext;
@ -117,37 +110,12 @@ public class CommonsApplicationModule {
return new SessionManager(context, mediaWikiApi, sharedPreferences); return new SessionManager(context, mediaWikiApi, sharedPreferences);
} }
@Provides
@Singleton
public MediaWikiApi provideMediaWikiApi(Context context,
@Named("default_preferences") SharedPreferences defaultPreferences,
@Named("category_prefs") SharedPreferences categoryPrefs,
Gson gson) {
return new ApacheHttpClientMediaWikiApi(context, BuildConfig.WIKIMEDIA_API_HOST, defaultPreferences, categoryPrefs, gson);
}
@Provides @Provides
@Singleton @Singleton
public LocationServiceManager provideLocationServiceManager(Context context) { public LocationServiceManager provideLocationServiceManager(Context context) {
return new LocationServiceManager(context); return new LocationServiceManager(context);
} }
/**
* 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
*/
@Provides
@Singleton
public Gson provideGson() {
return new Gson();
}
@Provides
@Singleton
public CacheController provideCacheController() {
return new CacheController();
}
@Provides @Provides
@Singleton @Singleton
public DBOpenHelper provideDBOpenHelper(Context context) { public DBOpenHelper provideDBOpenHelper(Context context) {

View file

@ -0,0 +1,59 @@
package fr.free.nrw.commons.di;
import android.content.Context;
import android.content.SharedPreferences;
import android.support.annotation.NonNull;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import javax.inject.Named;
import javax.inject.Singleton;
import dagger.Module;
import dagger.Provides;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.mwapi.ApacheHttpClientMediaWikiApi;
import fr.free.nrw.commons.mwapi.MediaWikiApi;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
@Module
@SuppressWarnings({"WeakerAccess", "unused"})
public class NetworkingModule {
public static final long OK_HTTP_CACHE_SIZE = 10 * 1024 * 1024;
@Provides
@Singleton
public OkHttpClient provideOkHttpClient() {
return new OkHttpClient.Builder().build();
}
@Provides
@Singleton
public MediaWikiApi provideMediaWikiApi(Context context,
@Named("default_preferences") SharedPreferences defaultPreferences,
@Named("category_prefs") SharedPreferences categoryPrefs,
Gson gson) {
return new ApacheHttpClientMediaWikiApi(context, BuildConfig.WIKIMEDIA_API_HOST, defaultPreferences, categoryPrefs, gson);
}
@Provides
@Named("commons_mediawiki_url")
@NonNull
@SuppressWarnings("ConstantConditions")
public HttpUrl provideMwUrl() {
return HttpUrl.parse(BuildConfig.COMMONS_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
*/
@Provides
@Singleton
public Gson provideGson() {
return new GsonBuilder().create();
}
}

View file

@ -0,0 +1,101 @@
package fr.free.nrw.commons.mwapi;
import com.google.gson.Gson;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Named;
import fr.free.nrw.commons.mwapi.model.ApiResponse;
import fr.free.nrw.commons.mwapi.model.Page;
import fr.free.nrw.commons.mwapi.model.PageCategory;
import io.reactivex.Single;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import timber.log.Timber;
/**
* Uses the OkHttp library to implement calls to the Commons MediaWiki API to match GPS coordinates
* with nearby Commons categories. Parses the results using GSON to obtain a list of relevant
* categories. Note: that caller is responsible for executing the request() method on a background
* thread.
*/
public class CategoryApi {
private final OkHttpClient okHttpClient;
private final HttpUrl mwUrl;
private final Gson gson;
@Inject
public CategoryApi(OkHttpClient okHttpClient, Gson gson,
@Named("commons_mediawiki_url") HttpUrl mwUrl) {
this.okHttpClient = okHttpClient;
this.mwUrl = mwUrl;
this.gson = gson;
}
public Single<List<String>> request(String coords) {
return Single.fromCallable(() -> {
HttpUrl apiUrl = buildUrl(coords);
Timber.d("URL: %s", apiUrl.toString());
Request request = new Request.Builder().get().url(apiUrl).build();
Response response = okHttpClient.newCall(request).execute();
ResponseBody body = response.body();
if (body == null) {
return Collections.emptyList();
}
ApiResponse apiResponse = gson.fromJson(body.charStream(), ApiResponse.class);
Set<String> categories = new LinkedHashSet<>();
if (apiResponse != null && apiResponse.hasPages()) {
for (Page page : apiResponse.query.pages) {
for (PageCategory category : page.getCategories()) {
categories.add(category.withoutPrefix());
}
}
}
return new ArrayList<>(categories);
});
}
/**
* Builds URL with image coords for MediaWiki API calls
* Example URL: https://commons.wikimedia.org/w/api.php?action=query&prop=categories|coordinates|pageprops&format=json&clshow=!hidden&coprop=type|name|dim|country|region|globe&codistancefrompoint=38.11386944444445|13.356263888888888&generator=geosearch&redirects=&ggscoord=38.11386944444445|1.356263888888888&ggsradius=100&ggslimit=10&ggsnamespace=6&ggsprop=type|name|dim|country|region|globe&ggsprimary=all&formatversion=2
*
* @param coords Coordinates to build query with
* @return URL for API query
*/
private HttpUrl buildUrl(String coords) {
return mwUrl.newBuilder()
.addPathSegment("w")
.addPathSegment("api.php")
.addQueryParameter("action", "query")
.addQueryParameter("prop", "categories|coordinates|pageprops")
.addQueryParameter("format", "json")
.addQueryParameter("clshow", "!hidden")
.addQueryParameter("coprop", "type|name|dim|country|region|globe")
.addQueryParameter("codistancefrompoint", coords)
.addQueryParameter("generator", "geosearch")
.addQueryParameter("ggscoord", coords)
.addQueryParameter("ggsradius", "10000")
.addQueryParameter("ggslimit", "10")
.addQueryParameter("ggsnamespace", "6")
.addQueryParameter("ggsprop", "type|name|dim|country|region|globe")
.addQueryParameter("ggsprimary", "all")
.addQueryParameter("formatversion", "2")
.build();
}
}

View file

@ -0,0 +1,12 @@
package fr.free.nrw.commons.mwapi.model;
public class ApiResponse {
public Query query;
public ApiResponse() {
}
public boolean hasPages() {
return query != null && query.pages != null;
}
}

View file

@ -0,0 +1,17 @@
package fr.free.nrw.commons.mwapi.model;
import android.support.annotation.NonNull;
public class Page {
public String title;
public PageCategory[] categories;
public PageCategory category;
public Page() {
}
@NonNull
public PageCategory[] getCategories() {
return categories != null ? categories : new PageCategory[0];
}
}

View file

@ -0,0 +1,12 @@
package fr.free.nrw.commons.mwapi.model;
public class PageCategory {
public String title;
public PageCategory() {
}
public String withoutPrefix() {
return title != null ? title.replace("Category:", "") : "";
}
}

View file

@ -0,0 +1,10 @@
package fr.free.nrw.commons.mwapi.model;
public class Query {
public Page[] pages;
public Query() {
pages = new Page[0];
}
}

View file

@ -0,0 +1,40 @@
package fr.free.nrw.commons.upload;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Singleton;
@Singleton
public class GpsCategoryModel {
private Set<String> categorySet;
@Inject
public GpsCategoryModel() {
clear();
}
public void clear() {
categorySet = new HashSet<>();
}
public boolean getGpsCatExists() {
return !categorySet.isEmpty();
}
public List<String> getCategoryList() {
return new ArrayList<>(categorySet);
}
public void setCategoryList(List<String> categoryList) {
clear();
categorySet.addAll(categoryList != null ? categoryList : new ArrayList<>());
}
public void add(String categoryString) {
categorySet.add(categoryString);
}
}

View file

@ -1,249 +0,0 @@
package fr.free.nrw.commons.upload;
import android.content.Context;
import android.net.Uri;
import com.android.volley.Cache;
import com.android.volley.NetworkResponse;
import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.HttpHeaderParser;
import com.android.volley.toolbox.JsonRequest;
import com.android.volley.toolbox.Volley;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import timber.log.Timber;
/**
* Uses the Volley library to implement asynchronous calls to the Commons MediaWiki API to match
* GPS coordinates with nearby Commons categories. Parses the results using GSON to obtain a list
* of relevant categories.
*/
public class MwVolleyApi {
private static RequestQueue REQUEST_QUEUE;
private static final Gson GSON = new GsonBuilder().create();
private static Set<String> categorySet;
private static List<String> categoryList;
private static final String MWURL = "https://commons.wikimedia.org/";
private final Context context;
public MwVolleyApi(Context context) {
this.context = context;
categorySet = new HashSet<>();
}
public static List<String> getGpsCat() {
return categoryList;
}
public static void setGpsCat(List<String> cachedList) {
categoryList = new ArrayList<>();
categoryList.addAll(cachedList);
Timber.d("Setting GPS cats from cache: %s", categoryList);
}
public void request(String coords) {
String apiUrl = buildUrl(coords);
Timber.d("URL: %s", apiUrl);
JsonRequest<QueryResponse> request = new QueryRequest(apiUrl,
new LogResponseListener<>(), new LogResponseErrorListener());
getQueue().add(request);
}
/**
* Builds URL with image coords for MediaWiki API calls
* Example URL: https://commons.wikimedia.org/w/api.php?action=query&prop=categories|coordinates|pageprops&format=json&clshow=!hidden&coprop=type|name|dim|country|region|globe&codistancefrompoint=38.11386944444445|13.356263888888888&generator=geosearch&redirects=&ggscoord=38.11386944444445|1.356263888888888&ggsradius=100&ggslimit=10&ggsnamespace=6&ggsprop=type|name|dim|country|region|globe&ggsprimary=all&formatversion=2
* @param coords Coordinates to build query with
* @return URL for API query
*/
private String buildUrl(String coords) {
Uri.Builder builder = Uri.parse(MWURL).buildUpon();
builder.appendPath("w")
.appendPath("api.php")
.appendQueryParameter("action", "query")
.appendQueryParameter("prop", "categories|coordinates|pageprops")
.appendQueryParameter("format", "json")
.appendQueryParameter("clshow", "!hidden")
.appendQueryParameter("coprop", "type|name|dim|country|region|globe")
.appendQueryParameter("codistancefrompoint", coords)
.appendQueryParameter("generator", "geosearch")
.appendQueryParameter("ggscoord", coords)
.appendQueryParameter("ggsradius", "10000")
.appendQueryParameter("ggslimit", "10")
.appendQueryParameter("ggsnamespace", "6")
.appendQueryParameter("ggsprop", "type|name|dim|country|region|globe")
.appendQueryParameter("ggsprimary", "all")
.appendQueryParameter("formatversion", "2");
return builder.toString();
}
private synchronized RequestQueue getQueue() {
if (REQUEST_QUEUE == null) {
REQUEST_QUEUE = Volley.newRequestQueue(context);
}
return REQUEST_QUEUE;
}
private static class LogResponseListener<T> implements Response.Listener<T> {
@Override
public void onResponse(T response) {
Timber.d(response.toString());
}
}
private static class LogResponseErrorListener implements Response.ErrorListener {
@Override
public void onErrorResponse(VolleyError error) {
Timber.e(error.toString());
}
}
private static class QueryRequest extends JsonRequest<QueryResponse> {
public QueryRequest(String url,
Response.Listener<QueryResponse> listener,
Response.ErrorListener errorListener) {
super(Request.Method.GET, url, null, listener, errorListener);
}
@Override
protected Response<QueryResponse> parseNetworkResponse(NetworkResponse response) {
String json = parseString(response);
QueryResponse queryResponse = GSON.fromJson(json, QueryResponse.class);
return Response.success(queryResponse, cacheEntry(response));
}
private Cache.Entry cacheEntry(NetworkResponse response) {
return HttpHeaderParser.parseCacheHeaders(response);
}
private String parseString(NetworkResponse response) {
try {
return new String(response.data, HttpHeaderParser.parseCharset(response.headers));
} catch (UnsupportedEncodingException e) {
return new String(response.data);
}
}
}
public static class GpsCatExists {
private static boolean gpsCatExists;
public static void setGpsCatExists(boolean gpsCat) {
gpsCatExists = gpsCat;
}
public static boolean getGpsCatExists() {
return gpsCatExists;
}
}
private static class QueryResponse {
private Query query = new Query();
private String printSet() {
if (categorySet == null || categorySet.isEmpty()) {
GpsCatExists.setGpsCatExists(false);
Timber.d("gpsCatExists=%b", GpsCatExists.getGpsCatExists());
return "No collection of categories";
} else {
GpsCatExists.setGpsCatExists(true);
Timber.d("gpsCatExists=%b", GpsCatExists.getGpsCatExists());
return "CATEGORIES FOUND" + categorySet.toString();
}
}
@Override
public String toString() {
if (query != null) {
return "query=" + query.toString() + "\n" + printSet();
} else {
return "No pages found";
}
}
}
private static class Query {
private Page [] pages;
@Override
public String toString() {
StringBuilder builder = new StringBuilder("pages=" + "\n");
if (pages != null) {
for (Page page : pages) {
builder.append(page.toString());
builder.append("\n");
}
builder.replace(builder.length() - 1, builder.length(), "");
return builder.toString();
} else {
return "No pages found";
}
}
}
public static class Page {
private int pageid;
private int ns;
private String title;
private Category[] categories;
private Category category;
public Page() {
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder("PAGEID=" + pageid + " ns=" + ns + " title=" + title + "\n" + " CATEGORIES= ");
if (categories == null || categories.length == 0) {
builder.append("no categories exist\n");
} else {
for (Category category : categories) {
builder.append(category.toString());
builder.append("\n");
if (category != null) {
String categoryString = category.toString().replace("Category:", "");
categorySet.add(categoryString);
}
}
}
categoryList = new ArrayList<>(categorySet);
builder.replace(builder.length() - 1, builder.length(), "");
return builder.toString();
}
}
private static class Category {
private String title;
@Override
public String toString() {
return title;
}
}
}

View file

@ -1,6 +1,7 @@
package fr.free.nrw.commons.upload; package fr.free.nrw.commons.upload;
import android.Manifest; import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.animation.Animator; import android.animation.Animator;
import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorListenerAdapter;
@ -73,8 +74,10 @@ import fr.free.nrw.commons.modifications.ModificationsContentProvider;
import fr.free.nrw.commons.modifications.ModifierSequence; import fr.free.nrw.commons.modifications.ModifierSequence;
import fr.free.nrw.commons.modifications.ModifierSequenceDao; import fr.free.nrw.commons.modifications.ModifierSequenceDao;
import fr.free.nrw.commons.modifications.TemplateRemoveModifier; import fr.free.nrw.commons.modifications.TemplateRemoveModifier;
import fr.free.nrw.commons.mwapi.CategoryApi;
import fr.free.nrw.commons.mwapi.MediaWikiApi; import fr.free.nrw.commons.mwapi.MediaWikiApi;
import io.reactivex.schedulers.Schedulers;
import fr.free.nrw.commons.utils.ViewUtil; import fr.free.nrw.commons.utils.ViewUtil;
import timber.log.Timber; import timber.log.Timber;
@ -123,8 +126,12 @@ public class ShareActivity
@Inject @Inject
ModifierSequenceDao modifierSequenceDao; ModifierSequenceDao modifierSequenceDao;
@Inject @Inject
CategoryApi apiCall;
@Inject
@Named("default_preferences") @Named("default_preferences")
SharedPreferences prefs; SharedPreferences prefs;
@Inject
GpsCategoryModel gpsCategoryModel;
private String source; private String source;
private String mimeType; private String mimeType;
@ -685,8 +692,9 @@ public class ShareActivity
/** /**
* Initiates retrieval of image coordinates or user coordinates, and caching of coordinates. * Initiates retrieval of image coordinates or user coordinates, and caching of coordinates.
* Then initiates the calls to MediaWiki API through an instance of MwVolleyApi. * Then initiates the calls to MediaWiki API through an instance of CategpryApi.
*/ */
@SuppressLint("CheckResult")
public void useImageCoords() { public void useImageCoords() {
if (decimalCoords != null) { if (decimalCoords != null) {
Timber.d("Decimal coords of image: %s", decimalCoords); Timber.d("Decimal coords of image: %s", decimalCoords);
@ -699,20 +707,27 @@ public class ShareActivity
cacheController.setQtPoint(decLongitude, decLatitude); cacheController.setQtPoint(decLongitude, decLatitude);
} }
MwVolleyApi apiCall = new MwVolleyApi(this);
List<String> displayCatList = cacheController.findCategory(); List<String> displayCatList = cacheController.findCategory();
boolean catListEmpty = displayCatList.isEmpty(); boolean catListEmpty = displayCatList.isEmpty();
// If no categories found in cache, call MediaWiki API to match image coords with nearby Commons categories // If no categories found in cache, call MediaWiki API to match image coords with nearby Commons categories
if (catListEmpty) { if (catListEmpty) {
cacheFound = false; cacheFound = false;
apiCall.request(decimalCoords); apiCall.request(decimalCoords)
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe(
gpsCategoryModel::setCategoryList,
throwable -> {
Timber.e(throwable);
gpsCategoryModel.clear();
}
);
Timber.d("displayCatList size 0, calling MWAPI %s", displayCatList); Timber.d("displayCatList size 0, calling MWAPI %s", displayCatList);
} else { } else {
cacheFound = true; cacheFound = true;
Timber.d("Cache found, setting categoryList in MwVolleyApi to %s", displayCatList); Timber.d("Cache found, setting categoryList in model to %s", displayCatList);
MwVolleyApi.setGpsCat(displayCatList); gpsCategoryModel.setCategoryList(displayCatList);
} }
}else{ }else{
Timber.d("EXIF: no coords"); Timber.d("EXIF: no coords");

View file

@ -1,14 +1,13 @@
package fr.free.nrw.commons package fr.free.nrw.commons
import android.content.ContentProviderClient
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.support.v4.util.LruCache import android.support.v4.util.LruCache
import com.google.gson.Gson
import com.nhaarman.mockito_kotlin.mock import com.nhaarman.mockito_kotlin.mock
import com.squareup.leakcanary.RefWatcher import com.squareup.leakcanary.RefWatcher
import fr.free.nrw.commons.auth.AccountUtil import fr.free.nrw.commons.auth.AccountUtil
import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.caching.CacheController
import fr.free.nrw.commons.data.DBOpenHelper import fr.free.nrw.commons.data.DBOpenHelper
import fr.free.nrw.commons.di.CommonsApplicationComponent import fr.free.nrw.commons.di.CommonsApplicationComponent
import fr.free.nrw.commons.di.CommonsApplicationModule import fr.free.nrw.commons.di.CommonsApplicationModule
@ -33,21 +32,30 @@ class TestCommonsApplication : CommonsApplication() {
override fun setupLeakCanary(): RefWatcher = RefWatcher.DISABLED override fun setupLeakCanary(): RefWatcher = RefWatcher.DISABLED
} }
@Suppress("MemberVisibilityCanBePrivate")
class MockCommonsApplicationModule(appContext: Context) : CommonsApplicationModule(appContext) { class MockCommonsApplicationModule(appContext: Context) : CommonsApplicationModule(appContext) {
val accountUtil: AccountUtil = mock() val accountUtil: AccountUtil = mock()
val appSharedPreferences: SharedPreferences = mock() val appSharedPreferences: SharedPreferences = mock()
val defaultSharedPreferences: SharedPreferences = mock() val defaultSharedPreferences: SharedPreferences = mock()
val categorySharedPreferences: SharedPreferences = mock()
val otherSharedPreferences: SharedPreferences = mock() val otherSharedPreferences: SharedPreferences = mock()
val uploadController: UploadController = mock() val uploadController: UploadController = mock()
val mockSessionManager: SessionManager = mock() val mockSessionManager: SessionManager = mock()
val mediaWikiApi: MediaWikiApi = mock()
val locationServiceManager: LocationServiceManager = mock() val locationServiceManager: LocationServiceManager = mock()
val cacheController: CacheController = mock()
val mockDbOpenHelper: DBOpenHelper = mock() val mockDbOpenHelper: DBOpenHelper = mock()
val nearbyPlaces: NearbyPlaces = mock() val nearbyPlaces: NearbyPlaces = mock()
val lruCache: LruCache<String, String> = mock() val lruCache: LruCache<String, String> = mock()
val gson: Gson = Gson() val categoryClient: ContentProviderClient = mock()
val contributionClient: ContentProviderClient = mock()
val modificationClient: ContentProviderClient = mock()
val uploadPrefs: SharedPreferences = mock()
override fun provideCategoryContentProviderClient(context: Context?): ContentProviderClient = categoryClient
override fun provideContributionContentProviderClient(context: Context?): ContentProviderClient = contributionClient
override fun provideModificationContentProviderClient(context: Context?): ContentProviderClient = modificationClient
override fun providesDirectNearbyUploadPreferences(context: Context?): SharedPreferences = uploadPrefs
override fun providesAccountUtil(context: Context): AccountUtil = accountUtil override fun providesAccountUtil(context: Context): AccountUtil = accountUtil
@ -61,12 +69,8 @@ class MockCommonsApplicationModule(appContext: Context) : CommonsApplicationModu
override fun providesSessionManager(context: Context, mediaWikiApi: MediaWikiApi, sharedPreferences: SharedPreferences): SessionManager = mockSessionManager override fun providesSessionManager(context: Context, mediaWikiApi: MediaWikiApi, sharedPreferences: SharedPreferences): SessionManager = mockSessionManager
override fun provideMediaWikiApi(context: Context, sharedPreferences: SharedPreferences, categorySharedPreferences: SharedPreferences, gson: Gson): MediaWikiApi = mediaWikiApi
override fun provideLocationServiceManager(context: Context): LocationServiceManager = locationServiceManager override fun provideLocationServiceManager(context: Context): LocationServiceManager = locationServiceManager
override fun provideCacheController(): CacheController = cacheController
override fun provideDBOpenHelper(context: Context): DBOpenHelper = mockDbOpenHelper override fun provideDBOpenHelper(context: Context): DBOpenHelper = mockDbOpenHelper
override fun provideNearbyPlaces(): NearbyPlaces = nearbyPlaces override fun provideNearbyPlaces(): NearbyPlaces = nearbyPlaces

View file

@ -0,0 +1,178 @@
package fr.free.nrw.commons.mwapi
import com.google.gson.Gson
import fr.free.nrw.commons.mwapi.model.Page
import fr.free.nrw.commons.mwapi.model.PageCategory
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class CategoryApiTest {
private lateinit var server: MockWebServer
private lateinit var url: String
private lateinit var categoryApi: CategoryApi
@Before
fun setUp() {
server = MockWebServer()
url = "http://${server.hostName}:${server.port}/"
categoryApi = CategoryApi(OkHttpClient.Builder().build(), Gson(), HttpUrl.parse(url))
}
@After
fun teardown() {
server.shutdown()
}
@Test
fun apiReturnsEmptyListWhenError() {
server.enqueue(MockResponse().setResponseCode(400).setBody(""))
assertTrue(categoryApi.request("foo").blockingGet().isEmpty())
}
@Test
fun apiReturnsEmptyWhenTheresNoQuery() {
server.success(emptyMap())
assertTrue(categoryApi.request("foo").blockingGet().isEmpty())
}
@Test
fun apiReturnsEmptyWhenQueryHasNoPages() {
server.success(mapOf("query" to emptyMap<String, Any>()))
assertTrue(categoryApi.request("foo").blockingGet().isEmpty())
}
@Test
fun apiReturnsEmptyWhenQueryHasPagesButTheyreEmpty() {
server.success(mapOf("query" to
mapOf("pages" to emptyList<String>())))
assertTrue(categoryApi.request("foo").blockingGet().isEmpty())
}
@Test
fun singlePageSingleCategory() {
server.success(mapOf("query" to
mapOf("pages" to listOf(
page(listOf("one"))
))))
val response = categoryApi.request("foo").blockingGet()
assertEquals(1, response.size)
assertEquals("one", response[0])
}
@Test
fun multiplePagesSingleCategory() {
server.success(mapOf("query" to
mapOf("pages" to listOf(
page(listOf("one")),
page(listOf("two"))
))))
val response = categoryApi.request("foo").blockingGet()
assertEquals(2, response.size)
assertEquals("one", response[0])
assertEquals("two", response[1])
}
@Test
fun singlePageMultipleCategories() {
server.success(mapOf("query" to
mapOf("pages" to listOf(
page(listOf("one", "two"))
))))
val response = categoryApi.request("foo").blockingGet()
assertEquals(2, response.size)
assertEquals("one", response[0])
assertEquals("two", response[1])
}
@Test
fun multiplePagesMultipleCategories() {
server.success(mapOf("query" to
mapOf("pages" to listOf(
page(listOf("one", "two")),
page(listOf("three", "four"))
))))
val response = categoryApi.request("foo").blockingGet()
assertEquals(4, response.size)
assertEquals("one", response[0])
assertEquals("two", response[1])
assertEquals("three", response[2])
assertEquals("four", response[3])
}
@Test
fun multiplePagesMultipleCategories_duplicatesRemoved() {
server.success(mapOf("query" to
mapOf("pages" to listOf(
page(listOf("one", "two", "three")),
page(listOf("three", "four", "one"))
))))
val response = categoryApi.request("foo").blockingGet()
assertEquals(4, response.size)
assertEquals("one", response[0])
assertEquals("two", response[1])
assertEquals("three", response[2])
assertEquals("four", response[3])
}
@Test
fun requestSendsWhatWeExpect() {
server.success(mapOf("query" to mapOf("pages" to emptyList<String>())))
val coords = "foo,bar"
categoryApi.request(coords).blockingGet()
server.takeRequest().let { request ->
assertEquals("GET", request.method)
assertEquals("/w/api.php", request.requestUrl.encodedPath())
request.requestUrl.let { url ->
assertEquals("query", url.queryParameter("action"))
assertEquals("categories|coordinates|pageprops", url.queryParameter("prop"))
assertEquals("json", url.queryParameter("format"))
assertEquals("!hidden", url.queryParameter("clshow"))
assertEquals("type|name|dim|country|region|globe", url.queryParameter("coprop"))
assertEquals(coords, url.queryParameter("codistancefrompoint"))
assertEquals("geosearch", url.queryParameter("generator"))
assertEquals(coords, url.queryParameter("ggscoord"))
assertEquals("10000", url.queryParameter("ggsradius"))
assertEquals("10", url.queryParameter("ggslimit"))
assertEquals("6", url.queryParameter("ggsnamespace"))
assertEquals("type|name|dim|country|region|globe", url.queryParameter("ggsprop"))
assertEquals("all", url.queryParameter("ggsprimary"))
assertEquals("2", url.queryParameter("formatversion"))
}
}
}
private fun page(catList: List<String>) = Page().apply {
categories = catList.map {
PageCategory().apply {
title = "Category:$it"
}
}.toTypedArray()
}
}
fun MockWebServer.success(response: Map<String, Any>) {
enqueue(MockResponse().setResponseCode(200).setBody(Gson().toJson(response)))
}

View file

@ -0,0 +1,28 @@
package fr.free.nrw.commons.mwapi.model
import org.junit.Assert.*
import org.junit.Test
class ApiResponseTest {
@Test
fun hasPages_whenQueryIsNull() {
val response = ApiResponse()
assertFalse(response.hasPages())
}
@Test
fun hasPages_whenPagesIsNull() {
val response = ApiResponse()
response.query = Query()
response.query.pages = null
assertFalse(response.hasPages())
}
@Test
fun hasPages_defaultsToSafeValue() {
val response = ApiResponse()
response.query = Query()
assertNotNull(response.query.pages)
assertTrue(response.hasPages())
}
}

View file

@ -0,0 +1,20 @@
package fr.free.nrw.commons.mwapi.model
import org.junit.Assert.assertEquals
import org.junit.Test
class PageCategoryTest {
@Test
fun stripPrefix_whenPresent() {
val testObject = PageCategory()
testObject.title = "Category:Foo"
assertEquals("Foo", testObject.withoutPrefix())
}
@Test
fun stripPrefix_prefixAbsent() {
val testObject = PageCategory()
testObject.title = "Foo_Bar"
assertEquals("Foo_Bar", testObject.withoutPrefix())
}
}

View file

@ -0,0 +1,12 @@
package fr.free.nrw.commons.mwapi.model
import org.junit.Assert.assertNotNull
import org.junit.Test
class PageTest {
@Test
fun categoriesDefaultToSafeValue() {
val page = Page()
assertNotNull(page.getCategories())
}
}

View file

@ -0,0 +1,77 @@
package fr.free.nrw.commons.upload
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class GpsCategoryModelTest {
private lateinit var testObject : GpsCategoryModel
@Before
fun setUp() {
testObject = GpsCategoryModel()
}
@Test
fun initiallyTheModelIsEmpty() {
assertFalse(testObject.gpsCatExists)
assertTrue(testObject.categoryList.isEmpty())
}
@Test
fun addingCategoriesToTheModel() {
testObject.add("one")
assertTrue(testObject.gpsCatExists)
assertFalse(testObject.categoryList.isEmpty())
assertEquals(listOf("one"), testObject.categoryList)
}
@Test
fun duplicatesAreIgnored() {
testObject.add("one")
testObject.add("one")
assertEquals(listOf("one"), testObject.categoryList)
}
@Test
fun modelProtectsAgainstExternalModification() {
testObject.add("one")
val list = testObject.categoryList
list.add("two")
assertEquals(listOf("one"), testObject.categoryList)
}
@Test
fun clearingTheModel() {
testObject.add("one")
testObject.clear()
assertFalse(testObject.gpsCatExists)
assertTrue(testObject.categoryList.isEmpty())
testObject.add("two")
assertEquals(listOf("two"), testObject.categoryList)
}
@Test
fun settingTheListHandlesNull() {
testObject.add("one")
testObject.categoryList = null
assertFalse(testObject.gpsCatExists)
assertTrue(testObject.categoryList.isEmpty())
}
@Test
fun setttingTheListOverwritesExistingValues() {
testObject.add("one")
testObject.categoryList = listOf("two")
assertEquals(listOf("two"), testObject.categoryList)
}
}