diff --git a/enabler/src/de/schildbach/pte/AbstractHafasLegacyProvider.java b/enabler/src/de/schildbach/pte/AbstractHafasLegacyProvider.java new file mode 100644 index 00000000..33724673 --- /dev/null +++ b/enabler/src/de/schildbach/pte/AbstractHafasLegacyProvider.java @@ -0,0 +1,3044 @@ +/* + * Copyright 2010-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 static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.EnumSet; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; + +import javax.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import com.google.common.base.Charsets; +import com.google.common.base.Strings; + +import de.schildbach.pte.dto.Departure; +import de.schildbach.pte.dto.Line; +import de.schildbach.pte.dto.Line.Attr; +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.ParserException; +import de.schildbach.pte.exception.SessionExpiredException; +import de.schildbach.pte.util.HttpClient; +import de.schildbach.pte.util.LittleEndianDataInputStream; +import de.schildbach.pte.util.ParserUtils; +import de.schildbach.pte.util.StringReplaceReader; +import de.schildbach.pte.util.XmlPullUtil; + +import okhttp3.HttpUrl; +import okhttp3.ResponseBody; + +/** + * @author Andreas Schildbach + */ +public abstract class AbstractHafasLegacyProvider extends AbstractHafasProvider { + private static final String REQC_PROD = "hafas"; + + protected HttpUrl stationBoardEndpoint; + protected HttpUrl getStopEndpoint; + protected HttpUrl queryEndpoint; + private @Nullable HttpUrl extXmlEndpoint = null; + protected final String apiLanguage; + private @Nullable String accessId = null; + private @Nullable String clientType = "ANDROID"; + private boolean jsonGetStopsUseWeight = true; + private Charset jsonNearbyLocationsEncoding = Charsets.ISO_8859_1; + private boolean dominantPlanStopTime = false; + private boolean useIso8601 = false; + private boolean stationBoardHasStationTable = true; + private boolean stationBoardHasLocation = false; + private boolean stationBoardCanDoEquivs = true; + + @SuppressWarnings("serial") + private static class Context implements QueryTripsContext { + public final String laterContext; + public final String earlierContext; + public final int sequence; + + public Context(final String laterContext, final String earlierContext, final int sequence) { + this.laterContext = laterContext; + this.earlierContext = earlierContext; + this.sequence = sequence; + } + + @Override + public boolean canQueryLater() { + return laterContext != null; + } + + @Override + public boolean canQueryEarlier() { + return earlierContext != null; + } + } + + @SuppressWarnings("serial") + public static class QueryTripsBinaryContext implements QueryTripsContext { + public final String ident; + public final int seqNr; + public final String ld; + public final int usedBufferSize; + private final boolean canQueryMore; + + public QueryTripsBinaryContext(final String ident, final int seqNr, final String ld, final int usedBufferSize, + final boolean canQueryMore) { + this.ident = ident; + this.seqNr = seqNr; + this.ld = ld; + this.usedBufferSize = usedBufferSize; + this.canQueryMore = canQueryMore; + } + + @Override + public boolean canQueryLater() { + return canQueryMore; + } + + @Override + public boolean canQueryEarlier() { + return canQueryMore; + } + } + + public AbstractHafasLegacyProvider(final NetworkId network, final HttpUrl apiBase, final String apiLanguage, + final Product[] productsMap) { + super(network, productsMap); + this.stationBoardEndpoint = apiBase.newBuilder().addPathSegment("stboard.exe").build(); + this.getStopEndpoint = apiBase.newBuilder().addPathSegment("ajax-getstop.exe").build(); + this.queryEndpoint = apiBase.newBuilder().addPathSegment("query.exe").build(); + this.apiLanguage = apiLanguage; + } + + protected AbstractHafasProvider setStationBoardEndpoint(final HttpUrl stationBoardEndpoint) { + this.stationBoardEndpoint = stationBoardEndpoint; + return this; + } + + protected AbstractHafasProvider setGetStopEndpoint(final HttpUrl getStopEndpoint) { + this.getStopEndpoint = getStopEndpoint; + return this; + } + + protected AbstractHafasProvider setQueryEndpoint(final HttpUrl queryEndpoint) { + this.queryEndpoint = queryEndpoint; + return this; + } + + protected AbstractHafasProvider setExtXmlEndpoint(final HttpUrl extXmlEndpoint) { + this.extXmlEndpoint = extXmlEndpoint; + return this; + } + + protected AbstractHafasProvider setAccessId(final String accessId) { + this.accessId = accessId; + return this; + } + + protected AbstractHafasProvider setClientType(final String clientType) { + this.clientType = clientType; + return this; + } + + protected AbstractHafasProvider setDominantPlanStopTime(final boolean dominantPlanStopTime) { + this.dominantPlanStopTime = dominantPlanStopTime; + return this; + } + + protected AbstractHafasProvider setJsonGetStopsUseWeight(final boolean jsonGetStopsUseWeight) { + this.jsonGetStopsUseWeight = jsonGetStopsUseWeight; + return this; + } + + protected AbstractHafasProvider setJsonNearbyLocationsEncoding(final Charset jsonNearbyLocationsEncoding) { + this.jsonNearbyLocationsEncoding = jsonNearbyLocationsEncoding; + return this; + } + + protected AbstractHafasProvider setUseIso8601(final boolean useIso8601) { + this.useIso8601 = useIso8601; + return this; + } + + protected AbstractHafasProvider setStationBoardHasStationTable(final boolean stationBoardHasStationTable) { + this.stationBoardHasStationTable = stationBoardHasStationTable; + return this; + } + + protected AbstractHafasProvider setStationBoardHasLocation(final boolean stationBoardHasLocation) { + this.stationBoardHasLocation = stationBoardHasLocation; + return this; + } + + protected AbstractHafasProvider setStationBoardCanDoEquivs(final boolean canDoEquivs) { + this.stationBoardCanDoEquivs = canDoEquivs; + return this; + } + + private final String wrapReqC(final CharSequence request, final Charset encoding) { + return "" // + + "" // + + request // + + ""; + } + + private final Location parseStation(final XmlPullParser pp) { + final String type = pp.getName(); + if ("Station".equals(type)) { + final String name = XmlPullUtil.attr(pp, "name"); + final String id = XmlPullUtil.attr(pp, "externalStationNr"); + final int x = XmlPullUtil.intAttr(pp, "x"); + final int y = XmlPullUtil.intAttr(pp, "y"); + + final String[] placeAndName = splitStationName(name); + return new Location(LocationType.STATION, id, y, x, placeAndName[0], placeAndName[1]); + } + throw new IllegalStateException("cannot handle: " + type); + } + + private static final Location parsePoi(final XmlPullParser pp) { + final String type = pp.getName(); + if ("Poi".equals(type)) { + String name = XmlPullUtil.attr(pp, "name"); + if (name.equals("unknown")) + name = null; + final int x = XmlPullUtil.intAttr(pp, "x"); + final int y = XmlPullUtil.intAttr(pp, "y"); + return new Location(LocationType.POI, null, y, x, null, name); + } + throw new IllegalStateException("cannot handle: " + type); + } + + private final Location parseAddress(final XmlPullParser pp) { + final String type = pp.getName(); + if ("Address".equals(type)) { + String name = XmlPullUtil.attr(pp, "name"); + if (name.equals("unknown")) + name = null; + final int x = XmlPullUtil.intAttr(pp, "x"); + final int y = XmlPullUtil.intAttr(pp, "y"); + + final String[] placeAndName = splitAddress(name); + return new Location(LocationType.ADDRESS, null, y, x, placeAndName[0], placeAndName[1]); + } + throw new IllegalStateException("cannot handle: " + type); + } + + private final Position parsePlatform(final XmlPullParser pp) throws XmlPullParserException, IOException { + XmlPullUtil.enter(pp, "Platform"); + final String platformText = XmlPullUtil.valueTag(pp, "Text"); + XmlPullUtil.skipExit(pp, "Platform"); + + if (platformText == null || platformText.length() == 0) + return null; + else + return parsePosition(platformText); + } + + @Override + public SuggestLocationsResult suggestLocations(final CharSequence constraint) throws IOException { + final HttpUrl.Builder url = getStopEndpoint.newBuilder().addPathSegment(apiLanguage); + appendJsonGetStopsParameters(url, checkNotNull(constraint), 0); + return jsonGetStops(url.build()); + } + + protected void appendJsonGetStopsParameters(final HttpUrl.Builder url, final CharSequence constraint, + final int maxStops) { + url.addQueryParameter("getstop", "1"); + url.addQueryParameter("REQ0JourneyStopsS0A", "255"); + url.addEncodedQueryParameter("REQ0JourneyStopsS0G", + ParserUtils.urlEncode(constraint.toString() + "?", requestUrlEncoding)); + if (maxStops > 0) + url.addQueryParameter("REQ0JourneyStopsB", Integer.toString(maxStops)); + url.addQueryParameter("js", "true"); + } + + private static final Pattern P_AJAX_GET_STOPS_JSON = Pattern + .compile("SLs\\.sls\\s*=\\s*(.*?);\\s*SLs\\.showSuggestion\\(\\);", Pattern.DOTALL); + private static final Pattern P_AJAX_GET_STOPS_ID = Pattern.compile(".*?@L=0*(\\d+)@.*?"); + + protected final SuggestLocationsResult jsonGetStops(final HttpUrl url) throws IOException { + final CharSequence page = httpClient.get(url); + + final Matcher mJson = P_AJAX_GET_STOPS_JSON.matcher(page); + if (mJson.matches()) { + final String json = mJson.group(1); + final List locations = new ArrayList(); + + try { + final JSONObject head = new JSONObject(json); + final JSONArray aSuggestions = head.getJSONArray("suggestions"); + + for (int i = 0; i < aSuggestions.length(); i++) { + final JSONObject suggestion = aSuggestions.optJSONObject(i); + if (suggestion != null) { + final int type = suggestion.getInt("type"); + final String value = suggestion.getString("value"); + final int lat = suggestion.optInt("ycoord"); + final int lon = suggestion.optInt("xcoord"); + final int weight = jsonGetStopsUseWeight ? suggestion.getInt("weight") : -i; + String localId = null; + final Matcher m = P_AJAX_GET_STOPS_ID.matcher(suggestion.getString("id")); + if (m.matches()) + localId = m.group(1); + + final Location location; + + if (type == 1) // station + { + final String[] placeAndName = splitStationName(value); + location = new Location(LocationType.STATION, localId, lat, lon, placeAndName[0], + placeAndName[1]); + } else if (type == 2) // address + { + final String[] placeAndName = splitAddress(value); + location = new Location(LocationType.ADDRESS, null, lat, lon, placeAndName[0], + placeAndName[1]); + } else if (type == 4) // poi + { + final String[] placeAndName = splitPOI(value); + location = new Location(LocationType.POI, localId, lat, lon, placeAndName[0], + placeAndName[1]); + } else if (type == 128) // crossing + { + final String[] placeAndName = splitAddress(value); + location = new Location(LocationType.ADDRESS, localId, lat, lon, placeAndName[0], + placeAndName[1]); + } else if (type == 87) { + location = null; + // don't know what to do + } else { + throw new IllegalStateException("unknown type " + type + " on " + url); + } + + if (location != null) { + final SuggestedLocation suggestedLocation = new SuggestedLocation(location, weight); + locations.add(suggestedLocation); + } + } + } + + return new SuggestLocationsResult(new ResultHeader(network, SERVER_PRODUCT), locations); + } catch (final JSONException x) { + throw new RuntimeException("cannot parse: '" + json + "' on " + url, x); + } + } else { + throw new RuntimeException("cannot parse: '" + page + "' on " + url); + } + } + + @Override + public QueryDeparturesResult queryDepartures(final String stationId, final @Nullable Date time, + final int maxDepartures, final boolean equivs) throws IOException { + checkNotNull(Strings.emptyToNull(stationId)); + + final HttpUrl.Builder url = stationBoardEndpoint.newBuilder().addPathSegment(apiLanguage); + appendXmlStationBoardParameters(url, time, stationId, maxDepartures, equivs, "vs_java3"); + return xmlStationBoard(url.build(), stationId); + } + + protected void appendXmlStationBoardParameters(final HttpUrl.Builder url, final @Nullable Date time, + final String stationId, final int maxDepartures, final boolean equivs, final @Nullable String styleSheet) { + url.addQueryParameter("productsFilter", allProductsString().toString()); + url.addQueryParameter("boardType", "dep"); + if (stationBoardCanDoEquivs) + url.addQueryParameter("disableEquivs", equivs ? "0" : "1"); + url.addQueryParameter("maxJourneys", + Integer.toString(maxDepartures > 0 ? maxDepartures : DEFAULT_MAX_DEPARTURES)); + url.addEncodedQueryParameter("input", ParserUtils.urlEncode(normalizeStationId(stationId), requestUrlEncoding)); + appendDateTimeParameters(url, time, "date", "time"); + if (clientType != null) + url.addEncodedQueryParameter("clientType", ParserUtils.urlEncode(clientType, requestUrlEncoding)); + if (styleSheet != null) + url.addQueryParameter("L", styleSheet); + url.addQueryParameter("hcount", "0"); // prevents showing old departures + url.addQueryParameter("start", "yes"); + } + + protected void appendDateTimeParameters(final HttpUrl.Builder url, final Date time, final String dateParamName, + final String timeParamName) { + final Calendar c = new GregorianCalendar(timeZone); + c.setTime(time); + final int year = c.get(Calendar.YEAR); + final int month = c.get(Calendar.MONTH) + 1; + final int day = c.get(Calendar.DAY_OF_MONTH); + final int hour = c.get(Calendar.HOUR_OF_DAY); + final int minute = c.get(Calendar.MINUTE); + url.addEncodedQueryParameter(dateParamName, + ParserUtils.urlEncode( + useIso8601 ? String.format(Locale.ENGLISH, "%04d-%02d-%02d", year, month, day) + : String.format(Locale.ENGLISH, "%02d.%02d.%02d", day, month, year - 2000), + requestUrlEncoding)); + url.addEncodedQueryParameter(timeParamName, + ParserUtils.urlEncode(String.format(Locale.ENGLISH, "%02d:%02d", hour, minute), requestUrlEncoding)); + } + + private static final Pattern P_XML_STATION_BOARD_DELAY = Pattern.compile("(?:-|k\\.A\\.?|cancel|([+-]?\\s*\\d+))"); + + protected final QueryDeparturesResult xmlStationBoard(final HttpUrl url, final String stationId) + throws IOException { + final String normalizedStationId = normalizeStationId(stationId); + final AtomicReference result = new AtomicReference(); + + httpClient.getInputStream(new HttpClient.Callback() { + + @Override + public void onSuccessful(final CharSequence bodyPeek, final ResponseBody body) throws IOException { + StringReplaceReader reader = null; + String firstChars = null; + + // work around unparsable XML + reader = new StringReplaceReader(body.charStream(), " & ", " & "); + reader.replace("", " "); + reader.replace("", " "); + reader.replace("", " "); + reader.replace("", " "); + reader.replace("", " "); + reader.replace("", " "); + reader.replace("
", " "); + reader.replace(" ->", " →"); // right arrow + reader.replace(" <-", " ←"); // left arrow + reader.replace(" <> ", " ↔ "); // left-right arrow + addCustomReplaces(reader); + + try { + final XmlPullParserFactory factory = XmlPullParserFactory + .newInstance(System.getProperty(XmlPullParserFactory.PROPERTY_NAME), null); + final XmlPullParser pp = factory.newPullParser(); + pp.setInput(reader); + + pp.nextTag(); + + final ResultHeader header = new ResultHeader(network, SERVER_PRODUCT); + final QueryDeparturesResult r = new QueryDeparturesResult(header); + + if (XmlPullUtil.test(pp, "Err")) { + final String code = XmlPullUtil.attr(pp, "code"); + final String text = XmlPullUtil.attr(pp, "text"); + + if (code.equals("H730")) { + result.set(new QueryDeparturesResult(header, QueryDeparturesResult.Status.INVALID_STATION)); + return; + } + if (code.equals("H890")) { + r.stationDepartures + .add(new StationDepartures(new Location(LocationType.STATION, normalizedStationId), + Collections. emptyList(), null)); + result.set(r); + return; + } + throw new IllegalArgumentException("unknown error " + code + ", " + text); + } + + String[] stationPlaceAndName = null; + + if (stationBoardHasStationTable) + XmlPullUtil.enter(pp, "StationTable"); + else + checkState(!XmlPullUtil.test(pp, "StationTable")); + + if (stationBoardHasLocation) { + XmlPullUtil.require(pp, "St"); + + final String evaId = XmlPullUtil.attr(pp, "evaId"); + if (evaId != null) { + if (!evaId.equals(normalizedStationId)) + throw new IllegalStateException( + "stationId: " + normalizedStationId + ", evaId: " + evaId); + + final String name = XmlPullUtil.attr(pp, "name"); + if (name != null) + stationPlaceAndName = splitStationName(name.trim()); + } + XmlPullUtil.requireSkip(pp, "St"); + } else { + checkState(!XmlPullUtil.test(pp, "St")); + } + + while (XmlPullUtil.test(pp, "Journey")) { + final String fpTime = XmlPullUtil.attr(pp, "fpTime"); + final String fpDate = XmlPullUtil.attr(pp, "fpDate"); + final String delay = XmlPullUtil.attr(pp, "delay"); + final String eDelay = XmlPullUtil.optAttr(pp, "e_delay", null); + final String platform = XmlPullUtil.optAttr(pp, "platform", null); + // TODO newpl + final String targetLoc = XmlPullUtil.optAttr(pp, "targetLoc", null); + // TODO hafasname + final String dirnr = XmlPullUtil.optAttr(pp, "dirnr", null); + final String prod = XmlPullUtil.attr(pp, "prod"); + final String classStr = XmlPullUtil.optAttr(pp, "class", null); + final String dir = XmlPullUtil.optAttr(pp, "dir", null); + final String capacityStr = XmlPullUtil.optAttr(pp, "capacity", null); + final String depStation = XmlPullUtil.optAttr(pp, "depStation", null); + final String delayReason = XmlPullUtil.optAttr(pp, "delayReason", null); + // TODO is_reachable + // TODO disableTrainInfo + // TODO lineFG/lineBG (ZVV) + final String administration = normalizeLineAdministration( + XmlPullUtil.optAttr(pp, "administration", null)); + + if (!"cancel".equals(delay) && !"cancel".equals(eDelay)) { + final Calendar plannedTime = new GregorianCalendar(timeZone); + plannedTime.clear(); + ParserUtils.parseEuropeanTime(plannedTime, fpTime); + if (fpDate.length() == 8) + ParserUtils.parseGermanDate(plannedTime, fpDate); + else if (fpDate.length() == 10) + ParserUtils.parseIsoDate(plannedTime, fpDate); + else + throw new IllegalStateException("cannot parse: '" + fpDate + "'"); + + final Calendar predictedTime; + if (eDelay != null) { + predictedTime = new GregorianCalendar(timeZone); + predictedTime.setTimeInMillis(plannedTime.getTimeInMillis()); + predictedTime.add(Calendar.MINUTE, Integer.parseInt(eDelay)); + } else if (delay != null) { + final Matcher m = P_XML_STATION_BOARD_DELAY.matcher(delay); + if (m.matches()) { + if (m.group(1) != null) { + predictedTime = new GregorianCalendar(timeZone); + predictedTime.setTimeInMillis(plannedTime.getTimeInMillis()); + predictedTime.add(Calendar.MINUTE, Integer.parseInt(m.group(1))); + } else { + predictedTime = null; + } + } else { + throw new RuntimeException("cannot parse delay: '" + delay + "'"); + } + } else { + predictedTime = null; + } + + final Position position = parsePosition(ParserUtils.resolveEntities(platform)); + + final String destinationName; + if (dir != null) + destinationName = dir.trim(); + else if (targetLoc != null) + destinationName = targetLoc.trim(); + else + destinationName = null; + + final Location destination; + if (dirnr != null) { + final String[] destinationPlaceAndName = splitStationName(destinationName); + destination = new Location(LocationType.STATION, dirnr, destinationPlaceAndName[0], + destinationPlaceAndName[1]); + } else { + destination = new Location(LocationType.ANY, null, null, destinationName); + } + + final Line prodLine = parseLineAndType(prod); + final Line line; + if (classStr != null) { + final Product product = intToProduct(Integer.parseInt(classStr)); + if (product == null) + throw new IllegalArgumentException(); + // could check for type consistency here + final Set attrs = prodLine.attrs; + if (attrs != null) + line = newLine(administration, product, prodLine.label, null, + attrs.toArray(new Line.Attr[0])); + else + line = newLine(administration, product, prodLine.label, null); + } else { + final Set attrs = prodLine.attrs; + if (attrs != null) + line = newLine(administration, prodLine.product, prodLine.label, null, + attrs.toArray(new Line.Attr[0])); + else + line = newLine(administration, prodLine.product, prodLine.label, null); + } + + final int[] capacity; + if (capacityStr != null && !"0|0".equals(capacityStr)) { + final String[] capacityParts = capacityStr.split("\\|"); + capacity = new int[] { Integer.parseInt(capacityParts[0]), + Integer.parseInt(capacityParts[1]) }; + } else { + capacity = null; + } + + final String message; + if (delayReason != null) { + final String msg = delayReason.trim(); + message = msg.length() > 0 ? msg : null; + } else { + message = null; + } + + final Departure departure = new Departure(plannedTime.getTime(), + predictedTime != null ? predictedTime.getTime() : null, line, position, destination, + capacity, message); + + final Location location; + if (!stationBoardCanDoEquivs || depStation == null) { + location = new Location(LocationType.STATION, normalizedStationId, + stationPlaceAndName != null ? stationPlaceAndName[0] : null, + stationPlaceAndName != null ? stationPlaceAndName[1] : null); + } else { + final String[] depPlaceAndName = splitStationName(depStation); + location = new Location(LocationType.STATION, null, depPlaceAndName[0], + depPlaceAndName[1]); + } + + StationDepartures stationDepartures = findStationDepartures(r.stationDepartures, location); + if (stationDepartures == null) { + stationDepartures = new StationDepartures(location, new ArrayList(8), null); + r.stationDepartures.add(stationDepartures); + } + + stationDepartures.departures.add(departure); + } + + XmlPullUtil.requireSkip(pp, "Journey"); + } + + if (stationBoardHasStationTable) + XmlPullUtil.exit(pp, "StationTable"); + + XmlPullUtil.requireEndDocument(pp); + + // sort departures + for (final StationDepartures stationDepartures : r.stationDepartures) + Collections.sort(stationDepartures.departures, Departure.TIME_COMPARATOR); + + result.set(r); + } catch (final XmlPullParserException x) { + throw new ParserException("cannot parse xml: " + firstChars, x); + } + } + }, url); + + return result.get(); + } + + protected void addCustomReplaces(final StringReplaceReader reader) { + } + + @Override + public QueryTripsResult queryTrips(final Location from, final @Nullable Location via, final Location to, + final Date date, final boolean dep, final @Nullable Set products, + final @Nullable Optimize optimize, final @Nullable WalkSpeed walkSpeed, + final @Nullable Accessibility accessibility, final @Nullable Set