Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@
},
{
"tableName": "regions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `oba_base_url` TEXT NOT NULL, `siri_base_url` TEXT NOT NULL, `lang` TEXT NOT NULL, `contact_email` TEXT NOT NULL, `supports_api_discovery` INTEGER NOT NULL, `supports_api_realtime` INTEGER NOT NULL, `supports_siri_realtime` INTEGER NOT NULL, `twitter_url` TEXT, `experimental` INTEGER, `stop_info_url` TEXT, `otp_base_url` TEXT, `otp_contact_email` TEXT, `supports_otp_bikeshare` INTEGER, `uses_otp2_graphql` INTEGER DEFAULT 0, `supports_embedded_social` INTEGER, `payment_android_app_id` TEXT, `payment_warning_title` TEXT, `payment_warning_body` TEXT, `sidecar_base_url` TEXT DEFAULT 'https://onebusaway.co', `plausible_analytics_server_url` TEXT, `umami_analytics_url` TEXT, `umami_analytics_id` TEXT, PRIMARY KEY(`_id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `oba_base_url` TEXT NOT NULL, `siri_base_url` TEXT NOT NULL, `lang` TEXT NOT NULL, `contact_email` TEXT NOT NULL, `supports_api_discovery` INTEGER NOT NULL, `supports_api_realtime` INTEGER NOT NULL, `supports_siri_realtime` INTEGER NOT NULL, `twitter_url` TEXT, `experimental` INTEGER, `stop_info_url` TEXT, `otp_base_url` TEXT, `otp_contact_email` TEXT, `supports_otp_bikeshare` INTEGER, `otp_base_graphql_url` TEXT, `supports_embedded_social` INTEGER, `payment_android_app_id` TEXT, `payment_warning_title` TEXT, `payment_warning_body` TEXT, `sidecar_base_url` TEXT DEFAULT 'https://onebusaway.co', `plausible_analytics_server_url` TEXT, `umami_analytics_url` TEXT, `umami_analytics_id` TEXT, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
Expand Down Expand Up @@ -524,10 +524,9 @@
"affinity": "INTEGER"
},
{
"fieldPath": "usesOtp2GraphQl",
"columnName": "uses_otp2_graphql",
"affinity": "INTEGER",
"defaultValue": "0"
"fieldPath": "otpBaseGraphqlUrl",
"columnName": "otp_base_graphql_url",
"affinity": "TEXT"
},
{
"fieldPath": "supportsEmbeddedSocial",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.room.testing.MigrationTestHelper
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
Expand All @@ -29,7 +30,7 @@ import org.junit.runner.RunWith
* empty — the data import from the legacy ContentProvider DB is a separate slice); and that
* [MIGRATION_3_4] reconciles `routes.favorite` from the authoritative `route_headsign_favorites` table
* before dropping it (#1751) and adds the `surveys.study_id` foreign-key child index (#1739); and that
* [MIGRATION_5_6] adds `regions.uses_otp2_graphql` defaulting existing rows to OTP1 (#1780).
* [MIGRATION_5_6] adds `regions.otp_base_graphql_url` defaulting existing rows to OTP1 (#1780).
*/
@RunWith(AndroidJUnit4::class)
class AppDatabaseMigrationTest {
Expand Down Expand Up @@ -143,7 +144,7 @@ class AppDatabaseMigrationTest {
}

@Test
fun migrate5To6_addsUsesOtp2GraphQlColumn_defaultingExistingRowsToFalse() {
fun migrate5To6_addsOtpBaseGraphqlUrlColumn_defaultingExistingRowsToNull() {
helper.createDatabase(TEST_DB, 5).use { db ->
db.execSQL(
"INSERT INTO regions (_id, name, oba_base_url, siri_base_url, lang, contact_email, " +
Expand All @@ -155,9 +156,9 @@ class AppDatabaseMigrationTest {
// runMigrationsAndValidate asserts the resulting schema matches the exported 6.json.
val db = helper.runMigrationsAndValidate(TEST_DB, 6, true, MIGRATION_5_6)

db.query("SELECT uses_otp2_graphql FROM regions WHERE _id = 1").use { c ->
db.query("SELECT otp_base_graphql_url FROM regions WHERE _id = 1").use { c ->
c.moveToFirst()
assertEquals("pre-existing region should default to OTP1 (0)", 0, c.getInt(0))
assertTrue("pre-existing region should default to OTP1 (NULL GraphQL URL)", c.isNull(0))
}
db.close()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public static Region getRegionWithPathNoSeparator(Context context) {
null,
null,
false,
false,
null, // otpBaseGraphqlUrl (#1780): OTP1 REST region
false,
"co.bytemark.hart",
null,
Expand Down Expand Up @@ -131,7 +131,7 @@ public static Region getRegionNoSeparator(Context context) {
null,
null,
false,
false,
null, // otpBaseGraphqlUrl (#1780): OTP1 REST region
false,
null,
null,
Expand Down Expand Up @@ -173,7 +173,7 @@ public static Region getRegionWithPort(Context context) {
null,
null,
false,
false,
null, // otpBaseGraphqlUrl (#1780): OTP1 REST region
false,
"co.bytemark.hart",
null,
Expand Down Expand Up @@ -215,7 +215,7 @@ public static Region getRegionNoScheme(Context context) {
null,
null,
false,
false,
null, // otpBaseGraphqlUrl (#1780): OTP1 REST region
false,
"co.bytemark.hart",
null,
Expand Down Expand Up @@ -257,7 +257,7 @@ public static Region getRegionWithHttps() {
null,
null,
false,
false,
null, // otpBaseGraphqlUrl (#1780): OTP1 REST region
false,
"co.bytemark.hart",
null,
Expand Down Expand Up @@ -299,7 +299,7 @@ public static Region getRegionWithHttpsAndPort() {
null,
null,
false,
false,
null, // otpBaseGraphqlUrl (#1780): OTP1 REST region
false,
"co.bytemark.hart",
null,
Expand Down Expand Up @@ -339,7 +339,7 @@ public static Region getRegionWithoutObaApis(Context context) {
null,
null,
false,
false,
null, // otpBaseGraphqlUrl (#1780): OTP1 REST region
false,
"co.bytemark.hart",
null,
Expand Down Expand Up @@ -379,7 +379,7 @@ public static Region getInactiveRegion(Context context) {
null,
null,
false,
false,
null, // otpBaseGraphqlUrl (#1780): OTP1 REST region
false,
"co.bytemark.hart",
null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ fun RegionDto.toObaRegion(): Region = Region(
otpBaseUrl,
otpContactEmail,
supportsOtpBikeshare,
usesOtp2GraphQl,
otpBaseGraphqlUrl,
supportsEmbeddedSocial,
paymentAndroidAppId,
paymentWarningTitle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,9 +458,12 @@ data class RegionDto(
val otpBaseUrl: String? = null,
val otpContactEmail: String? = null,
val supportsOtpBikeshare: Boolean = false,
// OTP protocol selection (#1780); absent in the regions directory today, so every region
// decodes to OTP1 REST (false) until explicitly set otherwise.
val usesOtp2GraphQl: Boolean = false,
// OTP 2.x GraphQL endpoint (#1780). Its presence is the explicit protocol signal: a non-blank
// value routes trip planning through the OTP2 `planConnection` path against this URL; a null/
// absent value (every region but the OTP2-enabled ones) stays on the OTP1 REST `/plan` path.
// A distinct field because a region's GraphQL endpoint is a different host from its OTP1
// `otpBaseUrl` (e.g. Puget Sound), so the two URLs can't share one field.
val otpBaseGraphqlUrl: String? = null,
val supportsEmbeddedSocial: Boolean = false,
val paymentAndroidAppId: String? = null,
val paymentWarningTitle: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ import org.onebusaway.android.database.widealerts.entity.AlertEntity
* cache of nearby-stops loads (separate from the user-state `stops` table) so the map renders stops
* instantly on a slow/cold-start load.
*
* v6 adds `regions.uses_otp2_graphql` (#1780): explicit per-region OTP protocol selection (OTP1 REST
* vs. OTP 2.x GraphQL). Defaults to 0 (OTP1) for every existing cached row.
* v6 adds `regions.otp_base_graphql_url` (#1780): the per-region OTP 2.x GraphQL endpoint. A non-null
* value routes that region through the OTP2 `planConnection` path; NULL (every existing cached row)
* stays on OTP1 REST.
*/
@Database(
entities = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,14 @@ val MIGRATION_4_5 = object : Migration(4, 5) {
}

/**
* Adds `regions.uses_otp2_graphql` (#1780): explicit per-region OTP protocol selection. Defaults
* every existing row to 0 (OTP1 REST) — purely additive, no other v5 table/column changes.
* Adds `regions.otp_base_graphql_url` (#1780): the per-region OTP 2.x GraphQL endpoint. A non-null
* value routes that region's trip planning through the OTP2 `planConnection` path; NULL (every
* existing row) stays on OTP1 REST. Purely additive, no other v5 table/column changes.
*/
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `regions` ADD COLUMN `uses_otp2_graphql` INTEGER DEFAULT 0"
"ALTER TABLE `regions` ADD COLUMN `otp_base_graphql_url` TEXT"
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ data class RegionRecord(
@ColumnInfo(name = "otp_base_url") val otpBaseUrl: String? = null,
@ColumnInfo(name = "otp_contact_email") val otpContactEmail: String? = null,
@ColumnInfo(name = "supports_otp_bikeshare") val supportsOtpBikeshare: Int? = null,
@ColumnInfo(name = "uses_otp2_graphql", defaultValue = "0") val usesOtp2GraphQl: Int? = null,
@ColumnInfo(name = "otp_base_graphql_url") val otpBaseGraphqlUrl: String? = null,
@ColumnInfo(name = "supports_embedded_social") val supportsEmbeddedSocial: Int? = null,
@ColumnInfo(name = "payment_android_app_id") val paymentAndroidAppId: String? = null,
@ColumnInfo(name = "payment_warning_title") val paymentWarningTitle: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,13 @@ object Otp2PlanRequestBuilder {
PlanModesInput(
transit = Optional.present(
PlanTransitModesInput(
access = Optional.present(listOf(PlanAccessMode.BICYCLE_RENTAL)),
egress = Optional.present(listOf(PlanEgressMode.BICYCLE_RENTAL)),
// WALK must accompany BICYCLE_RENTAL in the same access/egress list —
// OTP2 rejects a bare BICYCLE_RENTAL leg ("BIKE_RENTAL needs to be
// combined with WALK mode for the same leg", BadRequestError), since a
// rental trip always walks to/from the vehicle. Verified against the
// live OTP 2.x server. #1780.
access = Optional.present(listOf(PlanAccessMode.WALK, PlanAccessMode.BICYCLE_RENTAL)),
egress = Optional.present(listOf(PlanEgressMode.WALK, PlanEgressMode.BICYCLE_RENTAL)),
)
)
)
Expand All @@ -167,9 +172,10 @@ object Otp2PlanRequestBuilder {
Optional.Absent
}

// WALK must accompany BICYCLE_RENTAL here too (see the TRANSIT_AND_BIKE branch above).
TripModes.BIKESHARE -> Optional.present(
PlanModesInput(
direct = Optional.present(listOf(PlanDirectMode.BICYCLE_RENTAL)),
direct = Optional.present(listOf(PlanDirectMode.WALK, PlanDirectMode.BICYCLE_RENTAL)),
directOnly = Optional.present(true),
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,55 +206,64 @@ class TripRequestBuilder(context: Context, private val mBundle: Bundle) {
}

/**
* The user's custom OTP API URL preference, or null if unset/blank — the shared "is a custom
* server configured" signal both [formattedOtpBaseUrl] and [usesOtp2] branch on.
* The user's custom OTP API URL preference, or null if unset/blank — the "is a custom server
* configured" signal [otpTarget] branches on.
*/
private val customOtpApiUrl: String?
get() = PreferencesEntryPoint.get(mContext)
.getString(mContext.getString(R.string.preference_key_otp_api_url), null as String?)
?.takeUnless { TextUtils.isEmpty(it) }

/** The OTP server a request targets: its [baseUrl] and whether it speaks OTP 2.x GraphQL. */
private data class OtpTarget(val baseUrl: String?, val usesOtp2: Boolean)

/**
* Resolves and formats the OTP base URL (the user's custom URL if set, otherwise the current
* region's), or null if neither is available.
* Resolves the custom-URL-or-region branch once so [formattedOtpBaseUrl] and [usesOtp2] can't
* disagree (#1780). Protocol selection is explicit — a custom server's manual `..._is_graphql`
* preference, or a region publishing an `otpBaseGraphqlUrl` — never sniffed from the URL shape
* or a failed request. [baseUrl] is null when neither a custom URL nor a region is available.
*/
val formattedOtpBaseUrl: String?
private val otpTarget: OtpTarget
get() {
var otpBaseUrl: String?
val customUrl = customOtpApiUrl
if (customUrl != null) {
otpBaseUrl = customUrl
Log.d(TAG, "Using custom OTP API URL set by user '$otpBaseUrl'.")
} else {
// No custom URL and no selected region: return null so the caller
// (TripPlanRepository) surfaces a "no server selected" error instead of crashing.
val region = RegionEntryPoint.get(mContext).currentRegion() ?: return null
otpBaseUrl = region.otpBaseUrl
Log.d(TAG, "Using custom OTP API URL set by user '$customUrl'.")
// No [Region] to carry the setting for a custom server, so the user sets it.
return OtpTarget(
baseUrl = customUrl,
usesOtp2 = PreferencesEntryPoint.get(mContext)
.getBoolean(R.string.preference_key_otp_api_url_is_graphql, false),
)
}
// No custom URL and no selected region: baseUrl stays null so the caller
// (TripPlanRepository) surfaces a "no server selected" error instead of crashing.
val region = RegionEntryPoint.get(mContext).currentRegion() ?: return OtpTarget(null, false)
// An OTP2 region publishes its GraphQL endpoint separately (a different host than the
// OTP1 REST server); route to it when present, else the OTP1 REST base URL.
val graphqlBase = region.otpBaseGraphqlUrl?.takeUnless { it.isBlank() }
return OtpTarget(baseUrl = graphqlBase ?: region.otpBaseUrl, usesOtp2 = graphqlBase != null)
}

/**
* The [otpTarget] base URL with a scheme ensured and formatted, or null if no server is
* available.
*/
val formattedOtpBaseUrl: String?
get() {
var otpBaseUrl = otpTarget.baseUrl ?: return null
try {
// URI.parse() doesn't tell us if the scheme is missing, so use URL() instead (#126)
URL(otpBaseUrl)
} catch (e: MalformedURLException) {
// Assume HTTPS scheme, since without a scheme the Uri won't parse the authority
otpBaseUrl = mContext.getString(R.string.https_prefix) + otpBaseUrl
}
return if (otpBaseUrl != null) RegionUtils.formatOtpBaseUrl(otpBaseUrl) else null
return RegionUtils.formatOtpBaseUrl(otpBaseUrl)
}

/**
* Whether this request should go through the OTP 2.x GraphQL path rather than OTP1 REST
* (#1780). Resolved the same way as [formattedOtpBaseUrl] (custom-URL-or-region): with a
* custom OTP URL set, the manual override preference applies (there's no [Region] to carry
* the flag); otherwise the current region's `usesOtp2GraphQl` flag applies. Explicit, never
* sniffed from the URL shape or a failed request.
*/
/** Whether this request goes through the OTP 2.x GraphQL path rather than OTP1 REST (#1780). */
val usesOtp2: Boolean
get() = if (customOtpApiUrl != null) {
PreferencesEntryPoint.get(mContext)
.getBoolean(R.string.preference_key_otp_api_url_is_graphql, false)
} else {
RegionEntryPoint.get(mContext).currentRegion()?.usesOtp2GraphQl ?: false
}
get() = otpTarget.usesOtp2

private fun getAddressString(address: CustomAddress?): String? {
if (address == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ data class Region(
val otpBaseUrl: String? = "",
val otpContactEmail: String? = "",
val supportsOtpBikeshare: Boolean = false,
// Explicit protocol selection (#1780): false selects the OTP1 REST `/plan` path (the default,
// and the only path any bundled region uses today), true selects the OTP 2.x GraphQL
// `planConnection` path. Never sniffed/inferred from the URL or a failed request.
val usesOtp2GraphQl: Boolean = false,
// OTP 2.x GraphQL endpoint (#1780). A non-blank value is the explicit protocol signal — trip
// planning routes through the OTP2 `planConnection` path against this URL; null/blank (the
// default, and every region but the OTP2-enabled ones) stays on OTP1 REST `/plan`. Distinct
// from [otpBaseUrl] because the GraphQL endpoint is a different host than the OTP1 REST server.
// Never sniffed/inferred from the URL shape or a failed request.
val otpBaseGraphqlUrl: String? = null,
val supportsEmbeddedSocial: Boolean = false,
val paymentAndroidAppId: String? = null,
val paymentWarningTitle: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ object RegionMapper {
otpBaseUrl = r.otpBaseUrl,
otpContactEmail = r.otpContactEmail,
supportsOtpBikeshare = (r.supportsOtpBikeshare ?: 0) > 0,
usesOtp2GraphQl = (r.usesOtp2GraphQl ?: 0) > 0,
otpBaseGraphqlUrl = r.otpBaseGraphqlUrl,
supportsEmbeddedSocial = (r.supportsEmbeddedSocial ?: 0) > 0,
paymentAndroidAppId = r.paymentAndroidAppId,
paymentWarningTitle = r.paymentWarningTitle,
Expand Down Expand Up @@ -88,7 +88,7 @@ object RegionMapper {
otpBaseUrl = region.otpBaseUrl,
otpContactEmail = region.otpContactEmail,
supportsOtpBikeshare = region.supportsOtpBikeshare.toInt(),
usesOtp2GraphQl = region.usesOtp2GraphQl.toInt(),
otpBaseGraphqlUrl = region.otpBaseGraphqlUrl,
supportsEmbeddedSocial = region.supportsEmbeddedSocial.toInt(),
paymentAndroidAppId = region.paymentAndroidAppId,
paymentWarningTitle = region.paymentWarningTitle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,9 @@ fun AdvancedSettingsScreen(
onValueChange = onOtpUrlChange,
)
// Only meaningful once a custom OTP URL is set — TripRequestBuilder.usesOtp2 only
// reads this preference in that case, falling back to the region's own flag
// otherwise, so showing it unconditionally would suggest it does something it
// doesn't.
// reads this preference in that case, falling back to the region's own
// otpBaseGraphqlUrl otherwise, so showing it unconditionally would suggest it does
// something it doesn't.
if (!state.customOtpApiUrl.isNullOrEmpty()) {
SwitchPreferenceItem(
title = stringResource(R.string.preferences_otp_api_url_is_graphql_title),
Expand Down
Loading
Loading