From 9340072f935c3282d77655ae8635bcd087a3042d Mon Sep 17 00:00:00 2001 From: Shedowe Date: Sat, 10 Oct 2026 19:37:54 +0200 Subject: [PATCH] Add anonymous DBF IRIS live data for rail journeys --- .../traewelling/app/data/dbf/DbfJsonParser.kt | 94 +++++ .../de/traewelling/app/data/dbf/DbfMatcher.kt | 98 +++++ .../de/traewelling/app/data/dbf/DbfModels.kt | 53 +++ .../app/data/dbf/DbfRealtimeRepository.kt | 343 ++++++++++++++++++ .../de/traewelling/app/data/model/Models.kt | 7 +- .../app/data/model/StopRealtimeInfo.kt | 40 ++ .../data/repository/TraewellingRepository.kt | 24 +- .../app/service/JourneyTimeResolver.kt | 8 +- .../app/service/TripChangeMonitor.kt | 48 ++- .../app/service/TripTrackingService.kt | 12 +- .../app/ui/screens/SettingsScreen.kt | 24 ++ .../app/ui/screens/StatusDetailScreen.kt | 158 ++++---- .../app/util/PreferencesManager.kt | 8 + .../app/viewmodel/RealtimePresentation.kt | 108 ++++++ .../app/viewmodel/SettingsViewModel.kt | 8 +- .../app/viewmodel/StatusDetailViewModel.kt | 64 +++- .../app/data/dbf/DbfJsonParserTest.kt | 71 ++++ .../app/data/dbf/DbfMatcherTest.kt | 142 ++++++++ .../app/data/dbf/DbfRealtimeRepositoryTest.kt | 334 +++++++++++++++++ .../app/data/model/StopRealtimeInfoTest.kt | 46 +++ .../repository/PublicRealtimeSessionTest.kt | 187 ++++++++++ .../app/service/JourneyTimeResolverTest.kt | 13 + .../app/service/TripChangeMonitorTest.kt | 77 ++++ .../app/viewmodel/RealtimePresentationTest.kt | 111 ++++++ app/src/test/resources/dbf/README.md | 17 + .../resources/dbf/public-hannover-v3.json | 143 ++++++++ docs/wiki/api/externe-schnittstellen.md | 6 +- docs/wiki/api/live-datenquellen.md | 93 +++++ docs/wiki/index.md | 1 + .../wiki/konfiguration/preferences-manager.md | 3 + docs/wiki/module/status-detail.md | 8 +- docs/wiki/module/trip-changes.md | 2 + docs/wiki/module/trip-tracking.md | 3 + docs/wiki/offene-fragen.md | 10 + 34 files changed, 2258 insertions(+), 106 deletions(-) create mode 100644 app/src/main/kotlin/de/traewelling/app/data/dbf/DbfJsonParser.kt create mode 100644 app/src/main/kotlin/de/traewelling/app/data/dbf/DbfMatcher.kt create mode 100644 app/src/main/kotlin/de/traewelling/app/data/dbf/DbfModels.kt create mode 100644 app/src/main/kotlin/de/traewelling/app/data/dbf/DbfRealtimeRepository.kt create mode 100644 app/src/main/kotlin/de/traewelling/app/data/model/StopRealtimeInfo.kt create mode 100644 app/src/main/kotlin/de/traewelling/app/viewmodel/RealtimePresentation.kt create mode 100644 app/src/test/kotlin/de/traewelling/app/data/dbf/DbfJsonParserTest.kt create mode 100644 app/src/test/kotlin/de/traewelling/app/data/dbf/DbfMatcherTest.kt create mode 100644 app/src/test/kotlin/de/traewelling/app/data/dbf/DbfRealtimeRepositoryTest.kt create mode 100644 app/src/test/kotlin/de/traewelling/app/data/model/StopRealtimeInfoTest.kt create mode 100644 app/src/test/kotlin/de/traewelling/app/data/repository/PublicRealtimeSessionTest.kt create mode 100644 app/src/test/kotlin/de/traewelling/app/viewmodel/RealtimePresentationTest.kt create mode 100644 app/src/test/resources/dbf/README.md create mode 100644 app/src/test/resources/dbf/public-hannover-v3.json create mode 100644 docs/wiki/api/live-datenquellen.md diff --git a/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfJsonParser.kt b/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfJsonParser.kt new file mode 100644 index 0000000..c167b9d --- /dev/null +++ b/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfJsonParser.kt @@ -0,0 +1,94 @@ +package de.traewelling.app.data.dbf + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.Strictness +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import java.io.StringReader +import java.time.Instant +import java.time.LocalTime + +/** The documented IRIS JSON v3 branch of derf/db-fakedisplay's Stationboard renderer. */ +object DbfJsonParser { + private const val MAX_JSON_CHARS = 2 * 1024 * 1024 + private const val MAX_DEPARTURES = 5_000 + private val timePattern = Regex("(?:[01]\\d|2[0-3]):[0-5]\\d") + private val integerPattern = Regex("-?(?:0|[1-9]\\d*)") + + /** Throw on a failed or unexpected response instead of treating it as an empty board. */ + fun parse(json: String, eva: String, fetchedAt: Instant = Instant.now()): DbfBoard { + require(normalizedEva(eva) != null) { "Invalid DBF station EVA" } + require(json.length <= MAX_JSON_CHARS) { "DBF response is too large" } + val reader = JsonReader(StringReader(json)).apply { strictness = Strictness.STRICT } + val root = reader.use { + val parsed = JsonParser.parseReader(it) + require(it.peek() == JsonToken.END_DOCUMENT) { "Trailing data in DBF response" } + parsed + } + require(root.isJsonObject) { "Expected a DBF station board" } + val objectRoot = root.asJsonObject + require(!objectRoot.has("error")) { "DBF returned an error" } + val rows = objectRoot.get("departures") + require(rows != null && rows.isJsonArray) { "DBF departures are missing" } + require(rows.asJsonArray.size() <= MAX_DEPARTURES) { "Too many DBF departures" } + val departures = rows.asJsonArray.map { row -> + require(row.isJsonObject) { "Invalid DBF departure" } + val obj = row.asJsonObject + // HAFAS and other backends also expose JSON, but use epoch fields instead. + require(obj.has("scheduledArrival") && obj.has("scheduledDeparture") && + obj.has("delayArrival") && obj.has("delayDeparture")) { "Expected DBF IRIS JSON v3" } + DbfDeparture( + trainNumber = obj.string("trainNumber"), + scheduledArrival = obj.time("scheduledArrival"), + scheduledDeparture = obj.time("scheduledDeparture"), + delayArrival = obj.integer("delayArrival"), + delayDeparture = obj.integer("delayDeparture"), + platform = obj.string("platform"), + scheduledPlatform = obj.string("scheduledPlatform"), + isCancelled = obj.booleanOrFlag("isCancelled"), + missingRealtime = obj.booleanOrFlag("missingRealtime"), + messages = obj.messages() + ) + } + return DbfBoard(eva.trim(), departures.toList(), fetchedAt) + } + + internal fun normalizedEva(value: String?): String? = value?.trim() + ?.takeIf { it.length == 7 && it.all(Char::isDigit) && it.toLongOrNull() != 0L } + + private fun JsonObject.string(key: String): String? = get(key)?.takeUnless(JsonElement::isJsonNull) + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString } + ?.asString?.trim()?.takeIf(String::isNotEmpty) + + private fun JsonObject.time(key: String): LocalTime? = string(key)?.takeIf(timePattern::matches) + ?.let { runCatching { LocalTime.parse(it) }.getOrNull() } + + private fun JsonObject.integer(key: String): Int? { + val value = get(key)?.takeUnless(JsonElement::isJsonNull) ?: return null + if (!value.isJsonPrimitive || !value.asJsonPrimitive.isNumber) return null + return value.asString.takeIf(integerPattern::matches)?.toIntOrNull() + } + + private fun JsonObject.booleanOrFlag(key: String): Boolean? { + val value = get(key)?.takeUnless(JsonElement::isJsonNull) ?: return null + if (!value.isJsonPrimitive) return null + val primitive = value.asJsonPrimitive + if (primitive.isBoolean) return primitive.asBoolean + if (primitive.isNumber) return when (primitive.asString) { "0" -> false; "1" -> true; else -> null } + return null + } + + private fun JsonObject.messages(): List { + val groups = get("messages")?.takeIf(JsonElement::isJsonObject)?.asJsonObject ?: return emptyList() + return listOf("delay", "qos").flatMap { group -> + groups.get(group)?.takeIf(JsonElement::isJsonArray)?.asJsonArray?.mapNotNull { raw -> + val item = raw.takeIf(JsonElement::isJsonObject)?.asJsonObject ?: return@mapNotNull null + val text = item.string("text") ?: return@mapNotNull null + // This is a message creation time; it must not be attached to predictions. + DbfMessage(text, item.string("timestamp")) + } ?: emptyList() + } + } +} diff --git a/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfMatcher.kt b/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfMatcher.kt new file mode 100644 index 0000000..3506e9e --- /dev/null +++ b/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfMatcher.kt @@ -0,0 +1,98 @@ +package de.traewelling.app.data.dbf + +import java.time.Duration +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId + +/** Match only the current board window, operational train number and planned event. */ +object DbfMatcher { + private val zone = ZoneId.of("Europe/Berlin") + // The repository requests past=1. Restrict matching to a nearby actual event + // window without guessing a date for the source's date-less HH:mm fields. + private val lookBehind = Duration.ofMinutes(60) + private val lookAhead = Duration.ofMinutes(180) + + fun match( + board: DbfBoard, + eva: String, + journeyNumber: String?, + arrivalPlanned: Instant?, + departurePlanned: Instant? + ): DbfStopDelta? { + val station = DbfJsonParser.normalizedEva(eva) ?: return null + if (station != board.eva) return null + val number = trainNumber(journeyNumber) ?: return null + val rows = board.departures.filter { trainNumber(it.trainNumber) == number } + val arrivals = rows.filter { + matchesTime(it.scheduledArrival, arrivalPlanned, board.fetchedAt, it.delayArrival, it.missingRealtime) + } + val departures = rows.filter { + matchesTime(it.scheduledDeparture, departurePlanned, board.fetchedAt, it.delayDeparture, it.missingRealtime) + } + if (arrivals.size > 1 || departures.size > 1) return null + val arrivalRow = arrivals.singleOrNull() + val departureRow = departures.singleOrNull() + if (arrivalRow == null && departureRow == null) return null + // Equality is not identity: equal duplicates must have been rejected above as well. + if (arrivalRow != null && departureRow != null && arrivalRow !== departureRow) return null + val row = arrivalRow ?: departureRow ?: return null + val allRequestedEventsMatched = (arrivalPlanned == null || arrivalRow != null) && + (departurePlanned == null || departureRow != null) + val cancelled: DbfField = when { + !allRequestedEventsMatched -> DbfField.Absent + row.isCancelled == true -> DbfField.Present(true) + // False also includes partial cancellation; v3 cannot certify restoration. + else -> DbfField.Absent + } + return DbfStopDelta( + arrival = arrivalRow?.let { + eventDelta(it, arrivalPlanned!!, it.delayArrival, it.scheduledDeparture == null) + }, + departure = departureRow?.let { eventDelta(it, departurePlanned!!, it.delayDeparture, true) }, + cancelled = cancelled, + fetchedAt = board.fetchedAt + ) + } + + private fun matchesTime( + time: LocalTime?, planned: Instant?, fetchedAt: Instant, delay: Int?, missingRealtime: Boolean? + ): Boolean { + if (time == null || planned == null) return false + // A substantially delayed train can have a planned time outside this window, + // but a confirmed nonzero delay brings its actual event into the current board. + // Bound the delay to avoid matching an occurrence from another service day. + val relevant = if (missingRealtime == false && delay != null && delay != 0 && delay in -180..720) { + planned.plusSeconds(delay.toLong() * 60) + } else planned + if (relevant < fetchedAt.minus(lookBehind) || relevant > fetchedAt.plus(lookAhead)) return false + val local = planned.atZone(zone).toLocalDateTime() + // DBF HH:mm cannot distinguish either occurrence of the repeated autumn hour. + if (zone.rules.getValidOffsets(local).size != 1) return false + return local.toLocalTime().withSecond(0).withNano(0) == time + } + + private fun eventDelta(row: DbfDeparture, planned: Instant, delay: Int?, usePlatform: Boolean): DbfEventDelta { + if (row.missingRealtime != false || row.isCancelled == true) return DbfEventDelta() + // DBF also calculates 0 from a planned-time fallback. Never erase a known delay + // merely because its v3 representation cannot prove realtime punctuality. + val actual = delay?.takeIf { it != 0 && it in -180..720 } + ?.let { planned.plusSeconds(it.toLong() * 60) } + val changedPlatform = if (usePlatform) changedPlatform(row) else null + return DbfEventDelta( + realTime = actual?.let { DbfField.Present(it) } ?: DbfField.Absent, + // v3 collapses the event platforms into one value, preferring departure. + platform = changedPlatform?.let { DbfField.Present(it) } ?: DbfField.Absent, + scheduledPlatform = if (changedPlatform != null) DbfField.Present(row.scheduledPlatform) + else DbfField.Absent + ) + } + + private fun changedPlatform(row: DbfDeparture): String? = row.platform?.takeIf { + row.scheduledPlatform != null && it != row.scheduledPlatform + } + + private fun trainNumber(value: String?): Long? = value?.trim() + ?.takeIf { it.isNotEmpty() && it.all(Char::isDigit) } + ?.toLongOrNull()?.takeIf { it > 0 } +} diff --git a/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfModels.kt b/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfModels.kt new file mode 100644 index 0000000..f28f955 --- /dev/null +++ b/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfModels.kt @@ -0,0 +1,53 @@ +package de.traewelling.app.data.dbf + +import java.time.Instant +import java.time.LocalTime + +/** A missing or null source field must never manufacture a punctual prediction. */ +sealed interface DbfField { + data object Absent : DbfField + data class Present(val value: T?) : DbfField +} + +data class DbfMessage(val text: String, val timestamp: String?) + +data class DbfDeparture( + val trainNumber: String?, + val scheduledArrival: LocalTime?, + val scheduledDeparture: LocalTime?, + val delayArrival: Int?, + val delayDeparture: Int?, + val platform: String?, + val scheduledPlatform: String?, + val isCancelled: Boolean?, + val missingRealtime: Boolean?, + val messages: List = emptyList() +) + +/** + * DBF v3 supplies neither station/date identifiers nor a prediction update timestamp. + * eva is the exact requested station, with related stations disabled in the request. + * fetchedAt records retrieval only, and is not the provider's update time. + */ +data class DbfBoard( + val eva: String, + val departures: List, + val fetchedAt: Instant, + val providerUpdatedAt: Instant? = null +) + +data class DbfEventDelta( + val realTime: DbfField = DbfField.Absent, + val platform: DbfField = DbfField.Absent, + // Apply together with platform so the UI compares the same provider's pair. + val scheduledPlatform: DbfField = DbfField.Absent, + val providerUpdatedAt: Instant? = null +) + +data class DbfStopDelta( + val arrival: DbfEventDelta?, + val departure: DbfEventDelta?, + val cancelled: DbfField, + val fetchedAt: Instant, + val providerUpdatedAt: Instant? = null +) diff --git a/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfRealtimeRepository.kt b/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfRealtimeRepository.kt new file mode 100644 index 0000000..a7bf3ab --- /dev/null +++ b/app/src/main/kotlin/de/traewelling/app/data/dbf/DbfRealtimeRepository.kt @@ -0,0 +1,343 @@ +package de.traewelling.app.data.dbf + +import android.content.Context +import de.traewelling.app.data.model.CheckinInfo +import de.traewelling.app.data.model.StopRealtimeInfo +import de.traewelling.app.data.model.StopStation +import de.traewelling.app.util.PreferencesManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okio.Buffer +import java.io.Closeable +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.math.abs + +/** Anonymous, optional DBF/IRIS station-board data. Träwelling owns trip and stopover identity. */ +class DbfRealtimeRepository internal constructor( + private val enabledProvider: suspend () -> Boolean, + private val store: DbfBoardStore = sharedStore, + private val nowMillis: () -> Long = System::currentTimeMillis +) { + constructor(context: Context) : this(PreferencesManager(context.applicationContext)::getPublicRailRealtimeEnabled) + + suspend fun enrich(stops: List, checkin: CheckinInfo): List { + if (!isRail(checkin) || (checkin.journeyNumber ?: 0) <= 0 || stops.isEmpty() || !isEnabled()) return stops + val now = nowMillis() + val candidates = stops.withIndex().mapNotNull { (index, stop) -> + val eva = stop.station?.ibnr?.toString()?.takeIf(EVA::matches) ?: return@mapNotNull null + val planned = listOfNotNull(parseInstant(stop.arrivalPlanned), parseInstant(stop.departurePlanned)) + if (planned.isEmpty()) return@mapNotNull null + // A delayed current train may have no usable primary realtime. Its bounded + // planned marker must still permit fetching the independently matched board. + val markers = (planned + listOfNotNull(parseInstant(stop.arrivalReal), parseInstant(stop.departureReal))).distinct() + val event = markers.filter { it.toEpochMilli() in (now - PAST_WINDOW_MILLIS)..(now + FUTURE_WINDOW_MILLIS) } + .minByOrNull { abs(it.toEpochMilli() - now) } ?: return@mapNotNull null + Candidate(index, eva, event.toEpochMilli(), stop.matchesStopover(checkin.destination)) + } + // Current/next visit first, then the exit if its event is inside the board horizon. + val sorted = candidates.sortedBy { abs(it.eventMillis - now) } + val selectedEvas = buildList { + sorted.firstOrNull()?.let { add(it.eva) } + sorted.firstOrNull { it.destination && it.eva !in this }?.let { add(it.eva) } + sorted.firstOrNull { it.eva !in this }?.let { if (size < MAX_STATIONS) add(it.eva) } + }.take(MAX_STATIONS) + val selected = candidates.filter { it.eva in selectedEvas }.take(MAX_VISITS) + if (selected.isEmpty()) return stops + val result = stops.toMutableList() + // Parallel boards allow two fresh stations without serially blocking the primary snapshot. + // A timed-out caller cannot cancel a shared board that another screen/service awaits. + withTimeoutOrNull(ENRICHMENT_TIMEOUT_MILLIS) { + coroutineScope { + selected.groupBy { it.eva }.map { (eva, visits) -> async { + val board = store.get(eva) ?: return@async + val fetchedAt = board.fetchedAt.toEpochMilli() + if (fetchedAt > nowMillis() || nowMillis() - fetchedAt > LIVE_TTL_MILLIS) return@async + for (candidate in visits) { + currentCoroutineContext().ensureActive() + val stop = stops[candidate.index] + val delta = DbfMatcher.match(board, eva, checkin.journeyNumber?.toString(), + parseInstant(stop.arrivalPlanned), parseInstant(stop.departurePlanned)) ?: continue + result[candidate.index] = applyDelta(stop, delta) + } + } }.awaitAll() + } + } + currentCoroutineContext().ensureActive() + // Disabling optional public requests also discards a still-running result. + return if (isEnabled()) result else stops + } + + private suspend fun isEnabled(): Boolean = try { + enabledProvider() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } + + private data class Candidate(val index: Int, val eva: String, val eventMillis: Long, val destination: Boolean) + + companion object { + const val LIVE_TTL_MILLIS = 60_000L + internal const val FAILURE_TTL_MILLIS = 60_000L + internal const val ENRICHMENT_TIMEOUT_MILLIS = 8_000L + internal const val MAX_STATIONS = 2 + private const val MAX_VISITS = 6 + private const val PAST_WINDOW_MILLIS = 60 * 60_000L + private const val FUTURE_WINDOW_MILLIS = 2 * 60 * 60_000L + private val EVA = Regex("[0-9]{7}") + private val RAIL_CATEGORIES = setOf("nationalexpress", "national", "regionalexp", "regional", "suburban") + private val sharedStore: DbfBoardStore by lazy { DbfBoardStore(DbfBoardHttp()::fetch) } + + private fun isRail(checkin: CheckinInfo): Boolean { + val mode = checkin.mode?.trim()?.lowercase(Locale.ROOT) + val category = checkin.category?.trim()?.lowercase(Locale.ROOT) + return mode != "bus" && category in RAIL_CATEGORIES + } + + private fun parseInstant(value: String?): Instant? = value?.let { + runCatching { Instant.parse(it).also { instant -> instant.toEpochMilli() } }.getOrNull() + } + + /** Each field retains its own read age; a platform-only update cannot renew an old time. */ + internal fun applyDelta(stop: StopStation, delta: DbfStopDelta): StopStation { + fun DbfField?.valueOrNull(): T? = (this as? DbfField.Present)?.value + val arrivalTime = delta.arrival?.realTime.valueOrNull() + val departureTime = delta.departure?.realTime.valueOrNull() + fun platformPair(event: DbfEventDelta?): Pair? { + val planned = event?.scheduledPlatform.valueOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: return null + val actual = event?.platform.valueOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: return null + return planned to actual + } + val arrivalPlatforms = platformPair(delta.arrival) + val departurePlatforms = platformPair(delta.departure) + val cancelled = delta.cancelled.valueOrNull() + if (arrivalTime == null && departureTime == null && arrivalPlatforms == null && departurePlatforms == null && + cancelled == null) return stop + val info = StopRealtimeInfo(delta.fetchedAt.toEpochMilli(), "DBF · IRIS", delta.providerUpdatedAt?.toEpochMilli()) + return stop.copy( + arrivalReal = arrivalTime?.toString() ?: stop.arrivalReal, + departureReal = departureTime?.toString() ?: stop.departureReal, + arrivalPlatformPlanned = arrivalPlatforms?.first ?: stop.arrivalPlatformPlanned, + arrivalPlatformReal = arrivalPlatforms?.second ?: stop.arrivalPlatformReal, + departurePlatformPlanned = departurePlatforms?.first ?: stop.departurePlatformPlanned, + departurePlatformReal = departurePlatforms?.second ?: stop.departurePlatformReal, + cancelled = cancelled ?: stop.cancelled, + arrivalRealtimeInfo = if (arrivalTime != null) info else stop.arrivalRealtimeInfo, + departureRealtimeInfo = if (departureTime != null) info else stop.departureRealtimeInfo, + arrivalPlatformRealtimeInfo = if (arrivalPlatforms != null) info else stop.arrivalPlatformRealtimeInfo, + departurePlatformRealtimeInfo = if (departurePlatforms != null) info else stop.departurePlatformRealtimeInfo, + cancellationRealtimeInfo = if (cancelled != null) info else stop.cancellationRealtimeInfo + ) + } + } +} + +internal data class DbfFetchResult(val board: DbfBoard?, val retryAfterMillis: Long = 0L) + +/** Process-wide singleflight and exact provider quotas, including failed requests. */ +internal class DbfBoardStore( + private val fetch: suspend (String) -> DbfFetchResult, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000L }, + private val cacheLimit: Int = 96, + private val inFlightLimit: Int = 4 +) : Closeable { + private data class Entry(val board: DbfBoard?, val expiresAtMillis: Long) + private val lock = Any() + private var closed = false + private val entries = LinkedHashMap(16, 0.75f, true) + private val pending = mutableMapOf>() + private val starts = ArrayDeque() + private val stationStarts = mutableMapOf() + private var pauseUntil = 0L + + init { require(cacheLimit > 0 && inFlightLimit > 0) } + + suspend fun get(eva: String): DbfBoard? { + if (!Regex("[0-9]{7}").matches(eva)) return null + val task = synchronized(lock) { + if (closed) return null + val now = nowMillis() + entries[eva]?.let { if (now < it.expiresAtMillis) return it.board else entries.remove(eva) } + pending[eva] ?: run { + if (pending.size >= inFlightLimit || !reserveStart(eva, now)) return null + scope.async(start = CoroutineStart.LAZY) { + var finished = false + var fetched = DbfFetchResult(null) + try { + fetched = fetch(eva) + // The requested EVA scopes the anonymous board; do not cache a mismatched fixture/source. + if (fetched.board?.eva?.let { it != eva } == true) fetched = DbfFetchResult(null) + finished = true + fetched.board + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + finished = true + null + } finally { + synchronized(lock) { + pending.remove(eva) + if (!closed && finished) { + val completed = nowMillis() + if (fetched.retryAfterMillis > 0) pauseUntil = maxOf(pauseUntil, + expiry(completed, fetched.retryAfterMillis.coerceAtLeast(60_000L))) + val ttl = if (fetched.board == null) maxOf(DbfRealtimeRepository.FAILURE_TTL_MILLIS, + fetched.retryAfterMillis) else DbfRealtimeRepository.LIVE_TTL_MILLIS + entries[eva] = Entry(fetched.board, expiry(completed, ttl)) + while (entries.size > cacheLimit) entries.remove(entries.keys.first()) + } + } + } + }.also { pending[eva] = it } + } + } + task.start() + return task.await() + } + + /** Never wait for a quota slot: optional data falls back promptly to the primary provider. */ + private fun reserveStart(eva: String, now: Long): Boolean { + while (starts.firstOrNull()?.let { now - it >= WINDOW_MILLIS } == true) starts.removeFirst() + stationStarts.entries.removeAll { now - it.value >= WINDOW_MILLIS } + if (now < pauseUntil || starts.size >= MAX_STARTS || eva in stationStarts) return false + starts.addLast(now) + stationStarts[eva] = now + return true + } + + override fun close() { + synchronized(lock) { closed = true; entries.clear(); pending.clear(); starts.clear(); stationStarts.clear() } + scope.cancel() + } + + companion object { + internal const val WINDOW_MILLIS = 60_000L + internal const val MAX_STARTS = 10 + private fun expiry(now: Long, duration: Long): Long = + if (now >= 0 && duration > Long.MAX_VALUE - now) Long.MAX_VALUE else now + duration + } +} + +/** No account interceptors, keys, cookies, redirects, retries, or Träwelling bearer credentials. */ +internal class DbfBoardHttp( + val client: OkHttpClient = OkHttpClient.Builder() + .callTimeout(6, TimeUnit.SECONDS) + .connectTimeout(3, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .build(), + private val nowMillis: () -> Long = System::currentTimeMillis +) { + internal fun request(eva: String): Request { + require(Regex("[0-9]{7}").matches(eva)) + val url = BASE_URL.toHttpUrl().newBuilder().addPathSegment("$eva.json") + .addQueryParameter("version", "3") + // Without this flag DBF combines nearby stations, and v3 rows contain no station ID. + .addQueryParameter("no_related", "1") + .addQueryParameter("past", "1").build() + return Request.Builder().url(url) + .header("Accept", "application/json") + .header("User-Agent", "Routely/1.0 (+https://github.com/shedowe19/routely)") + .build() + } + + suspend fun fetch(eva: String): DbfFetchResult = suspendCancellableCoroutine { continuation -> + val request = request(eva) + val call = client.newCall(request) + continuation.invokeOnCancellation { call.cancel() } + call.enqueue(object : Callback { + override fun onFailure(call: Call, exception: IOException) { + if (continuation.isActive) continuation.resume(DbfFetchResult(null)) + } + override fun onResponse(call: Call, response: Response) { + val result = decodeResponse(response, request, eva) + if (continuation.isActive) continuation.resume(result) + } + }) + } + + internal fun decodeResponse(response: Response, request: Request, eva: String): DbfFetchResult = runCatching { + response.use { + if (it.request.url != request.url) return@use DbfFetchResult(null) + if (!it.isSuccessful) return@use DbfFetchResult(null, + if (it.code in setOf(429, 503)) retryAfter(it.header("Retry-After")) ?: 60_000L else 0L) + // v3 contains HH:mm but no service date. A clearly old HTTP response must not + // be rebound to today's matching train, and HTTP Date is not prediction provenance. + if (staleHttpMetadata(it)) return@use DbfFetchResult(null) + val body = it.body ?: return@use DbfFetchResult(null) + if (body.contentLength() > MAX_BODY_BYTES) return@use DbfFetchResult(null) + val buffer = Buffer() + while (buffer.size <= MAX_BODY_BYTES) { + val count = body.source().read(buffer, minOf(8_192L, MAX_BODY_BYTES + 1L - buffer.size)) + if (count == -1L) break + } + if (buffer.size > MAX_BODY_BYTES) return@use DbfFetchResult(null) + val json = Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(buffer.readByteArray())).toString() + DbfFetchResult(DbfJsonParser.parse(json, eva, Instant.ofEpochMilli(nowMillis()))) + } + }.getOrElse { DbfFetchResult(null) } + + private fun staleHttpMetadata(response: Response): Boolean { + response.header("Age")?.let { value -> + val age = value.trim().toLongOrNull() ?: return true + if (age !in 0L..MAX_HTTP_AGE_SECONDS) return true + } + response.header("Date")?.let { value -> + val date = runCatching { + ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli() + }.getOrNull() ?: return true + val age = nowMillis() - date + if (age > MAX_HTTP_AGE_SECONDS * 1_000L || age < -60_000L) return true + } + return false + } + + private fun retryAfter(value: String?): Long? { + value ?: return null + val seconds = value.trim().toLongOrNull()?.takeIf { it >= 0 } + val millis = if (seconds != null) { + if (seconds > Long.MAX_VALUE / 1_000L) Long.MAX_VALUE else seconds * 1_000L + } else runCatching { + ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli() - nowMillis() + }.getOrNull() ?: return null + return millis.coerceAtLeast(60_000L) + } + + companion object { + internal const val MAX_BODY_BYTES = 2 * 1024 * 1024 + internal const val BASE_URL = "https://dbf.finalrewind.org/" + private const val MAX_HTTP_AGE_SECONDS = 180L + } +} diff --git a/app/src/main/kotlin/de/traewelling/app/data/model/Models.kt b/app/src/main/kotlin/de/traewelling/app/data/model/Models.kt index 34d5364..f1eddfb 100644 --- a/app/src/main/kotlin/de/traewelling/app/data/model/Models.kt +++ b/app/src/main/kotlin/de/traewelling/app/data/model/Models.kt @@ -166,7 +166,12 @@ data class StopStation( @SerializedName("departurePlatformReal") val departurePlatformReal: String? = null, val cancelled: Boolean? = null, @SerializedName("isArrivalDelayed") val isArrivalDelayed: Boolean? = null, - @SerializedName("isDepartureDelayed") val isDepartureDelayed: Boolean? = null + @SerializedName("isDepartureDelayed") val isDepartureDelayed: Boolean? = null, + val arrivalRealtimeInfo: StopRealtimeInfo? = null, + val departureRealtimeInfo: StopRealtimeInfo? = null, + val cancellationRealtimeInfo: StopRealtimeInfo? = null, + val arrivalPlatformRealtimeInfo: StopRealtimeInfo? = null, + val departurePlatformRealtimeInfo: StopRealtimeInfo? = null ) { val stationId: Int? get() = station?.id val stationName: String? get() = station?.name ?: legacyName diff --git a/app/src/main/kotlin/de/traewelling/app/data/model/StopRealtimeInfo.kt b/app/src/main/kotlin/de/traewelling/app/data/model/StopRealtimeInfo.kt new file mode 100644 index 0000000..9ac2b9d --- /dev/null +++ b/app/src/main/kotlin/de/traewelling/app/data/model/StopRealtimeInfo.kt @@ -0,0 +1,40 @@ +package de.traewelling.app.data.model + +/** A successful client read is distinct from the provider's last data change. */ +data class StopRealtimeInfo( + val fetchedAtMillis: Long, + val sourceLabel: String, + val providerUpdatedAtMillis: Long? = null +) { + fun isFresh(nowMillis: Long, maxAgeMillis: Long = MAX_READ_AGE_MILLIS): Boolean = + fetchedAtMillis > 0 && maxAgeMillis >= 0 && nowMillis >= fetchedAtMillis && + nowMillis - fetchedAtMillis <= maxAgeMillis + + fun displayLabel(nowMillis: Long): String = when { + isFresh(nowMillis) -> sourceLabel + fetchedAtMillis <= 0 || nowMillis < fetchedAtMillis -> "$sourceLabel · Aktualität unbekannt" + else -> "$sourceLabel · älterer Abruf" + } + + companion object { + const val MAX_READ_AGE_MILLIS = 120_000L + } +} + +/** Never stamp a retained snapshot after a failed stopover request. */ +internal fun StopStation.withTraewellingReadInfo(fetchedAtMillis: Long): StopStation { + val info = StopRealtimeInfo(fetchedAtMillis, "Träwelling") + return copy(arrivalRealtimeInfo = info, departureRealtimeInfo = info, + cancellationRealtimeInfo = info, arrivalPlatformRealtimeInfo = info, + departurePlatformRealtimeInfo = info) +} + +/** Legacy caches have no evidence of when their live fields were obtained. */ +internal fun StopStation.withUnknownReadInfo(): StopStation { + val unknown = StopRealtimeInfo(0, "Träwelling") + return copy(arrivalRealtimeInfo = arrivalRealtimeInfo ?: unknown, + departureRealtimeInfo = departureRealtimeInfo ?: unknown, + cancellationRealtimeInfo = cancellationRealtimeInfo ?: unknown, + arrivalPlatformRealtimeInfo = arrivalPlatformRealtimeInfo ?: unknown, + departurePlatformRealtimeInfo = departurePlatformRealtimeInfo ?: unknown) +} diff --git a/app/src/main/kotlin/de/traewelling/app/data/repository/TraewellingRepository.kt b/app/src/main/kotlin/de/traewelling/app/data/repository/TraewellingRepository.kt index d8219f7..5ac660d 100644 --- a/app/src/main/kotlin/de/traewelling/app/data/repository/TraewellingRepository.kt +++ b/app/src/main/kotlin/de/traewelling/app/data/repository/TraewellingRepository.kt @@ -8,6 +8,7 @@ import de.traewelling.app.data.local.AppDatabase import de.traewelling.app.data.local.StatusDao import de.traewelling.app.data.local.StatusEntity import de.traewelling.app.data.model.* +import de.traewelling.app.data.dbf.DbfRealtimeRepository import de.traewelling.app.util.AuthSession import de.traewelling.app.util.PreferencesManager import kotlinx.coroutines.CancellationException @@ -27,7 +28,8 @@ class TraewellingRepository internal constructor( private val sessionProvider: suspend () -> AuthSession, private val apiFactory: (AuthSession) -> TraewellingApiService, private val mutations: StatusMutationStore, - private val invalidateTrackingState: suspend (Int, AuthSession) -> Unit = { _, _ -> } + private val invalidateTrackingState: suspend (Int, AuthSession) -> Unit = { _, _ -> }, + private val enrichRealtime: suspend (List, CheckinInfo) -> List = { stops, _ -> stops } ) { internal constructor(statusDao: StatusDao, sessionProvider: suspend () -> AuthSession, apiFactory: (AuthSession) -> TraewellingApiService) : @@ -39,7 +41,8 @@ class TraewellingRepository internal constructor( { session -> RetrofitClient.createApiService(session.serverUrl, session.accessToken?.takeIf { it.isNotBlank() } ?: error("Not authenticated")) }, StatusMutationEvents.store, - prefs::invalidateTrackingState + prefs::invalidateTrackingState, + DbfRealtimeRepository(context.applicationContext)::enrich ) private val gson = Gson() @@ -359,8 +362,21 @@ class TraewellingRepository internal constructor( } suspend fun getStopovers(tripId: Int): Result> = apiResult { - val r = api().getStopovers(tripId) - r.body()?.allStopovers()?.deduplicate() ?: error("Keine Halte gefunden (${r.code()})") + val session = authenticatedSession() + val r = apiFactory(session).getStopovers(tripId) + requireCurrentSession(session) + if (!r.isSuccessful) throw HttpException(r) + val stops = r.body()?.allStopovers()?.deduplicate() ?: error("Keine Halte gefunden (${r.code()})") + val fetchedAt = System.currentTimeMillis() + stops.map { it.withTraewellingReadInfo(fetchedAt) } + } + + /** Public station-board data is a local display layer; it never modifies the Träwelling check-in. */ + suspend fun enrichStopovers(stops: List, checkin: CheckinInfo): List { + val session = authenticatedSession() + val enriched = enrichRealtime(stops, checkin) + requireCurrentSession(session) + return enriched } // ─── Follow / Unfollow ──────────────────────────────────────────────────── diff --git a/app/src/main/kotlin/de/traewelling/app/service/JourneyTimeResolver.kt b/app/src/main/kotlin/de/traewelling/app/service/JourneyTimeResolver.kt index 6ee7d89..f7eea32 100644 --- a/app/src/main/kotlin/de/traewelling/app/service/JourneyTimeResolver.kt +++ b/app/src/main/kotlin/de/traewelling/app/service/JourneyTimeResolver.kt @@ -9,12 +9,13 @@ enum class JourneyTimeSource { GPS_OBSERVED, GPS_ESTIMATE, API_REALTIME, TIMETAB data class JourneyTime( val millis: Long, val source: JourneyTimeSource, - val plannedMillis: Long? + val plannedMillis: Long?, + val providerLabel: String? = null ) { val sourceLabel: String get() = when (source) { JourneyTimeSource.GPS_OBSERVED -> "GPS beobachtet" JourneyTimeSource.GPS_ESTIMATE -> "GPS-Schätzung" - JourneyTimeSource.API_REALTIME -> "API-Echtzeit" + JourneyTimeSource.API_REALTIME -> providerLabel ?: "API-Echtzeit" JourneyTimeSource.TIMETABLE -> "Fahrplan" JourneyTimeSource.MANUAL -> "Manuell" } @@ -81,7 +82,8 @@ object JourneyTimeResolver { } parseMillis(manualTime)?.let { return JourneyTime(it, JourneyTimeSource.MANUAL, planned) } parseMillis(if (arrival) stop.arrivalReal else stop.departureReal)?.let { - return JourneyTime(it, JourneyTimeSource.API_REALTIME, planned) + val readInfo = if (arrival) stop.arrivalRealtimeInfo else stop.departureRealtimeInfo + return JourneyTime(it, JourneyTimeSource.API_REALTIME, planned, readInfo?.displayLabel(nowMillis)) } return planned?.let { JourneyTime(it, JourneyTimeSource.TIMETABLE, planned) } } diff --git a/app/src/main/kotlin/de/traewelling/app/service/TripChangeMonitor.kt b/app/src/main/kotlin/de/traewelling/app/service/TripChangeMonitor.kt index a1abd7a..8486e76 100644 --- a/app/src/main/kotlin/de/traewelling/app/service/TripChangeMonitor.kt +++ b/app/src/main/kotlin/de/traewelling/app/service/TripChangeMonitor.kt @@ -35,7 +35,12 @@ internal data class TripChangeStop( val isOrigin: Boolean = false, val isDestination: Boolean = false, val arrivalPlatformIsLive: Boolean? = null, - val departurePlatformIsLive: Boolean? = null + val departurePlatformIsLive: Boolean? = null, + val arrivalTimeSourceLabel: String? = null, + val departureTimeSourceLabel: String? = null, + val arrivalPlatformSourceLabel: String? = null, + val departurePlatformSourceLabel: String? = null, + val cancellationSourceLabel: String? = null ) internal data class TripChangeSnapshot( @@ -86,7 +91,12 @@ internal data class TripChangeSnapshot( cleanPlatform(stop.departurePlatformReal) != null -> true cleanPlatform(stop.departurePlatformPlanned) != null -> false else -> null - } + }, + arrivalTimeSourceLabel = stop.arrivalRealtimeInfo?.sourceLabel, + departureTimeSourceLabel = stop.departureRealtimeInfo?.sourceLabel, + arrivalPlatformSourceLabel = stop.arrivalPlatformRealtimeInfo?.sourceLabel, + departurePlatformSourceLabel = stop.departurePlatformRealtimeInfo?.sourceLabel, + cancellationSourceLabel = stop.cancellationRealtimeInfo?.sourceLabel ) }, nextIndex = nextIndex, @@ -110,7 +120,8 @@ internal class TripChangeMonitor(private val delayThresholdMinutes: Int = 5) { private val delayReferences = mutableMapOf() private val platformLiveGaps = mutableSetOf() - private data class DelayObservation(val plannedMillis: Long, val minutes: Int, val arrival: Boolean) + private data class DelayObservation(val plannedMillis: Long, val minutes: Int, val arrival: Boolean, + val sourceLabel: String?) fun reset(statusId: Int, state: TripChangeMonitorState? = null) { this.statusId = statusId @@ -141,7 +152,11 @@ internal class TripChangeMonitor(private val delayThresholdMinutes: Int = 5) { val events = mutableListOf() for ((index, stop) in remaining.withIndex()) { val old = previousByKey[stop.key] ?: continue - if (old.cancelled != null && stop.cancelled != null && old.cancelled != stop.cancelled) { + // A confirmed new cancellation is actionable even after switching providers. A + // fallback provider's older "not cancelled" value does not prove reinstatement. + if (old.cancelled != null && stop.cancelled != null && old.cancelled != stop.cancelled && + (stop.cancelled || !differentKnownSources(old.cancellationSourceLabel, stop.cancellationSourceLabel)) + ) { val cancelled = stop.cancelled val subject = when { stop.isDestination -> "Deine Ausstiegshaltestelle ${stop.name}" @@ -161,10 +176,12 @@ internal class TripChangeMonitor(private val delayThresholdMinutes: Int = 5) { val newPlatform = platform(stop) val oldIsLive = platformIsLive(old) val newIsLive = platformIsLive(stop) - val liveValueLost = oldIsLive == true && newIsLive != true + val sourceChanged = differentKnownSources(platformSourceLabel(old), platformSourceLabel(stop)) + if (sourceChanged) platformLiveGaps.remove(stop.key) + val liveValueLost = !sourceChanged && oldIsLive == true && newIsLive != true if (liveValueLost) platformLiveGaps += stop.key - val liveValueRestored = newIsLive == true && platformLiveGaps.remove(stop.key) - if (!liveValueLost && !liveValueRestored && oldPlatform != null && newPlatform != null && + val liveValueRestored = !sourceChanged && newIsLive == true && platformLiveGaps.remove(stop.key) + if (!sourceChanged && !liveValueLost && !liveValueRestored && oldPlatform != null && newPlatform != null && !oldPlatform.equals(newPlatform, ignoreCase = true) ) { emit(events, stop, "platform", newPlatform.uppercase(Locale.ROOT), TripChangeKind.PLATFORM, @@ -185,7 +202,9 @@ internal class TripChangeMonitor(private val delayThresholdMinutes: Int = 5) { val manualChanged = (delayStop.isOrigin && !current.arrival && previous.manualDepartureMillis != snapshot.manualDepartureMillis) || (delayStop.isDestination && current.arrival && previous.manualArrivalMillis != snapshot.manualArrivalMillis) val reference = delayReferences[delayStop.key] - if (manualChanged || prior.plannedMillis != current.plannedMillis || prior.arrival != current.arrival || + if (manualChanged || differentKnownSources(prior.sourceLabel, current.sourceLabel) || + differentKnownSources(reference?.sourceLabel, current.sourceLabel) || + prior.plannedMillis != current.plannedMillis || prior.arrival != current.arrival || reference == null || reference.plannedMillis != current.plannedMillis || reference.arrival != current.arrival ) { delayReferences[delayStop.key] = current @@ -239,12 +258,23 @@ internal class TripChangeMonitor(private val delayThresholdMinutes: Int = 5) { private fun platformIsLive(stop: TripChangeStop): Boolean? = if (stop.isOrigin) stop.departurePlatformIsLive else stop.arrivalPlatformIsLive + private fun platformSourceLabel(stop: TripChangeStop): String? = + if (stop.isOrigin) stop.departurePlatformSourceLabel else stop.arrivalPlatformSourceLabel + + /** Unknown legacy provenance keeps the existing comparison behavior. */ + private fun differentKnownSources(previous: String?, current: String?): Boolean { + val old = previous?.trim()?.takeIf(String::isNotEmpty) ?: return false + val new = current?.trim()?.takeIf(String::isNotEmpty) ?: return false + return !old.equals(new, ignoreCase = true) + } + private fun delayObservation(stop: TripChangeStop): DelayObservation? { val arrival = !stop.isOrigin && stop.arrivalPlannedMillis != null && stop.arrivalRealMillis != null val planned = if (arrival) stop.arrivalPlannedMillis else stop.departurePlannedMillis val real = if (arrival) stop.arrivalRealMillis else stop.departureRealMillis if (planned == null || real == null) return null - return DelayObservation(planned, ((real - planned) / 60_000L).toInt(), arrival) + return DelayObservation(planned, ((real - planned) / 60_000L).toInt(), arrival, + if (arrival) stop.arrivalTimeSourceLabel else stop.departureTimeSourceLabel) } private fun emit( diff --git a/app/src/main/kotlin/de/traewelling/app/service/TripTrackingService.kt b/app/src/main/kotlin/de/traewelling/app/service/TripTrackingService.kt index e888924..0a3ee0d 100644 --- a/app/src/main/kotlin/de/traewelling/app/service/TripTrackingService.kt +++ b/app/src/main/kotlin/de/traewelling/app/service/TripTrackingService.kt @@ -42,6 +42,7 @@ import de.traewelling.app.data.model.Status import de.traewelling.app.data.model.GpsSegmentGeometry import de.traewelling.app.data.model.RoadRouteGeometry import de.traewelling.app.data.model.StopStation +import de.traewelling.app.data.model.withUnknownReadInfo import de.traewelling.app.data.model.SevMap import de.traewelling.app.data.model.SevStopInfo import de.traewelling.app.data.sev.SevJourneyEnricher @@ -748,9 +749,10 @@ class TripTrackingService : Service() { cache.progress.announcedKeys.none { it.isBlank() } }.getOrDefault(false) if (!valid || currentStatusId != statusId) return - cachedCheckin = cache.checkin - cachedStops = cache.stopovers - cachedFullStops = cache.fullStopovers ?: cache.stopovers + cachedCheckin = cache.checkin.copy(origin = cache.checkin.origin?.withUnknownReadInfo(), + destination = cache.checkin.destination?.withUnknownReadInfo()) + cachedStops = cache.stopovers.map { it.withUnknownReadInfo() } + cachedFullStops = (cache.fullStopovers ?: cache.stopovers).map { it.withUnknownReadInfo() } cachedSevMaps = cache.sevMaps ?: emptyMap() revalidateSevStops(System.currentTimeMillis()) // A restored SEV point can have expired or moved while the process was dead. @@ -773,7 +775,9 @@ class TripTrackingService : Service() { val checkin = status.checkin ?: return val tripId = checkin.trip ?: return if (!isCurrentTracking(statusId, expectedGeneration) || prefs.getAuthSession() != session) return - val stops = repo.getStopovers(tripId).getOrNull() ?: return + val stops = repo.getStopovers(tripId).getOrNull()?.let { + repo.enrichStopovers(it, checkin) + } ?: return trackingMutex.withLock { if (!isCurrentTracking(statusId, expectedGeneration) || prefs.getAuthSession() != session) return@withLock diff --git a/app/src/main/kotlin/de/traewelling/app/ui/screens/SettingsScreen.kt b/app/src/main/kotlin/de/traewelling/app/ui/screens/SettingsScreen.kt index 1520e79..636f30c 100644 --- a/app/src/main/kotlin/de/traewelling/app/ui/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/de/traewelling/app/ui/screens/SettingsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics @@ -67,6 +68,7 @@ fun SettingsScreen( onOpenLiveUpdateSettings: () -> Unit ) { val uiState by viewModel.uiState.collectAsState() + val uriHandler = LocalUriHandler.current Scaffold( topBar = { @@ -136,6 +138,28 @@ fun SettingsScreen( } } } + item { + Text("Bahn-Livedaten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(22.dp)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + SettingsToggle( + title = "Zusätzliche Bahn-Livedaten", + description = "Ergänzt deine Fahrt um aktuelle Gleise, Verspätungen und Ausfälle an DB-Bahnhöfen. Ohne Konto oder API-Key.", + checked = uiState.publicRailRealtimeEnabled, + onCheckedChange = viewModel::setPublicRailRealtimeEnabled + ) + Text( + "Verwendet die öffentliche DBF-Abfahrtstafel mit DB-IRIS-Daten. Dafür werden die Bahnhöfe deiner Fahrt an dbf.finalrewind.org gesendet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = { uriHandler.openUri("https://github.com/derf/db-fakedisplay") }) { + Text("Quelloffenes DBF-Projekt") + } + } + } + } item { Text( text = "Erscheinungsbild", diff --git a/app/src/main/kotlin/de/traewelling/app/ui/screens/StatusDetailScreen.kt b/app/src/main/kotlin/de/traewelling/app/ui/screens/StatusDetailScreen.kt index 561d7fe..d33d1c7 100644 --- a/app/src/main/kotlin/de/traewelling/app/ui/screens/StatusDetailScreen.kt +++ b/app/src/main/kotlin/de/traewelling/app/ui/screens/StatusDetailScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset @@ -36,6 +35,7 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import de.traewelling.app.data.model.GpsGeometrySource import de.traewelling.app.data.model.StopStation +import de.traewelling.app.data.model.StopRealtimeInfo import de.traewelling.app.data.model.SevStopInfo import de.traewelling.app.data.model.Status import de.traewelling.app.data.model.TravelReason @@ -46,12 +46,18 @@ import de.traewelling.app.service.JourneyTime import de.traewelling.app.service.JourneyTimeResolver import de.traewelling.app.service.JourneyTimeSource import de.traewelling.app.service.TrackingSource -import de.traewelling.app.service.trackingPlatform import de.traewelling.app.ui.components.StateMessage import de.traewelling.app.ui.components.TraewellingTopAppBar import de.traewelling.app.ui.theme.* import de.traewelling.app.viewmodel.StatusDetailViewModel import de.traewelling.app.viewmodel.StatusDetailUiState +import de.traewelling.app.viewmodel.StopEvent +import de.traewelling.app.viewmodel.eventRealtimeInfo +import de.traewelling.app.viewmodel.eventPlatformRealtimeInfo +import de.traewelling.app.viewmodel.journeyTimeSourceLabel +import de.traewelling.app.viewmodel.platformPresentation +import de.traewelling.app.viewmodel.realtimeRetrievalPresentation +import de.traewelling.app.viewmodel.stopoversRetrievalPresentation import kotlinx.coroutines.delay import java.time.Instant import java.time.ZoneId @@ -129,54 +135,6 @@ fun StatusDetailScreen( } }, actions = { - val isToday = remember(uiState.status) { - val createdAt = uiState.status?.createdAt - if (createdAt != null) { - try { - val zdt = ZonedDateTime.parse(createdAt) - val tripDate = zdt.toLocalDate() - val today = ZonedDateTime.now().toLocalDate() - tripDate == today - } catch (e: Exception) { false } - } else false - } - - if (uiState.lastUpdated > 0 && isToday) { - val pulseAnim = rememberInfiniteTransition(label = "live") - val pulseAlpha by pulseAnim.animateFloat( - initialValue = 1f, targetValue = 0.3f, - animationSpec = infiniteRepeatable( - animation = tween(800, easing = EaseInOutCubic), - repeatMode = RepeatMode.Reverse - ), label = "pulse" - ) - Surface( - color = Color(0xFF00E676).copy(alpha = 0.2f), - shape = RoundedCornerShape(12.dp), - modifier = Modifier.padding(end = 8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp) - ) { - Box( - Modifier - .size(8.dp) - .alpha(pulseAlpha) - .background(Color(0xFF00E676), CircleShape) - ) - Spacer(Modifier.width(6.dp)) - Text( - "LIVE", - style = MaterialTheme.typography.labelSmall, - color = Color(0xFF00E676), - fontWeight = FontWeight.Bold, - letterSpacing = 1.sp - ) - } - } - } - if (uiState.isOwnStatus) { if (uiState.isDeleting || uiState.isUpdating) { CircularProgressIndicator( @@ -331,6 +289,30 @@ private fun StatusDetailContent( } } + if (checkin != null) { + item { + val retrieval = stopoversRetrievalPresentation(stopovers, nowMillis, uiState.realtimeRefreshFailed) + val tint = if (retrieval.recentlyRetrieved) MaterialTheme.colorScheme.primary else WarningOrange + Surface( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + color = tint.copy(alpha = 0.08f), + shape = RoundedCornerShape(12.dp) + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(if (uiState.realtimeRefreshFailed) Icons.Default.CloudOff else Icons.Default.Schedule, + contentDescription = null, tint = tint, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Column { + Text(retrieval.label, style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, color = tint) + Text(retrieval.detail, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)) + } + } + } + } + } + // Trip info card if (checkin != null) { item { @@ -342,7 +324,7 @@ private fun StatusDetailContent( ) ) { TripInfoCard(status, gpsTimes, nowMillis, isReplacementBus, - sevStops.isNotEmpty(), uiState.isLoadingSevStops) + sevStops.isNotEmpty(), uiState.isLoadingSevStops, uiState.realtimeRefreshFailed) } } } @@ -480,6 +462,8 @@ private fun StatusDetailContent( stop = stop, sevInfo = sevStops[SevStopResolver.visitKey(stop)], isReplacementBus = isReplacementBus, + nowMillis = nowMillis, + realtimeRefreshFailed = uiState.realtimeRefreshFailed, arrivalTime = JourneyTimeResolver.arrival(stop, gpsTimes, nowMillis, if (isDestination) checkin?.manualArrival else null), departureTime = JourneyTimeResolver.departure(stop, gpsTimes, nowMillis, @@ -590,7 +574,8 @@ private fun TripInfoCard( nowMillis: Long, isReplacementBus: Boolean, hasSevStops: Boolean, - isLoadingSevStops: Boolean + isLoadingSevStops: Boolean, + realtimeRefreshFailed: Boolean ) { val checkin = status.checkin ?: return val transportColor = TransportColors.forCategory(checkin.category) @@ -716,10 +701,14 @@ private fun TripInfoCard( val origin = checkin.origin val dest = checkin.destination if (origin != null) { - TimeRow("Abfahrt", JourneyTimeResolver.departure(origin, gpsTimes, nowMillis, checkin.manualDeparture)) + TimeRow("Abfahrt", JourneyTimeResolver.departure(origin, gpsTimes, nowMillis, checkin.manualDeparture), + eventRealtimeInfo(origin, StopEvent.DEPARTURE), nowMillis, realtimeRefreshFailed, + origin.cancelled == true) } if (dest != null) { - TimeRow("Ankunft", JourneyTimeResolver.arrival(dest, gpsTimes, nowMillis, checkin.manualArrival)) + TimeRow("Ankunft", JourneyTimeResolver.arrival(dest, gpsTimes, nowMillis, checkin.manualArrival), + eventRealtimeInfo(dest, StopEvent.ARRIVAL), nowMillis, realtimeRefreshFailed, + dest.cancelled == true) } } } @@ -777,7 +766,8 @@ private fun StatPill(icon: androidx.compose.ui.graphics.vector.ImageVector, valu } @Composable -private fun TimeRow(label: String, time: JourneyTime?) { +private fun TimeRow(label: String, time: JourneyTime?, realtimeInfo: StopRealtimeInfo?, nowMillis: Long, + realtimeRefreshFailed: Boolean, cancelled: Boolean) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -785,13 +775,17 @@ private fun TimeRow(label: String, time: JourneyTime?) { ) { Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)) - JourneyTimeValue(time) + if (cancelled) Text("ENTFÄLLT", style = MaterialTheme.typography.labelLarge, + color = ErrorRed, fontWeight = FontWeight.SemiBold) + else JourneyTimeValue(time, realtimeInfo = realtimeInfo, nowMillis = nowMillis, + realtimeRefreshFailed = realtimeRefreshFailed) } } @Composable private fun JourneyTimeValue(time: JourneyTime?, prefix: String = "", showDelayBadge: Boolean = false, - textAlpha: Float = 1f) { + textAlpha: Float = 1f, realtimeInfo: StopRealtimeInfo? = null, + nowMillis: Long = 0L, realtimeRefreshFailed: Boolean = false) { val actual = formatJourneyTime(time?.millis) val planned = formatJourneyTime(time?.plannedMillis) val differs = actual != planned && planned != "–" @@ -801,7 +795,7 @@ private fun JourneyTimeValue(time: JourneyTime?, prefix: String = "", showDelayB delay > 0 -> WarningOrange else -> SuccessGreen } - Column(horizontalAlignment = Alignment.End) { + Column(horizontalAlignment = Alignment.End, modifier = Modifier.widthIn(max = 180.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { if (prefix.isNotBlank()) { Text(prefix, style = MaterialTheme.typography.bodySmall, @@ -827,7 +821,8 @@ private fun JourneyTimeValue(time: JourneyTime?, prefix: String = "", showDelayB fontWeight = if (differs) FontWeight.Bold else FontWeight.Medium) } time?.let { - Text(it.sourceLabel, style = MaterialTheme.typography.labelSmall, + Text(journeyTimeSourceLabel(it, realtimeInfo, nowMillis, realtimeRefreshFailed), + style = MaterialTheme.typography.labelSmall, color = if (it.source == JourneyTimeSource.GPS_ESTIMATE || it.source == JourneyTimeSource.GPS_OBSERVED) TealAccent else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)) } @@ -844,6 +839,8 @@ private fun StopoverItem( stop: StopStation, sevInfo: SevStopInfo?, isReplacementBus: Boolean, + nowMillis: Long, + realtimeRefreshFailed: Boolean, arrivalTime: JourneyTime?, departureTime: JourneyTime?, progress: StopTimelineProgress, @@ -896,6 +893,8 @@ private fun StopoverItem( } val isCancelled = stop.cancelled == true + val platformEvent = if (isOrigin) StopEvent.DEPARTURE else StopEvent.ARRIVAL + val platform = platformPresentation(stop, platformEvent) // Journeys range logic for lines val isTopTraveled = originIndex != -1 && destinationIndex != -1 && index > originIndex && index <= destinationIndex @@ -975,13 +974,20 @@ private fun StopoverItem( if (!isCancelled) { Column(horizontalAlignment = Alignment.End) { if (isOrigin || isDestination) { + val event = if (isOrigin) StopEvent.DEPARTURE else StopEvent.ARRIVAL JourneyTimeValue( - if (isOrigin) departureTime ?: arrivalTime else arrivalTime ?: departureTime, - showDelayBadge = true, textAlpha = textAlpha + if (isOrigin) departureTime else arrivalTime, + showDelayBadge = true, textAlpha = textAlpha, + realtimeInfo = eventRealtimeInfo(stop, event), nowMillis = nowMillis, + realtimeRefreshFailed = realtimeRefreshFailed ) } else { - arrivalTime?.let { JourneyTimeValue(it, prefix = "An: ", textAlpha = textAlpha) } - departureTime?.let { JourneyTimeValue(it, prefix = "Ab: ", textAlpha = textAlpha) } + arrivalTime?.let { JourneyTimeValue(it, prefix = "An: ", textAlpha = textAlpha, + realtimeInfo = eventRealtimeInfo(stop, StopEvent.ARRIVAL), nowMillis = nowMillis, + realtimeRefreshFailed = realtimeRefreshFailed) } + departureTime?.let { JourneyTimeValue(it, prefix = "Ab: ", textAlpha = textAlpha, + realtimeInfo = eventRealtimeInfo(stop, StopEvent.DEPARTURE), nowMillis = nowMillis, + realtimeRefreshFailed = realtimeRefreshFailed) } } } } @@ -1019,18 +1025,19 @@ private fun StopoverItem( } } } - val plat = trackingPlatform(stop, isOrigin = isOrigin, isReplacementBus = isReplacementBus) - if (plat != null && !isReplacementBus) { - val displayPlat = if (plat.startsWith("Gl", ignoreCase = true)) plat else "Gl. $plat" + if (platform != null && !isReplacementBus && !isCancelled) { Surface( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + color = if (platform.changed) WarningOrangeLight + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), shape = RoundedCornerShape(4.dp) ) { Text( - displayPlat, + platform.label, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + color = if (platform.changed) WarningOrange + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + fontWeight = if (platform.changed) FontWeight.Bold else FontWeight.Normal ) } } @@ -1162,6 +1169,19 @@ private fun StopoverItem( } } } + val platformInfo = eventPlatformRealtimeInfo(stop, platformEvent) + if (platform != null && !isReplacementBus && !isCancelled && platformInfo != null) { + val retrieval = realtimeRetrievalPresentation(platformInfo, nowMillis, realtimeRefreshFailed) + Text("Gleis: ${retrieval.detail}" + if (retrieval.recentlyRetrieved) "" else " · ${retrieval.label.lowercase()}", + modifier = Modifier.padding(top = 4.dp), style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)) + } + if (isCancelled && stop.cancellationRealtimeInfo != null) { + val retrieval = realtimeRetrievalPresentation(stop.cancellationRealtimeInfo, nowMillis, realtimeRefreshFailed) + Text("Ausfall: ${retrieval.detail}" + if (retrieval.recentlyRetrieved) "" else " · ${retrieval.label.lowercase()}", + modifier = Modifier.padding(top = 4.dp), style = MaterialTheme.typography.labelSmall, + color = ErrorRed) + } if (!isCancelled && sevInfo != null) { SevStopGuidance(sevInfo) } diff --git a/app/src/main/kotlin/de/traewelling/app/util/PreferencesManager.kt b/app/src/main/kotlin/de/traewelling/app/util/PreferencesManager.kt index cf29406..de35688 100644 --- a/app/src/main/kotlin/de/traewelling/app/util/PreferencesManager.kt +++ b/app/src/main/kotlin/de/traewelling/app/util/PreferencesManager.kt @@ -44,6 +44,7 @@ class PreferencesManager(private val context: Context) { val KEY_TRIP_CHANGE_SPEECH_ENABLED = booleanPreferencesKey("trip_change_speech_enabled") val KEY_LIVE_PROGRESS_ENABLED = booleanPreferencesKey("live_progress_enabled") val KEY_LOCK_SCREEN_DETAILS_ENABLED = booleanPreferencesKey("lock_screen_details_enabled") + val KEY_PUBLIC_RAIL_REALTIME_ENABLED = booleanPreferencesKey("public_rail_realtime_enabled") private val KEY_TRACKING_STATE = AuthSessionPreferences.trackingState private val KEY_LOCATION_PERMISSION_REQUESTED = booleanPreferencesKey("location_permission_requested") @@ -123,6 +124,9 @@ class PreferencesManager(private val context: Context) { val lockScreenDetailsEnabled: Flow = context.dataStore.data.map { it[KEY_LOCK_SCREEN_DETAILS_ENABLED] ?: true } + val publicRailRealtimeEnabled: Flow = context.dataStore.data.map { + it[KEY_PUBLIC_RAIL_REALTIME_ENABLED] ?: true + }.distinctUntilChanged() suspend fun setRideRecognitionEnabled(enabled: Boolean, expectedSession: AuthSession? = null): Boolean { var changed = false @@ -143,12 +147,16 @@ class PreferencesManager(private val context: Context) { context.dataStore.edit { it[KEY_LOCK_SCREEN_DETAILS_ENABLED] = enabled } if (!enabled) de.traewelling.app.service.TripTrackingService.clearChangeNotifications(context) } + suspend fun setPublicRailRealtimeEnabled(enabled: Boolean) { + context.dataStore.edit { it[KEY_PUBLIC_RAIL_REALTIME_ENABLED] = enabled } + } suspend fun getRideRecognitionEnabled(): Boolean = rideRecognitionEnabled.first() suspend fun getTripChangeAlertsEnabled(): Boolean = tripChangeAlertsEnabled.first() suspend fun getTripChangeSpeechEnabled(): Boolean = tripChangeSpeechEnabled.first() suspend fun getLiveProgressEnabled(): Boolean = liveProgressEnabled.first() suspend fun getLockScreenDetailsEnabled(): Boolean = lockScreenDetailsEnabled.first() + suspend fun getPublicRailRealtimeEnabled(): Boolean = publicRailRealtimeEnabled.first() suspend fun saveServerConfig(serverUrl: String, clientId: String, clientSecret: String) { context.dataStore.edit { prefs -> diff --git a/app/src/main/kotlin/de/traewelling/app/viewmodel/RealtimePresentation.kt b/app/src/main/kotlin/de/traewelling/app/viewmodel/RealtimePresentation.kt new file mode 100644 index 0000000..9ad710d --- /dev/null +++ b/app/src/main/kotlin/de/traewelling/app/viewmodel/RealtimePresentation.kt @@ -0,0 +1,108 @@ +package de.traewelling.app.viewmodel + +import de.traewelling.app.data.model.StopRealtimeInfo +import de.traewelling.app.data.model.StopStation +import de.traewelling.app.service.JourneyTime +import de.traewelling.app.service.JourneyTimeSource + +enum class StopEvent { ARRIVAL, DEPARTURE } + +data class PlatformPresentation(val label: String, val changed: Boolean) + +data class RealtimeRetrievalPresentation( + val label: String, + val detail: String, + val recentlyRetrieved: Boolean +) + +fun eventRealtimeInfo(stop: StopStation?, event: StopEvent): StopRealtimeInfo? = when (event) { + StopEvent.ARRIVAL -> stop?.arrivalRealtimeInfo + StopEvent.DEPARTURE -> stop?.departureRealtimeInfo +} + +fun eventPlatformRealtimeInfo(stop: StopStation, event: StopEvent): StopRealtimeInfo? = when (event) { + StopEvent.ARRIVAL -> stop.arrivalPlatformRealtimeInfo + StopEvent.DEPARTURE -> stop.departurePlatformRealtimeInfo +} + +/** A departure change must never be shown as the arrival platform, or vice versa. */ +fun platformPresentation(stop: StopStation, event: StopEvent): PlatformPresentation? { + val planned = (when (event) { + StopEvent.ARRIVAL -> stop.arrivalPlatformPlanned + StopEvent.DEPARTURE -> stop.departurePlatformPlanned + }).platformValue() + val real = (when (event) { + StopEvent.ARRIVAL -> stop.arrivalPlatformReal + StopEvent.DEPARTURE -> stop.departurePlatformReal + }).platformValue() + val platform = real ?: planned ?: stop.platform.platformValue() ?: return null + val changed = planned != null && real != null && !planned.equals(real, ignoreCase = true) + return PlatformPresentation( + if (changed) "Gleis $platform statt $planned" else "Gleis $platform", changed + ) +} + +private fun String?.platformValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() } + ?.replace(Regex("^(?:Gleis\\s+|Gl\\.\\s*|Gl\\s+)", RegexOption.IGNORE_CASE), "") + ?.trim()?.takeIf { it.isNotEmpty() } + +/** The timestamp proves a client retrieval, never how fresh the provider's underlying feed is. */ +fun realtimeRetrievalPresentation( + info: StopRealtimeInfo?, + nowMillis: Long, + refreshFailed: Boolean = false +): RealtimeRetrievalPresentation { + val source = info?.sourceLabel?.takeIf { it.isNotBlank() } ?: "Quelle unbekannt" + val age = info?.fetchedAtMillis?.takeIf { it > 0 && it <= nowMillis }?.let { nowMillis - it } + val ageLabel = when { + age == null -> "Abrufzeit unbekannt" + age < 60_000 -> "abgerufen vor ${age / 1_000} s" + age < 3_600_000 -> "abgerufen vor ${age / 60_000} min" + else -> "abgerufen vor ${age / 3_600_000} h" + } + val fresh = !refreshFailed && info?.isFresh(nowMillis) == true + val label = when { + refreshFailed -> "Offline / Abruffehler" + info == null || age == null -> "Abruf nicht bestätigt" + fresh -> "Daten abgerufen" + else -> "Abruf veraltet" + } + return RealtimeRetrievalPresentation(label, "$source · $ageLabel", fresh) +} + +/** Report the oldest included event, so a fresh IRIS departure cannot hide an old arrival. */ +fun stopoversRetrievalPresentation( + stops: List, + nowMillis: Long, + refreshFailed: Boolean +): RealtimeRetrievalPresentation { + val events = stops.flatMap { stop -> listOfNotNull( + stop.arrivalRealtimeInfo, stop.departureRealtimeInfo, stop.cancellationRealtimeInfo, + stop.arrivalPlatformRealtimeInfo, stop.departurePlatformRealtimeInfo + ) } + val unknownEvent = stops.any { stop -> + (stop.arrivalPlanned != null || stop.arrivalReal != null) && stop.arrivalRealtimeInfo == null || + (stop.departurePlanned != null || stop.departureReal != null) && stop.departureRealtimeInfo == null + } + val oldest = if (unknownEvent) null else (events.firstOrNull { + it.fetchedAtMillis <= 0 || it.fetchedAtMillis > nowMillis + } ?: events.minByOrNull { it.fetchedAtMillis }) + val presentation = realtimeRetrievalPresentation(oldest, nowMillis, refreshFailed) + val sources = events.map { it.sourceLabel }.filter { it.isNotBlank() }.distinct().joinToString(" + ") + return if (sources.isEmpty() || oldest == null) presentation else presentation.copy( + detail = "$sources · ${presentation.detail.removePrefix("${oldest.sourceLabel.takeIf { it.isNotBlank() } ?: "Quelle unbekannt"} · ")}" + ) +} + +/** Manual and GPS displays keep their own source; provider provenance belongs only to API time. */ +fun journeyTimeSourceLabel( + time: JourneyTime, + info: StopRealtimeInfo?, + nowMillis: Long, + refreshFailed: Boolean +): String { + if (time.source != JourneyTimeSource.API_REALTIME) return time.sourceLabel + val retrieval = realtimeRetrievalPresentation(info, nowMillis, refreshFailed) + return if (retrieval.recentlyRetrieved) retrieval.detail + else "${retrieval.detail} · ${retrieval.label.lowercase()}" +} diff --git a/app/src/main/kotlin/de/traewelling/app/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/de/traewelling/app/viewmodel/SettingsViewModel.kt index 2da8566..5e3ef9b 100644 --- a/app/src/main/kotlin/de/traewelling/app/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/de/traewelling/app/viewmodel/SettingsViewModel.kt @@ -28,7 +28,8 @@ data class SettingsUiState( val tripChangeAlertsEnabled: Boolean = true, val tripChangeSpeechEnabled: Boolean = true, val liveProgressEnabled: Boolean = true, - val lockScreenDetailsEnabled: Boolean = true + val lockScreenDetailsEnabled: Boolean = true, + val publicRailRealtimeEnabled: Boolean = true ) class SettingsViewModel(application: Application) : AndroidViewModel(application) { @@ -103,6 +104,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application launch { prefs.tripChangeSpeechEnabled.collect { enabled -> _uiState.update { it.copy(tripChangeSpeechEnabled = enabled) } } } launch { prefs.liveProgressEnabled.collect { enabled -> _uiState.update { it.copy(liveProgressEnabled = enabled) } } } launch { prefs.lockScreenDetailsEnabled.collect { enabled -> _uiState.update { it.copy(lockScreenDetailsEnabled = enabled) } } } + launch { prefs.publicRailRealtimeEnabled.collect { enabled -> _uiState.update { it.copy(publicRailRealtimeEnabled = enabled) } } } } } @@ -230,6 +232,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun setLockScreenDetailsEnabled(enabled: Boolean) { viewModelScope.launch { prefs.setLockScreenDetailsEnabled(enabled) } } + + fun setPublicRailRealtimeEnabled(enabled: Boolean) { + viewModelScope.launch { prefs.setPublicRailRealtimeEnabled(enabled) } + } } private data class TtsSettings(val enabled: Boolean, val engine: String?, val language: String?, val voice: String?) diff --git a/app/src/main/kotlin/de/traewelling/app/viewmodel/StatusDetailViewModel.kt b/app/src/main/kotlin/de/traewelling/app/viewmodel/StatusDetailViewModel.kt index bbc408e..f310dc7 100644 --- a/app/src/main/kotlin/de/traewelling/app/viewmodel/StatusDetailViewModel.kt +++ b/app/src/main/kotlin/de/traewelling/app/viewmodel/StatusDetailViewModel.kt @@ -7,6 +7,7 @@ import android.widget.Toast import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import de.traewelling.app.data.model.SevStopInfo +import de.traewelling.app.data.model.CheckinInfo import de.traewelling.app.data.model.Status import de.traewelling.app.data.model.StopStation import de.traewelling.app.data.model.TrainStation @@ -33,6 +34,7 @@ data class StatusDetailUiState( val stopovers: List = emptyList(), val error: String? = null, val lastUpdated: Long = 0, + val realtimeRefreshFailed: Boolean = false, val isDeleting: Boolean = false, val deletedStatusId: Int? = null, val isOwnStatus: Boolean = false, @@ -145,18 +147,43 @@ class StatusDetailViewModel(application: Application) : AndroidViewModel(applica private suspend fun loadSnapshot(statusId: Int, request: Long, showLoading: Boolean) { val session = prefs.getAuthSession() + suspend fun ensureCurrentRead() { + coroutineContext.ensureActive() + val currentSession = prefs.getAuthSession() + coroutineContext.ensureActive() + if (currentStatusId != statusId || request != loadGeneration || + currentSession != session) throw CancellationException("Detailabruf ist nicht mehr aktuell") + } + var currentCheckin: CheckinInfo? = null val result = readConsistentStatusDetail( - readStatus = { repo.getStatusDetailSnapshot(statusId) }, - readStopovers = repo::getStopovers, + readStatus = { + ensureCurrentRead() + repo.getStatusDetailSnapshot(statusId).also { + ensureCurrentRead() + currentCheckin = it.getOrNull()?.status?.checkin + } + }, + readStopovers = { tripId -> + val stops = repo.getStopovers(tripId) + ensureCurrentRead() + val checkin = currentCheckin + if (stops.isSuccess && checkin != null) { + val enriched = repo.enrichStopovers(stops.getOrThrow(), checkin) + ensureCurrentRead() + Result.success(enriched) + } else stops + }, isCurrentRevision = { revision -> - repo.getStatusMutationSnapshot(statusId, session).revision == revision + val current = repo.getStatusMutationSnapshot(statusId, session).revision == revision + ensureCurrentRead() + current } ) - coroutineContext.ensureActive() - if (currentStatusId != statusId || request != loadGeneration) return + ensureCurrentRead() val snapshot = result.getOrElse { error -> - if (showLoading) _uiState.update { - it.copy(isLoading = false, error = "Fahrt konnte nicht geladen werden: ${error.message}") + _uiState.update { + it.copy(isLoading = false, realtimeRefreshFailed = true, + error = "Aktualisierung fehlgeschlagen: ${error.message}") } return } @@ -171,9 +198,15 @@ class StatusDetailViewModel(application: Application) : AndroidViewModel(applica isLoading = false, status = statusWithStopoverBoundaries(status, stops), stopovers = stops, - lastUpdated = System.currentTimeMillis(), - error = if (showLoading && stopsResult?.isFailure == true) - "Halte konnten nicht geladen werden: ${stopsResult.exceptionOrNull()?.message}" else state.error + lastUpdated = when { + stopsResult?.isSuccess == true -> System.currentTimeMillis() + stops.isEmpty() -> 0 + else -> state.lastUpdated + }, + realtimeRefreshFailed = stopsResult?.isFailure == true, + error = if (stopsResult?.isFailure == true) + "Halte konnten nicht aktualisiert werden: ${stopsResult.exceptionOrNull()?.message}" + else if (state.realtimeRefreshFailed) null else state.error ) } enrichSevStops(status, _uiState.value.stopovers) @@ -203,6 +236,7 @@ class StatusDetailViewModel(application: Application) : AndroidViewModel(applica ) } sevEnrichmentJob = viewModelScope.launch { + val session = prefs.getAuthSession() val resolved = try { SevJourneyEnricher.enrich(checkin, stops) } catch (cancelled: CancellationException) { @@ -210,8 +244,10 @@ class StatusDetailViewModel(application: Application) : AndroidViewModel(applica } catch (_: Exception) { emptyMap() } + val currentSession = prefs.getAuthSession() + coroutineContext.ensureActive() _uiState.update { state -> - if (currentStatusId == status.id && generation == sevGeneration && + if (currentSession == session && currentStatusId == status.id && generation == sevGeneration && state.status?.let { SevDetailSnapshot.from(it, state.stopovers) } == snapshot) { state.copy(sevStops = resolved, isLoadingSevStops = false) } else state @@ -443,10 +479,14 @@ class StatusDetailViewModel(application: Application) : AndroidViewModel(applica private fun checkIfOwnStatus(status: Status) { val view = viewGeneration viewModelScope.launch { + val session = prefs.getAuthSession() repo.getCurrentUser().onSuccess { currentUser -> + coroutineContext.ensureActive() + val currentSession = prefs.getAuthSession() coroutineContext.ensureActive() _uiState.update { - if (view == viewGeneration && currentStatusId == status.id && it.status?.id == status.id) { + if (currentSession == session && view == viewGeneration && + currentStatusId == status.id && it.status?.id == status.id) { it.copy(isOwnStatus = status.user?.id != null && status.user.id == currentUser.id) } else it } diff --git a/app/src/test/kotlin/de/traewelling/app/data/dbf/DbfJsonParserTest.kt b/app/src/test/kotlin/de/traewelling/app/data/dbf/DbfJsonParserTest.kt new file mode 100644 index 0000000..f322a14 --- /dev/null +++ b/app/src/test/kotlin/de/traewelling/app/data/dbf/DbfJsonParserTest.kt @@ -0,0 +1,71 @@ +package de.traewelling.app.data.dbf + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.LocalTime + +class DbfJsonParserTest { + private val fetched = Instant.parse("2026-10-10T17:26:55Z") + + @Test fun readsCapturedPublicIrisV3WithoutInventingStationDateOrUpdateTime() { + val board = DbfJsonParser.parse(fixture(), "8000152", fetched) + assertEquals("8000152", board.eva) + assertEquals(fetched, board.fetchedAt) + assertNull(board.providerUpdatedAt) + val arrival = board.departures.first() + assertEquals("34328", arrival.trainNumber) + assertEquals(LocalTime.of(19, 24), arrival.scheduledArrival) + assertNull(arrival.scheduledDeparture) + assertEquals(3, arrival.delayArrival) + assertNull(arrival.delayDeparture) + assertFalse(arrival.isCancelled!!) + assertFalse(arrival.missingRealtime!!) + assertTrue(arrival.messages.isNotEmpty()) + } + + @Test fun nullDelayAndMissingRealtimeStayUnknown() { + val board = DbfJsonParser.parse(row("\"delayArrival\":null", "\"missingRealtime\":true"), "8000152", fetched) + assertNull(board.departures.single().delayArrival) + assertTrue(board.departures.single().missingRealtime!!) + } + + @Test fun invalidFieldTypesDoNotBecomeValidPredictionsOrCancellation() { + val board = DbfJsonParser.parse(row("\"delayArrival\":\"5\"", "\"isCancelled\":\"true\""), "8000152", fetched) + assertNull(board.departures.single().delayArrival) + assertNull(board.departures.single().isCancelled) + } + + @Test fun rejectsErrorOtherBackendAndMalformedOrTrailingData() { + listOf( + "{\"error\":\"backend failed\",\"departures\":[]}", + "{\"departures\":[{\"scheduledTime\":1791653000,\"trainNumber\":\"123\"}]}", + "{\"departures\":", "unavailable", + "{\"departures\":[]} {\"other\":1}", "{departures:[]}" + ).forEach { json -> assertTrue(json, runCatching { DbfJsonParser.parse(json, "8000152", fetched) }.isFailure) } + } + + @Test fun refusesInvalidEvaAndEmptyBoardIsSuccessful() { + listOf("Hannover Hbf", "0", "8000152/../other", "0000000").forEach { eva -> + assertTrue(runCatching { DbfJsonParser.parse("{\"departures\":[]}", eva, fetched) }.isFailure) + } + assertTrue(DbfJsonParser.parse("{\"departures\":[]}", "8000152", fetched).departures.isEmpty()) + } + + private fun fixture() = requireNotNull(javaClass.getResource("/dbf/public-hannover-v3.json")).readText() + + private fun row(vararg fields: String): String { + val defaults = linkedMapOf( + "scheduledArrival" to "\"19:24\"", "scheduledDeparture" to "null", + "delayArrival" to "3", "delayDeparture" to "null", "trainNumber" to "\"34328\"" + ) + fields.forEach { field -> + val key = field.substringBefore(':').trim('"') + defaults[key] = field.substringAfter(':') + } + return "{\"departures\":[{" + defaults.entries.joinToString(",") { "\"${it.key}\":${it.value}" } + "}]}" + } +} diff --git a/app/src/test/kotlin/de/traewelling/app/data/dbf/DbfMatcherTest.kt b/app/src/test/kotlin/de/traewelling/app/data/dbf/DbfMatcherTest.kt new file mode 100644 index 0000000..af6e92d --- /dev/null +++ b/app/src/test/kotlin/de/traewelling/app/data/dbf/DbfMatcherTest.kt @@ -0,0 +1,142 @@ +package de.traewelling.app.data.dbf + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.Instant +import java.time.LocalTime + +class DbfMatcherTest { + private val fetched = Instant.parse("2026-10-10T17:26:55Z") + private val arrival = Instant.parse("2026-10-10T17:24:00Z") + private val departure = Instant.parse("2026-10-10T17:25:00Z") + + @Test fun usesExistingPlannedDateAndIndependentArrivalDepartureDelays() { + val delta = match(board(row()))!! + assertEquals(DbfField.Present(arrival.plusSeconds(180)), delta.arrival!!.realTime) + assertEquals(DbfField.Present(departure.plusSeconds(300)), delta.departure!!.realTime) + assertEquals(DbfField.Absent, delta.arrival.platform) + assertEquals(DbfField.Present("4"), delta.departure.platform) + assertEquals(DbfField.Absent, delta.arrival.scheduledPlatform) + assertEquals(DbfField.Present("3"), delta.departure.scheduledPlatform) + assertNull(delta.providerUpdatedAt) + } + + @Test fun capturedArrivalOnlyRowDoesNotInventDeparture() { + val board = DbfJsonParser.parse( + requireNotNull(javaClass.getResource("/dbf/public-hannover-v3.json")).readText(), "8000152", fetched + ) + val delta = DbfMatcher.match(board, "8000152", "34328", arrival, null)!! + assertEquals(DbfField.Present(arrival.plusSeconds(180)), delta.arrival!!.realTime) + assertNull(delta.departure) + assertEquals(DbfField.Absent, delta.arrival.platform) + } + + @Test fun requiresPositiveOperationalNumberAndExactStationNotPublicLine() { + listOf(null, "0", "-34328", "RE 1", "34329", "34328a").forEach { number -> + assertNull(DbfMatcher.match(board(row()), "8000152", number, arrival, departure)) + } + assertNull(DbfMatcher.match(board(row()), "8000191", "34328", arrival, departure)) + } + + @Test fun repeatedTrainTimeAndEqualDuplicateRowsAreAmbiguous() { + assertNull(match(board(row(), row()))) + assertNull(match(board(row(), row().copy(delayArrival = 10)))) + } + + @Test fun zeroNullAndMissingRealtimeCannotEraseKnownDelayOrInventLivePlatform() { + for (row in listOf( + row().copy(delayArrival = 0, delayDeparture = 0, platform = "3"), + row().copy(delayArrival = null, delayDeparture = null, platform = "3"), + row().copy(missingRealtime = true), row().copy(missingRealtime = null) + )) { + val delta = match(board(row))!! + assertEquals(DbfField.Absent, delta.arrival!!.realTime) + assertEquals(DbfField.Absent, delta.departure!!.realTime) + assertEquals(DbfField.Absent, delta.departure.platform) + assertEquals(DbfField.Absent, delta.departure.scheduledPlatform) + } + } + + @Test fun falseAggregateCancellationNeverRestoresPotentiallyPartlyCancelledStop() { + val delta = match(board(row().copy(isCancelled = false)))!! + assertEquals(DbfField.Absent, delta.cancelled) + } + + @Test fun confirmedWholeStopCancellationDoesNotManufactureRealtimeTimes() { + val delta = match(board(row().copy(isCancelled = true)))!! + assertEquals(DbfField.Present(true), delta.cancelled) + assertEquals(DbfField.Absent, delta.arrival!!.realTime) + assertEquals(DbfField.Absent, delta.departure!!.platform) + } + + @Test fun cancellationRequiresAllRequestedEventSidesToMatch() { + val delta = DbfMatcher.match(board(row().copy(isCancelled = true)), "8000152", "34328", arrival, + departure.plusSeconds(120))!! + assertNull(delta.departure) + assertEquals(DbfField.Absent, delta.cancelled) + } + + @Test fun arrivalOnlyCancellationAndChangedPlatformAreSupported() { + val terminal = row().copy(scheduledDeparture = null, delayDeparture = null) + val delta = DbfMatcher.match(board(terminal), "8000152", "34328", arrival, null)!! + assertEquals(DbfField.Present("4"), delta.arrival!!.platform) + assertEquals(DbfField.Present("3"), delta.arrival.scheduledPlatform) + val cancelled = DbfMatcher.match(board(terminal.copy(isCancelled = true)), "8000152", "34328", arrival, null)!! + assertEquals(DbfField.Present(true), cancelled.cancelled) + } + + @Test fun dateLessBoardNeverMatchesSameClockTimeOnAnotherDayOrOutsideWindow() { + assertNull(DbfMatcher.match(board(row()), "8000152", "34328", arrival.minusSeconds(86_400), null)) + assertNull(DbfMatcher.match(board(row()), "8000152", "34328", arrival.plusSeconds(86_400), null)) + assertNull(DbfMatcher.match(board(row(), fetchedAt = fetched.plusSeconds(7200)), "8000152", "34328", arrival, null)) + } + + @Test fun refusesRepeatedBerlinDstHourDespiteKnownAppOffset() { + val scheduled = Instant.parse("2026-10-25T00:30:00Z") + val row = row().copy(scheduledArrival = LocalTime.of(2, 30), scheduledDeparture = null) + val board = board(row, fetchedAt = scheduled.minusSeconds(60)) + assertNull(DbfMatcher.match(board, "8000152", "34328", scheduled, null)) + assertNull(DbfMatcher.match(board.copy(fetchedAt = scheduled.plusSeconds(3540)), "8000152", "34328", + scheduled.plusSeconds(3600), null)) + } + + @Test fun handlesMidnightOnlyUsingExistingPlannedInstantWithinCurrentWindow() { + val midnight = Instant.parse("2026-10-10T22:05:00Z") + val row = row().copy(scheduledArrival = LocalTime.of(0, 5), scheduledDeparture = null) + val delta = DbfMatcher.match(board(row, fetchedAt = midnight.minusSeconds(600)), "8000152", "34328", midnight, null)!! + assertEquals(DbfField.Present(midnight.plusSeconds(180)), delta.arrival!!.realTime) + } + + @Test fun negativeDelayWorksButExtremeOrInvalidDelayDoesNot() { + val delta = match(board(row().copy(delayArrival = -2, delayDeparture = 2_000)))!! + assertEquals(DbfField.Present(arrival.minusSeconds(120)), delta.arrival!!.realTime) + assertEquals(DbfField.Absent, delta.departure!!.realTime) + } + + @Test fun largeDelayMatchesTheCurrentActualEventInsteadOfOldPlannedWindow() { + val scheduled = Instant.parse("2026-10-10T16:40:00Z") + val fetched = Instant.parse("2026-10-10T17:23:00Z") + val row = row().copy(scheduledArrival = LocalTime.of(18, 40), scheduledDeparture = null, + delayArrival = 43) + val delta = DbfMatcher.match(board(row, fetchedAt = fetched), "8000152", "34328", scheduled, null)!! + assertEquals(DbfField.Present(fetched), delta.arrival!!.realTime) + } + + @Test fun delayedPreviousDayArrivalMatchesAfterMidnightWithoutInventingDate() { + val scheduled = Instant.parse("2026-10-10T21:40:00Z") + val actual = Instant.parse("2026-10-10T22:20:00Z") + val row = row().copy(scheduledArrival = LocalTime.of(23, 40), scheduledDeparture = null, + delayArrival = 40) + val delta = DbfMatcher.match(board(row, fetchedAt = actual), "8000152", "34328", scheduled, null)!! + assertEquals(DbfField.Present(actual), delta.arrival!!.realTime) + assertNull(DbfMatcher.match(board(row.copy(delayArrival = 1_440), fetchedAt = actual), + "8000152", "34328", scheduled.minusSeconds(86_400), null)) + } + + private fun row() = DbfDeparture("34328", LocalTime.of(19, 24), LocalTime.of(19, 25), + 3, 5, "4", "3", false, false) + private fun board(vararg rows: DbfDeparture, fetchedAt: Instant = fetched) = + DbfBoard("8000152", rows.toList(), fetchedAt) + private fun match(board: DbfBoard) = DbfMatcher.match(board, "8000152", "34328", arrival, departure) +} diff --git a/app/src/test/kotlin/de/traewelling/app/data/dbf/DbfRealtimeRepositoryTest.kt b/app/src/test/kotlin/de/traewelling/app/data/dbf/DbfRealtimeRepositoryTest.kt new file mode 100644 index 0000000..d73ed6b --- /dev/null +++ b/app/src/test/kotlin/de/traewelling/app/data/dbf/DbfRealtimeRepositoryTest.kt @@ -0,0 +1,334 @@ +package de.traewelling.app.data.dbf + +import de.traewelling.app.data.model.CheckinInfo +import de.traewelling.app.data.model.StationIdentifier +import de.traewelling.app.data.model.StopRealtimeInfo +import de.traewelling.app.data.model.StopStation +import de.traewelling.app.data.model.TrainStation +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import okio.BufferedSource +import org.junit.Assert.* +import org.junit.Test +import java.time.Instant +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +class DbfRealtimeRepositoryTest { + private val now = Instant.parse("2026-10-10T17:10:00Z") + private val eva = "8000152" + + @Test fun publicRequestIsAnonymousAndExcludesUnidentifiedRelatedStations() { + val http = DbfBoardHttp() + val request = http.request(eva) + assertEquals("https://dbf.finalrewind.org/8000152.json?version=3&no_related=1&past=1", request.url.toString()) + assertEquals("GET", request.method) + assertNull(request.body) + assertNull(request.header("Authorization")) + assertNull(request.header("DB-Client-ID")) + assertNull(request.header("DB-Api-Key")) + assertNull(request.header("Cookie")) + assertTrue(http.client.interceptors.isEmpty()) + assertTrue(http.client.networkInterceptors.isEmpty()) + assertFalse(http.client.followRedirects) + assertFalse(http.client.followSslRedirects) + assertFalse(http.client.retryOnConnectionFailure) + assertEquals(6_000, http.client.callTimeoutMillis) + listOf("Hannover", "8000152/else", "123", "8000152?token=x").forEach { + assertTrue(runCatching { http.request(it) }.isFailure) + } + } + + @Test fun decoderRejectsForeignUrlRedirectOversizeAndMalformedUtf8() { + val http = DbfBoardHttp(nowMillis = { now.toEpochMilli() }) + val request = http.request(eva) + val foreign = response(http, json().toResponseBody()).newBuilder() + .request(request.newBuilder().url("https://other.test/8000152.json").build()).build() + assertNull(http.decodeResponse(foreign, request, eva).board) + assertNull(http.decodeResponse(response(http, json().toResponseBody(), 302), request, eva).board) + val buffer = Buffer().write(ByteArray(DbfBoardHttp.MAX_BODY_BYTES + 100) { 32 }) + val unknownLength = object : ResponseBody() { + override fun contentType(): MediaType = "application/json".toMediaType() + override fun contentLength(): Long = -1 + override fun source(): BufferedSource = buffer + } + assertNull(http.decodeResponse(response(http, unknownLength), request, eva).board) + assertEquals(99L, buffer.size) + val malformed = byteArrayOf(0xc3.toByte(), 0x28).toResponseBody("application/json".toMediaType()) + assertNull(http.decodeResponse(response(http, malformed), request, eva).board) + } + + @Test fun retryAfterSecondsAndDatePauseFutureStationsWithoutShorteningLongPauses() = runBlocking { + val http = DbfBoardHttp(nowMillis = { now.toEpochMilli() }) + val request = http.request(eva) + val rateLimited = response(http, "".toResponseBody(), 429).newBuilder().header("Retry-After", "120").build() + assertEquals(120_000L, http.decodeResponse(rateLimited, request, eva).retryAfterMillis) + val dated = response(http, "".toResponseBody(), 503).newBuilder() + .header("Retry-After", "Sat, 10 Oct 2026 17:12:00 GMT").build() + assertEquals(120_000L, http.decodeResponse(dated, request, eva).retryAfterMillis) + val huge = response(http, "".toResponseBody(), 429).newBuilder().header("Retry-After", "999999999").build() + assertEquals(999_999_999_000L, http.decodeResponse(huge, request, eva).retryAfterMillis) + val overflow = response(http, "".toResponseBody(), 429).newBuilder() + .header("Retry-After", Long.MAX_VALUE.toString()).build() + assertEquals(Long.MAX_VALUE, http.decodeResponse(overflow, request, eva).retryAfterMillis) + val clock = AtomicLong() + val calls = AtomicInteger() + val store = store(clock) { calls.incrementAndGet(); DbfFetchResult(null, 120_000L) } + try { + assertNull(store.get(eva)) + clock.set(119_999L) + assertNull(store.get("8000105")) + assertEquals(1, calls.get()) + clock.incrementAndGet() + assertNull(store.get("8000105")) + assertEquals(2, calls.get()) + } finally { store.close() } + } + + @Test fun datedOrCachedOldBoardsCannotBeRelabelledAsTodaysFreshPredictions() { + val http = DbfBoardHttp(nowMillis = { now.toEpochMilli() }) + val request = http.request(eva) + for ((name, value) in listOf("Date" to "Fri, 9 Oct 2026 17:10:00 GMT", + "Date" to "Sat, 10 Oct 2026 17:12:00 GMT", "Age" to "86400", "Age" to "invalid")) { + val old = response(http, json().toResponseBody()).newBuilder().header(name, value).build() + assertNull(http.decodeResponse(old, request, eva).board) + } + val validCached = response(http, json().toResponseBody()).newBuilder() + .header("Date", "Sat, 10 Oct 2026 17:08:50 GMT").header("Age", "70").build() + val board = http.decodeResponse(validCached, request, eva).board + assertNotNull(board) + assertEquals(now, board?.fetchedAt) + assertNull(board?.providerUpdatedAt) + } + + @Test fun cachedBoardIsSharedForSixtySecondsAndFailureNeverRevivesExpiredSuccess() = runBlocking { + val clock = AtomicLong() + val calls = AtomicInteger() + val store = store(clock) { station -> + if (calls.incrementAndGet() == 1) DbfFetchResult(board(station)) else DbfFetchResult(null) + } + try { + assertNotNull(store.get(eva)) + clock.set(59_999) + assertNotNull(store.get(eva)) + assertEquals(1, calls.get()) + clock.incrementAndGet() + assertNull(store.get(eva)) + clock.set(119_999) + assertNull(store.get(eva)) + assertEquals(2, calls.get()) + clock.incrementAndGet() + assertNull(store.get(eva)) + assertEquals(3, calls.get()) + } finally { store.close() } + } + + @Test fun providerHourLongCooldownStillBlocksAllStationsAfterThirtyMinutes() = runBlocking { + val clock = AtomicLong() + val calls = AtomicInteger() + val store = store(clock) { calls.incrementAndGet(); DbfFetchResult(null, 3_600_000L) } + try { + assertNull(store.get(eva)) + clock.set(30 * 60_000L) + assertNull(store.get(eva)) + assertNull(store.get("8000105")) + assertEquals(1, calls.get()) + clock.set(3_600_000L) + assertNull(store.get("8000105")) + assertEquals(2, calls.get()) + } finally { store.close() } + } + + @Test fun exactSlidingWindowAllowsTenStationStartsAndCountsFailedRequests() = runBlocking { + val clock = AtomicLong() + val starts = mutableListOf() + val store = store(clock) { starts.add(clock.get()); DbfFetchResult(null) } + try { + repeat(10) { index -> assertNull(store.get((8_000_000 + index).toString())) } + assertEquals(10, starts.size) + clock.set(59_999L) + assertNull(store.get("8000111")) + assertEquals(10, starts.size) + clock.incrementAndGet() + assertNull(store.get("8000111")) + assertEquals(11, starts.size) + assertEquals(60_000L, starts.last()) + } finally { store.close() } + } + + @Test fun cacheEvictionDoesNotPermitSecondStationRequestInsideOneMinute() = runBlocking { + val clock = AtomicLong() + val calls = AtomicInteger() + val store = store(clock, cacheLimit = 1) { station -> calls.incrementAndGet(); DbfFetchResult(board(station)) } + try { + store.get(eva) + store.get("8000105") + assertNull(store.get(eva)) + assertEquals(2, calls.get()) + clock.set(60_000L) + assertNotNull(store.get(eva)) + assertEquals(3, calls.get()) + } finally { store.close() } + } + + @Test fun cancellingOneAwaiterPreservesSingleflightForOtherScreenAndCache() = runBlocking { + val clock = AtomicLong() + val calls = AtomicInteger() + val entered = CompletableDeferred() + val release = CompletableDeferred() + val store = store(clock) { station -> calls.incrementAndGet(); entered.complete(Unit); release.await(); DbfFetchResult(board(station)) } + try { + val first = async { store.get(eva) } + entered.await() + val second = async { store.get(eva) } + yield() + first.cancelAndJoin() + assertFalse(second.isCancelled) + release.complete(Unit) + assertNotNull(second.await()) + assertNotNull(store.get(eva)) + assertEquals(1, calls.get()) + } finally { store.close() } + } + + @Test fun enrichmentPreservesIdentityPlansAndSeparateUnchangedFieldReadAges() = runBlocking { + val clock = AtomicLong() + val sourceInfo = StopRealtimeInfo(now.minusSeconds(90).toEpochMilli(), "Träwelling") + val original = stop().copy(arrivalReal = "2026-10-10T17:17:00Z", departureReal = "2026-10-10T17:20:00Z", + arrivalPlatformPlanned = "5", departurePlatformPlanned = "5", + arrivalRealtimeInfo = sourceInfo, departureRealtimeInfo = sourceInfo, + arrivalPlatformRealtimeInfo = sourceInfo, departurePlatformRealtimeInfo = sourceInfo) + // Platform-only source update has no trustworthy zero-minute realtime prediction. + val store = store(clock) { station -> DbfFetchResult(board(station, arrivalDelay = 0, departureDelay = 0, + scheduledPlatform = "12")) } + try { + val repo = DbfRealtimeRepository({ true }, store) { now.toEpochMilli() } + val changed = repo.enrich(listOf(original), checkin(original)).single() + assertEquals(original.station, changed.station) + assertEquals(original.uuid, changed.uuid) + assertEquals(original.arrivalPlanned, changed.arrivalPlanned) + assertEquals(original.departurePlanned, changed.departurePlanned) + assertEquals(original.arrivalReal, changed.arrivalReal) + assertEquals(original.departureReal, changed.departureReal) + assertEquals(sourceInfo, changed.arrivalRealtimeInfo) + assertEquals(sourceInfo, changed.departureRealtimeInfo) + // v3 has one platform and prefers departure, so it must not overwrite arrival. + assertNull(changed.arrivalPlatformReal) + assertEquals("5", changed.arrivalPlatformPlanned) + assertEquals("7", changed.departurePlatformReal) + assertEquals("12", changed.departurePlatformPlanned) + assertEquals(sourceInfo, changed.arrivalPlatformRealtimeInfo) + assertEquals("DBF · IRIS", changed.departurePlatformRealtimeInfo?.sourceLabel) + } finally { store.close() } + } + + @Test fun missingRealtimeOrDisabledOrBusOrMissingOperationalNumberMakesNoFalseUpdate() = runBlocking { + val original = stop().copy(arrivalReal = "2026-10-10T17:25:00Z", cancelled = true) + val clock = AtomicLong() + val calls = AtomicInteger() + val store = store(clock) { station -> calls.incrementAndGet(); DbfFetchResult(board(station, missingRealtime = true)) } + try { + val disabled = DbfRealtimeRepository({ false }, store) { now.toEpochMilli() } + assertEquals(listOf(original), disabled.enrich(listOf(original), checkin(original))) + val repo = DbfRealtimeRepository({ true }, store) { now.toEpochMilli() } + assertEquals(listOf(original), repo.enrich(listOf(original), checkin(original).copy(mode = "bus"))) + assertEquals(listOf(original), repo.enrich(listOf(original), checkin(original).copy(journeyNumber = null))) + assertEquals(0, calls.get()) + assertEquals(listOf(original), repo.enrich(listOf(original), checkin(original))) + } finally { store.close() } + } + + @Test fun disablingDuringBoardReadDiscardsResultAndSourceDateDoesNotGetInvented() = runBlocking { + val original = stop() + val clock = AtomicLong() + val entered = CompletableDeferred() + val release = CompletableDeferred() + var enabled = true + val store = store(clock) { station -> entered.complete(Unit); release.await(); DbfFetchResult(board(station)) } + try { + val repo = DbfRealtimeRepository({ enabled }, store) { now.toEpochMilli() } + val read = async { repo.enrich(listOf(original), checkin(original)) } + entered.await() + enabled = false + release.complete(Unit) + assertEquals(listOf(original), read.await()) + enabled = true + val changed = repo.enrich(listOf(original), checkin(original)).single() + assertNull(changed.arrivalRealtimeInfo?.providerUpdatedAtMillis) + assertEquals("2026-10-10T17:15:00Z", changed.arrivalReal) + } finally { store.close() } + } + + @Test fun noDeltaDoesNotRestampRetainedTimesPlatformsOrCancellation() { + val info = StopRealtimeInfo(123L, "Träwelling") + val original = stop().copy(arrivalReal = "2026-10-10T17:25:00Z", arrivalPlatformReal = "4", cancelled = true, + arrivalRealtimeInfo = info, arrivalPlatformRealtimeInfo = info, cancellationRealtimeInfo = info) + val delta = DbfStopDelta(null, null, DbfField.Absent, now) + assertEquals(original, DbfRealtimeRepository.applyDelta(original, delta)) + } + + @Test fun delayedCurrentTrainCanBeDiscoveredWhenPrimaryRealtimeIsMissing() = runBlocking { + val original = stop().copy(arrivalPlanned = "2026-10-10T16:40:00Z", departurePlanned = "2026-10-10T16:41:00Z") + val clock = AtomicLong() + val store = store(clock) { station -> + val delayed = json(arrivalDelay = 43, departureDelay = 43) + .replace("19:12", "18:40").replace("19:13", "18:41") + DbfFetchResult(DbfJsonParser.parse(delayed, station, now)) + } + try { + val repo = DbfRealtimeRepository({ true }, store) { now.toEpochMilli() } + val changed = repo.enrich(listOf(original), checkin(original)).single() + assertEquals("2026-10-10T17:23:00Z", changed.arrivalReal) + assertEquals("2026-10-10T17:24:00Z", changed.departureReal) + assertEquals(original.arrivalPlanned, changed.arrivalPlanned) + } finally { store.close() } + } + + @Test fun unpairedPlatformDeltaCannotCreateCrossProviderPlatformComparison() { + val original = stop().copy(departurePlatformPlanned = "5", departurePlatformReal = "6") + val delta = DbfStopDelta(null, DbfEventDelta(platform = DbfField.Present("7")), DbfField.Absent, now) + assertEquals(original, DbfRealtimeRepository.applyDelta(original, delta)) + } + + private fun store(clock: AtomicLong, cacheLimit: Int = 96, fetch: suspend (String) -> DbfFetchResult): DbfBoardStore = + DbfBoardStore(fetch, CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), clock::get, cacheLimit) + + private fun board(station: String = eva, arrivalDelay: Int = 3, departureDelay: Int = 5, + missingRealtime: Boolean = false, scheduledPlatform: String = "4"): DbfBoard = DbfJsonParser.parse( + json(arrivalDelay, departureDelay, missingRealtime, scheduledPlatform), station, now) + + private fun json(arrivalDelay: Int = 3, departureDelay: Int = 5, missingRealtime: Boolean = false, + scheduledPlatform: String = "4"): String = """ + {"departures":[{"trainType":"RE","trainNumber":"5001","train":"RE 5001", + "scheduledArrival":"19:12","scheduledDeparture":"19:13","delayArrival":$arrivalDelay, + "delayDeparture":$departureDelay,"platform":"7","scheduledPlatform":"$scheduledPlatform", + "isCancelled":0,"missingRealtime":$missingRealtime}]} + """.trimIndent() + + private fun stop(station: String = eva): StopStation = StopStation(uuid = "provider-stop-id", station = TrainStation( + id = 80, name = "Synthetic station", identifiers = listOf(StationIdentifier("de_db_ibnr", station))), + arrivalPlanned = "2026-10-10T17:12:00Z", departurePlanned = "2026-10-10T17:13:00Z", + arrivalPlatformPlanned = "4", departurePlatformPlanned = "4") + + private fun checkin(stop: StopStation): CheckinInfo = CheckinInfo( + hafasId = "synthetic-trip", category = "regional", mode = "train", lineName = "RE 1", distanceMeters = null, + points = null, duration = null, origin = stop, destination = stop, operator = null, trip = 42, tripUuid = null, + number = null, routeColor = null, routeTextColor = null, journeyNumber = 5001, + manualDeparture = null, manualArrival = null) + + private fun response(http: DbfBoardHttp, body: ResponseBody, code: Int = 200): Response = Response.Builder() + .request(http.request(eva)).protocol(Protocol.HTTP_1_1).code(code).message("synthetic").body(body).build() +} diff --git a/app/src/test/kotlin/de/traewelling/app/data/model/StopRealtimeInfoTest.kt b/app/src/test/kotlin/de/traewelling/app/data/model/StopRealtimeInfoTest.kt new file mode 100644 index 0000000..b46934f --- /dev/null +++ b/app/src/test/kotlin/de/traewelling/app/data/model/StopRealtimeInfoTest.kt @@ -0,0 +1,46 @@ +package de.traewelling.app.data.model + +import org.junit.Assert.* +import org.junit.Test + +class StopRealtimeInfoTest { + @Test fun recentReadDoesNotInventAProviderTimestamp() { + val info = StopRealtimeInfo(1_000, "DBF · IRIS") + assertTrue(info.isFresh(121_000)) + assertEquals("DBF · IRIS", info.displayLabel(121_000)) + assertNull(info.providerUpdatedAtMillis) + } + + @Test fun expiredReadKeepsItsSourceButIsMarkedOlder() { + val info = StopRealtimeInfo(1_000, "DBF · IRIS") + assertFalse(info.isFresh(121_001)) + assertEquals("DBF · IRIS · älterer Abruf", info.displayLabel(121_001)) + } + + @Test fun missingOrFutureReadTimeCannotProveFreshness() { + assertEquals("Träwelling · Aktualität unbekannt", StopRealtimeInfo(0, "Träwelling").displayLabel(1_000)) + assertEquals("DBF · IRIS · Aktualität unbekannt", StopRealtimeInfo(1_001, "DBF · IRIS").displayLabel(1_000)) + } + + @Test fun successfulTraewellingReadLabelsBothEventsAndCancellation() { + val stop = StopStation(arrivalReal = "2026-10-10T17:00:00Z").withTraewellingReadInfo(1_000) + assertEquals(StopRealtimeInfo(1_000, "Träwelling"), stop.arrivalRealtimeInfo) + assertEquals(stop.arrivalRealtimeInfo, stop.departureRealtimeInfo) + assertEquals(stop.arrivalRealtimeInfo, stop.cancellationRealtimeInfo) + } + + @Test fun legacyCacheRemainsUnknownWithoutChangingTheValues() { + val original = StopStation(arrivalReal = "2026-10-10T17:00:00Z", arrivalPlatformReal = "7") + val restored = original.withUnknownReadInfo() + assertEquals(original.arrivalReal, restored.arrivalReal) + assertEquals(original.arrivalPlatformReal, restored.arrivalPlatformReal) + assertFalse(restored.arrivalRealtimeInfo!!.isFresh(1_000)) + } + + @Test fun cacheRestorePreservesOriginalReadTimes() { + val info = StopRealtimeInfo(1_000, "DBF · IRIS") + val restored = StopStation(arrivalRealtimeInfo = info).withUnknownReadInfo() + assertEquals(info, restored.arrivalRealtimeInfo) + assertEquals(0L, restored.departureRealtimeInfo!!.fetchedAtMillis) + } +} diff --git a/app/src/test/kotlin/de/traewelling/app/data/repository/PublicRealtimeSessionTest.kt b/app/src/test/kotlin/de/traewelling/app/data/repository/PublicRealtimeSessionTest.kt new file mode 100644 index 0000000..35ff95a --- /dev/null +++ b/app/src/test/kotlin/de/traewelling/app/data/repository/PublicRealtimeSessionTest.kt @@ -0,0 +1,187 @@ +package de.traewelling.app.data.repository + +import com.google.gson.Gson +import de.traewelling.app.data.api.TraewellingApiService +import de.traewelling.app.data.local.StatusDao +import de.traewelling.app.data.local.StatusEntity +import de.traewelling.app.data.model.* +import de.traewelling.app.util.AuthSession +import de.traewelling.app.viewmodel.readConsistentStatusDetail +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test +import retrofit2.Response + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class PublicRealtimeSessionTest { + @Test fun aLatePublicReplyCannotPublishAfterLogoutAndSameCredentialsRelogin() = runTest { + val fixture = Fixture() + val originalSession = fixture.session + val entered = CompletableDeferred() + val release = CompletableDeferred() + fixture.enrich = { stops, _ -> + entered.complete(Unit) + release.await() + stops.map { it.copy(departurePlatformReal = "9") } + } + val originalStops = listOf(fixture.stop) + val request = async { fixture.repository.enrichStopovers(originalStops, fixture.status.checkin!!) } + entered.await() + fixture.session = originalSession.copy(accessToken = null, revision = "logged-out") + fixture.session = originalSession.copy(revision = "new-login-same-token") + release.complete(Unit) + request.join() + assertTrue(request.isCancelled) + assertNull(originalStops.single().departurePlatformReal) + assertTrue(fixture.apiCalls.isEmpty()) + } + + @Test fun aLatePublicReplyCannotPublishWhileLoggedOut() = runTest { + val fixture = Fixture() + val entered = CompletableDeferred() + val release = CompletableDeferred() + fixture.enrich = { stops, _ -> entered.complete(Unit); release.await(); stops } + val request = async { fixture.repository.enrichStopovers(listOf(fixture.stop), fixture.status.checkin!!) } + entered.await() + fixture.session = fixture.session.copy(accessToken = null, revision = "logged-out") + release.complete(Unit) + request.join() + assertTrue(request.isCancelled) + } + + @Test fun successfulStopoverGetStampsTheReadWithoutInventingAProviderUpdate() = runTest { + val fixture = Fixture() + val oldProviderInfo = StopRealtimeInfo(1L, "Old provider", providerUpdatedAtMillis = 2L) + fixture.stop = fixture.stop.copy( + arrivalReal = "2026-10-10T12:07:00Z", + arrivalRealtimeInfo = oldProviderInfo, + departureRealtimeInfo = oldProviderInfo, + cancellationRealtimeInfo = oldProviderInfo, + arrivalPlatformRealtimeInfo = oldProviderInfo, + departurePlatformRealtimeInfo = oldProviderInfo + ) + val before = System.currentTimeMillis() + val actual = fixture.repository.getStopovers(9).getOrThrow().single() + val after = System.currentTimeMillis() + listOf(actual.arrivalRealtimeInfo, actual.departureRealtimeInfo, actual.cancellationRealtimeInfo, + actual.arrivalPlatformRealtimeInfo, actual.departurePlatformRealtimeInfo).forEach { nullableInfo -> + val info = requireNotNull(nullableInfo) + assertTrue(info.fetchedAtMillis in before..after) + assertEquals("Träwelling", info.sourceLabel) + assertNull(info.providerUpdatedAtMillis) + } + assertEquals(fixture.stop.arrivalReal, actual.arrivalReal) + assertEquals(listOf("GET stopovers"), fixture.apiCalls) + } + + @Test fun publicEnrichmentChangesOnlyTheLocalDisplayAndDoesNotSendStatusWrites() = runTest { + val fixture = Fixture() + val serverStatusBefore = fixture.status + val supplied = fixture.repository.getStopovers(9).getOrThrow() + val supplementalInfo = StopRealtimeInfo(System.currentTimeMillis(), "DBF / DB IRIS") + fixture.enrich = { stops, _ -> stops.map { + it.copy(departureReal = "2026-10-10T12:08:00Z", departurePlatformReal = "7", + departureRealtimeInfo = supplementalInfo, departurePlatformRealtimeInfo = supplementalInfo) + } } + val displayed = fixture.repository.enrichStopovers(supplied, fixture.status.checkin!!).single() + assertEquals("7", displayed.departurePlatformReal) + assertEquals("2026-10-10T12:08:00Z", displayed.departureReal) + assertNull(supplied.single().departurePlatformReal) + assertSame(serverStatusBefore, fixture.status) + // An unintended status PUT would appear here; unsupported service calls fail immediately. + assertEquals(listOf("GET stopovers"), fixture.apiCalls) + assertEquals(0, fixture.dao.clears) + } + + @Test fun aTimeCorrectionWhileTheSupplementWaitsRetriesTheEntireDetailSnapshot() = runTest { + val fixture = Fixture() + val heldPublicReply = CompletableDeferred() + val publicEntered = CompletableDeferred() + var supplementalReads = 0 + fixture.enrich = { stops, _ -> + ++supplementalReads + if (supplementalReads == 1) { + publicEntered.complete(Unit) + heldPublicReply.await() + stops.map { it.copy(departurePlatformReal = "old-platform") } + } else stops.map { it.copy(departurePlatformReal = "new-platform") } + } + var snapshotCheckin: CheckinInfo? = null + val capturedSession = fixture.session + val reading = async { + readConsistentStatusDetail( + readStatus = { + fixture.repository.getStatusDetailSnapshot(42).also { + snapshotCheckin = it.getOrNull()?.status?.checkin + } + }, + readStopovers = { tripId -> + val stops = fixture.repository.getStopovers(tripId) + if (stops.isSuccess) Result.success(fixture.repository.enrichStopovers( + stops.getOrThrow(), requireNotNull(snapshotCheckin) + )) else stops + }, + isCurrentRevision = { expected -> + fixture.repository.getStatusMutationSnapshot(42, capturedSession).revision == expected + } + ) + } + publicEntered.await() + fixture.repository.updateStatus(42, UpdateStatusRequest(arrival = "2026-10-10T12:15:00Z")).getOrThrow() + heldPublicReply.complete(Unit) + runCurrent() + val result = reading.await().getOrThrow() + assertEquals("2026-10-10T12:15:00Z", result.status.checkin?.manualArrival) + assertEquals("new-platform", result.stopovers?.getOrThrow()?.single()?.departurePlatformReal) + assertEquals(2, supplementalReads) + assertEquals(2, fixture.apiCalls.count { it == "GET status" }) + assertEquals(2, fixture.apiCalls.count { it == "GET stopovers" }) + assertEquals(1, fixture.apiCalls.count { it == "PUT status" }) // Only the explicit correction. + } + + private class Fixture { + var session = AuthSession("https://example.test", "synthetic-test-token", "original-login") + val mutations = StatusMutationStore() + val dao = Dao() + val apiCalls = mutableListOf() + var status: Status = Gson().fromJson( + """{"id":42,"checkin":{"trip":9,"lineName":"RE 1","number":"4306","manualArrival":null}}""", + Status::class.java + ) + var stop = StopStation(id = 1, station = TrainStation(id = 11, name = "Test station"), + arrivalPlanned = "2026-10-10T12:00:00Z", departurePlanned = "2026-10-10T12:02:00Z") + var enrich: suspend (List, CheckinInfo) -> List = { stops, _ -> stops } + private val api = object : TraewellingApiService by unusedService() { + override suspend fun getStopovers(tripId: Int): Response { + assertEquals(9, tripId) + apiCalls += "GET stopovers" + return Response.success(StopoversResponse(mapOf("9" to listOf(stop)))) + } + override suspend fun getStatus(id: Int): Response { + assertEquals(42, id) + apiCalls += "GET status" + return Response.success(SingleStatusResponse(status)) + } + override suspend fun updateStatus(id: Int, request: UpdateStatusRequest): Response { + assertEquals(42, id) + apiCalls += "PUT status" + status = status.copy(checkin = status.checkin!!.copy(manualArrival = request.arrival)) + return Response.success(SingleStatusResponse(status)) + } + } + val repository = TraewellingRepository(dao, { session }, { api }, mutations, + enrichRealtime = { stops, checkin -> enrich(stops, checkin) }) + } + + private class Dao : StatusDao { + var clears = 0 + override suspend fun getStatuses(type: String): List = error("Supplement must not read a feed cache") + override suspend fun insertStatuses(statuses: List) = error("Supplement must not write a feed cache") + // Only an explicit correction may invalidate its known feed partitions. + override suspend fun clearStatuses(type: String) { ++clears } + override suspend fun replaceStatuses(type: String, statuses: List) = error("Supplement must not write a feed cache") + } +} diff --git a/app/src/test/kotlin/de/traewelling/app/service/JourneyTimeResolverTest.kt b/app/src/test/kotlin/de/traewelling/app/service/JourneyTimeResolverTest.kt index bec2b4d..47dcd1c 100644 --- a/app/src/test/kotlin/de/traewelling/app/service/JourneyTimeResolverTest.kt +++ b/app/src/test/kotlin/de/traewelling/app/service/JourneyTimeResolverTest.kt @@ -2,6 +2,7 @@ package de.traewelling.app.service import de.traewelling.app.data.model.StopStation import de.traewelling.app.data.model.TrainStation +import de.traewelling.app.data.model.StopRealtimeInfo import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -174,6 +175,18 @@ class JourneyTimeResolverTest { iso(plannedDeparture - MINUTE), iso(plannedArrival - MINUTE))) } + @Test fun realtimeSourceUsesTheMatchingEventReadTime() { + val labelled = stop.copy(arrivalRealtimeInfo = StopRealtimeInfo(now, "DBF · IRIS"), + departureRealtimeInfo = StopRealtimeInfo(now - 120_001, "Träwelling")) + assertEquals("DBF · IRIS", JourneyTimeResolver.arrival(labelled, null, now)!!.sourceLabel) + assertEquals("Träwelling · älterer Abruf", JourneyTimeResolver.departure(labelled, null, now)!!.sourceLabel) + } + + @Test fun freshGpsKeepsItsOwnSourceAfterPublicRailEnrichment() { + val labelled = stop.copy(arrivalRealtimeInfo = StopRealtimeInfo(now, "DBF · IRIS")) + assertEquals("GPS-Schätzung", JourneyTimeResolver.arrival(labelled, gps(), now)!!.sourceLabel) + } + private fun gps(arrival: Long? = plannedArrival - 2 * MINUTE, departure: Long? = plannedDeparture - 2 * MINUTE, arrivalObserved: Boolean = false, departureObserved: Boolean = false): GpsJourneyTimes = diff --git a/app/src/test/kotlin/de/traewelling/app/service/TripChangeMonitorTest.kt b/app/src/test/kotlin/de/traewelling/app/service/TripChangeMonitorTest.kt index 98de083..f322214 100644 --- a/app/src/test/kotlin/de/traewelling/app/service/TripChangeMonitorTest.kt +++ b/app/src/test/kotlin/de/traewelling/app/service/TripChangeMonitorTest.kt @@ -2,6 +2,7 @@ package de.traewelling.app.service import de.traewelling.app.data.model.CheckinInfo import de.traewelling.app.data.model.StopStation +import de.traewelling.app.data.model.StopRealtimeInfo import de.traewelling.app.data.model.TrainStation import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -297,6 +298,82 @@ class TripChangeMonitorTest { monitor.observe(snapshot(listOf(stop(delay = 15), stop("exit", delay = null, destination = true)), 4)).single().kind) } + @Test fun switchingBetweenDbfAndFallbackDoesNotExplainOldPlatformsOrDelaysAsChanges() { + val monitor = TripChangeMonitor() + monitor.observe(snapshot(listOf(stop(delay = 15, platform = "7").fromSource("DBF / DB IRIS")), 1)) + assertTrue(monitor.observe(snapshot(listOf(stop(delay = 0, platform = "2").fromSource("Träwelling")), 2)).isEmpty()) + assertTrue(monitor.observe(snapshot(listOf(stop(delay = 15, platform = "7").fromSource("DBF / DB IRIS")), 3)).isEmpty()) + val changes = monitor.observe(snapshot(listOf(stop(delay = 20, platform = "9").fromSource("DBF / DB IRIS")), 4)) + assertEquals(listOf(TripChangeKind.PLATFORM, TripChangeKind.DELAY_INCREASE), changes.map { it.kind }) + assertTrue(changes.last().message.contains("5 Minuten später als zuletzt gemeldet")) + } + + @Test fun aProviderSwitchClearsOldPlatformGapWithoutDisablingLaterSameProviderChanges() { + val monitor = TripChangeMonitor() + val liveDbf = stop(platform = "7").fromSource("DBF").copy(arrivalPlatformIsLive = true) + val dbfGap = stop(platform = "2").fromSource("DBF").copy(arrivalPlatformIsLive = false) + val liveFallback = stop(platform = "3").fromSource("Träwelling").copy(arrivalPlatformIsLive = true) + monitor.observe(snapshot(listOf(liveDbf), 1)) + assertTrue(monitor.observe(snapshot(listOf(dbfGap), 2)).isEmpty()) + assertTrue(monitor.observe(snapshot(listOf(liveFallback), 3)).isEmpty()) + assertEquals(TripChangeKind.PLATFORM, + monitor.observe(snapshot(listOf(liveFallback.copy(arrivalPlatform = "4")), 4)).single().kind) + } + + @Test fun providerFallbackCannotRestoreACancelledStopButNewConfirmedCancellationStillWarns() { + val monitor = TripChangeMonitor() + monitor.observe(snapshot(listOf(stop(cancelled = true).fromSource("DBF")), 1)) + assertTrue(monitor.observe(snapshot(listOf(stop(cancelled = false).fromSource("Träwelling")), 2)).isEmpty()) + // A new positive cancellation remains important even when it comes from another source. + assertEquals(TripChangeKind.CANCELLED, + monitor.observe(snapshot(listOf(stop(cancelled = true).fromSource("DBF")), 3)).single().kind) + assertTrue(monitor.observe(snapshot(listOf(stop(cancelled = true).fromSource("DBF")), 4)).isEmpty()) + assertEquals(TripChangeKind.RESTORED, + monitor.observe(snapshot(listOf(stop(cancelled = false).fromSource("DBF")), 5)).single().kind) + } + + @Test fun apiSnapshotKeepsPerEventSourcesAndAnUnrelatedDepartureSourceDoesNotHideArrivalChanges() { + val raw = StopStation(uuid = "exit", station = TrainStation(id = 7, name = "Station"), + arrivalPlanned = "2026-10-10T10:00:00Z", arrivalReal = "2026-10-10T10:05:00Z", + departurePlanned = "2026-10-10T10:06:00Z", departureReal = "2026-10-10T10:11:00Z", + arrivalPlatformReal = "2", departurePlatformReal = "7", cancelled = false, + arrivalRealtimeInfo = StopRealtimeInfo(1, "DBF"), + departureRealtimeInfo = StopRealtimeInfo(1, "Träwelling"), + arrivalPlatformRealtimeInfo = StopRealtimeInfo(1, "DBF"), + departurePlatformRealtimeInfo = StopRealtimeInfo(1, "Träwelling"), + cancellationRealtimeInfo = StopRealtimeInfo(1, "DBF")) + val initial = TripChangeSnapshot.fromApi(42, 1, listOf(raw), checkin(raw), 0) + with(initial.stops.single()) { + assertEquals("DBF", arrivalTimeSourceLabel) + assertEquals("Träwelling", departureTimeSourceLabel) + assertEquals("DBF", arrivalPlatformSourceLabel) + assertEquals("Träwelling", departurePlatformSourceLabel) + assertEquals("DBF", cancellationSourceLabel) + } + val monitor = TripChangeMonitor() + monitor.observe(initial) + val changed = raw.copy(arrivalReal = "2026-10-10T10:10:00Z", arrivalPlatformReal = "4", + departureRealtimeInfo = StopRealtimeInfo(2, "DBF"), + departurePlatformRealtimeInfo = StopRealtimeInfo(2, "DBF")) + assertEquals(listOf(TripChangeKind.PLATFORM, TripChangeKind.DELAY_INCREASE), + monitor.observe(TripChangeSnapshot.fromApi(42, 2, listOf(changed), checkin(raw), 0)).map { it.kind }) + } + + @Test fun unknownProvenanceKeepsExistingComparisonBehaviorWhenOnlyOneSourceIsKnown() { + val monitor = TripChangeMonitor() + monitor.observe(snapshot(listOf(stop()), 1)) + assertEquals(listOf(TripChangeKind.PLATFORM, TripChangeKind.DELAY_INCREASE), + monitor.observe(snapshot(listOf(stop(delay = 5, platform = "4").fromSource("DBF")), 2)).map { it.kind }) + assertEquals(listOf(TripChangeKind.PLATFORM, TripChangeKind.DELAY_INCREASE), + monitor.observe(snapshot(listOf(stop(delay = 10, platform = "5")), 3)).map { it.kind }) + } + + private fun TripChangeStop.fromSource(source: String): TripChangeStop = copy( + arrivalTimeSourceLabel = source, departureTimeSourceLabel = source, + arrivalPlatformSourceLabel = source, departurePlatformSourceLabel = source, + cancellationSourceLabel = source + ) + private fun checkin(destination: StopStation, manualArrival: String? = null): CheckinInfo = CheckinInfo( hafasId = null, category = null, mode = null, lineName = "RE 1", distanceMeters = null, points = null, duration = null, origin = null, destination = destination, operator = null, trip = 1, tripUuid = null, diff --git a/app/src/test/kotlin/de/traewelling/app/viewmodel/RealtimePresentationTest.kt b/app/src/test/kotlin/de/traewelling/app/viewmodel/RealtimePresentationTest.kt new file mode 100644 index 0000000..2e04eb7 --- /dev/null +++ b/app/src/test/kotlin/de/traewelling/app/viewmodel/RealtimePresentationTest.kt @@ -0,0 +1,111 @@ +package de.traewelling.app.viewmodel + +import de.traewelling.app.data.model.StopRealtimeInfo +import de.traewelling.app.data.model.StopStation +import de.traewelling.app.service.JourneyTime +import de.traewelling.app.service.JourneyTimeSource +import org.junit.Assert.* +import org.junit.Test + +class RealtimePresentationTest { + private val now = 1_000_000L + private val iris = StopRealtimeInfo(now - 5_000L, "DBF · IRIS") + private val traewelling = StopRealtimeInfo(now - 30_000L, "Träwelling") + + @Test fun retrievalTimestampDoesNotPromiseProviderFreshness() { + val recent = realtimeRetrievalPresentation(iris.copy(providerUpdatedAtMillis = 1L), now) + assertTrue(recent.recentlyRetrieved) + assertEquals("Daten abgerufen", recent.label) + assertEquals("DBF · IRIS · abgerufen vor 5 s", recent.detail) + assertFalse(recent.label.contains("live", ignoreCase = true)) + } + + @Test fun missingFutureAndOldReadTimesAreUnconfirmedOrStale() { + assertFalse(realtimeRetrievalPresentation(null, now).recentlyRetrieved) + assertEquals("Abruf nicht bestätigt", realtimeRetrievalPresentation(null, now).label) + assertFalse(realtimeRetrievalPresentation(iris.copy(fetchedAtMillis = 0L), now).recentlyRetrieved) + assertFalse(realtimeRetrievalPresentation(iris.copy(fetchedAtMillis = now + 1L), now).recentlyRetrieved) + assertEquals("Abruf veraltet", + realtimeRetrievalPresentation(iris.copy(fetchedAtMillis = now - 120_001L), now).label) + assertTrue(realtimeRetrievalPresentation(iris.copy(fetchedAtMillis = now - 120_000L), now).recentlyRetrieved) + } + + @Test fun failedRefreshDoesNotRelabelRetainedReadsAsFresh() { + val result = realtimeRetrievalPresentation(iris, now, refreshFailed = true) + assertFalse(result.recentlyRetrieved) + assertEquals("Offline / Abruffehler", result.label) + assertTrue(result.detail.endsWith("abgerufen vor 5 s")) + } + + @Test fun platformsCompareTheSelectedEventWithItsOwnPlan() { + val stop = StopStation(arrivalPlatformPlanned = "5", arrivalPlatformReal = "7", + departurePlatformPlanned = "1", departurePlatformReal = "2") + assertEquals(PlatformPresentation("Gleis 7 statt 5", true), platformPresentation(stop, StopEvent.ARRIVAL)) + assertEquals(PlatformPresentation("Gleis 2 statt 1", true), platformPresentation(stop, StopEvent.DEPARTURE)) + } + + @Test fun missingArrivalCannotBorrowTheDeparturePlatform() { + val stop = StopStation(departurePlatformPlanned = "1", departurePlatformReal = "2") + assertNull(platformPresentation(stop, StopEvent.ARRIVAL)) + assertEquals("Gleis 2 statt 1", platformPresentation(stop, StopEvent.DEPARTURE)?.label) + } + + @Test fun platformPrefixesAndWhitespaceDoNotFabricateChanges() { + val stop = StopStation(arrivalPlatformPlanned = " Gl. 05 A ", arrivalPlatformReal = "Gleis 05 A") + assertEquals(PlatformPresentation("Gleis 05 A", false), platformPresentation(stop, StopEvent.ARRIVAL)) + assertEquals("Gleis 05 A", platformPresentation(stop.copy(arrivalPlatformReal = " "), StopEvent.ARRIVAL)?.label) + assertEquals("Gleis 4 Süd", platformPresentation(StopStation(platform = "4 Süd"), StopEvent.DEPARTURE)?.label) + } + + @Test fun eventAndPlatformProvenanceStayIndependent() { + val stop = StopStation(arrivalRealtimeInfo = traewelling, departureRealtimeInfo = iris, + arrivalPlatformRealtimeInfo = iris, departurePlatformRealtimeInfo = traewelling) + assertSame(traewelling, eventRealtimeInfo(stop, StopEvent.ARRIVAL)) + assertSame(iris, eventRealtimeInfo(stop, StopEvent.DEPARTURE)) + assertSame(iris, eventPlatformRealtimeInfo(stop, StopEvent.ARRIVAL)) + assertSame(traewelling, eventPlatformRealtimeInfo(stop, StopEvent.DEPARTURE)) + } + + @Test fun freshDepartureDoesNotHideAnOldArrival() { + val stop = StopStation(arrivalRealtimeInfo = traewelling.copy(fetchedAtMillis = now - 120_001L), + departureRealtimeInfo = iris) + val summary = stopoversRetrievalPresentation(listOf(stop), now, false) + assertFalse(summary.recentlyRetrieved) + assertEquals("Abruf veraltet", summary.label) + assertTrue(summary.detail.startsWith("Träwelling + DBF · IRIS")) + } + + @Test fun stalePlatformReadIsIncludedInTheSummary() { + val stop = StopStation(arrivalRealtimeInfo = iris, departureRealtimeInfo = iris, + arrivalPlatformRealtimeInfo = traewelling.copy(fetchedAtMillis = now - 130_000L)) + assertEquals("Abruf veraltet", stopoversRetrievalPresentation(listOf(stop), now, false).label) + } + + @Test fun unknownArrivalMetadataCannotBeHiddenByAFreshDeparture() { + val stop = StopStation(arrivalPlanned = "2026-10-10T12:00:00Z", departureRealtimeInfo = iris) + assertEquals("Abruf nicht bestätigt", stopoversRetrievalPresentation(listOf(stop), now, false).label) + } + + @Test fun anyFutureTimestampMakesTheSummaryUnconfirmed() { + val stop = StopStation(arrivalRealtimeInfo = traewelling, + departureRealtimeInfo = iris.copy(fetchedAtMillis = now + 1L)) + assertEquals("Abruf nicht bestätigt", stopoversRetrievalPresentation(listOf(stop), now, false).label) + } + + @Test fun combinedSourceLabelsDoNotDuplicateTheirSeparatorSuffix() { + val stop = StopStation(arrivalRealtimeInfo = iris, departureRealtimeInfo = iris) + assertEquals("DBF · IRIS · abgerufen vor 5 s", + stopoversRetrievalPresentation(listOf(stop), now, false).detail) + } + + @Test fun gpsAndManualDisplaySourcesNeverAcquireProviderProvenance() { + for (source in listOf(JourneyTimeSource.GPS_OBSERVED, JourneyTimeSource.GPS_ESTIMATE, + JourneyTimeSource.MANUAL, JourneyTimeSource.TIMETABLE)) { + val time = JourneyTime(now, source, now) + assertEquals(time.sourceLabel, journeyTimeSourceLabel(time, iris, now, false)) + } + val apiTime = JourneyTime(now, JourneyTimeSource.API_REALTIME, now) + assertTrue(journeyTimeSourceLabel(apiTime, iris, now, false).startsWith("DBF · IRIS")) + assertTrue(journeyTimeSourceLabel(apiTime, iris, now, true).contains("abruffehler")) + } +} diff --git a/app/src/test/resources/dbf/README.md b/app/src/test/resources/dbf/README.md new file mode 100644 index 0000000..8f82728 --- /dev/null +++ b/app/src/test/resources/dbf/README.md @@ -0,0 +1,17 @@ +# DBF JSON v3 fixture + +`public-hannover-v3.json` contains the first two unchanged rows captured from +`https://dbf.finalrewind.org/8000152.json?version=3` on 2026-10-10. +The HTTP Date was `Sat, 10 Oct 2026 17:26:55 GMT`. Other rows were omitted. +This original capture does not claim station identity for production: the app +requests `no_related=1` to exclude nearby stations. + +Data: Deutsche Bahn, via DBF / IRIS; Timetables data licensed CC BY 4.0. +DBF JSON implementation: , +`lib/DBInfoscreen/Controller/Stationboard.pm`, IRIS v3 branch. DBF program code +is AGPL-3.0-or-later; Routely implements its own parser and does not copy DBF code. + +The schema has no station EVA, date, trip ID or provider prediction timestamp. +Planned times are HH:mm, and message timestamps are message creation times. +Synthetic test mutations cover unknown realtime, zero-time fallbacks, platform +changes, whole-stop cancellation, partial-event matching and ambiguous dates. diff --git a/app/src/test/resources/dbf/public-hannover-v3.json b/app/src/test/resources/dbf/public-hannover-v3.json new file mode 100644 index 0000000..e7bedc9 --- /dev/null +++ b/app/src/test/resources/dbf/public-hannover-v3.json @@ -0,0 +1,143 @@ +{ + "departures": [ + { + "delayArrival": 3, + "delayDeparture": null, + "destination": "Hannover Hbf", + "isCancelled": 0, + "messages": { + "delay": [ + { + "text": "Vorfahrt eines anderen Zuges", + "timestamp": "2026-10-10T19:20:00" + } + ], + "qos": [ + { + "text": "Ein Wagen fehlt", + "timestamp": "2026-10-10T14:43:00" + } + ] + }, + "missingRealtime": false, + "platform": "13", + "route": [ + { + "name": "Hildesheim Hbf" + }, + { + "name": "Harsum" + }, + { + "name": "Algermissen" + }, + { + "name": "Sehnde" + }, + { + "name": "Lehrte" + }, + { + "name": "Ahlten" + }, + { + "name": "Hannover Anderten-Misburg" + }, + { + "name": "Hannover Karl-Wiechert-Allee" + }, + { + "name": "Hannover-Kleefeld" + }, + { + "name": "Hannover Hbf" + } + ], + "scheduledArrival": "19:24", + "scheduledDeparture": null, + "scheduledPlatform": "13", + "train": "SBH S3", + "trainClasses": [], + "trainNumber": "34328", + "via": [] + }, + { + "delayArrival": 2, + "delayDeparture": 2, + "destination": "Hameln", + "isCancelled": 0, + "messages": { + "delay": [], + "qos": [ + { + "text": "1. Klasse fehlt", + "timestamp": "2026-10-10T15:06:00" + } + ] + }, + "missingRealtime": false, + "platform": "1", + "route": [ + { + "name": "Hannover Flughafen" + }, + { + "name": "Langenhagen Pferdemarkt" + }, + { + "name": "Langenhagen Mitte" + }, + { + "name": "Hannover-Vinnhorst" + }, + { + "name": "Hannover-Ledeburg" + }, + { + "name": "Hannover-Nordstadt" + }, + { + "name": "Hannover Hbf" + }, + { + "name": "Hannover Bismarckstr." + }, + { + "name": "Hannover-Linden/Fischerhof" + }, + { + "name": "Weetzen" + }, + { + "name": "Holtensen/Linderte" + }, + { + "name": "Bennigsen" + }, + { + "name": "Völksen/Eldagsen" + }, + { + "name": "Springe" + }, + { + "name": "Bad Münder(Deister)" + }, + { + "name": "Hameln" + } + ], + "scheduledArrival": "19:23", + "scheduledDeparture": "19:25", + "scheduledPlatform": "1", + "train": "SBH S5", + "trainClasses": [], + "trainNumber": "89463", + "via": [ + "Hannover Bismarckstr.", + "Hannover-Linden/Fischerhof", + "Weetzen" + ] + } + ] +} diff --git a/docs/wiki/api/externe-schnittstellen.md b/docs/wiki/api/externe-schnittstellen.md index 74926a7..29d906c 100644 --- a/docs/wiki/api/externe-schnittstellen.md +++ b/docs/wiki/api/externe-schnittstellen.md @@ -10,7 +10,7 @@ Die primäre externe Schnittstelle ist die Träwelling RESTful API. - **Basis-URL**: Laufzeitwert aus `PreferencesManager`, Standard `https://traewelling.de`. Der manuelle Login erlaubt eine gültige HTTPS-Server-URL ohne eingebettete Zugangsdaten, Query oder Fragment; keine Gradle-Property setzt den API-Server. - **Authentifizierung**: Geschützte Endpunkte erhalten einen Bearer-Token. Der erreichbare Login nimmt ihn manuell entgegen und prüft `GET /api/v1/auth/user`, bevor die Sitzung gespeichert wird. `OAuthApiService` (`POST /oauth/token`) enthält zusätzliche Austausch-/Refresh-Helfer, die noch nicht an den Login oder einen automatischen Refresh angebunden sind. -- **Provider (Transitous/HAFAS)**: Die Träwelling API greift intern auf Transit-Provider (wie HAFAS) zu, um Abfahrten und Trips zu liefern. +- **Provider (Transitous/MOTIS)**: Der am 10.10.2026 geprüfte Träwelling-Upstream verwendet Transitous für Stations-, Abfahrts- und Fahrtdaten. Die alten `hafasTripId`-Bezeichnungen im Clientvertrag belegen keinen aktuellen DB-HAFAS-Zugriff. Routely `main` enthält keinen direkten Transitous-Live-Datenclient. Quellenvergleich, Backend-Aktualisierung und offene Aktualitätsgrenzen stehen unter [Live-Datenquellen](./live-datenquellen.md). Besonderheiten beim Umgang mit den von Träwelling gelieferten Transit-Daten: @@ -23,6 +23,10 @@ Besonderheiten beim Umgang mit den von Träwelling gelieferten Transit-Daten: API- und OAuth-Clients begrenzen den gesamten HTTP-Call auf 60 Sekunden sowie Connect-, Read- und Write-Phasen auf jeweils 30 Sekunden. Coroutine-Abbruch wird unverändert weitergereicht; Timeout und vorübergehender Netzfehler machen eine fehlgeschlagene Authprüfung nicht zu einem Logout. +## Anonyme DBF-/IRIS-Livedaten + +Die zusätzliche [Bahn-Livedatenquelle](./live-datenquellen.md) verwendet `GET https://dbf.finalrewind.org/.json?version=3&no_related=1&past=1` mit einem getrennten anonymen OkHttp-Client. Zugangsdaten, Träwelling-Bearer und Gerätepositionen werden nicht übertragen. Die offene DBF-Software verarbeitet IRIS-Tafeln. Die App begrenzt Starts auf zehn/min und einen je EVA/min, teilt Cache und laufende Abrufe und behält bei fehlenden eindeutigen Zusatzdaten die Träwelling-Werte. Quelle und Client-Abrufzeit sind pro Zeit-/Gleis-/Ausfallfeld getrennt; sie belegen keinen Provider-Änderungszeitpunkt. Der standardmäßig aktive Zusatzabruf ist in den Einstellungen abschaltbar. + ## Träwelling-Streckenverlauf für Bahn und Tram Der native Vertrag `GET /api/v1/polyline/{statusId}` liefert den zum Check-in gehörenden Linienzug als `data`-GeoJSON-`FeatureCollection`. Ein Feature enthält `geometry.type = LineString`, Koordinaten in der Reihenfolge Längengrad, Breitengrad und `properties.statusId`. Die Kennung ist die numerische Status-ID, weder Trip-ID noch Stopover-UUID. Der Backend-Endpunkt erlaubt mehrere Status-IDs; die aktive Begleitung benötigt nur die eigene Fahrt. Unsichtbare beziehungsweise nicht gefundene Status können mit HTTP 200 und leerer Featureliste antworten; das ist kein verfügbarer Streckenverlauf. diff --git a/docs/wiki/api/live-datenquellen.md b/docs/wiki/api/live-datenquellen.md new file mode 100644 index 0000000..6eb43f4 --- /dev/null +++ b/docs/wiki/api/live-datenquellen.md @@ -0,0 +1,93 @@ +# Live-Datenquellen und Aktualität + +## Zweck + +Zusätzliche Bahn-Livedaten für Gleisänderungen, Verspätungen und Haltausfälle ohne Registrierung oder API-Schlüssel. Umsetzung und Quellenprüfung: 10.10.2026, auf Routely `main` bei `8ecd87b1727d7a95d6fb09d40bb8541ece0c4695`. + +## Kontext + +Träwelling bleibt für Konto, Check-ins, Fahrtidentität und vollständige Haltfolge zuständig. Routely ergänzt aktuelle eindeutig zuordenbare Bahn-Halte über die öffentliche [DBF-Abfahrtstafel](https://dbf.finalrewind.org/). Der Dienst verwendet IRIS und besitzt [offenen Quellcode](https://github.com/derf/db-fakedisplay). Die App benötigt dafür keinen DB-Marketplace-Zugang, API-Key oder eigenen Proxy. + +Die ursprüngliche Empfehlung für den offiziellen DB-Timetables-Zugang wurde nach der Nutzerpräzisierung verworfen: Dieser Zugang benötigt Registrierung und Anwendungsschlüssel. RIS::Journeys/Boards benötigen zusätzlich Freigabe und Vertrag. Der anonyme Dienst `v6.db.transport.rest` antwortete im Entwicklungsabruf mit HTTP 503; seine aktuellen DB-Vendo-Daten liefern in Bahnhofstafeln außerdem keine durchgehend belastbare operative Zugnummer für den hier benötigten Abgleich. Diese Dienste werden nicht eingebaut. + +## Wichtige Dateien + +- `app/src/main/kotlin/de/traewelling/app/data/dbf/DbfRealtimeRepository.kt` +- `app/src/main/kotlin/de/traewelling/app/data/model/StopRealtimeInfo.kt` +- `app/src/main/kotlin/de/traewelling/app/data/model/Models.kt` +- `app/src/main/kotlin/de/traewelling/app/data/repository/TraewellingRepository.kt` +- `app/src/main/kotlin/de/traewelling/app/service/TripTrackingService.kt` +- `app/src/main/kotlin/de/traewelling/app/service/JourneyTimeResolver.kt` +- `app/src/main/kotlin/de/traewelling/app/viewmodel/RealtimePresentation.kt` +- `app/src/main/kotlin/de/traewelling/app/viewmodel/StatusDetailViewModel.kt` +- `app/src/main/kotlin/de/traewelling/app/ui/screens/StatusDetailScreen.kt` +- `app/src/main/kotlin/de/traewelling/app/util/PreferencesManager.kt` + +## Warum eine zusätzliche Quelle? + +Träwelling bezieht Fahrplandaten selbst von Transitous. Die am 10.10.2026 untersuchte veröffentlichte Backend-Revision `4d602796da8409017314cc771b1127d169155f02` liest im Stopovers-Endpunkt gespeicherte Werte. Sein minütlicher Scheduler wählt eingecheckte aktive Fahrten; der Default-Mindestabstand für die nächste Transitous-Aktualisierung ist zwei Minuten. Eine HTTP-Anfrage an Träwelling erzwingt keine neue Providerabfrage. Eingesetzte Produktionsrevision, Konfiguration und Queue-Laufzeiten wurden nicht beobachtet; die Codekonfiguration ist kein gemessenes Produktionsintervall. + +Ein anderer Zugang zu demselben Transitous-Feed erweitert dessen Echtzeitabdeckung nicht. DBF ergänzt dagegen eine IRIS-Bahnhofstafel. Der Entwicklungsabruf am 10.10.2026 lieferte anonym HTTP 200 für Hannover Hbf mit Minutenverspätungen und abweichendem geplantem/aktuellem Gleis. Dies belegt die Erreichbarkeit und die konkreten gelieferten Felder zu diesem Zeitpunkt, keine garantierte höhere Aktualität jeder Fahrt. + +## Vertrag und Abgleich + +```text +GET https://dbf.finalrewind.org/.json?version=3&no_related=1&past=1 +``` + +Die dokumentierte JSON-Version 3 ist stabil. `no_related=1` ist zwingend: Ohne diese Einschränkung kann DBF Halte verwandter Bahnhöfe aufnehmen, während die JSON-Zeile selbst keine Stationskennung enthält. Die EVA stammt aus `station.identifiers.de_db_ibnr`; interne Träwelling-Stations-IDs sind keine EVA. + +`past=1` nimmt auch die vergangene Stunde auf; der Standard-Vorblick beträgt 180 Minuten. Bei verspäteten Fahrten wird der Fensterabgleich am belegten verzögerten Ereignis geprüft, sodass eine vor mehr als 20 Minuten geplante, jetzt aktuelle Fahrt nicht allein wegen ihrer Verspätung entfällt. DBF hält IRIS-Echtzeit laut geprüftem Quellcode selbst etwa 70 Sekunden im Cache. Der zusätzliche Clientcache kann deshalb keinen sekundengenauen Providerstand garantieren. + +Die Zuordnung verwendet positive operative `CheckinInfo.journeyNumber`, EVA und vorhandene Ankunfts-/Abfahrts-Sollzeiten. Linienbezeichnungen wie `RE1`, Namen oder Koordinaten ersetzen diese Identitäten nicht. Nur eindeutige Treffer aus dem aktuellen Bahnhofstafel-Zeitfenster werden übernommen. Die API liefert Sollzeiten lediglich als `HH:mm`: Das Fahrtdatum stammt deshalb aus dem vorhandenen konkreten App-Ereignis und wird gegen den Abrufhorizont geprüft. Mehrdeutige Tage, widersprüchliche Zeiten und nicht eindeutig auflösbare Sommerzeitstunden bleiben ohne Zusatzdaten. + +Die Tafel enthält nullable Minutenverspätungen, `platform`, `scheduledPlatform`, `missingRealtime` und ein zusammengefasstes `isCancelled`. Fehlende Daten bleiben unbekannt. Ein synthetischer Nullverspätungswert beziehungsweise ein mit dem Plan identisches Gleis belegt nicht selbstständig neue Echtzeit. Version 3 liefert keine getrennten Ankunfts-/Abfahrtsausfälle; daraus wird kein erfundenes Teilausfallmodell erzeugt. Ein bestätigter kompletter Haltausfall kann ergänzt werden; ein bloßes `isCancelled=false` beweist keine vollständige Rücknahme eines möglichen Teilausfalls. + +## Verhalten und Grenzen + +- Die Quelle ist standardmäßig aktiv und über **Zusätzliche Bahn-Livedaten** abschaltbar. Es wird ausschließlich die benötigte öffentliche Bahnhofstafel angefragt; Bearer-Token, Kontoinformationen und Gerätepositionen gehen nicht an DBF. +- Der Prozess teilt Cache und laufende Abrufe zwischen Detailansicht und Begleitungsservice. Höchstens zehn Request-Starts in 60 Sekunden und einer je EVA in 60 Sekunden entsprechen der aktuellen Dienstvorgabe; auch fehlgeschlagene Starts zählen. Die Quote gilt je App-Prozess, keine flottenweite Kapazitätszusage. +- Erfolgreiche Tafeln werden mindestens 60 Sekunden wiederverwendet. Fehlversuche und `Retry-After` begrenzen erneute Anfragen. Wenige zeitnahe Bahnhöfe und eine kurze Gesamtfrist begrenzen den Zusatzabruf; historische Fahrten, andere Verkehrsmittel, fehlende Kennungen und uneindeutige Treffer behalten Träwelling-Werte. +- Zeiten, Gleise und Ausfälle erhalten getrennte Quell-/Abrufinformationen, ausschließlich wenn das jeweilige Feld übernommen wurde. Ein erfolgreicher HTTP-Abruf ohne passenden Wert erneuert keine beibehaltenen Fremdwerte. +- Zusatzdaten werden nur lokal angezeigt und für die bestehende Begleitung verwendet. Sie lösen keinen Check-in-PUT aus. Manuelle Korrekturen und lokale GPS-Zeiten behalten ihre bisherige Priorität. +- Der Änderungsmonitor behandelt einen bekannten Wechsel der jeweiligen Zeit-/Gleisquelle als neue Vergleichsbasis. Der Rückfall auf einen anders bezogenen Wert erzeugt keine scheinbare Verspätungsverbesserung, Gleisänderung oder Ausfallrücknahme. Neu bestätigte komplette Ausfälle bleiben meldbar. +- Session-, Fahrt- und Statusrevision werden auch nach dem optionalen Zusatzabruf geprüft. Verspätete Antworten dürfen keinen neuen Check-in oder eine andere Sitzung überschreiben. + +## Anzeige der Aktualität + +`StopRealtimeInfo.fetchedAtMillis` beschreibt den erfolgreichen Clientabruf, nicht den Zeitpunkt einer Änderung bei DB oder Transitous. `providerUpdatedAtMillis` bleibt ohne belastbaren Beleg leer; ein Störungsmeldungs-Zeitstempel ist keine Prognosezeit. + +Die Detailansicht zeigt Quelle und Abrufalter sowie Fehler beziehungsweise ältere Abrufe. Das frühere tagesabhängige grüne `LIVE`-Badge ist ersetzt. `lastUpdated` wird nur nach erfolgreichem Halteabruf erneuert. Ein Gleisvergleich zeigt beispielsweise `Gleis 7 statt 5`; Herkunft und Alter von Gleisen sind von Zeitwerten getrennt. GPS- und manuelle Zeiten behalten ihre eigene Quellenbezeichnung. Alte Fahrtcache-Einträge ohne Abrufmetadaten gelten als unbekannt, statt beim Neustart als frisch gestempelt zu werden. + +## Abhängigkeiten + +Vorhandenes OkHttp, Gson und Coroutines; keine zusätzliche Android-Bibliothek, kein DB-Zugangsspeicher und kein mitgelieferter Proxy. DBF ist ein öffentlich betriebener Dienst mit begrenzter Kapazität; sein Quellcode ermöglicht einen späteren eigenen Betrieb. Die Softwarelizenz beschreibt den Dienstcode und ist kein pauschaler Lizenznachweis für alle zugrunde liegenden Bahndaten. + +## Offene Fragen + +- TODO: Neue Quelle auf echten Fahrten mit Gleiswechsel, Ausfall, Mitternacht und Rücknahmen mit Träwelling vergleichen. Eine einzelne erfolgreiche Tafel beweist weder Vollständigkeit noch einen festen Aktualitätsvorsprung. +- TODO: Abdeckung und Belastung des öffentlichen Dienstes bei wachsender Zahl von Installationen bewerten; bei Bedarf die quelloffene Software selbst betreiben. +- Unklar: Tatsächliches Produktionsalter der Träwelling-/IRIS-Prognosen; Client-Abrufzeit ist kein Provider-Aktualitätsbeleg. +- TODO: Möglichen Datenverlust in der vorhandenen Stopover-Duplikatauswahl gesondert prüfen; aktuell bewertet sie nicht alle Echtzeitfelder. + +## Quellen + +- [DBF: JSON-Version 3 und aktuelle Nutzungsgrenzen](https://dbf.finalrewind.org/) +- [DBF: Quellcode und Lizenz](https://github.com/derf/db-fakedisplay) +- [DBF: JSON-Controller zum Prüfstand](https://github.com/derf/db-fakedisplay/blob/7e23707ac622dca704dc78523fc3683ba5ccebae/lib/DBInfoscreen/Controller/Stationboard.pm) +- [Träwelling: Transitous](https://help.traewelling.de/features/timetable/transitous/) +- [Träwelling: Refresh-Auswahl zum Prüfstand](https://github.com/Traewelling/traewelling/blob/4d602796da8409017314cc771b1127d169155f02/app/DataProviders/Repositories/TripRepository.php) +- [Träwelling: Refresh-Default](https://github.com/Traewelling/traewelling/blob/4d602796da8409017314cc771b1127d169155f02/config/trwl.php) +- [DB Timetables: Nutzungsplan](https://developers.deutschebahn.com/db-api-marketplace/apis/product/timetables) +- [DB-Vendo-Client: aktueller Funktionsvertrag und Grenzen](https://github.com/public-transport/db-vendo-client) + +## Verwandte Seiten + +- [Externe Schnittstellen](./externe-schnittstellen.md) +- [API-Kompatibilität](./traewelling-kompatibilitaet.md) +- [TripTracking](../module/trip-tracking.md) +- [StatusDetail](../module/status-detail.md) +- [Fahrtänderungen](../module/trip-changes.md) +- [PreferencesManager](../konfiguration/preferences-manager.md) +- [Tests](../entwicklung/tests.md) +- [Offene Fragen](../offene-fragen.md) diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 026db80..02319a5 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -48,6 +48,7 @@ Die SEV-Korrektur vom 07.10.2026 behebt die fälschliche Sperre der Mülheimer R - [API Überblick](./api/ueberblick.md) - [Interne Schnittstellen](./api/interne-schnittstellen.md) - [Externe Schnittstellen](./api/externe-schnittstellen.md) +- [Live-Datenquellen und Aktualitätsprüfung vom 10.10.2026](./api/live-datenquellen.md) - [Träwelling-API-Kompatibilität](./api/traewelling-kompatibilitaet.md) ## Daten diff --git a/docs/wiki/konfiguration/preferences-manager.md b/docs/wiki/konfiguration/preferences-manager.md index 3a50134..b8d3dcf 100644 --- a/docs/wiki/konfiguration/preferences-manager.md +++ b/docs/wiki/konfiguration/preferences-manager.md @@ -66,9 +66,12 @@ Native Bahn-/Tram-Linienzüge und OSRM-Straßen-Geometrien bleiben ausschließli | `trip_change_speech_enabled` | `Flow`, `true` | Änderungssprache; benötigt zusätzlich aktivierte Hinweise und globale TTS. | | `live_progress_enabled` | `Flow`, `true` | Fortschrittsdarstellung und systemabhängige Live-Update-Anfrage. | | `lock_screen_details_enabled` | `Flow`, `true` | Sichtbare Reisedetails; `false` verwendet öffentliche Ersatzanzeigen und unterdrückt Promotion. | +| `public_rail_realtime_enabled` | `Flow`, `true` | Anonyme zusätzliche Bahn-Livedaten über DBF/IRIS; benötigt keine Zugangsdaten. | Die zugehörigen suspend-Getter und Setter entsprechen den Flow-Namen. Ein aktiver Check-in pausiert die Erkennung, ohne einen Kandidaten als Fahrt zu speichern. Der Trackingcache enthält zusätzlich `TripChangeMonitorState` mit letzten Ereigniswerten zur Deduplizierung. Die frische Vergleichsbasis, Verspätungsreferenzen und Gerätepositionen werden weiterhin nicht persistiert. +`getPublicRailRealtimeEnabled()` und `setPublicRailRealtimeEnabled(Boolean)` steuern die [DBF-Ergänzung](../api/live-datenquellen.md). Ein laufender Zusatzabruf wird vor der Veröffentlichung erneut gegen diese Einstellung geprüft. Erfolgreich gelesene Zeiten/Gleise behalten im bestehenden Fahrtcache ihre Quell- und Abrufmetadaten; alte Einträge ohne Metadaten werden als unbekannt behandelt. Cachewiederherstellung setzt keinen neuen Live-Abrufzeitpunkt. + ### TTS-Einstellungen | Key | Flow-Typ | Beschreibung | diff --git a/docs/wiki/module/status-detail.md b/docs/wiki/module/status-detail.md index 1aad0a9..0de17d0 100644 --- a/docs/wiki/module/status-detail.md +++ b/docs/wiki/module/status-detail.md @@ -33,6 +33,8 @@ Die Timeline verwendet `StopStation.stationName` und `stationId` aus dem verscha Alle 30 Sekunden ruft der Hintergrundauftrag `loadSnapshot(..., showLoading = false)` für Live-Delay-Daten auf. Während die frische Haltantwort aussteht, bleibt der vorherige vollständige Snapshot sichtbar. Der neue Status wird mit seinen zugeordneten Stopover-Grenzen und der neuen Timeline in einem UIState-Update übernommen; ein vorübergehender Rohstatus ohne passend hydratisierte Grenzen wird nicht angezeigt. Dadurch wechseln Headerzeiten nicht allein wegen nacheinander eintreffender API-Antworten zwischen GPS und API. Der Speichern-Erfolg ordnet ebenfalls noch kompatible vorhandene Grenzen vor der Veröffentlichung zu. +Die optionale [DBF-/IRIS-Ergänzung](../api/live-datenquellen.md) läuft nach dem Träwelling-Halteabruf innerhalb derselben `readConsistentStatusDetail`-Revision. Eindeutig zugeordnete Bahnhofstafelwerte ergänzen nur den lokalen Snapshot. Ein gleichzeitig bearbeiteter Check-in erzwingt die erneute vollständige Status-/Halte-/Zusatzprüfung; Sitzungs- und Ladegeneration verhindern die Übernahme abgelöster Antworten. Abruffehler behalten kompatible alte Halte samt ursprünglichen Quellenzeiten und setzen `realtimeRefreshFailed`. + Schlägt die Halteanfrage fehl, werden dennoch die neuen Status-/Text-/manuellen Zeitfelder übernommen. Vorhandene Stopovers bleiben nur bei derselben Status-ID und Trip-ID, kompatibler vorhandener Trip-UUID sowie eindeutig passenden gelieferten Grenzen mit unveränderten gelieferten Planzeiten verwendbar. Andernfalls wird die alte Timeline geleert und die eingehenden API-Grenzen bleiben bestehen. Beim initialen Laden wird der Haltefehler angezeigt; der stille Refresh bleibt still. Dies ist keine Anzeigeverzögerung für abgelaufene GPS-Werte. Antworten auf Status-, Halte- und Nutzeranfragen werden nur übernommen, wenn weiterhin dieselbe Status-ID angezeigt wird. Späte Antworten einer zuvor geöffneten Fahrt überschreiben dadurch nicht die neue Ansicht. @@ -104,7 +106,8 @@ Die Timeline zeigt: - Dezente Container für aktuellen Halt, Einstieg, Ziel und Halte innerhalb der eigenen Reise - Weiche Status-Übergänge zwischen Ladezuständen, Error und Timeline via `AnimatedContent` - Gestaffelte Fade-in/Slide-in Animationen der Timeline-Einträge via `AnimatedVisibility` -- "LIVE" Badge mit Puls-Animation wenn Status heute ist +- Abrufstatus mit Quelle und Alter statt des früheren tagesabhängigen `LIVE`-Badges; ältere Daten und Abruffehler bleiben sichtbar gekennzeichnet +- Ereignisbezogener Gleisvergleich, beispielsweise `Gleis 7 statt 5`, mit eigener Quellen-/Abrufinformation - Höchstens ein aktueller beziehungsweise nächster Besuch gemäß der unten beschriebenen Fortschrittsquelle - Verspätungs-Badges (grün/rot) - "HALT ENTFÄLLT" für gestrichene Halte @@ -149,7 +152,8 @@ Die Linie wird mit `drawBehind` über die vollständige Zeilenhöhe gezeichnet. | `editInitialStatus` | Status? | Beim Öffnen gebundener Edit-Anfangsstatus | | `editArrivalManuallyChanged` | Boolean? | Explizite Ankunftseingabe gegenüber automatisch angepasster Zielzeit | | `isDeleting` | Boolean | Löschvorgang | -| `lastUpdated` | Long | Timestamp letzte Aktualisierung | +| `lastUpdated` | Long | Timestamp letzter erfolgreicher Halteabruf; ein Statusabruf allein erneuert ihn nicht | +| `realtimeRefreshFailed` | Boolean | Aktueller Status-/Halteabruf fehlgeschlagen; vorhandene Werte behalten ihre Abrufzeit | ### Erhaltener Fachzustand bei Activity-Neuanlage diff --git a/docs/wiki/module/trip-changes.md b/docs/wiki/module/trip-changes.md index 53fae39..1b8ff0e 100644 --- a/docs/wiki/module/trip-changes.md +++ b/docs/wiki/module/trip-changes.md @@ -41,6 +41,8 @@ Für die Relevanz eines Gleiswechsels zählt der erste nicht gestrichene verblei ## Abhängigkeiten +Die [DBF-/IRIS-Ergänzung](../api/live-datenquellen.md) liefert optionale zusätzliche Bahnhofstafelwerte. Snapshots führen Zeit-, Gleis- und Ausfallquelle getrennt. Bei bekanntem Quellenwechsel werden Gleis-/Verspätungsvergleiche neu basiert und eine bloße `true`-zu-`false`-Ausfalländerung nicht als Rücknahme gemeldet. Ein neu bestätigter kompletter Ausfall bleibt auch beim Quellenwechsel meldbar. Unbekannte Legacy-Quellen behalten das bisherige Verhalten. + `TripTrackingService`, Träwelling-Status/Stopovers, `PreferencesManager`, Android-Benachrichtigungen und Android-TTS. Die reine Vergleichslogik benötigt weder Android noch Standortdaten. ## Offene Fragen diff --git a/docs/wiki/module/trip-tracking.md b/docs/wiki/module/trip-tracking.md index 407a2f9..b47611d 100644 --- a/docs/wiki/module/trip-tracking.md +++ b/docs/wiki/module/trip-tracking.md @@ -59,6 +59,7 @@ Android-Grundlagen: [Doze und App Standby](https://developer.android.com/trainin | Verarbeitung | Gewünschtes Intervall | | --- | --- | | Status und Stopovers über Träwelling aktualisieren | 60 Sekunden | +| Optionale zusätzliche Bahnhofstafel über DBF/IRIS | Gemeinsamer 60-Sekunden-Cache; höchstens zehn Starts/min und ein Start je EVA/min | | Fahrplan-/Signalalter prüfen | 10 Sekunden | | Standortupdates unterwegs | 12 Sekunden | | Standortupdates bis 3 km zum aktuellen Halt | 3 Sekunden | @@ -177,6 +178,8 @@ Die Prognosebasis berücksichtigt nur Endpunkte und Linienzüge des aktuell eing `JourneyTimeResolver` verwendet je Ereignis frische eindeutig zugeordnete GPS-Zeit, sonst manuelle Zeit, parsebare API-Echtzeit und schließlich Planzeit. Notification, Widget, Fahrtdetail und Sperrbildschirm verwenden denselben Resolver und kennzeichnen die Zeitquelle. Eine bereits belegte Prognose wird bei Bremsen oder geordnetem Haltwechsel mit passender frischer Position bis zu ihrem unveränderten ursprünglichen Gültigkeitsende erhalten. Standortqualität, Korridor, Ablauf und fehlende Daten können die GPS-Zeit weiterhin sofort verwerfen, während der räumlich etablierte Besuchscursor erhalten bleibt. Schwellen, stabile Ankunftsbeobachtung, längere Halte und Quellenentscheidung stehen unter [GPS-Zeiten](./gps-zeiten.md). +Die [DBF-/IRIS-Ergänzung](../api/live-datenquellen.md) reichert die Träwelling-Halte vor der bestehenden Fahrt-/Sitzungsrevision-Prüfung lokal an. Zeit, Gleis und Ausfall behalten getrennte Quellen-/Abrufinformationen. Cachewiederherstellung ohne diese Metadaten gilt als unbekannt, erhaltene Metadaten bekommen keinen neuen Zeitstempel. `JourneyTimeResolver` kennzeichnet API-Zeiten mit Quelle und gegebenenfalls älterem Abruf; GPS und manuelle Zeiten behalten ihre bisherigen Labels. Der Änderungsmonitor behandelt bekannte Quellenwechsel als neue Vergleichsbasis, damit ein Rückfall keine falsche Verbesserung oder Entwarnung auslöst. + ## Persistenz und Offlinebetrieb `trip_tracking_state` speichert ein versioniertes JSON mit Status-ID, Check-in, zuletzt gültiger eingegrenzter Haltfolge und `TrackingProgress` (Cursor, Besuchsschlüssel, innerer Ankunftsstatus, `gpsEstablished`, erfolgreich eingereihte Ansageschlüssel und Abschlussstatus). Standortfixes, Bewegungshistorie, GPS-Istzeiten und GPS-Prognosen bleiben ausschließlich im Speicher; es wird keine GPS-Historie an Träwelling gesendet und kein automatischer Status-PUT ausgelöst. diff --git a/docs/wiki/offene-fragen.md b/docs/wiki/offene-fragen.md index f79f380..b125e35 100644 --- a/docs/wiki/offene-fragen.md +++ b/docs/wiki/offene-fragen.md @@ -35,8 +35,18 @@ Sammlung von offenen Punkten, Unsicherheiten und Annahmen im Projekt. - TODO: [Änderungshinweise](./module/trip-changes.md) mit realen API-Änderungen, deaktivierten Benachrichtigungen und konkurrierenden TTS-Ansagen prüfen. Ohne Providerwerte gibt es keine Änderungserklärung. - TODO: [Reisefortschritt](./module/trip-progress.md) auf Android API 35, 36 und 36.1 sowie Samsung prüfen: System-/OEM-Promotion, große Schrift, Wegwischen, kalte/warme Navigation und ausgeschaltete Sperrbildschirmdetails. Live Updates bleiben vom System und Nutzer abhängig. +## Live-Datenprüfung vom 10.10.2026 + +Die [anonyme DBF-/IRIS-Ergänzung vom 10.10.2026](./api/live-datenquellen.md) liefert zusätzliche Bahnwerte ohne Registrierung. Abrufalter, Quellenkennzeichnung und Soll-/Ist-Gleisvergleich sind umgesetzt. Offen bleiben: + +- TODO: Auf echten Fahrten Gleiswechsel, Verspätungen und Haltausfälle mit Träwelling vergleichen; ein erfolgreicher öffentlicher Tafelabruf belegt keine Vollständigkeit oder garantierte höhere Aktualität. +- TODO: DBF-Dienstlast bei mehreren Installationen prüfen. Der Client begrenzt Request-Starts je Prozess auf zehn/min und einen je Bahnhof/min; keine flottenweite Quote ist nachgewiesen. +- Unklar: Alter der zugrunde liegenden Providerprognosen. Die Anzeige belegt den Clientabruf, keine Änderung bei DB beziehungsweise Transitous. Version 3 erlaubt keinen belastbaren Import separater Teilausfälle beziehungsweise deren Rücknahme. + ## Verwandte Seiten +- [Live-Datenquellen](./api/live-datenquellen.md) + - [Index](./index.md) - [Tests](./entwicklung/tests.md) - [TripTracking](./module/trip-tracking.md)