From a60313edf9ed96b20db0ae63f1a90f3fa0b7ac77 Mon Sep 17 00:00:00 2001 From: full-duplex Date: Mon, 9 Jan 2017 12:29:48 +0100 Subject: [PATCH] Negentwee: New provider for the Netherlands. --- .../de/schildbach/pte/NegentweeProvider.java | 875 ++++++++++++++++++ enabler/src/de/schildbach/pte/NetworkId.java | 2 +- .../de/schildbach/pte/util/ParserUtils.java | 12 + .../src/de/schildbach/pte/util/WordUtils.java | 8 + .../pte/live/NegentweeProviderLiveTest.java | 163 ++++ 5 files changed, 1059 insertions(+), 1 deletion(-) create mode 100644 enabler/src/de/schildbach/pte/NegentweeProvider.java create mode 100644 enabler/test/de/schildbach/pte/live/NegentweeProviderLiveTest.java diff --git a/enabler/src/de/schildbach/pte/NegentweeProvider.java b/enabler/src/de/schildbach/pte/NegentweeProvider.java new file mode 100644 index 000000000..adbc8fb34 --- /dev/null +++ b/enabler/src/de/schildbach/pte/NegentweeProvider.java @@ -0,0 +1,875 @@ +/* + * Copyright 2017 the original author or authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.schildbach.pte; + +import java.io.IOException; +import java.io.Serializable; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Currency; +import java.util.Date; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import de.schildbach.pte.dto.Departure; +import de.schildbach.pte.dto.Fare; +import de.schildbach.pte.dto.Line; +import de.schildbach.pte.dto.LineDestination; +import de.schildbach.pte.dto.Location; +import de.schildbach.pte.dto.LocationType; +import de.schildbach.pte.dto.NearbyLocationsResult; +import de.schildbach.pte.dto.Point; +import de.schildbach.pte.dto.Position; +import de.schildbach.pte.dto.Product; +import de.schildbach.pte.dto.QueryDeparturesResult; +import de.schildbach.pte.dto.QueryTripsContext; +import de.schildbach.pte.dto.QueryTripsResult; +import de.schildbach.pte.dto.ResultHeader; +import de.schildbach.pte.dto.StationDepartures; +import de.schildbach.pte.dto.Stop; +import de.schildbach.pte.dto.SuggestLocationsResult; +import de.schildbach.pte.dto.SuggestedLocation; +import de.schildbach.pte.dto.Trip; +import de.schildbach.pte.exception.InternalErrorException; +import de.schildbach.pte.exception.NotFoundException; +import de.schildbach.pte.util.ParserUtils; +import de.schildbach.pte.util.WordUtils; + +import okhttp3.HttpUrl; + +/** + * @author full-duplex + */ +public class NegentweeProvider extends AbstractNetworkProvider { + + private static final String API_BASE = "https://api.9292.nl/0.1/"; + private static final String SERVER_PRODUCT = "negentwee"; + + private static final Language DEFAULT_API_LANG = Language.NL_NL; + private static final int DEFAULT_MAX_LOCATIONS = 50; + + private static final SimpleDateFormat dateTimeParser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm"); + private static final SimpleDateFormat timeParser = new SimpleDateFormat("HH:mm"); + + private static final EnumSet trainProducts = EnumSet.of(Product.HIGH_SPEED_TRAIN, Product.REGIONAL_TRAIN, + Product.SUBURBAN_TRAIN); + + private final Language language; + private final ResultHeader resultHeader; + + public enum Language { + NL_NL("nl-NL"), EN_GB("en-GB"); + + private final String lang; + + private Language(String lang) { + this.lang = lang; + } + + @Override + public String toString() { + return this.lang; + } + } + + private enum InterchangeTime { + STANDARD, EXTRA; + + @Override + public String toString() { + return name().toLowerCase(); + } + } + + @SuppressWarnings("serial") + private static class QueryParameter implements Serializable { + public String name, value; + + private QueryParameter(String name, String value) { + this.name = name; + this.value = value; + } + + @Override + public String toString() { + return this.name + "=" + this.value; + } + } + + @SuppressWarnings("serial") + private static class TripsContext implements QueryTripsContext { + private String url, earlier, later; + public Location from, to, via; + + private TripsContext(HttpUrl url, @Nullable String earlier, @Nullable String later, Location from, + @Nullable Location via, Location to) { + this.url = url.toString(); + this.earlier = earlier; + this.later = later; + this.from = from; + this.via = via; + this.to = to; + } + + private HttpUrl getQueryEarlier() { + return HttpUrl.parse(this.url).newBuilder(this.earlier).addQueryParameter("before", "4").build(); + } + + private HttpUrl getQueryLater() { + return HttpUrl.parse(this.url).newBuilder(this.later).addQueryParameter("after", "4").build(); + } + + @Override + public boolean canQueryEarlier() { + return (earlier != null); + } + + @Override + public boolean canQueryLater() { + return (later != null); + } + } + + public NegentweeProvider() { + this(DEFAULT_API_LANG); + } + + public NegentweeProvider(Language language) { + super(NetworkId.NEGENTWEE); + + this.language = language; + this.resultHeader = new ResultHeader(network, SERVER_PRODUCT); + } + + private HttpUrl buildApiUrl(String action, List queries) { + HttpUrl.Builder url = HttpUrl.parse(API_BASE).newBuilder().addPathSegments(action).addQueryParameter("lang", + this.language.toString()); + + for (QueryParameter q : queries) { + url.addQueryParameter(q.name, q.value); + } + + return url.build(); + } + + private Location queryLocationById(String stationId) throws IOException { + HttpUrl url = buildApiUrl("locations/" + stationId, new ArrayList()); + final CharSequence page = httpClient.get(url); + + try { + JSONObject head = new JSONObject(page.toString()); + JSONObject location = head.getJSONObject("location"); + + return locationFromJSONObject(location); + } catch (final JSONException x) { + throw new IOException("cannot parse: '" + page + "' on " + url, x); + } + } + + private Location queryLocationByName(String locationName, EnumSet types) throws IOException { + for (Location location : queryLocationsByName(locationName, types)) { + if (location.name != null && location.name.equals(locationName)) { + return location; + } + } + + throw new RuntimeException("Cannot find station with name " + locationName); + } + + private List queryLocationsByName(String locationName, EnumSet types) throws IOException { + List queryParameters = new ArrayList<>(); + queryParameters.add(new QueryParameter("q", locationName)); + + if (!types.contains(LocationType.ANY) && types.size() > 0) { + StringBuilder typeValue = new StringBuilder(); + for (LocationType type : types) { + for (String addition : locationStringsFromLocationType(type)) { + if (typeValue.length() > 0) + typeValue.append(","); + typeValue.append(addition); + } + } + queryParameters.add(new QueryParameter("type", typeValue.toString())); + } + + HttpUrl url = buildApiUrl("locations", queryParameters); + final CharSequence page = httpClient.get(url); + + try { + JSONObject head = new JSONObject(page.toString()); + JSONArray locations = head.getJSONArray("locations"); + + Location[] foundLocations = new Location[locations.length()]; + for (int i = 0; i < locations.length(); i++) { + foundLocations[i] = locationFromJSONObject(locations.getJSONObject(i)); + } + + return Arrays.asList(foundLocations); + } catch (final JSONException x) { + throw new RuntimeException("cannot parse: '" + page + "' on " + url, x); + } + } + + private Point pointFromLocation(Location location) throws JSONException { + return new Point(location.lat, location.lon); + } + + private LocationType locationTypeFromTypeString(String type) throws JSONException { + switch (type) { + case "station": + case "stop": + return LocationType.STATION; + case "address": + case "street": + case "streetrange": + case "place": + case "postcode": + return LocationType.ADDRESS; + case "poi": + return LocationType.POI; + case "latlong": + return LocationType.COORD; + default: + throw new JSONException("Unsupported location type: " + type); + } + } + + private List locationStringsFromLocationType(LocationType type) { + switch (type) { + case STATION: + return Arrays.asList("station", "stop"); + case POI: + return Arrays.asList("poi"); + case ADDRESS: + return Arrays.asList("address", "street", "streetrange", "place", "postcode"); + case COORD: + return Arrays.asList("latlong"); + default: + return Arrays.asList(); + } + } + + private EnumSet productSetFromTypeString(String type) { + switch (type.toLowerCase()) { + case "train": + return EnumSet.of(Product.HIGH_SPEED_TRAIN, Product.REGIONAL_TRAIN, Product.SUBURBAN_TRAIN); + case "subway": + return EnumSet.of(Product.SUBWAY); + case "tram": + return EnumSet.of(Product.TRAM); + case "bus": + return EnumSet.of(Product.BUS); + case "ferry": + return EnumSet.of(Product.FERRY); + case "walk": + return EnumSet.of(Product.ON_DEMAND); + default: + return EnumSet.noneOf(Product.class); + } + } + + private Product productFromMode(String type, String name) { + switch (type.toLowerCase()) { + case "train": + switch (name.toLowerCase()) { + // TODO: Likely not all possible train names, add here if trains are classified incorrectly. + case "thalys": + case "ice": + case "intercity direct": + case "intercity": + return Product.HIGH_SPEED_TRAIN; + case "sprinter": + default: + return Product.REGIONAL_TRAIN; + } + case "tram": + return Product.TRAM; + case "subway": + return Product.SUBWAY; + case "bus": + return Product.BUS; + case "ferry": + return Product.FERRY; + case "walk": + return Product.ON_DEMAND; + } + + return null; + } + + private Date dateFromJSONObject(JSONObject obj, String key) throws JSONException { + try { + return dateTimeParser.parse(obj.getString(key)); + } catch (ParseException e) { + return null; + } + } + + private Date timeFromJSONObject(JSONObject obj, String key) throws JSONException { + try { + return timeParser.parse(obj.getString(key)); + } catch (ParseException e) { + return null; + } + } + + private Date realtimeDateFromJSONObject(JSONObject obj, String key, String realtimeKey) throws JSONException { + return dateFromJSONObject(obj, (!obj.isNull(realtimeKey)) ? realtimeKey : key); + } + + private Trip tripFromJSONObject(JSONObject trip, @Nullable Location from, @Nullable Location to, + @Nullable Map disturbances) throws JSONException { + JSONArray legs = trip.getJSONArray("legs"); + + Date tripDeparture = realtimeDateFromJSONObject(trip, "departure", "realtimeDeparture"); + /* Date tripArrival = */ realtimeDateFromJSONObject(trip, "arrival", "realtimeArrival"); + + // Get journey legs + LinkedList foundLegs = new LinkedList<>(); + for (int i = 0; i < legs.length(); i++) { + JSONObject leg = legs.getJSONObject(i); + + JSONArray stops = leg.getJSONArray("stops"); + JSONObject mode = leg.getJSONObject("mode"); + JSONObject operator = leg.optJSONObject("operator"); + + LinkedList foundPoints = new LinkedList<>(); + + // First stop + Stop firstStop = stopFromJSONObject(stops.getJSONObject(0)); + foundPoints.add(pointFromLocation(firstStop.location)); + + // Intermediate stops + LinkedList foundStops = new LinkedList<>(); + for (int j = 1; j < stops.length() - 1; j++) { + foundStops.add(stopFromJSONObject(stops.getJSONObject(j))); + foundPoints.add(pointFromLocation(foundStops.getLast().location)); + } + + // Last stop + Stop lastStop = stopFromJSONObject(stops.getJSONObject(stops.length() - 1)); + foundPoints.add(pointFromLocation(lastStop.location)); + + switch (leg.getString("type").toLowerCase()) { + case "scheduled": + Product lineProduct = productFromMode(mode.getString("type"), mode.getString("name")); + + StringBuilder legMessage = new StringBuilder(); + + // Add attributes to leg message + JSONArray legAttributes = leg.getJSONArray("attributes"); + for (int k = 0; k < legAttributes.length(); k++) { + JSONObject legAttribute = legAttributes.getJSONObject(k); + + if (legMessage.length() > 0) + legMessage.append(", "); + legMessage.append(WordUtils.capitalizeFirst(legAttribute.getString("title"))); + } + + // Add disturbances to leg message + if (disturbances != null) { + JSONArray legDisturbances = leg.getJSONArray("disturbancePlannerIds"); + for (int k = 0; k < legDisturbances.length(); k++) { + String legDisturbanceId = legDisturbances.optString(k); + + if (legDisturbanceId != null && disturbances.containsKey(legDisturbanceId)) { + JSONObject legDisturbance = disturbances.get(legDisturbanceId); + + if (legMessage.length() > 0) + legMessage.append("
\n
\n"); + legMessage.append(legDisturbance.getString("title")); + legMessage.append(":
\n"); + legMessage.append(legDisturbance.getString("effect")); + legMessage.append(" "); + legMessage.append(legDisturbance.getString("measure")); + } + } + } + + StringBuilder lineName = new StringBuilder(); + lineName.append(mode.getString("name")); + + // Service codes have no relevant meaning for trains + if (!leg.isNull("service") && !trainProducts.contains(lineProduct)) { + lineName.append(" "); + lineName.append(leg.getString("service")); + } + + foundLegs.add(new Trip.Public( + new Line(leg.getString("service"), (operator != null) ? operator.getString("name") : null, + lineProduct, lineName.toString(), leg.optString("service"), + Standard.STYLES.get(lineProduct), null, null), + new Location(LocationType.STATION, null, null, leg.getString("destination")), firstStop, + lastStop, foundStops, foundPoints, legMessage.length() > 0 ? legMessage.toString() : null)); + break; + case "continuous": + // Get leg time from trip or previous leg + Date legDeparture = (i == 0) ? tripDeparture : foundLegs.getLast().getArrivalTime(); + Date legArrival = ParserUtils.addMinutes(legDeparture, + ParserUtils.parseMinutesFromTimeString(leg.getString("duration"))); + + foundLegs.add(new Trip.Individual(Trip.Individual.Type.WALK, firstStop.location, legDeparture, + lastStop.location, legArrival, foundPoints, -1)); + break; + default: + throw new JSONException("Unknown leg type: " + leg.getString("type")); + } + } + + // Get journey fares + JSONObject fareInfo = trip.getJSONObject("fareInfo"); + JSONArray fareLegs = fareInfo.getJSONArray("legs"); + + Fare[] foundFares = new Fare[fareLegs.length()]; + for (int i = 0; i < fareLegs.length(); i++) { + foundFares[i] = fareFromJSONObject(fareLegs.getJSONObject(i)); + } + + return new Trip(trip.getString("id"), from, to, foundLegs, Arrays.asList(foundFares), null, + trip.getInt("numberOfChanges")); + } + + private Stop stopFromJSONObject(JSONObject stop) throws JSONException { + Position plannedPlatform = positionFromJSONObject(stop, "platform"); + Position changedPlatform = positionFromJSONObject(stop, "platformChange"); + + return new Stop(locationFromJSONObject(stop.getJSONObject("location")), dateFromJSONObject(stop, "arrival"), + dateFromJSONObject(stop, "realtimeArrival"), plannedPlatform, changedPlatform, false, + dateFromJSONObject(stop, "departure"), dateFromJSONObject(stop, "realtimeDeparture"), plannedPlatform, + changedPlatform, false); + } + + private Fare fareFromJSONObject(JSONObject fareLeg) throws JSONException { + JSONArray fares = fareLeg.getJSONArray("fares"); + + float farePrice = 0; + for (int j = 0; j < fares.length(); j++) { + JSONObject fare = fares.getJSONObject(j); + + // Always get the full non-reduced 2nd class fare price + String fareClass = fare.getString("class"); + if (!fare.getBoolean("reduced") && (fareClass.equals("none") || fareClass.equals("second"))) { + farePrice = (fare.getInt("eurocents") / 100); + break; + } + } + + return new Fare(fareLeg.getString("operatorString"), Fare.Type.ADULT, Currency.getInstance("EUR"), farePrice, + null, null); + } + + private Departure departureFromJSONObject(JSONObject departure) throws JSONException { + JSONObject mode = departure.getJSONObject("mode"); + + /* String lineName = */ departure.optString("service"); + Product lineProduct = productFromMode(mode.getString("type"), mode.getString("name")); + return new Departure(timeFromJSONObject(departure, "time"), timeFromJSONObject(departure, "time"), + new Line(null, departure.getString("operatorName"), lineProduct, + !departure.isNull("service") ? departure.getString("service") : mode.getString("name"), null, + Standard.STYLES.get(lineProduct), null, null), + !departure.isNull("platform") ? new Position(departure.getString("platform")) : null, + new Location(LocationType.STATION, null, null, departure.getString("destinationName")), null, + !departure.isNull("realtimeText") ? departure.optString("realtimeText") : null); + } + + private Position positionFromJSONObject(JSONObject obj, String key) throws JSONException { + String position = obj.getString(key); + if (position != null && !position.equals("null")) { + return new Position(position); + } else { + return null; + } + } + + private Location locationFromJSONObject(JSONObject location) throws JSONException { + return locationFromJSONObject(location, true); + } + + private Location locationFromJSONObject(JSONObject location, boolean addTypePrefix) throws JSONException { + JSONObject latlon = location.getJSONObject("latLong"); + JSONObject place = location.optJSONObject("place"); + + String locationType = location.getString("type"); + String locationName = location.getString("name"); + if (addTypePrefix && !location.isNull(locationType + "Type") && !locationType.equals("poi")) { + locationName = location.getString(locationType + "Type") + " " + locationName; + } + + Point locationPoint = Point.fromDouble(latlon.getDouble("lat"), latlon.getDouble("long")); + + return new Location(locationTypeFromTypeString(locationType), location.getString("id"), locationPoint.lat, + locationPoint.lon, !(place == null) ? place.getString("name") : null, locationName, null); + } + + private List solveAmbiguousLocation(Location location) throws IOException { + if (location.hasId()) { + return Arrays.asList(location); + } else if (location.hasLocation()) { + return queryNearbyLocations(EnumSet.of(location.type), location, -1, -1).locations; + } else if (location.hasName()) { + return queryLocationsByName(location.name, EnumSet.of(location.type)); + } else { + return null; + } + } + + private QueryTripsResult ambiguousQueryTrips(Location from, @Nullable Location via, Location to) + throws IOException { + List ambiguousFrom = solveAmbiguousLocation(from); + if (ambiguousFrom == null || ambiguousFrom.size() <= 0) + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_FROM); + + List ambiguousTo = solveAmbiguousLocation(to); + if (ambiguousTo == null || ambiguousTo.size() <= 0) + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_TO); + + List ambiguousVia = null; + if (via != null) { + ambiguousVia = solveAmbiguousLocation(via); + if (ambiguousVia == null || ambiguousVia.size() <= 0) + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_VIA); + } + + return new QueryTripsResult(this.resultHeader, ambiguousFrom, ambiguousVia, ambiguousTo); + } + + private QueryTripsResult queryTrips(HttpUrl url, Location from, @Nullable Location via, Location to) + throws IOException { + final CharSequence page; + try { + page = httpClient.get(url); + } catch (InternalErrorException e) { + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.SERVICE_DOWN); + } + + List foundTrips = new ArrayList<>(); + String tripsEarlier, tripsLater; + try { + final JSONObject head = new JSONObject(page.toString()); + + if (head.has("error")) { + switch (head.getString("error")) { + case "WithinWalkingDistance": + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.TOO_CLOSE); + case "DateOutOfRange": + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.INVALID_DATE); + case "UnknownLocations": + String errorDetails = head.getString("details"); + if (errorDetails.startsWith("From:")) { + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_FROM); + } else if (errorDetails.startsWith("Via:")) { + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_VIA); + } else if (errorDetails.startsWith("To:")) { + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_TO); + } else { + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNRESOLVABLE_ADDRESS); + } + default: + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.NO_TRIPS); + } + } + + if (head.has("exception")) { + return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.NO_TRIPS); + } + + final JSONArray trips = head.optJSONArray("journeys"); + + final JSONArray disturbances = head.optJSONArray("disturbances"); + + // Prepare disturbances mapping for leg messages + Map disturbancesMap; + if (disturbances != null && disturbances.length() > 0) { + disturbancesMap = new HashMap<>(); + for (int i = 0; i < disturbances.length(); i++) { + JSONObject disturbance = disturbances.getJSONObject(i); + disturbancesMap.put(disturbance.getString("plannerDisturbanceId"), disturbance); + } + } else { + disturbancesMap = null; + } + + tripsEarlier = head.optString("earlier"); + tripsLater = head.optString("later"); + + for (int i = 0; i < trips.length(); i++) { + JSONObject trip = trips.getJSONObject(i); + + // Skip impossible trips + if (trip.getJSONObject("realtimeInfo").getString("delays").equals("fatal")) + continue; + + foundTrips.add(tripFromJSONObject(trip, from, to, disturbancesMap)); + } + } catch (final JSONException x) { + throw new RuntimeException("cannot parse: '" + page + "' on " + url, x); + } + + return new QueryTripsResult(null, url.toString(), from, via, to, + new TripsContext(url, tripsEarlier, tripsLater, from, via, to), foundTrips); + } + + @Override + public Set defaultProducts() { + return EnumSet.of(Product.HIGH_SPEED_TRAIN, Product.REGIONAL_TRAIN, Product.SUBURBAN_TRAIN, Product.SUBWAY, + Product.TRAM, Product.BUS, Product.FERRY); + } + + @Override + protected boolean hasCapability(Capability capability) { + switch (capability) { + case SUGGEST_LOCATIONS: + case NEARBY_LOCATIONS: + case DEPARTURES: + case TRIPS: + return true; + default: + return false; + } + } + + @Override + public NearbyLocationsResult queryNearbyLocations(EnumSet types, Location location, int maxDistance, + int maxLocations) throws IOException { + // Coordinates are required + if (!location.hasLocation()) { + try { + if (location.hasId()) { + location = queryLocationById(location.id); + } else if (location.hasName()) { + location = queryLocationByName(location.name, EnumSet.of(location.type)); + } + } catch (InternalErrorException | NotFoundException | RuntimeException e) { + return new NearbyLocationsResult(this.resultHeader, NearbyLocationsResult.Status.INVALID_ID); + } catch (IOException e) { + return new NearbyLocationsResult(this.resultHeader, NearbyLocationsResult.Status.SERVICE_DOWN); + } + + if (location == null || !location.hasLocation()) { + return new NearbyLocationsResult(this.resultHeader, NearbyLocationsResult.Status.INVALID_ID); + } + } + + // Default query options + List queryParameters = new ArrayList<>(); + queryParameters.add(new QueryParameter("latlong", location.getLatAsDouble() + "," + location.getLonAsDouble())); + queryParameters.add(new QueryParameter("rows", + String.valueOf(Math.min((maxLocations <= 0) ? DEFAULT_MAX_LOCATIONS : maxLocations, 100)))); + + // Add type if specified + if (!types.contains(LocationType.ANY) && types.size() > 0) { + StringBuilder typeValue = new StringBuilder(); + for (LocationType type : types) { + for (String addition : locationStringsFromLocationType(type)) { + if (typeValue.length() > 0) + typeValue.append(","); + typeValue.append(addition); + } + } + queryParameters.add(new QueryParameter("type", typeValue.toString())); + } + HttpUrl url = buildApiUrl("locations", queryParameters); + + CharSequence page; + try { + page = httpClient.get(url); + } catch (InternalErrorException e) { + return new NearbyLocationsResult(this.resultHeader, NearbyLocationsResult.Status.SERVICE_DOWN); + } + + // Parse result into location list + final List foundLocations = new ArrayList<>(); + try { + final JSONObject head = new JSONObject(page.toString()); + final JSONArray locations = head.optJSONArray("locations"); + + for (int i = 0; i < locations.length(); i++) { + foundLocations.add(locationFromJSONObject(locations.getJSONObject(i))); + } + } catch (final JSONException x) { + throw new RuntimeException("cannot parse: '" + page + "' on " + url, x); + } + + return new NearbyLocationsResult(new ResultHeader(network, SERVER_PRODUCT), foundLocations); + } + + @Override + public QueryDeparturesResult queryDepartures(String stationId, @Nullable Date time, int maxDepartures, + boolean equivs) throws IOException { + // The stationId does not need the / character escaped + HttpUrl url = buildApiUrl("locations/" + stationId + "/departure-times", new ArrayList()); + final CharSequence page; + try { + page = httpClient.get(url); + } catch (InternalErrorException | NotFoundException e) { + return new QueryDeparturesResult(this.resultHeader, QueryDeparturesResult.Status.INVALID_STATION); + } catch (Exception e) { + return new QueryDeparturesResult(this.resultHeader, QueryDeparturesResult.Status.SERVICE_DOWN); + } + + QueryDeparturesResult queryDeparturesResult = new QueryDeparturesResult(this.resultHeader); + try { + JSONObject head = new JSONObject(page.toString()); + JSONArray tabs = head.getJSONArray("tabs"); + for (int t = 0; t < tabs.length(); t++) { + JSONObject tab = tabs.getJSONObject(t); + + JSONArray locations = tab.getJSONArray("locations"); + for (int l = 0; l < locations.length(); l++) { + JSONObject location = locations.getJSONObject(l); + + // Ignore if equivs is false and stationId is not a strict match + if (!equivs && !location.getString("id").equals(stationId)) { + continue; + } + + // Get list of departures + List departuresResult = new ArrayList<>(); + List lineDestinationResult = new ArrayList<>(); + + JSONArray departures = tab.getJSONArray("departures"); + for (int i = 0; i < departures.length(); i++) { + JSONObject departure = departures.getJSONObject(i); + JSONObject mode = departure.getJSONObject("mode"); + + departuresResult.add(departureFromJSONObject(departure)); + + Product lineProduct = productFromMode(mode.getString("type"), mode.getString("name")); + lineDestinationResult.add(new LineDestination( + new Line(null, departure.getString("operatorName"), lineProduct, mode.getString("name"), + null, Standard.STYLES.get(lineProduct), null, null), + new Location(LocationType.STATION, null, 0, 0, null, + departure.getString("destinationName"), EnumSet.of(lineProduct)))); + } + + // Add to result object + queryDeparturesResult.stationDepartures.add(new StationDepartures(locationFromJSONObject(location), + departuresResult, lineDestinationResult)); + } + } + + return queryDeparturesResult; + } catch (final JSONException x) { + throw new RuntimeException("cannot parse: '" + page + "' on " + url, x); + } + } + + @Override + public SuggestLocationsResult suggestLocations(CharSequence constraint) throws IOException { + HttpUrl url = buildApiUrl("locations", Arrays.asList(new QueryParameter("q", constraint.toString()))); + final CharSequence page; + try { + page = httpClient.get(url); + } catch (InternalErrorException e) { + return new SuggestLocationsResult(this.resultHeader, SuggestLocationsResult.Status.SERVICE_DOWN); + } + + final List foundLocations = new ArrayList<>(); + try { + final JSONObject head = new JSONObject(page.toString()); + final JSONArray locations = head.optJSONArray("locations"); + + if (head.has("error")) { + return new SuggestLocationsResult(this.resultHeader, SuggestLocationsResult.Status.SERVICE_DOWN); + } + + for (int i = 0; i < locations.length(); i++) { + JSONObject location = locations.getJSONObject(i); + + foundLocations.add(new SuggestedLocation(locationFromJSONObject(location))); + } + } catch (final JSONException x) { + throw new RuntimeException("cannot parse: '" + page + "' on " + url, x); + } + + return new SuggestLocationsResult(this.resultHeader, foundLocations); + } + + @Override + public QueryTripsResult queryTrips(Location from, @Nullable Location via, Location to, Date date, boolean dep, + @Nullable Set products, @Nullable Optimize optimize, @Nullable WalkSpeed walkSpeed, + @Nullable Accessibility accessibility, @Nullable Set