From 9dd504e56096bc5892edff901d3a939e50846939 Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Thu, 5 Dec 2024 13:01:47 +0100 Subject: [PATCH 1/5] Localisation updates from https://translatewiki.net. --- app/src/main/res/values-tr/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 54593c681f..2e4e464813 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -21,6 +21,7 @@ * Okkerem * Oyuncu * Rapsar +* RuzDD * SaldırganSincap * Sayginer * Sezgin İbiş @@ -146,6 +147,7 @@ Kategori ara Medyanızın tasvir ettiği ögeleri arayın (dağ, Tac Mahal, vb.) Kaydet + Taşma menüsü Yenile Liste !Henüz yükleme yok) @@ -800,6 +802,7 @@ Lütfen bir yorum girin Tartışma \' %1$s \' öğesi hakkında bir şeyler yazın. Herkes tarafından görülebilir olacaktır. + \'%1$s\' artık yok, dolayısı ile resmi çekilemez. Diğer sorun veya bilgi (lütfen aşağıda açıklayınız). Geri bildiriminiz aşağıdaki wiki sayfasına gönderilir: <a href=\"https://commons.wikimedia.org/wiki/Commons:Mobile_app/Feedback\">Commons:Mobile app/Feedback</a> Tüm yüklemeleri iptal etmek istediğinizden emin misiniz? @@ -807,5 +810,10 @@ Yüklemeler Beklemede Başarısız + Sil + İptal + %1$s klasörü başarıyla silindi + %1$s klasörü silinemedi Bu yerin zaten bir resmi var. + Şimdi bu yerin bir resime sahip olup olmadığı denetleniyor. From 3777f18bf9c7efa4c679023b91ba9bad6cc10ad0 Mon Sep 17 00:00:00 2001 From: Paul Hawke Date: Thu, 5 Dec 2024 08:13:38 -0600 Subject: [PATCH 2/5] 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 | 28 +- 41 files changed, 1491 insertions(+), 1747 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 ffbf925406..4743e0e543 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 5ecc041209..7ca3b4fd03 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 f587893c5f..0000000000 --- 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 0000000000..1f8c51187b --- /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 8ed37a2937..0000000000 --- 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 0000000000..c3ae11b949 --- /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 68c6f13fbe..d51ab1796e 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 00cd29a6d9..ae2c461f83 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 ca523a21fd..bc0ba24fa1 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 c9d37eda5c..0000000000 --- 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 0000000000..1a0ae0aebc --- /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 f89b5aee01..0000000000 --- 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 0000000000..6343342cb3 --- /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 30fb26ddc7..0000000000 --- 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 0000000000..5e382b4ce8 --- /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 a97d0ededf..0000000000 --- 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 0000000000..6827ab30cc --- /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 21567f5e44..0000000000 --- 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 0000000000..396f928245 --- /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 cc6dcc9f92..0000000000 --- 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 0000000000..09f1dc5e85 --- /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 b6b67d4d22..0000000000 --- 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 0000000000..cf07eabf49 --- /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 c01b9fe662..0000000000 --- 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 0000000000..ec26e8345c --- /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 828dfbd681..0000000000 --- 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 0000000000..87acc939f3 --- /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 069e02f321..0000000000 --- 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 0000000000..305cfa28a5 --- /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 c268d1e738..0000000000 --- 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 0000000000..da5cb08024 --- /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 98e12745b8..0000000000 --- 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 0000000000..189a3a42cd --- /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 2d1dbdf28a..929fe0d13a 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 void setPrimary(@Nullable final JsonElement primary) { 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 3ac9e39159..0000000000 --- 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 0000000000..c9182a821f --- /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 fd940c12fa..d9c8ad4fbb 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 @@ protected void enqueueEmptyJson() { .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 ec3ad82f1b..f876916b64 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 52c7953ec5..9266783085 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 6584550b06..7fb3ba8bde 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 e9451cd759..7d7c668a8e 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) } + )) } + } } } From f8d519e8eb04f3de5897d1b05b08a9cb94c2614e Mon Sep 17 00:00:00 2001 From: Saifuddin Adenwala Date: Fri, 6 Dec 2024 14:01:40 +0530 Subject: [PATCH 3/5] Migrated filepicker from Java to Kotlin (#5997) * Rename .java to .kt * Migrated filepicker module from Java to Kotlin * Rename .java to .kt * Migrated filepicker module from Java to Kotlin * fix: test cases --- .../nrw/commons/filepicker/Constants.java | 23 - .../free/nrw/commons/filepicker/Costants.kt | 29 ++ .../commons/filepicker/DefaultCallback.java | 16 - .../nrw/commons/filepicker/DefaultCallback.kt | 12 + .../filepicker/ExtendedFileProvider.java | 7 - .../filepicker/ExtendedFileProvider.kt | 5 + .../nrw/commons/filepicker/FilePicker.java | 355 -------------- .../free/nrw/commons/filepicker/FilePicker.kt | 441 ++++++++++++++++++ .../filepicker/FilePickerConfiguration.java | 44 -- .../filepicker/FilePickerConfiguration.kt | 46 ++ .../filepicker/MimeTypeMapWrapper.java | 26 -- .../commons/filepicker/MimeTypeMapWrapper.kt | 24 + .../nrw/commons/filepicker/PickedFiles.java | 208 --------- .../nrw/commons/filepicker/PickedFiles.kt | 195 ++++++++ .../commons/filepicker/UploadableFile.java | 213 --------- .../nrw/commons/filepicker/UploadableFile.kt | 168 +++++++ .../nrw/commons/settings/SettingsFragment.kt | 15 +- .../nrw/commons/utils/CustomSelectorUtils.kt | 2 +- .../filepicker/ShadowFileProvider.java | 32 -- .../commons/filepicker/ShadowFileProvider.kt | 36 ++ .../nrw/commons/upload/UploadPresenterTest.kt | 2 +- 21 files changed, 970 insertions(+), 929 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt delete mode 100644 app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.java create mode 100644 app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.kt diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java b/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java deleted file mode 100644 index 97a16acc32..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -public interface Constants { - String DEFAULT_FOLDER_NAME = "CommonsContributions"; - - /** - * Provides the request codes for permission handling - */ - interface RequestCodes { - int LOCATION = 1; - int STORAGE = 2; - } - - /** - * Provides locations as string for corresponding operations - */ - interface BundleKeys { - String FOLDER_NAME = "fr.free.nrw.commons.folder_name"; - String ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple"; - String COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos"; - String COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images"; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt new file mode 100644 index 0000000000..e405a6d52c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt @@ -0,0 +1,29 @@ +package fr.free.nrw.commons.filepicker + +interface Constants { + companion object { + const val DEFAULT_FOLDER_NAME = "CommonsContributions" + } + + /** + * Provides the request codes for permission handling + */ + interface RequestCodes { + companion object { + const val LOCATION = 1 + const val STORAGE = 2 + } + } + + /** + * Provides locations as string for corresponding operations + */ + interface BundleKeys { + companion object { + const val FOLDER_NAME = "fr.free.nrw.commons.folder_name" + const val ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple" + const val COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos" + const val COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images" + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java deleted file mode 100644 index e8373dc6fa..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java +++ /dev/null @@ -1,16 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -/** - * Provides abstract methods which are overridden while handling Contribution Results - * inside the ContributionsController - */ -public abstract class DefaultCallback implements FilePicker.Callbacks { - - @Override - public void onImagePickerError(Exception e, FilePicker.ImageSource source, int type) { - } - - @Override - public void onCanceled(FilePicker.ImageSource source, int type) { - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt new file mode 100644 index 0000000000..baaba67b5d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.filepicker + +/** + * Provides abstract methods which are overridden while handling Contribution Results + * inside the ContributionsController + */ +abstract class DefaultCallback: FilePicker.Callbacks { + + override fun onImagePickerError(e: Exception, source: FilePicker.ImageSource, type: Int) {} + + override fun onCanceled(source: FilePicker.ImageSource, type: Int) {} +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java deleted file mode 100644 index af3dc8622e..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import androidx.core.content.FileProvider; - -public class ExtendedFileProvider extends FileProvider { - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt new file mode 100644 index 0000000000..746058fc43 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.filepicker + +import androidx.core.content.FileProvider + +class ExtendedFileProvider: FileProvider() {} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java deleted file mode 100644 index b64db24c5f..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java +++ /dev/null @@ -1,355 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import static fr.free.nrw.commons.filepicker.PickedFiles.singleFileList; - -import android.app.Activity; -import android.content.ClipData; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.provider.MediaStore; -import android.text.TextUtils; -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.PreferenceManager; -import fr.free.nrw.commons.customselector.model.Image; -import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity; -import java.io.File; -import java.io.IOException; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; - -public class FilePicker implements Constants { - - private static final String KEY_PHOTO_URI = "photo_uri"; - private static final String KEY_VIDEO_URI = "video_uri"; - private static final String KEY_LAST_CAMERA_PHOTO = "last_photo"; - private static final String KEY_LAST_CAMERA_VIDEO = "last_video"; - private static final String KEY_TYPE = "type"; - - /** - * Returns the uri of the clicked image so that it can be put in MediaStore - */ - private static Uri createCameraPictureFile(@NonNull Context context) throws IOException { - File imagePath = PickedFiles.getCameraPicturesLocation(context); - Uri uri = PickedFiles.getUriToFile(context, imagePath); - SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit(); - editor.putString(KEY_PHOTO_URI, uri.toString()); - editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString()); - editor.apply(); - return uri; - } - - private static Intent createGalleryIntent(@NonNull Context context, int type, - boolean openDocumentIntentPreferred) { - // storing picked image type to shared preferences - storeType(context, type); - //Supported types are SVG, PNG and JPEG,GIF, TIFF, WebP, XCF - final String[] mimeTypes = { "image/jpg","image/png","image/jpeg", "image/gif", "image/tiff", "image/webp", "image/xcf", "image/svg+xml", "image/webp"}; - return plainGalleryPickerIntent(openDocumentIntentPreferred) - .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, configuration(context).allowsMultiplePickingInGallery()) - .putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); - } - - /** - * CreateCustomSectorIntent, creates intent for custom selector activity. - * @param context - * @param type - * @return Custom selector intent - */ - private static Intent createCustomSelectorIntent(@NonNull Context context, int type) { - storeType(context, type); - return new Intent(context, CustomSelectorActivity.class); - } - - private static Intent createCameraForImageIntent(@NonNull Context context, int type) { - storeType(context, type); - - Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - try { - Uri capturedImageUri = createCameraPictureFile(context); - //We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20 - grantWritePermission(context, intent, capturedImageUri); - intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri); - } catch (Exception e) { - e.printStackTrace(); - } - - return intent; - } - - private static void revokeWritePermission(@NonNull Context context, Uri uri) { - context.revokeUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - - private static void grantWritePermission(@NonNull Context context, Intent intent, Uri uri) { - List resInfoList = context.getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - private static void storeType(@NonNull Context context, int type) { - PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply(); - } - - private static int restoreType(@NonNull Context context) { - return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0); - } - - /** - * Opens default galery or a available galleries picker if there is no default - * - * @param type Custom type of your choice, which will be returned with the images - */ - public static void openGallery(Activity activity, ActivityResultLauncher resultLauncher, int type, boolean openDocumentIntentPreferred) { - Intent intent = createGalleryIntent(activity, type, openDocumentIntentPreferred); - resultLauncher.launch(intent); - } - - /** - * Opens Custom Selector - */ - public static void openCustomSelector(Activity activity, ActivityResultLauncher resultLauncher, int type) { - Intent intent = createCustomSelectorIntent(activity, type); - resultLauncher.launch(intent); - } - - /** - * Opens the camera app to pick image clicked by user - */ - public static void openCameraForImage(Activity activity, ActivityResultLauncher resultLauncher, int type) { - Intent intent = createCameraForImageIntent(activity, type); - resultLauncher.launch(intent); - } - - @Nullable - private static UploadableFile takenCameraPicture(Context context) throws URISyntaxException { - String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_PHOTO, null); - if (lastCameraPhoto != null) { - return new UploadableFile(new File(lastCameraPhoto)); - } else { - return null; - } - } - - @Nullable - private static UploadableFile takenCameraVideo(Context context) throws URISyntaxException { - String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_VIDEO, null); - if (lastCameraPhoto != null) { - return new UploadableFile(new File(lastCameraPhoto)); - } else { - return null; - } - } - - public static List handleExternalImagesPicked(Intent data, Activity activity) { - try { - return getFilesFromGalleryPictures(data, activity); - } catch (IOException | SecurityException e) { - e.printStackTrace(); - } - return new ArrayList<>(); - } - - private static boolean isPhoto(Intent data) { - return data == null || (data.getData() == null && data.getClipData() == null); - } - - private static Intent plainGalleryPickerIntent(boolean openDocumentIntentPreferred) { - /* - * Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue - * in the custom selector in Contributions fragment. - * Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015 - * - * This permission check, however, was insufficient to fix location-loss in - * the regular selector in Contributions fragment and Nearby fragment, - * especially on some devices running Android 13 that use the new Photo Picker by default. - * - * New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker - * - * The new Photo Picker introduced by Android redacts location tags from EXIF metadata. - * Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058 - * Status: Won't fix (Intended behaviour) - * - * Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can - * be changed through the Setting page) as: - * - * ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data - * The best application is the new Photo Picker that redacts the location tags - * - * ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances - * installed on the device, letting the user interactively navigate through them. - * - * So, this allows us to use the traditional file picker that does not redact location tags - * from EXIF. - * - */ - Intent intent; - if (openDocumentIntentPreferred) { - intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - } else { - intent = new Intent(Intent.ACTION_GET_CONTENT); - } - intent.setType("image/*"); - return intent; - } - - public static void onPictureReturnedFromDocuments(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - if(result.getResultCode() == Activity.RESULT_OK && !isPhoto(result.getData())){ - try { - Uri photoPath = result.getData().getData(); - UploadableFile photoFile = PickedFiles.pickedExistingPicture(activity, photoPath); - callbacks.onImagesPicked(singleFileList(photoFile), FilePicker.ImageSource.DOCUMENTS, restoreType(activity)); - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)); - } - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.DOCUMENTS, restoreType(activity)); - } - } else { - callbacks.onCanceled(FilePicker.ImageSource.DOCUMENTS, restoreType(activity)); - } - } - - /** - * onPictureReturnedFromCustomSelector. - * Retrieve and forward the images to upload wizard through callback. - */ - public static void onPictureReturnedFromCustomSelector(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - if(result.getResultCode() == Activity.RESULT_OK){ - try { - List files = getFilesFromCustomSelector(result.getData(), activity); - callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity)); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity)); - } - } else { - callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity)); - } - } - - /** - * Get files from custom selector - * Retrieve and process the selected images from the custom selector. - */ - private static List getFilesFromCustomSelector(Intent data, Activity activity) throws IOException, SecurityException { - List files = new ArrayList<>(); - ArrayList images = data.getParcelableArrayListExtra("Images"); - for(Image image : images) { - Uri uri = image.getUri(); - UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri); - files.add(file); - } - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, files); - } - - return files; - } - - public static void onPictureReturnedFromGallery(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - if(result.getResultCode() == Activity.RESULT_OK && !isPhoto(result.getData())){ - try { - List files = getFilesFromGalleryPictures(result.getData(), activity); - callbacks.onImagesPicked(files, FilePicker.ImageSource.GALLERY, restoreType(activity)); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.GALLERY, restoreType(activity)); - } - } else{ - callbacks.onCanceled(FilePicker.ImageSource.GALLERY, restoreType(activity)); - } - } - - private static List getFilesFromGalleryPictures(Intent data, Activity activity) throws IOException, SecurityException { - List files = new ArrayList<>(); - ClipData clipData = data.getClipData(); - if (clipData == null) { - Uri uri = data.getData(); - UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri); - files.add(file); - } else { - for (int i = 0; i < clipData.getItemCount(); i++) { - Uri uri = clipData.getItemAt(i).getUri(); - UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri); - files.add(file); - } - } - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, files); - } - - return files; - } - - public static void onPictureReturnedFromCamera(ActivityResult activityResult, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - if(activityResult.getResultCode() == Activity.RESULT_OK){ - try { - String lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity).getString(KEY_PHOTO_URI, null); - if (!TextUtils.isEmpty(lastImageUri)) { - revokeWritePermission(activity, Uri.parse(lastImageUri)); - } - - UploadableFile photoFile = FilePicker.takenCameraPicture(activity); - List files = new ArrayList<>(); - files.add(photoFile); - - if (photoFile == null) { - Exception e = new IllegalStateException("Unable to get the picture returned from camera"); - callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } else { - if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)); - } - - callbacks.onImagesPicked(files, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } - - PreferenceManager.getDefaultSharedPreferences(activity) - .edit() - .remove(KEY_LAST_CAMERA_PHOTO) - .remove(KEY_PHOTO_URI) - .apply(); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } - } else { - callbacks.onCanceled(FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } - } - - public static FilePickerConfiguration configuration(@NonNull Context context) { - return new FilePickerConfiguration(context); - } - - - public enum ImageSource { - GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR - } - - public interface Callbacks { - void onImagePickerError(Exception e, FilePicker.ImageSource source, int type); - - void onImagesPicked(@NonNull List imageFiles, FilePicker.ImageSource source, int type); - - void onCanceled(FilePicker.ImageSource source, int type); - } - - public interface HandleActivityResult{ - void onHandleActivityResult(FilePicker.Callbacks callbacks); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt new file mode 100644 index 0000000000..6bf8a10613 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt @@ -0,0 +1,441 @@ +package fr.free.nrw.commons.filepicker + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.preference.PreferenceManager +import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity +import fr.free.nrw.commons.filepicker.PickedFiles.singleFileList +import java.io.File +import java.io.IOException +import java.net.URISyntaxException + + +object FilePicker : Constants { + + private const val KEY_PHOTO_URI = "photo_uri" + private const val KEY_VIDEO_URI = "video_uri" + private const val KEY_LAST_CAMERA_PHOTO = "last_photo" + private const val KEY_LAST_CAMERA_VIDEO = "last_video" + private const val KEY_TYPE = "type" + + /** + * Returns the uri of the clicked image so that it can be put in MediaStore + */ + @Throws(IOException::class) + @JvmStatic + private fun createCameraPictureFile(context: Context): Uri { + val imagePath = PickedFiles.getCameraPicturesLocation(context) + val uri = PickedFiles.getUriToFile(context, imagePath) + val editor = PreferenceManager.getDefaultSharedPreferences(context).edit() + editor.putString(KEY_PHOTO_URI, uri.toString()) + editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString()) + editor.apply() + return uri + } + + + @JvmStatic + private fun createGalleryIntent( + context: Context, + type: Int, + openDocumentIntentPreferred: Boolean + ): Intent { + // storing picked image type to shared preferences + storeType(context, type) + // Supported types are SVG, PNG and JPEG, GIF, TIFF, WebP, XCF + val mimeTypes = arrayOf( + "image/jpg", + "image/png", + "image/jpeg", + "image/gif", + "image/tiff", + "image/webp", + "image/xcf", + "image/svg+xml", + "image/webp" + ) + return plainGalleryPickerIntent(openDocumentIntentPreferred) + .putExtra( + Intent.EXTRA_ALLOW_MULTIPLE, + configuration(context).allowsMultiplePickingInGallery() + ) + .putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) + } + + /** + * CreateCustomSectorIntent, creates intent for custom selector activity. + * @param context + * @param type + * @return Custom selector intent + */ + @JvmStatic + private fun createCustomSelectorIntent(context: Context, type: Int): Intent { + storeType(context, type) + return Intent(context, CustomSelectorActivity::class.java) + } + + @JvmStatic + private fun createCameraForImageIntent(context: Context, type: Int): Intent { + storeType(context, type) + + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + try { + val capturedImageUri = createCameraPictureFile(context) + // We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20 + grantWritePermission(context, intent, capturedImageUri) + intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri) + } catch (e: Exception) { + e.printStackTrace() + } + + return intent + } + + @JvmStatic + private fun revokeWritePermission(context: Context, uri: Uri) { + context.revokeUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + + @JvmStatic + private fun grantWritePermission(context: Context, intent: Intent, uri: Uri) { + val resInfoList = + context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + for (resolveInfo in resInfoList) { + val packageName = resolveInfo.activityInfo.packageName + context.grantUriPermission( + packageName, + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + + @JvmStatic + private fun storeType(context: Context, type: Int) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply() + } + + @JvmStatic + private fun restoreType(context: Context): Int { + return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0) + } + + /** + * Opens default gallery or available galleries picker if there is no default + * + * @param type Custom type of your choice, which will be returned with the images + */ + @JvmStatic + fun openGallery( + activity: Activity, + resultLauncher: ActivityResultLauncher, + type: Int, + openDocumentIntentPreferred: Boolean + ) { + val intent = createGalleryIntent(activity, type, openDocumentIntentPreferred) + resultLauncher.launch(intent) + } + + /** + * Opens Custom Selector + */ + @JvmStatic + fun openCustomSelector( + activity: Activity, + resultLauncher: ActivityResultLauncher, + type: Int + ) { + val intent = createCustomSelectorIntent(activity, type) + resultLauncher.launch(intent) + } + + /** + * Opens the camera app to pick image clicked by user + */ + @JvmStatic + fun openCameraForImage( + activity: Activity, + resultLauncher: ActivityResultLauncher, + type: Int + ) { + val intent = createCameraForImageIntent(activity, type) + resultLauncher.launch(intent) + } + + @Throws(URISyntaxException::class) + @JvmStatic + private fun takenCameraPicture(context: Context): UploadableFile? { + val lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context) + .getString(KEY_LAST_CAMERA_PHOTO, null) + return if (lastCameraPhoto != null) { + UploadableFile(File(lastCameraPhoto)) + } else { + null + } + } + + @Throws(URISyntaxException::class) + @JvmStatic + private fun takenCameraVideo(context: Context): UploadableFile? { + val lastCameraVideo = PreferenceManager.getDefaultSharedPreferences(context) + .getString(KEY_LAST_CAMERA_VIDEO, null) + return if (lastCameraVideo != null) { + UploadableFile(File(lastCameraVideo)) + } else { + null + } + } + + @JvmStatic + fun handleExternalImagesPicked(data: Intent?, activity: Activity): List { + return try { + getFilesFromGalleryPictures(data, activity) + } catch (e: IOException) { + e.printStackTrace() + emptyList() + } catch (e: SecurityException) { + e.printStackTrace() + emptyList() + } + } + + @JvmStatic + private fun isPhoto(data: Intent?): Boolean { + return data == null || (data.data == null && data.clipData == null) + } + + @JvmStatic + private fun plainGalleryPickerIntent( + openDocumentIntentPreferred: Boolean + ): Intent { + /* + * Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue + * in the custom selector in Contributions fragment. + * Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015 + * + * This permission check, however, was insufficient to fix location-loss in + * the regular selector in Contributions fragment and Nearby fragment, + * especially on some devices running Android 13 that use the new Photo Picker by default. + * + * New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker + * + * The new Photo Picker introduced by Android redacts location tags from EXIF metadata. + * Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058 + * Status: Won't fix (Intended behaviour) + * + * Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can + * be changed through the Setting page) as: + * + * ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data + * The best application is the new Photo Picker that redacts the location tags + * + * ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances + * installed on the device, letting the user interactively navigate through them. + * + * So, this allows us to use the traditional file picker that does not redact location tags + * from EXIF. + * + */ + val intent = if (openDocumentIntentPreferred) { + Intent(Intent.ACTION_OPEN_DOCUMENT) + } else { + Intent(Intent.ACTION_GET_CONTENT) + } + intent.type = "image/*" + return intent + } + + @JvmStatic + fun onPictureReturnedFromDocuments( + result: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) { + try { + val photoPath = result.data?.data + val photoFile = PickedFiles.pickedExistingPicture(activity, photoPath!!) + callbacks.onImagesPicked( + singleFileList(photoFile), + ImageSource.DOCUMENTS, + restoreType(activity) + ) + + if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)) + } + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.DOCUMENTS, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.DOCUMENTS, restoreType(activity)) + } + } + + /** + * onPictureReturnedFromCustomSelector. + * Retrieve and forward the images to upload wizard through callback. + */ + @JvmStatic + fun onPictureReturnedFromCustomSelector( + result: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (result.resultCode == Activity.RESULT_OK) { + try { + val files = getFilesFromCustomSelector(result.data, activity) + callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity)) + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity)) + } + } + + /** + * Get files from custom selector + * Retrieve and process the selected images from the custom selector. + */ + @Throws(IOException::class, SecurityException::class) + @JvmStatic + private fun getFilesFromCustomSelector( + data: Intent?, + activity: Activity + ): List { + val files = mutableListOf() + val images = data?.getParcelableArrayListExtra("Images") + images?.forEach { image -> + val uri = image.uri + val file = PickedFiles.pickedExistingPicture(activity, uri) + files.add(file) + } + + if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, files) + } + + return files + } + + @JvmStatic + fun onPictureReturnedFromGallery( + result: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) { + try { + val files = getFilesFromGalleryPictures(result.data, activity) + callbacks.onImagesPicked(files, ImageSource.GALLERY, restoreType(activity)) + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.GALLERY, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.GALLERY, restoreType(activity)) + } + } + + @Throws(IOException::class, SecurityException::class) + @JvmStatic + private fun getFilesFromGalleryPictures( + data: Intent?, + activity: Activity + ): List { + val files = mutableListOf() + val clipData = data?.clipData + if (clipData == null) { + val uri = data?.data + val file = PickedFiles.pickedExistingPicture(activity, uri!!) + files.add(file) + } else { + for (i in 0 until clipData.itemCount) { + val uri = clipData.getItemAt(i).uri + val file = PickedFiles.pickedExistingPicture(activity, uri) + files.add(file) + } + } + + if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, files) + } + + return files + } + + @JvmStatic + fun onPictureReturnedFromCamera( + activityResult: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (activityResult.resultCode == Activity.RESULT_OK) { + try { + val lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity) + .getString(KEY_PHOTO_URI, null) + if (!lastImageUri.isNullOrEmpty()) { + revokeWritePermission(activity, Uri.parse(lastImageUri)) + } + + val photoFile = takenCameraPicture(activity) + val files = mutableListOf() + photoFile?.let { files.add(it) } + + if (photoFile == null) { + val e = IllegalStateException("Unable to get the picture returned from camera") + callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity)) + } else { + if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)) + } + callbacks.onImagesPicked(files, ImageSource.CAMERA_IMAGE, restoreType(activity)) + } + + PreferenceManager.getDefaultSharedPreferences(activity).edit() + .remove(KEY_LAST_CAMERA_PHOTO) + .remove(KEY_PHOTO_URI) + .apply() + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.CAMERA_IMAGE, restoreType(activity)) + } + } + + @JvmStatic + fun configuration(context: Context): FilePickerConfiguration { + return FilePickerConfiguration(context) + } + + enum class ImageSource { + GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR + } + + interface Callbacks { + fun onImagePickerError(e: Exception, source: ImageSource, type: Int) + + fun onImagesPicked(imageFiles: List, source: ImageSource, type: Int) + + fun onCanceled(source: ImageSource, type: Int) + } + + interface HandleActivityResult { + fun onHandleActivityResult(callbacks: Callbacks) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java deleted file mode 100644 index 08a204e8b9..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java +++ /dev/null @@ -1,44 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.content.Context; -import androidx.preference.PreferenceManager; - -public class FilePickerConfiguration implements Constants { - - private Context context; - - FilePickerConfiguration(Context context) { - this.context = context; - } - - public FilePickerConfiguration setAllowMultiplePickInGallery(boolean allowMultiple) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putBoolean(BundleKeys.ALLOW_MULTIPLE, allowMultiple) - .apply(); - return this; - } - - public FilePickerConfiguration setCopyTakenPhotosToPublicGalleryAppFolder(boolean copy) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putBoolean(BundleKeys.COPY_TAKEN_PHOTOS, copy) - .apply(); - return this; - } - - public String getFolderName() { - return PreferenceManager.getDefaultSharedPreferences(context).getString(BundleKeys.FOLDER_NAME, DEFAULT_FOLDER_NAME); - } - - public boolean allowsMultiplePickingInGallery() { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.ALLOW_MULTIPLE, false); - } - - public boolean shouldCopyTakenPhotosToPublicGalleryAppFolder() { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_TAKEN_PHOTOS, false); - } - - public boolean shouldCopyPickedImagesToPublicGalleryAppFolder() { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_PICKED_IMAGES, false); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt new file mode 100644 index 0000000000..db025a5442 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.filepicker + +import android.content.Context +import androidx.preference.PreferenceManager + +class FilePickerConfiguration( + private val context: Context +): Constants { + + fun setAllowMultiplePickInGallery(allowMultiple: Boolean): FilePickerConfiguration { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, allowMultiple) + .apply() + return this + } + + fun setCopyTakenPhotosToPublicGalleryAppFolder(copy: Boolean): FilePickerConfiguration { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, copy) + .apply() + return this + } + + fun getFolderName(): String { + return PreferenceManager.getDefaultSharedPreferences(context) + .getString( + Constants.BundleKeys.FOLDER_NAME, + Constants.DEFAULT_FOLDER_NAME + ) ?: Constants.DEFAULT_FOLDER_NAME + } + + fun allowsMultiplePickingInGallery(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, false) + } + + fun shouldCopyTakenPhotosToPublicGalleryAppFolder(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, false) + } + + fun shouldCopyPickedImagesToPublicGalleryAppFolder(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.BundleKeys.COPY_PICKED_IMAGES, false) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java deleted file mode 100644 index e6c82f5c1c..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java +++ /dev/null @@ -1,26 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.webkit.MimeTypeMap; - -import com.facebook.common.internal.ImmutableMap; - -import java.util.Map; - -public class MimeTypeMapWrapper { - - private static final MimeTypeMap sMimeTypeMap = MimeTypeMap.getSingleton(); - - private static final Map sMimeTypeToExtensionMap = - ImmutableMap.of( - "image/heif", "heif", - "image/heic", "heic"); - - public static String getExtensionFromMimeType(String mimeType) { - String result = sMimeTypeToExtensionMap.get(mimeType); - if (result != null) { - return result; - } - return sMimeTypeMap.getExtensionFromMimeType(mimeType); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt new file mode 100644 index 0000000000..0cf21cc027 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt @@ -0,0 +1,24 @@ +package fr.free.nrw.commons.filepicker + +import android.webkit.MimeTypeMap + +class MimeTypeMapWrapper { + + companion object { + private val sMimeTypeMap = MimeTypeMap.getSingleton() + + private val sMimeTypeToExtensionMap = mapOf( + "image/heif" to "heif", + "image/heic" to "heic" + ) + + @JvmStatic + fun getExtensionFromMimeType(mimeType: String): String? { + val result = sMimeTypeToExtensionMap[mimeType] + if (result != null) { + return result + } + return sMimeTypeMap.getExtensionFromMimeType(mimeType) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java deleted file mode 100644 index ca1abba623..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java +++ /dev/null @@ -1,208 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.content.ContentResolver; -import android.content.Context; -import android.media.MediaScannerConnection; -import android.net.Uri; -import android.os.Environment; -import android.webkit.MimeTypeMap; - -import androidx.annotation.NonNull; -import androidx.core.content.FileProvider; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; -import java.util.UUID; - -import timber.log.Timber; - -/** - * PickedFiles. - * Process the upload items. - */ -public class PickedFiles implements Constants { - - /** - * Get Folder Name - * @param context - * @return default application folder name. - */ - private static String getFolderName(@NonNull Context context) { - return FilePicker.configuration(context).getFolderName(); - } - - /** - * tempImageDirectory - * @param context - * @return temporary image directory to copy and perform exif changes. - */ - private static File tempImageDirectory(@NonNull Context context) { - File privateTempDir = new File(context.getCacheDir(), DEFAULT_FOLDER_NAME); - if (!privateTempDir.exists()) privateTempDir.mkdirs(); - return privateTempDir; - } - - /** - * writeToFile - * writes inputStream data to the destination file. - * @param in input stream of source file. - * @param file destination file - */ - private static void writeToFile(InputStream in, File file) throws IOException { - try (OutputStream out = new FileOutputStream(file)) { - byte[] buf = new byte[1024]; - int len; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - } - } - } - - /** - * Copy file function. - * Copies source file to destination file. - * @param src source file - * @param dst destination file - * @throws IOException (File input stream exception) - */ - private static void copyFile(File src, File dst) throws IOException { - try (InputStream in = new FileInputStream(src)) { - writeToFile(in, dst); - } - } - - /** - * Copy files in separate thread. - * Copies all the uploadable files to the temp image folder on background thread. - * @param context - * @param filesToCopy uploadable file list to be copied. - */ - static void copyFilesInSeparateThread(final Context context, final List filesToCopy) { - new Thread(() -> { - List copiedFiles = new ArrayList<>(); - int i = 1; - for (UploadableFile uploadableFile : filesToCopy) { - File fileToCopy = uploadableFile.getFile(); - File dstDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), getFolderName(context)); - if (!dstDir.exists()) { - dstDir.mkdirs(); - } - - String[] filenameSplit = fileToCopy.getName().split("\\."); - String extension = "." + filenameSplit[filenameSplit.length - 1]; - String filename = String.format("IMG_%s_%d.%s", new SimpleDateFormat("yyyyMMdd_HHmmss").format(Calendar.getInstance().getTime()), i, extension); - - File dstFile = new File(dstDir, filename); - try { - dstFile.createNewFile(); - copyFile(fileToCopy, dstFile); - copiedFiles.add(dstFile); - } catch (IOException e) { - e.printStackTrace(); - } - i++; - } - scanCopiedImages(context, copiedFiles); - }).run(); - } - - /** - * singleFileList. - * converts a single uploadableFile to list of uploadableFile. - * @param file uploadable file - * @return - */ - static List singleFileList(UploadableFile file) { - List list = new ArrayList<>(); - list.add(file); - return list; - } - - /** - * ScanCopiedImages - * Scan copied images metadata using media scanner. - * @param context - * @param copiedImages copied images list. - */ - static void scanCopiedImages(Context context, List copiedImages) { - String[] paths = new String[copiedImages.size()]; - for (int i = 0; i < copiedImages.size(); i++) { - paths[i] = copiedImages.get(i).toString(); - } - - MediaScannerConnection.scanFile(context, - paths, null, - (path, uri) -> { - Timber.d("Scanned " + path + ":"); - Timber.d("-> uri=%s", uri); - }); - } - - /** - * pickedExistingPicture - * convert the image into uploadable file. - * @param photoUri Uri of the image. - * @return Uploadable file ready for tag redaction. - */ - public static UploadableFile pickedExistingPicture(@NonNull Context context, Uri photoUri) throws IOException, SecurityException {// SecurityException for those file providers who share URI but forget to grant necessary permissions - File directory = tempImageDirectory(context); - File photoFile = new File(directory, UUID.randomUUID().toString() + "." + getMimeType(context, photoUri)); - if (photoFile.createNewFile()) { - try (InputStream pictureInputStream = context.getContentResolver().openInputStream(photoUri)) { - writeToFile(pictureInputStream, photoFile); - } - } else { - throw new IOException("could not create photoFile to write upon"); - } - return new UploadableFile(photoUri, photoFile); - } - - /** - * getCameraPictureLocation - */ - static File getCameraPicturesLocation(@NonNull Context context) throws IOException { - File dir = tempImageDirectory(context); - return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir); - } - - /** - * To find out the extension of required object in given uri - * Solution by http://stackoverflow.com/a/36514823/1171484 - */ - private static String getMimeType(@NonNull Context context, @NonNull Uri uri) { - String extension; - - //Check uri format to avoid null - if (uri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) { - //If scheme is a content - extension = MimeTypeMapWrapper.getExtensionFromMimeType(context.getContentResolver().getType(uri)); - } else { - //If scheme is a File - //This will replace white spaces with %20 and also other special characters. This will avoid returning null values on file name with spaces and special characters. - extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(uri.getPath())).toString()); - - } - - return extension; - } - - /** - * GetUriToFile - * @param file get uri of file - * @return uri of requested file. - */ - static Uri getUriToFile(@NonNull Context context, @NonNull File file) { - String packageName = context.getApplicationContext().getPackageName(); - String authority = packageName + ".provider"; - return FileProvider.getUriForFile(context, authority, file); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt new file mode 100644 index 0000000000..9694dedb53 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt @@ -0,0 +1,195 @@ +package fr.free.nrw.commons.filepicker + +import android.content.ContentResolver +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Environment +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import fr.free.nrw.commons.filepicker.Constants.Companion.DEFAULT_FOLDER_NAME +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID + + +/** + * PickedFiles. + * Process the upload items. + */ +object PickedFiles : Constants { + + /** + * Get Folder Name + * @return default application folder name. + */ + @JvmStatic + private fun getFolderName(context: Context): String { + return FilePicker.configuration(context).getFolderName() + } + + /** + * tempImageDirectory + * @return temporary image directory to copy and perform exif changes. + */ + @JvmStatic + private fun tempImageDirectory(context: Context): File { + val privateTempDir = File(context.cacheDir, DEFAULT_FOLDER_NAME) + if (!privateTempDir.exists()) privateTempDir.mkdirs() + return privateTempDir + } + + /** + * writeToFile + * Writes inputStream data to the destination file. + */ + @JvmStatic + @Throws(IOException::class) + private fun writeToFile(inputStream: InputStream, file: File) { + inputStream.use { input -> + FileOutputStream(file).use { output -> + val buffer = ByteArray(1024) + var length: Int + while (input.read(buffer).also { length = it } > 0) { + output.write(buffer, 0, length) + } + } + } + } + + /** + * Copy file function. + * Copies source file to destination file. + */ + @Throws(IOException::class) + @JvmStatic + private fun copyFile(src: File, dst: File) { + FileInputStream(src).use { inputStream -> + writeToFile(inputStream, dst) + } + } + + /** + * Copy files in separate thread. + * Copies all the uploadable files to the temp image folder on background thread. + */ + @JvmStatic + fun copyFilesInSeparateThread(context: Context, filesToCopy: List) { + Thread { + val copiedFiles = mutableListOf() + var index = 1 + filesToCopy.forEach { uploadableFile -> + val fileToCopy = uploadableFile.file + val dstDir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + getFolderName(context) + ) + if (!dstDir.exists()) dstDir.mkdirs() + + val filenameSplit = fileToCopy.name.split(".") + val extension = ".${filenameSplit.last()}" + val filename = "IMG_${SimpleDateFormat( + "yyyyMMdd_HHmmss", + Locale.getDefault()).format(Date())}_$index$extension" + val dstFile = File(dstDir, filename) + + try { + dstFile.createNewFile() + copyFile(fileToCopy, dstFile) + copiedFiles.add(dstFile) + } catch (e: IOException) { + e.printStackTrace() + } + index++ + } + scanCopiedImages(context, copiedFiles) + }.start() + } + + /** + * singleFileList + * Converts a single uploadableFile to list of uploadableFile. + */ + @JvmStatic + fun singleFileList(file: UploadableFile): List { + return listOf(file) + } + + /** + * ScanCopiedImages + * Scans copied images metadata using media scanner. + */ + @JvmStatic + fun scanCopiedImages(context: Context, copiedImages: List) { + val paths = copiedImages.map { it.toString() }.toTypedArray() + MediaScannerConnection.scanFile(context, paths, null) { path, uri -> + Timber.d("Scanned $path:") + Timber.d("-> uri=$uri") + } + } + + /** + * pickedExistingPicture + * Convert the image into uploadable file. + */ + @Throws(IOException::class, SecurityException::class) + @JvmStatic + fun pickedExistingPicture(context: Context, photoUri: Uri): UploadableFile { + val directory = tempImageDirectory(context) + val mimeType = getMimeType(context, photoUri) + val photoFile = File(directory, "${UUID.randomUUID()}.$mimeType") + + if (photoFile.createNewFile()) { + context.contentResolver.openInputStream(photoUri)?.use { inputStream -> + writeToFile(inputStream, photoFile) + } + } else { + throw IOException("Could not create photoFile to write upon") + } + return UploadableFile(photoUri, photoFile) + } + + /** + * getCameraPictureLocation + */ + @Throws(IOException::class) + @JvmStatic + fun getCameraPicturesLocation(context: Context): File { + val dir = tempImageDirectory(context) + return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir) + } + + /** + * To find out the extension of the required object in a given uri + */ + @JvmStatic + private fun getMimeType(context: Context, uri: Uri): String { + return if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + context.contentResolver.getType(uri) + ?.let { MimeTypeMapWrapper.getExtensionFromMimeType(it) } + } else { + MimeTypeMap.getFileExtensionFromUrl( + Uri.fromFile(uri.path?.let { File(it) }).toString() + ) + } ?: "jpg" // Default to jpg if unable to determine type + } + + /** + * GetUriToFile + * @param file get uri of file + * @return uri of requested file. + */ + @JvmStatic + fun getUriToFile(context: Context, file: File): Uri { + val packageName = context.applicationContext.packageName + val authority = "$packageName.provider" + return FileProvider.getUriForFile(context, authority, file) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java deleted file mode 100644 index 1fe306a8b5..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java +++ /dev/null @@ -1,213 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.Nullable; -import androidx.exifinterface.media.ExifInterface; - -import fr.free.nrw.commons.upload.FileUtils; -import java.io.File; -import java.io.IOException; -import java.util.Date; -import timber.log.Timber; - -public class UploadableFile implements Parcelable { - public static final Creator CREATOR = new Creator() { - @Override - public UploadableFile createFromParcel(Parcel in) { - return new UploadableFile(in); - } - - @Override - public UploadableFile[] newArray(int size) { - return new UploadableFile[size]; - } - }; - - private final Uri contentUri; - private final File file; - - public UploadableFile(Uri contentUri, File file) { - this.contentUri = contentUri; - this.file = file; - } - - public UploadableFile(File file) { - this.file = file; - this.contentUri = Uri.fromFile(new File(file.getPath())); - } - - public UploadableFile(Parcel in) { - this.contentUri = in.readParcelable(Uri.class.getClassLoader()); - file = (File) in.readSerializable(); - } - - public Uri getContentUri() { - return contentUri; - } - - public File getFile() { - return file; - } - - public String getFilePath() { - return file.getPath(); - } - - public Uri getMediaUri() { - return Uri.parse(getFilePath()); - } - - public String getMimeType(Context context) { - return FileUtils.getMimeType(context, getMediaUri()); - } - - @Override - public int describeContents() { - return 0; - } - - /** - * First try to get the file creation date from EXIF else fall back to CP - * @param context - * @return - */ - @Nullable - public DateTimeWithSource getFileCreatedDate(Context context) { - DateTimeWithSource dateTimeFromExif = getDateTimeFromExif(); - if (dateTimeFromExif == null) { - return getFileCreatedDateFromCP(context); - } else { - return dateTimeFromExif; - } - } - - /** - * Get filePath creation date from uri from all possible content providers - * - * @return - */ - private DateTimeWithSource getFileCreatedDateFromCP(Context context) { - try { - Cursor cursor = context.getContentResolver().query(contentUri, null, null, null, null); - if (cursor == null) { - return null;//Could not fetch last_modified - } - //Content provider contracts for opening gallery from the app and that by sharing from gallery from outside are different and we need to handle both the cases - int lastModifiedColumnIndex = cursor.getColumnIndex("last_modified");//If gallery is opened from in app - if (lastModifiedColumnIndex == -1) { - lastModifiedColumnIndex = cursor.getColumnIndex("datetaken"); - } - //If both the content providers do not give the data, lets leave it to Jesus - if (lastModifiedColumnIndex == -1) { - cursor.close(); - return null; - } - cursor.moveToFirst(); - return new DateTimeWithSource(cursor.getLong(lastModifiedColumnIndex), DateTimeWithSource.CP_SOURCE); - } catch (Exception e) { - return null;////Could not fetch last_modified - } - } - - /** - * Indicate whether the EXIF contains the location (both latitude and longitude). - * - * @return whether the location exists for the file's EXIF - */ - public boolean hasLocation() { - try { - ExifInterface exif = new ExifInterface(file.getAbsolutePath()); - final String latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE); - final String longitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE); - return latitude != null && longitude != null; - } catch (IOException | NumberFormatException | IndexOutOfBoundsException e) { - Timber.tag("UploadableFile"); - Timber.d(e); - } - return false; - } - - /** - * Get filePath creation date from uri from EXIF - * - * @return - */ - private DateTimeWithSource getDateTimeFromExif() { - try { - ExifInterface exif = new ExifInterface(file.getAbsolutePath()); - // TAG_DATETIME returns the last edited date, we need TAG_DATETIME_ORIGINAL for creation date - // See issue https://github.com/commons-app/apps-android-commons/issues/1971 - String dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL); - if (dateTimeSubString!=null) { //getAttribute may return null - String year = dateTimeSubString.substring(0,4); - String month = dateTimeSubString.substring(5,7); - String day = dateTimeSubString.substring(8,10); - // This date is stored as a string (not as a date), the rason is we don't want to include timezones - String dateCreatedString = String.format("%04d-%02d-%02d", Integer.parseInt(year), Integer.parseInt(month), Integer.parseInt(day)); - if (dateCreatedString.length() == 10) { //yyyy-MM-dd format of date is expected - @SuppressLint("RestrictedApi") Long dateTime = exif.getDateTimeOriginal(); - if(dateTime != null){ - Date date = new Date(dateTime); - return new DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE); - } - } - } - } catch (IOException | NumberFormatException | IndexOutOfBoundsException e) { - Timber.tag("UploadableFile"); - Timber.d(e); - } - return null; - } - - @Override - public void writeToParcel(Parcel parcel, int i) { - parcel.writeParcelable(contentUri, 0); - parcel.writeSerializable(file); - } - - /** - * This class contains the epochDate along with the source from which it was extracted - */ - public class DateTimeWithSource { - public static final String CP_SOURCE = "contentProvider"; - public static final String EXIF_SOURCE = "exif"; - - private final long epochDate; - private String dateString; // this does not includes timezone information - private final String source; - - public DateTimeWithSource(long epochDate, String source) { - this.epochDate = epochDate; - this.source = source; - } - - public DateTimeWithSource(Date date, String source) { - this.epochDate = date.getTime(); - this.source = source; - } - - public DateTimeWithSource(Date date, String dateString, String source) { - this.epochDate = date.getTime(); - this.dateString = dateString; - this.source = source; - } - - public long getEpochDate() { - return epochDate; - } - - public String getDateString() { - return dateString; - } - - public String getSource() { - return source; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt new file mode 100644 index 0000000000..1398e77853 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt @@ -0,0 +1,168 @@ +package fr.free.nrw.commons.filepicker + +import android.annotation.SuppressLint +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable + +import androidx.exifinterface.media.ExifInterface + +import fr.free.nrw.commons.upload.FileUtils +import java.io.File +import java.io.IOException +import java.util.Date +import timber.log.Timber + +class UploadableFile : Parcelable { + + val contentUri: Uri + val file: File + + constructor(contentUri: Uri, file: File) { + this.contentUri = contentUri + this.file = file + } + + constructor(file: File) { + this.file = file + this.contentUri = Uri.fromFile(File(file.path)) + } + + private constructor(parcel: Parcel) { + contentUri = parcel.readParcelable(Uri::class.java.classLoader)!! + file = parcel.readSerializable() as File + } + + fun getFilePath(): String { + return file.path + } + + fun getMediaUri(): Uri { + return Uri.parse(getFilePath()) + } + + fun getMimeType(context: Context): String? { + return FileUtils.getMimeType(context, getMediaUri()) + } + + override fun describeContents(): Int = 0 + + /** + * First try to get the file creation date from EXIF, else fall back to Content Provider (CP) + */ + fun getFileCreatedDate(context: Context): DateTimeWithSource? { + return getDateTimeFromExif() ?: getFileCreatedDateFromCP(context) + } + + /** + * Get filePath creation date from URI using all possible content providers + */ + private fun getFileCreatedDateFromCP(context: Context): DateTimeWithSource? { + return try { + val cursor: Cursor? = context.contentResolver.query(contentUri, null, null, null, null) + cursor?.use { + val lastModifiedColumnIndex = cursor + .getColumnIndex( + "last_modified" + ).takeIf { it != -1 } + ?: cursor.getColumnIndex("datetaken") + if (lastModifiedColumnIndex == -1) return null // No valid column found + cursor.moveToFirst() + DateTimeWithSource( + cursor.getLong( + lastModifiedColumnIndex + ), DateTimeWithSource.CP_SOURCE) + } + } catch (e: Exception) { + Timber.tag("UploadableFile").d(e) + null + } + } + + /** + * Indicates whether the EXIF contains the location (both latitude and longitude). + */ + fun hasLocation(): Boolean { + return try { + val exif = ExifInterface(file.absolutePath) + val latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE) + val longitude = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE) + latitude != null && longitude != null + } catch (e: IOException) { + Timber.tag("UploadableFile").d(e) + false + } + } + + /** + * Get filePath creation date from URI using EXIF data + */ + private fun getDateTimeFromExif(): DateTimeWithSource? { + return try { + val exif = ExifInterface(file.absolutePath) + val dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + if (dateTimeSubString != null) { + val year = dateTimeSubString.substring(0, 4).toInt() + val month = dateTimeSubString.substring(5, 7).toInt() + val day = dateTimeSubString.substring(8, 10).toInt() + val dateCreatedString = "%04d-%02d-%02d".format(year, month, day) + if (dateCreatedString.length == 10) { + @SuppressLint("RestrictedApi") + val dateTime = exif.dateTimeOriginal + if (dateTime != null) { + val date = Date(dateTime) + return DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE) + } + } + } + null + } catch (e: Exception) { + Timber.tag("UploadableFile").d(e) + null + } + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(contentUri, flags) + parcel.writeSerializable(file) + } + + class DateTimeWithSource { + companion object { + const val CP_SOURCE = "contentProvider" + const val EXIF_SOURCE = "exif" + } + + val epochDate: Long + var dateString: String? = null + val source: String + + constructor(epochDate: Long, source: String) { + this.epochDate = epochDate + this.source = source + } + + constructor(date: Date, source: String) { + epochDate = date.time + this.source = source + } + + constructor(date: Date, dateString: String, source: String) { + epochDate = date.time + this.dateString = dateString + this.source = source + } + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): UploadableFile { + return UploadableFile(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt index 86ee5c4feb..91146059d4 100644 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt @@ -38,6 +38,7 @@ import fr.free.nrw.commons.campaigns.CampaignView import fr.free.nrw.commons.contributions.ContributionController import fr.free.nrw.commons.contributions.MainActivity import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.filepicker.FilePicker import fr.free.nrw.commons.kvstore.JsonKvStore import fr.free.nrw.commons.location.LocationServiceManager import fr.free.nrw.commons.logging.CommonsLogSender @@ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() { private val cameraPickLauncherForResult: ActivityResultLauncher = registerForActivityResult(StartActivityForResult()) { result -> - contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks -> - contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks) - } + contributionController.handleActivityResultWithCallback( + requireActivity(), + object: FilePicker.HandleActivityResult { + override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) { + contributionController.onPictureReturnedFromCamera( + result, + requireActivity(), + callbacks + ) + } + }) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt index fc80252fc9..62bd3f1a90 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt @@ -63,7 +63,7 @@ class CustomSelectorUtils { fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) val sha1 = fileUtilsWrapper.getSHA1( - fileUtilsWrapper.getFileInputStream(uploadableFile.filePath), + fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()), ) uploadableFile.file.delete() sha1 diff --git a/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.java b/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.java deleted file mode 100644 index 4da9e26906..0000000000 --- a/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.java +++ /dev/null @@ -1,32 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.database.Cursor; -import android.database.MatrixCursor; -import android.net.Uri; -import android.provider.OpenableColumns; -import androidx.core.content.FileProvider; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; - -@Implements(FileProvider.class) -public class ShadowFileProvider { - - @Implementation - public Cursor query(final Uri uri, final String[] projection, final String selection, - final String[] selectionArgs, - final String sortOrder) { - - if (uri == null) { - return null; - } - - final String[] columns = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; - final Object[] values = {"dummy", 500}; - final MatrixCursor cursor = new MatrixCursor(columns, 1); - - if (!uri.equals(Uri.EMPTY)) { - cursor.addRow(values); - } - return cursor; - } -} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.kt b/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.kt new file mode 100644 index 0000000000..fc9d20cf64 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.kt @@ -0,0 +1,36 @@ +package fr.free.nrw.commons.filepicker + +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.OpenableColumns +import androidx.core.content.FileProvider +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@Implements(FileProvider::class) +class ShadowFileProvider { + + @Implementation + fun query( + uri: Uri?, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? { + + if (uri == null) { + return null + } + + val columns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + val values = arrayOf("dummy", 500) + val cursor = MatrixCursor(columns, 1) + + if (uri != Uri.EMPTY) { + cursor.addRow(values) + } + return cursor + } +} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt index 29a35c1e55..861d1a6a49 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt @@ -62,7 +62,7 @@ class UploadPresenterTest { `when`(repository.buildContributions()).thenReturn(Observable.just(contribution)) uploadableFiles.add(uploadableFile) `when`(view.uploadableFiles).thenReturn(uploadableFiles) - `when`(uploadableFile.filePath).thenReturn("data://test") + `when`(uploadableFile.getFilePath()).thenReturn("data://test") } /** From ae52267a277976474eb2fa7aa2826f3873765800 Mon Sep 17 00:00:00 2001 From: Paul Hawke Date: Fri, 6 Dec 2024 02:50:29 -0600 Subject: [PATCH 4/5] Convert wikidata/mwapi to kotlin (part 2) (#5999) * Convert DepictSearchResponse to kotlin * Convert Entities to kotlin * Convert WikiSite to kotlin --------- Co-authored-by: Nicolas Raoul --- .../fr/free/nrw/commons/AboutActivityTest.kt | 2 +- .../free/nrw/commons/di/NetworkingModule.kt | 6 +- .../wikidata/model/DepictSearchResponse.java | 24 -- .../wikidata/model/DepictSearchResponse.kt | 12 + .../nrw/commons/wikidata/model/Entities.java | 106 ------- .../nrw/commons/wikidata/model/Entities.kt | 64 ++++ .../nrw/commons/wikidata/model/WikiSite.java | 292 ------------------ .../nrw/commons/wikidata/model/WikiSite.kt | 269 ++++++++++++++++ 8 files changed, 348 insertions(+), 427 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java create mode 100644 app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt diff --git a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt index 45ff9e49dd..50dfe8e7f0 100644 --- a/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt +++ b/app/src/androidTest/java/fr/free/nrw/commons/AboutActivityTest.kt @@ -105,7 +105,7 @@ class AboutActivityTest { fun testLaunchTranslate() { Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click()) - val langCode = CommonsApplication.instance.languageLookUpTable!!.codes[0] + val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0] Intents.intended( CoreMatchers.allOf( IntentMatchers.hasAction(Intent.ACTION_VIEW), 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 7ca3b4fd03..0e9d834784 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 @@ -44,7 +44,6 @@ import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor.Level import timber.log.Timber import java.io.File -import java.util.Locale import java.util.concurrent.TimeUnit import javax.inject.Named import javax.inject.Singleton @@ -293,9 +292,8 @@ class NetworkingModule { @Provides @Singleton @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) - fun provideLanguageWikipediaSite(): WikiSite { - return WikiSite.forLanguageCode(Locale.getDefault().language) - } + fun provideLanguageWikipediaSite(): WikiSite = + WikiSite.forDefaultLocaleLanguageCode() companion object { private const val WIKIDATA_SPARQL_QUERY_URL = "https://query.wikidata.org/sparql" diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java deleted file mode 100644 index 8ea2fa1eda..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package fr.free.nrw.commons.wikidata.model; - -import java.util.List; - -/** - * Model class for API response obtained from search for depictions - */ -public class DepictSearchResponse { - private final List search; - - /** - * Constructor to initialise value of the search object - */ - public DepictSearchResponse(List search) { - this.search = search; - } - - /** - * @return List for the DepictSearchResponse - */ - public List getSearch() { - return search; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt new file mode 100644 index 0000000000..5a0ed8c49f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/DepictSearchResponse.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.wikidata.model + +/** + * Model class for API response obtained from search for depictions + */ +class DepictSearchResponse( + /** + * @return List for the DepictSearchResponse + + */ + val search: List +) diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java deleted file mode 100644 index 9dab836cf8..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.java +++ /dev/null @@ -1,106 +0,0 @@ -package fr.free.nrw.commons.wikidata.model; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.gson.annotations.SerializedName; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import fr.free.nrw.commons.wikidata.mwapi.MwResponse; - - -public class Entities extends MwResponse { - @Nullable private Map entities; - private int success; - - @NotNull - public Map entities() { - return entities != null ? entities : Collections.emptyMap(); - } - - public int getSuccess() { - return success; - } - - @Nullable public Entity getFirst() { - if (entities == null) { - return null; - } - return entities.values().iterator().next(); - } - - @Override - public void postProcess() { - if (getFirst() != null && getFirst().isMissing()) { - throw new RuntimeException("The requested entity was not found."); - } - } - - public static class Entity { - @Nullable private String type; - @Nullable private String id; - @Nullable private Map labels; - @Nullable private Map descriptions; - @Nullable private Map sitelinks; - @Nullable @SerializedName(value = "statements", alternate = "claims") private Map> statements; - @Nullable private String missing; - - @NonNull public String id() { - return StringUtils.defaultString(id); - } - - @NonNull public Map labels() { - return labels != null ? labels : Collections.emptyMap(); - } - - @NonNull public Map descriptions() { - return descriptions != null ? descriptions : Collections.emptyMap(); - } - - @NonNull public Map sitelinks() { - return sitelinks != null ? sitelinks : Collections.emptyMap(); - } - - @Nullable - public Map> getStatements() { - return statements; - } - - boolean isMissing() { - return "-1".equals(id) && missing != null; - } - } - - public static class Label { - @Nullable private String language; - @Nullable private String value; - - public Label(@Nullable final String language, @Nullable final String value) { - this.language = language; - this.value = value; - } - - @NonNull public String language() { - return StringUtils.defaultString(language); - } - - @NonNull public String value() { - return StringUtils.defaultString(value); - } - } - - public static class SiteLink { - @Nullable private String site; - @Nullable private String title; - - @NonNull public String getSite() { - return StringUtils.defaultString(site); - } - - @NonNull public String getTitle() { - return StringUtils.defaultString(title); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt new file mode 100644 index 0000000000..588dbd262b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/Entities.kt @@ -0,0 +1,64 @@ +package fr.free.nrw.commons.wikidata.model + +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.wikidata.mwapi.MwResponse +import org.apache.commons.lang3.StringUtils + +class Entities : MwResponse() { + private val entities: Map? = null + val success: Int = 0 + + fun entities(): Map = entities ?: emptyMap() + + private val first : Entity? + get() = entities?.values?.iterator()?.next() + + override fun postProcess() { + first?.let { + if (it.isMissing()) throw RuntimeException("The requested entity was not found.") + } + } + + class Entity { + private val type: String? = null + private val id: String? = null + private val labels: Map? = null + private val descriptions: Map? = null + private val sitelinks: Map? = null + + @SerializedName(value = "statements", alternate = ["claims"]) + val statements: Map>? = null + private val missing: String? = null + + fun id(): String = + StringUtils.defaultString(id) + + fun labels(): Map = + labels ?: emptyMap() + + fun descriptions(): Map = + descriptions ?: emptyMap() + + fun sitelinks(): Map = + sitelinks ?: emptyMap() + + fun isMissing(): Boolean = + "-1" == id && missing != null + } + + class Label(private val language: String?, private val value: String?) { + fun language(): String = + StringUtils.defaultString(language) + + fun value(): String = + StringUtils.defaultString(value) + } + + class SiteLink { + val site: String? = null + get() = StringUtils.defaultString(field) + + private val title: String? = null + get() = StringUtils.defaultString(field) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java b/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java deleted file mode 100644 index 204ea0ab46..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.java +++ /dev/null @@ -1,292 +0,0 @@ -package fr.free.nrw.commons.wikidata.model; - -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextUtils; - -import androidx.annotation.NonNull; - -import com.google.gson.annotations.SerializedName; - -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.language.AppLanguageLookUpTable; - -/** - * The base URL and Wikipedia language code for a MediaWiki site. Examples: - * - *
    - * Name: scheme / authority / language code - *
  • English Wikipedia: HTTPS / en.wikipedia.org / en
  • - *
  • Chinese Wikipedia: HTTPS / zh.wikipedia.org / zh-hans or zh-hant
  • - *
  • Meta-Wiki: HTTPS / meta.wikimedia.org / (none)
  • - *
  • Test Wikipedia: HTTPS / test.wikipedia.org / test
  • - *
  • Võro Wikipedia: HTTPS / fiu-vro.wikipedia.org / fiu-vro
  • - *
  • Simple English Wikipedia: HTTPS / simple.wikipedia.org / simple
  • - *
  • Simple English Wikipedia (beta cluster mirror): HTTP / simple.wikipedia.beta.wmflabs.org / simple
  • - *
  • Development: HTTP / 192.168.1.11:8080 / (none)
  • - *
- * - * As shown above, the language code or mapping is part of the authority: - *
    - * Validity: authority / language code - *
  • Correct: "test.wikipedia.org" / "test"
  • - *
  • Correct: "wikipedia.org", ""
  • - *
  • Correct: "no.wikipedia.org", "nb"
  • - *
  • Incorrect: "wikipedia.org", "test"
  • - *
- */ -public class WikiSite implements Parcelable { - private static String WIKIPEDIA_URL = "https://wikipedia.org/"; - - public static final String DEFAULT_SCHEME = "https"; - private static String DEFAULT_BASE_URL = WIKIPEDIA_URL; - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - @Override - public WikiSite createFromParcel(Parcel in) { - return new WikiSite(in); - } - - @Override - public WikiSite[] newArray(int size) { - return new WikiSite[size]; - } - }; - - // todo: remove @SerializedName. this is now in the TypeAdapter and a "uri" case may be added - @SerializedName("domain") @NonNull private final Uri uri; - @NonNull private String languageCode; - - public static WikiSite forLanguageCode(@NonNull String languageCode) { - Uri uri = ensureScheme(Uri.parse(DEFAULT_BASE_URL)); - return new WikiSite((languageCode.isEmpty() - ? "" : (languageCodeToSubdomain(languageCode) + ".")) + uri.getAuthority(), - languageCode); - } - - public WikiSite(@NonNull Uri uri) { - Uri tempUri = ensureScheme(uri); - String authority = tempUri.getAuthority(); - if (("wikipedia.org".equals(authority) || "www.wikipedia.org".equals(authority)) - && tempUri.getPath() != null && tempUri.getPath().startsWith("/wiki")) { - // Special case for Wikipedia only: assume English subdomain when none given. - authority = "en.wikipedia.org"; - } - String langVariant = getLanguageVariantFromUri(tempUri); - if (!TextUtils.isEmpty(langVariant)) { - languageCode = langVariant; - } else { - languageCode = authorityToLanguageCode(authority); - } - this.uri = new Uri.Builder() - .scheme(tempUri.getScheme()) - .encodedAuthority(authority) - .build(); - } - - /** Get language variant code from a Uri, e.g. "zh-*", otherwise returns empty string. */ - @NonNull - private String getLanguageVariantFromUri(@NonNull Uri uri) { - if (TextUtils.isEmpty(uri.getPath())) { - return ""; - } - String[] parts = StringUtils.split(StringUtils.defaultString(uri.getPath()), '/'); - return parts.length > 1 && !parts[0].equals("wiki") ? parts[0] : ""; - } - - public WikiSite(@NonNull String url) { - this(url.startsWith("http") ? Uri.parse(url) : url.startsWith("//") - ? Uri.parse(DEFAULT_SCHEME + ":" + url) : Uri.parse(DEFAULT_SCHEME + "://" + url)); - } - - public WikiSite(@NonNull String authority, @NonNull String languageCode) { - this(authority); - this.languageCode = languageCode; - } - - @NonNull - public String scheme() { - return TextUtils.isEmpty(uri.getScheme()) ? DEFAULT_SCHEME : uri.getScheme(); - } - - /** - * @return The complete wiki authority including language subdomain but not including scheme, - * authentication, port, nor trailing slash. - * - * @see URL syntax - */ - @NonNull - public String authority() { - return uri.getAuthority(); - } - - /** - * Like {@link #authority()} but with a "m." between the language subdomain and the rest of the host. - * Examples: - * - *
    - *
  • English Wikipedia: en.m.wikipedia.org
  • - *
  • Chinese Wikipedia: zh.m.wikipedia.org
  • - *
  • Meta-Wiki: meta.m.wikimedia.org
  • - *
  • Test Wikipedia: test.m.wikipedia.org
  • - *
  • Võro Wikipedia: fiu-vro.m.wikipedia.org
  • - *
  • Simple English Wikipedia: simple.m.wikipedia.org
  • - *
  • Simple English Wikipedia (beta cluster mirror): simple.m.wikipedia.beta.wmflabs.org
  • - *
  • Development: m.192.168.1.11
  • - *
- */ - @NonNull - public String mobileAuthority() { - return authorityToMobile(authority()); - } - - /** - * Get wiki's mobile URL - * Eg. https://en.m.wikipedia.org - * @return - */ - public String mobileUrl() { - return String.format("%1$s://%2$s", scheme(), mobileAuthority()); - } - - @NonNull - public String subdomain() { - return languageCodeToSubdomain(languageCode); - } - - /** - * @return A path without an authority for the segment including a leading "/". - */ - @NonNull - public String path(@NonNull String segment) { - return "/w/" + segment; - } - - - @NonNull public Uri uri() { - return uri; - } - - /** - * @return The canonical URL. e.g., https://en.wikipedia.org. - */ - @NonNull public String url() { - return uri.toString(); - } - - /** - * @return The canonical URL for segment. e.g., https://en.wikipedia.org/w/foo. - */ - @NonNull public String url(@NonNull String segment) { - return url() + path(segment); - } - - /** - * @return The wiki language code which may differ from the language subdomain. Empty if - * language code is unknown. Ex: "en", "zh-hans", "" - * - * @see AppLanguageLookUpTable - */ - @NonNull - public String languageCode() { - return languageCode; - } - - // Auto-generated - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - - WikiSite wiki = (WikiSite) o; - - if (!uri.equals(wiki.uri)) { - return false; - } - return languageCode.equals(wiki.languageCode); - } - - // Auto-generated - @Override - public int hashCode() { - int result = uri.hashCode(); - result = 31 * result + languageCode.hashCode(); - return result; - } - - // Auto-generated - @Override - public String toString() { - return "WikiSite{" - + "uri=" + uri - + ", languageCode='" + languageCode + '\'' - + '}'; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(@NonNull Parcel dest, int flags) { - dest.writeParcelable(uri, 0); - dest.writeString(languageCode); - } - - protected WikiSite(@NonNull Parcel in) { - this.uri = in.readParcelable(Uri.class.getClassLoader()); - this.languageCode = in.readString(); - } - - @NonNull - private static String languageCodeToSubdomain(@NonNull String languageCode) { - switch (languageCode) { - case AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE: - case AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_CN_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_HK_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_MO_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_SG_LANGUAGE_CODE: - case AppLanguageLookUpTable.CHINESE_TW_LANGUAGE_CODE: - return AppLanguageLookUpTable.CHINESE_LANGUAGE_CODE; - case AppLanguageLookUpTable.NORWEGIAN_BOKMAL_LANGUAGE_CODE: - return AppLanguageLookUpTable.NORWEGIAN_LEGACY_LANGUAGE_CODE; // T114042 - default: - return languageCode; - } - } - - @NonNull private static String authorityToLanguageCode(@NonNull String authority) { - String[] parts = authority.split("\\."); - final int minLengthForSubdomain = 3; - if (parts.length < minLengthForSubdomain - || parts.length == minLengthForSubdomain && parts[0].equals("m")) { - // "" - // wikipedia.org - // m.wikipedia.org - return ""; - } - return parts[0]; - } - - @NonNull private static Uri ensureScheme(@NonNull Uri uri) { - if (TextUtils.isEmpty(uri.getScheme())) { - return uri.buildUpon().scheme(DEFAULT_SCHEME).build(); - } - return uri; - } - - /** @param authority Host and optional port. */ - @NonNull private String authorityToMobile(@NonNull String authority) { - if (authority.startsWith("m.") || authority.contains(".m.")) { - return authority; - } - return authority.replaceFirst("^" + subdomain() + "\\.?", "$0m."); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt b/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt new file mode 100644 index 0000000000..1cd0bb8585 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/wikidata/model/WikiSite.kt @@ -0,0 +1,269 @@ +package fr.free.nrw.commons.wikidata.model + +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import android.text.TextUtils +import com.google.gson.annotations.SerializedName +import fr.free.nrw.commons.language.AppLanguageLookUpTable +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_CN_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_HK_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_MO_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_SG_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.CHINESE_TW_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.NORWEGIAN_BOKMAL_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.NORWEGIAN_LEGACY_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.SIMPLIFIED_CHINESE_LANGUAGE_CODE +import fr.free.nrw.commons.language.AppLanguageLookUpTable.Companion.TRADITIONAL_CHINESE_LANGUAGE_CODE +import org.apache.commons.lang3.StringUtils +import java.util.Locale + +/** + * The base URL and Wikipedia language code for a MediaWiki site. Examples: + * + * + * Name: scheme / authority / language code + * * English Wikipedia: HTTPS / en.wikipedia.org / en + * * Chinese Wikipedia: HTTPS / zh.wikipedia.org / zh-hans or zh-hant + * * Meta-Wiki: HTTPS / meta.wikimedia.org / (none) + * * Test Wikipedia: HTTPS / test.wikipedia.org / test + * * Võro Wikipedia: HTTPS / fiu-vro.wikipedia.org / fiu-vro + * * Simple English Wikipedia: HTTPS / simple.wikipedia.org / simple + * * Simple English Wikipedia (beta cluster mirror): HTTP / simple.wikipedia.beta.wmflabs.org / simple + * * Development: HTTP / 192.168.1.11:8080 / (none) + * + * + * **As shown above, the language code or mapping is part of the authority:** + * + * Validity: authority / language code + * * Correct: "test.wikipedia.org" / "test" + * * Correct: "wikipedia.org", "" + * * Correct: "no.wikipedia.org", "nb" + * * Incorrect: "wikipedia.org", "test" + * + */ +class WikiSite : Parcelable { + //TODO: remove @SerializedName. this is now in the TypeAdapter and a "uri" case may be added + @SerializedName("domain") + private val uri: Uri + + private var languageCode: String? = null + + constructor(uri: Uri) { + val tempUri = ensureScheme(uri) + var authority = tempUri.authority + + if (authority.isWikipedia && tempUri.path?.startsWith("/wiki") == true) { + // Special case for Wikipedia only: assume English subdomain when none given. + authority = "en.wikipedia.org" + } + + val langVariant = getLanguageVariantFromUri(tempUri) + languageCode = if (!TextUtils.isEmpty(langVariant)) { + langVariant + } else { + authorityToLanguageCode(authority!!) + } + + this.uri = Uri.Builder() + .scheme(tempUri.scheme) + .encodedAuthority(authority) + .build() + } + + private val String?.isWikipedia: Boolean get() = + (this == "wikipedia.org" || this == "www.wikipedia.org") + + /** Get language variant code from a Uri, e.g. "zh-*", otherwise returns empty string. */ + private fun getLanguageVariantFromUri(uri: Uri): String { + if (TextUtils.isEmpty(uri.path)) { + return "" + } + val parts = StringUtils.split(StringUtils.defaultString(uri.path), '/') + return if (parts.size > 1 && parts[0] != "wiki") parts[0] else "" + } + + constructor(url: String) : this( + if (url.startsWith("http")) Uri.parse(url) else if (url.startsWith("//")) + Uri.parse("$DEFAULT_SCHEME:$url") + else + Uri.parse("$DEFAULT_SCHEME://$url") + ) + + constructor(authority: String, languageCode: String) : this(authority) { + this.languageCode = languageCode + } + + fun scheme(): String = + if (TextUtils.isEmpty(uri.scheme)) DEFAULT_SCHEME else uri.scheme!! + + /** + * @return The complete wiki authority including language subdomain but not including scheme, + * authentication, port, nor trailing slash. + * + * @see [URL syntax](https://en.wikipedia.org/wiki/Uniform_Resource_Locator.Syntax) + */ + fun authority(): String = uri.authority!! + + /** + * Like [.authority] but with a "m." between the language subdomain and the rest of the host. + * Examples: + * + * + * * English Wikipedia: en.m.wikipedia.org + * * Chinese Wikipedia: zh.m.wikipedia.org + * * Meta-Wiki: meta.m.wikimedia.org + * * Test Wikipedia: test.m.wikipedia.org + * * Võro Wikipedia: fiu-vro.m.wikipedia.org + * * Simple English Wikipedia: simple.m.wikipedia.org + * * Simple English Wikipedia (beta cluster mirror): simple.m.wikipedia.beta.wmflabs.org + * * Development: m.192.168.1.11 + * + */ + fun mobileAuthority(): String = authorityToMobile(authority()) + + /** + * Get wiki's mobile URL + * Eg. https://en.m.wikipedia.org + * @return + */ + fun mobileUrl(): String = String.format("%1\$s://%2\$s", scheme(), mobileAuthority()) + + fun subdomain(): String = languageCodeToSubdomain(languageCode!!) + + /** + * @return A path without an authority for the segment including a leading "/". + */ + fun path(segment: String): String = "/w/$segment" + + + fun uri(): Uri = uri + + /** + * @return The canonical URL. e.g., https://en.wikipedia.org. + */ + fun url(): String = uri.toString() + + /** + * @return The canonical URL for segment. e.g., https://en.wikipedia.org/w/foo. + */ + fun url(segment: String): String = url() + path(segment) + + /** + * @return The wiki language code which may differ from the language subdomain. Empty if + * language code is unknown. Ex: "en", "zh-hans", "" + * + * @see AppLanguageLookUpTable + */ + fun languageCode(): String = languageCode!! + + // Auto-generated + override fun equals(o: Any?): Boolean { + if (this === o) { + return true + } + if (o == null || javaClass != o.javaClass) { + return false + } + + val wiki = o as WikiSite + + if (uri != wiki.uri) { + return false + } + return languageCode == wiki.languageCode + } + + // Auto-generated + override fun hashCode(): Int { + var result = uri.hashCode() + result = 31 * result + languageCode.hashCode() + return result + } + + // Auto-generated + override fun toString(): String { + return ("WikiSite{" + + "uri=" + uri + + ", languageCode='" + languageCode + '\'' + + '}') + } + + override fun describeContents(): Int = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(uri, 0) + dest.writeString(languageCode) + } + + protected constructor(`in`: Parcel) { + uri = `in`.readParcelable(Uri::class.java.classLoader)!! + languageCode = `in`.readString() + } + + /** @param authority Host and optional port. + */ + private fun authorityToMobile(authority: String): String { + if (authority.startsWith("m.") || authority.contains(".m.")) { + return authority + } + return authority.replaceFirst(("^" + subdomain() + "\\.?").toRegex(), "$0m.") + } + + companion object { + const val WIKIPEDIA_URL = "https://wikipedia.org/" + const val DEFAULT_SCHEME: String = "https" + + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): WikiSite { + return WikiSite(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + fun forDefaultLocaleLanguageCode(): WikiSite { + val languageCode: String = Locale.getDefault().language + val subdomain = if (languageCode.isEmpty()) "" else languageCodeToSubdomain(languageCode) + "." + val uri = ensureScheme(Uri.parse(WIKIPEDIA_URL)) + return WikiSite(subdomain + uri.authority, languageCode) + } + + private fun languageCodeToSubdomain(languageCode: String): String = when (languageCode) { + SIMPLIFIED_CHINESE_LANGUAGE_CODE, + TRADITIONAL_CHINESE_LANGUAGE_CODE, + CHINESE_CN_LANGUAGE_CODE, + CHINESE_HK_LANGUAGE_CODE, + CHINESE_MO_LANGUAGE_CODE, + CHINESE_SG_LANGUAGE_CODE, + CHINESE_TW_LANGUAGE_CODE -> CHINESE_LANGUAGE_CODE + + NORWEGIAN_BOKMAL_LANGUAGE_CODE -> NORWEGIAN_LEGACY_LANGUAGE_CODE // T114042 + + else -> languageCode + } + + private fun authorityToLanguageCode(authority: String): String { + val parts = authority.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val minLengthForSubdomain = 3 + if (parts.size < minLengthForSubdomain || parts.size == minLengthForSubdomain && parts[0] == "m") { + // "" + // wikipedia.org + // m.wikipedia.org + return "" + } + return parts[0] + } + + private fun ensureScheme(uri: Uri): Uri { + if (TextUtils.isEmpty(uri.scheme)) { + return uri.buildUpon().scheme(DEFAULT_SCHEME).build() + } + return uri + } + } +} From a8387f01c9e7d54b4f6cd7b7da342c64f38de352 Mon Sep 17 00:00:00 2001 From: Neel Doshi <60827173+neeldoshii@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:45:47 +0530 Subject: [PATCH 5/5] Bug Fixs & Enhancement of Achievement Screen (#5666) * Rename AchievementFragment from `.java` to `.kt` * Migrated AchievementFragment to kotlin * Revamped Achievement Screen * fixed AchievementFragment Unit Test * fixed Level on MoreBottomSheetFragment * Implemented Badge and Minor Code Refactor * Fixed the badge issue & made the badge clickable * Removed Redundant XML Code & Converted badges to green color and added values inside it * Fixed : showSnackBarWithRetry Test * Fixed : Theme issues on Light Mode --------- Co-authored-by: Nicolas Raoul --- app/build.gradle | 2 +- .../commons/navtab/MoreBottomSheetFragment.kt | 14 +- .../nrw/commons/profile/ProfileActivity.java | 1 - .../achievements/AchievementsFragment.java | 492 --------- .../achievements/AchievementsFragment.kt | 566 ++++++++++ .../main/res/layout/fragment_achievements.xml | 990 +++++++----------- app/src/main/res/values/strings.xml | 4 +- app/src/main/res/values/styles.xml | 5 +- .../AchievementsFragmentUnitTests.kt | 2 +- 9 files changed, 944 insertions(+), 1132 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt diff --git a/app/build.gradle b/app/build.gradle index 468255d38c..b83f2b01ba 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,7 +47,7 @@ dependencies { implementation 'com.jakewharton.timber:timber:4.7.1' implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0' - implementation "com.google.android.material:material:1.9.0" + implementation "com.google.android.material:material:1.12.0" implementation 'com.karumi:dexter:5.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt index 857e18ec31..cbdf5f0875 100644 --- a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt @@ -111,10 +111,18 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() { private fun setUserName() { val store = BasicKvStore(requireContext(), getUserName()) val level = store.getString("userAchievementsLevel", "0") - binding?.moreProfile?.text = if (level == "0") { - "${getUserName()} (${getString(R.string.see_your_achievements)})" + if (level == "0"){ + binding?.moreProfile?.text = getString( + R.string.profileLevel, + getUserName(), + getString(R.string.see_your_achievements) // Second argument + ) } else { - "${getUserName()} (${getString(R.string.level)} $level)" + binding?.moreProfile?.text = getString( + R.string.profileLevel, + getUserName(), + level + ) } } diff --git a/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java b/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java index 60a0f47a1a..390768416f 100644 --- a/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/profile/ProfileActivity.java @@ -16,7 +16,6 @@ import androidx.core.content.FileProvider; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; -import com.google.android.material.tabs.TabLayout; import fr.free.nrw.commons.R; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.ViewPagerAdapter; diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java deleted file mode 100644 index ef6a323b2e..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.java +++ /dev/null @@ -1,492 +0,0 @@ -package fr.free.nrw.commons.profile.achievements; - -import android.accounts.Account; -import android.content.Context; -import android.net.Uri; -import android.os.Bundle; -import android.util.DisplayMetrics; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.databinding.FragmentAchievementsBinding; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.kvstore.BasicKvStore; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.profile.ProfileActivity; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import java.util.Locale; -import java.util.Objects; -import javax.inject.Inject; -import org.apache.commons.lang3.StringUtils; -import timber.log.Timber; - -/** - * fragment for sharing feedback on uploaded activity - */ -public class AchievementsFragment extends CommonsDaggerSupportFragment { - - private static final double BADGE_IMAGE_WIDTH_RATIO = 0.4; - private static final double BADGE_IMAGE_HEIGHT_RATIO = 0.3; - - /** - * Help link URLs - */ - private static final String IMAGES_UPLOADED_URL = "https://commons.wikimedia.org/wiki/Commons:Project_scope"; - private static final String IMAGES_REVERT_URL = "https://commons.wikimedia.org/wiki/Commons:Deletion_policy#Reasons_for_deletion"; - private static final String IMAGES_USED_URL = "https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style/Images"; - private static final String IMAGES_NEARBY_PLACES_URL = "https://www.wikidata.org/wiki/Property:P18"; - private static final String IMAGES_FEATURED_URL = "https://commons.wikimedia.org/wiki/Commons:Featured_pictures"; - private static final String QUALITY_IMAGE_URL = "https://commons.wikimedia.org/wiki/Commons:Quality_images"; - private static final String THANKS_URL = "https://www.mediawiki.org/wiki/Extension:Thanks"; - - private LevelController.LevelInfo levelInfo; - - @Inject - SessionManager sessionManager; - - @Inject - OkHttpJsonApiClient okHttpJsonApiClient; - - private FragmentAchievementsBinding binding; - - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - - // To keep track of the number of wiki edits made by a user - private int numberOfEdits = 0; - - private String userName; - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (getArguments() != null) { - userName = getArguments().getString(ProfileActivity.KEY_USERNAME); - } - } - - /** - * This method helps in the creation Achievement screen and - * dynamically set the size of imageView - * - * @param savedInstanceState Data bundle - */ - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - binding = FragmentAchievementsBinding.inflate(inflater, container, false); - View rootView = binding.getRoot(); - - binding.achievementInfo.setOnClickListener(view -> showInfoDialog()); - binding.imagesUploadInfo.setOnClickListener(view -> showUploadInfo()); - binding.imagesRevertedInfo.setOnClickListener(view -> showRevertedInfo()); - binding.imagesUsedByWikiInfo.setOnClickListener(view -> showUsedByWikiInfo()); - binding.imagesNearbyInfo.setOnClickListener(view -> showImagesViaNearbyInfo()); - binding.imagesFeaturedInfo.setOnClickListener(view -> showFeaturedImagesInfo()); - binding.thanksReceivedInfo.setOnClickListener(view -> showThanksReceivedInfo()); - binding.qualityImagesInfo.setOnClickListener(view -> showQualityImagesInfo()); - - // DisplayMetrics used to fetch the size of the screen - DisplayMetrics displayMetrics = new DisplayMetrics(); - getActivity().getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - int height = displayMetrics.heightPixels; - int width = displayMetrics.widthPixels; - - // Used for the setting the size of imageView at runtime - ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) - binding.achievementBadgeImage.getLayoutParams(); - params.height = (int) (height * BADGE_IMAGE_HEIGHT_RATIO); - params.width = (int) (width * BADGE_IMAGE_WIDTH_RATIO); - binding.achievementBadgeImage.requestLayout(); - binding.progressBar.setVisibility(View.VISIBLE); - - setHasOptionsMenu(true); - - // Set the initial value of WikiData edits to 0 - binding.wikidataEdits.setText("0"); - if(sessionManager.getUserName() == null || sessionManager.getUserName().equals(userName)){ - binding.tvAchievementsOfUser.setVisibility(View.GONE); - }else{ - binding.tvAchievementsOfUser.setVisibility(View.VISIBLE); - binding.tvAchievementsOfUser.setText(getString(R.string.achievements_of_user,userName)); - } - - // Achievements currently unimplemented in Beta flavor. Skip all API calls. - if(ConfigUtils.isBetaFlavour()) { - binding.progressBar.setVisibility(View.GONE); - binding.imagesUsedByWikiText.setText(R.string.no_image); - binding.imagesRevertedText.setText(R.string.no_image_reverted); - binding.imagesUploadTextParam.setText(R.string.no_image_uploaded); - binding.wikidataEdits.setText("0"); - binding.imageFeatured.setText("0"); - binding.qualityImages.setText("0"); - binding.achievementLevel.setText("0"); - setMenuVisibility(true); - return rootView; - } - setWikidataEditCount(); - setAchievements(); - return rootView; - } - - @Override - public void onDestroyView() { - binding = null; - super.onDestroyView(); - } - - @Override - public void setMenuVisibility(boolean visible) { - super.setMenuVisibility(visible); - - // Whenever this fragment is revealed in a menu, - // notify Beta users the page data is unavailable - if(ConfigUtils.isBetaFlavour() && visible) { - Context ctx = null; - if(getContext() != null) { - ctx = getContext(); - } else if(getView() != null && getView().getContext() != null) { - ctx = getView().getContext(); - } - if(ctx != null) { - Toast.makeText(ctx, - R.string.achievements_unavailable_beta, - Toast.LENGTH_LONG).show(); - } - } - } - - /** - * To invoke the AlertDialog on clicking info button - */ - protected void showInfoDialog(){ - launchAlert( - getResources().getString(R.string.Achievements), - getResources().getString(R.string.achievements_info_message)); - } - - /** - * To call the API to get results in form Single - * which then calls parseJson when results are fetched - */ - private void setAchievements() { - binding.progressBar.setVisibility(View.VISIBLE); - if (checkAccount()) { - try{ - - compositeDisposable.add(okHttpJsonApiClient - .getAchievements(Objects.requireNonNull(userName)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - response -> { - if (response != null) { - setUploadCount(Achievements.from(response)); - } else { - Timber.d("success"); - binding.layoutImageReverts.setVisibility(View.INVISIBLE); - binding.achievementBadgeImage.setVisibility(View.INVISIBLE); - // If the number of edits made by the user are more than 150,000 - // in some cases such high number of wiki edit counts cause the - // achievements calculator to fail in some cases, for more details - // refer Issue: #3295 - if (numberOfEdits <= 150000) { - showSnackBarWithRetry(false); - } else { - showSnackBarWithRetry(true); - } - } - }, - t -> { - Timber.e(t, "Fetching achievements statistics failed"); - if (numberOfEdits <= 150000) { - showSnackBarWithRetry(false); - } else { - showSnackBarWithRetry(true); - } - } - )); - } - catch (Exception e){ - Timber.d(e+"success"); - } - } - } - - /** - * To call the API to fetch the count of wiki data edits - * in the form of JavaRx Single object - */ - private void setWikidataEditCount() { - if (StringUtils.isBlank(userName)) { - return; - } - compositeDisposable.add(okHttpJsonApiClient - .getWikidataEdits(userName) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(edits -> { - numberOfEdits = edits; - binding.wikidataEdits.setText(String.valueOf(edits)); - }, e -> { - Timber.e("Error:" + e); - })); - } - - /** - * Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the - * listener passed - * @param tooManyAchievements if this value is true it means that the number of achievements of the - * user are so high that it wrecks havoc with the Achievements calculator due to which request may time - * out. Well this is the Ultimate Achievement - */ - private void showSnackBarWithRetry(boolean tooManyAchievements) { - if (tooManyAchievements) { - binding.progressBar.setVisibility(View.GONE); - ViewUtil.showDismissibleSnackBar(getActivity().findViewById(android.R.id.content), - R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry, view -> setAchievements()); - } else { - binding.progressBar.setVisibility(View.GONE); - ViewUtil.showDismissibleSnackBar(getActivity().findViewById(android.R.id.content), - R.string.achievements_fetch_failed, R.string.retry, view -> setAchievements()); - } - } - - /** - * Shows a generic error toast when error occurs while loading achievements or uploads - */ - private void onError() { - ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.error_occurred)); - binding.progressBar.setVisibility(View.GONE); - } - - /** - * used to the count of images uploaded by user - */ - private void setUploadCount(Achievements achievements) { - if (checkAccount()) { - compositeDisposable.add(okHttpJsonApiClient - .getUploadCount(Objects.requireNonNull(userName)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - uploadCount -> setAchievementsUploadCount(achievements, uploadCount), - t -> { - Timber.e(t, "Fetching upload count failed"); - onError(); - } - )); - } - } - - /** - * used to set achievements upload count and call hideProgressbar - * @param uploadCount - */ - private void setAchievementsUploadCount(Achievements achievements, int uploadCount) { - // Create a new instance of Achievements with updated imagesUploaded - Achievements updatedAchievements = new Achievements( - achievements.getUniqueUsedImages(), - achievements.getArticlesUsingImages(), - achievements.getThanksReceived(), - achievements.getFeaturedImages(), - achievements.getQualityImages(), - uploadCount, // Update imagesUploaded with new value - achievements.getRevertCount() - ); - - hideProgressBar(updatedAchievements); - } - - /** - * used to the uploaded images progressbar - * @param uploadCount - */ - private void setUploadProgress(int uploadCount){ - if (uploadCount==0){ - setZeroAchievements(); - }else { - binding.imagesUploadedProgressbar.setVisibility(View.VISIBLE); - binding.imagesUploadedProgressbar.setProgress - (100*uploadCount/levelInfo.getMaxUploadCount()); - binding.tvUploadedImages.setText - (uploadCount + "/" + levelInfo.getMaxUploadCount()); - } - - } - - private void setZeroAchievements() { - String message = !Objects.equals(sessionManager.getUserName(), userName) ? - getString(R.string.no_achievements_yet, userName) : - getString(R.string.you_have_no_achievements_yet); - DialogUtil.showAlertDialog(getActivity(), - null, - message, - getString(R.string.ok), - () -> {}, - true); -// binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE); -// binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE); -// binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE); - binding.achievementBadgeImage.setVisibility(View.INVISIBLE); - binding.imagesUsedByWikiText.setText(R.string.no_image); - binding.imagesRevertedText.setText(R.string.no_image_reverted); - binding.imagesUploadTextParam.setText(R.string.no_image_uploaded); - binding.achievementBadgeImage.setVisibility(View.INVISIBLE); - } - - /** - * used to set the non revert image percentage - * @param notRevertPercentage - */ - private void setImageRevertPercentage(int notRevertPercentage){ - binding.imageRevertsProgressbar.setVisibility(View.VISIBLE); - binding.imageRevertsProgressbar.setProgress(notRevertPercentage); - final String revertPercentage = Integer.toString(notRevertPercentage); - binding.tvRevertedImages.setText(revertPercentage + "%"); - binding.imagesRevertLimitText.setText(getResources().getString(R.string.achievements_revert_limit_message)+ levelInfo.getMinNonRevertPercentage() + "%"); - } - - /** - * Used the inflate the fetched statistics of the images uploaded by user - * and assign badge and level. Also stores the achievements level of the user in BasicKvStore to display in menu - * @param achievements - */ - private void inflateAchievements(Achievements achievements) { -// binding.imagesUsedByWikiProgressBar.setVisibility(View.VISIBLE); - binding.thanksReceived.setText(String.valueOf(achievements.getThanksReceived())); - binding.imagesUsedByWikiProgressBar.setProgress - (100 * achievements.getUniqueUsedImages() / levelInfo.getMaxUniqueImages()); - binding.tvWikiPb.setText(achievements.getUniqueUsedImages() + "/" - + levelInfo.getMaxUniqueImages()); - binding.imageFeatured.setText(String.valueOf(achievements.getFeaturedImages())); - binding.qualityImages.setText(String.valueOf(achievements.getQualityImages())); - String levelUpInfoString = getString(R.string.level).toUpperCase(Locale.ROOT); - levelUpInfoString += " " + levelInfo.getLevelNumber(); - binding.achievementLevel.setText(levelUpInfoString); - binding.achievementBadgeImage.setImageDrawable(VectorDrawableCompat.create(getResources(), R.drawable.badge, - new ContextThemeWrapper(getActivity(), levelInfo.getLevelStyle()).getTheme())); - binding.achievementBadgeText.setText(Integer.toString(levelInfo.getLevelNumber())); - BasicKvStore store = new BasicKvStore(this.getContext(), userName); - store.putString("userAchievementsLevel", Integer.toString(levelInfo.getLevelNumber())); - } - - /** - * to hide progressbar - */ - private void hideProgressBar(Achievements achievements) { - if (binding.progressBar != null) { - levelInfo = LevelController.LevelInfo.from(achievements.getImagesUploaded(), - achievements.getUniqueUsedImages(), - achievements.getNotRevertPercentage()); - inflateAchievements(achievements); - setUploadProgress(achievements.getImagesUploaded()); - setImageRevertPercentage(achievements.getNotRevertPercentage()); - binding.progressBar.setVisibility(View.GONE); - } - } - - protected void showUploadInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.images_uploaded), - getResources().getString(R.string.images_uploaded_explanation), - IMAGES_UPLOADED_URL); - } - - protected void showRevertedInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.image_reverts), - getResources().getString(R.string.images_reverted_explanation), - IMAGES_REVERT_URL); - } - - protected void showUsedByWikiInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.images_used_by_wiki), - getResources().getString(R.string.images_used_explanation), - IMAGES_USED_URL); - } - - protected void showImagesViaNearbyInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.statistics_wikidata_edits), - getResources().getString(R.string.images_via_nearby_explanation), - IMAGES_NEARBY_PLACES_URL); - } - - protected void showFeaturedImagesInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.statistics_featured), - getResources().getString(R.string.images_featured_explanation), - IMAGES_FEATURED_URL); - } - - protected void showThanksReceivedInfo(){ - launchAlertWithHelpLink( - getResources().getString(R.string.statistics_thanks), - getResources().getString(R.string.thanks_received_explanation), - THANKS_URL); - } - - public void showQualityImagesInfo() { - launchAlertWithHelpLink( - getResources().getString(R.string.statistics_quality), - getResources().getString(R.string.quality_images_info), - QUALITY_IMAGE_URL); - } - - /** - * takes title and message as input to display alerts - * @param title - * @param message - */ - private void launchAlert(String title, String message){ - DialogUtil.showAlertDialog(getActivity(), - title, - message, - getString(R.string.ok), - () -> {}, - true); - } - - /** - * Launch Alert with a READ MORE button and clicking it open a custom webpage - */ - private void launchAlertWithHelpLink(String title, String message, String helpLinkUrl) { - DialogUtil.showAlertDialog(getActivity(), - title, - message, - getString(R.string.ok), - getString(R.string.read_help_link), - () -> {}, - () -> Utils.handleWebUrl(requireContext(), Uri.parse(helpLinkUrl)), - null, - true); - } - - /** - * check to ensure that user is logged in - * @return - */ - private boolean checkAccount(){ - Account currentAccount = sessionManager.getCurrentAccount(); - if (currentAccount == null) { - Timber.d("Current account is null"); - ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.user_not_logged_in)); - sessionManager.forceLogin(getActivity()); - return false; - } - return true; - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt new file mode 100644 index 0000000000..020a67f24f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/achievements/AchievementsFragment.kt @@ -0,0 +1,566 @@ +package fr.free.nrw.commons.profile.achievements + +import android.net.Uri +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.Toast +import androidx.appcompat.view.ContextThemeWrapper +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat +import com.google.android.material.badge.BadgeDrawable +import com.google.android.material.badge.BadgeUtils +import com.google.android.material.badge.ExperimentalBadgeUtils +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.databinding.FragmentAchievementsBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.kvstore.BasicKvStore +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.profile.achievements.LevelController.LevelInfo.Companion.from +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.ViewUtil.showDismissibleSnackBar +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.apache.commons.lang3.StringUtils +import timber.log.Timber +import java.util.Objects +import javax.inject.Inject + +class AchievementsFragment : CommonsDaggerSupportFragment(){ + private lateinit var levelInfo: LevelController.LevelInfo + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var okHttpJsonApiClient: OkHttpJsonApiClient + + private var _binding: FragmentAchievementsBinding? = null + private val binding get() = _binding!! + // To keep track of the number of wiki edits made by a user + private var numberOfEdits: Int = 0 + + private var userName: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + userName = it.getString(ProfileActivity.KEY_USERNAME) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAchievementsBinding.inflate(inflater, container, false) + + binding.achievementInfo.setOnClickListener { showInfoDialog() } + binding.imagesUploadInfoIcon.setOnClickListener { showUploadInfo() } + binding.imagesRevertedInfoIcon.setOnClickListener { showRevertedInfo() } + binding.imagesUsedByWikiInfoIcon.setOnClickListener { showUsedByWikiInfo() } + binding.wikidataEditsIcon.setOnClickListener { showImagesViaNearbyInfo() } + binding.featuredImageIcon.setOnClickListener { showFeaturedImagesInfo() } + binding.thanksImageIcon.setOnClickListener { showThanksReceivedInfo() } + binding.qualityImageIcon.setOnClickListener { showQualityImagesInfo() } + + // DisplayMetrics used to fetch the size of the screen + val displayMetrics = DisplayMetrics() + requireActivity().windowManager.defaultDisplay.getMetrics(displayMetrics) + val height = displayMetrics.heightPixels + val width = displayMetrics.widthPixels + + // Used for the setting the size of imageView at runtime + // TODO REMOVE + val params = binding.achievementBadgeImage.layoutParams as ConstraintLayout.LayoutParams + params.height = (height * BADGE_IMAGE_HEIGHT_RATIO).toInt() + params.width = (width * BADGE_IMAGE_WIDTH_RATIO).toInt() + binding.achievementBadgeImage.requestLayout() + binding.progressBar.visibility = View.VISIBLE + + setHasOptionsMenu(true) + if (sessionManager.userName == null || sessionManager.userName == userName) { + binding.tvAchievementsOfUser.visibility = View.GONE + } else { + binding.tvAchievementsOfUser.visibility = View.VISIBLE + binding.tvAchievementsOfUser.text = getString(R.string.achievements_of_user, userName) + } + if (isBetaFlavour) { + binding.layout.visibility = View.GONE + setMenuVisibility(true) + return binding.root + } + + + setWikidataEditCount() + setAchievements() + return binding.root + + } + + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + + override fun setMenuVisibility(visible: Boolean) { + super.setMenuVisibility(visible) + + // Whenever this fragment is revealed in a menu, + // notify Beta users the page data is unavailable + if (isBetaFlavour && visible) { + val ctx = context ?: view?.context + ctx?.let { + Toast.makeText(it, R.string.achievements_unavailable_beta, Toast.LENGTH_LONG).show() + } + } + } + + /** + * To invoke the AlertDialog on clicking info button + */ + fun showInfoDialog() { + launchAlert( + resources.getString(R.string.Achievements), + resources.getString(R.string.achievements_info_message) + ) + } + + + + + /** + * To call the API to get results in form Single + * which then calls parseJson when results are fetched + */ + + private fun setAchievements() { + binding.progressBar.visibility = View.VISIBLE + if (checkAccount()) { + try { + compositeDisposable.add( + okHttpJsonApiClient + .getAchievements(userName ?: return) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + if (response != null) { + setUploadCount(Achievements.from(response)) + } else { + Timber.d("Success") + // TODO Create a Method to Hide all the Statistics +// binding.layoutImageReverts.visibility = View.INVISIBLE +// binding.achievementBadgeImage.visibility = View.INVISIBLE + // If the number of edits made by the user are more than 150,000 + // in some cases such high number of wiki edit counts cause the + // achievements calculator to fail in some cases, for more details + // refer Issue: #3295 + if (numberOfEdits <= 150_000) { + showSnackBarWithRetry(false) + } else { + showSnackBarWithRetry(true) + } + } + }, + { throwable -> + Timber.e(throwable, "Fetching achievements statistics failed") + if (numberOfEdits <= 150_000) { + showSnackBarWithRetry(false) + } else { + showSnackBarWithRetry(true) + } + } + ) + ) + } catch (e: Exception) { + Timber.d("Exception: ${e.message}") + } + } + } + + /** + * To call the API to fetch the count of wiki data edits + * in the form of JavaRx Single object + */ + + private fun setWikidataEditCount() { + if (StringUtils.isBlank(userName)) { + return + } + compositeDisposable.add( + okHttpJsonApiClient + .getWikidataEdits(userName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ edits: Int -> + numberOfEdits = edits + showBadgesWithCount(view = binding.wikidataEditsIcon, count = edits) + }, { e: Throwable -> + Timber.e("Error:$e") + }) + ) + } + + /** + * Shows a snack bar which has an action button which on click dismisses the snackbar and invokes the + * listener passed + * @param tooManyAchievements if this value is true it means that the number of achievements of the + * user are so high that it wrecks havoc with the Achievements calculator due to which request may time + * out. Well this is the Ultimate Achievement + */ + private fun showSnackBarWithRetry(tooManyAchievements: Boolean) { + if (tooManyAchievements) { + if (view == null) { + return + } + else { + binding.progressBar.visibility = View.GONE + showDismissibleSnackBar( + requireView().findViewById(android.R.id.content), + R.string.achievements_fetch_failed_ultimate_achievement, R.string.retry + ) { setAchievements() } + } + + } else { + if (view == null) { + return + } + binding.progressBar.visibility = View.GONE + showDismissibleSnackBar( + requireView().findViewById(android.R.id.content), + R.string.achievements_fetch_failed, R.string.retry + ) { setAchievements() } + } + } + + /** + * Shows a generic error toast when error occurs while loading achievements or uploads + */ + private fun onError() { + showLongToast(requireActivity(), resources.getString(R.string.error_occurred)) + binding.progressBar.visibility = View.GONE + } + + /** + * used to the count of images uploaded by user + */ + + private fun setUploadCount(achievements: Achievements) { + if (checkAccount()) { + compositeDisposable.add(okHttpJsonApiClient + .getUploadCount(Objects.requireNonNull(userName)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { uploadCount: Int? -> + setAchievementsUploadCount( + achievements, + uploadCount ?:0 + ) + }, + { t: Throwable? -> + Timber.e(t, "Fetching upload count failed") + onError() + } + )) + } + } + + /** + * used to set achievements upload count and call hideProgressbar + * @param uploadCount + */ + private fun setAchievementsUploadCount(achievements: Achievements, uploadCount: Int) { + // Create a new instance of Achievements with updated imagesUploaded + val updatedAchievements = Achievements( + achievements.uniqueUsedImages, + achievements.articlesUsingImages, + achievements.thanksReceived, + achievements.featuredImages, + achievements.qualityImages, + uploadCount, // Update imagesUploaded with new value + achievements.revertCount + ) + + hideProgressBar(updatedAchievements) + } + + /** + * used to the uploaded images progressbar + * @param uploadCount + */ + private fun setUploadProgress(uploadCount: Int) { + if (uploadCount == 0) { + setZeroAchievements() + } else { + binding.imagesUploadedProgressbar.visibility = View.VISIBLE + binding.imagesUploadedProgressbar.progress = + 100 * uploadCount / levelInfo.maxUploadCount + binding.imageUploadedTVCount.text = uploadCount.toString() + "/" + levelInfo.maxUploadCount + } + } + + private fun setZeroAchievements() { + val message = if (sessionManager.userName != userName) { + getString(R.string.no_achievements_yet, userName ) + } else { + getString(R.string.you_have_no_achievements_yet) + } + showAlertDialog( + requireActivity(), + null, + message, + getString(R.string.ok), + {}, + true + ) + +// binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE); +// binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE); +// binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE); + //binding.achievementBadgeImage.visibility = View.INVISIBLE // TODO + binding.imagesUsedCount.setText(R.string.no_image) + binding.imagesRevertedText.setText(R.string.no_image_reverted) + binding.imagesUploadTextParam.setText(R.string.no_image_uploaded) + } + + /** + * used to set the non revert image percentage + * @param notRevertPercentage + */ + private fun setImageRevertPercentage(notRevertPercentage: Int) { + binding.imageRevertsProgressbar.visibility = View.VISIBLE + binding.imageRevertsProgressbar.progress = notRevertPercentage + val revertPercentage = notRevertPercentage.toString() + binding.imageRevertTVCount.text = "$revertPercentage%" + binding.imagesRevertLimitText.text = + resources.getString(R.string.achievements_revert_limit_message) + levelInfo.minNonRevertPercentage + "%" + } + + /** + * Used the inflate the fetched statistics of the images uploaded by user + * and assign badge and level. Also stores the achievements level of the user in BasicKvStore to display in menu + * @param achievements + */ + private fun inflateAchievements(achievements: Achievements) { + + // Thanks Received Badge + showBadgesWithCount(view = binding.thanksImageIcon, count = achievements.thanksReceived) + + // Featured Images Badge + showBadgesWithCount(view = binding.featuredImageIcon, count = achievements.featuredImages) + + // Quality Images Badge + showBadgesWithCount(view = binding.qualityImageIcon, count = achievements.qualityImages) + + binding.imagesUsedByWikiProgressBar.progress = + 100 * achievements.uniqueUsedImages / levelInfo.maxUniqueImages + binding.imagesUsedCount.text = (achievements.uniqueUsedImages.toString() + "/" + + levelInfo.maxUniqueImages) + + binding.achievementLevel.text = getString(R.string.level,levelInfo.levelNumber) + binding.achievementBadgeImage.setImageDrawable( + VectorDrawableCompat.create( + resources, R.drawable.badge, + ContextThemeWrapper(activity, levelInfo.levelStyle).theme + ) + ) + binding.achievementBadgeText.text = levelInfo.levelNumber.toString() + val store = BasicKvStore(requireContext(), userName) + store.putString("userAchievementsLevel", levelInfo.levelNumber.toString()) + } + + /** + * This function is used to show badge on any view (button, imageView, etc) + * @param view The View on which the badge will be displayed eg (button, imageView, etc) + * @param count The number to be displayed inside the badge. + * @param backgroundColor The badge background color. Default is R.attr.colorPrimary + * @param badgeTextColor The badge text color. Default is R.attr.colorPrimary + * @param badgeGravity The position of the badge [TOP_END,TOP_START,BOTTOM_END,BOTTOM_START]. Default is TOP_END + * @return if the number is 0, then it will not create badge for it and hide the view + * @see https://developer.android.com/reference/com/google/android/material/badge/BadgeDrawable + */ + + private fun showBadgesWithCount( + view: View, + count: Int, + backgroundColor: Int = R.attr.colorPrimary, + badgeTextColor: Int = R.attr.textEnabled, + badgeGravity: Int = BadgeDrawable.TOP_END + ) { + //https://stackoverflow.com/a/67742035 + if (count == 0) { + view.visibility = View.GONE + return + } + + view.viewTreeObserver.addOnGlobalLayoutListener(object : + ViewTreeObserver.OnGlobalLayoutListener { + /** + * Callback method to be invoked when the global layout state or the visibility of views + * within the view tree changes + */ + @ExperimentalBadgeUtils + override fun onGlobalLayout() { + view.visibility = View.VISIBLE + val badgeDrawable = BadgeDrawable.create(requireActivity()) + badgeDrawable.number = count + badgeDrawable.badgeGravity = badgeGravity + badgeDrawable.badgeTextColor = badgeTextColor + badgeDrawable.backgroundColor = backgroundColor + BadgeUtils.attachBadgeDrawable(badgeDrawable, view) + view.getViewTreeObserver().removeOnGlobalLayoutListener(this) + } + }) + } + + /** + * to hide progressbar + */ + private fun hideProgressBar(achievements: Achievements) { + if (binding.progressBar != null) { + levelInfo = from( + achievements.imagesUploaded, + achievements.uniqueUsedImages, + achievements.notRevertPercentage + ) + inflateAchievements(achievements) + setUploadProgress(achievements.imagesUploaded) + setImageRevertPercentage(achievements.notRevertPercentage) + binding.progressBar.visibility = View.GONE + } + } + + fun showUploadInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.images_uploaded), + resources.getString(R.string.images_uploaded_explanation), + IMAGES_UPLOADED_URL + ) + } + + fun showRevertedInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.image_reverts), + resources.getString(R.string.images_reverted_explanation), + IMAGES_REVERT_URL + ) + } + + fun showUsedByWikiInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.images_used_by_wiki), + resources.getString(R.string.images_used_explanation), + IMAGES_USED_URL + ) + } + + fun showImagesViaNearbyInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.statistics_wikidata_edits), + resources.getString(R.string.images_via_nearby_explanation), + IMAGES_NEARBY_PLACES_URL + ) + } + + fun showFeaturedImagesInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.statistics_featured), + resources.getString(R.string.images_featured_explanation), + IMAGES_FEATURED_URL + ) + } + + fun showThanksReceivedInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.statistics_thanks), + resources.getString(R.string.thanks_received_explanation), + THANKS_URL + ) + } + + fun showQualityImagesInfo() { + launchAlertWithHelpLink( + resources.getString(R.string.statistics_quality), + resources.getString(R.string.quality_images_info), + QUALITY_IMAGE_URL + ) + } + + /** + * takes title and message as input to display alerts + * @param title + * @param message + */ + private fun launchAlert(title: String, message: String) { + showAlertDialog( + requireActivity(), + title, + message, + getString(R.string.ok), + {}, + true + ) + } + + /** + * Launch Alert with a READ MORE button and clicking it open a custom webpage + */ + private fun launchAlertWithHelpLink(title: String, message: String, helpLinkUrl: String) { + showAlertDialog( + requireActivity(), + title, + message, + getString(R.string.ok), + getString(R.string.read_help_link), + {}, + { Utils.handleWebUrl(requireContext(), Uri.parse(helpLinkUrl)) }, + null, + true + ) + } + /** + * check to ensure that user is logged in + * @return + */ + private fun checkAccount(): Boolean { + val currentAccount = sessionManager.currentAccount + if (currentAccount == null) { + Timber.d("Current account is null") + showLongToast(requireActivity(), resources.getString(R.string.user_not_logged_in)) + sessionManager.forceLogin(activity) + return false + } + return true + } + + + + companion object{ + private const val BADGE_IMAGE_WIDTH_RATIO = 0.4 + private const val BADGE_IMAGE_HEIGHT_RATIO = 0.3 + + /** + * Help link URLs + */ + private const val IMAGES_UPLOADED_URL = "https://commons.wikimedia.org/wiki/Commons:Project_scope" + private const val IMAGES_REVERT_URL = "https://commons.wikimedia.org/wiki/Commons:Deletion_policy#Reasons_for_deletion" + private const val IMAGES_USED_URL = "https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style/Images" + private const val IMAGES_NEARBY_PLACES_URL = "https://www.wikidata.org/wiki/Property:P18" + private const val IMAGES_FEATURED_URL = "https://commons.wikimedia.org/wiki/Commons:Featured_pictures" + private const val QUALITY_IMAGE_URL = "https://commons.wikimedia.org/wiki/Commons:Quality_images" + private const val THANKS_URL = "https://www.mediawiki.org/wiki/Extension:Thanks" + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_achievements.xml b/app/src/main/res/layout/fragment_achievements.xml index e0dddcf5bf..00c18b3232 100644 --- a/app/src/main/res/layout/fragment_achievements.xml +++ b/app/src/main/res/layout/fragment_achievements.xml @@ -1,640 +1,368 @@ - - - + + + + + + + + + - - + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_centerInParent="true" + android:progressDrawable="@android:drawable/progress_horizontal" + android:progressBackgroundTintMode="multiply" + android:progressTint="#5ce65c" + tools:progress="50" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 187c5fc96f..fa21736990 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -371,11 +371,13 @@ Delete Achievements Profile + Badges Statistics Thanks Received Featured Images Images via \"Nearby Places\" - Level + Level %d + %s (Level %s) Images Uploaded Images Not Reverted Images Used diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 94856e4eb6..67b5eae0f5 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,6 +1,6 @@ - -