diff --git a/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt b/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt index 0b3c44c2..27bf2ab1 100644 --- a/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt +++ b/src/main/java/org/mtransit/android/commons/TimeUtilsK.kt @@ -21,8 +21,10 @@ object TimeUtilsK { if (negative) insert(0, "-") }.trim() + @JvmStatic fun currentInstant() = TimeUtils.currentTimeMillis().millisToInstant() + @JvmStatic val EPOCH_TIME_0: Instant = 0L.millisToInstant() } diff --git a/src/main/java/org/mtransit/android/commons/data/ServiceUpdate.java b/src/main/java/org/mtransit/android/commons/data/ServiceUpdate.java index 26dd8080..c226f881 100644 --- a/src/main/java/org/mtransit/android/commons/data/ServiceUpdate.java +++ b/src/main/java/org/mtransit/android/commons/data/ServiceUpdate.java @@ -15,6 +15,7 @@ import org.mtransit.android.commons.provider.serviceupdate.ServiceUpdateProviderContract; import java.util.Comparator; +import java.util.Objects; public class ServiceUpdate implements MTLog.Loggable { @@ -228,6 +229,31 @@ public String toString() { return sb.toString(); } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ServiceUpdate that = (ServiceUpdate) o; + return lastUpdateInMs == that.lastUpdateInMs && + maxValidityInMs == that.maxValidityInMs && + severity == that.severity && + Objects.equals(id, that.id) && + Objects.equals(targetUUID, that.targetUUID) && + Objects.equals(targetTripId, that.targetTripId) && + Objects.equals(text, that.text) && + Objects.equals(textHTML, that.textHTML) && + Objects.equals(noService, that.noService) && + Objects.equals(language, that.language) && + Objects.equals(sourceLabel, that.sourceLabel) && + Objects.equals(sourceId, that.sourceId) && + Objects.equals(originalId, that.originalId); + } + + @Override + public int hashCode() { + return Objects.hash(id, targetUUID, targetTripId, lastUpdateInMs, maxValidityInMs, text, textHTML, severity, noService, language, sourceLabel, sourceId, originalId); + } + public boolean isUseful() { return this.lastUpdateInMs + this.maxValidityInMs >= TimeUtils.currentTimeMillis(); } 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 3d4f9b6e..ad839de3 100644 --- a/src/main/java/org/mtransit/android/commons/provider/StmInfoApiProvider.java +++ b/src/main/java/org/mtransit/android/commons/provider/StmInfoApiProvider.java @@ -684,7 +684,7 @@ private static String getApiDirection(@NonNull Direction direction) { return StringUtils.EMPTY; } - private static final String SERVICE_UPDATE_SOURCE_ID = "api_stm_info_arrivals_messages"; + public static final String SERVICE_UPDATE_SOURCE_ID = "api_stm_info_arrivals_messages"; private static final String APPLICATION_JSON = "application/JSON"; private static final String ACCEPT = "accept"; 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 index df716026..0526de71 100644 --- 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 @@ -2,6 +2,7 @@ package org.mtransit.android.commons.provider.ca.info.stm import android.content.Context import android.util.Log +import androidx.annotation.VisibleForTesting import org.mtransit.android.commons.Constants import org.mtransit.android.commons.HtmlUtils import org.mtransit.android.commons.LocaleUtils @@ -32,6 +33,7 @@ import java.net.SocketException import java.net.UnknownHostException import java.util.Locale import javax.net.ssl.SSLHandshakeException +import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @@ -45,12 +47,17 @@ object StmInfoServiceUpdateProvider : MTLog.Loggable { @JvmStatic val SERVICE_UPDATE_MAX_VALIDITY_IN_MS = 1.days.inWholeMilliseconds + // .takeUnless { Constants.DEBUG } ?: 1.minutes.inWholeMilliseconds val SERVICE_UPDATE_VALIDITY_IN_MS = 1.hours.inWholeMilliseconds + // .takeUnless { Constants.DEBUG } ?: 1.minutes.inWholeMilliseconds val SERVICE_UPDATE_VALIDITY_IN_FOCUS_IN_MS = 10.minutes.inWholeMilliseconds + // .takeUnless { Constants.DEBUG } ?: 1.minutes.inWholeMilliseconds val SERVICE_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_MS = 10.minutes.inWholeMilliseconds + // .takeUnless { Constants.DEBUG } ?: 1.minutes.inWholeMilliseconds val SERVICE_UPDATE_MIN_DURATION_BETWEEN_REFRESH_IN_FOCUS_IN_MS = 1.minutes.inWholeMilliseconds + // .takeUnless { Constants.DEBUG } ?: 1.minutes.inWholeMilliseconds @JvmStatic fun getValidityInMs(inFocus: Boolean) = @@ -60,8 +67,6 @@ object StmInfoServiceUpdateProvider : MTLog.Loggable { 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) @@ -129,7 +134,7 @@ object StmInfoServiceUpdateProvider : MTLog.Loggable { if (!deleteAllDone) { deleteAllAgencyServiceUpdateData() } - cacheServiceUpdates(newServiceUpdates) + cacheServiceUpdates(newServiceUpdates.toList()) } // else keep whatever we have until max validity reached } @@ -154,7 +159,7 @@ object StmInfoServiceUpdateProvider : MTLog.Loggable { private const val SERVICE_UPDATE_URL = "https://api.stm.info/pub/od/i3/v2/messages/etatservice" - private fun StmInfoApiProvider.loadAgencyDataFromWWW(context: Context): List? { + private fun StmInfoApiProvider.loadAgencyDataFromWWW(context: Context): Collection? { try { val call = getSERVICE_UPDATES_URL_CACHED(context).takeIf { it.isNotBlank() }?.let { urlCachedString -> getStmInfoApi(context).getV2MessageEtatService(urlCachedString) @@ -176,88 +181,15 @@ object StmInfoServiceUpdateProvider : MTLog.Loggable { 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 - ) - ) - } - } - } + val serviceUpdates = etatServiceResponse.toServiceUpdates( + maxValidity = serviceUpdateMaxValidity, + sourceLabel = sourceLabel, + now = now, + ) MTLog.i(this@StmInfoServiceUpdateProvider, "Found %d service updates.", serviceUpdates.size) if (Constants.DEBUG) { for (serviceUpdate in serviceUpdates) { @@ -299,6 +231,94 @@ object StmInfoServiceUpdateProvider : MTLog.Loggable { } } + @VisibleForTesting + internal fun EtatServiceResponse?.toServiceUpdates( + maxValidity: Duration, + sourceLabel: String, + now: Instant, + ): Collection { + val serviceUpdates = mutableSetOf() + val alerts = this?.alerts?.takeIf { it.isNotEmpty() } ?: return serviceUpdates + val headerTimestamp = this.header?.timestamp ?: now + 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 = maxValidity, + text = ServiceUpdateCleaner.makeText(header, description), + optTextHTML = ServiceUpdateCleaner.makeTextHTML(header, descriptionHtml), + severity = severity, + sourceId = StmInfoApiProvider.SERVICE_UPDATE_SOURCE_ID, + sourceLabel = sourceLabel, + language = language + ) + ) + } + } + } + return serviceUpdates + } + private fun List.parseTranslations(): Map? { this.takeIf { it.isNotEmpty() } ?: return null var hasDefaultLanguage = false diff --git a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSProviderDbHelper.java b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSProviderDbHelper.java index 3f4c089a..fc5554e2 100644 --- a/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSProviderDbHelper.java +++ b/src/main/java/org/mtransit/android/commons/provider/gtfs/GTFSProviderDbHelper.java @@ -10,6 +10,7 @@ import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; +import org.mtransit.android.commons.Constants; import org.mtransit.android.commons.MTLog; import org.mtransit.android.commons.NotificationUtils; import org.mtransit.android.commons.PackageManagerUtils; @@ -217,38 +218,47 @@ private void initAllDbTables(@NonNull SQLiteDatabase db, boolean upgrade) { return kotlin.Unit.INSTANCE; } ); // 1st + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_STRINGS, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); } if (notifEnabled) NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, ++progress); initDbTableWithRetry(context, db, T_ROUTE, T_ROUTE_SQL_CREATE, T_ROUTE_SQL_INSERT, T_ROUTE_SQL_DROP, getRouteFiles(), 0, 0, allStrings, T_ROUTE_STRINGS_COLUMN_IDX); + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_ROUTE, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); if (notifEnabled) NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, ++progress); initDbTableWithRetry(context, db, T_DIRECTION, T_DIRECTION_SQL_CREATE, T_DIRECTION_SQL_INSERT, T_DIRECTION_SQL_DROP, getDirectionFiles(), 0, 0, allStrings, T_DIRECTION_STRINGS_COLUMN_IDX); + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_DIRECTION, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); if (notifEnabled) NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, ++progress); initDbTableWithRetry(context, db, T_STOP, T_STOP_SQL_CREATE, T_STOP_SQL_INSERT, T_STOP_SQL_DROP, getStopFiles(), 0, 0, allStrings, T_STOP_STRINGS_COLUMN_IDX); + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_STOP, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); if (notifEnabled) NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, ++progress); initDbTableWithRetry(context, db, T_DIRECTION_STOPS, T_DIRECTION_STOPS_SQL_CREATE, T_DIRECTION_STOPS_SQL_INSERT, T_DIRECTION_STOPS_SQL_DROP, getDirectionStopsFiles()); + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_DIRECTION_STOPS, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); if (FeatureFlags.F_EXPORT_TRIP_ID) { if (notifEnabled) NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, ++progress); initDbTableWithRetry(context, db, T_TRIP, T_TRIP_SQL_CREATE, T_TRIP_SQL_INSERT, T_TRIP_SQL_DROP, getTripFiles(), T_TRIP_SAME_COLUMNS_COUNT, T_TRIP_OTHER_COLUMNS_COUNT); + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_TRIP, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); } if (FeatureFlags.F_EXPORT_SERVICE_ID_INTS) { if (notifEnabled) NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, ++progress); initDbTableWithRetry(context, db, T_SERVICE_IDS, T_SERVICE_IDS_SQL_CREATE, T_SERVICE_IDS_SQL_INSERT, T_SERVICE_IDS_SQL_DROP, getServiceIdsFiles()); + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_SERVICE_IDS, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); } if (notifEnabled) NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, ++progress); initDbTableWithRetry(context, db, T_SERVICE_DATES, T_SERVICE_DATES_SQL_CREATE, T_SERVICE_DATES_SQL_INSERT, T_SERVICE_DATES_SQL_DROP, getServiceDatesFiles(), T_SERVICE_DATES_SAME_COLUMNS_COUNT, T_SERVICE_DATES_OTHER_COLUMNS_COUNT); + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_SERVICE_DATES, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); if (FeatureFlags.F_EXPORT_TRIP_ID_INTS) { if (notifEnabled) NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, ++progress); initDbTableWithRetry(context, db, T_TRIP_IDS, T_TRIP_IDS_SQL_CREATE, T_TRIP_IDS_SQL_INSERT, T_TRIP_IDS_SQL_DROP, getTripIdsFiles()); + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_TRIP_IDS, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); } if (notifEnabled) NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, ++progress); db.execSQL(T_ROUTE_DIRECTION_STOP_STATUS_SQL_CREATE); + if (Constants.DEBUG) MTLog.d(this, "Data: deploying DB... %s done (%s)", T_ROUTE_DIRECTION_STOP_STATUS, MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); if (notifEnabled) { nb.setSmallIcon(android.R.drawable.stat_notify_sync_noanim); NotificationUtils.setProgressAndNotify(nm, nb, nId, nbTotalOperations, nbTotalOperations); nm.cancel(nId); } - final long durationInMs = TimeUtils.currentTimeMillis() - startInMs; - MTLog.i(this, "Data: deploying DB... DONE (%s)", MTLog.formatDuration(durationInMs)); + MTLog.i(this, "Data: deploying DB... DONE (%s)", MTLog.formatDuration(TimeUtils.currentTimeMillis() - startInMs)); } /** 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 7a68406a..7dd14684 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 @@ -248,12 +248,12 @@ object GtfsRealtimeExt { append(if (short) "STU:" else "StopTimeUpdate:") append( buildList { - optStopSequence?.let { add("stopSeq=$stopSequence") } - optStopId?.let { add("stopId=$stopId") } - optArrival?.let { add("arrival=" + it.toStringExt(short = true)) } - optDeparture?.let { add("departure=" + it.toStringExt(short = true)) } - optDepartureOccupancyStatus?.let { add("depOcc=$departureOccupancyStatus") } - optScheduleRelationship?.let { add("schedRel=$scheduleRelationship") } + optStopSequence?.let { add(if (short) "seq=$stopSequence" else "stopSeq=$stopSequence") } + optStopId?.let { add(if (short) "id=$stopId" else "stopId=$stopId") } + optArrival?.let { add((if (short) "a=" else "arrival=") + it.toStringExt(short = true)) } + optDeparture?.let { add((if (short) "d=" else "departure=") + it.toStringExt(short = true)) } + optDepartureOccupancyStatus?.let { add(if (short) "oc=$departureOccupancyStatus" else "depOcc=$departureOccupancyStatus") } + optScheduleRelationship?.let { add(if (short) "sR=$scheduleRelationship" else "schedRel=$scheduleRelationship") } optStopTimeProperties?.let { add(it.toStringExt(short = true)) } }.joinToStringList() ) @@ -273,10 +273,10 @@ object GtfsRealtimeExt { append(if (short) "STE:" else "StopTimeEvent:") append( buildList { - optDelay?.let { add("delay=$delay") } - optTime?.let { add("time=$time") } - optUncertainty?.let { add("uncertainty=$uncertainty") } - optScheduledTime?.let { add("schedTime=$scheduledTime") } + optDelay?.let { add(if (short) "d=$delay" else "delay=$delay") } + optTime?.let { add(if (short) "t=$time" else "time=$time") } + optUncertainty?.let { add(if (short) "u=$uncertainty" else "uncertainty=$uncertainty") } + optScheduledTime?.let { add(if (short) "sT=$scheduledTime" else "schedTime=$scheduledTime") } }.joinToStringList() ) } 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 49cd2c60..35cd5d90 100644 --- a/src/test/java/org/mtransit/android/commons/data/ScheduleExtTests.kt +++ b/src/test/java/org/mtransit/android/commons/data/ScheduleExtTests.kt @@ -12,7 +12,7 @@ class ScheduleExtTests { companion object { private const val LOCAL_TZ_ID: String = "America/Montreal" - private const val DEPARTURE_SEC = 1772722800L // 2026-03-06 10:00: + private const val DEPARTURE_SEC = 1772722800L // 2026-03-06 10:00 } @Test diff --git a/src/test/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateProviderTest.kt b/src/test/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateProviderTest.kt new file mode 100644 index 00000000..50a5bb5b --- /dev/null +++ b/src/test/java/org/mtransit/android/commons/provider/ca/info/stm/StmInfoServiceUpdateProviderTest.kt @@ -0,0 +1,145 @@ +package org.mtransit.android.commons.provider.ca.info.stm + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mtransit.android.commons.provider.ca.info.stm.StmInfoServiceUpdateProvider.toServiceUpdates +import org.mtransit.android.commons.secsToInstant +import kotlin.time.Duration.Companion.days + +class StmInfoServiceUpdateProviderTest { + + companion object { + private val NOW = 1772722800L.secsToInstant() // 2026-03-06 10:00 + private val MAX_VALIDITY = 1.days + private const val SOURCE_LABEL = "stm.info" + } + + @Test + fun testToServiceUpdates_deduplicatesDuplicateAlerts() { + // Two identical alerts for route "34" -> should produce 2 service updates (one per language) + val alert = makeAlert(routeShortName = "34") + val response = EtatServiceResponse( + header = null, + alerts = listOf(alert, alert), + ) + + val result = response.toServiceUpdates( + maxValidity = MAX_VALIDITY, + sourceLabel = SOURCE_LABEL, + now = NOW, + ) + + // 2 languages (fr, en) x 1 unique targetUUID -> 2 distinct service updates (not 4) + assertEquals(2, result.size) + } + + @Test + fun testToServiceUpdates_distinctLanguagesPerAlert() { + // Alert has both "fr" and "en" in both header and description (potential duplicates in languages list) + val alert = EtatServiceResponse.Alert( + activePeriods = null, + cause = null, + effect = null, + informedEntities = listOf( + EtatServiceResponse.Alert.InformedEntity(routeShortName = "14", directionId = null, stopCode = null), + ), + headerTexts = listOf( + EtatServiceResponse.Alert.TranslatedText(language = "fr", text = "Titre"), + EtatServiceResponse.Alert.TranslatedText(language = "en", text = "Title"), + ), + descriptionTexts = listOf( + // Same languages as headerTexts - should not produce duplicate entries + EtatServiceResponse.Alert.TranslatedText(language = "fr", text = "Desc FR"), + EtatServiceResponse.Alert.TranslatedText(language = "en", text = "Desc EN"), + ), + ) + val response = EtatServiceResponse(header = null, alerts = listOf(alert)) + + val result = response.toServiceUpdates( + maxValidity = MAX_VALIDITY, + sourceLabel = SOURCE_LABEL, + now = NOW, + ) + + // Should produce exactly 2: one for "fr", one for "en" + assertEquals(2, result.size) + assertEquals(result.size, result.distinctBy { it.targetUUID to it.language }.size) + } + + @Test + fun testToServiceUpdates_nullResponse_returnsEmpty() { + val result = null.toServiceUpdates( + maxValidity = MAX_VALIDITY, + sourceLabel = SOURCE_LABEL, + now = NOW, + ) + + assertEquals(0, result.size) + } + + @Test + fun testToServiceUpdates_multipleDistinctAlerts() { + // Alerts for two different routes -> should produce 4 service updates (2 per route, one per language) + val alert34 = makeAlert(routeShortName = "34") + val alert35 = makeAlert(routeShortName = "35") + val response = EtatServiceResponse(header = null, alerts = listOf(alert34, alert35)) + + val result = response.toServiceUpdates( + maxValidity = MAX_VALIDITY, + sourceLabel = SOURCE_LABEL, + now = NOW, + ) + + assertEquals(4, result.size) + assertEquals(result.size, result.distinctBy { it.targetUUID to it.language }.size) + } + + private fun makeAlert( + routeShortName: String, + directionId: String? = null, + stopCode: String? = null, + startInSec: Long? = null, + endInSec: Long? = null, + headerFr: String = "Titre", + headerEn: String = "Title", + descFr: String = "Description FR", + descEn: String = "Description EN", + ) = EtatServiceResponse.Alert( + activePeriods = EtatServiceResponse.Alert.ActivePeriods( + startInSec = startInSec, + endInSec = endInSec, + ), + cause = null, + effect = null, + // The STM API sends each attribute (route, direction, stop) as a separate InformedEntity object + informedEntities = listOfNotNull( + EtatServiceResponse.Alert.InformedEntity( + routeShortName = routeShortName, + directionId = null, + stopCode = null, + ), + directionId?.let { + EtatServiceResponse.Alert.InformedEntity( + routeShortName = null, + directionId = it, + stopCode = null, + ) + }, + stopCode?.let { + EtatServiceResponse.Alert.InformedEntity( + routeShortName = null, + directionId = null, + stopCode = it, + ) + }, + ), + headerTexts = listOf( + EtatServiceResponse.Alert.TranslatedText(language = "fr", text = headerFr), + EtatServiceResponse.Alert.TranslatedText(language = "en", text = headerEn), + ), + descriptionTexts = listOf( + EtatServiceResponse.Alert.TranslatedText(language = "fr", text = descFr), + EtatServiceResponse.Alert.TranslatedText(language = "en", text = descEn), + ), + ) +}