From 3777f18bf9c7efa4c679023b91ba9bad6cc10ad0 Mon Sep 17 00:00:00 2001 From: Paul Hawke Date: Thu, 5 Dec 2024 08:13:38 -0600 Subject: [PATCH] Convert mwapi/wikidata to kotlin (part 1) (#5991) * Convert OkHttpJsonApiClient and CategoryApi to kotlin * Convert GsonUtil to kotlin * Convert WikidataConstants to kotlin * Convert WikidataEditListener to kotlin * Convert WikidataEditService to kotlin * work in progress * Convert RequiredFieldsCheckOnReadTypeAdapterFactory to kotlin * Converted type adapters * Convert WikiSiteTypeAdapter to kotlin * Fixed nullability --- .../commons/campaigns/CampaignsPresenter.kt | 4 +- .../free/nrw/commons/di/NetworkingModule.kt | 3 +- .../free/nrw/commons/mwapi/CategoryApi.java | 99 --- .../fr/free/nrw/commons/mwapi/CategoryApi.kt | 83 +++ .../commons/mwapi/OkHttpJsonApiClient.java | 677 ------------------ .../nrw/commons/mwapi/OkHttpJsonApiClient.kt | 543 ++++++++++++++ .../free/nrw/commons/upload/FileProcessor.kt | 4 +- .../nrw/commons/upload/worker/UploadWorker.kt | 4 +- .../commons/wikidata/CommonsServiceFactory.kt | 5 +- .../free/nrw/commons/wikidata/GsonUtil.java | 34 - .../fr/free/nrw/commons/wikidata/GsonUtil.kt | 29 + .../commons/wikidata/WikidataConstants.java | 11 - .../nrw/commons/wikidata/WikidataConstants.kt | 11 + .../wikidata/WikidataEditListener.java | 16 - .../commons/wikidata/WikidataEditListener.kt | 11 + .../wikidata/WikidataEditListenerImpl.java | 20 - .../wikidata/WikidataEditListenerImpl.kt | 13 + .../commons/wikidata/WikidataEditService.java | 271 ------- .../commons/wikidata/WikidataEditService.kt | 252 +++++++ .../wikidata/json/NamespaceTypeAdapter.java | 29 - .../wikidata/json/NamespaceTypeAdapter.kt | 26 + .../json/PostProcessingTypeAdapter.java | 34 - .../json/PostProcessingTypeAdapter.kt | 35 + ...edFieldsCheckOnReadTypeAdapterFactory.java | 94 --- ...iredFieldsCheckOnReadTypeAdapterFactory.kt | 75 ++ .../json/RuntimeTypeAdapterFactory.java | 280 -------- .../json/RuntimeTypeAdapterFactory.kt | 273 +++++++ .../commons/wikidata/json/UriTypeAdapter.java | 22 - .../commons/wikidata/json/UriTypeAdapter.kt | 19 + .../wikidata/json/WikiSiteTypeAdapter.java | 63 -- .../wikidata/json/WikiSiteTypeAdapter.kt | 61 ++ .../wikidata/json/annotations/Required.java | 21 - .../wikidata/json/annotations/Required.kt | 12 + .../model/notifications/Notification.java | 2 +- .../nrw/commons/wikidata/mwapi/UserInfo.java | 34 - .../nrw/commons/wikidata/mwapi/UserInfo.kt | 21 + .../free/nrw/commons/MockWebServerTest.java | 2 +- .../campaigns/CampaignsPresenterTest.kt | 6 +- .../free/nrw/commons/mwapi/UserClientTest.kt | 9 +- .../nearby/NearbyParentFragmentUnitTest.kt | 2 +- .../notification/NotificationClientTest.kt | 26 +- 41 files changed, 1490 insertions(+), 1746 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java create mode 100644 app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java create mode 100644 app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt diff --git a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt index ffbf92540..4743e0e54 100644 --- a/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/campaigns/CampaignsPresenter.kt @@ -52,12 +52,12 @@ class CampaignsPresenter @Inject constructor( return } - okHttpJsonApiClient.campaigns + okHttpJsonApiClient.getCampaigns() .observeOn(mainThreadScheduler) .subscribeOn(ioScheduler) .doOnSubscribe { disposable = it } .subscribe({ campaignResponseDTO -> - val campaigns = campaignResponseDTO.campaigns?.toMutableList() + val campaigns = campaignResponseDTO?.campaigns?.toMutableList() if (campaigns.isNullOrEmpty()) { Timber.e("The campaigns list is empty") view!!.showCampaigns(null) diff --git a/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt b/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt index 5ecc04120..7ca3b4fd0 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/NetworkingModule.kt @@ -170,14 +170,13 @@ class NetworkingModule { @Named(NAMED_WIKI_DATA_WIKI_SITE) fun provideWikidataWikiSite(): WikiSite = WikiSite(BuildConfig.WIKIDATA_URL) - /** * Gson objects are very heavy. The app should ideally be using just one instance of it instead of creating new instances everywhere. * @return returns a singleton Gson instance */ @Provides @Singleton - fun provideGson(): Gson = GsonUtil.getDefaultGson() + fun provideGson(): Gson = GsonUtil.defaultGson @Provides @Singleton diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java deleted file mode 100644 index f587893c5..000000000 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.java +++ /dev/null @@ -1,99 +0,0 @@ -package fr.free.nrw.commons.mwapi; - -import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_PREFIX; - -import com.google.gson.Gson; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.category.CategoryItem; -import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage; -import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse; -import io.reactivex.Single; -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 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 Gson gson; - - @Inject - public CategoryApi(final OkHttpClient okHttpClient, final Gson gson) { - this.okHttpClient = okHttpClient; - this.gson = gson; - } - - public Single> 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(); - } - - MwQueryResponse apiResponse = gson.fromJson(body.charStream(), MwQueryResponse.class); - Set categories = new LinkedHashSet<>(); - if (apiResponse != null && apiResponse.query() != null && apiResponse.query().pages() != null) { - for (MwQueryPage page : apiResponse.query().pages()) { - if (page.categories() != null) { - for (MwQueryPage.Category category : page.categories()) { - categories.add(new CategoryItem(category.title().replace(CATEGORY_PREFIX, ""), "", "", false)); - } - } - } - } - 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(final String coords) { - return HttpUrl - .parse(BuildConfig.WIKIMEDIA_API_HOST) - .newBuilder() - .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(); - } - -} - - - diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt b/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt new file mode 100644 index 000000000..1f8c51187 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/CategoryApi.kt @@ -0,0 +1,83 @@ +package fr.free.nrw.commons.mwapi + +import com.google.gson.Gson +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.category.CATEGORY_PREFIX +import fr.free.nrw.commons.category.CategoryItem +import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse +import io.reactivex.Single +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import javax.inject.Inject + +/** + * 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. + */ +class CategoryApi @Inject constructor( + private val okHttpClient: OkHttpClient, + private val gson: Gson +) { + private val apiUrl : HttpUrl by lazy { BuildConfig.WIKIMEDIA_API_HOST.toHttpUrlOrNull()!! } + + fun request(coords: String): Single> = Single.fromCallable { + val apiUrl = buildUrl(coords) + Timber.d("URL: %s", apiUrl.toString()) + + val request: Request = Request.Builder().get().url(apiUrl).build() + val response = okHttpClient.newCall(request).execute() + val body = response.body ?: return@fromCallable emptyList() + + val apiResponse = gson.fromJson(body.charStream(), MwQueryResponse::class.java) + val categories: MutableSet = mutableSetOf() + if (apiResponse?.query() != null && apiResponse.query()!!.pages() != null) { + for (page in apiResponse.query()!!.pages()!!) { + if (page.categories() != null) { + for (category in page.categories()!!) { + categories.add( + CategoryItem( + name = category.title().replace(CATEGORY_PREFIX, ""), + description = "", + thumbnail = "", + isSelected = false + ) + ) + } + } + } + } + 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 fun buildUrl(coords: String): HttpUrl = apiUrl.newBuilder() + .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() +} + + + diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java deleted file mode 100644 index 8ed37a293..000000000 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java +++ /dev/null @@ -1,677 +0,0 @@ -package fr.free.nrw.commons.mwapi; - -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LEADERBOARD_END_POINT; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.UPDATE_AVATAR_END_POINT; - -import android.text.TextUtils; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.gson.Gson; -import fr.free.nrw.commons.campaigns.CampaignResponseDTO; -import fr.free.nrw.commons.explore.depictions.DepictsClient; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.nearby.model.ItemsClass; -import fr.free.nrw.commons.nearby.model.NearbyResponse; -import fr.free.nrw.commons.nearby.model.NearbyResultItem; -import fr.free.nrw.commons.nearby.model.PlaceBindings; -import fr.free.nrw.commons.profile.achievements.FeaturedImages; -import fr.free.nrw.commons.profile.achievements.FeedbackResponse; -import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse; -import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse; -import fr.free.nrw.commons.upload.FileUtils; -import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.wikidata.model.GetWikidataEditCountResponse; -import io.reactivex.Observable; -import io.reactivex.Single; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.inject.Inject; -import javax.inject.Singleton; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.jetbrains.annotations.NotNull; -import timber.log.Timber; - -/** - * Test methods in ok http api client - */ -@Singleton -public class OkHttpJsonApiClient { - - private final OkHttpClient okHttpClient; - private final DepictsClient depictsClient; - private final HttpUrl wikiMediaToolforgeUrl; - private final String sparqlQueryUrl; - private final String campaignsUrl; - private final Gson gson; - - - @Inject - public OkHttpJsonApiClient(OkHttpClient okHttpClient, - DepictsClient depictsClient, - HttpUrl wikiMediaToolforgeUrl, - String sparqlQueryUrl, - String campaignsUrl, - Gson gson) { - this.okHttpClient = okHttpClient; - this.depictsClient = depictsClient; - this.wikiMediaToolforgeUrl = wikiMediaToolforgeUrl; - this.sparqlQueryUrl = sparqlQueryUrl; - this.campaignsUrl = campaignsUrl; - this.gson = gson; - } - - /** - * The method will gradually calls the leaderboard API and fetches the leaderboard - * - * @param userName username of leaderboard user - * @param duration duration for leaderboard - * @param category category for leaderboard - * @param limit page size limit for list - * @param offset offset for the list - * @return LeaderboardResponse object - */ - @NonNull - public Observable getLeaderboard(String userName, String duration, - String category, String limit, String offset) { - final String fetchLeaderboardUrlTemplate = wikiMediaToolforgeUrl - + LEADERBOARD_END_POINT; - String url = String.format(Locale.ENGLISH, - fetchLeaderboardUrlTemplate, - userName, - duration, - category, - limit, - offset); - HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); - urlBuilder.addQueryParameter("user", userName); - urlBuilder.addQueryParameter("duration", duration); - urlBuilder.addQueryParameter("category", category); - urlBuilder.addQueryParameter("limit", limit); - urlBuilder.addQueryParameter("offset", offset); - Timber.i("Url %s", urlBuilder.toString()); - Request request = new Request.Builder() - .url(urlBuilder.toString()) - .build(); - return Observable.fromCallable(() -> { - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return new LeaderboardResponse(); - } - Timber.d("Response for leaderboard is %s", json); - try { - return gson.fromJson(json, LeaderboardResponse.class); - } catch (Exception e) { - return new LeaderboardResponse(); - } - } - return new LeaderboardResponse(); - }); - } - - /** - * This method will update the leaderboard user avatar - * - * @param username username to update - * @param avatar url of the new avatar - * @return UpdateAvatarResponse object - */ - @NonNull - public Single setAvatar(String username, String avatar) { - final String urlTemplate = wikiMediaToolforgeUrl - + UPDATE_AVATAR_END_POINT; - return Single.fromCallable(() -> { - String url = String.format(Locale.ENGLISH, - urlTemplate, - username, - avatar); - HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); - urlBuilder.addQueryParameter("user", username); - urlBuilder.addQueryParameter("avatar", avatar); - Timber.i("Url %s", urlBuilder.toString()); - Request request = new Request.Builder() - .url(urlBuilder.toString()) - .build(); - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return null; - } - try { - return gson.fromJson(json, UpdateAvatarResponse.class); - } catch (Exception e) { - return new UpdateAvatarResponse(); - } - } - return null; - }); - } - - @NonNull - public Single getUploadCount(String userName) { - HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder(); - urlBuilder - .addPathSegments("uploadsbyuser.py") - .addQueryParameter("user", userName); - - if (ConfigUtils.isBetaFlavour()) { - urlBuilder.addQueryParameter("labs", "commonswiki"); - } - - Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - return Single.fromCallable(() -> { - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.isSuccessful()) { - ResponseBody responseBody = response.body(); - if (null != responseBody) { - String responseBodyString = responseBody.string().trim(); - if (!TextUtils.isEmpty(responseBodyString)) { - try { - return Integer.parseInt(responseBodyString); - } catch (NumberFormatException e) { - Timber.e(e); - } - } - } - } - return 0; - }); - } - - @NonNull - public Single getWikidataEdits(String userName) { - HttpUrl.Builder urlBuilder = wikiMediaToolforgeUrl.newBuilder(); - urlBuilder - .addPathSegments("wikidataedits.py") - .addQueryParameter("user", userName); - - if (ConfigUtils.isBetaFlavour()) { - urlBuilder.addQueryParameter("labs", "commonswiki"); - } - - Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - return Single.fromCallable(() -> { - Response response = okHttpClient.newCall(request).execute(); - if (response != null && - response.isSuccessful() && response.body() != null) { - String json = response.body().string(); - if (json == null) { - return 0; - } - // Extract JSON from response - json = json.substring(json.indexOf('{')); - GetWikidataEditCountResponse countResponse = gson - .fromJson(json, GetWikidataEditCountResponse.class); - if (null != countResponse) { - return countResponse.getWikidataEditCount(); - } - } - return 0; - }); - } - - /** - * This takes userName as input, which is then used to fetch the feedback/achievements - * statistics using OkHttp and JavaRx. This function return JSONObject - * - * @param userName MediaWiki user name - * @return - */ - public Single getAchievements(String userName) { - final String fetchAchievementUrlTemplate = - wikiMediaToolforgeUrl + (ConfigUtils.isBetaFlavour() ? "/feedback.py?labs=commonswiki" - : "/feedback.py"); - return Single.fromCallable(() -> { - String url = String.format( - Locale.ENGLISH, - fetchAchievementUrlTemplate, - userName); - HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); - urlBuilder.addQueryParameter("user", userName); - Request request = new Request.Builder() - .url(urlBuilder.toString()) - .build(); - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return null; - } - // Extract JSON from response - json = json.substring(json.indexOf('{')); - Timber.d("Response for achievements is %s", json); - try { - return gson.fromJson(json, FeedbackResponse.class); - } catch (Exception e) { - return new FeedbackResponse(0, 0, 0, new FeaturedImages(0, 0), 0, ""); - } - - - } - return null; - }); - } - - /** - * Make API Call to get Nearby Places - * - * @param cur Search lat long - * @param language Language - * @param radius Search Radius - * @return - * @throws Exception - */ - @Nullable - public List getNearbyPlaces(final LatLng cur, final String language, final double radius, - final String customQuery) - throws Exception { - - Timber.d("Fetching nearby items at radius %s", radius); - Timber.d("CUSTOM_SPARQL: %s", String.valueOf(customQuery != null)); - final String wikidataQuery; - if (customQuery != null) { - wikidataQuery = customQuery; - } else { - wikidataQuery = FileUtils.readFromResource( - "/queries/radius_query_for_upload_wizard.rq"); - } - final String query = wikidataQuery - .replace("${RAD}", String.format(Locale.ROOT, "%.2f", radius)) - .replace("${LAT}", String.format(Locale.ROOT, "%.4f", cur.getLatitude())) - .replace("${LONG}", String.format(Locale.ROOT, "%.4f", cur.getLongitude())) - .replace("${LANG}", language); - - final HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - - final Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - final Response response = okHttpClient.newCall(request).execute(); - if (response.body() != null && response.isSuccessful()) { - final String json = response.body().string(); - final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class); - final List bindings = nearbyResponse.getResults().getBindings(); - final List places = new ArrayList<>(); - for (final NearbyResultItem item : bindings) { - final Place placeFromNearbyItem = Place.from(item); - placeFromNearbyItem.setMonument(false); - places.add(placeFromNearbyItem); - } - return places; - } - throw new Exception(response.message()); - } - - /** - * Retrieves nearby places based on screen coordinates and optional query parameters. - * - * @param screenTopRight The top right corner of the screen (latitude, longitude). - * @param screenBottomLeft The bottom left corner of the screen (latitude, longitude). - * @param language The language for the query. - * @param shouldQueryForMonuments Flag indicating whether to include monuments in the query. - * @param customQuery Optional custom SPARQL query to use instead of default - * queries. - * @return A list of nearby places. - * @throws Exception If an error occurs during the retrieval process. - */ - @Nullable - public List getNearbyPlaces( - final fr.free.nrw.commons.location.LatLng screenTopRight, - final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String language, - final boolean shouldQueryForMonuments, final String customQuery) - throws Exception { - - Timber.d("CUSTOM_SPARQL: %s", String.valueOf(customQuery != null)); - - final String wikidataQuery; - if (customQuery != null) { - wikidataQuery = customQuery; - } else if (!shouldQueryForMonuments) { - wikidataQuery = FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq"); - } else { - wikidataQuery = FileUtils.readFromResource( - "/queries/rectangle_query_for_nearby_monuments.rq"); - } - - final double westCornerLat = screenTopRight.getLatitude(); - final double westCornerLong = screenTopRight.getLongitude(); - final double eastCornerLat = screenBottomLeft.getLatitude(); - final double eastCornerLong = screenBottomLeft.getLongitude(); - - final String query = wikidataQuery - .replace("${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat)) - .replace("${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong)) - .replace("${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat)) - .replace("${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong)) - .replace("${LANG}", language); - final HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - - final Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - final Response response = okHttpClient.newCall(request).execute(); - if (response.body() != null && response.isSuccessful()) { - final String json = response.body().string(); - final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class); - final List bindings = nearbyResponse.getResults().getBindings(); - final List places = new ArrayList<>(); - for (final NearbyResultItem item : bindings) { - final Place placeFromNearbyItem = Place.from(item); - if (shouldQueryForMonuments && item.getMonument() != null) { - placeFromNearbyItem.setMonument(true); - } else { - placeFromNearbyItem.setMonument(false); - } - places.add(placeFromNearbyItem); - } - return places; - } - throw new Exception(response.message()); - } - - /** - * Retrieves a list of places based on the provided list of places and language. - * - * @param placeList A list of Place objects for which to fetch information. - * @param language The language code to use for the query. - * @return A list of Place objects with additional information retrieved from Wikidata, or null - * if an error occurs. - * @throws IOException If there is an issue with reading the resource file or executing the HTTP - * request. - */ - @Nullable - public List getPlaces( - final List placeList, final String language) throws IOException { - final String wikidataQuery = FileUtils.readFromResource("/queries/query_for_item.rq"); - String qids = ""; - for (final Place place : placeList) { - qids += "\n" + ("wd:" + place.getWikiDataEntityId()); - } - final String query = wikidataQuery - .replace("${ENTITY}", qids) - .replace("${LANG}", language); - final HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - - final Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - try (Response response = okHttpClient.newCall(request).execute()) { - if (response.isSuccessful()) { - final String json = response.body().string(); - final NearbyResponse nearbyResponse = gson.fromJson(json, NearbyResponse.class); - final List bindings = nearbyResponse.getResults().getBindings(); - final List places = new ArrayList<>(); - for (final NearbyResultItem item : bindings) { - final Place placeFromNearbyItem = Place.from(item); - places.add(placeFromNearbyItem); - } - return places; - } else { - throw new IOException("Unexpected response code: " + response.code()); - } - } - } - - /** - * Make API Call to get Places - * - * @param leftLatLng Left lat long - * @param rightLatLng Right lat long - * @return - * @throws Exception - */ - @Nullable - public String getPlacesAsKML(final LatLng leftLatLng, final LatLng rightLatLng) - throws Exception { - String kmlString = "\n" + - "\n" + - "\n" + - " "; - List placeBindings = runQuery(leftLatLng, - rightLatLng); - if (placeBindings != null) { - for (PlaceBindings item : placeBindings) { - if (item.getItem() != null && item.getLabel() != null && item.getClas() != null) { - String input = item.getLocation().getValue(); - Pattern pattern = Pattern.compile( - "Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)"); - Matcher matcher = pattern.matcher(input); - - if (matcher.find()) { - String longStr = matcher.group(1); - String latStr = matcher.group(2); - String itemUrl = item.getItem().getValue(); - String itemName = item.getLabel().getValue().replace("&", "&"); - String itemLatitude = latStr; - String itemLongitude = longStr; - String itemClass = item.getClas().getValue(); - - String formattedItemName = - !itemClass.isEmpty() ? itemName + " (" + itemClass + ")" - : itemName; - - String kmlEntry = "\n \n" + - " " + formattedItemName + "\n" + - " " + itemUrl + "\n" + - " \n" + - " " + itemLongitude + "," - + itemLatitude - + "\n" + - " \n" + - " "; - kmlString = kmlString + kmlEntry; - } else { - Timber.e("No match found"); - } - } - } - } - kmlString = kmlString + "\n \n" + - "\n"; - return kmlString; - } - - /** - * Make API Call to get Places - * - * @param leftLatLng Left lat long - * @param rightLatLng Right lat long - * @return - * @throws Exception - */ - @Nullable - public String getPlacesAsGPX(final LatLng leftLatLng, final LatLng rightLatLng) - throws Exception { - String gpxString = "\n" + - "" - + "\n"; - - List placeBindings = runQuery(leftLatLng, rightLatLng); - if (placeBindings != null) { - for (PlaceBindings item : placeBindings) { - if (item.getItem() != null && item.getLabel() != null && item.getClas() != null) { - String input = item.getLocation().getValue(); - Pattern pattern = Pattern.compile( - "Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)"); - Matcher matcher = pattern.matcher(input); - - if (matcher.find()) { - String longStr = matcher.group(1); - String latStr = matcher.group(2); - String itemUrl = item.getItem().getValue(); - String itemName = item.getLabel().getValue().replace("&", "&"); - String itemLatitude = latStr; - String itemLongitude = longStr; - String itemClass = item.getClas().getValue(); - - String formattedItemName = - !itemClass.isEmpty() ? itemName + " (" + itemClass + ")" - : itemName; - - String gpxEntry = - "\n \n" + - " " + itemName + "\n" + - " " + itemUrl + "\n" + - " "; - gpxString = gpxString + gpxEntry; - - } else { - Timber.e("No match found"); - } - } - } - - } - gpxString = gpxString + "\n"; - return gpxString; - } - - private List runQuery(final LatLng currentLatLng, final LatLng nextLatLng) - throws IOException { - - final String wikidataQuery = FileUtils.readFromResource("/queries/places_query.rq"); - final String query = wikidataQuery - .replace("${LONGITUDE}", - String.format(Locale.ROOT, "%.2f", currentLatLng.getLongitude())) - .replace("${LATITUDE}", String.format(Locale.ROOT, "%.4f", currentLatLng.getLatitude())) - .replace("${NEXT_LONGITUDE}", - String.format(Locale.ROOT, "%.4f", nextLatLng.getLongitude())) - .replace("${NEXT_LATITUDE}", - String.format(Locale.ROOT, "%.4f", nextLatLng.getLatitude())); - - final HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - - final Request request = new Request.Builder() - .url(urlBuilder.build()) - .build(); - - final Response response = okHttpClient.newCall(request).execute(); - if (response.body() != null && response.isSuccessful()) { - final String json = response.body().string(); - final ItemsClass item = gson.fromJson(json, ItemsClass.class); - return item.getResults().getBindings(); - } else { - return null; - } - } - - /** - * Make API Call to get Nearby Places Implementation does not expects a custom query - * - * @param cur Search lat long - * @param language Language - * @param radius Search Radius - * @return - * @throws Exception - */ - @Nullable - public List getNearbyPlaces(final LatLng cur, final String language, final double radius) - throws Exception { - return getNearbyPlaces(cur, language, radius, null); - } - - /** - * Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example: - * bridge -> suspended bridge, aqueduct, etc - */ - public Single> getChildDepictions(String qid, int startPosition, - int limit) throws IOException { - return depictedItemsFrom( - sparqlQuery(qid, startPosition, limit, "/queries/subclasses_query.rq")); - } - - /** - * Get the QIDs of all Wikidata items that are subclasses of the given Wikidata item. Example: - * bridge -> suspended bridge, aqueduct, etc - */ - public Single> getParentDepictions(String qid, int startPosition, - int limit) throws IOException { - return depictedItemsFrom(sparqlQuery(qid, startPosition, limit, - "/queries/parentclasses_query.rq")); - } - - private Single> depictedItemsFrom(Request request) { - return depictsClient.toDepictions(Single.fromCallable(() -> { - try (ResponseBody body = okHttpClient.newCall(request).execute().body()) { - return gson.fromJson(body.string(), SparqlResponse.class); - } - }).doOnError(Timber::e)); - } - - @NotNull - private Request sparqlQuery(String qid, int startPosition, int limit, String fileName) - throws IOException { - String query = FileUtils.readFromResource(fileName) - .replace("${QID}", qid) - .replace("${LANG}", "\"" + Locale.getDefault().getLanguage() + "\"") - .replace("${LIMIT}", "" + limit) - .replace("${OFFSET}", "" + startPosition); - HttpUrl.Builder urlBuilder = HttpUrl - .parse(sparqlQueryUrl) - .newBuilder() - .addQueryParameter("query", query) - .addQueryParameter("format", "json"); - return new Request.Builder() - .url(urlBuilder.build()) - .build(); - } - - public Single getCampaigns() { - return Single.fromCallable(() -> { - Request request = new Request.Builder().url(campaignsUrl) - .build(); - Response response = okHttpClient.newCall(request).execute(); - if (response != null && response.body() != null && response.isSuccessful()) { - String json = response.body().string(); - if (json == null) { - return null; - } - return gson.fromJson(json, CampaignResponseDTO.class); - } - return null; - }); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt new file mode 100644 index 000000000..c3ae11b94 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt @@ -0,0 +1,543 @@ +package fr.free.nrw.commons.mwapi + +import android.text.TextUtils +import com.google.gson.Gson +import fr.free.nrw.commons.campaigns.CampaignResponseDTO +import fr.free.nrw.commons.explore.depictions.DepictsClient +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.model.ItemsClass +import fr.free.nrw.commons.nearby.model.NearbyResponse +import fr.free.nrw.commons.nearby.model.PlaceBindings +import fr.free.nrw.commons.profile.achievements.FeaturedImages +import fr.free.nrw.commons.profile.achievements.FeedbackResponse +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants +import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse +import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse +import fr.free.nrw.commons.upload.FileUtils +import fr.free.nrw.commons.upload.structure.depictions.DepictedItem +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.wikidata.model.GetWikidataEditCountResponse +import io.reactivex.Observable +import io.reactivex.Single +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import timber.log.Timber +import java.io.IOException +import java.util.Locale +import java.util.regex.Pattern +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Test methods in ok http api client + */ +@Singleton +class OkHttpJsonApiClient @Inject constructor( + private val okHttpClient: OkHttpClient, + private val depictsClient: DepictsClient, + private val wikiMediaToolforgeUrl: HttpUrl, + private val sparqlQueryUrl: String, + private val campaignsUrl: String, + private val gson: Gson +) { + fun getLeaderboard( + userName: String?, duration: String?, + category: String?, limit: String?, offset: String? + ): Observable { + val fetchLeaderboardUrlTemplate = + wikiMediaToolforgeUrl.toString() + LeaderboardConstants.LEADERBOARD_END_POINT + val url = String.format(Locale.ENGLISH, + fetchLeaderboardUrlTemplate, userName, duration, category, limit, offset) + val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder() + .addQueryParameter("user", userName) + .addQueryParameter("duration", duration) + .addQueryParameter("category", category) + .addQueryParameter("limit", limit) + .addQueryParameter("offset", offset) + Timber.i("Url %s", urlBuilder.toString()) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + return Observable.fromCallable({ + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() + Timber.d("Response for leaderboard is %s", json) + try { + return@fromCallable gson.fromJson( + json, + LeaderboardResponse::class.java + ) + } catch (e: Exception) { + return@fromCallable LeaderboardResponse() + } + } + LeaderboardResponse() + }) + } + + fun setAvatar(username: String?, avatar: String?): Single { + val urlTemplate = wikiMediaToolforgeUrl + .toString() + LeaderboardConstants.UPDATE_AVATAR_END_POINT + return Single.fromCallable({ + val url = String.format(Locale.ENGLISH, urlTemplate, username, avatar) + val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder() + .addQueryParameter("user", username) + .addQueryParameter("avatar", avatar) + Timber.i("Url %s", urlBuilder.toString()) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() ?: return@fromCallable null + try { + return@fromCallable gson.fromJson( + json, + UpdateAvatarResponse::class.java + ) + } catch (e: Exception) { + return@fromCallable UpdateAvatarResponse() + } + } + null + }) + } + + fun getUploadCount(userName: String?): Single { + val urlBuilder: HttpUrl.Builder = wikiMediaToolforgeUrl.newBuilder() + .addPathSegments("uploadsbyuser.py") + .addQueryParameter("user", userName) + + if (isBetaFlavour) { + urlBuilder.addQueryParameter("labs", "commonswiki") + } + + val request: Request = Request.Builder() + .url(urlBuilder.build()) + .build() + + return Single.fromCallable({ + val response: Response = okHttpClient.newCall(request).execute() + if (response != null && response.isSuccessful) { + val responseBody = response.body + if (null != responseBody) { + val responseBodyString = responseBody.string().trim { it <= ' ' } + if (!TextUtils.isEmpty(responseBodyString)) { + try { + return@fromCallable responseBodyString.toInt() + } catch (e: NumberFormatException) { + Timber.e(e) + } + } + } + } + 0 + }) + } + + fun getWikidataEdits(userName: String?): Single { + val urlBuilder: HttpUrl.Builder = wikiMediaToolforgeUrl.newBuilder() + .addPathSegments("wikidataedits.py") + .addQueryParameter("user", userName) + + if (isBetaFlavour) { + urlBuilder.addQueryParameter("labs", "commonswiki") + } + + val request: Request = Request.Builder() + .url(urlBuilder.build()) + .build() + + return Single.fromCallable({ + val response: Response = okHttpClient.newCall(request).execute() + if (response != null && response.isSuccessful && response.body != null) { + var json: String = response.body!!.string() + // Extract JSON from response + json = json.substring(json.indexOf('{')) + val countResponse = gson + .fromJson( + json, + GetWikidataEditCountResponse::class.java + ) + if (null != countResponse) { + return@fromCallable countResponse.wikidataEditCount + } + } + 0 + }) + } + + fun getAchievements(userName: String?): Single { + val suffix = if (isBetaFlavour) "/feedback.py?labs=commonswiki" else "/feedback.py" + val fetchAchievementUrlTemplate = wikiMediaToolforgeUrl.toString() + suffix + return Single.fromCallable({ + val url = String.format( + Locale.ENGLISH, + fetchAchievementUrlTemplate, + userName + ) + val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder() + .addQueryParameter("user", userName) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + var json: String = response.body!!.string() + // Extract JSON from response + json = json.substring(json.indexOf('{')) + Timber.d("Response for achievements is %s", json) + try { + return@fromCallable gson.fromJson( + json, + FeedbackResponse::class.java + ) + } catch (e: Exception) { + return@fromCallable FeedbackResponse(0, 0, 0, FeaturedImages(0, 0), 0, "") + } + } + null + }) + } + + @JvmOverloads + @Throws(Exception::class) + fun getNearbyPlaces( + cur: LatLng, language: String, radius: Double, + customQuery: String? = null + ): List? { + Timber.d("Fetching nearby items at radius %s", radius) + Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString()) + val wikidataQuery: String = if (customQuery != null) { + customQuery + } else { + FileUtils.readFromResource("/queries/radius_query_for_upload_wizard.rq") + } + val query = wikidataQuery + .replace("\${RAD}", String.format(Locale.ROOT, "%.2f", radius)) + .replace("\${LAT}", String.format(Locale.ROOT, "%.4f", cur.latitude)) + .replace("\${LONG}", String.format(Locale.ROOT, "%.4f", cur.longitude)) + .replace("\${LANG}", language) + + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + + val request: Request = Request.Builder() + .url(urlBuilder.build()) + .build() + + val response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json = response.body!!.string() + val nearbyResponse = gson.fromJson(json, NearbyResponse::class.java) + val bindings = nearbyResponse.results.bindings + val places: MutableList = ArrayList() + for (item in bindings) { + val placeFromNearbyItem = Place.from(item) + placeFromNearbyItem.isMonument = false + places.add(placeFromNearbyItem) + } + return places + } + throw Exception(response.message) + } + + @Throws(Exception::class) + fun getNearbyPlaces( + screenTopRight: LatLng, + screenBottomLeft: LatLng, language: String, + shouldQueryForMonuments: Boolean, customQuery: String? + ): List? { + Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString()) + + val wikidataQuery: String = if (customQuery != null) { + customQuery + } else if (!shouldQueryForMonuments) { + FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq") + } else { + FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq") + } + + val westCornerLat = screenTopRight.latitude + val westCornerLong = screenTopRight.longitude + val eastCornerLat = screenBottomLeft.latitude + val eastCornerLong = screenBottomLeft.longitude + + val query = wikidataQuery + .replace("\${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat)) + .replace("\${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong)) + .replace("\${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat)) + .replace("\${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong)) + .replace("\${LANG}", language) + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + + val request: Request = Request.Builder() + .url(urlBuilder.build()) + .build() + + val response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json = response.body!!.string() + val nearbyResponse = gson.fromJson(json, NearbyResponse::class.java) + val bindings = nearbyResponse.results.bindings + val places: MutableList = ArrayList() + for (item in bindings) { + val placeFromNearbyItem = Place.from(item) + if (shouldQueryForMonuments && item.getMonument() != null) { + placeFromNearbyItem.isMonument = true + } else { + placeFromNearbyItem.isMonument = false + } + places.add(placeFromNearbyItem) + } + return places + } + throw Exception(response.message) + } + + @Throws(IOException::class) + fun getPlaces( + placeList: List, language: String + ): List? { + val wikidataQuery = FileUtils.readFromResource("/queries/query_for_item.rq") + var qids = "" + for (place in placeList) { + qids += """ +${"wd:" + place.wikiDataEntityId}""" + } + val query = wikidataQuery + .replace("\${ENTITY}", qids) + .replace("\${LANG}", language) + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + + val request: Request = Request.Builder().url(urlBuilder.build()).build() + + okHttpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val json = response.body!!.string() + val nearbyResponse = gson.fromJson(json, NearbyResponse::class.java) + val bindings = nearbyResponse.results.bindings + val places: MutableList = ArrayList() + for (item in bindings) { + val placeFromNearbyItem = Place.from(item) + places.add(placeFromNearbyItem) + } + return places + } else { + throw IOException("Unexpected response code: " + response.code) + } + } + } + + @Throws(Exception::class) + fun getPlacesAsKML(leftLatLng: LatLng, rightLatLng: LatLng): String? { + var kmlString = """ + + + """ + val placeBindings = runQuery( + leftLatLng, + rightLatLng + ) + if (placeBindings != null) { + for ((item1, label, location, clas) in placeBindings) { + if (item1 != null && label != null && clas != null) { + val input = location.value + val pattern = Pattern.compile( + "Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)" + ) + val matcher = pattern.matcher(input) + + if (matcher.find()) { + val longStr = matcher.group(1) + val latStr = matcher.group(2) + val itemUrl = item1.value + val itemName = label.value.replace("&", "&") + val itemLatitude = latStr + val itemLongitude = longStr + val itemClass = clas.value + + val formattedItemName = + if (!itemClass.isEmpty()) + "$itemName ($itemClass)" + else + itemName + + val kmlEntry = (""" + + $formattedItemName + $itemUrl + + $itemLongitude,$itemLatitude + + """) + kmlString = kmlString + kmlEntry + } else { + Timber.e("No match found") + } + } + } + } + kmlString = """$kmlString + + +""" + return kmlString + } + + @Throws(Exception::class) + fun getPlacesAsGPX(leftLatLng: LatLng, rightLatLng: LatLng): String? { + var gpxString = (""" + +""") + + val placeBindings = runQuery(leftLatLng, rightLatLng) + if (placeBindings != null) { + for ((item1, label, location, clas) in placeBindings) { + if (item1 != null && label != null && clas != null) { + val input = location.value + val pattern = Pattern.compile( + "Point\\(([-+]?[0-9]*\\.?[0-9]+) ([-+]?[0-9]*\\.?[0-9]+)\\)" + ) + val matcher = pattern.matcher(input) + + if (matcher.find()) { + val longStr = matcher.group(1) + val latStr = matcher.group(2) + val itemUrl = item1.value + val itemName = label.value.replace("&", "&") + val itemLatitude = latStr + val itemLongitude = longStr + val itemClass = clas.value + + val formattedItemName = if (!itemClass.isEmpty()) + "$itemName ($itemClass)" + else + itemName + + val gpxEntry = + (""" + + $itemName + $itemUrl + """) + gpxString = gpxString + gpxEntry + } else { + Timber.e("No match found") + } + } + } + } + gpxString = "$gpxString\n" + return gpxString + } + + @Throws(IOException::class) + fun getChildDepictions( + qid: String, startPosition: Int, + limit: Int + ): Single> = + depictedItemsFrom(sparqlQuery(qid, startPosition, limit, "/queries/subclasses_query.rq")) + + @Throws(IOException::class) + fun getParentDepictions( + qid: String, startPosition: Int, + limit: Int + ): Single> = depictedItemsFrom( + sparqlQuery( + qid, + startPosition, + limit, + "/queries/parentclasses_query.rq" + ) + ) + + fun getCampaigns(): Single { + return Single.fromCallable({ + val request: Request = Request.Builder().url(campaignsUrl).build() + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() + return@fromCallable gson.fromJson( + json, + CampaignResponseDTO::class.java + ) + } + null + }) + } + + private fun depictedItemsFrom(request: Request): Single> { + return depictsClient.toDepictions(Single.fromCallable({ + okHttpClient.newCall(request).execute().body.use { body -> + return@fromCallable gson.fromJson( + body!!.string(), + SparqlResponse::class.java + ) + } + }).doOnError({ t: Throwable? -> Timber.e(t) })) + } + + @Throws(IOException::class) + private fun sparqlQuery( + qid: String, + startPosition: Int, + limit: Int, + fileName: String + ): Request { + val query = FileUtils.readFromResource(fileName) + .replace("\${QID}", qid) + .replace("\${LANG}", "\"" + Locale.getDefault().language + "\"") + .replace("\${LIMIT}", "" + limit) + .replace("\${OFFSET}", "" + startPosition) + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + return Request.Builder().url(urlBuilder.build()).build() + } + + @Throws(IOException::class) + private fun runQuery(currentLatLng: LatLng, nextLatLng: LatLng): List? { + val wikidataQuery = FileUtils.readFromResource("/queries/places_query.rq") + val query = wikidataQuery + .replace("\${LONGITUDE}", String.format(Locale.ROOT, "%.2f", currentLatLng.longitude)) + .replace("\${LATITUDE}", String.format(Locale.ROOT, "%.4f", currentLatLng.latitude)) + .replace("\${NEXT_LONGITUDE}", String.format(Locale.ROOT, "%.4f", nextLatLng.longitude)) + .replace("\${NEXT_LATITUDE}", String.format(Locale.ROOT, "%.4f", nextLatLng.latitude)) + + val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! + .newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("format", "json") + + val request: Request = Request.Builder().url(urlBuilder.build()).build() + + val response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json = response.body!!.string() + val item = gson.fromJson(json, ItemsClass::class.java) + return item.results.bindings + } else { + return null + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt index 68c6f13fb..d51ab1796 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt @@ -194,7 +194,7 @@ class FileProcessor requireNotNull(imageCoordinates.decimalCoords) compositeDisposable.add( apiCall - .request(imageCoordinates.decimalCoords) + .request(imageCoordinates.decimalCoords!!) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) .subscribe( @@ -220,7 +220,7 @@ class FileProcessor .concatMap { Observable.fromCallable { okHttpJsonApiClient.getNearbyPlaces( - imageCoordinates.latLng, + imageCoordinates.latLng!!, Locale.getDefault().language, it, ) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt index 00cd29a6d..ae2c461f8 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt @@ -496,14 +496,14 @@ class UploadWorker( withContext(Dispatchers.Main) { wikidataEditService.handleImageClaimResult( - contribution.wikidataPlace, + contribution.wikidataPlace!!, revisionID, ) } } else { withContext(Dispatchers.Main) { wikidataEditService.handleImageClaimResult( - contribution.wikidataPlace, + contribution.wikidataPlace!!, null, ) } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt index ca523a21f..bc0ba24fa 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/CommonsServiceFactory.kt @@ -10,11 +10,10 @@ class CommonsServiceFactory( ) { val builder: Retrofit.Builder by lazy { // All instances of retrofit share this configuration, but create it lazily - Retrofit - .Builder() + Retrofit.Builder() .client(okHttpClient) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) + .addConverterFactory(GsonConverterFactory.create(GsonUtil.defaultGson)) } val retrofitCache: MutableMap = mutableMapOf() diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java b/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java deleted file mode 100644 index c9d37eda5..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.java +++ /dev/null @@ -1,34 +0,0 @@ -package fr.free.nrw.commons.wikidata; - -import android.net.Uri; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory; -import fr.free.nrw.commons.wikidata.model.DataValue; -import fr.free.nrw.commons.wikidata.model.WikiSite; -import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter; -import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter; -import fr.free.nrw.commons.wikidata.json.UriTypeAdapter; -import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter; -import fr.free.nrw.commons.wikidata.model.page.Namespace; - -public final class GsonUtil { - private static final String DATE_FORMAT = "MMM dd, yyyy HH:mm:ss"; - - private static final GsonBuilder DEFAULT_GSON_BUILDER = new GsonBuilder() - .setDateFormat(DATE_FORMAT) - .registerTypeAdapterFactory(DataValue.getPolymorphicTypeAdapter()) - .registerTypeHierarchyAdapter(Uri.class, new UriTypeAdapter().nullSafe()) - .registerTypeHierarchyAdapter(Namespace.class, new NamespaceTypeAdapter().nullSafe()) - .registerTypeAdapter(WikiSite.class, new WikiSiteTypeAdapter().nullSafe()) - .registerTypeAdapterFactory(new RequiredFieldsCheckOnReadTypeAdapterFactory()) - .registerTypeAdapterFactory(new PostProcessingTypeAdapter()); - - private static final Gson DEFAULT_GSON = DEFAULT_GSON_BUILDER.create(); - - public static Gson getDefaultGson() { - return DEFAULT_GSON; - } - - private GsonUtil() { } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt new file mode 100644 index 000000000..1a0ae0aeb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/GsonUtil.kt @@ -0,0 +1,29 @@ +package fr.free.nrw.commons.wikidata + +import android.net.Uri +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import fr.free.nrw.commons.wikidata.json.NamespaceTypeAdapter +import fr.free.nrw.commons.wikidata.json.PostProcessingTypeAdapter +import fr.free.nrw.commons.wikidata.json.RequiredFieldsCheckOnReadTypeAdapterFactory +import fr.free.nrw.commons.wikidata.json.UriTypeAdapter +import fr.free.nrw.commons.wikidata.json.WikiSiteTypeAdapter +import fr.free.nrw.commons.wikidata.model.DataValue.Companion.polymorphicTypeAdapter +import fr.free.nrw.commons.wikidata.model.WikiSite +import fr.free.nrw.commons.wikidata.model.page.Namespace + +object GsonUtil { + private const val DATE_FORMAT = "MMM dd, yyyy HH:mm:ss" + + private val DEFAULT_GSON_BUILDER: GsonBuilder by lazy { + GsonBuilder().setDateFormat(DATE_FORMAT) + .registerTypeAdapterFactory(polymorphicTypeAdapter) + .registerTypeHierarchyAdapter(Uri::class.java, UriTypeAdapter().nullSafe()) + .registerTypeHierarchyAdapter(Namespace::class.java, NamespaceTypeAdapter().nullSafe()) + .registerTypeAdapter(WikiSite::class.java, WikiSiteTypeAdapter().nullSafe()) + .registerTypeAdapterFactory(RequiredFieldsCheckOnReadTypeAdapterFactory()) + .registerTypeAdapterFactory(PostProcessingTypeAdapter()) + } + + val defaultGson: Gson by lazy { DEFAULT_GSON_BUILDER.create() } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java deleted file mode 100644 index f89b5aee0..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.java +++ /dev/null @@ -1,11 +0,0 @@ -package fr.free.nrw.commons.wikidata; - -public class WikidataConstants { - public static final String PLACE_OBJECT = "place"; - public static final String BOOKMARKS_ITEMS = "bookmarks.items"; - public static final String SELECTED_NEARBY_PLACE = "selected.nearby.place"; - public static final String SELECTED_NEARBY_PLACE_CATEGORY = "selected.nearby.place.category"; - - public static final String MW_API_PREFIX = "w/api.php?format=json&formatversion=2&errorformat=plaintext&"; - public static final String WIKIPEDIA_URL = "https://wikipedia.org/"; -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt new file mode 100644 index 000000000..6343342cb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataConstants.kt @@ -0,0 +1,11 @@ +package fr.free.nrw.commons.wikidata + +object WikidataConstants { + const val PLACE_OBJECT: String = "place" + const val BOOKMARKS_ITEMS: String = "bookmarks.items" + const val SELECTED_NEARBY_PLACE: String = "selected.nearby.place" + const val SELECTED_NEARBY_PLACE_CATEGORY: String = "selected.nearby.place.category" + + const val MW_API_PREFIX: String = "w/api.php?format=json&formatversion=2&errorformat=plaintext&" + const val WIKIPEDIA_URL: String = "https://wikipedia.org/" +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java deleted file mode 100644 index 30fb26ddc..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.java +++ /dev/null @@ -1,16 +0,0 @@ -package fr.free.nrw.commons.wikidata; - -public abstract class WikidataEditListener { - - protected WikidataP18EditListener wikidataP18EditListener; - - public abstract void onSuccessfulWikidataEdit(); - - public void setAuthenticationStateListener(WikidataP18EditListener wikidataP18EditListener) { - this.wikidataP18EditListener = wikidataP18EditListener; - } - - public interface WikidataP18EditListener { - void onWikidataEditSuccessful(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt new file mode 100644 index 000000000..5e382b4ce --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListener.kt @@ -0,0 +1,11 @@ +package fr.free.nrw.commons.wikidata + +abstract class WikidataEditListener { + var authenticationStateListener: WikidataP18EditListener? = null + + abstract fun onSuccessfulWikidataEdit() + + interface WikidataP18EditListener { + fun onWikidataEditSuccessful() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java deleted file mode 100644 index a97d0eded..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.java +++ /dev/null @@ -1,20 +0,0 @@ -package fr.free.nrw.commons.wikidata; - -/** - * Listener for wikidata edits - */ -public class WikidataEditListenerImpl extends WikidataEditListener { - - public WikidataEditListenerImpl() { - } - - /** - * Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired - */ - @Override - public void onSuccessfulWikidataEdit() { - if (wikidataP18EditListener != null) { - wikidataP18EditListener.onWikidataEditSuccessful(); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt new file mode 100644 index 000000000..6827ab30c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditListenerImpl.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.wikidata + +/** + * Listener for wikidata edits + */ +class WikidataEditListenerImpl : WikidataEditListener() { + /** + * Fired when wikidata P18 edit is successful. If there's an active listener, then it is fired + */ + override fun onSuccessfulWikidataEdit() { + authenticationStateListener?.onWikidataEditSuccessful() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java deleted file mode 100644 index 21567f5e4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.java +++ /dev/null @@ -1,271 +0,0 @@ -package fr.free.nrw.commons.wikidata; - - -import static fr.free.nrw.commons.media.MediaClientKt.PAGE_ID_PREFIX; - -import android.annotation.SuppressLint; -import android.content.Context; -import androidx.annotation.Nullable; -import com.google.gson.Gson; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.upload.UploadResult; -import fr.free.nrw.commons.upload.WikidataItem; -import fr.free.nrw.commons.upload.WikidataPlace; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.wikidata.model.DataValue; -import fr.free.nrw.commons.wikidata.model.DataValue.ValueString; -import fr.free.nrw.commons.wikidata.model.EditClaim; -import fr.free.nrw.commons.wikidata.model.RemoveClaim; -import fr.free.nrw.commons.wikidata.model.SnakPartial; -import fr.free.nrw.commons.wikidata.model.StatementPartial; -import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue; -import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse; -import io.reactivex.Observable; -import io.reactivex.schedulers.Schedulers; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.UUID; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; -import timber.log.Timber; - -/** - * This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki - * Apis to make the necessary calls, log the edits and fire listeners on successful edits - */ -@Singleton -public class WikidataEditService { - - public static final String COMMONS_APP_TAG = "wikimedia-commons-app"; - - private final Context context; - private final WikidataEditListener wikidataEditListener; - private final JsonKvStore directKvStore; - private final WikiBaseClient wikiBaseClient; - private final WikidataClient wikidataClient; - private final Gson gson; - - @Inject - public WikidataEditService(final Context context, - final WikidataEditListener wikidataEditListener, - @Named("default_preferences") final JsonKvStore directKvStore, - final WikiBaseClient wikiBaseClient, - final WikidataClient wikidataClient, final Gson gson) { - this.context = context; - this.wikidataEditListener = wikidataEditListener; - this.directKvStore = directKvStore; - this.wikiBaseClient = wikiBaseClient; - this.wikidataClient = wikidataClient; - this.gson = gson; - } - - /** - * Edits the wikibase entity by adding DEPICTS property. Adding DEPICTS property requires call - * to the wikibase API to set tag against the entity. - */ - @SuppressLint("CheckResult") - private Observable addDepictsProperty( - final String fileEntityId, - final List depictedItems - ) { - final EditClaim data = editClaim( - ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") - // Wikipedia:Sandbox (Q10) - : depictedItems - ); - - return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) - .doOnNext(success -> { - if (success) { - Timber.d("DEPICTS property was set successfully for %s", fileEntityId); - } else { - Timber.d("Unable to set DEPICTS property for %s", fileEntityId); - } - }) - .doOnError(throwable -> { - Timber.e(throwable, "Error occurred while setting DEPICTS property"); - ViewUtil.showLongToast(context, throwable.toString()); - }) - .subscribeOn(Schedulers.io()); - } - - /** - * Takes depicts ID as a parameter and create a uploadable data with the Id - * and send the data for POST operation - * - * @param fileEntityId ID of the file - * @param depictedItems IDs of the selected depict item - * @return Observable - */ - @SuppressLint("CheckResult") - public Observable updateDepictsProperty( - final String fileEntityId, - final List depictedItems - ) { - final String entityId = PAGE_ID_PREFIX + fileEntityId; - final List claimIds = getDepictionsClaimIds(entityId); - - final RemoveClaim data = removeClaim( /* Please consider removeClaim scenario for BetaDebug */ - ConfigUtils.isBetaFlavour() ? Collections.singletonList("Q10") - // Wikipedia:Sandbox (Q10) - : claimIds - ); - - return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data)) - .doOnError(throwable -> { - Timber.e(throwable, "Error occurred while removing existing claims for DEPICTS property"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }).switchMap(success-> { - if(success) { - Timber.d("DEPICTS property was deleted successfully"); - return addDepictsProperty(fileEntityId, depictedItems); - } else { - Timber.d("Unable to delete DEPICTS property"); - return Observable.empty(); - } - }); - } - - @SuppressLint("CheckResult") - private List getDepictionsClaimIds(final String entityId) { - return wikiBaseClient.getClaimIdsByProperty(entityId, WikidataProperties.DEPICTS.getPropertyName()) - .subscribeOn(Schedulers.io()) - .blockingFirst(); - } - - private EditClaim editClaim(final List entityIds) { - return EditClaim.from(entityIds, WikidataProperties.DEPICTS.getPropertyName()); - } - - private RemoveClaim removeClaim(final List claimIds) { - return RemoveClaim.from(claimIds); - } - - /** - * Show a success toast when the edit is made successfully - */ - private void showSuccessToast(final String wikiItemName) { - final String successStringTemplate = context.getString(R.string.successful_wikidata_edit); - final String successMessage = String - .format(Locale.getDefault(), successStringTemplate, wikiItemName); - ViewUtil.showLongToast(context, successMessage); - } - - /** - * Adds label to Wikidata using the fileEntityId and the edit token, obtained from - * csrfTokenClient - * - * @param fileEntityId - * @return - */ - @SuppressLint("CheckResult") - private Observable addCaption(final long fileEntityId, final String languageCode, - final String captionValue) { - return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue) - .doOnNext(mwPostResponse -> onAddCaptionResponse(fileEntityId, mwPostResponse)) - .doOnError(throwable -> { - Timber.e(throwable, "Error occurred while setting Captions"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }) - .map(mwPostResponse -> mwPostResponse != null); - } - - private void onAddCaptionResponse(Long fileEntityId, MwPostResponse response) { - if (response != null) { - Timber.d("Caption successfully set, revision id = %s", response); - } else { - Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId); - } - } - - public Long createClaim(@Nullable final WikidataPlace wikidataPlace, final String fileName, - final Map captions) { - if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { - Timber - .d("Image location and nearby place location mismatched, so Wikidata item won't be edited"); - return null; - } - return addImageAndMediaLegends(wikidataPlace, fileName, captions); - } - - public Long addImageAndMediaLegends(final WikidataItem wikidataItem, final String fileName, - final Map captions) { - final SnakPartial p18 = new SnakPartial("value", - WikidataProperties.IMAGE.getPropertyName(), - new ValueString(fileName.replace("File:", ""))); - - final List snaks = new ArrayList<>(); - for (final Map.Entry entry : captions.entrySet()) { - snaks.add(new SnakPartial("value", - WikidataProperties.MEDIA_LEGENDS.getPropertyName(), new DataValue.MonoLingualText( - new WikiBaseMonolingualTextValue(entry.getValue(), entry.getKey())))); - } - - final String id = wikidataItem.getId() + "$" + UUID.randomUUID().toString(); - final StatementPartial claim = new StatementPartial(p18, "statement", "normal", id, - Collections.singletonMap(WikidataProperties.MEDIA_LEGENDS.getPropertyName(), snaks), - Arrays.asList(WikidataProperties.MEDIA_LEGENDS.getPropertyName())); - - return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle(); - } - - public void handleImageClaimResult(final WikidataItem wikidataItem, final Long revisionId) { - if (revisionId != null) { - if (wikidataEditListener != null) { - wikidataEditListener.onSuccessfulWikidataEdit(); - } - showSuccessToast(wikidataItem.getName()); - } else { - Timber.d("Unable to make wiki data edit for entity %s", wikidataItem); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - } - } - - public Observable addDepictionsAndCaptions( - final UploadResult uploadResult, - final Contribution contribution - ) { - return wikiBaseClient.getFileEntityId(uploadResult) - .doOnError(throwable -> { - Timber - .e(throwable, "Error occurred while getting EntityID to set DEPICTS property"); - ViewUtil.showLongToast(context, context.getString(R.string.wikidata_edit_failure)); - }) - .switchMap(fileEntityId -> { - if (fileEntityId != null) { - Timber.d("EntityId for image was received successfully: %s", fileEntityId); - return Observable.concat( - depictionEdits(contribution, fileEntityId), - captionEdits(contribution, fileEntityId) - ); - } else { - Timber.d("Error acquiring EntityId for image: %s", uploadResult); - return Observable.empty(); - } - } - ); - } - - private Observable captionEdits(Contribution contribution, Long fileEntityId) { - return Observable.fromIterable(contribution.getMedia().getCaptions().entrySet()) - .concatMap(entry -> addCaption(fileEntityId, entry.getKey(), entry.getValue())); - } - - private Observable depictionEdits(Contribution contribution, Long fileEntityId) { - final List depictIDs = new ArrayList<>(); - for (final WikidataItem wikidataItem : - contribution.getDepictedItems()) { - depictIDs.add(wikidataItem.getId()); - } - return addDepictsProperty(fileEntityId.toString(), depictIDs); - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt new file mode 100644 index 000000000..396f92824 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/WikidataEditService.kt @@ -0,0 +1,252 @@ +package fr.free.nrw.commons.wikidata + +import android.annotation.SuppressLint +import android.content.Context +import com.google.gson.Gson +import fr.free.nrw.commons.R +import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.media.PAGE_ID_PREFIX +import fr.free.nrw.commons.upload.UploadResult +import fr.free.nrw.commons.upload.WikidataItem +import fr.free.nrw.commons.upload.WikidataPlace +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import fr.free.nrw.commons.wikidata.WikidataProperties.DEPICTS +import fr.free.nrw.commons.wikidata.WikidataProperties.IMAGE +import fr.free.nrw.commons.wikidata.WikidataProperties.MEDIA_LEGENDS +import fr.free.nrw.commons.wikidata.model.DataValue.MonoLingualText +import fr.free.nrw.commons.wikidata.model.DataValue.ValueString +import fr.free.nrw.commons.wikidata.model.EditClaim +import fr.free.nrw.commons.wikidata.model.RemoveClaim +import fr.free.nrw.commons.wikidata.model.SnakPartial +import fr.free.nrw.commons.wikidata.model.StatementPartial +import fr.free.nrw.commons.wikidata.model.WikiBaseMonolingualTextValue +import fr.free.nrw.commons.wikidata.mwapi.MwPostResponse +import io.reactivex.Observable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Arrays +import java.util.Collections +import java.util.Locale +import java.util.Objects +import java.util.UUID +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + + +/** + * This class is meant to handle the Wikidata edits made through the app It will talk with MediaWiki + * Apis to make the necessary calls, log the edits and fire listeners on successful edits + */ +@Singleton +class WikidataEditService @Inject constructor( + private val context: Context, + private val wikidataEditListener: WikidataEditListener?, + @param:Named("default_preferences") private val directKvStore: JsonKvStore, + private val wikiBaseClient: WikiBaseClient, + private val wikidataClient: WikidataClient, private val gson: Gson +) { + @SuppressLint("CheckResult") + private fun addDepictsProperty( + fileEntityId: String, + depictedItems: List + ): Observable { + val data = EditClaim.from( + if (isBetaFlavour) listOf("Q10") else depictedItems, DEPICTS.propertyName + ) + + return wikiBaseClient.postEditEntity(PAGE_ID_PREFIX + fileEntityId, gson.toJson(data)) + .doOnNext { success: Boolean -> + if (success) { + Timber.d("DEPICTS property was set successfully for %s", fileEntityId) + } else { + Timber.d("Unable to set DEPICTS property for %s", fileEntityId) + } + } + .doOnError { throwable: Throwable -> + Timber.e(throwable, "Error occurred while setting DEPICTS property") + showLongToast(context, throwable.toString()) + } + .subscribeOn(Schedulers.io()) + } + + @SuppressLint("CheckResult") + fun updateDepictsProperty( + fileEntityId: String?, + depictedItems: List + ): Observable { + val entityId: String = PAGE_ID_PREFIX + fileEntityId + val claimIds = getDepictionsClaimIds(entityId) + + /* Please consider removeClaim scenario for BetaDebug */ + val data = RemoveClaim.from(if (isBetaFlavour) listOf("Q10") else claimIds) + + return wikiBaseClient.postDeleteClaims(entityId, gson.toJson(data)) + .doOnError { throwable: Throwable? -> + Timber.e( + throwable, + "Error occurred while removing existing claims for DEPICTS property" + ) + showLongToast( + context, + context.getString(R.string.wikidata_edit_failure) + ) + }.switchMap { success: Boolean -> + if (success) { + Timber.d("DEPICTS property was deleted successfully") + return@switchMap addDepictsProperty(fileEntityId!!, depictedItems) + } else { + Timber.d("Unable to delete DEPICTS property") + return@switchMap Observable.empty() + } + } + } + + @SuppressLint("CheckResult") + private fun getDepictionsClaimIds(entityId: String): List { + return wikiBaseClient.getClaimIdsByProperty(entityId, DEPICTS.propertyName) + .subscribeOn(Schedulers.io()) + .blockingFirst() + } + + private fun showSuccessToast(wikiItemName: String) { + val successStringTemplate = context.getString(R.string.successful_wikidata_edit) + val successMessage = String.format(Locale.getDefault(), successStringTemplate, wikiItemName) + showLongToast(context, successMessage) + } + + @SuppressLint("CheckResult") + private fun addCaption( + fileEntityId: Long, languageCode: String, + captionValue: String + ): Observable { + return wikiBaseClient.addLabelsToWikidata(fileEntityId, languageCode, captionValue) + .doOnNext { mwPostResponse: MwPostResponse? -> + onAddCaptionResponse( + fileEntityId, + mwPostResponse + ) + } + .doOnError { throwable: Throwable? -> + Timber.e(throwable, "Error occurred while setting Captions") + showLongToast( + context, + context.getString(R.string.wikidata_edit_failure) + ) + } + .map(Objects::nonNull) + } + + private fun onAddCaptionResponse(fileEntityId: Long, response: MwPostResponse?) { + if (response != null) { + Timber.d("Caption successfully set, revision id = %s", response) + } else { + Timber.d("Error occurred while setting Captions, fileEntityId = %s", fileEntityId) + } + } + + fun createClaim( + wikidataPlace: WikidataPlace?, fileName: String, + captions: Map + ): Long? { + if (!(directKvStore.getBoolean("Picture_Has_Correct_Location", true))) { + Timber.d( + "Image location and nearby place location mismatched, so Wikidata item won't be edited" + ) + return null + } + return addImageAndMediaLegends(wikidataPlace!!, fileName, captions) + } + + fun addImageAndMediaLegends( + wikidataItem: WikidataItem, fileName: String, + captions: Map + ): Long { + val p18 = SnakPartial( + "value", + IMAGE.propertyName, + ValueString(fileName.replace("File:", "")) + ) + + val snaks: MutableList = ArrayList() + for ((key, value) in captions) { + snaks.add( + SnakPartial( + "value", + MEDIA_LEGENDS.propertyName, MonoLingualText( + WikiBaseMonolingualTextValue(value!!, key!!) + ) + ) + ) + } + + val id = wikidataItem.id + "$" + UUID.randomUUID().toString() + val claim = StatementPartial( + p18, "statement", "normal", id, Collections.singletonMap>( + MEDIA_LEGENDS.propertyName, snaks + ), Arrays.asList(MEDIA_LEGENDS.propertyName) + ) + + return wikidataClient.setClaim(claim, COMMONS_APP_TAG).blockingSingle() + } + + fun handleImageClaimResult(wikidataItem: WikidataItem, revisionId: Long?) { + if (revisionId != null) { + wikidataEditListener?.onSuccessfulWikidataEdit() + showSuccessToast(wikidataItem.name) + } else { + Timber.d("Unable to make wiki data edit for entity %s", wikidataItem) + showLongToast(context, context.getString(R.string.wikidata_edit_failure)) + } + } + + fun addDepictionsAndCaptions( + uploadResult: UploadResult, + contribution: Contribution + ): Observable { + return wikiBaseClient.getFileEntityId(uploadResult) + .doOnError { throwable: Throwable? -> + Timber.e( + throwable, + "Error occurred while getting EntityID to set DEPICTS property" + ) + showLongToast( + context, + context.getString(R.string.wikidata_edit_failure) + ) + } + .switchMap { fileEntityId: Long? -> + if (fileEntityId != null) { + Timber.d("EntityId for image was received successfully: %s", fileEntityId) + return@switchMap Observable.concat( + depictionEdits(contribution, fileEntityId), + captionEdits(contribution, fileEntityId) + ) + } else { + Timber.d("Error acquiring EntityId for image: %s", uploadResult) + return@switchMap Observable.empty() + } + } + } + + private fun captionEdits(contribution: Contribution, fileEntityId: Long): Observable { + return Observable.fromIterable(contribution.media.captions.entries) + .concatMap { addCaption(fileEntityId, it.key, it.value) } + } + + private fun depictionEdits( + contribution: Contribution, + fileEntityId: Long + ): Observable = addDepictsProperty(fileEntityId.toString(), buildList { + for ((_, _, _, _, _, _, id) in contribution.depictedItems) { + add(id) + } + }) + + companion object { + const val COMMONS_APP_TAG: String = "wikimedia-commons-app" + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java deleted file mode 100644 index cc6dcc9f9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.java +++ /dev/null @@ -1,29 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonToken; -import com.google.gson.stream.JsonWriter; - -import fr.free.nrw.commons.wikidata.model.page.Namespace; - -import java.io.IOException; - -public class NamespaceTypeAdapter extends TypeAdapter { - - @Override - public void write(JsonWriter out, Namespace namespace) throws IOException { - out.value(namespace.code()); - } - - @Override - public Namespace read(JsonReader in) throws IOException { - if (in.peek() == JsonToken.STRING) { - // Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of - // the code number. This introduces a backwards-compatible check for the string value. - // TODO: remove after April 2017, when all older namespaces have been deserialized. - return Namespace.valueOf(in.nextString()); - } - return Namespace.of(in.nextInt()); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt new file mode 100644 index 000000000..09f1dc5e8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/NamespaceTypeAdapter.kt @@ -0,0 +1,26 @@ +package fr.free.nrw.commons.wikidata.json + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import fr.free.nrw.commons.wikidata.model.page.Namespace +import java.io.IOException + +class NamespaceTypeAdapter : TypeAdapter() { + @Throws(IOException::class) + override fun write(out: JsonWriter, namespace: Namespace) { + out.value(namespace.code().toLong()) + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): Namespace { + if (reader.peek() == JsonToken.STRING) { + // Prior to 3210ce44, we marshaled Namespace as the name string of the enum, instead of + // the code number. This introduces a backwards-compatible check for the string value. + // TODO: remove after April 2017, when all older namespaces have been deserialized. + return Namespace.valueOf(reader.nextString()) + } + return Namespace.of(reader.nextInt()) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java deleted file mode 100644 index b6b67d4d2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.java +++ /dev/null @@ -1,34 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import com.google.gson.Gson; -import com.google.gson.TypeAdapter; -import com.google.gson.TypeAdapterFactory; -import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -public class PostProcessingTypeAdapter implements TypeAdapterFactory { - public interface PostProcessable { - void postProcess(); - } - - public TypeAdapter create(Gson gson, TypeToken type) { - final TypeAdapter delegate = gson.getDelegateAdapter(this, type); - - return new TypeAdapter() { - public void write(JsonWriter out, T value) throws IOException { - delegate.write(out, value); - } - - public T read(JsonReader in) throws IOException { - T obj = delegate.read(in); - if (obj instanceof PostProcessable) { - ((PostProcessable)obj).postProcess(); - } - return obj; - } - }; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt new file mode 100644 index 000000000..cf07eabf4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/PostProcessingTypeAdapter.kt @@ -0,0 +1,35 @@ +package fr.free.nrw.commons.wikidata.json + +import com.google.gson.Gson +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import java.io.IOException + +class PostProcessingTypeAdapter : TypeAdapterFactory { + interface PostProcessable { + fun postProcess() + } + + override fun create(gson: Gson, type: TypeToken): TypeAdapter { + val delegate = gson.getDelegateAdapter(this, type) + + return object : TypeAdapter() { + @Throws(IOException::class) + override fun write(out: JsonWriter, value: T) { + delegate.write(out, value) + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): T { + val obj = delegate.read(reader) + if (obj is PostProcessable) { + (obj as PostProcessable).postProcess() + } + return obj + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java deleted file mode 100644 index c01b9fe66..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java +++ /dev/null @@ -1,94 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.collection.ArraySet; - -import com.google.gson.Gson; -import com.google.gson.JsonParseException; -import com.google.gson.TypeAdapter; -import com.google.gson.TypeAdapterFactory; -import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -import fr.free.nrw.commons.wikidata.json.annotations.Required; - -import java.io.IOException; -import java.lang.reflect.Field; -import java.util.Collections; -import java.util.Set; - -/** - * TypeAdapterFactory that provides TypeAdapters that return null values for objects that are - * missing fields annotated with @Required. - * - * BEWARE: This means that a List or other Collection of objects that have @Required fields can - * contain null elements after deserialization! - * - * TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements - * annotation and another corresponding TypeAdapter(Factory). - */ -public class RequiredFieldsCheckOnReadTypeAdapterFactory implements TypeAdapterFactory { - @Nullable @Override public final TypeAdapter create(@NonNull Gson gson, @NonNull TypeToken typeToken) { - Class rawType = typeToken.getRawType(); - Set requiredFields = collectRequiredFields(rawType); - - if (requiredFields.isEmpty()) { - return null; - } - - setFieldsAccessible(requiredFields, true); - return new Adapter<>(gson.getDelegateAdapter(this, typeToken), requiredFields); - } - - @NonNull private Set collectRequiredFields(@NonNull Class clazz) { - Field[] fields = clazz.getDeclaredFields(); - Set required = new ArraySet<>(); - for (Field field : fields) { - if (field.isAnnotationPresent(Required.class)) { - required.add(field); - } - } - return Collections.unmodifiableSet(required); - } - - private void setFieldsAccessible(Iterable fields, boolean accessible) { - for (Field field : fields) { - field.setAccessible(accessible); - } - } - - private static final class Adapter extends TypeAdapter { - @NonNull private final TypeAdapter delegate; - @NonNull private final Set requiredFields; - - private Adapter(@NonNull TypeAdapter delegate, @NonNull final Set requiredFields) { - this.delegate = delegate; - this.requiredFields = requiredFields; - } - - @Override public void write(JsonWriter out, T value) throws IOException { - delegate.write(out, value); - } - - @Override @Nullable public T read(JsonReader in) throws IOException { - T deserialized = delegate.read(in); - return allRequiredFieldsPresent(deserialized, requiredFields) ? deserialized : null; - } - - private boolean allRequiredFieldsPresent(@NonNull T deserialized, - @NonNull Set required) { - for (Field field : required) { - try { - if (field.get(deserialized) == null) { - return false; - } - } catch (IllegalArgumentException | IllegalAccessException e) { - throw new JsonParseException(e); - } - } - return true; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt new file mode 100644 index 000000000..ec26e8345 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RequiredFieldsCheckOnReadTypeAdapterFactory.kt @@ -0,0 +1,75 @@ +package fr.free.nrw.commons.wikidata.json + +import com.google.gson.Gson +import com.google.gson.JsonParseException +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import fr.free.nrw.commons.wikidata.json.annotations.Required +import java.io.IOException +import java.lang.reflect.Field + +/** + * TypeAdapterFactory that provides TypeAdapters that return null values for objects that are + * missing fields annotated with @Required. + * + * BEWARE: This means that a List or other Collection of objects that have @Required fields can + * contain null elements after deserialization! + * + * TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements + * annotation and another corresponding TypeAdapter(Factory). + */ +class RequiredFieldsCheckOnReadTypeAdapterFactory : TypeAdapterFactory { + override fun create(gson: Gson, typeToken: TypeToken): TypeAdapter? { + val rawType: Class<*> = typeToken.rawType + val requiredFields = collectRequiredFields(rawType) + + if (requiredFields.isEmpty()) { + return null + } + + for (field in requiredFields) { + field.isAccessible = true + } + + return Adapter(gson.getDelegateAdapter(this, typeToken), requiredFields) + } + + private fun collectRequiredFields(clazz: Class<*>): Set = buildSet { + for (field in clazz.declaredFields) { + if (field.isAnnotationPresent(Required::class.java)) add(field) + } + } + + private class Adapter( + private val delegate: TypeAdapter, + private val requiredFields: Set + ) : TypeAdapter() { + + @Throws(IOException::class) + override fun write(out: JsonWriter, value: T?) = + delegate.write(out, value) + + @Throws(IOException::class) + override fun read(reader: JsonReader): T? = + if (allRequiredFieldsPresent(delegate.read(reader), requiredFields)) + delegate.read(reader) + else + null + + fun allRequiredFieldsPresent(deserialized: T, required: Set): Boolean { + for (field in required) { + try { + if (field[deserialized] == null) return false + } catch (e: IllegalArgumentException) { + throw JsonParseException(e) + } catch (e: IllegalAccessException) { + throw JsonParseException(e) + } + } + return true + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java deleted file mode 100644 index 828dfbd68..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.java +++ /dev/null @@ -1,280 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -/* - * Copyright (C) 2011 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import android.util.Log; -import java.io.IOException; -import java.util.LinkedHashMap; -import java.util.Map; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; -import com.google.gson.JsonPrimitive; -import com.google.gson.TypeAdapter; -import com.google.gson.TypeAdapterFactory; -import com.google.gson.internal.Streams; -import com.google.gson.reflect.TypeToken; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -/** - * Adapts values whose runtime type may differ from their declaration type. This - * is necessary when a field's type is not the same type that GSON should create - * when deserializing that field. For example, consider these types: - *
   {@code
- *   abstract class Shape {
- *     int x;
- *     int y;
- *   }
- *   class Circle extends Shape {
- *     int radius;
- *   }
- *   class Rectangle extends Shape {
- *     int width;
- *     int height;
- *   }
- *   class Diamond extends Shape {
- *     int width;
- *     int height;
- *   }
- *   class Drawing {
- *     Shape bottomShape;
- *     Shape topShape;
- *   }
- * }
- *

Without additional type information, the serialized JSON is ambiguous. Is - * the bottom shape in this drawing a rectangle or a diamond?

   {@code
- *   {
- *     "bottomShape": {
- *       "width": 10,
- *       "height": 5,
- *       "x": 0,
- *       "y": 0
- *     },
- *     "topShape": {
- *       "radius": 2,
- *       "x": 4,
- *       "y": 1
- *     }
- *   }}
- * This class addresses this problem by adding type information to the - * serialized JSON and honoring that type information when the JSON is - * deserialized:
   {@code
- *   {
- *     "bottomShape": {
- *       "type": "Diamond",
- *       "width": 10,
- *       "height": 5,
- *       "x": 0,
- *       "y": 0
- *     },
- *     "topShape": {
- *       "type": "Circle",
- *       "radius": 2,
- *       "x": 4,
- *       "y": 1
- *     }
- *   }}
- * Both the type field name ({@code "type"}) and the type labels ({@code - * "Rectangle"}) are configurable. - * - *

Registering Types

- * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field - * name to the {@link #of} factory method. If you don't supply an explicit type - * field name, {@code "type"} will be used.
   {@code
- *   RuntimeTypeAdapterFactory shapeAdapterFactory
- *       = RuntimeTypeAdapterFactory.of(Shape.class, "type");
- * }
- * Next register all of your subtypes. Every subtype must be explicitly - * registered. This protects your application from injection attacks. If you - * don't supply an explicit type label, the type's simple name will be used. - *
   {@code
- *   shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
- *   shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
- *   shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
- * }
- * Finally, register the type adapter factory in your application's GSON builder: - *
   {@code
- *   Gson gson = new GsonBuilder()
- *       .registerTypeAdapterFactory(shapeAdapterFactory)
- *       .create();
- * }
- * Like {@code GsonBuilder}, this API supports chaining:
   {@code
- *   RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
- *       .registerSubtype(Rectangle.class)
- *       .registerSubtype(Circle.class)
- *       .registerSubtype(Diamond.class);
- * }
- * - *

Serialization and deserialization

- * In order to serialize and deserialize a polymorphic object, - * you must specify the base type explicitly. - *
   {@code
- *   Diamond diamond = new Diamond();
- *   String json = gson.toJson(diamond, Shape.class);
- * }
- * And then: - *
   {@code
- *   Shape shape = gson.fromJson(json, Shape.class);
- * }
- */ -public final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { - private final Class baseType; - private final String typeFieldName; - private final Map> labelToSubtype = new LinkedHashMap>(); - private final Map, String> subtypeToLabel = new LinkedHashMap, String>(); - private final boolean maintainType; - - private RuntimeTypeAdapterFactory(Class baseType, String typeFieldName, boolean maintainType) { - if (typeFieldName == null || baseType == null) { - throw new NullPointerException(); - } - this.baseType = baseType; - this.typeFieldName = typeFieldName; - this.maintainType = maintainType; - } - - /** - * Creates a new runtime type adapter using for {@code baseType} using {@code - * typeFieldName} as the type field name. Type field names are case sensitive. - * {@code maintainType} flag decide if the type will be stored in pojo or not. - */ - public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName, boolean maintainType) { - return new RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType); - } - - /** - * Creates a new runtime type adapter using for {@code baseType} using {@code - * typeFieldName} as the type field name. Type field names are case sensitive. - */ - public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName) { - return new RuntimeTypeAdapterFactory(baseType, typeFieldName, false); - } - - /** - * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as - * the type field name. - */ - public static RuntimeTypeAdapterFactory of(Class baseType) { - return new RuntimeTypeAdapterFactory(baseType, "type", false); - } - - /** - * Registers {@code type} identified by {@code label}. Labels are case - * sensitive. - * - * @throws IllegalArgumentException if either {@code type} or {@code label} - * have already been registered on this type adapter. - */ - public RuntimeTypeAdapterFactory registerSubtype(Class type, String label) { - if (type == null || label == null) { - throw new NullPointerException(); - } - if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { - throw new IllegalArgumentException("types and labels must be unique"); - } - labelToSubtype.put(label, type); - subtypeToLabel.put(type, label); - return this; - } - - /** - * Registers {@code type} identified by its {@link Class#getSimpleName simple - * name}. Labels are case sensitive. - * - * @throws IllegalArgumentException if either {@code type} or its simple name - * have already been registered on this type adapter. - */ - public RuntimeTypeAdapterFactory registerSubtype(Class type) { - return registerSubtype(type, type.getSimpleName()); - } - - public TypeAdapter create(Gson gson, TypeToken type) { - if (type.getRawType() != baseType) { - return null; - } - - final Map> labelToDelegate - = new LinkedHashMap>(); - final Map, TypeAdapter> subtypeToDelegate - = new LinkedHashMap, TypeAdapter>(); - for (Map.Entry> entry : labelToSubtype.entrySet()) { - TypeAdapter delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); - labelToDelegate.put(entry.getKey(), delegate); - subtypeToDelegate.put(entry.getValue(), delegate); - } - - return new TypeAdapter() { - @Override public R read(JsonReader in) throws IOException { - JsonElement jsonElement = Streams.parse(in); - JsonElement labelJsonElement; - if (maintainType) { - labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); - } else { - labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); - } - - if (labelJsonElement == null) { - throw new JsonParseException("cannot deserialize " + baseType - + " because it does not define a field named " + typeFieldName); - } - String label = labelJsonElement.getAsString(); - @SuppressWarnings("unchecked") // registration requires that subtype extends T - TypeAdapter delegate = (TypeAdapter) labelToDelegate.get(label); - if (delegate == null) { - - Log.e("RuntimeTypeAdapter", "cannot deserialize " + baseType + " subtype named " - + label + "; did you forget to register a subtype? " +jsonElement); - return null; - } - return delegate.fromJsonTree(jsonElement); - } - - @Override public void write(JsonWriter out, R value) throws IOException { - Class srcType = value.getClass(); - String label = subtypeToLabel.get(srcType); - @SuppressWarnings("unchecked") // registration requires that subtype extends T - TypeAdapter delegate = (TypeAdapter) subtypeToDelegate.get(srcType); - if (delegate == null) { - throw new JsonParseException("cannot serialize " + srcType.getName() - + "; did you forget to register a subtype?"); - } - JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); - - if (maintainType) { - Streams.write(jsonObject, out); - return; - } - - JsonObject clone = new JsonObject(); - - if (jsonObject.has(typeFieldName)) { - throw new JsonParseException("cannot serialize " + srcType.getName() - + " because it already defines a field named " + typeFieldName); - } - clone.add(typeFieldName, new JsonPrimitive(label)); - - for (Map.Entry e : jsonObject.entrySet()) { - clone.add(e.getKey(), e.getValue()); - } - Streams.write(clone, out); - } - }.nullSafe(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt new file mode 100644 index 000000000..87acc939f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/RuntimeTypeAdapterFactory.kt @@ -0,0 +1,273 @@ +package fr.free.nrw.commons.wikidata.json + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonPrimitive +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.internal.Streams +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import timber.log.Timber +import java.io.IOException + +/* +* Copyright (C) 2011 Google Inc. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +/** + * Adapts values whose runtime type may differ from their declaration type. This + * is necessary when a field's type is not the same type that GSON should create + * when deserializing that field. For example, consider these types: + *
   `abstract class Shape {
+ * int x;
+ * int y;
+ * }
+ * class Circle extends Shape {
+ * int radius;
+ * }
+ * class Rectangle extends Shape {
+ * int width;
+ * int height;
+ * }
+ * class Diamond extends Shape {
+ * int width;
+ * int height;
+ * }
+ * class Drawing {
+ * Shape bottomShape;
+ * Shape topShape;
+ * }
+`
* + * + * Without additional type information, the serialized JSON is ambiguous. Is + * the bottom shape in this drawing a rectangle or a diamond?
   `{
+ * "bottomShape": {
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }`
+ * This class addresses this problem by adding type information to the + * serialized JSON and honoring that type information when the JSON is + * deserialized:
   `{
+ * "bottomShape": {
+ * "type": "Diamond",
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "type": "Circle",
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }`
+ * Both the type field name (`"type"`) and the type labels (`"Rectangle"`) are configurable. + * + *

Registering Types

+ * Create a `RuntimeTypeAdapterFactory` by passing the base type and type field + * name to the [.of] factory method. If you don't supply an explicit type + * field name, `"type"` will be used.
   `RuntimeTypeAdapterFactory shapeAdapterFactory
+ * = RuntimeTypeAdapterFactory.of(Shape.class, "type");
+`
* + * Next register all of your subtypes. Every subtype must be explicitly + * registered. This protects your application from injection attacks. If you + * don't supply an explicit type label, the type's simple name will be used. + *
   `shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
+ * shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
+ * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
+`
* + * Finally, register the type adapter factory in your application's GSON builder: + *
   `Gson gson = new GsonBuilder()
+ * .registerTypeAdapterFactory(shapeAdapterFactory)
+ * .create();
+`
* + * Like `GsonBuilder`, this API supports chaining:
   `RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
+ * .registerSubtype(Rectangle.class)
+ * .registerSubtype(Circle.class)
+ * .registerSubtype(Diamond.class);
+`
* + * + *

Serialization and deserialization

+ * In order to serialize and deserialize a polymorphic object, + * you must specify the base type explicitly. + *
   `Diamond diamond = new Diamond();
+ * String json = gson.toJson(diamond, Shape.class);
+`
* + * And then: + *
   `Shape shape = gson.fromJson(json, Shape.class);
+`
* + */ +class RuntimeTypeAdapterFactory( + baseType: Class<*>?, + typeFieldName: String?, + maintainType: Boolean +) : TypeAdapterFactory { + + private val baseType: Class<*> + private val typeFieldName: String + private val labelToSubtype = mutableMapOf>() + private val subtypeToLabel = mutableMapOf, String>() + private val maintainType: Boolean + + init { + if (typeFieldName == null || baseType == null) { + throw NullPointerException() + } + this.baseType = baseType + this.typeFieldName = typeFieldName + this.maintainType = maintainType + } + + /** + * Registers `type` identified by `label`. Labels are case + * sensitive. + * + * @throws IllegalArgumentException if either `type` or `label` + * have already been registered on this type adapter. + */ + fun registerSubtype(type: Class?, label: String?): RuntimeTypeAdapterFactory { + if (type == null || label == null) { + throw NullPointerException() + } + require(!(subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label))) { + "types and labels must be unique" + } + + labelToSubtype[label] = type + subtypeToLabel[type] = label + return this + } + + /** + * Registers `type` identified by its [simple][Class.getSimpleName]. Labels are case sensitive. + * + * @throws IllegalArgumentException if either `type` or its simple name + * have already been registered on this type adapter. + */ + fun registerSubtype(type: Class): RuntimeTypeAdapterFactory { + return registerSubtype(type, type.simpleName) + } + + override fun create(gson: Gson, type: TypeToken): TypeAdapter? { + if (type.rawType != baseType) { + return null + } + + val labelToDelegate = mutableMapOf>() + val subtypeToDelegate = mutableMapOf, TypeAdapter<*>>() + for ((key, value) in labelToSubtype) { + val delegate = gson.getDelegateAdapter( + this, TypeToken.get( + value + ) + ) + labelToDelegate[key] = delegate + subtypeToDelegate[value] = delegate + } + + return object : TypeAdapter() { + @Throws(IOException::class) + override fun read(reader: JsonReader): R? { + val jsonElement = Streams.parse(reader) + val labelJsonElement = if (maintainType) { + jsonElement.asJsonObject[typeFieldName] + } else { + jsonElement.asJsonObject.remove(typeFieldName) + } + + if (labelJsonElement == null) { + throw JsonParseException( + "cannot deserialize $baseType because it does not define a field named $typeFieldName" + ) + } + val label = labelJsonElement.asString + val delegate = labelToDelegate[label] as TypeAdapter? + if (delegate == null) { + Timber.tag("RuntimeTypeAdapter").e( + "cannot deserialize $baseType subtype named $label; did you forget to register a subtype? $jsonElement" + ) + return null + } + return delegate.fromJsonTree(jsonElement) + } + + @Throws(IOException::class) + override fun write(out: JsonWriter, value: R) { + val srcType: Class<*> = value::class.java.javaClass + val delegate = + subtypeToDelegate[srcType] as TypeAdapter? ?: throw JsonParseException( + "cannot serialize ${srcType.name}; did you forget to register a subtype?" + ) + + val jsonObject = delegate.toJsonTree(value).asJsonObject + if (maintainType) { + Streams.write(jsonObject, out) + return + } + + if (jsonObject.has(typeFieldName)) { + throw JsonParseException( + "cannot serialize ${srcType.name} because it already defines a field named $typeFieldName" + ) + } + val clone = JsonObject() + val label = subtypeToLabel[srcType] + clone.add(typeFieldName, JsonPrimitive(label)) + for ((key, value1) in jsonObject.entrySet()) { + clone.add(key, value1) + } + Streams.write(clone, out) + } + }.nullSafe() + } + + companion object { + /** + * Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive. + * `maintainType` flag decide if the type will be stored in pojo or not. + */ + fun of( + baseType: Class, + typeFieldName: String, + maintainType: Boolean + ): RuntimeTypeAdapterFactory = + RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType) + + /** + * Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive. + */ + fun of(baseType: Class, typeFieldName: String): RuntimeTypeAdapterFactory = + RuntimeTypeAdapterFactory(baseType, typeFieldName, false) + + /** + * Creates a new runtime type adapter for `baseType` using `"type"` as + * the type field name. + */ + fun of(baseType: Class): RuntimeTypeAdapterFactory = + RuntimeTypeAdapterFactory(baseType, "type", false) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java deleted file mode 100644 index 069e02f32..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.java +++ /dev/null @@ -1,22 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import android.net.Uri; - -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -public class UriTypeAdapter extends TypeAdapter { - @Override - public void write(JsonWriter out, Uri value) throws IOException { - out.value(value.toString()); - } - - @Override - public Uri read(JsonReader in) throws IOException { - String url = in.nextString(); - return Uri.parse(url); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt new file mode 100644 index 000000000..305cfa28a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/UriTypeAdapter.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons.wikidata.json + +import android.net.Uri +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import java.io.IOException + +class UriTypeAdapter : TypeAdapter() { + @Throws(IOException::class) + override fun write(out: JsonWriter, value: Uri) { + out.value(value.toString()) + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): Uri { + return Uri.parse(reader.nextString()) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java deleted file mode 100644 index c268d1e73..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.java +++ /dev/null @@ -1,63 +0,0 @@ -package fr.free.nrw.commons.wikidata.json; - -import android.net.Uri; - -import com.google.gson.JsonParseException; -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonToken; -import com.google.gson.stream.JsonWriter; - -import fr.free.nrw.commons.wikidata.model.WikiSite; - -import java.io.IOException; - -public class WikiSiteTypeAdapter extends TypeAdapter { - private static final String DOMAIN = "domain"; - private static final String LANGUAGE_CODE = "languageCode"; - - @Override public void write(JsonWriter out, WikiSite value) throws IOException { - out.beginObject(); - out.name(DOMAIN); - out.value(value.url()); - - out.name(LANGUAGE_CODE); - out.value(value.languageCode()); - out.endObject(); - } - - @Override public WikiSite read(JsonReader in) throws IOException { - // todo: legacy; remove in June 2018 - if (in.peek() == JsonToken.STRING) { - return new WikiSite(Uri.parse(in.nextString())); - } - - String domain = null; - String languageCode = null; - in.beginObject(); - while (in.hasNext()) { - String field = in.nextName(); - String val = in.nextString(); - switch (field) { - case DOMAIN: - domain = val; - break; - case LANGUAGE_CODE: - languageCode = val; - break; - default: break; - } - } - in.endObject(); - - if (domain == null) { - throw new JsonParseException("Missing domain"); - } - - // todo: legacy; remove in June 2018 - if (languageCode == null) { - return new WikiSite(domain); - } - return new WikiSite(domain, languageCode); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt new file mode 100644 index 000000000..da5cb0802 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/WikiSiteTypeAdapter.kt @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.wikidata.json + +import android.net.Uri +import com.google.gson.JsonParseException +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import fr.free.nrw.commons.wikidata.model.WikiSite +import java.io.IOException + +class WikiSiteTypeAdapter : TypeAdapter() { + @Throws(IOException::class) + override fun write(out: JsonWriter, value: WikiSite) { + out.beginObject() + out.name(DOMAIN) + out.value(value.url()) + + out.name(LANGUAGE_CODE) + out.value(value.languageCode()) + out.endObject() + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): WikiSite { + // todo: legacy; remove reader June 2018 + if (reader.peek() == JsonToken.STRING) { + return WikiSite(Uri.parse(reader.nextString())) + } + + var domain: String? = null + var languageCode: String? = null + reader.beginObject() + while (reader.hasNext()) { + val field = reader.nextName() + val value = reader.nextString() + when (field) { + DOMAIN -> domain = value + LANGUAGE_CODE -> languageCode = value + else -> {} + } + } + reader.endObject() + + if (domain == null) { + throw JsonParseException("Missing domain") + } + + // todo: legacy; remove reader June 2018 + return if (languageCode == null) { + WikiSite(domain) + } else { + WikiSite(domain, languageCode) + } + } + + companion object { + private const val DOMAIN = "domain" + private const val LANGUAGE_CODE = "languageCode" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java b/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java deleted file mode 100644 index 98e12745b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.java +++ /dev/null @@ -1,21 +0,0 @@ -package fr.free.nrw.commons.wikidata.json.annotations; - - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.FIELD; - -/** - * Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return - * an instantiated object. - * - * E.g.: @NonNull @Required private String title; - */ -@Documented -@Retention(RetentionPolicy.RUNTIME) -@Target(FIELD) -public @interface Required { -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt new file mode 100644 index 000000000..189a3a42c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/json/annotations/Required.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.wikidata.json.annotations + + +/** + * Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return + * an instantiated object. + * + * E.g.: @NonNull @Required private String title; + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FIELD) +annotation class Required diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java index 2d1dbdf28..929fe0d13 100644 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/notifications/Notification.java @@ -148,7 +148,7 @@ public class Notification { return null; } if (primaryLink == null && primary instanceof JsonObject) { - primaryLink = GsonUtil.getDefaultGson().fromJson(primary, Link.class); + primaryLink = GsonUtil.INSTANCE.getDefaultGson().fromJson(primary, Link.class); } return primaryLink; } diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java deleted file mode 100644 index 3ac9e3915..000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.java +++ /dev/null @@ -1,34 +0,0 @@ -package fr.free.nrw.commons.wikidata.mwapi; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import java.util.Map; - - -public class UserInfo { - @NonNull private String name; - @NonNull private int id; - - //Block information - private int blockid; - private String blockedby; - private int blockedbyid; - private String blockreason; - private String blocktimestamp; - private String blockexpiry; - - // Object type is any JSON type. - @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") - @Nullable private Map options; - - public int id() { - return id; - } - - @NonNull - public String blockexpiry() { - if (blockexpiry != null) - return blockexpiry; - else return ""; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt new file mode 100644 index 000000000..c9182a821 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/mwapi/UserInfo.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.wikidata.mwapi + +data class UserInfo( + val name: String = "", + val id: Int = 0, + + //Block information + val blockid: Int = 0, + val blockedby: String? = null, + val blockedbyid: Int = 0, + val blockreason: String? = null, + val blocktimestamp: String? = null, + val blockexpiry: String? = null, + + // Object type is any JSON type. + val options: Map? = null +) { + fun id(): Int = id + + fun blockexpiry(): String = blockexpiry ?: "" +} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/MockWebServerTest.java b/app/src/test/kotlin/fr/free/nrw/commons/MockWebServerTest.java index fd940c12f..d9c8ad4fb 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/MockWebServerTest.java +++ b/app/src/test/kotlin/fr/free/nrw/commons/MockWebServerTest.java @@ -69,7 +69,7 @@ public abstract class MockWebServerTest { .baseUrl(url) .callbackExecutor(new ImmediateExecutor()) .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) + .addConverterFactory(GsonConverterFactory.create(GsonUtil.INSTANCE.getDefaultGson())) .build() .create(clazz); } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/campaigns/CampaignsPresenterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/campaigns/CampaignsPresenterTest.kt index ec3ad82f1..f876916b6 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/campaigns/CampaignsPresenterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/campaigns/CampaignsPresenterTest.kt @@ -49,13 +49,13 @@ class CampaignsPresenterTest { campaignsSingle = Single.just(campaignResponseDTO) campaignsPresenter = CampaignsPresenter(okHttpJsonApiClient, testScheduler, testScheduler) campaignsPresenter.onAttachView(view) - Mockito.`when`(okHttpJsonApiClient.campaigns).thenReturn(campaignsSingle) + Mockito.`when`(okHttpJsonApiClient.getCampaigns()).thenReturn(campaignsSingle) } @Test fun getCampaignsTestNoCampaigns() { campaignsPresenter.getCampaigns() - verify(okHttpJsonApiClient).campaigns + verify(okHttpJsonApiClient).getCampaigns() testScheduler.triggerActions() verify(view).showCampaigns(null) } @@ -77,7 +77,7 @@ class CampaignsPresenterTest { Mockito.`when`(campaign.endDate).thenReturn(endDateString) Mockito.`when`(campaign.startDate).thenReturn(startDateString) Mockito.`when`(campaignResponseDTO.campaigns).thenReturn(campaigns) - verify(okHttpJsonApiClient).campaigns + verify(okHttpJsonApiClient).getCampaigns() testScheduler.triggerActions() verify(view).showCampaigns(campaign) } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/mwapi/UserClientTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/mwapi/UserClientTest.kt index 52c7953ec..926678308 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/mwapi/UserClientTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/mwapi/UserClientTest.kt @@ -30,8 +30,7 @@ class UserClientTest { @Test fun isUserBlockedFromCommonsForInfinitelyBlockedUser() { - val userInfo = Mockito.mock(UserInfo::class.java) - Mockito.`when`(userInfo.blockexpiry()).thenReturn("infinite") + val userInfo = UserInfo(blockexpiry = "infinite") val mwQueryResult = Mockito.mock(MwQueryResult::class.java) Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) val mockResponse = Mockito.mock(MwQueryResponse::class.java) @@ -49,8 +48,7 @@ class UserClientTest { val currentDate = Date() val expiredDate = Date(currentDate.time + 10000) - val userInfo = Mockito.mock(UserInfo::class.java) - Mockito.`when`(userInfo.blockexpiry()).thenReturn(DateUtil.iso8601DateFormat(expiredDate)) + val userInfo = UserInfo(blockexpiry = DateUtil.iso8601DateFormat(expiredDate)) val mwQueryResult = Mockito.mock(MwQueryResult::class.java) Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) val mockResponse = Mockito.mock(MwQueryResponse::class.java) @@ -65,8 +63,7 @@ class UserClientTest { @Test fun isUserBlockedFromCommonsForNeverBlockedUser() { - val userInfo = Mockito.mock(UserInfo::class.java) - Mockito.`when`(userInfo.blockexpiry()).thenReturn("") + val userInfo = UserInfo(blockexpiry = "") val mwQueryResult = Mockito.mock(MwQueryResult::class.java) Mockito.`when`(mwQueryResult.userInfo()).thenReturn(userInfo) val mockResponse = Mockito.mock(MwQueryResponse::class.java) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/nearby/NearbyParentFragmentUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/nearby/NearbyParentFragmentUnitTest.kt index 6584550b0..7fb3ba8bd 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/nearby/NearbyParentFragmentUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/nearby/NearbyParentFragmentUnitTest.kt @@ -325,7 +325,7 @@ class NearbyParentFragmentUnitTest { @Throws(Exception::class) fun testOnDestroy() { fragment.onDestroy() - verify(wikidataEditListener).setAuthenticationStateListener(null) + verify(wikidataEditListener).authenticationStateListener = null } @Test @Ignore diff --git a/app/src/test/kotlin/fr/free/nrw/commons/notification/NotificationClientTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/notification/NotificationClientTest.kt index e9451cd75..7d7c668a8 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/notification/NotificationClientTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/notification/NotificationClientTest.kt @@ -120,26 +120,16 @@ class NotificationClientTest { ) = Notification().apply { setId(notificationId) - setTimestamp( - Notification.Timestamp().apply { - setUtciso8601(timestamp) - }, - ) + setTimestamp(Notification.Timestamp().apply { setUtciso8601(timestamp) }) - contents = - Notification.Contents().apply { - setCompactHeader(compactHeader) + contents = Notification.Contents().apply { + setCompactHeader(compactHeader) - links = - Notification.Links().apply { - setPrimary( - GsonUtil.getDefaultGson().toJsonTree( - Notification.Link().apply { - setUrl(primaryUrl) - }, - ), - ) - } + links = Notification.Links().apply { + setPrimary(GsonUtil.defaultGson.toJsonTree( + Notification.Link().apply { setUrl(primaryUrl) } + )) } + } } }