diff --git a/src/main/java/org/mtransit/android/commons/provider/CleverDevicesProvider.java b/src/main/java/org/mtransit/android/commons/provider/CleverDevicesProvider.java index 164a0e9de..dbfb9cd5f 100644 --- a/src/main/java/org/mtransit/android/commons/provider/CleverDevicesProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/CleverDevicesProvider.java @@ -276,7 +276,7 @@ private void loadRealTimeStatusFromWWW(@NonNull RouteDirectionStop rds) { SAXParserFactory spf = SAXParserFactory.newInstance(); SAXParser sp = spf.newSAXParser(); XMLReader xr = sp.getXMLReader(); - CleverDevicesPredictionsDataHandler handler = new CleverDevicesPredictionsDataHandler(this, newLastUpdateInMs, AgencyUtils.getRDSAgencyTimeZoneId(context), sourceLabel, rds); + CleverDevicesPredictionsDataHandler handler = new CleverDevicesPredictionsDataHandler(this, newLastUpdateInMs, AgencyUtils.getAgencyTimeZoneId(context), sourceLabel, rds); xr.setContentHandler(handler); xr.parse(new InputSource(httpUrlConnection.getInputStream())); Collection statuses = handler.getStatuses(); 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 cb46bf276..6804093ad 100644 --- a/src/main/java/org/mtransit/android/commons/provider/GTFSRealTimeProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/GTFSRealTimeProvider.java @@ -577,17 +577,17 @@ private static String getAGENCY_TIME_AM_PM_FORMAT(@NonNull Context context) { } @Nullable - private static String agencyTimeZone = null; + private static String agencyTimeZoneId = null; /** * Override if multiple {@link GTFSRealTimeProvider} implementations in same app. */ @NonNull - public static String getAGENCY_TIME_ZONE(@NonNull Context context) { - if (agencyTimeZone == null) { - agencyTimeZone = context.getResources().getString(R.string.gtfs_real_time_agency_time_zone); + public static String getAGENCY_TIME_ZONE_ID(@NonNull Context context) { + if (agencyTimeZoneId == null) { + agencyTimeZoneId = context.getResources().getString(R.string.gtfs_real_time_agency_time_zone); } - return agencyTimeZone; + return agencyTimeZoneId; } @Override @@ -1244,9 +1244,9 @@ private ThreadSafeDateFormatter getTimeParser(@NonNull Context context) { formatter += StringUtils.SPACE_STRING + getAGENCY_TIME_AM_PM_FORMAT(context); } timeParser = new ThreadSafeDateFormatter(formatter, Locale.ENGLISH); - String agencyTimeZoneId = getAGENCY_TIME_ZONE(context); + String agencyTimeZoneId = getAGENCY_TIME_ZONE_ID(context); if (TextUtils.isEmpty(agencyTimeZoneId)) { - agencyTimeZoneId = AgencyUtils.getRDSAgencyTimeZoneId(context); + agencyTimeZoneId = AgencyUtils.getAgencyTimeZoneId(context); } if (!TextUtils.isEmpty(agencyTimeZoneId)) { timeParser.setTimeZone(TimeZone.getTimeZone(agencyTimeZoneId)); diff --git a/src/main/java/org/mtransit/android/commons/provider/NextBusProvider.java b/src/main/java/org/mtransit/android/commons/provider/NextBusProvider.java index 1b8bac5a8..992dc5e5d 100644 --- a/src/main/java/org/mtransit/android/commons/provider/NextBusProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/NextBusProvider.java @@ -1072,7 +1072,7 @@ private void loadPredictionsFromWWW(@NonNull Context context, @NonNull RouteDire final SAXParserFactory spf = SAXParserFactory.newInstance(); final SAXParser sp = spf.newSAXParser(); final XMLReader xr = sp.getXMLReader(); - final NextBusPredictionsDataHandler handler = new NextBusPredictionsDataHandler(this, sourceLabel, newLastUpdateInMs, AgencyUtils.getRDSAgencyTimeZoneId(context)); + final NextBusPredictionsDataHandler handler = new NextBusPredictionsDataHandler(this, sourceLabel, newLastUpdateInMs, AgencyUtils.getAgencyTimeZoneId(context)); xr.setContentHandler(handler); xr.parse(new InputSource(response.body().byteStream())); final Collection statuses = handler.getStatuses(); diff --git a/src/main/java/org/mtransit/android/commons/provider/OneBusAwayProvider.java b/src/main/java/org/mtransit/android/commons/provider/OneBusAwayProvider.java index 496ae7318..2f41b960e 100644 --- a/src/main/java/org/mtransit/android/commons/provider/OneBusAwayProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/OneBusAwayProvider.java @@ -358,7 +358,7 @@ private void loadPredictionsFromWWW(@NonNull Context context, @NonNull RouteDire private Collection parseAgencyJSON(@NonNull Context context, @Nullable String jsonString, @NonNull RouteDirectionStop rds, @Nullable String sourceLabel, long newLastUpdateInMs) { try { - final String localTimeZoneId = AgencyUtils.getRDSAgencyTimeZoneId(context); + final String localTimeZoneId = AgencyUtils.getAgencyTimeZoneId(context); ArrayList result = new ArrayList<>(); JSONObject json = jsonString == null ? null : new JSONObject(jsonString); if (json != null && json.has(JSON_DATA)) { diff --git a/src/main/java/org/mtransit/android/commons/provider/RSSNewsProvider.java b/src/main/java/org/mtransit/android/commons/provider/RSSNewsProvider.java index 5beafb665..2334da1d0 100644 --- a/src/main/java/org/mtransit/android/commons/provider/RSSNewsProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/RSSNewsProvider.java @@ -564,7 +564,7 @@ private ArrayList loadAgencyNewsDataFromWWW(@NonNull Context context, Stri final boolean ignoreGUID = RssNewProviderUtils.pickIgnoreGUID(context, i); final boolean ignoreLink = RssNewProviderUtils.pickIgnoreLink(context, i); final Pair dateLinkFallback = RssNewProviderUtils.pickDateLinkFallback(context, i); - final String localTimeZoneId = AgencyUtils.getRDSAgencyTimeZoneId(context); + final String localTimeZoneId = AgencyUtils.getAgencyTimeZoneId(context); final RSSDataHandler handler = new RSSDataHandler( url, authority, diff --git a/src/main/java/org/mtransit/android/commons/provider/agency/AgencyUtils.kt b/src/main/java/org/mtransit/android/commons/provider/agency/AgencyUtils.kt index 265cef8c4..daf73d21f 100644 --- a/src/main/java/org/mtransit/android/commons/provider/agency/AgencyUtils.kt +++ b/src/main/java/org/mtransit/android/commons/provider/agency/AgencyUtils.kt @@ -35,7 +35,7 @@ object AgencyUtils { private val _defaultTimeZoneId: String by lazy { TimeZone.getDefault().id } @JvmStatic - fun getRDSAgencyTimeZoneId(context: Context) = + fun getAgencyTimeZoneId(context: Context) = context.getAgencyString( R.string.poi_agency_timezone, R.string.gtfs_rts_timezone, // do not change to avoid breaking compat w/ old modules diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSDateTimeUtils.kt b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSDateTimeUtils.kt new file mode 100644 index 000000000..20c375b86 --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSDateTimeUtils.kt @@ -0,0 +1,48 @@ +package org.mtransit.android.commons.provider.gtfs + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.atStartOfDayIn +import org.mtransit.android.commons.MTLog +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlinx.datetime.TimeZone as KtTimeZone + +object GTFSDateTimeUtils : MTLog.Loggable { + + private val LOG_TAG: String = GTFSDateTimeUtils::class.java.simpleName + + override fun getLogTag() = LOG_TAG + + fun parseToDateTime(gtfsDateStr: String?, gtfsTimeStr: String?, agencyTimeZone: KtTimeZone): Instant? { + try { + val cleanedDate = gtfsDateStr?.trim() ?: return null + if (cleanedDate.length != 8 || cleanedDate.any { !it.isDigit() }) { + MTLog.w(this, "Invalid GTFS date format '$cleanedDate'! Must be YYYYMMDD") + return null + } + val parts = gtfsTimeStr?.trim()?.split(":") ?: return null + if (parts.size != 3) { + MTLog.w(this, "Invalid GTFS time format '$gtfsTimeStr'! Must be HH:MM:SS") + return null + } + + val hoursCount = parts.getOrNull(0)?.toIntOrNull()?.takeIf { it >= 0 } ?: return null + val minutesCount = parts.getOrNull(1)?.toIntOrNull()?.takeIf { it in 0..59 } ?: return null + val secondsCount = parts.getOrNull(2)?.toIntOrNull()?.takeIf { it in 0..59 } ?: return null + val durationFromStartOfDay = hoursCount.hours + // compat with 24+ hours + minutesCount.minutes + secondsCount.seconds + + val year = cleanedDate.substring(0, 4).toIntOrNull() ?: return null + val month = cleanedDate.substring(4, 6).toIntOrNull() ?: return null + val day = cleanedDate.substring(6, 8).toIntOrNull() ?: return null + val startOfDay = LocalDate(year, month, day).atStartOfDayIn(agencyTimeZone) + + return startOfDay + durationFromStartOfDay + } catch (e: Exception) { + MTLog.w(this, e, "Error while parsing GTFS date '$gtfsDateStr' & time '$gtfsTimeStr'!") + return null + } + } +} 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 c9fd838da..fea5bdc14 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 @@ -11,7 +11,7 @@ import org.mtransit.android.commons.data.Stop import org.mtransit.android.commons.data.toRouteDirection import org.mtransit.android.commons.provider.GTFSRealTimeProvider import org.mtransit.android.commons.provider.GTFSRealTimeProvider.MT_HASH_SECRET_AND_DATE -import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAGENCY_TIME_ZONE +import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAGENCY_TIME_ZONE_ID import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAGENCY_URL_HEADER_NAMES import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAGENCY_URL_HEADER_VALUES import org.mtransit.android.commons.provider.GTFSRealTimeProvider.getAGENCY_URL_TOKEN @@ -39,7 +39,8 @@ import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate as GTU val Int.isValidDirection get() = this in 0..1 val GTFSRealTimeProvider.ignoreDirection get() = isIGNORE_DIRECTION(requireContextCompat()) val GTFSRealTimeProvider.targetAuthority get() = getTARGET_AUTHORITY(requireContextCompat()) -val GTFSRealTimeProvider.timeZone get() = getAGENCY_TIME_ZONE(requireContextCompat()) +val GTFSRealTimeProvider.timeZoneId get() = getAGENCY_TIME_ZONE_ID(requireContextCompat()) +val GTFSRealTimeProvider.optTimeZoneId get() = timeZoneId.takeIf { it.isNotBlank() } fun GTFSRealTimeProvider.parseAgencyId(es: GEntitySelector) = es.optAgencyIdNotEmpty?.let { parseAgencyId(it) } fun GTFSRealTimeProvider.parseAgencyId(gAgencyId: String) = gAgencyId.originalIdToId(agencyIdCleanupPattern) 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 58d6658d2..3363bc625 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 @@ -49,7 +49,7 @@ public static ScheduleTimestamps getScheduleTimestamps(@NonNull GTFSProvider pro final Context context = provider.requireContextCompat(); final ThreadSafeDateFormatter dateFormat = GTFSStatusProvider.getDateFormat(context); final ThreadSafeDateFormatter timeFormat = GTFSStatusProvider.getTimeFormat(context); - final TimeZone timeZone = TimeZone.getTimeZone(AgencyUtils.getRDSAgencyTimeZoneId(context)); + final TimeZone timeZone = TimeZone.getTimeZone(AgencyUtils.getAgencyTimeZoneId(context)); final Calendar startsAt = TimeUtils.getNewCalendar(timeZone, startsAtInMs); startsAt.add(Calendar.DATE, -1); // starting yesterday Set dayTimestamps; 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 1edc26f6e..8854b45a8 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 @@ -175,7 +175,7 @@ public static POIStatus getNewStatus(@NonNull GTFSProvider provider, @NonNull St static ThreadSafeDateFormatter getDateFormat(@NonNull Context context) { if (dateFormat == null) { dateFormat = new ThreadSafeDateFormatter(DATE_FORMAT_PATTERN, Locale.ENGLISH); - final String localTimeZoneId = AgencyUtils.getRDSAgencyTimeZoneId(context); + final String localTimeZoneId = AgencyUtils.getAgencyTimeZoneId(context); dateFormat.setTimeZone(TimeZone.getTimeZone(localTimeZoneId)); } return dateFormat; @@ -189,7 +189,7 @@ static ThreadSafeDateFormatter getDateFormat(@NonNull Context context) { static ThreadSafeDateFormatter getTimeFormat(@NonNull Context context) { if (timeFormat == null) { timeFormat = new ThreadSafeDateFormatter(TIME_FORMAT_PATTERN, Locale.ENGLISH); - final String localTimeZoneId = AgencyUtils.getRDSAgencyTimeZoneId(context); + final String localTimeZoneId = AgencyUtils.getAgencyTimeZoneId(context); timeFormat.setTimeZone(TimeZone.getTimeZone(localTimeZoneId)); } return timeFormat; @@ -245,7 +245,7 @@ private static ArrayList findTimestamps(@NonNull GTFSProvide final Context context = provider.requireContextCompat(); final ThreadSafeDateFormatter dateFormat = getDateFormat(context); final ThreadSafeDateFormatter timeFormat = getTimeFormat(context); - final String localTimeZoneId = AgencyUtils.getRDSAgencyTimeZoneId(context); + final String localTimeZoneId = AgencyUtils.getAgencyTimeZoneId(context); final TimeZone timeZone = TimeZone.getTimeZone(localTimeZoneId); final Calendar startsAt = TimeUtils.getNewCalendar(timeZone, timestamp); if (lookBehindInMs > PROVIDER_PRECISION_IN_MS) { @@ -436,7 +436,7 @@ static Set findScheduleList( BufferedReader br = null; String line = null; final Context context = provider.requireContextCompat(); - final String localTimeZoneId = AgencyUtils.getRDSAgencyTimeZoneId(context); + final String localTimeZoneId = AgencyUtils.getAgencyTimeZoneId(context); String fileName = String.format(getSTOP_SCHEDULE_RAW_FILE_FORMAT(context), stopId); try { @SuppressLint("DiscouragedApi") @@ -604,7 +604,7 @@ private static ArrayList findFrequencies(@NonNull GTFSProvid final Context context = provider.requireContextCompat(); final ThreadSafeDateFormatter dateFormat = getDateFormat(context); final ThreadSafeDateFormatter timeFormat = getTimeFormat(context); - final TimeZone timeZone = TimeZone.getTimeZone(AgencyUtils.getRDSAgencyTimeZoneId(context)); + final TimeZone timeZone = TimeZone.getTimeZone(AgencyUtils.getAgencyTimeZoneId(context)); final Calendar startsAt = TimeUtils.getNewCalendar(timeZone, timestamp); startsAt.add(Calendar.DATE, -1); // starting yesterday HashSet dayFrequencies; @@ -766,7 +766,7 @@ private static Long convertToTimestamp(Context context, int timeInt, String date private static ThreadSafeDateFormatter getToTimestampFormat(Context context) { if (toTimestampFormat == null) { toTimestampFormat = new ThreadSafeDateFormatter(TO_TIMESTAMP_FORMAT_PATTERN, Locale.ENGLISH); - final String localTimeZoneId = AgencyUtils.getRDSAgencyTimeZoneId(context); + final String localTimeZoneId = AgencyUtils.getAgencyTimeZoneId(context); toTimestampFormat.setTimeZone(TimeZone.getTimeZone(localTimeZoneId)); } return toTimestampFormat; 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 d55256614..e3c702956 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 @@ -226,7 +226,8 @@ object GtfsRealtimeExt { 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() } + fun GTripUpdate.hasStopTimeUpdateList() = stopTimeUpdateList.isNotEmpty() + val GTripUpdate.optStopTimeUpdateList get() = if (hasStopTimeUpdateList()) stopTimeUpdateList else null val GTripUpdate.optTimestamp get() = if (hasTimestamp()) timestamp else null val GTripUpdate.optTimestampMs get() = optTimestamp?.secToMs() val GTripUpdate.optDelay get() = if (hasDelay()) delay else null @@ -299,6 +300,9 @@ object GtfsRealtimeExt { val GTUStopTimeEvent.optScheduledTime get() = if (hasScheduledTime()) scheduledTime else null val GTUStopTimeEvent.optScheduledTimeMs get() = optScheduledTime?.secToMs() val GTUStopTimeEvent.optScheduledTimeInstant get() = optScheduledTime?.secsToInstant() + fun GTUStopTimeEvent.hasTimeOrScheduledTime() = hasTime() || hasScheduledTime() + val GTUStopTimeEvent.optTimeOrScheduledTime get() = optTime ?: optScheduledTime + val GTUStopTimeEvent.optTimeOrScheduledTimeMs get() = optTimeOrScheduledTime?.secToMs() @JvmStatic @JvmOverloads 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 0ebe931d6..9d39b9bf2 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 @@ -109,8 +109,9 @@ object GTFSRealTimeServiceAlertsProvider : MTLog.Loggable { // Best practices: 10 minutes // https://gtfs.org/documentation/realtime/realtime-best-practices/#feed-publishing-general-practices + // Montréal-based competitor: no limit @JvmStatic - val SERVICE_ALERTS_MAX_AGE_MS = 10.times(3).minutes.inWholeMilliseconds + val SERVICE_ALERTS_MAX_AGE_MS = 10.times(13).minutes.inWholeMilliseconds @JvmStatic fun GTFSRealTimeProvider.getNew(filter: ServiceUpdateProviderContract.Filter): ServiceUpdates? { 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 index 765529d2f..00b2f03ac 100644 --- a/src/main/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProvider.kt +++ b/src/main/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProvider.kt @@ -18,13 +18,23 @@ import org.mtransit.android.commons.data.makeSchedule import org.mtransit.android.commons.data.toNoData import org.mtransit.android.commons.provider.GTFSRealTimeProvider import org.mtransit.android.commons.provider.GTFSRealTimeProvider.ALLOW_IGNORE_TRIP_DESCRIPTOR_DIRECTION_ID -import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optDelay +import org.mtransit.android.commons.provider.agency.AgencyUtils +import org.mtransit.android.commons.provider.gtfs.GTFSDateTimeUtils.parseToDateTime +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.hasStopTimeUpdateList +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.hasTimeOrScheduledTime +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optArrival +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optDeparture import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optDirectionIdValid +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optModifiedTrip import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optScheduleRelationship +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optStartDate +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optStartTime import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optStopTimeUpdateList +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTimeOrScheduledTimeMs import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTimestampMs import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTrip import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTripIdNotEmpty +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optVehicle import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.sortTripUpdates import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.toStringExt import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.toTripUpdates @@ -33,9 +43,11 @@ import org.mtransit.android.commons.provider.gtfs.getRDSSchedule import org.mtransit.android.commons.provider.gtfs.getTripIds import org.mtransit.android.commons.provider.gtfs.ignoreDirection import org.mtransit.android.commons.provider.gtfs.makeRequest +import org.mtransit.android.commons.provider.gtfs.optTimeZoneId import org.mtransit.android.commons.provider.gtfs.parseRouteId import org.mtransit.android.commons.provider.gtfs.parseTripId import org.mtransit.android.commons.provider.gtfs.storage +import org.mtransit.android.commons.toMillis import org.mtransit.android.toDateTimeLog import org.mtransit.android.toDurationLog import org.mtransit.commons.SourceUtils @@ -54,6 +66,7 @@ import com.google.transit.realtime.GtfsRealtime.FeedMessage as GFeedMessage 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 kotlinx.datetime.TimeZone as KtTimeZone object GTFSRealTimeTripUpdatesProvider : MTLog.Loggable { @@ -107,27 +120,47 @@ object GTFSRealTimeTripUpdatesProvider : MTLog.Loggable { private const val PRINT_ALL_RD_STOP_TIME_UPDATES = false // private const val PRINT_ALL_RD_STOP_TIME_UPDATES = true // DEBUG - private fun GTripUpdate.isUseful(nowMs: Long): Boolean { + private fun GTripUpdate.isUseful(nowMs: Long, agencyTimeZone: KtTimeZone): Boolean { optTimestampMs?.let { tuTimestamp -> if (tuTimestamp + TRIP_UPDATE_MAX_AGE_MS < nowMs) { - MTLog.d(LOG_TAG, "isUseful() > IGNORE ${(nowMs - tuTimestamp).toDurationLog()} old: ${this.toStringExt()}") - return false + MTLog.d(LOG_TAG, "isUseful() > IGNORE ${(nowMs - tuTimestamp).toDurationLog()} old: ${this.optTrip?.toStringExt(true)}") + return false // not useful (too old to display) } } + val hasVehicleInfo = optVehicle?.let { it.hasId() || it.hasLabel() || it.hasLicensePlate() } == true + val startDateTimeOrFirstTimeMs: Long? = if (hasVehicleInfo) null else + (optTrip?.let { parseToDateTime(it.optStartDate, it.optStartTime, agencyTimeZone) }?.toMillis() + ?: optTrip?.optModifiedTrip?.let { parseToDateTime(it.optStartDate, it.optStartTime, agencyTimeZone) }?.toMillis() + ?: optStopTimeUpdateList // not sorting because GTFS spec requires it to be already sorted & it's a fallback + ?.firstOrNull { it.optDeparture?.hasTimeOrScheduledTime() == true || it.optArrival?.hasTimeOrScheduledTime() == true } + ?.let { + it.optDeparture?.optTimeOrScheduledTimeMs ?: it.optArrival?.optTimeOrScheduledTimeMs + }) + startDateTimeOrFirstTimeMs?.let { timeMs -> + if (nowMs + FUTURE_TRIP_UPDATE_MAX_DIFF_MS < timeMs) { + MTLog.d(LOG_TAG, "isUseful() > IGNORE ${((timeMs - nowMs)).toDurationLog()} in the future: ${this.optTrip?.toStringExt(true)}") + return false // not useful (too far in advance to display) + } + } + if (hasDelay() || hasStopTimeUpdateList()) { + return true // useful (contains delay or STUs) + } optTrip?.let { td -> // cannot match w/ static data - if (optDelay != null - || optStopTimeUpdateList?.isNotEmpty() == true - || td.optScheduleRelationship?.let { it != GTDScheduleRelationship.SCHEDULED } == true - ) { + if (td.optScheduleRelationship?.let { it != GTDScheduleRelationship.SCHEDULED } == true) { return true // useful } } + MTLog.w(LOG_TAG, "isUseful() > IGNORE (why?): ${this.toStringExt()}") return false // not useful } // Best practices: 90 seconds // https://gtfs.org/documentation/realtime/realtime-best-practices/#feed-publishing-general-practices - private val TRIP_UPDATE_MAX_AGE_MS = 90.times(3).seconds.inWholeMilliseconds + // Montréal-based competitor: 10 minutes + private val TRIP_UPDATE_MAX_AGE_MS = 90.times(7).seconds.inWholeMilliseconds + + // Montréal-based competitor: 4.5 hours (?) + private val FUTURE_TRIP_UPDATE_MAX_DIFF_MS = 90.minutes.times(3).inWholeMilliseconds private fun GTFSRealTimeProvider.makeCachedStatusFromAgencyData( context: Context, @@ -155,7 +188,6 @@ object GTFSRealTimeTripUpdatesProvider : MTLog.Loggable { val targetAuthority = filter.targetAuthority val targetRouteIdHash = targetRoute.originalIdHash.toString() val targetDirectionOriginalId = targetDirection.originalDirectionIdOrNull - var tripIdsOutOfSync = false if (DEBUG_STATIC_RT_MATCH) { MTLog.d(LOG_TAG, "makeCachedStatusFromAgencyData() > target trip IDs [${staticRDTripIds.size}]:") staticRDTripIds.chunked(9).forEach { @@ -168,8 +200,13 @@ object GTFSRealTimeTripUpdatesProvider : MTLog.Loggable { val rdSchedules: Collection by lazy { context.getRDSSchedule(targetAuthority, sortedRDS, filter.isIncludeCancelledTimestampsOrDefault) } + val agencyTimeZoneId = this.optTimeZoneId + ?: AgencyUtils.getAgencyTimeZoneId(context) + val agencyTimeZone = runCatching { KtTimeZone.of(agencyTimeZoneId) }.getOrElse { e -> + MTLog.w(LOG_TAG, e, "makeCachedStatusFromAgencyData() > error getting timezone from '$agencyTimeZoneId'!") + KtTimeZone.currentSystemDefault() + } val rdTripUpdates = gTripUpdates - .filter { it.isUseful(nowMs) } .filter { gTripUpdate -> gTripUpdate.optTrip?.match( targetRouteIdHash = targetRouteIdHash, @@ -178,20 +215,12 @@ object GTFSRealTimeTripUpdatesProvider : MTLog.Loggable { ignoreDirection = ignoreDirection, parseRouteId = ::parseRouteId, parseTripId = ::parseTripId, - setTripIdsOutOfSync = { - tripIdsOutOfSync = it - }, ) == true } + .filter { it.isUseful(nowMs, agencyTimeZone) } .filterDuplicatesTrips() .takeIf { it.isNotEmpty() } ?.mapNotNull { gTripUpdate -> gTripUpdate.optTrip?.let { it to gTripUpdate } } - if (tripIdsOutOfSync) { - MTLog.i( - LOG_TAG, - "Trip IDs (might be) out of sync (or real-time route/direction ID provided) for route '${targetRoute.shortestName}' direction '${targetDirection.headsignValue}'." - ) - } rdTripUpdates ?: run { sortedRDS.forEach { rds -> rds.makeSchedule( @@ -224,7 +253,7 @@ object GTFSRealTimeTripUpdatesProvider : MTLog.Loggable { rdTripUpdates.forEach { (_, gTripUpdate) -> MTLog.d(LOG_TAG, "makeCachedStatusFromAgencyData() > GTFS - ${gTripUpdate.toStringExt()}.") if (PRINT_ALL_RD_STOP_TIME_UPDATES) { - gTripUpdate.stopTimeUpdateList?.forEachIndexed { idx, stu -> + gTripUpdate.optStopTimeUpdateList?.forEachIndexed { idx, stu -> MTLog.d(LOG_TAG, "makeCachedStatusFromAgencyData() > GTFS - [$idx] ${stu.toStringExt()}") } } @@ -247,6 +276,9 @@ object GTFSRealTimeTripUpdatesProvider : MTLog.Loggable { } } + // Montréal-based competitor: match with 'trip_id' first, + // else use 'route_id', 'direction_id', 'start_date' & 'start_time' to try to match correct trip as fallback + @VisibleForTesting internal fun GTripDescriptor.match( targetRouteIdHash: String, @@ -255,42 +287,49 @@ object GTFSRealTimeTripUpdatesProvider : MTLog.Loggable { ignoreDirection: Boolean, parseRouteId: (GTripDescriptor) -> String?, parseTripId: (GTripDescriptor) -> String?, - setTripIdsOutOfSync: (Boolean) -> Unit, ): Boolean { - parseRouteId(this)?.let { rtRouteIdHash -> - if (rtRouteIdHash != targetRouteIdHash) { - // if (DEBUG_STATIC_RT_MATCH) { // too much log - // MTLog.d(LOG_TAG, "match() > IGNORE: wrong route ID '$rtRouteIdHash' (t:$targetRouteIdHash)") - // } - return false // NOT A MATCH + parseTripId(this)?.let { tripId -> + if (tripId in staticRDTripIds) { + return true // MATCH } } - @Suppress("SimplifyBooleanWithConstants") - if (!ALLOW_IGNORE_TRIP_DESCRIPTOR_DIRECTION_ID || optTripIdNotEmpty == null) { - optDirectionIdValid?.takeIf { !ignoreDirection }?.let { directionId -> - if (directionId != targetDirectionOriginalId) { - if (DEBUG_STATIC_RT_MATCH) { - MTLog.d( - LOG_TAG, - "match() > IGNORE: wrong direction ID '$directionId' for ${toStringExt(short = true)}" - ) - } + @Suppress("ConstantConditionIf") + if (false) { // FIXME later try to match with route_id & direction_id & start date+time + parseRouteId(this)?.let { rtRouteIdHash -> + if (rtRouteIdHash != targetRouteIdHash) { + // if (DEBUG_STATIC_RT_MATCH) { // too much log + // MTLog.d(LOG_TAG, "match() > IGNORE: wrong route ID '$rtRouteIdHash' (t:$targetRouteIdHash)") + // } return false // NOT A MATCH } } - } - parseTripId(this)?.let { tripId -> - if (tripId !in staticRDTripIds) { - if (hasRouteId()) { - if (DEBUG_STATIC_RT_MATCH) { - MTLog.d(LOG_TAG, "match() > IGNORE: wrong trip ID ($tripId) for ${toStringExt(short = true)}") + @Suppress("SimplifyBooleanWithConstants") + if (!ALLOW_IGNORE_TRIP_DESCRIPTOR_DIRECTION_ID || optTripIdNotEmpty == null) { + optDirectionIdValid?.takeIf { !ignoreDirection }?.let { directionId -> + if (directionId != targetDirectionOriginalId) { + if (DEBUG_STATIC_RT_MATCH) { + MTLog.d( + LOG_TAG, + "match() > IGNORE: wrong direction ID '$directionId' for ${toStringExt(short = true)}" + ) + } + return false // NOT A MATCH } - setTripIdsOutOfSync(true) } - return false // NOT A MATCH } + parseTripId(this)?.let { tripId -> + if (tripId !in staticRDTripIds) { + if (hasRouteId()) { + if (DEBUG_STATIC_RT_MATCH) { + MTLog.d(LOG_TAG, "match() > IGNORE: wrong trip ID ($tripId) for ${toStringExt(short = true)}") + } + } + return false // NOT A MATCH + } + } + return true // MATCH } - return true // MATCH + return false // NOT A MATCH } private val OLDEST_FOR_REAL_TIME = 1.minutes 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 6b4c241a4..6425c221f 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 @@ -175,7 +175,8 @@ object GTFSRealTimeVehiclePositionsProvider : MTLog.Loggable { // Best practices: 90 seconds // https://gtfs.org/documentation/realtime/realtime-best-practices/#feed-publishing-general-practices - private val VEHICLE_POSITIONS_MAX_AGE_MS = 90.times(3).seconds.inWholeMilliseconds + // Montréal-based competitor: 3 minutes + private val VEHICLE_POSITIONS_MAX_AGE_MS = 90.times(2).seconds.inWholeMilliseconds private fun GTFSRealTimeProvider.loadAgencyDataFromWWW(): List? { try { diff --git a/src/test/java/org/mtransit/android/commons/provider/gtfs/GTFSDateTimeUtilsTest.kt b/src/test/java/org/mtransit/android/commons/provider/gtfs/GTFSDateTimeUtilsTest.kt new file mode 100644 index 000000000..fb4728d40 --- /dev/null +++ b/src/test/java/org/mtransit/android/commons/provider/gtfs/GTFSDateTimeUtilsTest.kt @@ -0,0 +1,42 @@ +package org.mtransit.android.commons.provider.gtfs + +import org.mtransit.android.commons.millisToInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.datetime.TimeZone as KtTimeZone + +class GTFSDateTimeUtilsTest { + + private val timeZone = KtTimeZone.of("America/Edmonton") + + @Test + fun test_parseToDateTime() { + ("20260730" to "12:24:00").let { (date, time) -> + GTFSDateTimeUtils.parseToDateTime(date, time, timeZone) + }.let { result -> + assertEquals(1785435840_000L.millisToInstant(), result) + } + ("20260730" to "24:24:00").let { (date, time) -> + GTFSDateTimeUtils.parseToDateTime(date, time, timeZone) + }.let { result -> + assertEquals(1785479040_000L.millisToInstant(), result) + } + ("20260730" to "00:24:00").let { (date, time) -> + GTFSDateTimeUtils.parseToDateTime(date, time, timeZone) + }.let { result -> + assertEquals(1785392640_000L.millisToInstant(), result) + } + (null to null).let { (date, time) -> + GTFSDateTimeUtils.parseToDateTime(date, time, timeZone) + }.let { result -> + assertNull(result) + } + ("2026-07-30" to "-1:74:77").let { (date, time) -> + GTFSDateTimeUtils.parseToDateTime(date, time, timeZone) + }.let { result -> + assertNull(result) + } + } + +} 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 index 74452fa00..5d0180dfb 100644 --- a/src/test/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProviderTests.kt +++ b/src/test/java/org/mtransit/android/commons/provider/status/GTFSRealTimeTripUpdatesProviderTests.kt @@ -18,6 +18,7 @@ import org.mtransit.android.commons.data.toScheduleTimestamp import org.mtransit.android.commons.provider.gtfs.GTFSStatusProvider import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.delayDuration import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optRouteId +import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optStopTimeUpdateList import org.mtransit.android.commons.provider.gtfs.GtfsRealtimeExt.optTripId import org.mtransit.android.commons.provider.status.GTFSRealTimeTripUpdatesProvider.match import org.mtransit.android.commons.secsToInstant @@ -1163,7 +1164,7 @@ class GTFSRealTimeTripUpdatesProviderTests { } }.sortedBy { (_, stopSequence) -> stopSequence } - val result = gTripUpdate.stopTimeUpdateList?.fixStopSequence( + val result = gTripUpdate.optStopTimeUpdateList?.fixStopSequence( tripId = TRIP_ID, tripSortedRDS = rdsList, sortedTargetUuidAndSequence = sortedTargetUuidAndSequence, @@ -1201,7 +1202,6 @@ class GTFSRealTimeTripUpdatesProviderTests { @Test fun test_match_trip_id_only_matches() { - var tripIdsOutOfSync = false tripDescriptor { tripId = TRIP_ID }.match( @@ -1211,16 +1211,13 @@ class GTFSRealTimeTripUpdatesProviderTests { ignoreDirection = false, parseRouteId = { it.optRouteId }, parseTripId = { it.optTripId }, - setTripIdsOutOfSync = { tripIdsOutOfSync = it} ).let { result -> assertTrue(result) - assertFalse(tripIdsOutOfSync) } } @Test fun test_match_trip_id_only_does_not_match() { - var tripIdsOutOfSync = false tripDescriptor { tripId = TRIP_ID }.match( @@ -1230,16 +1227,13 @@ class GTFSRealTimeTripUpdatesProviderTests { ignoreDirection = false, parseRouteId = { it.optRouteId }, parseTripId = { it.optTripId }, - setTripIdsOutOfSync = { tripIdsOutOfSync = it} ).let { result -> assertFalse(result) - assertFalse(tripIdsOutOfSync) } } @Test fun test_match_trip_id_does_not_match_and_route_matches() { - var tripIdsOutOfSync = false tripDescriptor { routeId = "1" tripId = TRIP_ID @@ -1250,10 +1244,8 @@ class GTFSRealTimeTripUpdatesProviderTests { ignoreDirection = false, parseRouteId = { it.optRouteId }, parseTripId = { it.optTripId }, - setTripIdsOutOfSync = { tripIdsOutOfSync = it} ).let { result -> assertFalse(result) - assertTrue(tripIdsOutOfSync) } }