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 index dc567088cf..e2da225339 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.java @@ -11,6 +11,8 @@ 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.PlaceBindings; +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.profile.achievements.FeaturedImages; @@ -27,6 +29,8 @@ 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; @@ -393,6 +397,196 @@ public List getNearbyPlaces( throw new Exception(response.message()); } + /** + * 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" + + " "; + + int increment = 1; + double longitude = leftLatLng.getLongitude(); + + while (longitude <= rightLatLng.getLongitude()) { + double NEXT_LONGITUDE = + (increment + longitude) >= 0.0 && (increment + longitude) <= 1.0 ? 0.0 + : increment + longitude; + + double latitude = leftLatLng.getLatitude(); + + while (latitude <= rightLatLng.getLatitude()) { + double NEXT_LATITUDE = + (increment + latitude) >= 0.0 && (increment + latitude) <= 1.0 ? 0.0 + : increment + latitude; + List placeBindings = runQuery(new LatLng(latitude, longitude, 0), + new LatLng(NEXT_LATITUDE, NEXT_LONGITUDE, 0)); + 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"); + } + } + } + } + latitude += increment; + } + longitude += increment; + } + 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"; + + int increment = 1; + double longitude = leftLatLng.getLongitude(); + + while (longitude <= rightLatLng.getLongitude()) { + double NEXT_LONGITUDE = + (increment + longitude) >= 0.0 && (increment + longitude) <= 1.0 ? 0.0 + : increment + longitude; + + double latitude = leftLatLng.getLatitude(); + + while (latitude <= rightLatLng.getLatitude()) { + double NEXT_LATITUDE = + (increment + latitude) >= 0.0 && (increment + latitude) <= 1.0 ? 0.0 + : increment + latitude; + List placeBindings = runQuery(new LatLng(latitude, longitude, 0), + new LatLng(NEXT_LATITUDE, NEXT_LONGITUDE, 0)); + 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"); + } + } + } + } + latitude += increment; + } + longitude += increment; + } + 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 * diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java index 12bc641b85..61d749147e 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java @@ -118,6 +118,14 @@ public NearbyPlacesInfo loadAttractionsFromLocation(final LatLng currentLatLng, return nearbyPlacesInfo; } + public String getPlacesAsKML(LatLng leftLatLng, LatLng rightLatLng) throws Exception { + return nearbyPlaces.getPlacesAsKML(leftLatLng, rightLatLng); + } + + public String getPlacesAsGPX(LatLng leftLatLng, LatLng rightLatLng) throws Exception { + return nearbyPlaces.getPlacesAsGPX(leftLatLng, rightLatLng); + } + /** * Prepares Place list to make their distance information update later. * diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java index 07b7f53fd4..787cc35e2a 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java @@ -119,4 +119,27 @@ public List getFromWikidataQuery( .getNearbyPlaces(screenTopRight, screenBottomLeft, lang, shouldQueryForMonuments, customQuery); } + + /** + * Runs the Wikidata query to retrieve the KML String + * + * @param leftLatLng coordinates of Left Most position + * @param rightLatLng coordinates of Right Most position + * @throws IOException if query fails + */ + public String getPlacesAsKML(LatLng leftLatLng, LatLng rightLatLng) throws Exception { + return okHttpJsonApiClient.getPlacesAsKML(leftLatLng, rightLatLng); + } + + /** + * Runs the Wikidata query to retrieve the GPX String + * + * @param leftLatLng coordinates of Left Most position + * @param rightLatLng coordinates of Right Most position + * @throws IOException if query fails + */ + public String getPlacesAsGPX(LatLng leftLatLng, LatLng rightLatLng) throws Exception { + return okHttpJsonApiClient.getPlacesAsGPX(leftLatLng, rightLatLng); + } + } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java index f84334db50..6a16fccf29 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java @@ -22,6 +22,7 @@ import android.location.LocationManager; import android.net.Uri; import android.os.Bundle; +import android.os.Environment; import android.preference.PreferenceManager; import android.provider.Settings; import android.text.Html; @@ -80,6 +81,7 @@ import fr.free.nrw.commons.contributions.MainActivity.ActiveFragment; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.kvstore.JsonKvStore; +import fr.free.nrw.commons.location.LatLng; import fr.free.nrw.commons.location.LocationServiceManager; import fr.free.nrw.commons.location.LocationUpdateListener; import fr.free.nrw.commons.nearby.CheckBoxTriStates; @@ -105,11 +107,15 @@ import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -357,6 +363,8 @@ public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.nearby_fragment_menu, menu); MenuItem listMenu = menu.findItem(R.id.list_sheet); + MenuItem saveAsGPXButton = menu.findItem(R.id.list_item_gpx); + MenuItem saveAsKMLButton = menu.findItem(R.id.list_item_kml); listMenu.setOnMenuItemClickListener(new OnMenuItemClickListener() { @Override public boolean onMenuItemClick(MenuItem item) { @@ -364,6 +372,44 @@ public boolean onMenuItemClick(MenuItem item) { return false; } }); + saveAsGPXButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { + + @Override + public boolean onMenuItemClick(@NonNull MenuItem item) { + try { + IGeoPoint screenTopRight = mapView.getProjection().fromPixels(mapView.getWidth(), 0); + IGeoPoint screenBottomLeft = mapView.getProjection().fromPixels(0, mapView.getHeight()); + fr.free.nrw.commons.location.LatLng screenTopRightLatLng = new fr.free.nrw.commons.location.LatLng( + screenBottomLeft.getLatitude(), screenBottomLeft.getLongitude(), 0); + fr.free.nrw.commons.location.LatLng screenBottomLeftLatLng = new fr.free.nrw.commons.location.LatLng( + screenTopRight.getLatitude(), screenTopRight.getLongitude(), 0); + setProgressBarVisibility(true); + savePlacesAsGPX(screenTopRightLatLng,screenBottomLeftLatLng); + } catch (Exception e) { + throw new RuntimeException(e); + } + return false; + } + }); + saveAsKMLButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { + + @Override + public boolean onMenuItemClick(@NonNull MenuItem item) { + try { + IGeoPoint screenTopRight = mapView.getProjection().fromPixels(mapView.getWidth(), 0); + IGeoPoint screenBottomLeft = mapView.getProjection().fromPixels(0, mapView.getHeight()); + fr.free.nrw.commons.location.LatLng screenTopRightLatLng = new fr.free.nrw.commons.location.LatLng( + screenBottomLeft.getLatitude(), screenBottomLeft.getLongitude(), 0); + fr.free.nrw.commons.location.LatLng screenBottomLeftLatLng = new fr.free.nrw.commons.location.LatLng( + screenTopRight.getLatitude(), screenTopRight.getLongitude(), 0); + setProgressBarVisibility(true); + savePlacesAsKML(screenTopRightLatLng,screenBottomLeftLatLng); + } catch (Exception e) { + throw new RuntimeException(e); + } + return false; + } + }); } @Override @@ -1198,6 +1244,102 @@ public void populatePlaces(final fr.free.nrw.commons.location.LatLng currentLatL } } + private void savePlacesAsKML(LatLng latLng, LatLng nextlatLng) { + final Observable savePlacesObservable = Observable + .fromCallable(() -> nearbyController + .getPlacesAsKML(latLng, nextlatLng)); + compositeDisposable.add(savePlacesObservable + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(kmlString -> { + if (kmlString != null) { + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", + Locale.getDefault()).format(new Date()); + String fileName = + "KML_" + timeStamp + "_" + System.currentTimeMillis() + ".kml"; + boolean saved = saveFile(kmlString, fileName); + setProgressBarVisibility(false); + if (saved) { + Toast.makeText(this.getContext(), + "KML file saved successfully at /Downloads/" + fileName, + Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this.getContext(), "Failed to save KML file.", + Toast.LENGTH_SHORT).show(); + } + } + }, + throwable -> { + Timber.d(throwable); + showErrorMessage(getString(R.string.error_fetching_nearby_places) + + throwable.getLocalizedMessage()); + setProgressBarVisibility(false); + presenter.lockUnlockNearby(false); + setFilterState(); + })); + } + + private void savePlacesAsGPX(LatLng latLng, LatLng nextlatLng) { + final Observable savePlacesObservable = Observable + .fromCallable(() -> nearbyController + .getPlacesAsGPX(latLng, nextlatLng)); + compositeDisposable.add(savePlacesObservable + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(gpxString -> { + if (gpxString != null) { + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", + Locale.getDefault()).format(new Date()); + String fileName = + "GPX_" + timeStamp + "_" + System.currentTimeMillis() + ".gpx"; + boolean saved = saveFile(gpxString, fileName); + setProgressBarVisibility(false); + if (saved) { + Toast.makeText(this.getContext(), + "GPX file saved successfully at /Downloads/" + fileName, + Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this.getContext(), "Failed to save KML file.", + Toast.LENGTH_SHORT).show(); + } + } + }, + throwable -> { + Timber.d(throwable); + showErrorMessage(getString(R.string.error_fetching_nearby_places) + + throwable.getLocalizedMessage()); + setProgressBarVisibility(false); + presenter.lockUnlockNearby(false); + setFilterState(); + })); + } + + public static boolean saveFile(String string, String fileName) { + + if (!isExternalStorageWritable()) { + return false; + } + + File downloadsDir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS); + File kmlFile = new File(downloadsDir, fileName); + + try { + FileOutputStream fos = new FileOutputStream(kmlFile); + fos.write(string.getBytes()); + fos.close(); + return true; + } catch (IOException e) { + e.printStackTrace(); + return false; + } + } + + private static boolean isExternalStorageWritable() { + String state = Environment.getExternalStorageState(); + return Environment.MEDIA_MOUNTED.equals(state); + } + private void populatePlacesForCurrentLocation( final fr.free.nrw.commons.location.LatLng currentLatLng, final fr.free.nrw.commons.location.LatLng screenTopRight, diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/model/PlaceBindings.kt b/app/src/main/java/fr/free/nrw/commons/nearby/model/PlaceBindings.kt new file mode 100644 index 0000000000..ccbdd156c4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/nearby/model/PlaceBindings.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.nearby.model + +import com.google.gson.annotations.SerializedName + +data class PlaceBindings( + @SerializedName("item") val item: Item, + @SerializedName("label") val label: Label, + @SerializedName("location") val location: Location, + @SerializedName("class") val clas: Clas +) + +data class ItemsClass( + @SerializedName("head") val head: Head, + @SerializedName("results") val results: Results +) + +data class Label( + @SerializedName("xml:lang") val xml: String, + @SerializedName("type") val type: String, + @SerializedName("value") val value: String +) + +data class Location( + @SerializedName("datatype") val datatype: String, + @SerializedName("type") val type: String, + @SerializedName("value") val value: String +) + +data class Results( + @SerializedName("bindings") val bindings: List +) + +data class Item( + @SerializedName("type") val type: String, + @SerializedName("value") val value: String +) + +data class Head( + @SerializedName("vars") val vars: List +) + + +data class Clas( + @SerializedName("type") val type: String, + @SerializedName("value") val value: String +) \ No newline at end of file diff --git a/app/src/main/res/menu/nearby_fragment_menu.xml b/app/src/main/res/menu/nearby_fragment_menu.xml index b9df1aa0a8..30b5c9dd59 100644 --- a/app/src/main/res/menu/nearby_fragment_menu.xml +++ b/app/src/main/res/menu/nearby_fragment_menu.xml @@ -6,4 +6,12 @@ app:showAsAction="ifRoom|withText" android:icon="@drawable/ic_list_white_24dp" /> + + diff --git a/app/src/main/resources/queries/places_query.rq b/app/src/main/resources/queries/places_query.rq new file mode 100644 index 0000000000..fea399d40d --- /dev/null +++ b/app/src/main/resources/queries/places_query.rq @@ -0,0 +1,22 @@ +SELECT + ?item + (SAMPLE(COALESCE(?en_label, ?fr_label, ?id_label, ?item_label)) as ?label) + (SAMPLE(?location) as ?location) + (GROUP_CONCAT(DISTINCT ?class_label ; separator=",") as ?class) +WHERE { + SERVICE wikibase:box { + ?item wdt:P625 ?location . + bd:serviceParam wikibase:cornerSouthWest "Point(${LONGITUDE} ${LATITUDE})"^^geo:wktLiteral . + bd:serviceParam wikibase:cornerNorthEast "Point(${NEXT_LONGITUDE} ${NEXT_LATITUDE})"^^geo:wktLiteral . + } + MINUS {?item wdt:P18 ?image} + MINUS {?item wdt:P582 ?endtime.} + MINUS {?item wdt:P582 ?dissolvedOrAbolished.} + MINUS {?item p:P31 ?instanceStatement. ?instanceStatement pq:P582 ?endtimeQualifier.} + OPTIONAL {?item rdfs:label ?en_label . FILTER(LANG(?en_label) = "en")} + OPTIONAL {?item rdfs:label ?fr_label . FILTER(LANG(?fr_label) = "fr")} + OPTIONAL {?item rdfs:label ?vn_label . FILTER(LANG(?id_label) = "id")} + OPTIONAL {?item rdfs:label ?item_label} + OPTIONAL {?item wdt:P31 ?class. ?class rdfs:label ?class_label. FILTER(LANG(?class_label) = "en")} +} +GROUP BY ?item \ No newline at end of file