package me.corriekay.pokegoutil.utils.helpers;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Type;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.commons.lang3.StringUtils;
import com.pokegoapi.google.common.geometry.S2CellId;
import com.pokegoapi.google.common.geometry.S2LatLng;
import me.corriekay.pokegoutil.data.enums.ExceptionMessages;
import me.corriekay.pokegoutil.utils.ConfigKey;
import me.corriekay.pokegoutil.utils.ConfigNew;
import me.corriekay.pokegoutil.utils.StringLiterals;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Helper class that provides utility functions concerning locations.
*/
public final class LocationHelper {
// General constants that are set for this file
private static final File LOCATION_FILE = new File(System.getProperty("user.dir"), "locations.json");
private static final int SAVE_DELAY_SECONDS = 5;
// Internal needed constants
private static final Map<Long, Location> SAVED_LOCATIONS;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
// A boolean showing if a save is currently in progress
private static AtomicBoolean isSaving = new AtomicBoolean(false);
static {
SAVED_LOCATIONS = new ConcurrentHashMap<>();
load();
}
/** Prevent initializing this class. */
private LocationHelper() {
}
/**
* Returns the location as lat long coordinates.
*
* @param s2CellId The cell id.
* @return The location as coordinates.
*/
public static LatLongLocation getCoordinates(final S2CellId s2CellId) {
return new LatLongLocation(s2CellId);
}
/**
* Returns a Future that resolves some time in the future after the location is queried from Google API.
* It returns a string for the location based on the cell ID.
*
* @param s2CellId The cell ID
* @return The location string.
*/
public static CompletableFuture<Location> getLocation(final S2CellId s2CellId) {
return CompletableFuture.supplyAsync(
() -> {
// If we have the location saved, we can return it already
if (SAVED_LOCATIONS.containsKey(s2CellId.id())) {
return SAVED_LOCATIONS.get(s2CellId.id());
}
Location location = null;
final LatLongLocation latLng = new LatLongLocation(s2CellId);
try {
final JSONObject json = queryJsonFromUrl(latLng.toString());
final String formattedLocation = formattedLocationFromGoogleResponse(json);
final String city = cityFromGoogleResponse(json);
if (formattedLocation != null) {
// We got the location, so we save it. If city wasn't found, we leave it empty.
location = new Location(formattedLocation, city != null ? city : "");
SAVED_LOCATIONS.put(s2CellId.id(), location);
} else {
location = new Location("Error: " + json.optString(GoogleKey.STATUS));
}
} catch (IOException | JSONException e) {
location = new Location("Exception: " + e.getMessage());
}
save();
return location;
});
}
/**
* Checks if the location file exists.
*
* @return Weather or not the location file exists.
*/
public static boolean locationFileExists() {
return LOCATION_FILE.exists();
}
/**
* Deletes the cached locations, and the location file too.
* Cache will be started from scratch again.
*/
public static void deleteCachedLocations() {
FileHelper.deleteFile(LOCATION_FILE);
SAVED_LOCATIONS.clear();
System.out.println("Deleted cached locations.");
}
/**
* Queries the JSON for location from the Google API.
* It uses the user chosen language to tell Google in which language the city names should be returned.
*
* @param latLong A string containing lat and long, like "1.124,1.566"
* @return The JSON from the server.
* @throws IOException io.
* @throws JSONException json.
*/
private static JSONObject queryJsonFromUrl(final String latLong) throws IOException, JSONException {
final String language = ConfigNew.getConfig().getString(ConfigKey.LANGUAGE);
final String apiUrl = "http://maps.googleapis.com/maps/api/geocode/json?latlng=%s&sensor=true&language=%s";
final String formattedUrl = String.format(apiUrl, latLong.replace(" ", "%20"), language);
try {
final URL url = new URL(formattedUrl);
final String apiResponse = FileHelper.readFile(url.openStream());
return new JSONObject(apiResponse);
} catch (IOException e) {
System.out.println(ExceptionMessages.COULD_NOT_QUERY_LOCATION.with(e));
return new JSONObject();
}
}
/**
* Gets the formatted location from the google response JSON.
*
* @param json The google response JSON.
* @return The formatted location.
*/
private static String formattedLocationFromGoogleResponse(final JSONObject json) {
final JSONArray matches = json.optJSONArray(GoogleKey.RESULTS);
String formattedLocation = null;
if (matches != null && matches.length() > 0) {
formattedLocation = matches.getJSONObject(0).optString(GoogleKey.FORMATTED_ADDRESS);
}
return formattedLocation;
}
/**
* Gets the city from the google response JSON.
*
* @param json The google response JSON.
* @return The city.
*/
private static String cityFromGoogleResponse(final JSONObject json) {
final JSONArray matches = json.optJSONArray(GoogleKey.RESULTS);
String city;
// First try, we check the different locations to find the city one
city = cityFromGoogleMatchesList(matches, GoogleKey.FORMATTED_ADDRESS);
// Second try. If we haven't got the city from the different locations,
// we use the address components of the most detailed location and search for the city there
if (city == null && matches != null && matches.length() > 0) {
final JSONArray addressComponents = matches.getJSONObject(0).optJSONArray(GoogleKey.ADDRESS_COMPONENTS);
city = cityFromGoogleMatchesList(addressComponents, GoogleKey.LONG_NAME);
}
return city;
}
/**
* Gets the city from a matches array from the google response.
* Should ONLY be used inside the cityFromGoogleResponse() function.
*
* @param array The matches array from the google response.
* @param node The node to return from the matched array element.
* @return The city if found, else null.
*/
private static String cityFromGoogleMatchesList(final JSONArray array, final String node) {
if (array == null || array.length() == 0) {
return null;
}
// We go through all components to see if we find the one we want
for (int i = 0; i < array.length(); i++) {
final JSONObject component = array.getJSONObject(i);
// We go through the types and check if we are in the right component
final JSONArray types = component.getJSONArray(GoogleKey.TYPES);
boolean isCity = false;
for (int typeIndex = 0; typeIndex < types.length(); typeIndex++) {
final String type = types.optString(typeIndex);
if ("postal_code".equals(type)) {
// We continue with the next component here, we do not want the postal code notation
isCity = false;
break;
}
if ("locality".equals(type)) {
isCity = true;
}
}
// If so, we return the searched value
if (isCity) {
return component.optString(node);
}
}
return null;
}
/**
* Saves the cached locations to location.json file.
*/
private static void save() {
// The map gets updated really often maybe, so we delay the save to save a bulk of it
if (isSaving.compareAndSet(false, true)) {
Executors.newSingleThreadScheduledExecutor().schedule(() -> {
// Save the location data to the location.json
FileHelper.saveFile(LOCATION_FILE, GSON.toJson(SAVED_LOCATIONS));
System.out.println("Saved queried locations to file.");
isSaving.set(false);
}, SAVE_DELAY_SECONDS, TimeUnit.SECONDS);
}
}
/**
* Loads saved locations from location.json file.
*/
private static void load() {
final Type mapType = new TypeToken<ConcurrentHashMap<Long, Location>>() {
}.getType();
Map<Long, Location> loadedLocations;
try {
loadedLocations = GSON.fromJson(FileHelper.readFileWithExceptions(LOCATION_FILE), mapType);
System.out.println("Load saved locations from file.");
} catch (JsonSyntaxException | IOException e) {
loadedLocations = null;
System.out.println(ExceptionMessages.COULD_NOT_LOAD_LOCATIONS.with(e));
FileHelper.deleteFile(LOCATION_FILE, false);
}
if (loadedLocations != null) {
SAVED_LOCATIONS.putAll(loadedLocations);
}
}
/**
* Class that holds lat long coordinates.
*/
public static final class LatLongLocation {
public final double latitude;
public final double longitude;
/**
* Internal constructor to create an object of this class.
*
* @param s2CellId The cell.
*/
private LatLongLocation(final S2CellId s2CellId) {
final S2LatLng s2LatLng = s2CellId.toLatLng();
this.latitude = s2LatLng.latDegrees();
this.longitude = s2LatLng.lngDegrees();
}
@Override
public String toString() {
return this.latitude + StringLiterals.CONCAT_SEPARATOR + this.longitude;
}
/**
* Formats the long and lat rounded to given decimal places.
*
* @param decimals The number of decimal places.
* @return The formatted string.
*/
public String toString(final int decimals) {
final DecimalFormat decimalFormat = new DecimalFormat("#." + StringUtils.repeat("#", decimals));
return decimalFormat.format(latitude).replace(',', '.')
+ StringLiterals.CONCAT_SEPARATOR
+ decimalFormat.format(longitude).replace(',', '.');
}
}
/**
* A wrapper to hold location information.
*/
public static final class Location {
public final String formattedLocation;
public final String city;
/**
* Internal constructor to create a location.
*
* @param formattedLocation The formatted location.
* @param city The city.
*/
private Location(final String formattedLocation, final String city) {
this.formattedLocation = formattedLocation;
this.city = city;
}
/**
* Internal constructor to create a location, based on an error.
*
* @param error The error.
*/
private Location(final String error) {
this.formattedLocation = error;
this.city = error;
}
}
/**
* Internal class that holds possible keys for the Google JSON.
*/
private static final class GoogleKey {
// Google keys for JSON accessing
private static final String STATUS = "status";
private static final String RESULTS = "results";
private static final String TYPES = "types";
private static final String FORMATTED_ADDRESS = "formatted_address";
private static final String ADDRESS_COMPONENTS = "address_components";
private static final String LONG_NAME = "long_name";
}
}