From 4a71f2d30aa2b52ef63b16718ee6b5c875259ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20M=C3=A9a?= Date: Wed, 18 Mar 2026 11:09:41 -0400 Subject: [PATCH 1/5] STM.info API > new service update endpoint - https://github.com/mtransitapps/ca-montreal-stm-bus-android/pull/12 --- .../mtransit/android/commons/TimeUtilsK.kt | 2 + .../android/commons/data/DefaultPOI.java | 2 +- .../android/commons/data/Direction.java | 2 +- .../mtransit/android/commons/data/POI.java | 6 + .../mtransit/android/commons/data/Route.java | 2 +- .../commons/data/RouteDirectionStop.java | 2 +- .../android/commons/data/ServiceUpdateKtx.kt | 83 +++- .../mtransit/android/commons/data/Stop.java | 2 +- .../commons/provider/CaEdmontonProvider.java | 2 +- .../commons/provider/CaLTCOnlineProvider.java | 2 +- .../commons/provider/CaTransLinkProvider.java | 2 +- .../commons/provider/GBFSProvider.java | 2 +- .../provider/GTFSRealTimeProvider.java | 14 +- .../provider/GreaterSudburyProvider.java | 2 +- .../commons/provider/NextBusProvider.java | 6 +- .../commons/provider/OCTranspoProvider.java | 4 +- .../commons/provider/RSSNewsProvider.java | 17 +- .../commons/provider/RTCQuebecProvider.java | 4 +- .../commons/provider/StmInfoApiProvider.java | 106 ++++- .../provider/StmInfoSubwayProvider.java | 2 +- .../provider/StrategicMappingProvider.java | 2 +- .../ca/info/stm/EtatServiceResponse.kt | 57 +++ .../provider/ca/info/stm/StmInfoApi.kt | 18 + .../info/stm/StmInfoServiceUpdateProvider.kt | 369 ++++++++++++++++++ .../info/stm/StmInfoServiceUpdateStorage.kt | 48 +++ .../provider/news/NewsProviderContract.java | 2 +- .../ServiceUpdateProviderContractExt.kt | 8 + src/main/res/values/stm_info_api_values.xml | 5 + 28 files changed, 723 insertions(+), 50 deletions(-) create mode 100644 src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt create mode 100644 src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoApi.kt create mode 100644 src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateProvider.kt create mode 100644 src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateStorage.kt create mode 100644 src/main/java/org/mtransit/android/commons/provider/serviceupdate/ServiceUpdateProviderContractExt.kt diff --git a/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt b/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt index c13e3c58..370bf2cc 100644 --- a/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt +++ b/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt @@ -22,6 +22,8 @@ object TimeUtilsK { }.trim() fun currentInstant() = TimeUtils.currentTimeMillis().millisToInstant() + + val EPOCH_TIME_0: Instant = 0L.millisToInstant() } fun Duration.formatSimpleDuration() = this.inWholeMilliseconds.let { TimeUtilsK.formatSimpleDuration(it) } diff --git a/src/main/java/org/mtransit/android/commons/data/DefaultPOI.java b/src/main/java/org/mtransit/android/commons/data/DefaultPOI.java index 3945f01c..43481279 100644 --- a/src/main/java/org/mtransit/android/commons/data/DefaultPOI.java +++ b/src/main/java/org/mtransit/android/commons/data/DefaultPOI.java @@ -135,7 +135,7 @@ public int getType() { @Override public String getUUID() { if (this.uuid == null) { - this.uuid = POI.POIUtils.getUUID(getAuthority(), getId()); + this.uuid = POI.POIUtils.makeUUID(getAuthority(), getId()); } return this.uuid; } diff --git a/src/main/java/org/mtransit/android/commons/data/Direction.java b/src/main/java/org/mtransit/android/commons/data/Direction.java index a55359ab..29f14c42 100644 --- a/src/main/java/org/mtransit/android/commons/data/Direction.java +++ b/src/main/java/org/mtransit/android/commons/data/Direction.java @@ -235,7 +235,7 @@ public String getAuthority() { @NonNull @Override public String getUUID() { - return POI.POIUtils.getUUID(this.authority, this.routeId, this.id); + return POI.POIUtils.makeUUID(this.authority, this.routeId, this.id); } public long getId() { diff --git a/src/main/java/org/mtransit/android/commons/data/POI.java b/src/main/java/org/mtransit/android/commons/data/POI.java index 40172c72..26033d51 100644 --- a/src/main/java/org/mtransit/android/commons/data/POI.java +++ b/src/main/java/org/mtransit/android/commons/data/POI.java @@ -122,8 +122,14 @@ public String getLogTag() { public static final String UID_SEPARATOR = "-"; + @Deprecated @NonNull public static String getUUID(@NonNull String authority, @NonNull Object... poiUIDs) { + return makeUUID(authority, poiUIDs); + } + + @NonNull + public static String makeUUID(@NonNull String authority, @NonNull Object... poiUIDs) { StringBuilder sb = new StringBuilder(authority); for (Object poiUID : poiUIDs) { sb.append(UID_SEPARATOR).append(poiUID); diff --git a/src/main/java/org/mtransit/android/commons/data/Route.java b/src/main/java/org/mtransit/android/commons/data/Route.java index ba2e2bea..8a5149c8 100644 --- a/src/main/java/org/mtransit/android/commons/data/Route.java +++ b/src/main/java/org/mtransit/android/commons/data/Route.java @@ -213,7 +213,7 @@ public long getId() { @Override public String getUUID() { if (this.uuid == null) { - this.uuid = POI.POIUtils.getUUID(this.authority, this.id); + this.uuid = POI.POIUtils.makeUUID(this.authority, this.id); } return this.uuid; } diff --git a/src/main/java/org/mtransit/android/commons/data/RouteDirectionStop.java b/src/main/java/org/mtransit/android/commons/data/RouteDirectionStop.java index bb0f14e6..170e6152 100644 --- a/src/main/java/org/mtransit/android/commons/data/RouteDirectionStop.java +++ b/src/main/java/org/mtransit/android/commons/data/RouteDirectionStop.java @@ -107,7 +107,7 @@ public String getUUID() { @NonNull public static String makeUUID(@NonNull String authority, long routeId, long directionId, int stopId) { - return POI.POIUtils.getUUID(authority, routeId, directionId, stopId); + return POI.POIUtils.makeUUID(authority, routeId, directionId, stopId); } @Override diff --git a/src/main/java/org/mtransit/android/commons/data/ServiceUpdateKtx.kt b/src/main/java/org/mtransit/android/commons/data/ServiceUpdateKtx.kt index 08aa185f..fc3f1a32 100644 --- a/src/main/java/org/mtransit/android/commons/data/ServiceUpdateKtx.kt +++ b/src/main/java/org/mtransit/android/commons/data/ServiceUpdateKtx.kt @@ -4,6 +4,9 @@ import org.mtransit.android.commons.MTLog import org.mtransit.android.commons.StringUtils import org.mtransit.android.commons.TimeUtils import org.mtransit.android.commons.provider.serviceupdate.ServiceUpdateProviderContract +import org.mtransit.android.commons.toMillis +import kotlin.time.Duration +import kotlin.time.Instant fun ServiceUpdate.syncTargetUUID(targetUUIDs: Map?) { targetUUIDs?.takeIf { it.isNotEmpty() } ?: return @@ -14,6 +17,7 @@ fun ServiceUpdate.syncTargetUUID(targetUUIDs: Map?) { } } +@Suppress("unused") // main app only fun Iterable?.isSeverityWarningInfo(): Pair { this ?: return false to false if (any { it.isSeverityWarning }) return true to false @@ -21,6 +25,7 @@ fun Iterable?.isSeverityWarningInfo(): Pair { return false to false } +@Suppress("unused") // main app only fun Iterable.distinctByOriginalId() = this.distinctBy { it.originalId ?: it.id } // keep 1st occurrence from sorted list (in *Manager) @@ -30,17 +35,69 @@ fun ServiceUpdateProviderContract.makeServiceUpdateNoneList(targetable: Targetab } fun ServiceUpdateProviderContract.makeServiceUpdateNone(targetUUID: String, sourceId: String) = - ServiceUpdate( - null, - targetUUID, - null, - TimeUtils.currentTimeMillis(), - getServiceUpdateMaxValidityInMs(), - StringUtils.EMPTY, - null, - ServiceUpdate.SEVERITY_NONE, - sourceId, - StringUtils.EMPTY, - null, - getServiceUpdateLanguage(), + makeServiceUpdate( + targetUUID = targetUUID, + lastUpdateMs = TimeUtils.currentTimeMillis(), + maxValidityMs = getServiceUpdateMaxValidityInMs(), + text = StringUtils.EMPTY, + severity = ServiceUpdate.SEVERITY_NONE, + sourceId = sourceId, + sourceLabel = StringUtils.EMPTY, + language = getServiceUpdateLanguage(), ) + +fun makeServiceUpdate( + optId: Int? = null, + targetUUID: String, + targetTripId: String? = null, + lastUpdate: Instant, + maxValidity: Duration, + text: String, + optTextHTML: String? = null, + severity: Int, + sourceId: String, + sourceLabel: String, + originalId: String? = null, + language: String +) = makeServiceUpdate( + optId, + targetUUID, + targetTripId, + lastUpdate.toMillis(), + maxValidity.inWholeMilliseconds, + text, + optTextHTML, + severity, + sourceId, + sourceLabel, + originalId, + language, +) + +fun makeServiceUpdate( + optId: Int? = null, + targetUUID: String, + targetTripId: String? = null, + lastUpdateMs: Long, + maxValidityMs: Long, + text: String, + optTextHTML: String? = null, + severity: Int, + sourceId: String, + sourceLabel: String, + originalId: String? = null, + language: String +) = ServiceUpdate( + optId, + targetUUID, + targetTripId, + lastUpdateMs, + maxValidityMs, + text, + optTextHTML, + severity, + sourceId, + sourceLabel, + originalId, + language, +) 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 9d19f19f..5b188e58 100644 --- a/src/main/java/org/mtransit/android/commons/data/Stop.java +++ b/src/main/java/org/mtransit/android/commons/data/Stop.java @@ -162,7 +162,7 @@ public int getId() { @NonNull public String getUUID(@NonNull String authority) { - return POI.POIUtils.getUUID(authority, this.id); + return POI.POIUtils.makeUUID(authority, this.id); } @NonNull diff --git a/src/main/java/org/mtransit/android/commons/provider/CaEdmontonProvider.java b/src/main/java/org/mtransit/android/commons/provider/CaEdmontonProvider.java index 9416ddf3..fa4bf792 100644 --- a/src/main/java/org/mtransit/android/commons/provider/CaEdmontonProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/CaEdmontonProvider.java @@ -184,7 +184,7 @@ private static String getAgencyRouteStopTargetUUID(@NonNull RouteDirectionStop r @NonNull private static String getAgencyRouteStopTargetUUID(String agencyAuthority, String routeShortName, String stopCode) { - return POI.POIUtils.getUUID(agencyAuthority, routeShortName, stopCode); + return POI.POIUtils.makeUUID(agencyAuthority, routeShortName, stopCode); } @Override diff --git a/src/main/java/org/mtransit/android/commons/provider/CaLTCOnlineProvider.java b/src/main/java/org/mtransit/android/commons/provider/CaLTCOnlineProvider.java index 8950f399..429c791f 100644 --- a/src/main/java/org/mtransit/android/commons/provider/CaLTCOnlineProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/CaLTCOnlineProvider.java @@ -193,7 +193,7 @@ protected static String getAgencyRouteStopTargetUUID(@NonNull String agencyAutho @NonNull String routeShortName, @Nullable String optTripHeaSignValue, @NonNull String stopId) { - return POI.POIUtils.getUUID(agencyAuthority, routeShortName, optTripHeaSignValue, stopId); + return POI.POIUtils.makeUUID(agencyAuthority, routeShortName, optTripHeaSignValue, stopId); } @NonNull diff --git a/src/main/java/org/mtransit/android/commons/provider/CaTransLinkProvider.java b/src/main/java/org/mtransit/android/commons/provider/CaTransLinkProvider.java index 43dc564e..f75efac2 100644 --- a/src/main/java/org/mtransit/android/commons/provider/CaTransLinkProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/CaTransLinkProvider.java @@ -192,7 +192,7 @@ private String getAgencyRouteStopTargetUUID(@NonNull RouteDirectionStop rds) { @NonNull protected static String getAgencyRouteStopTargetUUID(@NonNull String agencyAuthority, @NonNull String routeShortName, @NonNull String stopCode) { - return POI.POIUtils.getUUID(agencyAuthority, routeShortName, stopCode); + return POI.POIUtils.makeUUID(agencyAuthority, routeShortName, stopCode); } @Override diff --git a/src/main/java/org/mtransit/android/commons/provider/GBFSProvider.java b/src/main/java/org/mtransit/android/commons/provider/GBFSProvider.java index c99494d1..f81d9331 100644 --- a/src/main/java/org/mtransit/android/commons/provider/GBFSProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/GBFSProvider.java @@ -408,7 +408,7 @@ private POIStatus parseAgencyJSONStationStatus(@NonNull String authority, } BikeStationAvailabilityPercent newBikeStationStatus = new BikeStationAvailabilityPercent( null, - POI.POIUtils.getUUID(authority, bikeStationId), + POI.POIUtils.makeUUID(authority, bikeStationId), newLastUpdateInMs, statusMaxValidityInMs, newLastUpdateInMs, 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 5cd81a6d..a948586a 100644 --- a/src/main/java/org/mtransit/android/commons/provider/GTFSRealTimeProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/GTFSRealTimeProvider.java @@ -745,42 +745,42 @@ public String getStopTag(@NonNull Stop stop) { @Nullable public static String getAgencyStopTagTargetUUID(@NonNull String agencyTag, @Nullable String stopTag) { if (stopTag == null) return null; - return POI.POIUtils.getUUID(agencyTag, "si" + stopTag); + return POI.POIUtils.makeUUID(agencyTag, "si" + stopTag); } @NonNull public static String getAgencyRouteTagTargetUUID(@NonNull String agencyTag, @NonNull String routeTag) { - return POI.POIUtils.getUUID(agencyTag, "ri" + routeTag); + return POI.POIUtils.makeUUID(agencyTag, "ri" + routeTag); } @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); + return POI.POIUtils.makeUUID(agencyTag, "ri" + routeTag, "si" + stopTag); } @Nullable public static String getAgencyRouteTypeTagTargetUUID(@NonNull String agencyTag, @Nullable Integer routeType) { if (routeType == null) return null; - return POI.POIUtils.getUUID(agencyTag, "t" + routeType); + return POI.POIUtils.makeUUID(agencyTag, "t" + routeType); } @Nullable public static String getAgencyRouteDirectionTagTargetUUID(@NonNull String agencyTag, @NonNull String routeTag, @Nullable Integer directionTag) { if (directionTag == null) return null; - return POI.POIUtils.getUUID(agencyTag, "ri" + routeTag, "d" + directionTag); + return POI.POIUtils.makeUUID(agencyTag, "ri" + routeTag, "d" + directionTag); } @Nullable 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); + return POI.POIUtils.makeUUID(agencyTag, "ri" + routeTag, "d" + directionTag, "si" + stopTag); } @NonNull public static String getAgencyTagTargetUUID(@NonNull String agencyTag) { - return POI.POIUtils.getUUID(agencyTag); + return POI.POIUtils.makeUUID(agencyTag); } @Override diff --git a/src/main/java/org/mtransit/android/commons/provider/GreaterSudburyProvider.java b/src/main/java/org/mtransit/android/commons/provider/GreaterSudburyProvider.java index b02dd2b5..781b8f27 100644 --- a/src/main/java/org/mtransit/android/commons/provider/GreaterSudburyProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/GreaterSudburyProvider.java @@ -206,7 +206,7 @@ private static String getAgencyRouteStopTargetUUID(@NonNull RouteDirectionStop r } private static String getAgencyRouteStopTargetUUID(String agencyAuthority, String routeShortName, boolean noPickup, String stopCode) { - return POI.POIUtils.getUUID(agencyAuthority, routeShortName, noPickup ? 1 : 0, stopCode); + return POI.POIUtils.makeUUID(agencyAuthority, routeShortName, noPickup ? 1 : 0, stopCode); } @Override 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 4938b614..be1a5697 100644 --- a/src/main/java/org/mtransit/android/commons/provider/NextBusProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/NextBusProvider.java @@ -789,17 +789,17 @@ private String cleanStopTag(@NonNull String stopTag) { @NonNull public static String getAgencyRouteStopTagTargetUUID(@NonNull String agencyTag, @NonNull String routeTag, @NonNull String stopTag) { - return POI.POIUtils.getUUID(agencyTag, routeTag, stopTag); + return POI.POIUtils.makeUUID(agencyTag, routeTag, stopTag); } @NonNull public static String getAgencyRouteTagTargetUUID(@NonNull String agencyTag, @NonNull String routeTag) { - return POI.POIUtils.getUUID(agencyTag, routeTag); + return POI.POIUtils.makeUUID(agencyTag, routeTag); } @NonNull public static String getAgencyTargetUUID(@NonNull String agencyTag) { - return POI.POIUtils.getUUID(agencyTag); + return POI.POIUtils.makeUUID(agencyTag); } @Override diff --git a/src/main/java/org/mtransit/android/commons/provider/OCTranspoProvider.java b/src/main/java/org/mtransit/android/commons/provider/OCTranspoProvider.java index 0c0eb85e..52bf2117 100644 --- a/src/main/java/org/mtransit/android/commons/provider/OCTranspoProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/OCTranspoProvider.java @@ -669,12 +669,12 @@ private Map getTargetUUIDs(@NonNull Route route) { @NonNull protected static String getAgencyRouteShortNameTargetUUID(@NonNull String agencyAuthority, @NonNull String routeShortName) { - return POI.POIUtils.getUUID(agencyAuthority, routeShortName); + return POI.POIUtils.makeUUID(agencyAuthority, routeShortName); } @NonNull protected static String getAgencyTargetUUID(@NonNull String agencyAuthority) { - return POI.POIUtils.getUUID(agencyAuthority); + return POI.POIUtils.makeUUID(agencyAuthority); } @Override 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 30108f15..7660aed2 100644 --- a/src/main/java/org/mtransit/android/commons/provider/RSSNewsProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/RSSNewsProvider.java @@ -1058,6 +1058,13 @@ private String getUUID(Long pubDateInMs) { private static final ThreadSafeDateFormatter ATOM_UPDATED_FORMATTER = new ThreadSafeDateFormatter(DATE_TIME_FORMAT, Locale.ENGLISH); private static final ThreadSafeDateFormatter DC_DATE_FORMATTER = new ThreadSafeDateFormatter("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", Locale.ENGLISH); + private static final boolean DATE_PARSING_DEBUG = false; + + private void logDateParsing(@NonNull String msg, @NonNull Object... args) { + if (!DATE_PARSING_DEBUG) return; + MTLog.d(this, msg, args); + } + private long getPublicationDateInMs() { final String currentPubDate = this.currentPubDateSb.toString().trim(); final String currentDate = this.currentDateSb.toString().trim(); @@ -1072,7 +1079,7 @@ private long getPublicationDateInMs() { } } } catch (Exception e) { - MTLog.d(this, "Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, RSS_PUB_DATE_FORMATTER_X.toPattern()); + logDateParsing("Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, RSS_PUB_DATE_FORMATTER_X.toPattern()); } try { if (!currentPubDate.isEmpty()) { @@ -1082,7 +1089,7 @@ private long getPublicationDateInMs() { } } } catch (Exception e) { - MTLog.d(this, "Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, RSS_PUB_DATE_FORMATTER.toPattern()); + logDateParsing("Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, RSS_PUB_DATE_FORMATTER.toPattern()); } // UPDATED try { @@ -1093,7 +1100,7 @@ private long getPublicationDateInMs() { } } } catch (Exception e) { - MTLog.d(this, "Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, ATOM_UPDATED_FORMATTER.toPattern()); + logDateParsing("Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, ATOM_UPDATED_FORMATTER.toPattern()); } // DATE try { @@ -1104,7 +1111,7 @@ private long getPublicationDateInMs() { } } } catch (Exception e) { - MTLog.d(this, "Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, DC_DATE_FORMATTER.toPattern()); + logDateParsing("Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, DC_DATE_FORMATTER.toPattern()); } // LINK try { @@ -1119,7 +1126,7 @@ private long getPublicationDateInMs() { } } } catch (Exception e) { - MTLog.d(this, "Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, RSS_PUB_DATE_FORMATTER_X.toPattern()); + logDateParsing("Error while parsing pub date '%s' with '%s'!", this.currentPubDateSb, RSS_PUB_DATE_FORMATTER_X.toPattern()); } MTLog.w(this, "Created fake date for news item!"); return getLastItemPublicationDateInMs() - TimeUnit.HOURS.toMillis(6L); diff --git a/src/main/java/org/mtransit/android/commons/provider/RTCQuebecProvider.java b/src/main/java/org/mtransit/android/commons/provider/RTCQuebecProvider.java index 1c6e1bb7..d625ae88 100644 --- a/src/main/java/org/mtransit/android/commons/provider/RTCQuebecProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/RTCQuebecProvider.java @@ -489,7 +489,7 @@ private static String getAgencyRouteStopTargetUUID(@NonNull RouteDirectionStop r @NonNull private static String getAgencyRouteStopTargetUUID(String agencyAuthority, String routeShortName, long directionId, String stopCode) { - return POI.POIUtils.getUUID(agencyAuthority, routeShortName, directionId, stopCode); + return POI.POIUtils.makeUUID(agencyAuthority, routeShortName, directionId, stopCode); } @NonNull @@ -511,7 +511,7 @@ private Map getServiceUpdateTargetUUID(@NonNull Route route) { @NonNull private static String getAgencyRouteShortNameTargetUUID(@NonNull String agencyAuthority, @NonNull String routeShortName) { - return POI.POIUtils.getUUID(agencyAuthority, routeShortName); + return POI.POIUtils.makeUUID(agencyAuthority, routeShortName); } @Override diff --git a/src/main/java/org/mtransit/android/commons/provider/StmInfoApiProvider.java b/src/main/java/org/mtransit/android/commons/provider/StmInfoApiProvider.java index e05347b9..3176e847 100644 --- a/src/main/java/org/mtransit/android/commons/provider/StmInfoApiProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/StmInfoApiProvider.java @@ -49,6 +49,7 @@ import org.mtransit.android.commons.data.ServiceUpdate; import org.mtransit.android.commons.data.ServiceUpdateKtxKt; import org.mtransit.android.commons.data.Stop; +import org.mtransit.android.commons.provider.ca.info.stm.StmInfoServiceUpdateProvider; import org.mtransit.android.commons.provider.common.MTContentProvider; import org.mtransit.android.commons.provider.common.MTSQLiteOpenHelper; import org.mtransit.android.commons.provider.gtfs.GTFSRDSProvider; @@ -68,6 +69,7 @@ import java.net.UnknownHostException; import java.text.ParseException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.Date; @@ -87,10 +89,16 @@ // DO NOT MOVE: referenced in modules AndroidManifest.xml @SuppressLint("Registered") -public class StmInfoApiProvider extends MTContentProvider implements StatusProviderContract, ServiceUpdateProviderContract, ProviderInstaller.ProviderInstallListener { +public class StmInfoApiProvider extends MTContentProvider implements + StatusProviderContract, + ServiceUpdateProviderContract, + ProviderInstaller.ProviderInstallListener { private static final String LOG_TAG = StmInfoApiProvider.class.getSimpleName(); + // private static final boolean USE_NEW_API = false; + private static final boolean USE_NEW_API = true; // WIP + private static final boolean STORE_EMPTY_SERVICE_MESSAGE = false; @NonNull @@ -182,6 +190,7 @@ public long getStatusMaxValidityInMs() { @Override public long getServiceUpdateMaxValidityInMs() { + if (USE_NEW_API) return StmInfoServiceUpdateProvider.getSERVICE_UPDATE_MAX_VALIDITY_IN_MS(); return STM_INFO_API_SERVICE_UPDATE_MAX_VALIDITY_IN_MS; } @@ -195,6 +204,7 @@ public long getStatusValidityInMs(boolean inFocus) { @Override public long getServiceUpdateValidityInMs(boolean inFocus) { + if (USE_NEW_API) return StmInfoServiceUpdateProvider.getValidityInMs(inFocus); if (inFocus) { return STM_INFO_API_SERVICE_UPDATE_VALIDITY_IN_FOCUS_IN_MS; } @@ -211,6 +221,7 @@ public long getMinDurationBetweenRefreshInMs(boolean inFocus) { @Override public long getMinDurationBetweenServiceUpdateRefreshInMs(boolean inFocus) { + if (USE_NEW_API) return StmInfoServiceUpdateProvider.getMinDurationBetweenRefreshInMs(inFocus); if (inFocus) { return STM_INFO_API_SERVICE_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_FOCUS_IN_MS; } @@ -258,6 +269,7 @@ public POIStatus getCachedStatus(@NonNull StatusProviderContract.Filter statusFi @Nullable @Override public List getCachedServiceUpdates(@NonNull ServiceUpdateProviderContract.Filter serviceUpdateFilter) { + if (USE_NEW_API) return StmInfoServiceUpdateProvider.getCached(this, serviceUpdateFilter); final Context context = requireContextCompat(); if ((serviceUpdateFilter.getPoi() instanceof RouteDirectionStop)) { return getCachedServiceUpdates(context, (RouteDirectionStop) serviceUpdateFilter.getPoi()); @@ -448,7 +460,7 @@ private static String getStopStatusTargetUUID(@NonNull Context context, @NonNull } private static String getStopStatusTargetUUID(String agency, String routeShortName, String directionHeadsign, String stopCode) { - return POI.POIUtils.getUUID(agency, routeShortName, directionHeadsign, stopCode); + return POI.POIUtils.makeUUID(agency, routeShortName, directionHeadsign, stopCode); } // region Service Update Target UUID @@ -472,7 +484,7 @@ private String getRouteDirectionServiceUpdateTargetUUID(@NonNull Context context @NonNull protected static String getRouteDirectionServiceUpdateTargetUUID(@NonNull String agency, @NonNull String routeShortName, @NonNull String directionHeadsignValue) { - return POI.POIUtils.getUUID(agency, routeShortName, directionHeadsignValue); + return POI.POIUtils.makeUUID(agency, routeShortName, directionHeadsignValue); } // endregion Route Direction @@ -498,7 +510,7 @@ private String getStopServiceUpdateTargetUUID(@NonNull Context context, @NonNull @NonNull private static String getStopServiceUpdateTargetUUID(@NonNull String agency, @NonNull String routeShortName, @NonNull String directionHeadsignValue, @NonNull String stopCode) { - return POI.POIUtils.getUUID(agency, routeShortName, directionHeadsignValue, stopCode); + return POI.POIUtils.makeUUID(agency, routeShortName, directionHeadsignValue, stopCode); } // endregion Route Direction Stop @@ -567,6 +579,7 @@ public POIStatus getNewStatus(@NonNull StatusProviderContract.Filter statusFilte @Nullable @Override public List getNewServiceUpdates(@NonNull ServiceUpdateProviderContract.Filter serviceUpdateFilter) { + if (USE_NEW_API) return StmInfoServiceUpdateProvider.getNew(this, serviceUpdateFilter); final Context context = requireContextCompat(); if ((serviceUpdateFilter.getPoi() instanceof RouteDirectionStop)) { return getNewServiceUpdates(context, (RouteDirectionStop) serviceUpdateFilter.getPoi()); @@ -608,6 +621,7 @@ private List getNewServiceUpdates(@SuppressWarnings("unused") @No @NonNull @Override public String getServiceUpdateLanguage() { + if (USE_NEW_API) return StmInfoServiceUpdateProvider.getServiceUpdateLanguage(); return LocaleUtils.isFR() ? Locale.FRENCH.getLanguage() : Locale.ENGLISH.getLanguage(); } @@ -684,7 +698,7 @@ private void loadRealTimeStatusFromWWW(@NonNull Context context, final String uuid = rds.getUUID(); synchronizedLock.putIfAbsent(uuid, uuid); synchronized (CollectionUtils.getOrDefault(synchronizedLock, uuid, uuid)) { - if (hasStatusFilter || !STORE_EMPTY_SERVICE_MESSAGE) { // IF is loading status OR empty service update not stored + if (hasStatusFilter || !STORE_EMPTY_SERVICE_MESSAGE) { // IF it is loading status OR empty service update not stored final POIStatus cachedStopStatus = StatusProvider.getCachedStatusS(this, getStopStatusTargetUUID(context, rds)); if (cachedStopStatus != null) { // DO check status update MTLog.d(this, "loadRealTimeStatusFromWWW() > SKIP (status already in cache for %s)", uuid); @@ -959,6 +973,88 @@ private synchronized void deleteOldAndCacheNewServiceUpdates(List cacheServiceUpdates(serviceUpdates); } + @Nullable + private static java.util.List urlHeaderName = null; + + /** + * Override if multiple {@link StmInfoApiProvider} implementations in same app. + */ + @NonNull + public static java.util.List getURL_HEADER_NAMES(@NonNull Context context) { + if (urlHeaderName == null) { + urlHeaderName = Arrays.asList(context.getResources().getStringArray(R.array.stm_info_api_url_header_names)); + } + return urlHeaderName; + } + + @Nullable + private static java.util.List urlHeaderValue = null; + + /** + * Override if multiple {@link StmInfoApiProvider} implementations in same app. + */ + @NonNull + public static java.util.List getURL_HEADER_VALUES(@NonNull Context context) { + if (urlHeaderValue == null) { + urlHeaderValue = Arrays.asList(context.getResources().getStringArray(R.array.stm_info_api_agency_url_header_values)); + } + return urlHeaderValue; + } + + @Nullable + private static Boolean statusEnabled = null; + + /** + * Override if multiple {@link StmInfoApiProvider} implementations in same app. + */ + @SuppressWarnings("unused") // used in shell + public static boolean getSTATUS_ENABLED(@NonNull Context context) { + if (statusEnabled == null) { + statusEnabled = context.getResources().getBoolean(R.bool.stm_info_api_status_provider); + } + return statusEnabled; + } + + @Nullable + private static Boolean serviceUpdatesEnabled = null; + + /** + * Override if multiple {@link StmInfoApiProvider} implementations in same app. + */ + @SuppressWarnings("unused") // used in shell + public static boolean getSERVICE_UPDATES_ENABLED(@NonNull Context context) { + if (serviceUpdatesEnabled == null) { + serviceUpdatesEnabled = context.getResources().getBoolean(R.bool.stm_info_api_service_update_provider); + } + return serviceUpdatesEnabled; + } + + @Nullable + private static String serviceUpdatesUrlCached = null; + + /** + * Override if multiple {@link StmInfoApiProvider} implementations in same app. + */ + @NonNull + public static String getSERVICE_UPDATES_URL_CACHED(@NonNull Context context) { + if (serviceUpdatesUrlCached == null) { + serviceUpdatesUrlCached = context.getResources().getString(R.string.stm_info_api_service_alerts_url_cached); + } + return serviceUpdatesUrlCached; + } + + @SuppressWarnings("UnusedReturnValue") + public int deleteAllAgencyServiceUpdateData() { + int affectedRows = 0; + try { + String selection = SqlUtils.getWhereEqualsString(ServiceUpdateProviderContract.Columns.T_SERVICE_UPDATE_K_SOURCE_ID, SERVICE_UPDATE_SOURCE_ID); + affectedRows = getWriteDB().delete(getServiceUpdateDbTableName(), selection, null); + } catch (Exception e) { + MTLog.w(this, e, "Error while deleting all agency service update data!"); + } + return affectedRows; + } + @Deprecated private JMessages parseAgencyJSONMessages(String jsonString) { List results = new ArrayList<>(); diff --git a/src/main/java/org/mtransit/android/commons/provider/StmInfoSubwayProvider.java b/src/main/java/org/mtransit/android/commons/provider/StmInfoSubwayProvider.java index a068566e..5fccc1e5 100644 --- a/src/main/java/org/mtransit/android/commons/provider/StmInfoSubwayProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/StmInfoSubwayProvider.java @@ -281,7 +281,7 @@ private Map getAgencyTargetUUID(@NonNull Route route) { } private String getAgencyTargetUUID(String targetAuthority, long routeId) { - return POI.POIUtils.getUUID(targetAuthority, routeId); + return POI.POIUtils.makeUUID(targetAuthority, routeId); } @Override diff --git a/src/main/java/org/mtransit/android/commons/provider/StrategicMappingProvider.java b/src/main/java/org/mtransit/android/commons/provider/StrategicMappingProvider.java index 4cbbce43..f57ee2d5 100644 --- a/src/main/java/org/mtransit/android/commons/provider/StrategicMappingProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/StrategicMappingProvider.java @@ -219,7 +219,7 @@ private static String getAgencyRouteStopTargetUUID(@NonNull RouteDirectionStop r @NonNull private static String getAgencyRouteStopTargetUUID(String agencyAuthority, String routeShortName, long directionId, String stopCode) { - return POI.POIUtils.getUUID(agencyAuthority, routeShortName, directionId, stopCode); + return POI.POIUtils.makeUUID(agencyAuthority, routeShortName, directionId, stopCode); } @Override diff --git a/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt new file mode 100644 index 00000000..4ad36e97 --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt @@ -0,0 +1,57 @@ +package org.mtransit.android.commons.provider.ca.info.stm + +import com.google.gson.annotations.SerializedName +import org.mtransit.android.commons.secsToInstant +import kotlin.time.Instant + +data class EtatServiceResponse( + @SerializedName("header") + val header: Header?, + @SerializedName("alerts") + val alerts: List?, +) { + data class Header( + @SerializedName("timestamp") + val timestampInSec: Int?, // in sec + ) + + data class Alert( + @SerializedName("active_periods") + val activePeriods: ActivePeriods?, + @SerializedName("cause") + val cause: String?, + @SerializedName("effect") + val effect: String?, + @SerializedName("informed_entities") + val informedEntities: List?, + @SerializedName("header_texts") + val headerTexts: List?, + @SerializedName("description_texts") + val descriptionTexts: List?, + ) { + data class ActivePeriods( + @SerializedName("start") + val startInSec: Int?, + @SerializedName("end") + val endInSec: Int?, + ) + data class InformedEntity( + @SerializedName("route_short_name") + val routeShortName: String?, + @SerializedName("direction_id") + val directionId: String?, + @SerializedName("stop_code") + val stopCode: String?, + ) + data class TranslatedText( + @SerializedName("language") + val language: String?, + @SerializedName("text") + val text: String?, + ) + } +} + +val EtatServiceResponse.Header.timestamp: Instant? get() = timestampInSec?.secsToInstant() +val EtatServiceResponse.Alert.ActivePeriods.start: Instant? get() = startInSec?.secsToInstant() +val EtatServiceResponse.Alert.ActivePeriods.end: Instant? get() = endInSec?.secsToInstant() diff --git a/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoApi.kt b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoApi.kt new file mode 100644 index 00000000..8a01e991 --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoApi.kt @@ -0,0 +1,18 @@ +package org.mtransit.android.commons.provider.ca.info.stm + +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.HeaderMap +import retrofit2.http.Url + +// https://portail.developpeurs.stm.info/apihub/ +// https://api.stm.info/pub/od/i3/v2/messages/etatservice" +interface StmInfoApi { + + companion object { + const val BASE_HOST_URL = "https://api.stm.info/pub/od/i3/" + } + + @GET // ("v2/messages/etatservice") + fun getV2MessageEtatService(@Url url: String, @HeaderMap headers: Map = emptyMap()): Call +} diff --git a/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateProvider.kt b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateProvider.kt new file mode 100644 index 00000000..2ae04689 --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateProvider.kt @@ -0,0 +1,369 @@ +package org.mtransit.android.commons.provider.ca.info.stm + +import android.content.Context +import android.util.Log +import org.mtransit.android.commons.HtmlUtils +import org.mtransit.android.commons.LocaleUtils +import org.mtransit.android.commons.MTLog +import org.mtransit.android.commons.NetworkUtils +import org.mtransit.android.commons.SecurityUtils +import org.mtransit.android.commons.TimeUtilsK +import org.mtransit.android.commons.data.Direction +import org.mtransit.android.commons.data.POI.POIUtils.makeUUID +import org.mtransit.android.commons.data.Route +import org.mtransit.android.commons.data.RouteDirection +import org.mtransit.android.commons.data.RouteDirectionStop +import org.mtransit.android.commons.data.ServiceUpdate +import org.mtransit.android.commons.data.makeServiceUpdate +import org.mtransit.android.commons.provider.StmInfoApiProvider +import org.mtransit.android.commons.provider.StmInfoApiProvider.getSERVICE_UPDATES_URL_CACHED +import org.mtransit.android.commons.provider.StmInfoApiProvider.getURL_HEADER_NAMES +import org.mtransit.android.commons.provider.StmInfoApiProvider.getURL_HEADER_VALUES +import org.mtransit.android.commons.provider.serviceupdate.ServiceUpdateCleaner +import org.mtransit.android.commons.provider.serviceupdate.ServiceUpdateProviderContract +import org.mtransit.android.commons.provider.serviceupdate.getCachedServiceUpdatesS +import org.mtransit.android.commons.provider.serviceupdate.getServiceUpdateValidity +import org.mtransit.android.commons.provider.serviceupdate.serviceUpdateMaxValidity +import org.mtransit.commons.SourceUtils +import retrofit2.create +import java.net.HttpURLConnection +import java.net.SocketException +import java.net.UnknownHostException +import java.util.Locale +import javax.net.ssl.SSLHandshakeException +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +object StmInfoServiceUpdateProvider : MTLog.Loggable { + + internal val LOG_TAG: String = StmInfoServiceUpdateProvider::class.java.simpleName + + override fun getLogTag() = LOG_TAG + + @JvmStatic + val SERVICE_UPDATE_MAX_VALIDITY_IN_MS = 1.days.inWholeMilliseconds + + val SERVICE_UPDATE_VALIDITY_IN_MS = 1.hours.inWholeMilliseconds + val SERVICE_UPDATE_VALIDITY_IN_FOCUS_IN_MS = 10.minutes.inWholeMilliseconds + + val SERVICE_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_MS = 10.minutes.inWholeMilliseconds + val SERVICE_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_FOCUS_IN_MS = 1.minutes.inWholeMilliseconds + + @JvmStatic + fun getValidityInMs(inFocus: Boolean) = + if (inFocus) SERVICE_UPDATE_VALIDITY_IN_FOCUS_IN_MS else SERVICE_UPDATE_VALIDITY_IN_MS + + @JvmStatic + fun getMinDurationBetweenRefreshInMs(inFocus: Boolean) = + if (inFocus) SERVICE_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_FOCUS_IN_MS else SERVICE_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_MS + + private const val AGENCY_SOURCE_ID = "api_stm_info_messages" + + @JvmStatic + fun StmInfoApiProvider.getCached(filter: ServiceUpdateProviderContract.Filter): List? { + return ((filter.poi as? RouteDirectionStop)?.getTargetUUIDs(includeStopTags = true) + ?: filter.routeDirection?.getTargetUUIDs() + ?: filter.route?.getTargetUUIDs()) + ?.let { targetUUIDs -> + getCached(targetUUIDs) + } + } + + fun StmInfoApiProvider.getCached(targetUUIDs: Map) = buildList { + getCachedServiceUpdatesS(targetUUIDs.keys)?.let { + addAll(it) + } + }.map { it.apply { targetUUID = targetUUIDs[it.targetUUID] ?: it.targetUUID } } + + @JvmStatic + fun StmInfoApiProvider.getNew(filter: ServiceUpdateProviderContract.Filter): List? { + updateAgencyDataIfRequired(filter.isInFocusOrDefault) + return getCached(filter) + } + + private fun StmInfoApiProvider.updateAgencyDataIfRequired(inFocus: Boolean) { + val context = requireContextCompat() + var inFocus = inFocus + val lastUpdate = StmInfoServiceUpdateStorage.getServiceUpdateLastUpdate(context, TimeUtilsK.EPOCH_TIME_0) + val lastUpdateCode = StmInfoServiceUpdateStorage.getServiceUpdateLastUpdateCode(context, -1).takeIf { it >= 0 } + if (lastUpdateCode != null && lastUpdateCode != HttpURLConnection.HTTP_OK) { + inFocus = true // force earlier retry if last fetch returned HTTP error + } + val minUpdate = serviceUpdateMaxValidity.coerceAtMost(getServiceUpdateValidity(inFocus)) + val now = TimeUtilsK.currentInstant() + if (lastUpdate + minUpdate > now) { + return + } + updateAgencyDataIfRequiredSync(lastUpdate, inFocus) + } + + @Synchronized + private fun StmInfoApiProvider.updateAgencyDataIfRequiredSync(lastUpdate: Instant, inFocus: Boolean) { + val context = requireContextCompat() + if (StmInfoServiceUpdateStorage.getServiceUpdateLastUpdate(context, TimeUtilsK.EPOCH_TIME_0) > lastUpdate) { + return // too late, another thread already updated + } + val now = TimeUtilsK.currentInstant() + var deleteAllRequired = false + if (lastUpdate + serviceUpdateMaxValidity < now) { + deleteAllRequired = true // too old to display + } + val minUpdate = serviceUpdateMaxValidity.coerceAtMost(getServiceUpdateValidity(inFocus)) + if (!deleteAllRequired && lastUpdate + minUpdate >= now) { + return + } + updateAllAgencyDataFromWWW(context, deleteAllRequired) // try to update + } + + private fun StmInfoApiProvider.updateAllAgencyDataFromWWW(context: Context, deleteAllRequired: Boolean) { + var deleteAllDone = false + if (deleteAllRequired) { + deleteAllAgencyServiceUpdateData() + deleteAllDone = true + } + val newServiceUpdates = loadAgencyDataFromWWW(context) + if (newServiceUpdates != null) { // empty is OK + if (!deleteAllDone) { + deleteAllAgencyServiceUpdateData() + } + cacheServiceUpdates(newServiceUpdates) + } // else keep whatever we have until max validity reached + } + + private var _stmInfoApi: StmInfoApi? = null + + private fun getStmInfoApi(context: Context) = + _stmInfoApi ?: createStmInfoApi(context).also { _stmInfoApi = it } + + private fun createStmInfoApi(context: Context): StmInfoApi { + val retrofit = NetworkUtils.makeNewRetrofitWithGson( + baseHostUrl = StmInfoApi.BASE_HOST_URL, + context = context, + ) + + return retrofit.create() + } + + @JvmStatic + val serviceUpdateLanguage: String get() = if (LocaleUtils.isFR()) Locale.FRENCH.language else DEFAULT_LANGUAGE + + private val DEFAULT_LANGUAGE: String = Locale.ENGLISH.language + + private const val SERVICE_UPDATE_URL = "https://api.stm.info/pub/od/i3/v2/messages/etatservice" + + private fun StmInfoApiProvider.loadAgencyDataFromWWW(context: Context): List? { + try { + val call = getSERVICE_UPDATES_URL_CACHED(context).takeIf { it.isNotBlank() }?.let { urlCachedString -> + getStmInfoApi(context).getV2MessageEtatService(urlCachedString) + } ?: run { + val agencyUrlHeaderNames = getURL_HEADER_NAMES(context) + val agencyUrlHeaderValues = getURL_HEADER_VALUES(context) + if (agencyUrlHeaderNames.size != agencyUrlHeaderValues.size) { + MTLog.w(this@StmInfoServiceUpdateProvider, "ERROR: agencyUrlHeaderNames.size != agencyUrlHeaderValues.size!") + return null + } + val headers: Map = agencyUrlHeaderNames.zip(agencyUrlHeaderValues).associate { (name, value) -> + name to value + } + getStmInfoApi(context).getV2MessageEtatService(SERVICE_UPDATE_URL, headers = headers) + } + call.execute().let { response -> + val now = TimeUtilsK.currentInstant() + StmInfoServiceUpdateStorage.saveServiceUpdateLastUpdateCode(context, response.code()) + StmInfoServiceUpdateStorage.saveServiceUpdateLastUpdate(context, now) + when (response.code()) { + HttpURLConnection.HTTP_OK -> { + val serviceUpdates = mutableListOf() + val sourceLabel = SourceUtils.getSourceLabel( // always use source from official API + SERVICE_UPDATE_URL + ) + val etatServiceResponse = response.body() + val headerTimestamp = etatServiceResponse?.header?.timestamp ?: now + etatServiceResponse?.alerts?.forEach { alert -> + if (!alert.isActive()) { + MTLog.d(this@StmInfoServiceUpdateProvider, "Ignore inactive alert. ($alert)") + return@forEach + } + val informedEntities = alert.informedEntities?.takeIf { it.isNotEmpty() } + ?: run { + MTLog.w(this@StmInfoServiceUpdateProvider, "Ignore alert w/o informed entities! ($alert)") + return@forEach + } + val routeShortNames = informedEntities.mapNotNull { it.routeShortName }.takeIf { it.isNotEmpty() } + ?: run { + MTLog.w(this@StmInfoServiceUpdateProvider, "Ignore alert w/o route short names! ($alert)") + return@forEach + } + val directionId = informedEntities.singleOrNull { !it.directionId.isNullOrBlank() }?.directionId + val stopIds = informedEntities.mapNotNull { it.stopCode }.toSet() + + val targetUUIDs: Set = buildSet { + routeShortNames.forEach { routeShortName -> + if (stopIds.isEmpty()) { + (getAgencyRouteDirectionTagTargetUUID(routeShortName, directionId) + ?: getAgencyRouteTagTargetUUID(routeShortName)).let { + add(it) + } + } else { + stopIds.forEach { stopId -> + (getAgencyRouteDirectionStopTagTargetUUID(routeShortName, directionId, stopId) + ?: getAgencyRouteStopTagTargetUUID(routeShortName, stopId)).let { + add(it) + } + } + } + } + } + val headerTexts = alert.headerTexts?.parseTranslations() + val descriptionTexts = alert.descriptionTexts?.parseTranslations() + val languages = headerTexts?.keys.orEmpty() + descriptionTexts?.keys.orEmpty() + if (languages.isEmpty()) { + MTLog.w(this@StmInfoServiceUpdateProvider, "Ignore alert w/o translations! ($alert)") + return@forEach + } + targetUUIDs.forEach { targetUUID -> + val severity = if (stopIds.isNotEmpty()) { + ServiceUpdate.SEVERITY_WARNING_POI + } else { + ServiceUpdate.SEVERITY_INFO_RELATED_POI + } // else ServiceUpdate.SEVERITY_INFO_UNKNOWN? + languages.forEach { language -> + val header = headerTexts?.get(language) + val description = descriptionTexts?.get(language) + ?: return@forEach // no description == no service update to show + val replacement = ServiceUpdateCleaner.getReplacement(severity) + val descriptionHtml = description.let { + var textHtml = it + textHtml = HtmlUtils.toHTML(textHtml) + textHtml = HtmlUtils.fixTextViewBR(textHtml) + textHtml = ServiceUpdateCleaner.clean(textHtml, replacement, language) + textHtml + } + serviceUpdates.add( + makeServiceUpdate( + targetUUID = targetUUID, + lastUpdate = headerTimestamp, + maxValidity = serviceUpdateMaxValidity, + text = ServiceUpdateCleaner.makeText(header, description), + optTextHTML = ServiceUpdateCleaner.makeTextHTML(header, descriptionHtml), + severity = severity, + sourceId = AGENCY_SOURCE_ID, + sourceLabel = sourceLabel, + language = language + ) + ) + } + } + } + return serviceUpdates + } + + else -> { + MTLog.w( + this@StmInfoServiceUpdateProvider, + "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) + StmInfoServiceUpdateStorage.saveServiceUpdateLastUpdateCode(context, 567) // SSL certificate not trusted (on this device) + StmInfoServiceUpdateStorage.saveServiceUpdateLastUpdate(context, TimeUtilsK.currentInstant()) + return null + } catch (uhe: UnknownHostException) { + if (MTLog.isLoggable(Log.DEBUG)) { + MTLog.w(this@StmInfoServiceUpdateProvider, uhe, "No Internet Connection!") + } else { + MTLog.w(this@StmInfoServiceUpdateProvider, "No Internet Connection!") + } + return null + } catch (se: SocketException) { + MTLog.w(this@StmInfoServiceUpdateProvider, se, "No Internet Connection!") + return null + } catch (e: Exception) { // Unknown error + MTLog.e(this@StmInfoServiceUpdateProvider, e, "INTERNAL ERROR: Unknown Exception") + return null + } + } + + private fun List.parseTranslations(): Map? { + this.takeIf { it.isNotEmpty() } ?: return null + var hasDefaultLanguage = false + val translations = this.mapNotNull { translatedText -> + val text = translatedText.text ?: return@mapNotNull null + val language = translatedText.language ?: DEFAULT_LANGUAGE + if (language == DEFAULT_LANGUAGE) hasDefaultLanguage = true + language to text + }.toMap() + if (!hasDefaultLanguage) { + this.firstOrNull()?.text?.let { + return translations + (DEFAULT_LANGUAGE to it) + } + } + return translations + } + + private fun EtatServiceResponse.Alert.isActive(now: Instant = TimeUtilsK.currentInstant()): Boolean { + activePeriods?.start?.let { start -> + if (now < start) return false // not yet + } + activePeriods?.end?.let { end -> + if (end < now) return false // too late + } + return true + } + + private const val AGENCY_TAG = "StmInfo" + + private fun getAgencyRouteTagTargetUUID(routeShortName: String) = + makeUUID(AGENCY_TAG, "rsn$routeShortName") + + private fun getAgencyRouteStopTagTargetUUID(routeShortName: String, stopCode: String) = + makeUUID(AGENCY_TAG, "rsn$routeShortName", "sc$stopCode") + + private fun getAgencyRouteDirectionTagTargetUUID(routeShortName: String, directionHeadsignValue: String?) = + directionHeadsignValue?.let { makeUUID(AGENCY_TAG, "rsn$routeShortName", "dhv$it") } + + private fun getAgencyRouteDirectionStopTagTargetUUID(routeShortName: String, directionHeadsignValue: String?, stopCode: String) = + directionHeadsignValue?.let { makeUUID(AGENCY_TAG, "rsn$routeShortName", "dhv$directionHeadsignValue", "sc$stopCode") } + + private fun RouteDirectionStop.getRouteTag() = this.route.shortName + private fun RouteDirectionStop.getDirectionTag() = this.direction.headsignValue + .takeIf { this.direction.headsignType == Direction.HEADSIGN_TYPE_DIRECTION } + + private fun RouteDirectionStop.getStopTag() = this.stop.code + + private fun RouteDirectionStop.getTargetUUIDs( + includeStopTags: Boolean = false + ): Map = buildMap { + put(getAgencyRouteTagTargetUUID(getRouteTag()), route.uuid) + getAgencyRouteDirectionTagTargetUUID(getRouteTag(), getDirectionTag())?.let { put(it, routeDirectionUUID) } + if (includeStopTags) { + put(getAgencyRouteStopTagTargetUUID(getRouteTag(), getStopTag()), uuid) + getAgencyRouteDirectionStopTagTargetUUID(getRouteTag(), getDirectionTag(), getStopTag())?.let { put(it, uuid) } + } + } + + private fun RouteDirection.getRouteTag() = this.route.shortName + private fun RouteDirection.getDirectionTag() = this.direction.headsignValue + + private fun RouteDirection.getTargetUUIDs( + ): Map = buildMap { + put(getAgencyRouteTagTargetUUID(getRouteTag()), route.uuid) + getAgencyRouteDirectionTagTargetUUID(getRouteTag(), getDirectionTag())?.let { put(it, uuid) } + } + + private fun Route.getRouteTag() = this.shortName + + private fun Route.getTargetUUIDs( + ): Map = buildMap { + put(getAgencyRouteTagTargetUUID(getRouteTag()), uuid) + } +} + diff --git a/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateStorage.kt b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateStorage.kt new file mode 100644 index 00000000..a5697757 --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateStorage.kt @@ -0,0 +1,48 @@ +package org.mtransit.android.commons.provider.ca.info.stm + +import android.content.Context +import androidx.annotation.WorkerThread +import org.mtransit.android.commons.PreferenceUtils +import org.mtransit.android.commons.millisToInstant +import org.mtransit.android.commons.provider.StmInfoApiProvider +import org.mtransit.android.commons.toMillis +import kotlin.time.Instant + +object StmInfoServiceUpdateStorage { + + // region service update + + /** + * Override if multiple [StmInfoApiProvider] implementations in same app. + */ + private const val PREF_KEY_SERVICE_UPDATE_LAST_UPDATE_MS = "pCaInfoStmServiceUpdatesLastUpdate" + + @JvmStatic + @WorkerThread + fun getServiceUpdateLastUpdate(context: Context, default: Instant) = + PreferenceUtils.getPrefLcl(context, PREF_KEY_SERVICE_UPDATE_LAST_UPDATE_MS, default.toMillis()).millisToInstant() + + @JvmStatic + @WorkerThread + fun saveServiceUpdateLastUpdate(context: Context, lastUpdate: Instant) { + PreferenceUtils.savePrefLclSync(context, PREF_KEY_SERVICE_UPDATE_LAST_UPDATE_MS, lastUpdate.toMillis()) + } + + /** + * Override if multiple [StmInfoApiProvider] implementations in same app. + */ + private const val PREF_KEY_SERVICE_UPDATE_LAST_UPDATE_CODE = "pCaInfoStmServiceUpdateLastUpdateCode" + + @JvmStatic + @WorkerThread + fun getServiceUpdateLastUpdateCode(context: Context, default: Int) = + PreferenceUtils.getPrefLcl(context, PREF_KEY_SERVICE_UPDATE_LAST_UPDATE_CODE, default) + + @JvmStatic + @WorkerThread + fun saveServiceUpdateLastUpdateCode(context: Context, code: Int) { + PreferenceUtils.savePrefLclSync(context, PREF_KEY_SERVICE_UPDATE_LAST_UPDATE_CODE, code) + } + + // endregion +} \ No newline at end of file diff --git a/src/main/java/org/mtransit/android/commons/provider/news/NewsProviderContract.java b/src/main/java/org/mtransit/android/commons/provider/news/NewsProviderContract.java index 866ca37c..b46c1c66 100644 --- a/src/main/java/org/mtransit/android/commons/provider/news/NewsProviderContract.java +++ b/src/main/java/org/mtransit/android/commons/provider/news/NewsProviderContract.java @@ -207,7 +207,7 @@ public static ArrayList makeTargets(@NonNull POI poi) { final ArrayList targets = new ArrayList<>(); targets.add(poi.getAuthority()); if (poi instanceof RouteDirectionStop) { - targets.add(POI.POIUtils.getUUID(poi.getAuthority(), ((RouteDirectionStop) poi).getRoute().getId())); + targets.add(POI.POIUtils.makeUUID(poi.getAuthority(), ((RouteDirectionStop) poi).getRoute().getId())); } return targets; } diff --git a/src/main/java/org/mtransit/android/commons/provider/serviceupdate/ServiceUpdateProviderContractExt.kt b/src/main/java/org/mtransit/android/commons/provider/serviceupdate/ServiceUpdateProviderContractExt.kt new file mode 100644 index 00000000..489c7ab1 --- /dev/null +++ b/src/main/java/org/mtransit/android/commons/provider/serviceupdate/ServiceUpdateProviderContractExt.kt @@ -0,0 +1,8 @@ +package org.mtransit.android.commons.provider.serviceupdate + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +val ServiceUpdateProviderContract.serviceUpdateMaxValidity: Duration get() = this.serviceUpdateMaxValidityInMs.milliseconds + +fun ServiceUpdateProviderContract.getServiceUpdateValidity(inFocus: Boolean): Duration = this.getServiceUpdateValidityInMs(inFocus).milliseconds diff --git a/src/main/res/values/stm_info_api_values.xml b/src/main/res/values/stm_info_api_values.xml index 752476e8..5b968359 100644 --- a/src/main/res/values/stm_info_api_values.xml +++ b/src/main/res/values/stm_info_api_values.xml @@ -5,4 +5,9 @@ @string/poi_agency_authority @string/poi_agency_authority + false + false + + + \ No newline at end of file From 29efb87d318c95e2654c52bdcec7ce8d03155c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20M=C3=A9a?= Date: Wed, 18 Mar 2026 11:22:22 -0400 Subject: [PATCH 2/5] remove deprecated --- src/main/java/org/mtransit/android/commons/data/POI.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/java/org/mtransit/android/commons/data/POI.java b/src/main/java/org/mtransit/android/commons/data/POI.java index 26033d51..c5f0bf0e 100644 --- a/src/main/java/org/mtransit/android/commons/data/POI.java +++ b/src/main/java/org/mtransit/android/commons/data/POI.java @@ -122,12 +122,6 @@ public String getLogTag() { public static final String UID_SEPARATOR = "-"; - @Deprecated - @NonNull - public static String getUUID(@NonNull String authority, @NonNull Object... poiUIDs) { - return makeUUID(authority, poiUIDs); - } - @NonNull public static String makeUUID(@NonNull String authority, @NonNull Object... poiUIDs) { StringBuilder sb = new StringBuilder(authority); From a1c0709b64024de30562e2a174639d22ca5df426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20M=C3=A9a?= Date: Wed, 18 Mar 2026 11:23:44 -0400 Subject: [PATCH 3/5] Update src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../android/commons/provider/ca/info/stm/EtatServiceResponse.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt index 4ad36e97..6f000ab2 100644 --- a/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt +++ b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt @@ -12,7 +12,7 @@ data class EtatServiceResponse( ) { data class Header( @SerializedName("timestamp") - val timestampInSec: Int?, // in sec + val timestampInSec: Long?, // in sec ) data class Alert( From 5c0961b5a66694fac524e2a3d3f95fecee5c0a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20M=C3=A9a?= Date: Wed, 18 Mar 2026 11:24:02 -0400 Subject: [PATCH 4/5] Update src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../commons/provider/ca/info/stm/EtatServiceResponse.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt index 6f000ab2..8c596ebc 100644 --- a/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt +++ b/src/main/java/org/mtransit/android/commons/provider/ca/info/stm/EtatServiceResponse.kt @@ -31,9 +31,9 @@ data class EtatServiceResponse( ) { data class ActivePeriods( @SerializedName("start") - val startInSec: Int?, + val startInSec: Long?, @SerializedName("end") - val endInSec: Int?, + val endInSec: Long?, ) data class InformedEntity( @SerializedName("route_short_name") From d7d3e044e93b678a2986b2e3e07333e2e0c6f5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20M=C3=A9a?= Date: Wed, 18 Mar 2026 11:27:50 -0400 Subject: [PATCH 5/5] cleanup --- .../java/org/mtransit/android/commons/TimeUtilsK.kt | 3 --- src/main/res/values/stm_info_api_values.xml | 2 +- .../android/commons/data/ScheduleExtTests.kt | 12 ++++++------ 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt b/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt index 370bf2cc..0b3c44c2 100644 --- a/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt +++ b/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt @@ -32,9 +32,6 @@ fun Long.millisToInstant() = Instant.fromEpochMilliseconds(this) fun Long.secsToInstant() = Instant.fromEpochSeconds(this) -@Suppress("unused") -fun Int.secsToInstant() = this.toLong().secsToInstant() - fun Instant.toMillis() = this.toEpochMilliseconds() fun Instant.toSecs() = this.epochSeconds diff --git a/src/main/res/values/stm_info_api_values.xml b/src/main/res/values/stm_info_api_values.xml index 5b968359..d5f8c150 100644 --- a/src/main/res/values/stm_info_api_values.xml +++ b/src/main/res/values/stm_info_api_values.xml @@ -10,4 +10,4 @@ - \ No newline at end of file + diff --git a/src/test/java/org/mtransit/android/commons/data/ScheduleExtTests.kt b/src/test/java/org/mtransit/android/commons/data/ScheduleExtTests.kt index eb4fd4a1..49cd2c60 100644 --- a/src/test/java/org/mtransit/android/commons/data/ScheduleExtTests.kt +++ b/src/test/java/org/mtransit/android/commons/data/ScheduleExtTests.kt @@ -12,12 +12,12 @@ class ScheduleExtTests { 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 DEPARTURE_SEC = 1772722800L // 2026-03-06 10:00: } @Test fun test_departure_update() { - val departure = DEPARTURE_MS.secsToInstant() + val departure = DEPARTURE_SEC.secsToInstant() val arrival = departure - 10.minutes val timestamp = departure.toScheduleTimestamp(LOCAL_TZ_ID, arrival) @@ -37,7 +37,7 @@ class ScheduleExtTests { @Test fun test_departure_update_early() { - val departure = DEPARTURE_MS.secsToInstant() + val departure = DEPARTURE_SEC.secsToInstant() val arrival = departure - 10.minutes val timestamp = departure.toScheduleTimestamp(LOCAL_TZ_ID, arrival) @@ -59,7 +59,7 @@ class ScheduleExtTests { @Test fun test_departure_update_no_effect_on_arrival() { - val departure = DEPARTURE_MS.secsToInstant() + val departure = DEPARTURE_SEC.secsToInstant() val arrival = departure - 1.minutes val timestamp = departure.toScheduleTimestamp(LOCAL_TZ_ID, arrival) @@ -77,7 +77,7 @@ class ScheduleExtTests { @Test fun test_updateForRealTime_w_arrival() { - val departure = DEPARTURE_MS.secsToInstant() + val departure = DEPARTURE_SEC.secsToInstant() departure.toScheduleTimestamp(LOCAL_TZ_ID, arrival = departure).apply { updateForRealTime(delay = (-61).seconds, currentPrecision = 1.minutes, delayPrecision = 10.seconds) }.let { result -> @@ -145,7 +145,7 @@ class ScheduleExtTests { @Test fun test_updateDepartureForRealTime() { - val departure = DEPARTURE_MS.secsToInstant() + val departure = DEPARTURE_SEC.secsToInstant() departure.toScheduleTimestamp(LOCAL_TZ_ID).apply { updateDepartureForRealTime(departureDelay = (-61).seconds, currentPrecision = 1.minutes, delayPrecision = 10.seconds) }.let { result ->