diff --git a/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt b/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt index eb737e73..b6814f64 100644 --- a/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt +++ b/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt @@ -26,6 +26,7 @@ object TimeUtilsK { fun Long.millisToInstant() = Instant.fromEpochMilliseconds(this) fun Long.secsToInstant() = Instant.fromEpochSeconds(this) +fun Int.secsToInstant() = this.toLong().secsToInstant() fun Instant.toMillis() = this.toEpochMilliseconds() diff --git a/src/main/java/org/mtransit/android/commons/data/POIStatus.java b/src/main/java/org/mtransit/android/commons/data/POIStatus.java index c14e28dc..43ea7ce2 100644 --- a/src/main/java/org/mtransit/android/commons/data/POIStatus.java +++ b/src/main/java/org/mtransit/android/commons/data/POIStatus.java @@ -48,9 +48,9 @@ protected static int getDefaultStatusTextColor(@NonNull Context context) { private final int type; private final long lastUpdateInMs; private final long maxValidityInMs; - private final long readFromSourceAtInMs; + private long readFromSourceAtInMs; @Nullable - private final String sourceLabel; + private String sourceLabel; private final boolean noData; public POIStatus( @@ -181,6 +181,10 @@ public String getSourceLabel() { return this.sourceLabel; } + public void setSourceLabel(@Nullable String sourceLabel) { + this.sourceLabel = sourceLabel; + } + @Nullable private String getExtrasJSONString() { try { @@ -232,4 +236,8 @@ public long getMaxValidityInMs() { public long getReadFromSourceAtInMs() { return readFromSourceAtInMs; } + + public void setReadFromSourceAtInMs(long readFromSourceAtInMs) { + this.readFromSourceAtInMs = readFromSourceAtInMs; + } } diff --git a/src/main/java/org/mtransit/android/commons/data/Schedule.java b/src/main/java/org/mtransit/android/commons/data/Schedule.java index 0b288924..2697d73d 100644 --- a/src/main/java/org/mtransit/android/commons/data/Schedule.java +++ b/src/main/java/org/mtransit/android/commons/data/Schedule.java @@ -45,7 +45,7 @@ public String getLogTag() { @NonNull private final List timestamps = new ArrayList<>(); - private final long providerPrecisionInMs; + private long providerPrecisionInMs; private long usefulUntilInMs = -1L; @@ -68,10 +68,17 @@ public Schedule(@NonNull POIStatus status, long providerPrecisionInMs, boolean n ); } - public Schedule(@Nullable Integer id, @NonNull String targetUUID, - long lastUpdateInMs, long maxValidityInMs, - long readFromSourceAtInMs, long providerPrecisionInMs, - boolean noPickup, @Nullable String sourceLabel, boolean noData) { + public Schedule( + @Nullable Integer id, + @NonNull String targetUUID, + long lastUpdateInMs, + long maxValidityInMs, + long readFromSourceAtInMs, + long providerPrecisionInMs, + boolean noPickup, + @Nullable String sourceLabel, + boolean noData + ) { super(id, targetUUID, POI.ITEM_STATUS_TYPE_SCHEDULE, lastUpdateInMs, maxValidityInMs, readFromSourceAtInMs, sourceLabel, noData); this.noPickup = noPickup; this.providerPrecisionInMs = providerPrecisionInMs; @@ -86,6 +93,10 @@ public long getProviderPrecisionInMs() { return providerPrecisionInMs; } + public void setProviderPrecisionInMs(long providerPrecisionInMs) { + this.providerPrecisionInMs = providerPrecisionInMs; + } + @NonNull @Override public String toString() { @@ -230,6 +241,10 @@ public void setTimestampsAndSort(@NonNull List timestamps) { sortTimestamps(); } + public boolean removeTimestamp(@NonNull Timestamp timestamp) { + return this.timestamps.remove(timestamp); + } + public void sortTimestamps() { CollectionUtils.sort(this.timestamps, TIMESTAMPS_COMPARATOR); resetUsefulUntilInMs(); @@ -267,7 +282,7 @@ private void resetUsefulUntilInMs() { this.usefulUntilInMs = 0L; // NOT USEFUL return; } - this.usefulUntilInMs = this.timestamps.get(timestampsCount - 1).t + getUIProviderPrecisionInMs(); + this.usefulUntilInMs = this.timestamps.get(timestampsCount - 1).getDepartureT() + getUIProviderPrecisionInMs(); } private long getUsefulUntilInMs() { @@ -286,8 +301,8 @@ public boolean isUseful() { private static class TimestampComparator implements Comparator { @Override public int compare(Timestamp lhs, Timestamp rhs) { - long lt = lhs == null ? 0L : lhs.t; - long rt = rhs == null ? 0L : rhs.t; + long lt = lhs == null ? 0L : lhs.getDepartureT(); + long rt = rhs == null ? 0L : rhs.getDepartureT(); return (int) (lt - rt); } } @@ -411,7 +426,7 @@ public static JSONObject toJSON(@NonNull Frequency frequency) { } } - public static class Timestamp implements MTLog.Loggable { + public static class Timestamp implements MTLog.Loggable { // Stop Time private static final String LOG_TAG = Timestamp.class.getSimpleName(); @@ -421,7 +436,7 @@ public String getLogTag() { return LOG_TAG; } - public final long t; + private long departureInMs; @Direction.HeadSignType private int headsignType = Direction.HEADSIGN_TYPE_NONE; @Nullable @@ -435,44 +450,46 @@ public String getLogTag() { @Nullable private Integer accessible = null; @Nullable - private String tripId = null; // will store trip ID int initially but replaced with real trip ID soon after + private String tripId = null; // cleaned trip ID (string) // initial used to store trip id INT but replaced after private int stopSequence = -1; @Nullable private Long arrivalDiffMs = null; @VisibleForTesting - public Timestamp(long t) { - this.t = t; + public Timestamp(long departureT) { + this.departureInMs = departureT; } - public Timestamp(long t, @NonNull TimeZone localTimeZone) { - this(t, localTimeZone.getID()); + public Timestamp(long departureT, @NonNull TimeZone localTimeZone) { + this(departureT, localTimeZone.getID()); } - public Timestamp(long t, @NonNull String localTimeZoneId) { - this.t = t; + public Timestamp(long departureT, @NonNull String localTimeZoneId) { + this.departureInMs = departureT; this.localTimeZoneId = localTimeZoneId; } - public long getT() { - return t; + public long getDepartureT() { + return this.departureInMs; } - public long getDepartureT() { - return t; + public void setDepartureT(long departureT) { + final long originalArrivalT = getArrivalT(); // stored as diff -> do not change + this.departureInMs = departureT; + setArrivalT(originalArrivalT); // stored as diff -> do not change } public long getArrivalT() { - return t + (arrivalDiffMs == null ? 0L : arrivalDiffMs); + return getDepartureT() - (arrivalDiffMs == null ? 0L : arrivalDiffMs); } @Nullable public Long getArrivalTIfDifferent() { - return arrivalDiffMs == null ? null : t + arrivalDiffMs; + return arrivalDiffMs == null ? null : getDepartureT() - arrivalDiffMs; } - public void setArrivalTimestamp(long arrivalTimestamp) { - setArrivalDiffMs(arrivalTimestamp - this.t); + public void setArrivalT(long arrivalT) { + setArrivalDiffMs(getDepartureT() - arrivalT); } public void setArrivalDiffMs(@Nullable Long arrivalDiffMs) { @@ -657,7 +674,7 @@ public boolean equals(Object o) { Timestamp timestamp = (Timestamp) o; - if (t != timestamp.t) return false; + if (departureInMs != timestamp.departureInMs) return false; if (headsignType != timestamp.headsignType) return false; if (!Objects.equals(headsignValue, timestamp.headsignValue)) return false; if (!Objects.equals(localTimeZoneId, timestamp.localTimeZoneId)) return false; @@ -673,7 +690,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - int result = Long.hashCode(t); + int result = Long.hashCode(departureInMs); result = 31 * result + headsignType; result = 31 * result + (headsignValue != null ? headsignValue.hashCode() : 0); result = 31 * result + (localTimeZoneId != null ? localTimeZoneId.hashCode() : 0); @@ -692,7 +709,7 @@ public int hashCode() { public String toString() { StringBuilder sb = new StringBuilder(Timestamp.class.getSimpleName()); sb.append('{'); - sb.append("t=").append(Constants.DEBUG ? MTLog.formatDateTime(t) : t); + sb.append("t=").append(Constants.DEBUG ? MTLog.formatDateTime(getDepartureT()) : getDepartureT()); if (arrivalDiffMs != null) { sb.append(", aD:").append(arrivalDiffMs); } @@ -724,7 +741,7 @@ public String toString() { return sb.toString(); } - private static final String JSON_TIMESTAMP = "t"; + private static final String JSON_DEPARTURE = "t"; private static final String JSON_ARRIVAL_DIFF = "tDiffA"; private static final String JSON_TRIP_ID = "trip_id"; private static final String JSON_STOP_SEQUENCE = "stop_seq"; @@ -738,8 +755,8 @@ public String toString() { @Nullable static Timestamp parseJSON(@NonNull JSONObject jTimestamp) { try { - final long t = jTimestamp.getLong(JSON_TIMESTAMP); - final Timestamp timestamp = new Timestamp(t); + final long departureInMs = jTimestamp.getLong(JSON_DEPARTURE); + final Timestamp timestamp = new Timestamp(departureInMs); if (jTimestamp.has(JSON_ARRIVAL_DIFF)) { timestamp.setArrivalDiffMs(jTimestamp.getLong(JSON_ARRIVAL_DIFF)); } @@ -787,7 +804,7 @@ public JSONObject toJSON() { public static JSONObject toJSON(@NonNull Timestamp timestamp) { try { JSONObject jTimestamp = new JSONObject(); - jTimestamp.put(JSON_TIMESTAMP, timestamp.t); + jTimestamp.put(JSON_DEPARTURE, timestamp.departureInMs); if (timestamp.arrivalDiffMs != null) { jTimestamp.put(JSON_ARRIVAL_DIFF, timestamp.arrivalDiffMs); } @@ -856,7 +873,11 @@ public String getLogTag() { private Integer maxDataRequests = null; public ScheduleStatusFilter(@NonNull String targetUUID, @NonNull RouteDirectionStop rds) { - super(POI.ITEM_STATUS_TYPE_SCHEDULE, targetUUID); + this(rds); + } + + public ScheduleStatusFilter(@NonNull RouteDirectionStop rds) { + super(POI.ITEM_STATUS_TYPE_SCHEDULE, rds.getUUID()); this.routeDirectionStop = rds; } @@ -865,6 +886,19 @@ public RouteDirectionStop getRouteDirectionStop() { return routeDirectionStop; } + @NonNull + public String getTargetAuthority() { + return this.routeDirectionStop.getAuthority(); + } + + public long getRouteId() { + return this.routeDirectionStop.getRoute().getId(); + } + + public long getDirectionId() { + return this.routeDirectionStop.getDirection().getId(); + } + public long getLookBehindInMsOrDefault() { return lookBehindInMs == null ? LOOK_BEHIND_IN_MS_DEFAULT : lookBehindInMs; } diff --git a/src/main/java/org/mtransit/android/commons/data/ScheduleExt.kt b/src/main/java/org/mtransit/android/commons/data/ScheduleExt.kt new file mode 100644 index 00000000..17f109a8 --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/data/ScheduleExt.kt @@ -0,0 +1,29 @@ +package org.mtransit.android.commons.data + +import org.mtransit.android.commons.millisToInstant +import org.mtransit.android.commons.toMillis +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +fun Instant.toScheduleTimestamp(localTimeZoneId: String, arrival: Instant? = null, tripId: String? = null, stopSequence: Int? = null) = + Schedule.Timestamp(this.toMillis(), localTimeZoneId).apply { + arrival?.let { this.arrival = it } + tripId?.let { this.tripId = it } + stopSequence?.let { this.setStopSequence(it) } + } + +var Schedule.Timestamp.departure: Instant + get() = departureT.millisToInstant() + set(value) { + departureT = value.toMillis() + } + +@Suppress("unused") +val Schedule.Timestamp.arrivalDiff: Duration? get() = this.arrivalDiffMs?.milliseconds?.coerceAtLeast(Duration.ZERO) + +var Schedule.Timestamp.arrival: Instant + get() = arrivalT.millisToInstant() + set(value) { + arrivalT = value.toMillis() + } diff --git a/src/main/java/org/mtransit/android/commons/data/Stop.java b/src/main/java/org/mtransit/android/commons/data/Stop.java index d50abaef..9d19f19f 100644 --- a/src/main/java/org/mtransit/android/commons/data/Stop.java +++ b/src/main/java/org/mtransit/android/commons/data/Stop.java @@ -1,5 +1,7 @@ package org.mtransit.android.commons.data; +import static org.mtransit.android.commons.StringUtils.EMPTY; + import android.database.Cursor; import androidx.annotation.NonNull; @@ -186,7 +188,25 @@ public int getAccessible() { } @Nullable - public Integer getOriginalIdHash() { + protected Integer getOriginalIdHash() { return originalIdHash; } + + @Nullable + public String getOriginalIdHashString() { + return originalIdHash == null ? null : String.valueOf(originalIdHash); + } + + @Deprecated + @NonNull + public String getOriginalIdHashStringOrDefault() { + return originalIdHash == null ? EMPTY : String.valueOf(originalIdHash); + } + + public boolean isSameOriginalId(@Nullable String cleanedOriginalIdHash) { + if (cleanedOriginalIdHash == null) return false; + if (this.originalIdHash == null) return false; + return this.originalIdHash.toString().equals(cleanedOriginalIdHash); + + } } diff --git a/src/main/java/org/mtransit/android/commons/provider/GTFSRealTimeProvider.java b/src/main/java/org/mtransit/android/commons/provider/GTFSRealTimeProvider.java index 8f4d3328..5cd81a6d 100644 --- a/src/main/java/org/mtransit/android/commons/provider/GTFSRealTimeProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/GTFSRealTimeProvider.java @@ -33,6 +33,7 @@ import org.mtransit.android.commons.UriUtils; import org.mtransit.android.commons.data.Direction; import org.mtransit.android.commons.data.POI; +import org.mtransit.android.commons.data.POIStatus; import org.mtransit.android.commons.data.Route; import org.mtransit.android.commons.data.ServiceUpdate; import org.mtransit.android.commons.data.Stop; @@ -49,6 +50,9 @@ import org.mtransit.android.commons.provider.serviceupdate.ServiceUpdateDbHelper; import org.mtransit.android.commons.provider.serviceupdate.ServiceUpdateProvider; import org.mtransit.android.commons.provider.serviceupdate.ServiceUpdateProviderContract; +import org.mtransit.android.commons.provider.status.GTFSRealTimeTripUpdatesProvider; +import org.mtransit.android.commons.provider.status.StatusProvider; +import org.mtransit.android.commons.provider.status.StatusProviderContract; import org.mtransit.android.commons.provider.vehiclelocations.GTFSRealTimeVehiclePositionsProvider; import org.mtransit.android.commons.provider.vehiclelocations.VehicleLocationDbHelper; import org.mtransit.android.commons.provider.vehiclelocations.VehicleLocationProvider; @@ -91,6 +95,7 @@ @SuppressLint("Registered") public class GTFSRealTimeProvider extends MTContentProvider implements VehicleLocationProviderContract, + StatusProviderContract, ServiceUpdateProviderContract { private static final String LOG_TAG = GTFSRealTimeProvider.class.getSimpleName(); @@ -107,6 +112,7 @@ public String getLogTag() { private static UriMatcher getNewUriMatcher(String authority) { UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); ServiceUpdateProvider.append(URI_MATCHER, authority); + StatusProvider.append(URI_MATCHER, authority); VehicleLocationProvider.append(URI_MATCHER, authority); return URI_MATCHER; } @@ -362,6 +368,53 @@ public static String getAGENCY_VEHICLE_POSITIONS_URL_CACHED(@NonNull Context con return agencyVehiclePositionsUrlCached; } + @Nullable + private static String agencyTripsUrl = null; + + @NonNull + public static String getAgencyTripUpdatesUrlString(@NonNull Context context, @NonNull String token) { + if (agencyTripsUrl == null) { + agencyTripsUrl = getAGENCY_TRIP_UPDATES_URL(context, + token, // 1st (some agency config have only 1 "%s") + MT_HASH_SECRET_AND_DATE + ); + } + return agencyTripsUrl; + } + + @Nullable + private static String agencyTripUpdatesUrl = null; + + /** + * Override if multiple {@link GTFSRealTimeProvider} implementations in same app. + */ + @NonNull + @SuppressLint("StringFormatInvalid") // empty string: set in module app + private static String getAGENCY_TRIP_UPDATES_URL( + @NonNull Context context, + @NonNull String token, + @SuppressWarnings("SameParameterValue") @NonNull String hash + ) { + if (agencyTripUpdatesUrl == null) { + agencyTripUpdatesUrl = context.getResources().getString(R.string.gtfs_real_time_agency_trip_updates_url, token, hash); + } + return agencyTripUpdatesUrl; + } + + @Nullable + private static String agencyTripUpdatesUrlCached = null; + + /** + * Override if multiple {@link GTFSRealTimeProvider} implementations in same app. + */ + @NonNull + public static String getAGENCY_TRIP_UPDATES_URL_CACHED(@NonNull Context context) { + if (agencyTripUpdatesUrlCached == null) { + agencyTripUpdatesUrlCached = context.getResources().getString(R.string.gtfs_real_time_agency_trip_updates_url_cached); + } + return agencyTripUpdatesUrlCached; + } + @Nullable private static Boolean ignoreDirection = null; @@ -499,6 +552,65 @@ private static String getAGENCY_TIME_ZONE(@NonNull Context context) { return agencyTimeZone; } + @Override + public long getStatusMaxValidityInMs() { + return GTFSRealTimeTripUpdatesProvider.getMaxValidityInMs(this); + } + + @Override + public long getStatusValidityInMs(boolean inFocus) { + return GTFSRealTimeTripUpdatesProvider.getValidityInMs(this, inFocus); + } + + @Override + public long getMinDurationBetweenRefreshInMs(boolean inFocus) { + return GTFSRealTimeTripUpdatesProvider.getMinDurationBetweenRefreshInMs(this, inFocus); + } + + @Nullable + @Override + public POIStatus getNewStatus(@NonNull StatusProviderContract.Filter statusFilter) { + this.providedAgencyUrlToken = SecureStringUtils.dec(statusFilter.getProvidedEncryptKey(KeysIds.GTFS_REAL_TIME_URL_TOKEN)); + this.providedAgencyUrlSecret = SecureStringUtils.dec(statusFilter.getProvidedEncryptKey(KeysIds.GTFS_REAL_TIME_URL_SECRET)); + return GTFSRealTimeTripUpdatesProvider.getNew(this, statusFilter); + } + + @Override + public void cacheStatus(@NonNull POIStatus newStatusToCache) { + StatusProvider.cacheStatusS(this, newStatusToCache); + } + + @Nullable + @Override + public POIStatus getCachedStatus(@NonNull StatusProviderContract.Filter statusFilter) { + return GTFSRealTimeTripUpdatesProvider.getCached(this, statusFilter); + } + + @Override + public boolean purgeUselessCachedStatuses() { + return StatusProvider.purgeUselessCachedStatuses(this); + } + + @Override + public boolean deleteCachedStatus(int cachedStatusId) { + return StatusProvider.deleteCachedStatus(this, cachedStatusId); + } + + public boolean deleteAllCachedStatus() { + return StatusProvider.deleteAllCachedStatus(this); + } + + @Override + public int getStatusType() { + return POI.ITEM_STATUS_TYPE_SCHEDULE; + } + + @NonNull + @Override + public String getStatusDbTableName() { + return GTFSRealTimeDbHelper.T_GTFS_REAL_TIME_TRIP_UPDATES; + } + @SuppressWarnings("unused") @Override public long getMinDurationBetweenVehicleLocationRefreshInMs(boolean inFocus) { @@ -625,13 +737,14 @@ public Integer getDirectionTag(@NonNull Direction direction) { return direction.getOriginalDirectionIdOrNull(); } - @NonNull + @Nullable public String getStopTag(@NonNull Stop stop) { - return String.valueOf(stop.getOriginalIdHash()); + return stop.getOriginalIdHashString(); } - @NonNull - public static String getAgencyStopTagTargetUUID(@NonNull String agencyTag, @NonNull String stopTag) { + @Nullable + public static String getAgencyStopTagTargetUUID(@NonNull String agencyTag, @Nullable String stopTag) { + if (stopTag == null) return null; return POI.POIUtils.getUUID(agencyTag, "si" + stopTag); } @@ -640,8 +753,9 @@ public static String getAgencyRouteTagTargetUUID(@NonNull String agencyTag, @Non return POI.POIUtils.getUUID(agencyTag, "ri" + routeTag); } - @NonNull - public static String getAgencyRouteStopTagTargetUUID(@NonNull String agencyTag, @NonNull String routeTag, @NonNull String stopTag) { + @Nullable + public static String getAgencyRouteStopTagTargetUUID(@NonNull String agencyTag, @NonNull String routeTag, @Nullable String stopTag) { + if (stopTag == null) return null; return POI.POIUtils.getUUID(agencyTag, "ri" + routeTag, "si" + stopTag); } @@ -658,8 +772,9 @@ public static String getAgencyRouteDirectionTagTargetUUID(@NonNull String agency } @Nullable - public static String getAgencyRouteDirectionStopTagTargetUUID(@NonNull String agencyTag, @NonNull String routeTag, @Nullable Integer directionTag, @NonNull String stopTag) { + public static String getAgencyRouteDirectionStopTagTargetUUID(@NonNull String agencyTag, @NonNull String routeTag, @Nullable Integer directionTag, @Nullable String stopTag) { if (directionTag == null) return null; + if (stopTag == null) return null; return POI.POIUtils.getUUID(agencyTag, "ri" + routeTag, "d" + directionTag, "si" + stopTag); } @@ -741,6 +856,7 @@ private static String getAgencyServiceAlertsUrlString(@NonNull Context context, private static final ThreadSafeDateFormatter HASH_DATE_FORMATTER; static { + @SuppressWarnings("SpellCheckingInspection") ThreadSafeDateFormatter dateFormatter = new ThreadSafeDateFormatter("yyyyMMdd'T'HHmm'Z'", Locale.ENGLISH); dateFormatter.setTimeZone(UTC_TZ); HASH_DATE_FORMATTER = dateFormatter; @@ -1389,6 +1505,10 @@ public Cursor queryMT(@NonNull Uri uri, @Nullable String[] projection, @Nullable if (cursor != null) { return cursor; } + cursor = StatusProvider.queryS(this, uri, selection); + if (cursor != null) { + return cursor; + } cursor = VehicleLocationProvider.queryS(this, uri, selection); if (cursor != null) { return cursor; @@ -1403,6 +1523,10 @@ public String getTypeMT(@NonNull Uri uri) { if (type != null) { return type; } + type = StatusProvider.getTypeS(this, uri); + if (type != null) { + return type; + } type = VehicleLocationProvider.getTypeS(this, uri); if (type != null) { return type; @@ -1444,6 +1568,14 @@ public String getLogTag() { */ protected static final String DB_NAME = "gtfsrealtime.db"; + static final String T_GTFS_REAL_TIME_TRIP_UPDATES = StatusProvider.StatusDbHelper.T_STATUS; + + private static final String T_GTFS_REAL_TIME_TRIP_UPDATES_SQL_CREATE = StatusProvider.StatusDbHelper + .getSqlCreateBuilder(T_GTFS_REAL_TIME_TRIP_UPDATES) + .build(); + + private static final String T_GTFS_REAL_TIME_TRIP_UPDATES_SQL_DROP = SqlUtils.getSQLDropIfExistsQuery(T_GTFS_REAL_TIME_TRIP_UPDATES); + static final String T_GTFS_REAL_TIME_VEHICLE_LOCATION = VehicleLocationDbHelper.T_VEHICLE_LOCATION; private static final String T_GTFS_REAL_TIME_VEHICLE_LOCATION_SQL_CREATE = VehicleLocationDbHelper @@ -1471,8 +1603,9 @@ public static int getDbVersion(@NonNull Context context) { dbVersion++; // add "service_update.original_id" column dbVersion++; // add "vehicle_location" table dbVersion++; // add "vehicle_location.report_timestamp" column - dbVersion++; // change "vehicle_location.[bearing|speed] unit to Int + dbVersion++; // change "vehicle_location.[bearing|speed]" unit to Int dbVersion++; // add "service_update.trip_id" column + dbVersion++; // add "status" table } return dbVersion; } @@ -1491,6 +1624,7 @@ public void onCreateMT(@NonNull SQLiteDatabase db) { @Override public void onUpgradeMT(@NonNull SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL(T_GTFS_REAL_TIME_TRIP_UPDATES_SQL_DROP); db.execSQL(T_GTFS_REAL_TIME_VEHICLE_LOCATION_SQL_DROP); db.execSQL(T_GTFS_REAL_TIME_SERVICE_UPDATE_SQL_DROP); GtfsRealTimeStorage.saveServiceUpdateLastUpdateMs(context, 0L); @@ -1502,6 +1636,7 @@ public boolean isDbExist(@NonNull Context context) { } private void initAllDbTables(@NonNull SQLiteDatabase db) { + db.execSQL(T_GTFS_REAL_TIME_TRIP_UPDATES_SQL_CREATE); db.execSQL(T_GTFS_REAL_TIME_VEHICLE_LOCATION_SQL_CREATE); db.execSQL(T_GTFS_REAL_TIME_SERVICE_UPDATE_SQL_CREATE); } diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSRDSProviderExt.kt b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSRDSProviderExt.kt index 767a62eb..02c3185e 100644 --- a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSRDSProviderExt.kt +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSRDSProviderExt.kt @@ -5,8 +5,54 @@ import android.net.Uri import org.mtransit.android.commons.MTLog import org.mtransit.android.commons.SqlUtils import org.mtransit.android.commons.UriUtils +import org.mtransit.android.commons.data.RouteDirectionStop import org.mtransit.android.commons.data.Trip import org.mtransit.android.commons.provider.GTFSProviderContract +import org.mtransit.android.commons.provider.poi.POIProviderContract + +fun Context.getRDS( + authority: String, + routeId: Long, + directionId: Long? = null, +): List? = try { + contentResolver.query( + Uri.withAppendedPath( + UriUtils.newContentUri(authority), + POIProviderContract.POI_PATH + ), + GTFSProviderContract.PROJECTION_RDS_POI, + POIProviderContract.Filter.toJSON( + POIProviderContract.Filter.getNewSqlSelectionFilter( + buildString { + append( + SqlUtils.getWhereEquals( + GTFSProviderContract.RouteDirectionStopColumns.T_ROUTE_K_ID, + routeId + ) + ) + directionId?.let { + append(SqlUtils.AND) + append(SqlUtils.getWhereEquals(GTFSProviderContract.RouteDirectionStopColumns.T_DIRECTION_K_ID, it)) + } + }) + ).toString(), + null, + SqlUtils.getSortOrderAscending(GTFSProviderContract.RouteDirectionStopColumns.T_DIRECTION_STOPS_K_STOP_SEQUENCE) + ).use { cursor -> + buildList { + if (cursor != null && cursor.count > 0) { + if (cursor.moveToFirst()) { + do { + add(RouteDirectionStop.fromCursorStatic(cursor, authority)) + } while (cursor.moveToNext()) + } + } + } + } +} catch (e: Exception) { + MTLog.w(this, e, "Error!") + null +} fun Context.getTripIds(authority: String, routeId: Long, directionId: Long? = null) = getTrips(authority, routeId, directionId)?.map { it.tripId } diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSRealTimeProviderExt.kt b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSRealTimeProviderExt.kt index 3bfd38b0..dcaef724 100644 --- a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSRealTimeProviderExt.kt +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSRealTimeProviderExt.kt @@ -21,11 +21,29 @@ import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAgencyRoute import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAgencyStopTagTargetUUID import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAgencyTagTargetUUID import org.mtransit.android.commons.provider.GTFSRealTimeProvider.isUSE_URL_HASH_SECRET_AND_DATE +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optRouteId +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optStopId +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTripId +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.originalIdToHash +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.originalIdToId import java.net.URL +import com.google.transit.realtime.GtfsRealtime.EntitySelector as GEntitySelector +import com.google.transit.realtime.GtfsRealtime.TripDescriptor as GTripDescriptor +import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate as GTUStopTimeUpdate -val GTFSRealTimeProvider.routeIdCleanupPattern get() = getRouteIdCleanupPattern(requireContextCompat()) -val GTFSRealTimeProvider.tripIdCleanupPattern get() = getTripIdCleanupPattern(requireContextCompat()) -val GTFSRealTimeProvider.stopIdCleanupPattern get() = getStopIdCleanupPattern(requireContextCompat()) +private val GTFSRealTimeProvider.routeIdCleanupPattern get() = getRouteIdCleanupPattern(requireContextCompat()) +fun GTFSRealTimeProvider.parseRouteId(es: GEntitySelector) = es.optRouteId?.let { parseRouteId(it) } +fun GTFSRealTimeProvider.parseRouteId(td: GTripDescriptor) = td.optRouteId?.let { parseRouteId(it) } +fun GTFSRealTimeProvider.parseRouteId(gRouteId: String) = gRouteId.originalIdToHash(routeIdCleanupPattern) + +private val GTFSRealTimeProvider.tripIdCleanupPattern get() = getTripIdCleanupPattern(requireContextCompat()) +fun GTFSRealTimeProvider.parseTripId(td: GTripDescriptor) = td.optTripId?.let { parseTripId(it) } +fun GTFSRealTimeProvider.parseTripId(gTripId: String) = gTripId.originalIdToId(tripIdCleanupPattern) + +private val GTFSRealTimeProvider.stopIdCleanupPattern get() = getStopIdCleanupPattern(requireContextCompat()) +fun GTFSRealTimeProvider.parseStopId(es: GEntitySelector) = es.optStopId?.let { parseStopId(it) } +fun GTFSRealTimeProvider.parseStopId(stu: GTUStopTimeUpdate) = stu.optStopId?.let { parseStopId(it) } +fun GTFSRealTimeProvider.parseStopId(gStopId: String) = gStopId.originalIdToHash(stopIdCleanupPattern) val GTFSRealTimeProvider.agencyTag get() = getAgencyTag(requireContextCompat()) @@ -58,8 +76,8 @@ fun RouteDirectionStop.getTargetUUIDs( getAgencyRouteDirectionStopTagTargetUUID(provider.agencyTag, getRouteTag(provider), getDirectionTag(provider), getStopTag(provider))?.let { put(it, uuid) } - put(getAgencyStopTagTargetUUID(provider.agencyTag, getStopTag(provider)), uuid) - put(getAgencyRouteStopTagTargetUUID(provider.agencyTag, getRouteTag(provider), getStopTag(provider)), uuid) + getAgencyStopTagTargetUUID(provider.agencyTag, getStopTag(provider))?.let { put(it, uuid) } + getAgencyRouteStopTagTargetUUID(provider.agencyTag, getRouteTag(provider), getStopTag(provider))?.let { put(it, uuid) } } } diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSScheduleTimestampsProvider.java b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSScheduleTimestampsProvider.java index 8e8a7843..0657a8a7 100644 --- a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSScheduleTimestampsProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSScheduleTimestampsProvider.java @@ -104,7 +104,8 @@ public static ScheduleTimestamps getScheduleTimestamps(@NonNull GTFSProvider pro } dataRequests++; // 1 more data request done for (Schedule.Timestamp t : dayTimestamps) { - if (t.t >= startsAtInMs && t.t < endsAtInMs) { + final long departureT = t.getDepartureT(); + if (startsAtInMs <= departureT && departureT < endsAtInMs) { allTimestamps.add(t); } } diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSStatusProvider.java b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSStatusProvider.java index c19cd14a..4b144642 100644 --- a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSStatusProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSStatusProvider.java @@ -273,7 +273,7 @@ private static ArrayList findTimestamps(@NonNull GTFSProvide if (dataRequests == 0) { // IF yesterday DO override computed date & time with GTFS format for 24+ lookupDayTime = String.valueOf(Integer.parseInt(lookupDayTime) + TWENTY_FOUR_HOURS); } else if (dataRequests == 1) { // ELSE IF today DO - // DO NOTHING (keep now time) + // NOTHING (keep now time) } else { // ELSE IF tomorrow or later DO lookupDayTime = MIDNIGHT; } @@ -305,7 +305,7 @@ private static ArrayList findTimestamps(@NonNull GTFSProvide nbTimestamps += dayTimestamps.size(); } else { for (Schedule.Timestamp dayTimestamp : dayTimestamps) { - if (dayTimestamp.t >= timestamp) { + if (dayTimestamp.getDepartureT() >= timestamp) { nbTimestamps++; } } @@ -478,7 +478,7 @@ static Set findScheduleList( if (arrivalDiff > 0) { arrivalTimestampMs = convertToTimestamp(context, lineDeparture - arrivalDiff, dateS); if (arrivalTimestampMs != null) { - timestamp.setArrivalTimestamp(arrivalTimestampMs); + timestamp.setArrivalT(arrivalTimestampMs); } } } @@ -601,7 +601,7 @@ private static ArrayList findFrequencies(@NonNull GTFSProvid if (dataRequests == 0) { // IF yesterday DO override computed date & time with GTFS format for 24+ lookupDayTime = String.valueOf(Integer.parseInt(lookupDayTime) + TWENTY_FOUR_HOURS); } else if (dataRequests == 1) { // ELSE IF today DO - // DO NOTHING (keep now time) + // NOTHING (keep now time) } else { // ELSE IF tomorrow or later DO lookupDayTime = MIDNIGHT; } @@ -751,6 +751,7 @@ private static ThreadSafeDateFormatter getToTimestampFormat(Context context) { return toTimestampFormat; } + @SuppressWarnings("WeakerAccess") @Nullable public static Integer findLastServiceDate(@NonNull GTFSProvider provider) { Integer lastServiceDate = null; @@ -852,7 +853,8 @@ public static Cursor queryS(@NonNull GTFSProvider provider, @NonNull Uri uri, @N return StatusProvider.queryS(provider, uri, selection); } - public static String getSortOrderS(@NonNull GTFSProvider provider, Uri uri) { + @Nullable + public static String getSortOrderS(@NonNull GTFSProvider provider, @NonNull Uri uri) { return StatusProvider.getSortOrderS(provider, uri); } diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSTripIdsUtils.kt b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSTripIdsUtils.kt index 76f41560..2941d215 100644 --- a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSTripIdsUtils.kt +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSTripIdsUtils.kt @@ -24,7 +24,8 @@ object GTFSTripIdsUtils : MTLog.Loggable { val idIntToIdMap = loadTripIds(gtfsProvider, tripIdInts) timestamps.forEach { timestamp -> timestamp.tripId?.let { tripIdInt -> - timestamp.tripId = tripIdInt.toIntOrNull()?.let { idIntToIdMap[it] } ?: tripIdInt + timestamp.tripId = tripIdInt.toIntOrNull()?.let { idIntToIdMap[it] } // replace with trip ID (string) + ?: tripIdInt // keep trip ID int if not found // should never happen } } return timestamps @@ -32,11 +33,10 @@ object GTFSTripIdsUtils : MTLog.Loggable { private fun loadTripIds(gtfsProvider: GTFSProvider, tripIdInts: List): Map { if (tripIdInts.isEmpty()) return emptyMap() - val placeholders = tripIdInts.joinToString(",") { "?" } return gtfsProvider.readDB.query( GTFSProviderDbHelper.T_TRIP_IDS, arrayOf(GTFSProviderDbHelper.T_TRIP_IDS_K_ID_INT, GTFSProviderDbHelper.T_TRIP_IDS_K_ID), - "${GTFSProviderDbHelper.T_TRIP_IDS_K_ID_INT} IN ($placeholders)", + "${GTFSProviderDbHelper.T_TRIP_IDS_K_ID_INT} IN (${tripIdInts.joinToString(",") { "?" }})", tripIdInts.toTypedArray(), null, null, diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsRealTimeStorage.kt b/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsRealTimeStorage.kt index 9cc08c55..6ee3f087 100644 --- a/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsRealTimeStorage.kt +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsRealTimeStorage.kt @@ -6,6 +6,42 @@ import org.mtransit.android.commons.PreferenceUtils object GtfsRealTimeStorage { + // region Trip Updates (status schedule) + + /** + * Override if multiple {@link GTFSRealTimeDbHelper} implementations in same app. + */ + private const val PREF_KEY_TRIP_UPDATE_LAST_UPDATE_MS = "pGTFSRealTimeTripUpdatesLastUpdate" + + @JvmStatic + @WorkerThread + fun getTripUpdateLastUpdateMs(context: Context, default: Long) = + PreferenceUtils.getPrefLcl(context, PREF_KEY_TRIP_UPDATE_LAST_UPDATE_MS, default) + + @JvmStatic + @WorkerThread + fun saveTripUpdateLastUpdateMs(context: Context, lastUpdateInMs: Long) { + PreferenceUtils.savePrefLclSync(context, PREF_KEY_TRIP_UPDATE_LAST_UPDATE_MS, lastUpdateInMs) + } + + /** + * Override if multiple {@link GTFSRealTimeDbHelper} implementations in same app. + */ + private const val PREF_KEY_TRIP_UPDATE_LAST_UPDATE_CODE = "pGTFSRealTimeTripUpdateLastUpdateCode" + + @JvmStatic + @WorkerThread + fun getTripUpdateLastUpdateCode(context: Context, default: Int) = + PreferenceUtils.getPrefLcl(context, PREF_KEY_TRIP_UPDATE_LAST_UPDATE_CODE, default) + + @JvmStatic + @WorkerThread + fun saveTripUpdateLastUpdateCode(context: Context, code: Int) { + PreferenceUtils.savePrefLclSync(context, PREF_KEY_TRIP_UPDATE_LAST_UPDATE_CODE, code) + } + + // end region + // region Vehicle location /** diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsRealtimeExt.kt b/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsRealtimeExt.kt index 99ac21bb..5a7040fc 100644 --- a/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsRealtimeExt.kt +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsRealtimeExt.kt @@ -1,12 +1,32 @@ package org.mtransit.android.commons.provider.gtfs -import com.google.transit.realtime.GtfsRealtime +import com.google.transit.realtime.TripUpdateKt +import com.google.transit.realtime.TripUpdateKt.StopTimeEventKt +import com.google.transit.realtime.alertOrNull +import com.google.transit.realtime.tripUpdateOrNull +import com.google.transit.realtime.vehicleOrNull import org.mtransit.android.commons.Constants import org.mtransit.android.commons.TimeUtils +import org.mtransit.android.commons.secsToInstant import org.mtransit.android.toDateTimeLog import org.mtransit.commons.GTFSCommons import org.mtransit.commons.secToMs import java.util.regex.Pattern +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import com.google.transit.realtime.GtfsRealtime.Alert as GAlert +import com.google.transit.realtime.GtfsRealtime.EntitySelector as GEntitySelector +import com.google.transit.realtime.GtfsRealtime.FeedEntity as GFeedEntity +import com.google.transit.realtime.GtfsRealtime.Position as GPosition +import com.google.transit.realtime.GtfsRealtime.TimeRange as GTimeRange +import com.google.transit.realtime.GtfsRealtime.TranslatedString as GTranslatedString +import com.google.transit.realtime.GtfsRealtime.TranslatedString.Translation as GTSTranslation +import com.google.transit.realtime.GtfsRealtime.TripDescriptor as GTripDescriptor +import com.google.transit.realtime.GtfsRealtime.TripUpdate as GTripUpdate +import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeEvent as GTUStopTimeEvent +import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate as GTUStopTimeUpdate +import com.google.transit.realtime.GtfsRealtime.VehicleDescriptor as GVehicleDescriptor +import com.google.transit.realtime.GtfsRealtime.VehiclePosition as GVehiclePosition @Suppress("MemberVisibilityCanBePrivate", "unused") object GtfsRealtimeExt { @@ -14,7 +34,7 @@ object GtfsRealtimeExt { private const val MAX_LIST_ITEMS: Int = 5 @JvmStatic - fun List.filterUseless(): List { + fun List.filterUseless(): List { return if (this.size <= 1) { this } else { @@ -23,35 +43,58 @@ object GtfsRealtimeExt { } @JvmStatic - fun List.toVehicles(): List = + fun GFeedEntity.toStringExt(debug: Boolean = Constants.DEBUG) = buildString { + append("FeedEntity:") + append("{") + append("id:").append(id).append(", ") + tripUpdateOrNull?.let { append(it.toStringExt(debug)).append(", ") } + vehicleOrNull?.let { append(it.toStringExt(debug)).append(", ") } + alertOrNull?.let { append(it.toStringExt(debug)).append(", ") } + append("}") + } + + @JvmStatic + fun List.toTripUpdates(): List = + this.filter { it.hasTripUpdate() }.map { it.tripUpdate }.distinct() + + @JvmStatic + fun List.toTripUpdatesWithIdPair(): List> = + this.filter { it.hasTripUpdate() }.map { it.tripUpdate to it.id }.distinctBy { it.first } + + @JvmStatic + fun List.sortTripUpdates(nowMs: Long = TimeUtils.currentTimeMillis()): List = + this.sortedBy { it.timestamp } + + @JvmStatic + fun List>.sortTripUpdatesPair(nowMs: Long = TimeUtils.currentTimeMillis()): List> = + this.sortedBy { (it, _) -> it.timestamp } + + @JvmStatic + fun List.toVehicles(): List = this.filter { it.hasVehicle() }.map { it.vehicle }.distinct() @JvmStatic - fun List.toVehiclesWithIdPair(): List> = + fun List.toVehiclesWithIdPair(): List> = this.filter { it.hasVehicle() }.map { it.vehicle to it.id }.distinctBy { it.first } @JvmStatic - fun List.sortVehicles(nowMs: Long = TimeUtils.currentTimeMillis()): List = - this.sortedBy { vehiclePosition -> - vehiclePosition.timestamp - } + fun List.sortVehicles(nowMs: Long = TimeUtils.currentTimeMillis()): List = + this.sortedBy { it.timestamp } @JvmStatic - fun List>.sortVehiclesPair(nowMs: Long = TimeUtils.currentTimeMillis()): List> = - this.sortedBy { (vehiclePosition, _) -> - vehiclePosition.timestamp - } + fun List>.sortVehiclesPair(nowMs: Long = TimeUtils.currentTimeMillis()): List> = + this.sortedBy { (vehiclePosition, _) -> vehiclePosition.timestamp } @JvmStatic - fun List.toAlerts(): List = + fun List.toAlerts(): List = this.filter { it.hasAlert() }.map { it.alert }.distinct() @JvmStatic - fun List.toAlertsWithIdPair(): List> = + fun List.toAlertsWithIdPair(): List> = this.filter { it.hasAlert() }.map { it.alert to it.id }.distinctBy { it.first } @JvmStatic - fun List.sortAlerts(nowMs: Long = TimeUtils.currentTimeMillis()): List = + fun List.sortAlerts(nowMs: Long = TimeUtils.currentTimeMillis()): List = this.sortedBy { alert -> (alert.getActivePeriod(nowMs)?.startMs() ?: alert.activePeriodList.firstOrNull { it.hasStart() }?.startMs()) @@ -59,7 +102,7 @@ object GtfsRealtimeExt { } @JvmStatic - fun List>.sortAlertsPair(nowMs: Long = TimeUtils.currentTimeMillis()): List> = + fun List>.sortAlertsPair(nowMs: Long = TimeUtils.currentTimeMillis()): List> = this.sortedBy { (alert, _) -> (alert.getActivePeriod(nowMs)?.startMs() ?: alert.activePeriodList.firstOrNull { it.hasStart() }?.startMs()) @@ -69,7 +112,7 @@ object GtfsRealtimeExt { // https://gtfs.org/realtime/feed-entities/service-alerts/#timerange @JvmStatic @JvmOverloads - fun GtfsRealtime.Alert.isActive(nowMs: Long = TimeUtils.currentTimeMillis()): Boolean { + fun GAlert.isActive(nowMs: Long = TimeUtils.currentTimeMillis()): Boolean { return this.activePeriodList?.takeIf { it.isNotEmpty() }?.let { // if active period provided, must be respected it.any { timeRange -> timeRange.isActive(nowMs) } // If multiple ranges are given, the alert will be shown during all of them. @@ -77,22 +120,22 @@ object GtfsRealtimeExt { } @JvmStatic - fun GtfsRealtime.Alert.getActivePeriod(nowMs: Long = TimeUtils.currentTimeMillis()) = this.activePeriodList + fun GAlert.getActivePeriod(nowMs: Long = TimeUtils.currentTimeMillis()) = this.activePeriodList .filter { it.hasStart() && it.hasEnd() } .singleOrNull { it.isActive(nowMs) } @JvmStatic - fun GtfsRealtime.EntitySelector.getRouteIdHash(idCleanupRegex: Pattern?): String = + fun GEntitySelector.getRouteIdHash(idCleanupRegex: Pattern?): String = this.routeId.originalIdToHash(idCleanupRegex) @JvmStatic - fun GtfsRealtime.EntitySelector.getTripIdHash(idCleanupRegex: Pattern?): String = + fun GEntitySelector.getTripIdHash(idCleanupRegex: Pattern?): String = this.trip.tripId.originalIdToHash(idCleanupRegex) @JvmStatic - fun GtfsRealtime.EntitySelector.getStopIdHash(idCleanupRegex: Pattern?): String = + fun GEntitySelector.getStopIdHash(idCleanupRegex: Pattern?): String = this.stopId.originalIdToHash(idCleanupRegex) @JvmStatic @@ -103,27 +146,122 @@ object GtfsRealtimeExt { fun String.originalIdToId(idCleanupRegex: Pattern? = null): String = GTFSCommons.originalIdToId(this, idCleanupRegex) - fun GtfsRealtime.TimeRange.isActive(nowMs: Long = TimeUtils.currentTimeMillis()) = + fun GTimeRange.isActive(nowMs: Long = TimeUtils.currentTimeMillis()) = isStarted(nowMs) && !isEnded(nowMs) - fun GtfsRealtime.TimeRange.isStarted(nowMs: Long = TimeUtils.currentTimeMillis()) = + fun GTimeRange.isStarted(nowMs: Long = TimeUtils.currentTimeMillis()) = this.startMs()?.let { it <= nowMs } ?: true - fun GtfsRealtime.TimeRange.startMs(): Long? = + fun GTimeRange.startMs(): Long? = this.start.takeIf { this.hasStart() }?.secToMs() - fun GtfsRealtime.TimeRange.isEnded(nowMs: Long = TimeUtils.currentTimeMillis()) = + fun GTimeRange.isEnded(nowMs: Long = TimeUtils.currentTimeMillis()) = this.endMs()?.let { it <= nowMs } ?: false - fun GtfsRealtime.TimeRange.endMs(): Long? = + fun GTimeRange.endMs(): Long? = this.end.takeIf { this.hasEnd() }?.secToMs() @JvmStatic @JvmOverloads - fun GtfsRealtime.VehiclePosition.toStringExt(debug: Boolean = Constants.DEBUG) = buildString { + fun GTripUpdate.toStringExt(debug: Boolean = Constants.DEBUG) = buildString { + append("TripUpdate:") + append( + buildList { + optTrip?.let { add(it.toStringExt(short = true)) } + optVehicle?.let { add(it.toStringExt(short = true)) } + optStopTimeUpdateList?.let { add(it.toStringExt(short = true)) } + optTimestamp?.let { add("timestamp=$timestamp") } + optDelay?.let { add("delay=$delay") } + }.joinToString(separator = ",", prefix = "{", postfix = "}") + ) + } + + val GTripUpdate.optTrip get() = if (hasTrip()) trip else null + val GTripUpdate.optVehicle get() = if (hasVehicle()) vehicle else null + val GTripUpdate.optStopTimeUpdateList get() = stopTimeUpdateList?.takeIf { it.isNotEmpty() } + val GTripUpdate.optTimestamp get() = if (hasTimestamp()) timestamp else null + val GTripUpdate.optDelay get() = if (hasDelay()) delay else null + val GTripUpdate.optDelayDuration get() = this.optDelay?.seconds + val GTripUpdate.optTripProperties get() = if (hasTripProperties()) tripProperties else null + + @JvmName("toStringExtStopTimeUpdate") + @JvmStatic + @JvmOverloads + fun List?.toStringExt(short: Boolean = false, debug: Boolean = Constants.DEBUG) = buildString { + append(if (short) "STUs[" else "StopTimeUpdate[").append(this@toStringExt?.size ?: 0).append("]") + if (debug) { + this@toStringExt?.take(MAX_LIST_ITEMS)?.forEachIndexed { idx, stopTimeUpdate -> + if (idx > 0) append(",") else append("=") + append(stopTimeUpdate.toStringExt(short = true)) + } + } + } + + @JvmStatic + @JvmOverloads + fun GTUStopTimeUpdate.toStringExt(short: Boolean = false) = buildString { + append(if (short) "STU:" else "StopTimeUpdate:") + append("{") + optStopSequence?.let { append("stopSeq=").append(stopSequence).append(", ") } + optStopId?.let { append("stopId=").append(stopId).append(", ") } + optArrival?.let { append("arrival=").append(it.toStringExt(short = true)).append(", ") } + optDeparture?.let { append("departure=").append(it.toStringExt(short = true)).append(", ") } + optDepartureOccupancyStatus?.let { append("depOcc=").append(departureOccupancyStatus).append(", ") } + optScheduleRelationship?.let { append("schedRel=").append(scheduleRelationship).append(", ") } + optStopTimeProperties?.let { append(it.toStringExt(short = true)).append(", ") } + append("}") + } + + val GTUStopTimeUpdate.optStopSequence get() = if (hasStopSequence()) stopSequence else null + val GTUStopTimeUpdate.optStopId get() = if (hasStopId()) stopId else null + val GTUStopTimeUpdate.optArrival get() = if (hasArrival()) arrival else null + val GTUStopTimeUpdate.optDeparture get() = if (hasDeparture()) departure else null + val GTUStopTimeUpdate.optDepartureOccupancyStatus get() = if (hasDepartureOccupancyStatus()) departureOccupancyStatus else null + val GTUStopTimeUpdate.optScheduleRelationship get() = if (hasScheduleRelationship()) scheduleRelationship else null + val GTUStopTimeUpdate.optStopTimeProperties get() = if (hasStopTimeProperties()) stopTimeProperties else null + + @JvmStatic + @JvmOverloads + fun GTUStopTimeEvent.toStringExt(short: Boolean = false) = buildString { + append(if (short) "STE:" else "StopTimeEvent:") + append("{") + optDelay?.let { append("delay=").append(delay).append(", ") } + optTime?.let { append("time=").append(time).append(", ") } + optUncertainty?.let { append("uncertainty=").append(uncertainty).append(", ") } + optScheduledTime?.let { append("schedTime=").append(scheduledTime).append(", ") } + append("}") + } + + val GTUStopTimeEvent.optDelay get() = if (hasDelay()) delay else null + val GTUStopTimeEvent.optDelayDuration: Duration? get() = this.optDelay?.seconds + val GTUStopTimeEvent.optTime get() = if (hasTime()) time else null + val GTUStopTimeEvent.optTimeInstant get() = if (hasTime()) time.secsToInstant() else null + val GTUStopTimeEvent.optUncertainty get() = if (hasUncertainty()) uncertainty else null + val GTUStopTimeEvent.optScheduledTime get() = if (hasScheduledTime()) scheduledTime else null + + @JvmStatic + @JvmOverloads + fun GTUStopTimeUpdate.StopTimeProperties.toStringExt(short: Boolean = false) = buildString { + append(if (short) "STP:" else "StopTimeProperties:") + append("{") + optAssignedStopId?.let { append("aStopId=").append(assignedStopId).append(", ") } + optStopHeadsign?.let { append("stopHeadsign=").append(stopHeadsign).append(", ") } + optPickupType?.let { append("pickupType=").append(pickupType).append(", ") } + optDropOffType?.let { append("dropOffType=").append(dropOffType).append(", ") } + append("}") + } + + val GTUStopTimeUpdate.StopTimeProperties.optAssignedStopId get() = if (hasAssignedStopId()) assignedStopId else null + val GTUStopTimeUpdate.StopTimeProperties.optStopHeadsign get() = if (hasStopHeadsign()) stopHeadsign else null + val GTUStopTimeUpdate.StopTimeProperties.optPickupType get() = if (hasPickupType()) pickupType else null + val GTUStopTimeUpdate.StopTimeProperties.optDropOffType get() = if (hasDropOffType()) dropOffType else null + + @JvmStatic + @JvmOverloads + fun GVehiclePosition.toStringExt(debug: Boolean = Constants.DEBUG) = buildString { append("VehiclePosition:") append("{") - if (hasTrip()) append(trip.toStringExt(short = true)).append(", ") + optTrip?.let { append(it.toStringExt(short = true)).append(", ") } if (hasPosition()) append(position.toStringExt(short = true)).append(", ") if (hasVehicle()) append(vehicle.toStringExt(short = true)).append(", ") if (hasCurrentStopSequence()) append("currentStopSequence=").append(currentStopSequence).append(", ") @@ -136,14 +274,14 @@ object GtfsRealtimeExt { append("}") } - val GtfsRealtime.VehiclePosition.optTrip get() = if (hasTrip()) trip else null - val GtfsRealtime.VehiclePosition.optTimestamp get() = if (hasTimestamp()) timestamp else null - val GtfsRealtime.VehiclePosition.optPosition get() = if (hasPosition()) position else null - val GtfsRealtime.VehiclePosition.optVehicle get() = if (hasVehicle()) vehicle else null + val GVehiclePosition.optTrip get() = if (hasTrip()) trip else null + val GVehiclePosition.optTimestamp get() = if (hasTimestamp()) timestamp else null + val GVehiclePosition.optPosition get() = if (hasPosition()) position else null + val GVehiclePosition.optVehicle get() = if (hasVehicle()) vehicle else null @JvmStatic @JvmOverloads - fun GtfsRealtime.Position.toStringExt(short: Boolean = false) = buildString { + fun GPosition.toStringExt(short: Boolean = false) = buildString { append(if (short) "P:" else "Position:") append("{") if (hasLatitude()) append("lat=").append(latitude).append(", ") @@ -154,30 +292,32 @@ object GtfsRealtimeExt { append("}") } - val GtfsRealtime.Position.optLatitude get() = if (hasLatitude()) latitude else null - val GtfsRealtime.Position.optLongitude get() = if (hasLongitude()) longitude else null - val GtfsRealtime.Position.optBearing get() = if (hasBearing()) bearing else null - val GtfsRealtime.Position.optSpeed get() = if (hasSpeed()) speed else null - val GtfsRealtime.Position.optOdometer get() = if (hasOdometer()) odometer else null + val GPosition.optLatitude get() = if (hasLatitude()) latitude else null + val GPosition.optLongitude get() = if (hasLongitude()) longitude else null + val GPosition.optBearing get() = if (hasBearing()) bearing else null + val GPosition.optSpeed get() = if (hasSpeed()) speed else null + val GPosition.optOdometer get() = if (hasOdometer()) odometer else null @JvmStatic @JvmOverloads - fun GtfsRealtime.VehicleDescriptor.toStringExt(short: Boolean = false) = buildString { + fun GVehicleDescriptor.toStringExt(short: Boolean = false) = buildString { append(if (short) "VD:" else "VehicleDescriptor:") append("{") - if (hasId()) append("id=").append(id).append(", ") - if (hasLabel()) append("lbl=").append(label).append(", ") - if (hasLicensePlate()) append("licensePlate=").append(licensePlate).append(", ") - if (hasWheelchairAccessible()) append("a18n=").append(wheelchairAccessible).append(", ") + optId?.let { append("id=").append(id).append(", ") } + optLabel?.let { append("label=").append(label).append(", ") } + optLicensePlate?.let { append("licensePlate=").append(licensePlate).append(", ") } + optWheelchairAccessible?.let { append("a18n=").append(wheelchairAccessible).append(", ") } append("}") } - val GtfsRealtime.VehicleDescriptor.optId get() = if (hasId()) id else null - val GtfsRealtime.VehicleDescriptor.optLabel get() = if (hasLabel()) label else null + val GVehicleDescriptor.optId get() = if (hasId()) id else null + val GVehicleDescriptor.optLabel get() = if (hasLabel()) label else null + val GVehicleDescriptor.optLicensePlate get() = if (hasLicensePlate()) licensePlate else null + val GVehicleDescriptor.optWheelchairAccessible get() = if (hasWheelchairAccessible()) wheelchairAccessible else null @JvmStatic @JvmOverloads - fun GtfsRealtime.Alert.toStringExt(debug: Boolean = Constants.DEBUG) = buildString { + fun GAlert.toStringExt(debug: Boolean = Constants.DEBUG) = buildString { append("Alert:") append("{") append(informedEntityList.toStringExt(short = true, debug)).append(", ") @@ -195,7 +335,7 @@ object GtfsRealtimeExt { @JvmName("toStringExtEntity") @JvmStatic @JvmOverloads - fun List?.toStringExt(short: Boolean = false, debug: Boolean = Constants.DEBUG) = buildString { + fun List?.toStringExt(short: Boolean = false, debug: Boolean = Constants.DEBUG): String = buildString { append(if (short) "ESs[" else "EntitySelectors[").append(this@toStringExt?.size ?: 0).append("]") if (debug) { this@toStringExt?.take(MAX_LIST_ITEMS)?.forEachIndexed { idx, entity -> @@ -208,7 +348,7 @@ object GtfsRealtimeExt { @JvmName("toStringExtRange") @JvmStatic @JvmOverloads - fun List?.toStringExt(short: Boolean = false, debug: Boolean = Constants.DEBUG) = buildString { + fun List?.toStringExt(short: Boolean = false, debug: Boolean = Constants.DEBUG) = buildString { append(if (short) "TRs[" else "TimeRanges[").append(this@toStringExt?.size ?: 0).append("]") if (debug) { this@toStringExt?.take(MAX_LIST_ITEMS)?.forEachIndexed { idx, period -> @@ -221,7 +361,7 @@ object GtfsRealtimeExt { @JvmStatic @JvmOverloads - fun GtfsRealtime.TimeRange.toStringExt(short: Boolean = false, debug: Boolean = Constants.DEBUG) = buildString { + fun GTimeRange.toStringExt(short: Boolean = false, debug: Boolean = Constants.DEBUG) = buildString { append(if (short) "TR:" else "TimeRange:") append("{") if (hasStart()) { @@ -238,47 +378,55 @@ object GtfsRealtimeExt { @JvmStatic @JvmOverloads - fun GtfsRealtime.EntitySelector.toStringExt(short: Boolean = false) = buildString { + fun GEntitySelector.toStringExt(short: Boolean = false) = buildString { append(if (short) "ES:" else "EntitySelector:") - append("{") - if (hasAgencyId()) append(if (short) "a=" else "agencyId=").append(agencyId).append("|") - if (hasRouteType()) append(if (short) "rt=" else "routeType=").append(routeType).append("|") - if (hasRouteId()) append(if (short) "r=" else "routeId=").append(routeId).append("|") - if (hasStopId()) append(if (short) "s=" else "stopId=").append(stopId).append("|") - if (hasDirectionId()) append(if (short) "d=" else "directionId=").append(directionId).append("|") - if (hasTrip()) append(trip.toStringExt(short)) - append("}") + append( + buildList { + optAgencyId?.let { add((if (short) "a=" else "agencyId=") + agencyId) } + optRouteType?.let { add((if (short) "rt=" else "routeType=") + routeType) } + optRouteId?.let { add((if (short) "r=" else "routeId=") + routeId) } + optStopId?.let { add((if (short) "s=" else "stopId=") + stopId) } + optDirectionId?.let { add((if (short) "d=" else "directionId=") + directionId) } + optTrip?.let { add(it.toStringExt(short)) } + }.joinToString(separator = "|", prefix = "{", postfix = "}") + ) } - val GtfsRealtime.EntitySelector.optAgencyId get() = if (hasAgencyId()) agencyId else null - val GtfsRealtime.EntitySelector.optRouteId get() = if (hasRouteId()) routeId else this.optTrip?.optRouteId - val GtfsRealtime.EntitySelector.optDirectionId get() = if (hasDirectionId()) directionId else this.optTrip?.optDirectionId - val GtfsRealtime.EntitySelector.optStopId get() = if (hasStopId()) stopId else null - val GtfsRealtime.EntitySelector.optRouteType get() = if (hasRouteType()) routeType else null - val GtfsRealtime.EntitySelector.optTrip get() = if (hasTrip()) this.trip else null + val GEntitySelector.optAgencyId get() = if (hasAgencyId()) agencyId else null + val GEntitySelector.optRouteId get() = if (hasRouteId()) routeId else this.optTrip?.optRouteId + val GEntitySelector.optDirectionId get() = if (hasDirectionId()) directionId else this.optTrip?.optDirectionId + val GEntitySelector.optStopId get() = if (hasStopId()) stopId else null + val GEntitySelector.optRouteType get() = if (hasRouteType()) routeType else null + val GEntitySelector.optTrip get() = if (hasTrip()) this.trip else null @JvmStatic @JvmOverloads - fun GtfsRealtime.TripDescriptor.toStringExt(short: Boolean = false) = buildString { + fun GTripDescriptor.toStringExt(short: Boolean = false): String = buildString { append(if (short) "TD:" else "TripDescriptor:") - append("{") - if (hasTripId()) append(if (short) "t=" else "tripId=").append(tripId).append("|") - if (hasDirectionId()) append(if (short) "d=" else "directionId=").append(directionId).append("|") - if (hasRouteId()) append(if (short) "r=" else "routeId=").append(routeId).append("|") - if (hasModifiedTrip()) append(modifiedTrip.toStringExt()) - if (hasScheduleRelationship()) append(if (short) "sr=" else "schedRel=").append(scheduleRelationship).append("|") - if (hasStartDate()) append(if (short) "sd=" else "startDate=").append(startDate).append("|") - if (hasStartTime()) append(if (short) "st=" else "startTime=").append(startTime).append("|") - append("}") + append( + buildList { + optRouteId?.let { add((if (short) "r=" else "routeId=") + routeId) } + optDirectionId?.let { add((if (short) "d=" else "directionId=") + directionId) } + optTripId?.let { add((if (short) "t=" else "tripId=") + tripId) } + optModifiedTrip?.let { add(modifiedTrip.toStringExt()) } + optScheduleRelationship?.let { add((if (short) "sr=" else "schedRel=") + scheduleRelationship) } + optStartDate?.let { add((if (short) "sd=" else "startDate=") + startDate) } + optStartTime?.let { add((if (short) "st=" else "startTime=") + startTime) } + }.joinToString(separator = "|", prefix = "{", postfix = "}") + ) } - val GtfsRealtime.TripDescriptor.optTripId get() = if (hasTripId()) tripId else null - val GtfsRealtime.TripDescriptor.optRouteId get() = if (hasRouteId()) routeId else null - val GtfsRealtime.TripDescriptor.optDirectionId get() = if (hasDirectionId()) directionId else null + val GTripDescriptor.optTripId get() = if (hasTripId()) tripId else null + val GTripDescriptor.optRouteId get() = if (hasRouteId()) routeId else null + val GTripDescriptor.optDirectionId get() = if (hasDirectionId()) directionId else null + val GTripDescriptor.optModifiedTrip get() = if (hasModifiedTrip()) modifiedTrip else null + val GTripDescriptor.optScheduleRelationship get() = if (hasScheduleRelationship()) scheduleRelationship else null + val GTripDescriptor.optStartDate get() = if (hasStartDate()) startDate else null + val GTripDescriptor.optStartTime get() = if (hasStartTime()) startTime else null @JvmStatic @JvmOverloads - fun GtfsRealtime.TripDescriptor.ModifiedTripSelector.toStringExt(short: Boolean = false) = buildString { + fun GTripDescriptor.ModifiedTripSelector.toStringExt(short: Boolean = false) = buildString { append(if (short) "MTS:" else "ModifiedTripSelector:") append("{") if (hasModificationsId()) append(if (short) "m=" else "modificationsId=").append(modificationsId).append("|") @@ -290,7 +438,7 @@ object GtfsRealtimeExt { @JvmOverloads @JvmStatic - fun GtfsRealtime.TranslatedString.toStringExt(name: String = "i18n", debug: Boolean = Constants.DEBUG) = buildString { + fun GTranslatedString.toStringExt(name: String = "i18n", debug: Boolean = Constants.DEBUG) = buildString { append(name).append("[").append(translationList?.size ?: 0).append("]") if (debug) { translationList?.take(MAX_LIST_ITEMS)?.forEachIndexed { idx, translation -> @@ -301,7 +449,27 @@ object GtfsRealtimeExt { } @JvmStatic - fun GtfsRealtime.TranslatedString.Translation.toStringExt() = buildString { + fun GTSTranslation.toStringExt() = buildString { append("{").append(language).append(":").append(text).append("}") } + + var TripUpdateKt.Dsl.delayDuration: Duration? + get() = this.delay.takeIf { hasDelay() }?.seconds + set(value) { + value?.inWholeSeconds?.toInt()?.let { + this.delay = it + } ?: run { + this.clearDelay() + } + } + + var StopTimeEventKt.Dsl.delayDuration: Duration? + get() = this.delay.takeIf { hasDelay() }?.seconds + set(value) { + value?.inWholeSeconds?.toInt()?.let { + this.delay = it + } ?: run { + this.clearDelay() + } + } } \ No newline at end of file diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsStatusProviderExt.kt b/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsStatusProviderExt.kt new file mode 100644 index 00000000..ace35e7e --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GtfsStatusProviderExt.kt @@ -0,0 +1,43 @@ +package org.mtransit.android.commons.provider.gtfs + +import android.content.Context +import android.net.Uri +import org.mtransit.android.commons.MTLog +import org.mtransit.android.commons.UriUtils +import org.mtransit.android.commons.data.RouteDirectionStop +import org.mtransit.android.commons.data.Schedule +import org.mtransit.android.commons.provider.status.StatusProviderContract +import kotlin.time.Duration.Companion.hours + +fun Context.getRDSSchedule( + authority: String, + rdsList: Iterable, +) = rdsList.mapNotNull { + getRDSSchedule(authority, it) +} + +fun Context.getRDSSchedule( + authority: String, + rds: RouteDirectionStop, +): Schedule? = try { + contentResolver.query( + Uri.withAppendedPath( + UriUtils.newContentUri(authority), + StatusProviderContract.STATUS_PATH + ), + StatusProviderContract.PROJECTION_STATUS, + Schedule.ScheduleStatusFilter(rds).apply { + setLookBehindInMs(1.hours.inWholeMilliseconds) + setMaxDataRequests(3) // yesterday service ending + today + tomorrow? + }.let { it.toJSONStringStatic(it) }, + null, + null + ).use { cursor -> + cursor?.takeIf { it.count > 0 && it.moveToFirst() }?.let { + Schedule.fromCursorWithExtra(it) + } + } +} catch (e: Exception) { + MTLog.w(this, e, "Error!") + null +} diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/alert/GTFSRTAlertsManager.kt b/src/main/java/org/mtransit/android/commons/provider/gtfs/alert/GTFSRTAlertsManager.kt index 8f869214..95bc61fd 100644 --- a/src/main/java/org/mtransit/android/commons/provider/gtfs/alert/GTFSRTAlertsManager.kt +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/alert/GTFSRTAlertsManager.kt @@ -1,15 +1,15 @@ package org.mtransit.android.commons.provider.gtfs.alert -import com.google.transit.realtime.GtfsRealtime.Alert.Effect -import com.google.transit.realtime.GtfsRealtime.EntitySelector import org.mtransit.android.commons.data.ServiceUpdate +import com.google.transit.realtime.GtfsRealtime.Alert.Effect as GAEffect +import com.google.transit.realtime.GtfsRealtime.EntitySelector as GEntitySelector object GTFSRTAlertsManager { @JvmStatic fun parseSeverity( - gEntitySelector: EntitySelector, - gEffect: Effect + gEntitySelector: GEntitySelector, + gEffect: GAEffect ): Int { if (gEntitySelector.hasStopId()) { return parseEffectSeverity(gEffect, ServiceUpdate.SEVERITY_INFO_POI, ServiceUpdate.SEVERITY_WARNING_POI) @@ -22,21 +22,21 @@ object GTFSRTAlertsManager { } // https://gtfs.org/documentation/realtime/feed_entities/service-alerts/#effect - private fun parseEffectSeverity(gEffect: Effect, infoSeverity: Int, warningSeverity: Int): Int = when (gEffect) { - Effect.ADDITIONAL_SERVICE -> infoSeverity - Effect.MODIFIED_SERVICE -> infoSeverity - Effect.REDUCED_SERVICE -> warningSeverity - Effect.NO_SERVICE -> warningSeverity + private fun parseEffectSeverity(gEffect: GAEffect, infoSeverity: Int, warningSeverity: Int): Int = when (gEffect) { + GAEffect.ADDITIONAL_SERVICE -> infoSeverity + GAEffect.MODIFIED_SERVICE -> infoSeverity + GAEffect.REDUCED_SERVICE -> warningSeverity + GAEffect.NO_SERVICE -> warningSeverity - Effect.SIGNIFICANT_DELAYS -> warningSeverity + GAEffect.SIGNIFICANT_DELAYS -> warningSeverity - Effect.DETOUR -> warningSeverity - Effect.STOP_MOVED -> warningSeverity + GAEffect.DETOUR -> warningSeverity + GAEffect.STOP_MOVED -> warningSeverity - Effect.ACCESSIBILITY_ISSUE -> infoSeverity + GAEffect.ACCESSIBILITY_ISSUE -> infoSeverity - Effect.OTHER_EFFECT -> infoSeverity - Effect.UNKNOWN_EFFECT -> infoSeverity - Effect.NO_EFFECT -> infoSeverity + GAEffect.OTHER_EFFECT -> infoSeverity + GAEffect.UNKNOWN_EFFECT -> infoSeverity + GAEffect.NO_EFFECT -> infoSeverity } } \ No newline at end of file diff --git a/src/main/java/org/mtransit/android/commons/provider/serviceupdate/GTFSRealTimeServiceAlertsProvider.kt b/src/main/java/org/mtransit/android/commons/provider/serviceupdate/GTFSRealTimeServiceAlertsProvider.kt index 4bc71a73..149321e1 100644 --- a/src/main/java/org/mtransit/android/commons/provider/serviceupdate/GTFSRealTimeServiceAlertsProvider.kt +++ b/src/main/java/org/mtransit/android/commons/provider/serviceupdate/GTFSRealTimeServiceAlertsProvider.kt @@ -1,6 +1,5 @@ package org.mtransit.android.commons.provider.serviceupdate -import com.google.transit.realtime.GtfsRealtime import org.mtransit.android.commons.MTLog import org.mtransit.android.commons.data.RouteDirectionStop import org.mtransit.android.commons.data.ServiceUpdate @@ -16,20 +15,16 @@ import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAgencyStopT import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAgencyTagTargetUUID import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optAgencyId import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optDirectionId -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optRouteId import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optRouteType -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optStopId import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTrip -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTripId -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.originalIdToHash -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.originalIdToId import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.toStringExt import org.mtransit.android.commons.provider.gtfs.agencyTag import org.mtransit.android.commons.provider.gtfs.getTargetUUIDs import org.mtransit.android.commons.provider.gtfs.getTripIds -import org.mtransit.android.commons.provider.gtfs.routeIdCleanupPattern -import org.mtransit.android.commons.provider.gtfs.stopIdCleanupPattern -import org.mtransit.android.commons.provider.gtfs.tripIdCleanupPattern +import org.mtransit.android.commons.provider.gtfs.parseRouteId +import org.mtransit.android.commons.provider.gtfs.parseStopId +import org.mtransit.android.commons.provider.gtfs.parseTripId +import com.google.transit.realtime.GtfsRealtime.EntitySelector as GEntitySelector object GTFSRealTimeServiceAlertsProvider { @@ -63,24 +58,24 @@ object GTFSRealTimeServiceAlertsProvider { } @JvmStatic - fun GTFSRealTimeProvider.parseTargetTripId(gEntitySelector: GtfsRealtime.EntitySelector) = - gEntitySelector.optTrip?.optTripId?.originalIdToId(tripIdCleanupPattern) + fun GTFSRealTimeProvider.parseTargetTripId(gEntitySelector: GEntitySelector) = + gEntitySelector.optTrip?.let { parseTripId(it) } @JvmStatic - fun GTFSRealTimeProvider.parseProviderTargetUUID(gEntitySelector: GtfsRealtime.EntitySelector, ignoreDirection: Boolean): String? { - gEntitySelector.optRouteId?.originalIdToHash(routeIdCleanupPattern)?.let { routeId -> + fun GTFSRealTimeProvider.parseProviderTargetUUID(gEntitySelector: GEntitySelector, ignoreDirection: Boolean): String? { + parseRouteId(gEntitySelector)?.let { routeId -> gEntitySelector.optDirectionId?.takeIf { !ignoreDirection }?.let { directionId -> - gEntitySelector.optStopId?.originalIdToHash(stopIdCleanupPattern)?.let { stopId -> + parseStopId(gEntitySelector)?.let { stopId -> return getAgencyRouteDirectionStopTagTargetUUID(agencyTag, routeId, directionId, stopId) } // no stop return getAgencyRouteDirectionTagTargetUUID(agencyTag, routeId, directionId) } // no direction - gEntitySelector.optStopId?.originalIdToHash(stopIdCleanupPattern)?.let { stopId -> + parseStopId(gEntitySelector)?.let { stopId -> return getAgencyRouteStopTagTargetUUID(agencyTag, routeId, stopId) } return getAgencyRouteTagTargetUUID(agencyTag, routeId) } - gEntitySelector.optStopId?.originalIdToHash(stopIdCleanupPattern)?.let { stopId -> + parseStopId(gEntitySelector)?.let { stopId -> return getAgencyStopTagTargetUUID(agencyTag, stopId) } gEntitySelector.optRouteType?.let { routeType -> diff --git a/src/main/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProvider.kt b/src/main/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProvider.kt new file mode 100644 index 00000000..a75056c6 --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProvider.kt @@ -0,0 +1,283 @@ +package org.mtransit.android.commons.provider.status + +import android.content.Context +import android.util.Log +import org.mtransit.android.commons.Constants +import org.mtransit.android.commons.MTLog +import org.mtransit.android.commons.SecurityUtils +import org.mtransit.android.commons.TimeUtils +import org.mtransit.android.commons.data.POIStatus +import org.mtransit.android.commons.data.RouteDirectionStop +import org.mtransit.android.commons.data.Schedule +import org.mtransit.android.commons.provider.GTFSRealTimeProvider +import org.mtransit.android.commons.provider.GTFSRealTimeProvider.isIGNORE_DIRECTION +import org.mtransit.android.commons.provider.gtfs.GtfsRealTimeStorage +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optDirectionId +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTrip +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.toStringExt +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.toTripUpdates +import org.mtransit.android.commons.provider.gtfs.getRDS +import org.mtransit.android.commons.provider.gtfs.getRDSSchedule +import org.mtransit.android.commons.provider.gtfs.getTripIds +import org.mtransit.android.commons.provider.gtfs.makeRequest +import org.mtransit.android.commons.provider.gtfs.parseRouteId +import org.mtransit.android.commons.provider.gtfs.parseTripId +import org.mtransit.android.commons.provider.status.StatusProvider.cacheAllStatusesBulkLockDB +import org.mtransit.commons.SourceUtils +import java.io.File +import java.io.IOException +import java.net.HttpURLConnection +import java.net.SocketException +import java.net.UnknownHostException +import javax.net.ssl.SSLHandshakeException +import kotlin.math.min +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import com.google.transit.realtime.GtfsRealtime.FeedMessage as GFeedMessage + +object GTFSRealTimeTripUpdatesProvider { + + val PROVIDER_PRECISION_IN_MS = 10.seconds.inWholeMilliseconds + + val TRIP_UPDATE_MAX_VALIDITY_IN_MS = 1.hours.inWholeMilliseconds + + val TRIP_UPDATE_VALIDITY_IN_MS = 10.minutes.inWholeMilliseconds + val TRIP_UPDATE_VALIDITY_IN_FOCUS_IN_MS = 10.seconds.inWholeMilliseconds + + @Suppress("unused") + val TRIP_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_MS = 3.minutes.inWholeMilliseconds + + @Suppress("unused") + val TRIP_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_FOCUS_IN_MS = 1.minutes.inWholeMilliseconds + + @Suppress("unused") + @JvmStatic + fun GTFSRealTimeProvider.getMinDurationBetweenRefreshInMs(inFocus: Boolean) = + if (inFocus) TRIP_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_FOCUS_IN_MS.adaptForCachedAPI(this.context) + else TRIP_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_MS.adaptForCachedAPI(this.context) + + @JvmStatic + fun GTFSRealTimeProvider.getValidityInMs(inFocus: Boolean) = + if (inFocus) TRIP_UPDATE_VALIDITY_IN_FOCUS_IN_MS.adaptForCachedAPI(this.context) + else TRIP_UPDATE_VALIDITY_IN_MS.adaptForCachedAPI(this.context) + + @JvmStatic + val GTFSRealTimeProvider.maxValidityInMs: Long get() = TRIP_UPDATE_MAX_VALIDITY_IN_MS.adaptForCachedAPI(this.context) + + private fun Long.adaptForCachedAPI(context: Context?) = + if (context?.let { GTFSRealTimeProvider.getAGENCY_TRIP_UPDATES_URL_CACHED(it) }?.isNotBlank() == true) { + this * 2L // fewer calls to Cached API $$ + } else this + + @JvmStatic + fun GTFSRealTimeProvider.getCached(statusFilter: StatusProviderContract.Filter): POIStatus? { + val filter = statusFilter as? Schedule.ScheduleStatusFilter ?: run { + MTLog.w(this@getCached, "getCached() > Can't find new schedule without schedule filter!") + return null + } + val tripIds = filter.targetAuthority.let { targetAuthority -> + filter.routeId.let { routeId -> + context?.getTripIds(targetAuthority, routeId, filter.directionId) + } + }?.takeIf { tripIds -> tripIds.isNotEmpty() } // trip IDs REQUIRED for GTFS Trip Updates + ?: return null + return getCachedStatusS(filter.targetUUID, tripIds) + ?: makeCachedStatusFromAgencyData(filter, tripIds) + } + + val GTFSRealTimeProvider.ignoreDirection get() = isIGNORE_DIRECTION(this.requireContextCompat()) + + private fun GTFSRealTimeProvider.makeCachedStatusFromAgencyData( + filter: Schedule.ScheduleStatusFilter, + tripIds: List + ): POIStatus? { + val context = context ?: return null + val readFromSourceMs = GtfsRealTimeStorage.getTripUpdateLastUpdateMs(context, 0L) + if (readFromSourceMs <= 0L) return null // never loaded + val sourceLabel = SourceUtils.getSourceLabel( // always use source from official API + GTFSRealTimeProvider.getAgencyTripUpdatesUrlString(context, "T") + ) + try { + val rds = filter.routeDirectionStop + val targetAuthority = rds.authority + val routeId = rds.route.id + val directionId = rds.direction.id + var sortedRDS: List? = null + var uuidSchedule: Map? = null + val gFeedMessage = GFeedMessage.parseFrom(File(context.cacheDir, GTFS_RT_TRIP_UPDATE_PB_FILE_NAME).inputStream()) + val gTripUpdates = gFeedMessage.entityList.toTripUpdates() + val rdTripUpdates = gTripUpdates.mapNotNull { gTripUpdate -> + gTripUpdate.optTrip?.let { it to gTripUpdate } + }.filter { (trip, _) -> + parseTripId(trip)?.let { tripId -> + if (tripId !in tripIds) return@filter false + } + parseRouteId(trip)?.let { routeIdHash -> + if (routeIdHash != rds.route.originalIdHash.toString()) return@filter false + } + trip.optDirectionId?.takeIf { !ignoreDirection }?.let { directionId -> + if (directionId != rds.direction.originalDirectionIdOrNull) return@filter false + } + return@filter true + }.takeIf { it.isNotEmpty() } + rdTripUpdates ?: return null + if (Constants.DEBUG) { + rdTripUpdates.forEach { (_, gTripUpdate) -> + MTLog.d( + this@GTFSRealTimeTripUpdatesProvider, + "makeCachedStatusFromAgencyData() > GTFS [R:'${rds.route.shortestName}'|D:${rds.direction.headsignValue}] trip update: ${gTripUpdate.toStringExt()}." + ) + } + } + if (sortedRDS == null) { + sortedRDS = context.getRDS(rds.authority, routeId, directionId) + } + if (uuidSchedule == null) { + uuidSchedule = sortedRDS + ?.let { rdsList -> + context + .getRDSSchedule(targetAuthority, rdsList) + .associateBy { it.targetUUID } + } + } + uuidSchedule ?: return null + sortedRDS ?: return null + processRDTripUpdates(rdTripUpdates, uuidSchedule, sortedRDS) + uuidSchedule.values.filterNotNull().forEach { schedule -> + if (!schedule.timestamps.any { it.isRealTime }) return@forEach + schedule.sourceLabel = sourceLabel + schedule.readFromSourceAtInMs = readFromSourceMs + schedule.providerPrecisionInMs = PROVIDER_PRECISION_IN_MS + cacheStatus(schedule) + } + return getCachedStatusS(filter.targetUUID, tripIds) + } catch (e: Exception) { + MTLog.w(this, e, "makeCachedStatusFromAgencyData() > error!") + return null + } + } + + @JvmStatic + fun GTFSRealTimeProvider.getNew(statusFilter: StatusProviderContract.Filter): POIStatus? { + val filter = statusFilter as? Schedule.ScheduleStatusFilter ?: run { + MTLog.w(this, "getNew() > Can't find new schedule without schedule filter!") + return null + } + updateAgencyDataIfRequired(filter.isInFocusOrDefault) + return getCached(filter) + } + + private fun GTFSRealTimeProvider.updateAgencyDataIfRequired(inFocus: Boolean) { + val context = requireContextCompat() + var inFocus = inFocus + val lastUpdateInMs = GtfsRealTimeStorage.getTripUpdateLastUpdateMs(context, 0L) + val lastUpdateCode = GtfsRealTimeStorage.getTripUpdateLastUpdateCode(context, -1).takeIf { it >= 0 } + if (lastUpdateCode != null && lastUpdateCode != HttpURLConnection.HTTP_OK) { + inFocus = true // force earlier retry if last fetch returned HTTP error + } + val minUpdateMs = min(statusMaxValidityInMs, getStatusValidityInMs(inFocus)) + val nowInMs = TimeUtils.currentTimeMillis() + if (lastUpdateInMs + minUpdateMs > nowInMs) { + return + } + updateAgencyDataIfRequiredSync(lastUpdateInMs, inFocus) + } + + @Synchronized + private fun GTFSRealTimeProvider.updateAgencyDataIfRequiredSync(lastUpdateInMs: Long, inFocus: Boolean) { + val context = requireContextCompat() + if (GtfsRealTimeStorage.getTripUpdateLastUpdateMs(context, 0L) > lastUpdateInMs) { + return // too late, another thread already updated + } + val nowInMs = TimeUtils.currentTimeMillis() + var deleteAllRequired = false + if (lastUpdateInMs + statusMaxValidityInMs < nowInMs) { + deleteAllRequired = true // too old to display + } + val minUpdateMs = min(statusMaxValidityInMs, getStatusValidityInMs(inFocus)) + if (deleteAllRequired || lastUpdateInMs + minUpdateMs < nowInMs) { + updateAllAgencyDataFromWWW(context, deleteAllRequired) // try to update + } + } + + private fun GTFSRealTimeProvider.updateAllAgencyDataFromWWW(context: Context, deleteAllRequired: Boolean) { + var deleteAllDone = false + if (deleteAllRequired) { + deleteAllCachedStatus() + deleteAllDone = true + } + val newStatuses = loadAgencyDataFromWWW(context) + if (newStatuses != null) { // empty is OK + if (!deleteAllDone) { + deleteAllCachedStatus() + } + cacheAllStatusesBulkLockDB(this, newStatuses) + } // else keep whatever we have until max validity reached + } + + private const val GTFS_RT_TRIP_UPDATE_PB_FILE_NAME = "gtfs_rt_trip_update.pb" + + private fun GTFSRealTimeProvider.loadAgencyDataFromWWW(context: Context): List? { + try { + val urlRequest = makeRequest( + context, + urlCachedString = GTFSRealTimeProvider.getAGENCY_TRIP_UPDATES_URL_CACHED(context), + getUrlString = { token -> GTFSRealTimeProvider.getAgencyTripUpdatesUrlString(context, token) } + ) ?: return null + getOkHttpClient(context).newCall(urlRequest).execute().use { response -> + GtfsRealTimeStorage.saveTripUpdateLastUpdateCode(context, response.code) + GtfsRealTimeStorage.saveTripUpdateLastUpdateMs(context, TimeUtils.currentTimeMillis()) + when (response.code) { + HttpURLConnection.HTTP_OK -> { + val statuses = mutableListOf() + try { + try { + File(context.cacheDir, GTFS_RT_TRIP_UPDATE_PB_FILE_NAME).writeBytes(response.body.bytes()) + } catch (e: IOException) { + MTLog.w(this@GTFSRealTimeTripUpdatesProvider, e, "loadAgencyDataFromWWW() > error while saving GTFS RT Trip Updates data!") + } + return null + } catch (e: Exception) { + MTLog.w(this@GTFSRealTimeTripUpdatesProvider, e, "loadAgencyDataFromWWW() > error while parsing GTFS Real Time data!") + } + MTLog.i(this@GTFSRealTimeTripUpdatesProvider, "Found %d trip updates.", statuses.size) + if (Constants.DEBUG) { + for (schedule in statuses) { + MTLog.d(this@GTFSRealTimeTripUpdatesProvider, "loadAgencyDataFromWWW() > - new $schedule.") + } + } + return statuses + } + + else -> { + MTLog.w( + this@GTFSRealTimeTripUpdatesProvider, + "ERROR: HTTP URL-Connection Response Code ${response.code} (Message: ${response.message})" + ) + return null + } + } + } + } catch (sslhe: SSLHandshakeException) { + MTLog.w(this, sslhe, "SSL error!") + SecurityUtils.logCertPathValidatorException(sslhe) + GtfsRealTimeStorage.saveTripUpdateLastUpdateCode(context, 567) // SSL certificate not trusted (on this device) + GtfsRealTimeStorage.saveTripUpdateLastUpdateMs(context, TimeUtils.currentTimeMillis()) + return null + } catch (uhe: UnknownHostException) { + if (MTLog.isLoggable(Log.DEBUG)) { + MTLog.w(this@GTFSRealTimeTripUpdatesProvider, uhe, "No Internet Connection!") + } else { + MTLog.w(this@GTFSRealTimeTripUpdatesProvider, "No Internet Connection!") + } + return null + } catch (se: SocketException) { + MTLog.w(this@GTFSRealTimeTripUpdatesProvider, se, "No Internet Connection!") + return null + } catch (e: Exception) { // Unknown error + MTLog.e(this@GTFSRealTimeTripUpdatesProvider, e, "INTERNAL ERROR: Unknown Exception") + return null + } + } +} diff --git a/src/main/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProviderExt.kt b/src/main/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProviderExt.kt new file mode 100644 index 00000000..c0053a5a --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProviderExt.kt @@ -0,0 +1,236 @@ +package org.mtransit.android.commons.provider.status + +import org.mtransit.android.commons.TimeUtilsK +import org.mtransit.android.commons.data.RouteDirectionStop +import org.mtransit.android.commons.data.Schedule +import org.mtransit.android.commons.data.arrival +import org.mtransit.android.commons.data.arrivalDiff +import org.mtransit.android.commons.data.departure +import org.mtransit.android.commons.provider.GTFSRealTimeProvider +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optArrival +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optDelay +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optDeparture +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optScheduleRelationship +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optStopId +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optStopSequence +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optStopTimeUpdateList +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTimeInstant +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTrip +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTripId +import org.mtransit.android.commons.provider.gtfs.parseStopId +import org.mtransit.android.commons.provider.gtfs.parseTripId +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import com.google.transit.realtime.GtfsRealtime.TripDescriptor as GTripDescriptor +import com.google.transit.realtime.GtfsRealtime.TripDescriptor.ScheduleRelationship as GTDScheduleRelationship +import com.google.transit.realtime.GtfsRealtime.TripUpdate as GTripUpdate +import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeEvent as GTUStopTimeEvent +import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate as GTUStopTimeUpdate +import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate.ScheduleRelationship as GTUSTUScheduleRelationship + +fun GTFSRealTimeProvider.processRDTripUpdates( + rdTripUpdates: List>, + targetUuidSchedule: Map, + sortedRDS: List +) { + rdTripUpdates.forEach { (td, gTripUpdate) -> + val gTripId = td.optTripId ?: return@forEach + val tripId = parseTripId(gTripId) + val tripTargetUuidSchedule = targetUuidSchedule + .filter { (_, schedule) -> schedule?.timestamps?.any { it.tripId == tripId } == true } + .takeIf { it.isNotEmpty() } + ?: return@forEach + val tripSortedRDS = sortedRDS + .filter { rds -> tripTargetUuidSchedule.contains(rds.uuid) } + .takeIf { it.isNotEmpty() } + ?: return@forEach + val sortedTargetUuidAndSequence = makeTargetUuidAndSequenceList(tripId, tripTargetUuidSchedule, tripSortedRDS) + processRDTripUpdate( + tripId, gTripUpdate, tripSortedRDS, sortedTargetUuidAndSequence, tripTargetUuidSchedule, + isSameStop = { stu, rds, stopSeq -> isSameStop(stu, rds, stopSeq) }, + ) + } +} + +internal fun makeTargetUuidAndSequenceList( + tripId: String, + tripTargetUuidSchedule: Map, + tripSortedRDS: List, +): List> { + if (tripTargetUuidSchedule.values.any { + it?.timestamps?.filter { timestamp -> timestamp.tripId == tripId }?.any { timestamp -> timestamp.stopSequenceOrNull == null } == true + }) { + // should not happen if FF is turned ON + return tripSortedRDS + .mapIndexed { index, rds -> + rds.uuid to index + 1 // generated stop sequence + } + .sortedBy { (_, stopSequence) -> stopSequence } + } + var generatedStopSequence = 1 + return buildList { + tripTargetUuidSchedule.forEach { (targetUuid, schedule) -> + schedule?.timestamps?.filter { it.tripId == tripId }?.forEach { timestamp -> + val stopSequence = timestamp.stopSequenceOrNull ?: generatedStopSequence + add(targetUuid to stopSequence) + generatedStopSequence = stopSequence + 1 // next probable stop sequence + } + } + }.sortedBy { (_, stopSequence) -> stopSequence } +} + +internal fun processRDTripUpdate( + tripId: String, + gTripUpdate: GTripUpdate, + tripSortedRDS: List, + sortedTargetUuidAndSequence: List>, + tripTargetUuidSchedule: Map, + isSameStop: (GTUStopTimeUpdate?, RouteDirectionStop, Int) -> Boolean, +) { + if (gTripUpdate.optTrip?.optScheduleRelationship == GTDScheduleRelationship.CANCELED + || gTripUpdate.optTrip?.optScheduleRelationship == GTDScheduleRelationship.DELETED + ) { + tripTargetUuidSchedule.values.forEach { schedule -> + schedule ?: return@forEach + schedule.timestamps.filter { it.tripId == tripId }.forEach { + schedule.removeTimestamp(it) + } + } + } + var stuIdx = 0 + var uuidAndSeqIdx = 0 + var currentDelay = gTripUpdate.optDelay?.seconds // initial delay valid until 1st stop time update + val gStopTimeUpdates = gTripUpdate.optStopTimeUpdateList?.sortedBy { it.optStopSequence } + var currentStopTimeUpdate: GTUStopTimeUpdate? + var nextStopTimeUpdate: GTUStopTimeUpdate? = gStopTimeUpdates?.getOrNull(stuIdx) + var currentUuidAndSeq = sortedTargetUuidAndSequence.getOrNull(uuidAndSeqIdx) + ?: return // no more stop + var currentRDS: RouteDirectionStop = tripSortedRDS.singleOrNull { it.uuid == currentUuidAndSeq.first } + ?: return // stop not found! + while (uuidAndSeqIdx <= sortedTargetUuidAndSequence.size) { + while (!isSameStop(nextStopTimeUpdate, currentRDS, currentUuidAndSeq.second) + && uuidAndSeqIdx <= sortedTargetUuidAndSequence.size // allow null currentRDS to signify end of trip + ) { + currentDelay = applyDelay(tripId, currentUuidAndSeq.second, tripTargetUuidSchedule[currentRDS.uuid], currentDelay) + currentUuidAndSeq = sortedTargetUuidAndSequence.getOrNull(++uuidAndSeqIdx) ?: break // no more stop + currentRDS = tripSortedRDS.singleOrNull { it.uuid == currentUuidAndSeq.first } ?: break // stop not found! + } + if (uuidAndSeqIdx >= sortedTargetUuidAndSequence.size) break // no more stop + currentStopTimeUpdate = nextStopTimeUpdate ?: break // no more stop time update + nextStopTimeUpdate = gStopTimeUpdates?.getOrNull(++stuIdx) + currentDelay = applyDelaySTU(tripId, currentUuidAndSeq.second, tripTargetUuidSchedule[currentRDS.uuid], currentStopTimeUpdate, currentDelay) + currentUuidAndSeq = sortedTargetUuidAndSequence.getOrNull(++uuidAndSeqIdx) ?: break // no more stop + currentRDS = tripSortedRDS.singleOrNull { it.uuid == currentUuidAndSeq.first } ?: break // stop not found! + } +} + +internal fun applyDelaySTU( + tripId: String, + stopSequence: Int, + rdsSchedule: Schedule?, + gStopTimeUpdate: GTUStopTimeUpdate, + currentDelay: Duration? = null, +): Duration? { + val rdsTripTimestamp = rdsSchedule + ?.timestamps?.filter { it.tripId == tripId } + ?.filter { timestamp -> + (timestamp.stopSequenceOrNull == null || timestamp.stopSequenceOrNull == stopSequence) + }?.let { rdsTripTimestamps -> + if (rdsTripTimestamps.size > 1) { + val now = TimeUtilsK.currentInstant() + rdsTripTimestamps.sortedBy { (it.departure - now).absoluteValue } + } else { + rdsTripTimestamps + }.firstOrNull() + } + ?: return null // impossible to handle + val timestampOriginalArrival = rdsTripTimestamp.arrival + val timestampOriginalDeparture = rdsTripTimestamp.departure + val timestampOriginalArrivalDiff = rdsTripTimestamp.arrivalDiff ?: Duration.ZERO + val stuArrivalDelay = gStopTimeUpdate.optArrival + .takeIf { gStopTimeUpdate.scheduleRelationship != GTUSTUScheduleRelationship.NO_DATA } + .makeDelay(timestampOriginalArrival) + ?: currentDelay + .takeIf { gStopTimeUpdate.scheduleRelationship != GTUSTUScheduleRelationship.NO_DATA } + val stuDepartureDelay = gStopTimeUpdate.optDeparture + .takeIf { gStopTimeUpdate.scheduleRelationship != GTUSTUScheduleRelationship.NO_DATA } + .makeDelay(timestampOriginalDeparture, stuArrivalDelay, timestampOriginalArrivalDiff) + stuArrivalDelay?.let { rdsTripTimestamp.arrival += it; rdsTripTimestamp.realTime = true } + stuDepartureDelay?.let { rdsTripTimestamp.departure += it; rdsTripTimestamp.realTime = true } + if (gStopTimeUpdate.scheduleRelationship == GTUSTUScheduleRelationship.SKIPPED) { + rdsSchedule.removeTimestamp(rdsTripTimestamp) + } + return stuDepartureDelay + .takeIf { gStopTimeUpdate.scheduleRelationship != GTUSTUScheduleRelationship.NO_DATA } +} + +internal fun GTUStopTimeEvent?.makeDelay( + originalTime: Instant, + previousDelay: Duration? = null, + previousOriginalDiff: Duration? = null, +): Duration? { + return this?.optDelay?.seconds + ?: this?.optTimeInstant?.let { time -> time - originalTime } + ?: previousDelay?.let { + previousOriginalDiff?.let { + (previousDelay - previousOriginalDiff).coerceAtLeast(Duration.ZERO) + } + } +} + +internal fun applyDelay( + tripId: String, + stopSequence: Int, + rdsSchedule: Schedule?, + currentDelay: Duration? +): Duration? { + currentDelay ?: return null + val rdsTripTimestamp = rdsSchedule + ?.timestamps?.filter { it.tripId == tripId } + ?.filter { timestamp -> + (timestamp.stopSequenceOrNull == null || timestamp.stopSequenceOrNull == stopSequence) + }?.let { rdsTripTimestamps -> + if (rdsTripTimestamps.size > 1) { + val now = TimeUtilsK.currentInstant() + rdsTripTimestamps.sortedBy { (it.departure - now).absoluteValue } + } else { + rdsTripTimestamps + }.firstOrNull() + } + ?: return currentDelay + val currentDiffBetweenArrivalAndDeparture = rdsTripTimestamp.departure - rdsTripTimestamp.arrival + if (currentDelay < Duration.ZERO) { + rdsTripTimestamp.arrival += currentDelay + rdsTripTimestamp.departure += currentDelay + rdsTripTimestamp.realTime = true + return currentDelay // do not consume negative delay + } else if (currentDiffBetweenArrivalAndDeparture <= currentDelay) { + rdsTripTimestamp.arrival += currentDelay + val newDelay = (currentDelay - currentDiffBetweenArrivalAndDeparture).coerceAtLeast(Duration.ZERO) + rdsTripTimestamp.departure += newDelay + rdsTripTimestamp.realTime = true + return newDelay + } else { + rdsTripTimestamp.arrival += currentDelay + rdsTripTimestamp.realTime = true + return Duration.ZERO // all delay consumed + } +} + +private fun GTFSRealTimeProvider.isSameStop(stopTimeUpdate: GTUStopTimeUpdate?, rds: RouteDirectionStop?, stopSequence: Int) = + stopTimeUpdate?.isSameStop(rds, stopSequence, this::parseStopId) == true + +internal fun GTUStopTimeUpdate.isSameStop( + rds: RouteDirectionStop?, + stopSequence: Int, + parseStopId: (String) -> String, +): Boolean { + rds ?: return false + val sameOrUnspecifiedStopSequence = this.optStopSequence + ?.let { it == stopSequence } + val sameOrUnspecifiedStopId = this.optStopId?.let { + rds.stop.isSameOriginalId(parseStopId(it)) + } + return sameOrUnspecifiedStopSequence == true || sameOrUnspecifiedStopId == true +} diff --git a/src/main/java/org/mtransit/android/commons/provider/status/StatusProvider.java b/src/main/java/org/mtransit/android/commons/provider/status/StatusProvider.java index 103e7a72..7e85e535 100644 --- a/src/main/java/org/mtransit/android/commons/provider/status/StatusProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/status/StatusProvider.java @@ -204,7 +204,7 @@ public static void cacheStatusS(@NonNull StatusProviderContract provider, @NonNu private static final String STATUS_SORT_ORDER = SqlUtils.getSortOrderDescending(Columns.T_STATUS_K_LAST_UPDATE); @Nullable - private static POIStatus getCachedStatusS(@NonNull StatusProviderContract provider, + public static POIStatus getCachedStatusS(@NonNull StatusProviderContract provider, @SuppressWarnings("unused") Uri uri, String selection) { POIStatus cache = null; @@ -252,6 +252,16 @@ public static POIStatus getCachedStatusS(@NonNull StatusProviderContract provide ); } + public static boolean deleteAllCachedStatus(@NonNull StatusProviderContract provider) { + int deletedRows = 0; + try { + deletedRows = provider.getWriteDB().delete(provider.getStatusDbTableName(), null, null); + } catch (Exception e) { + MTLog.w(LOG_TAG, e, "Error while deleting ALL cached statuses!"); + } + return deletedRows > 0; + } + public static boolean deleteCachedStatus(@NonNull StatusProviderContract provider, int cachedStatusId) { String selection = SqlUtils.getWhereEquals(StatusProviderContract.Columns.T_STATUS_K_ID, cachedStatusId); int deletedRows = 0; diff --git a/src/main/java/org/mtransit/android/commons/provider/status/StatusProviderExt.kt b/src/main/java/org/mtransit/android/commons/provider/status/StatusProviderExt.kt new file mode 100644 index 00000000..c11df2ae --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/status/StatusProviderExt.kt @@ -0,0 +1,43 @@ +package org.mtransit.android.commons.provider.status + +import android.net.Uri +import org.mtransit.android.commons.SqlUtils +import org.mtransit.android.commons.data.POIStatus + +@JvmOverloads +fun

P.getCachedStatusS( + targetUUID: String, + tripIds: List? = null +) = getCachedStatusS(listOf(targetUUID), tripIds) + +@JvmOverloads +fun

P.getCachedStatusS( + targetUUIDs: Collection, + @Suppress("unused") tripIds: List? = null +): POIStatus? { + return StatusProvider.getCachedStatusS( + this, + this.contentUri, + buildString { + append(SqlUtils.getWhereInString(StatusProviderContract.Columns.T_STATUS_K_TARGET_UUID, targetUUIDs)) + // TODO ? if (FeatureFlags.F_USE_TRIP_IS_FOR_STATUSES) { + // tripIds?.takeIf { it.isNotEmpty() }?.let { + // append(SqlUtils.AND) + // append( + // SqlUtils.getWhereGroup( + // SqlUtils.OR, + // SqlUtils.getWhereInString(StatusProviderContract.Columns.T_STATUS_K_TARGET_TRIP_ID, it), + // SqlUtils.getWhereColumnIsNull(StatusProviderContract.Columns.T_STATUS_K_TARGET_TRIP_ID), + // ) + // ) + // } + // } + } + ) +} + +private val StatusProviderContract.contentUri: Uri + get() = Uri.withAppendedPath(this.authorityUri, StatusProviderContract.STATUS_PATH) + +private val StatusProviderContract.dbTableName: String + get() = this.statusDbTableName diff --git a/src/main/java/org/mtransit/android/commons/provider/vehiclelocations/GTFSRealTimeVehiclePositionsProvider.kt b/src/main/java/org/mtransit/android/commons/provider/vehiclelocations/GTFSRealTimeVehiclePositionsProvider.kt index 3184b691..b65d64ed 100644 --- a/src/main/java/org/mtransit/android/commons/provider/vehiclelocations/GTFSRealTimeVehiclePositionsProvider.kt +++ b/src/main/java/org/mtransit/android/commons/provider/vehiclelocations/GTFSRealTimeVehiclePositionsProvider.kt @@ -1,8 +1,6 @@ package org.mtransit.android.commons.provider.vehiclelocations import android.content.Context -import com.google.transit.realtime.GtfsRealtime -import com.google.transit.realtime.GtfsRealtime.FeedMessage import org.mtransit.android.commons.Constants import org.mtransit.android.commons.MTLog import org.mtransit.android.commons.SecurityUtils @@ -20,14 +18,10 @@ import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optLabel import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optLatitude import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optLongitude import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optPosition -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optRouteId import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optSpeed import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTimestamp import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTrip -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTripId import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optVehicle -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.originalIdToHash -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.originalIdToId import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.sortVehicles import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.toStringExt import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.toVehicles @@ -35,8 +29,8 @@ import org.mtransit.android.commons.provider.gtfs.agencyTag import org.mtransit.android.commons.provider.gtfs.getTargetUUIDs import org.mtransit.android.commons.provider.gtfs.getTripIds import org.mtransit.android.commons.provider.gtfs.makeRequest -import org.mtransit.android.commons.provider.gtfs.routeIdCleanupPattern -import org.mtransit.android.commons.provider.gtfs.tripIdCleanupPattern +import org.mtransit.android.commons.provider.gtfs.parseRouteId +import org.mtransit.android.commons.provider.gtfs.parseTripId import org.mtransit.android.commons.provider.vehiclelocations.VehicleLocationProvider.Companion.getCachedVehicleLocationsS import org.mtransit.android.commons.provider.vehiclelocations.model.VehicleLocation import org.mtransit.android.commons.secsToInstant @@ -48,6 +42,9 @@ import kotlin.math.min import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import com.google.transit.realtime.GtfsRealtime.FeedMessage as GFeedMessage +import com.google.transit.realtime.GtfsRealtime.TripDescriptor.ScheduleRelationship as GTDScheduleRelationship +import com.google.transit.realtime.GtfsRealtime.VehiclePosition as GVehiclePosition object GTFSRealTimeVehiclePositionsProvider { @@ -177,7 +174,7 @@ object GTFSRealTimeVehiclePositionsProvider { val vehicleLocations = mutableListOf() val ignoreDirection = GTFSRealTimeProvider.isIGNORE_DIRECTION(context) try { - val gFeedMessage = FeedMessage.parseFrom(response.body.bytes()) + val gFeedMessage = GFeedMessage.parseFrom(response.body.bytes()) val gVehiclePositions = gFeedMessage.entityList.toVehicles() for (gVehiclePosition in gVehiclePositions.sortVehicles(newLastUpdateInMs)) { if (Constants.DEBUG) { @@ -237,7 +234,7 @@ object GTFSRealTimeVehiclePositionsProvider { private fun GTFSRealTimeProvider.processVehiclePositions( newLastUpdateInMs: Long, - gVehiclePosition: GtfsRealtime.VehiclePosition, + gVehiclePosition: GVehiclePosition, ignoreDirection: Boolean, ): Set? { val targetUUIDs = parseProviderTargetUUID(gVehiclePosition, ignoreDirection)?.takeIf { it.isNotBlank() } ?: return null @@ -245,7 +242,7 @@ object GTFSRealTimeVehiclePositionsProvider { VehicleLocation( authority = this.authority, targetUUID = targetUUIDs, - targetTripId = gVehiclePosition.optTrip?.optTripId?.originalIdToId(tripIdCleanupPattern), + targetTripId = gVehiclePosition.optTrip?.let { parseTripId(it) }, lastUpdateInMs = newLastUpdateInMs, maxValidityInMs = this@processVehiclePositions.vehicleLocationMaxValidityInMs, // @@ -260,7 +257,7 @@ object GTFSRealTimeVehiclePositionsProvider { ) } - private fun GTFSRealTimeProvider.parseProviderTargetUUID(gVehiclePosition: GtfsRealtime.VehiclePosition, ignoreDirection: Boolean): String? { + private fun GTFSRealTimeProvider.parseProviderTargetUUID(gVehiclePosition: GVehiclePosition, ignoreDirection: Boolean): String? { val gTripDescriptor = gVehiclePosition.optTrip ?: return null if (gTripDescriptor.hasModifiedTrip()) { MTLog.d(this, "parseTargetUUID() > unhandled modified trip: ${gTripDescriptor.toStringExt()}") @@ -269,17 +266,17 @@ object GTFSRealTimeVehiclePositionsProvider { MTLog.d(this, "parseTargetUUID() > unhandled start date & time: ${gTripDescriptor.toStringExt()}") } when (gTripDescriptor.scheduleRelationship) { - GtfsRealtime.TripDescriptor.ScheduleRelationship.SCHEDULED -> {} // handled - GtfsRealtime.TripDescriptor.ScheduleRelationship.ADDED, - GtfsRealtime.TripDescriptor.ScheduleRelationship.UNSCHEDULED, - GtfsRealtime.TripDescriptor.ScheduleRelationship.CANCELED, - GtfsRealtime.TripDescriptor.ScheduleRelationship.REPLACEMENT, - GtfsRealtime.TripDescriptor.ScheduleRelationship.DUPLICATED, - GtfsRealtime.TripDescriptor.ScheduleRelationship.DELETED, - GtfsRealtime.TripDescriptor.ScheduleRelationship.NEW, + GTDScheduleRelationship.SCHEDULED -> {} // handled + GTDScheduleRelationship.ADDED, + GTDScheduleRelationship.UNSCHEDULED, + GTDScheduleRelationship.CANCELED, + GTDScheduleRelationship.REPLACEMENT, + GTDScheduleRelationship.DUPLICATED, + GTDScheduleRelationship.DELETED, + GTDScheduleRelationship.NEW, -> MTLog.d(this, "parseTargetUUID() > unhandled schedule relationship: ${gTripDescriptor.scheduleRelationship}") } - gTripDescriptor.optRouteId?.originalIdToHash(routeIdCleanupPattern)?.let { routeId -> + parseRouteId(gTripDescriptor)?.let { routeId -> gTripDescriptor.optDirectionId?.takeIf { !ignoreDirection }?.let { directionId -> return getAgencyRouteDirectionTagTargetUUID(agencyTag, routeId, directionId) } diff --git a/src/main/res/values/gtfs_real_time_values.xml b/src/main/res/values/gtfs_real_time_values.xml index 308779d8..f7c867e1 100755 --- a/src/main/res/values/gtfs_real_time_values.xml +++ b/src/main/res/values/gtfs_real_time_values.xml @@ -5,6 +5,8 @@ @string/poi_agency_authority false + + diff --git a/src/test/java/org/mtransit/android/commons/data/ScheduleExtTests.kt b/src/test/java/org/mtransit/android/commons/data/ScheduleExtTests.kt new file mode 100644 index 00000000..6a07f28e --- /dev/null +++ b/src/test/java/org/mtransit/android/commons/data/ScheduleExtTests.kt @@ -0,0 +1,25 @@ +package org.mtransit.android.commons.data + +import org.mtransit.android.commons.secsToInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes + +class ScheduleExtTests { + + companion object { + private const val LOCAL_TZ_ID: String = "America/Montreal" + private const val DEPARTURE_MS = 1772722800L // 2026-03-06 10:00: + } + + @Test + fun test_departure_update_no_effect_on_arrival() { + val departure = DEPARTURE_MS.secsToInstant() + val arrival = departure - 1.minutes + val timestamp = departure.toScheduleTimestamp(LOCAL_TZ_ID, arrival) + + timestamp.departure += 1.minutes + assertEquals(arrival, timestamp.arrival) + assertEquals(departure + 1.minutes, timestamp.departure) + } +} diff --git a/src/test/java/org/mtransit/android/commons/provider/CaLTCOnlineProviderTest.java b/src/test/java/org/mtransit/android/commons/provider/CaLTCOnlineProviderTest.java index 9c14b462..e937e2de 100644 --- a/src/test/java/org/mtransit/android/commons/provider/CaLTCOnlineProviderTest.java +++ b/src/test/java/org/mtransit/android/commons/provider/CaLTCOnlineProviderTest.java @@ -117,16 +117,16 @@ public void testParseAgencyJSON() { schedule.getTimestamps().get(0).getHeading()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(52770)), - schedule.getTimestamps().get(0).getT()); + schedule.getTimestamps().get(0).getDepartureT()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(54975)), - schedule.getTimestamps().get(1).getT()); + schedule.getTimestamps().get(1).getDepartureT()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(55215)), - schedule.getTimestamps().get(2).getT()); + schedule.getTimestamps().get(2).getDepartureT()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(55980)), - schedule.getTimestamps().get(3).getT()); + schedule.getTimestamps().get(3).getDepartureT()); } else if (_7_E.equalsIgnoreCase(targetUUID)) { assertEquals( "Argyle Mall Via York", @@ -134,10 +134,10 @@ public void testParseAgencyJSON() { assertEquals(2, schedule.getTimestampsCount()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(52725)), - schedule.getTimestamps().get(0).getT()); + schedule.getTimestamps().get(0).getDepartureT()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(54225)), - schedule.getTimestamps().get(1).getT()); + schedule.getTimestamps().get(1).getDepartureT()); } else if (_17_E.equalsIgnoreCase(targetUUID)) { assertEquals( "Argyle Mall Via Oxford", @@ -145,10 +145,10 @@ public void testParseAgencyJSON() { assertEquals(2, schedule.getTimestampsCount()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(52770)), - schedule.getTimestamps().get(0).getT()); + schedule.getTimestamps().get(0).getDepartureT()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(54495)), - schedule.getTimestamps().get(1).getT()); + schedule.getTimestamps().get(1).getDepartureT()); } else if (_17_W.equalsIgnoreCase(targetUUID)) { assertEquals( "Byron Via Oxford", @@ -156,10 +156,10 @@ public void testParseAgencyJSON() { assertEquals(2, schedule.getTimestampsCount()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(53490)), - schedule.getTimestamps().get(0).getT()); + schedule.getTimestamps().get(0).getDepartureT()); assertEquals( TimeUtils.timeToTheTensSecondsMillis(beginningOfTodayInMs + TimeUnit.SECONDS.toMillis(55215)), - schedule.getTimestamps().get(1).getT()); + schedule.getTimestamps().get(1).getDepartureT()); } else { fail("Unexpected target UUID'" + targetUUID + "'!"); } diff --git a/src/test/java/org/mtransit/android/commons/provider/GTFSRealTimeProviderTest.kt b/src/test/java/org/mtransit/android/commons/provider/GTFSRealTimeProviderTest.kt index b1cb929b..6561b1b6 100644 --- a/src/test/java/org/mtransit/android/commons/provider/GTFSRealTimeProviderTest.kt +++ b/src/test/java/org/mtransit/android/commons/provider/GTFSRealTimeProviderTest.kt @@ -1,6 +1,5 @@ package org.mtransit.android.commons.provider -import com.google.transit.realtime.GtfsRealtime import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -10,6 +9,8 @@ import org.mockito.kotlin.whenever import org.mtransit.android.commons.TimeUtils import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.isActive import org.mtransit.commons.msToSec +import com.google.transit.realtime.GtfsRealtime.Alert as GAlert +import com.google.transit.realtime.GtfsRealtime.TimeRange as GTimeRange class GTFSRealTimeProviderTest { @@ -40,8 +41,8 @@ class GTFSRealTimeProviderTest { @Test fun testIsInActivePeriod_InRange() { val nowInMs = TimeUtils.currentTimeMillis() - val gAlert = GtfsRealtime.Alert.newBuilder() - .addActivePeriod(mock().apply { + val gAlert = GAlert.newBuilder() + .addActivePeriod(mock().apply { whenever(hasStart()).thenReturn(true) whenever(start).thenReturn((nowInMs - 1000L).msToSec()) whenever(hasEnd()).thenReturn(true) @@ -57,8 +58,8 @@ class GTFSRealTimeProviderTest { @Test fun testIsInActivePeriod_InRange_StartOnly() { val nowInMs = TimeUtils.currentTimeMillis() - val gAlert = GtfsRealtime.Alert.newBuilder() - .addActivePeriod(mock().apply { + val gAlert = GAlert.newBuilder() + .addActivePeriod(mock().apply { whenever(hasStart()).thenReturn(true) whenever(start).thenReturn((nowInMs - 1000L).msToSec()) whenever(hasEnd()).thenReturn(false) @@ -73,8 +74,8 @@ class GTFSRealTimeProviderTest { @Test fun testIsInActivePeriod_InRange_EndOnly() { val nowInMs = TimeUtils.currentTimeMillis() - val gAlert = GtfsRealtime.Alert.newBuilder() - .addActivePeriod(mock().apply { + val gAlert = GAlert.newBuilder() + .addActivePeriod(mock().apply { whenever(hasStart()).thenReturn(false) whenever(hasEnd()).thenReturn(true) whenever(end).thenReturn((nowInMs + 1000L).msToSec()) @@ -89,8 +90,8 @@ class GTFSRealTimeProviderTest { @Test fun testIsInActivePeriod_OutRange_Before() { val nowInMs = TimeUtils.currentTimeMillis() - val gAlert = GtfsRealtime.Alert.newBuilder() - .addActivePeriod(mock().apply { + val gAlert = GAlert.newBuilder() + .addActivePeriod(mock().apply { whenever(hasStart()).thenReturn(true) whenever(start).thenReturn((nowInMs - 2000L).msToSec()) whenever(hasEnd()).thenReturn(true) @@ -106,8 +107,8 @@ class GTFSRealTimeProviderTest { @Test fun testIsInActivePeriod_OutRange_After() { val nowInMs = TimeUtils.currentTimeMillis() - val gAlert = GtfsRealtime.Alert.newBuilder() - .addActivePeriod(mock().apply { + val gAlert = GAlert.newBuilder() + .addActivePeriod(mock().apply { whenever(hasStart()).thenReturn(true) whenever(start).thenReturn((nowInMs + 1000L).msToSec()) whenever(hasEnd()).thenReturn(true) @@ -123,7 +124,7 @@ class GTFSRealTimeProviderTest { // https://gtfs.org/realtime/feed-entities/service-alerts/#timerange @Test fun testIsInActivePeriod_0_Range() { - val gAlert = GtfsRealtime.Alert.newBuilder() + val gAlert = GAlert.newBuilder() // no active period .buildPartial() diff --git a/src/test/java/org/mtransit/android/commons/provider/OCTranspoProviderTest.java b/src/test/java/org/mtransit/android/commons/provider/OCTranspoProviderTest.java index f8fa59b7..84d5c833 100644 --- a/src/test/java/org/mtransit/android/commons/provider/OCTranspoProviderTest.java +++ b/src/test/java/org/mtransit/android/commons/provider/OCTranspoProviderTest.java @@ -215,6 +215,6 @@ public void testParseAgencyJSONArrivalsResults_TwoDirections() { // stop: Lyon # assertNotNull(schedule); assertEquals(3, schedule.getTimestampsCount()); Schedule.Timestamp t0 = schedule.getTimestamps().get(0); - assertEquals("20191221102210", OCTranspoProvider.getDateFormat(context).formatThreadSafe(t0.getT())); + assertEquals("20191221102210", OCTranspoProvider.getDateFormat(context).formatThreadSafe(t0.getDepartureT())); } } \ No newline at end of file diff --git a/src/test/java/org/mtransit/android/commons/provider/StmInfoApiProviderTests.java b/src/test/java/org/mtransit/android/commons/provider/StmInfoApiProviderTests.java index 43583cfd..e48bec1e 100644 --- a/src/test/java/org/mtransit/android/commons/provider/StmInfoApiProviderTests.java +++ b/src/test/java/org/mtransit/android/commons/provider/StmInfoApiProviderTests.java @@ -93,14 +93,14 @@ public void testParseAgencyJSONArrivalsResults() { Schedule schedule = ((Schedule) result.iterator().next()); List timestamps = schedule.getTimestamps(); assertEquals(jResults.size(), timestamps.size()); - assertEquals(1533067680000L, timestamps.get(0).t); - assertEquals(1533068760000L, timestamps.get(1).t); - assertEquals(1533069600000L, timestamps.get(2).t); - assertEquals(1533070500000L, timestamps.get(3).t); - assertEquals(1533071580000L, timestamps.get(4).t); - assertEquals(1533072180000L, timestamps.get(5).t); - assertEquals(1533150000000L, timestamps.get(6).t); - assertEquals(1533154560000L, timestamps.get(7).t); + assertEquals(1533067680000L, timestamps.get(0).getDepartureT()); + assertEquals(1533068760000L, timestamps.get(1).getDepartureT()); + assertEquals(1533069600000L, timestamps.get(2).getDepartureT()); + assertEquals(1533070500000L, timestamps.get(3).getDepartureT()); + assertEquals(1533071580000L, timestamps.get(4).getDepartureT()); + assertEquals(1533072180000L, timestamps.get(5).getDepartureT()); + assertEquals(1533150000000L, timestamps.get(6).getDepartureT()); + assertEquals(1533154560000L, timestamps.get(7).getDepartureT()); } @Test @@ -122,12 +122,12 @@ public void testParseAgencyJSONArrivalsResultsRealTimeNotInMinute() { Schedule schedule = ((Schedule) result.iterator().next()); List timestamps = schedule.getTimestamps(); assertEquals(jResults.size(), timestamps.size()); - assertEquals(1533067680000L, timestamps.get(0).t); - assertEquals(1533068760000L, timestamps.get(1).t); - assertEquals(1533069600000L, timestamps.get(2).t); - assertEquals(1533070500000L, timestamps.get(3).t); - assertEquals(1533071580000L, timestamps.get(4).t); - assertEquals(1533072180000L, timestamps.get(5).t); + assertEquals(1533067680000L, timestamps.get(0).getDepartureT()); + assertEquals(1533068760000L, timestamps.get(1).getDepartureT()); + assertEquals(1533069600000L, timestamps.get(2).getDepartureT()); + assertEquals(1533070500000L, timestamps.get(3).getDepartureT()); + assertEquals(1533071580000L, timestamps.get(4).getDepartureT()); + assertEquals(1533072180000L, timestamps.get(5).getDepartureT()); } @Test @@ -163,13 +163,14 @@ public void testParseAgencyJSONArrivalsResultsInThePastCongestion() { List timestamps = schedule.getTimestamps(); assertEquals(jResults.size(), timestamps.size()); assertTrue(timestamps.get(0).hasHeadsign()); - assertEquals(1538775660000L, timestamps.get(0).t); - assertEquals(1538777700000L, timestamps.get(1).t); - assertEquals(1538779740000L, timestamps.get(2).t); - assertEquals(1538781780000L, timestamps.get(3).t); - assertEquals(1538783760000L, timestamps.get(4).t); + assertEquals(1538775660000L, timestamps.get(0).getDepartureT()); + assertEquals(1538777700000L, timestamps.get(1).getDepartureT()); + assertEquals(1538779740000L, timestamps.get(2).getDepartureT()); + assertEquals(1538781780000L, timestamps.get(3).getDepartureT()); + assertEquals(1538783760000L, timestamps.get(4).getDepartureT()); } + @SuppressWarnings("deprecation") @Test public void testParseAgencyJSONMessageResults() { // Arrange @@ -236,6 +237,7 @@ public void testParseAgencyJSONMessageResults() { serviceUpdate.getTargetUUID()); } + @SuppressWarnings("deprecation") @Test public void testParseAgencyJSONMessageResultsFr() { // Arrange @@ -302,6 +304,7 @@ public void testParseAgencyJSONMessageResultsFr() { serviceUpdate.getTargetUUID()); } + @SuppressWarnings("deprecation") @Test public void testParseAgencyJSONMessageResultsNonStandardDirectionName() { // Arrange @@ -436,6 +439,7 @@ public void testParseAgencyJSONMessageResultsNonStandardDirectionName() { serviceUpdate.getTargetUUID()); } + @SuppressWarnings("deprecation") @Test public void testParseAgencyJSONMessageResultsNonStandardDirectionName2() { // Arrange @@ -522,6 +526,7 @@ public void testParseAgencyJSONMessageResultsNonStandardDirectionName2() { serviceUpdate.getTargetUUID()); } + @SuppressWarnings("deprecation") @Test public void testParseAgencyJSONMessageResultsServiceNormal() { // Arrange @@ -574,6 +579,7 @@ public void testParseAgencyJSONMessageResultsServiceNormal() { assertFalse(ServiceUpdate.isSeverityInfo(serviceUpdate.getSeverity())); } + @SuppressWarnings("deprecation") @Test public void testParseAgencyJSONMessageResultsNoMessages() { // Arrange diff --git a/src/test/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProviderTests.kt b/src/test/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProviderTests.kt new file mode 100644 index 00000000..2ff22ed1 --- /dev/null +++ b/src/test/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProviderTests.kt @@ -0,0 +1,860 @@ +package org.mtransit.android.commons.provider.status + +import com.google.transit.realtime.TripUpdateKt.stopTimeEvent +import com.google.transit.realtime.TripUpdateKt.stopTimeUpdate +import com.google.transit.realtime.tripDescriptor +import com.google.transit.realtime.tripUpdate +import org.mtransit.android.commons.data.Accessibility +import org.mtransit.android.commons.data.Direction +import org.mtransit.android.commons.data.Route +import org.mtransit.android.commons.data.RouteDirectionStop +import org.mtransit.android.commons.data.Schedule +import org.mtransit.android.commons.data.Stop +import org.mtransit.android.commons.data.arrival +import org.mtransit.android.commons.data.arrivalDiff +import org.mtransit.android.commons.data.departure +import org.mtransit.android.commons.data.toScheduleTimestamp +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.delayDuration +import org.mtransit.android.commons.secsToInstant +import org.mtransit.android.commons.toSecs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import com.google.transit.realtime.GtfsRealtime.TripDescriptor.ScheduleRelationship as GTDScheduleRelationship +import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate as GTUStopTimeUpdate +import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeEvent as GTUStopTimeEvent +import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate.ScheduleRelationship as GTUSTUScheduleRelationship + +class GTFSRealTimeTripUpdatesProviderTests { + + companion object { + private const val LOCAL_TZ_ID: String = "America/Montreal" + + private const val DEPARTURE_MS = 1772722800L // 2026-03-06 10:00: + + private const val NOW_IN_MS = 123456789_000L + + private const val TRIP_ID = "123456789" + private const val STOP_SEQUENCE = 1 + } + + // region same stop + + @Test + fun test_isSameStop() { + assertTrue { stopTimeUpdate { stopId = "1234" }.isSameStop(makeRDS(stopId = 1234), 1, parseStopId = { it }) } + assertFalse { stopTimeUpdate { stopId = "1234" }.isSameStop(makeRDS(stopId = 5678), 1, parseStopId = { it }) } + assertFalse { stopTimeUpdate { }.isSameStop(makeRDS(stopId = 1234), 1, parseStopId = { it }) } + assertTrue { stopTimeUpdate { stopSequence = 7 }.isSameStop(makeRDS(stopId = 1234), 7, parseStopId = { it }) } + assertFalse { stopTimeUpdate { }.isSameStop(makeRDS(stopId = 1234), 7, parseStopId = { it }) } + assertTrue { stopTimeUpdate { stopId = "1234"; stopSequence = 7 }.isSameStop(makeRDS(stopId = 1234), 7, parseStopId = { it }) } + assertFalse { stopTimeUpdate { stopId = "1234"; stopSequence = 7 }.isSameStop(makeRDS(stopId = 5678), 1, parseStopId = { it }) } + } + + // endregion + + // region applyDelay + + @Test + fun text_applyDelay_null() { + val departure = DEPARTURE_MS.secsToInstant() + val timestamp = mkTime(departure) + val delay: Duration? = null + + val result = applyDelay(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), delay) + + assertNull(result) + assertFalse { timestamp.isRealTime } + assertEquals(departure, timestamp.departure) + } + + @Test + fun text_applyDelay_0_on_time() { + val departure = DEPARTURE_MS.secsToInstant() + val timestamp = mkTime(departure) + val delay = Duration.ZERO + + val result = applyDelay(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), delay) + + assertNotNull(result) + assertEquals(delay, result) // delay not consumed + assertTrue { timestamp.isRealTime } + assertEquals(departure, timestamp.departure) + } + + @Test + fun text_applyDelay_simple_late() { + val departure = DEPARTURE_MS.secsToInstant() + val timestamp = mkTime(departure) + val delay = 10.minutes + + val result = applyDelay(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), delay) + + assertNotNull(result) + assertEquals(delay, result) // delay not consumed + assertTrue { timestamp.isRealTime } + assertEquals(departure + delay, timestamp.departure) + } + + @Test + fun text_applyDelay_differentArrival_late() { + val departure = DEPARTURE_MS.secsToInstant() + val arrival = departure - 1.minutes + val timestamp = mkTime(departure, arrival = arrival) + val delay = 10.minutes + + val result = applyDelay(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), delay) + + assertNotNull(result) + assertEquals(9.minutes, result) // delay partially consumed + assertTrue { timestamp.isRealTime } + assertEquals(arrival + delay, timestamp.arrival) + assertEquals(departure + result, timestamp.departure) + } + + @Test + fun text_applyDelay_consumed_late() { + val departure = DEPARTURE_MS.secsToInstant() + val arrival = departure - 15.minutes + val timestamp = mkTime(departure, arrival = arrival) + val delay = 10.minutes + + val result = applyDelay(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), delay) + + assertNotNull(result) + assertEquals(Duration.ZERO, result) // delay consumed + assertTrue { timestamp.isRealTime } + assertEquals(arrival + delay, timestamp.arrival) + assertEquals(departure, timestamp.departure) + } + + @Test + fun text_applyDelay_simple_early() { + val departure = DEPARTURE_MS.secsToInstant() + val arrival = departure - 1.minutes + val timestamp = mkTime(departure, arrival = arrival) + val delay = (-5).minutes + + val result = applyDelay(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), delay) + + assertNotNull(result) + assertEquals(delay, result) // delay not consumed + assertTrue { timestamp.isRealTime } + assertEquals(arrival - 5.minutes, timestamp.arrival) + assertEquals(departure - 5.minutes, timestamp.departure) + } + + // endregion + + // region applyDelaySTU + + @Test + fun text_applyDelaySTU_simple() { + val departure = DEPARTURE_MS.secsToInstant() + val timestamp = mkTime(departure) + val stopTimeUpdate = stopTimeUpdate { + this.departure = stopTimeEvent { + time = (departure + 1.minutes).toSecs() + } + } + + val result = applyDelaySTU(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), stopTimeUpdate) + + assertNotNull(result) + assertEquals(1.minutes, result) + assertTrue { timestamp.isRealTime } + assertEquals(departure + 1.minutes, timestamp.departure) + } + + @Test + fun text_applyDelaySTU_2() { + val departure = DEPARTURE_MS.secsToInstant() + val arrival = departure - 5.minutes + val timestamp = mkTime(departure, arrival = arrival) + val stopTimeUpdate = stopTimeUpdate { + this.arrival = stopTimeEvent { + time = (arrival - 1.minutes).toSecs() + } + this.departure = stopTimeEvent { + time = (departure + 2.minutes).toSecs() + } + } + + val result = applyDelaySTU(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), stopTimeUpdate) + + assertNotNull(result) + assertEquals(2.minutes, result) + assertTrue { timestamp.isRealTime } + assertEquals(arrival - 1.minutes, timestamp.arrival) + assertEquals(departure + 2.minutes, timestamp.departure) + } + + @Test + fun text_applyDelaySTU_3() { + val departure = DEPARTURE_MS.secsToInstant() + val arrival = departure - 5.minutes + val delay = 1.minutes + val timestamp = mkTime(departure, arrival = arrival) + val stopTimeUpdate = stopTimeUpdate { + this.departure = stopTimeEvent { + time = (departure + 2.minutes).toSecs() + } + } + + val result = applyDelaySTU(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), stopTimeUpdate, delay) + + assertNotNull(result) + assertEquals(2.minutes, result) + assertTrue { timestamp.isRealTime } + assertEquals(arrival + 1.minutes, timestamp.arrival) + assertEquals(departure + 2.minutes, timestamp.departure) + } + + @Test + fun text_applyDelaySTU_4() { + val departure = DEPARTURE_MS.secsToInstant() + val arrival = departure - 1.minutes + val delay = 15.minutes // should be ignored + val timestamp = mkTime(departure, arrival = arrival) + val stopTimeUpdate = stopTimeUpdate { + this.arrival = stopTimeEvent { + time = (arrival + 3.minutes).toSecs() + } + } + + val result = applyDelaySTU(TRIP_ID, STOP_SEQUENCE, timestamp.toSchedule(), stopTimeUpdate, delay) + + assertNotNull(result) + assertEquals(2.minutes, result) + assertTrue { timestamp.isRealTime } + assertEquals(arrival + 3.minutes, timestamp.arrival) + assertEquals(departure + 2.minutes, timestamp.departure) + } + + // endregion + + // region makeDelay + + @Test + fun test_makeDelay_1() { + val originalTime = DEPARTURE_MS.secsToInstant() + val stopTimeEvent = stopTimeEvent { + delay = 10 + } + + val result = stopTimeEvent.makeDelay(originalTime) + + assertNotNull(result) + assertEquals(10.seconds, result) + } + + @Test + fun test_makeDelay_2() { + val originalTime = DEPARTURE_MS.secsToInstant() + val stopTimeEvent = stopTimeEvent { + time = (originalTime + 10.seconds).toSecs() + } + + val result = stopTimeEvent.makeDelay(originalTime) + + assertNotNull(result) + assertEquals(10.seconds, result) + } + + @Test + fun test_makeDelay_3() { + val departure = DEPARTURE_MS.secsToInstant() + val arrival = departure - 5.minutes + val timestamp = mkTime(departure, arrival = arrival) + val previousDelay = 10.minutes + val stopTimeEvent: GTUStopTimeEvent? = null + + val result = stopTimeEvent.makeDelay( + originalTime = timestamp.departure, + previousDelay = previousDelay, + previousOriginalDiff = timestamp.arrivalDiff + ) + + assertNotNull(result) + assertEquals(5.minutes, result) + } + + // endregion + + // region trip update + + private val isSameStop: ((GTUStopTimeUpdate?, RouteDirectionStop?, Int) -> Boolean) = + { stu, rds, stopSeq -> + stu?.isSameStop(rds, stopSeq, parseStopId = { it }) == true + } + + @Test + fun test_processRDTripUpdate_singleTUDelay() { + val tripStart = DEPARTURE_MS.secsToInstant() + val gTripUpdate = tripUpdate { + trip = tripDescriptor { + this.tripId = tripId + } + delayDuration = 1.minutes + } + val rdsList = buildList { + add(makeRDS(stopId = 1000)) + add(makeRDS(stopId = 2000)) + add(makeRDS(stopId = 3000)) + } + val tripTargetUuidSchedule = buildMap { + rdsList[0].uuid.let { put(it, mkSchedule(it, listOf(mkTime(tripStart)))) } + rdsList[1].uuid.let { put(it, mkSchedule(it, listOf(mkTime(tripStart + 10.minutes)))) } + rdsList[2].uuid.let { put(it, mkSchedule(it, listOf(mkTime(tripStart + 20.minutes)))) } + } + val sortedTargetUuidAndSequence = buildList { + rdsList.forEachIndexed { index, rds -> + add(rds.uuid to index + 1) + } + } + + processRDTripUpdate(TRIP_ID, gTripUpdate, rdsList, sortedTargetUuidAndSequence, tripTargetUuidSchedule, isSameStop) + + assertNotNull(tripTargetUuidSchedule[rdsList[0].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(tripStart + 1.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[1].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(tripStart + 11.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[2].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(tripStart + 21.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + } + + @Test + fun test_processRDTripUpdate_combined_complex_stop_id() { + val startsAt = DEPARTURE_MS.secsToInstant() + val gTripUpdate = tripUpdate { + trip = tripDescriptor { + tripId = TRIP_ID + } + delayDuration = 1.minutes + stopTimeUpdate += stopTimeUpdate { + stopId = "2000" + departure = stopTimeEvent { + delayDuration = 3.minutes + } + } + stopTimeUpdate += stopTimeUpdate { + stopId = "4000" + arrival = stopTimeEvent { + time = ((startsAt + 30.minutes) + 5.minutes).toSecs() + } + } + stopTimeUpdate += stopTimeUpdate { + stopId = "7000" + scheduleRelationship = GTUSTUScheduleRelationship.SKIPPED + } + stopTimeUpdate += stopTimeUpdate { + stopId = "9000" + scheduleRelationship = GTUSTUScheduleRelationship.NO_DATA + } + } + val rdsList = buildList { + add(makeRDS(stopId = 1000)) + add(makeRDS(stopId = 2000)) + add(makeRDS(stopId = 3000)) + add(makeRDS(stopId = 4000)) + add(makeRDS(stopId = 5000)) + add(makeRDS(stopId = 6000)) + add(makeRDS(stopId = 7000)) + add(makeRDS(stopId = 8000)) + add(makeRDS(stopId = 9000)) + add(makeRDS(stopId = 10000)) + } + val tripTargetUuidSchedule = buildMap { + rdsList[0].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt)))) } + rdsList[1].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 10.minutes)))) } + rdsList[2].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 20.minutes)))) } + rdsList[3].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 30.minutes)))) } + rdsList[4].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 43.minutes, arrival = startsAt + 37.minutes)))) } + rdsList[5].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 50.minutes)))) } + rdsList[6].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 60.minutes)))) } + rdsList[7].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 70.minutes)))) } + rdsList[8].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 80.minutes)))) } + rdsList[9].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 90.minutes)))) } + } + val sortedTargetUuidAndSequence = buildList { + rdsList.forEachIndexed { index, rds -> + add(rds.uuid to index + 1) + } + } + + processRDTripUpdate(TRIP_ID, gTripUpdate, rdsList, sortedTargetUuidAndSequence, tripTargetUuidSchedule, isSameStop) + + assertNotNull(tripTargetUuidSchedule[rdsList[0].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 1.minutes, timestamp.arrival) + assertEquals(startsAt + 1.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[1].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 10.minutes + 1.minutes, timestamp.arrival) + assertEquals(startsAt + 10.minutes + 3.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[2].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 20.minutes + 3.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[3].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 30.minutes + 5.minutes, timestamp.arrival) + assertEquals(startsAt + 30.minutes + 5.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[4].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 37.minutes + 5.minutes, timestamp.arrival) + assertEquals(startsAt + 43.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[5].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 50.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[6].uuid]) { schedule -> + assertTrue { schedule.timestamps.isEmpty() } + } + assertNotNull(tripTargetUuidSchedule[rdsList[7].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 70.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[8].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 80.minutes, timestamp.departure) + assertFalse { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[9].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 90.minutes, timestamp.departure) + assertFalse { timestamp.isRealTime } + } + } + } + + @Test + fun test_processRDTripUpdate_combined_complex_stop_sequence() { + val startsAt = DEPARTURE_MS.secsToInstant() + val gTripUpdate = tripUpdate { + trip = tripDescriptor { + tripId = TRIP_ID + } + delayDuration = 1.minutes + stopTimeUpdate += stopTimeUpdate { + stopSequence = 2 + departure = stopTimeEvent { + delayDuration = 3.minutes + } + } + stopTimeUpdate += stopTimeUpdate { + stopSequence = 4 + arrival = stopTimeEvent { + time = ((startsAt + 30.minutes) + 5.minutes).toSecs() + } + } + stopTimeUpdate += stopTimeUpdate { + stopSequence = 7 + scheduleRelationship = GTUSTUScheduleRelationship.SKIPPED + } + stopTimeUpdate += stopTimeUpdate { + stopSequence = 9 + scheduleRelationship = GTUSTUScheduleRelationship.NO_DATA + } + } + val rdsList = buildList { + add(makeRDS(stopId = 1000)) + add(makeRDS(stopId = 2000)) + add(makeRDS(stopId = 3000)) + add(makeRDS(stopId = 4000)) + add(makeRDS(stopId = 5000)) + add(makeRDS(stopId = 6000)) + add(makeRDS(stopId = 7000)) + add(makeRDS(stopId = 8000)) + add(makeRDS(stopId = 9000)) + add(makeRDS(stopId = 10000)) + } + var stopSeq = 0 + val tripTargetUuidSchedule = buildMap { + rdsList[0].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt, stopSeq = ++stopSeq)))) } + rdsList[1].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 10.minutes, stopSeq = ++stopSeq)))) } + rdsList[2].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 20.minutes, stopSeq = ++stopSeq)))) } + rdsList[3].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 30.minutes, stopSeq = ++stopSeq)))) } + rdsList[4].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 43.minutes, stopSeq = ++stopSeq, arrival = startsAt + 37.minutes)))) } + rdsList[5].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 50.minutes, stopSeq = ++stopSeq)))) } + rdsList[6].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 60.minutes, stopSeq = ++stopSeq)))) } + rdsList[7].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 70.minutes, stopSeq = ++stopSeq)))) } + rdsList[8].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 80.minutes, stopSeq = ++stopSeq)))) } + rdsList[9].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 90.minutes, stopSeq = ++stopSeq)))) } + } + val sortedTargetUuidAndSequence = buildList { + tripTargetUuidSchedule.forEach { (uuid, schedule) -> + schedule?.timestamps?.forEach { timestamp -> + add(uuid to assertNotNull(timestamp.stopSequenceOrNull)) + } + } + } + + processRDTripUpdate(TRIP_ID, gTripUpdate, rdsList, sortedTargetUuidAndSequence, tripTargetUuidSchedule, isSameStop) + + assertNotNull(tripTargetUuidSchedule[rdsList[0].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 1.minutes, timestamp.arrival) + assertEquals(startsAt + 1.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[1].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 10.minutes + 1.minutes, timestamp.arrival) + assertEquals(startsAt + 10.minutes + 3.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[2].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 20.minutes + 3.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[3].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 30.minutes + 5.minutes, timestamp.arrival) + assertEquals(startsAt + 30.minutes + 5.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[4].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 37.minutes + 5.minutes, timestamp.arrival) + assertEquals(startsAt + 43.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[5].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 50.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[6].uuid]) { schedule -> + assertTrue { schedule.timestamps.isEmpty() } + } + assertNotNull(tripTargetUuidSchedule[rdsList[7].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 70.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[8].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 80.minutes, timestamp.departure) + assertFalse { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[9].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 90.minutes, timestamp.departure) + assertFalse { timestamp.isRealTime } + } + } + } + + @Test + fun test_processRDTripUpdate_combined_complex_stop_sequence_repeated_stop() { + val startsAt = DEPARTURE_MS.secsToInstant() + val gTripUpdate = tripUpdate { + trip = tripDescriptor { + tripId = TRIP_ID + } + delayDuration = 1.minutes + stopTimeUpdate += stopTimeUpdate { + stopSequence = 2 + departure = stopTimeEvent { + delayDuration = 3.minutes + } + } + stopTimeUpdate += stopTimeUpdate { + stopSequence = 4 + arrival = stopTimeEvent { + time = ((startsAt + 30.minutes) + 5.minutes).toSecs() + } + } + stopTimeUpdate += stopTimeUpdate { + stopSequence = 7 + scheduleRelationship = GTUSTUScheduleRelationship.SKIPPED + } + stopTimeUpdate += stopTimeUpdate { + stopSequence = 9 + scheduleRelationship = GTUSTUScheduleRelationship.NO_DATA + } + } + val rdsList = buildList { + add(makeRDS(stopId = 1000)) + add(makeRDS(stopId = 2000)) + add(makeRDS(stopId = 3000)) + add(makeRDS(stopId = 4000)) + add(makeRDS(stopId = 5000)) + add(makeRDS(stopId = 6000)) + add(makeRDS(stopId = 7000)) + add(makeRDS(stopId = 9000)) + add(makeRDS(stopId = 10000)) + } + var stopSeq = 0 + val tripTargetUuidSchedule = buildMap { + rdsList[0].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt, stopSeq = ++stopSeq)))) } + rdsList[1].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 10.minutes, stopSeq = ++stopSeq)))) } + rdsList[2].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 20.minutes, stopSeq = ++stopSeq)))) } + rdsList[3].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 30.minutes, stopSeq = ++stopSeq)))) } + rdsList[4].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 43.minutes, stopSeq = ++stopSeq, arrival = startsAt + 37.minutes)))) } + rdsList[5].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 50.minutes, stopSeq = ++stopSeq)))) } + rdsList[6].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 60.minutes, stopSeq = ++stopSeq)))) } + get(rdsList[3].uuid)?.apply { + addTimestampWithoutSort(mkTime(startsAt + 70.minutes, stopSeq = ++stopSeq)) + sortTimestamps() + } + rdsList[7].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 80.minutes, stopSeq = ++stopSeq)))) } + rdsList[8].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 90.minutes, stopSeq = ++stopSeq)))) } + } + val sortedTargetUuidAndSequence = buildList { + tripTargetUuidSchedule.forEach { (uuid, schedule) -> + schedule?.timestamps?.forEach { timestamp -> + add(uuid to assertNotNull(timestamp.stopSequenceOrNull)) + } + } + }.sortedBy { (_, stopSequence) -> stopSequence } + + + processRDTripUpdate(TRIP_ID, gTripUpdate, rdsList, sortedTargetUuidAndSequence, tripTargetUuidSchedule, isSameStop) + + assertNotNull(tripTargetUuidSchedule[rdsList[0].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 1.minutes, timestamp.arrival) + assertEquals(startsAt + 1.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[1].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 10.minutes + 1.minutes, timestamp.arrival) + assertEquals(startsAt + 10.minutes + 3.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[2].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 20.minutes + 3.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[3].uuid]) { schedule -> + assertNotNull(schedule.timestamps.getOrNull(0)) { timestamp -> + assertEquals(startsAt + 30.minutes + 5.minutes, timestamp.arrival) + assertEquals(startsAt + 30.minutes + 5.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[4].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 37.minutes + 5.minutes, timestamp.arrival) + assertEquals(startsAt + 43.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[5].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 50.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[6].uuid]) { schedule -> + assertTrue { schedule.timestamps.isEmpty() } + } + assertNotNull(tripTargetUuidSchedule[rdsList[3].uuid]) { schedule -> + assertNotNull(schedule.timestamps.getOrNull(1)) { timestamp -> + assertEquals(startsAt + 70.minutes, timestamp.departure) + assertTrue { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[7].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 80.minutes, timestamp.departure) + assertFalse { timestamp.isRealTime } + } + } + assertNotNull(tripTargetUuidSchedule[rdsList[8].uuid]) { schedule -> + assertNotNull(schedule.timestamps.singleOrNull()) { timestamp -> + assertEquals(startsAt + 90.minutes, timestamp.departure) + assertFalse { timestamp.isRealTime } + } + } + } + + @Test + fun test_processRDTripUpdate_trip_cancelled() { + val startsAt = DEPARTURE_MS.secsToInstant() + val gTripUpdate = tripUpdate { + trip = tripDescriptor { + tripId = TRIP_ID + this.scheduleRelationship = GTDScheduleRelationship.CANCELED + } + delayDuration = 1.minutes + } + val rdsList = buildList { + add(makeRDS(stopId = 1000)) + add(makeRDS(stopId = 2000)) + add(makeRDS(stopId = 3000)) + } + val tripTargetUuidSchedule = buildMap { + rdsList[0].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt)))) } + rdsList[1].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 10.minutes)))) } + rdsList[2].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 20.minutes)))) } + } + val sortedTargetUuidAndSequence = buildList { + rdsList.forEachIndexed { index, rds -> + add(rds.uuid to index + 1) + } + } + + processRDTripUpdate(TRIP_ID, gTripUpdate, rdsList, sortedTargetUuidAndSequence, tripTargetUuidSchedule, isSameStop) + + assertNotNull(tripTargetUuidSchedule[rdsList[0].uuid]) { schedule -> + assertTrue { schedule.timestamps.isEmpty() } + } + assertNotNull(tripTargetUuidSchedule[rdsList[1].uuid]) { schedule -> + assertTrue { schedule.timestamps.isEmpty() } + } + assertNotNull(tripTargetUuidSchedule[rdsList[2].uuid]) { schedule -> + assertTrue { schedule.timestamps.isEmpty() } + } + } + + @Test + fun test_processRDTripUpdate_trip_deleted() { + val startsAt = DEPARTURE_MS.secsToInstant() + val gTripUpdate = tripUpdate { + trip = tripDescriptor { + tripId = TRIP_ID + this.scheduleRelationship = GTDScheduleRelationship.DELETED + } + delayDuration = 1.minutes + } + val rdsList = buildList { + add(makeRDS(stopId = 1000)) + add(makeRDS(stopId = 2000)) + add(makeRDS(stopId = 3000)) + } + val tripTargetUuidSchedule = buildMap { + rdsList[0].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt)))) } + rdsList[1].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 10.minutes)))) } + rdsList[2].uuid.let { put(it, mkSchedule(it, listOf(mkTime(startsAt + 20.minutes)))) } + } + val sortedTargetUuidAndSequence = buildList { + rdsList.forEachIndexed { index, rds -> + add(rds.uuid to index + 1) + } + } + + processRDTripUpdate(TRIP_ID, gTripUpdate, rdsList, sortedTargetUuidAndSequence, tripTargetUuidSchedule, isSameStop) + + assertNotNull(tripTargetUuidSchedule[rdsList[0].uuid]) { schedule -> + assertTrue { schedule.timestamps.isEmpty() } + } + assertNotNull(tripTargetUuidSchedule[rdsList[1].uuid]) { schedule -> + assertTrue { schedule.timestamps.isEmpty() } + } + assertNotNull(tripTargetUuidSchedule[rdsList[2].uuid]) { schedule -> + assertTrue { schedule.timestamps.isEmpty() } + } + } + + // end region + + private fun Schedule.Timestamp.toSchedule() = mkSchedule( + timestamps = listOf(this), + ) + + @Suppress("SameParameterValue") + private fun mkTime(time: Instant, tripId: String? = TRIP_ID, stopSeq: Int? = null, arrival: Instant? = null) = + time.toScheduleTimestamp(LOCAL_TZ_ID, arrival, tripId, stopSeq) + + private fun mkSchedule( + targetUuid: String = makeRDS().uuid, + timestamps: List = emptyList(), + nowInMs: Long = NOW_IN_MS, + ) = Schedule( + null, + targetUuid, + nowInMs, + nowInMs, + nowInMs, + 10.seconds.inWholeMilliseconds, + false, + null, + false + ).apply { + setTimestampsAndSort(timestamps) + } + + private fun makeRDS(stopId: Int = 1) = RouteDirectionStop( + 1, + Route( + "authority", + 1, + "1", + "route 1", + "color" + ), + Direction( + "authority", + 1, + 1, + "headsign", + 1 + ), + Stop( + stopId, + "#$stopId", + "Stop #$stopId", + 1.0, + 2.0, + Accessibility.DEFAULT, + stopId, // "$stopId".hashCode() + ), + false, + false, + ) +}