diff --git a/README.md b/README.md index 5cdc4a8d..90ba42b0 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Pico Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a live map that shows exactly where your ride actually is β€” no ads, no clutter, no infinite scroll. Just "where's my bus," answered nicely. 🚏✨ -Right now Pico Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. +Right now Pico Transit knows its way around **MBTA**, **RIPTA**, and **RTD Denver**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. Pico Transit can be used alongside the light phone's directions tool for more context on your commutes, or standalone, covering buses, commuter rail.subway systems, and transit stations. ## πŸ—ΊοΈ What can it do? -- 🏠 **Pick your agency** β€” MBTA or RIPTA β€” and Pico Transit downloads their schedule right onto your phone. +- 🏠 **Pick your agency** β€” MBTA, RIPTA, or RTD Denver β€” and Pico Transit downloads their schedule right onto your phone. ![alt text](docs/screenshots/Screenshot_20260801_202930.png) diff --git a/tool/README.md b/tool/README.md index e4f76f44..3bcdf209 100644 --- a/tool/README.md +++ b/tool/README.md @@ -2,11 +2,11 @@ Pico Transit is a friendly little companion for getting around on public transit. Real schedules, real-time arrivals, live connections at any stop, and a live map that shows exactly where your ride actually is β€” no ads, no clutter, no infinite scroll. Just "when's my bus," answered nicely. 🚏✨ -Right now Pico Transit knows its way around **MBTA** and **RIPTA**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. +Right now Pico Transit knows its way around **MBTA**, **RIPTA**, and **RTD Denver**, with more agencies hopefully hopping aboard down the road. It's built on the [Light SDK](../) for the Light Phone III, so it stays just as calm and un-distracting as the rest of your Light experience. ## πŸ—ΊοΈ What can it do? -- 🏠 **Pick your agency** β€” MBTA or RIPTA β€” and Pico Transit downloads their schedule right onto your phone. +- 🏠 **Pick your agency** β€” MBTA, RIPTA, or RTD Denver β€” and Pico Transit downloads their schedule right onto your phone. - βš™οΈ **Settings** β€” a default agency to skip the picker, light/dark map tiles, and on/off toggles (tap-and-hold a stop to jump to its arrivals β€” the same gesture also jumps from a Station map's own name to the main map centered on it; double-tap a station to zoom into its platforms; track tapped-open stops' own vehicles on the map; the home screen's trip progress bar; the home screen's daily message; and "See Everything," a map mode covered below). - πŸ“… **Explore Schedules** β€” browse by Subway πŸš‡, Commuter Rail πŸš†, or Bus 🚌, pick a route, a direction, and a stop, and see every departure today. - πŸ”— **Connections** β€” tap any stop along a trip to see what else comes through there next, across every platform of a station, not just the one your trip happened to use. Great for planning a transfer on the fly. diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt index 4e460523..797e3beb 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt @@ -31,6 +31,13 @@ enum class GtfsAgency( "https://cdn.mbta.com/realtime/VehiclePositions.pb", components = listOf(MbtaV3VehicleSource), ), + RTD( + "rtd", + "RTD Denver", + "https://www.rtd-denver.com/files/gtfs/google_transit.zip", + "https://open-data.rtd-denver.com/files/gtfs-rt/rtd/TripUpdate.pb", + "https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb", + ), // REMOVABLE: these two URLs only work because of the :netconfig cleartext exception (see // class doc above). Set both back to null to restore HTTPS-only enforcement for RIPTA. RIPTA( diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt index bdec6d76..483cfd0a 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt @@ -10,6 +10,7 @@ import io.ktor.client.request.head import io.ktor.http.HttpHeaders import java.io.BufferedReader import java.io.File +import java.net.URI import java.util.zip.ZipInputStream enum class GtfsIngestStatus { @@ -100,11 +101,7 @@ class GtfsIngestor(private val filesDir: File) { } status in 300..399 -> { val location = response.headers[HttpHeaders.Location] ?: return null - currentUrl = if (location.startsWith("http://")) { - "https://" + location.removePrefix("http://") - } else { - location - } + currentUrl = secureRedirectUrl(currentUrl, location) } else -> return null } @@ -151,11 +148,7 @@ class GtfsIngestor(private val filesDir: File) { status in 300..399 -> { val location = response.headers[HttpHeaders.Location] ?: throw GtfsIngestException("GTFS download redirected without a Location header") - currentUrl = if (location.startsWith("http://")) { - "https://" + location.removePrefix("http://") - } else { - location - } + currentUrl = secureRedirectUrl(currentUrl, location) } else -> throw GtfsIngestException("GTFS download failed: HTTP $status") } @@ -199,6 +192,16 @@ class GtfsIngestor(private val filesDir: File) { } } +/** Resolves absolute and relative redirects while never following a redirect back to plain HTTP. */ +private fun secureRedirectUrl(currentUrl: String, location: String): String { + val resolved = URI(currentUrl).resolve(location).toString() + return if (resolved.startsWith("http://")) { + "https://" + resolved.removePrefix("http://") + } else { + resolved + } +} + private fun loadRoutes(db: SQLiteDatabase, reader: BufferedReader) { db.delete("routes", null, null) val stmt = db.compileStatement( diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt index 4c587c87..ce394ecf 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRealtime.kt @@ -7,6 +7,7 @@ import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.request.get import io.ktor.http.HttpHeaders +import java.net.URI import java.time.LocalDate import java.time.ZoneId import kotlinx.serialization.ExperimentalSerializationApi @@ -170,11 +171,7 @@ object GtfsRealtimeClient { status in 300..399 -> { val location = response.headers[HttpHeaders.Location] ?: throw GtfsRealtimeException("GTFS-RT redirected without a Location header") - currentUrl = if (location.startsWith("http://")) { - "https://" + location.removePrefix("http://") - } else { - location - } + currentUrl = secureRealtimeRedirectUrl(currentUrl, location) } else -> throw GtfsRealtimeException("GTFS-RT fetch failed: HTTP $status") } @@ -186,6 +183,16 @@ object GtfsRealtimeClient { } } +/** Resolves absolute and relative redirects while never following a redirect back to plain HTTP. */ +private fun secureRealtimeRedirectUrl(currentUrl: String, location: String): String { + val resolved = URI(currentUrl).resolve(location).toString() + return if (resolved.startsWith("http://")) { + "https://" + resolved.removePrefix("http://") + } else { + resolved + } +} + /** Default +/- window (seconds) within which a live prediction still counts as "On time". */ const val ARRIVAL_STATUS_TOLERANCE_SECONDS = 90L