diff --git a/CLAUDE.md b/CLAUDE.md index 1244260e84..76afba06bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,54 @@ Tests are in `onebusaway-android/src/androidTest/java/`. Key test classes: CI runs on API level 33 emulator via GitHub Actions. +## Time domains: server clock vs device clock (#1612) + +Any user-facing duration measured against a **server-provided timestamp** — ETAs ("N min"), +countdowns, "arriving now", vehicle age ("data updated N sec ago"), alert active-window checks — must +use the response's server clock as "now", **never** `System.currentTimeMillis()`. Mixing the two in a +single subtraction (server timestamp − device now) leaks device clock skew straight into the number. + +- The server clock is on the response: `StopArrivals.currentTime`, `RouteTrips.currentTimeMs`, + `TripDetailsRepository.lastLoadedTime()`. +- Extrapolation deliberately stays on the **device** clock (`TripState.anchorLocalTimeMs`) because it + pairs each server time with the local receive time; cross back to the server clock with + `TripState.toServerClock(...)` before plotting against server-clock data. +- Purely local timers stay on the device clock: cache TTLs / recent-window cutoffs (compared against + locally-stamped `System.currentTimeMillis()` writes), poll scheduling (`SystemClock.elapsedRealtimeNanos`), + WorkManager/alarm scheduling, "updated Ns ago" against a locally-stamped `lastResponseTimeMs`. + +Keep ETA/active-window helpers pure — pass the "now" in as a parameter (see `SituationUtils`, +`ArrivalInfo`); don't call the clock inside a helper. This is verified by `SituationUtilsTest` and +`ServerNowMsTest`. + +## No unsanctioned heuristics + +Do **not** introduce heuristics — magic thresholds, magnitude guesses, or "good enough" inference of +something the data should state explicitly. Examples of what counts: guessing whether a timestamp is +in seconds vs milliseconds from its size; inferring a unit, type, ID scheme, or intent from a value's +range; fuzzy string matching where an exact key exists; "if it looks like X, treat it as X." Heuristics +pass the happy path and misbehave silently at the edges, and they rot as the upstream data shifts. + +Prefer resolving the fact at its source instead: normalize units/shape at the parse or wire boundary, +carry an explicit field, or fail loudly (throw / return an error) rather than guessing. + +If a heuristic is genuinely unavoidable, it is a **human-sign-off gate**, not a judgment call an agent +makes alone: +1. Call it out explicitly in the PR description and get a human to approve it before merge. +2. Document it at the call site: the exact assumption, why no exact source exists, and its failure mode. +3. This applies equally to **pre-existing** heuristics you touch — reworking one re-opens the sign-off. + +When you're tempted to guess a unit/type/intent, check the wire contract, the sample payloads, **and +the producer's source** first — the answer is usually knowable, and sometimes the honest answer is +"the field really is polymorphic," which is itself worth documenting. (Worked example: a legacy helper +guessed whether an alert active-window timestamp was seconds or millis by magnitude. Reading the OBA +**server** source settled it — GTFS-RT `active_period` is seconds per spec, but the server normalizes to +millis on ingestion via its own magnitude rule (`GtfsRealtimeAlertLibrary.toMillis`, threshold 1e12), +while older servers still emit seconds — so the field genuinely varies. The client normalization stays, +but it's now sanctioned by the server evidence and mirrors the server's exact threshold, rather than +being an invented guess — and it lives at the wire→domain adapter (`situationEpochToMillis`) so the +domain model is unambiguously millis. See `situationEpochToMillis` and `SituationWindow`.) + ## Key Technical Details - **Min SDK**: 23 (Android 6.0) diff --git a/onebusaway-android/build.gradle b/onebusaway-android/build.gradle index 773e42dd6d..c51bdb4e26 100644 --- a/onebusaway-android/build.gradle +++ b/onebusaway-android/build.gradle @@ -57,6 +57,11 @@ android { } } + // Expose the exported Room schemas to instrumented tests so MigrationTestHelper can load them. + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas") + } + /** * brand - how the name, look, and feel of each OneBusAway-based app is presented * platform - Google @@ -162,7 +167,8 @@ android { } /** - * Set brand BuildConfig.DATABASE_AUTHORITY, used in org.onebusaway.android.provider.ObaContract + * Set brand BuildConfig.DATABASE_AUTHORITY, used in org.onebusaway.android.ui.nav.DeepLinkUris (the + * content:// stop/route deep-link authority) — historically the ObaProvider ContentProvider authority. */ androidComponents { onVariants(selector().all(), { variant -> @@ -297,6 +303,7 @@ dependencies { implementation "androidx.room:room-runtime:2.7.0" ksp "androidx.room:room-compiler:2.7.0" implementation "androidx.room:room-ktx:2.7.0" + androidTestImplementation "androidx.room:room-testing:2.7.0" // Hilt implementation "com.google.dagger:hilt-android:2.59.2" ksp "com.google.dagger:hilt-android-compiler:2.59.2" diff --git a/onebusaway-android/flavors/agencyX.gradle b/onebusaway-android/flavors/agencyX.gradle index 9fd660c994..591304fa9a 100644 --- a/onebusaway-android/flavors/agencyX.gradle +++ b/onebusaway-android/flavors/agencyX.gradle @@ -40,8 +40,6 @@ android.productFlavors { buildConfigField "String", "FIXED_REGION_PAYMENT_ANDROID_APP_ID", "null" buildConfigField "String", "FIXED_REGION_PAYMENT_WARNING_TITLE", "null" buildConfigField "String", "FIXED_REGION_PAYMENT_WARNING_BODY", "null" - buildConfigField "boolean", "FIXED_REGION_TRAVEL_BEHAVIOR_DATA_COLLECTION", "false" - buildConfigField "boolean", "FIXED_REGION_ENROLL_PARTICIPANTS_IN_STUDY", "false" buildConfigField "String", "FIXED_REGION_SIDECAR_BASE_URL", "null" buildConfigField "String", "FIXED_REGION_PLAUSIBLE_ANALYTICS_SERVER_URL", "null" } diff --git a/onebusaway-android/flavors/agencyY.gradle b/onebusaway-android/flavors/agencyY.gradle index 1d68c83e56..803d309818 100644 --- a/onebusaway-android/flavors/agencyY.gradle +++ b/onebusaway-android/flavors/agencyY.gradle @@ -45,8 +45,6 @@ android.productFlavors { buildConfigField "String", "FIXED_REGION_PAYMENT_ANDROID_APP_ID", "\"co.bytemark.hart\"" buildConfigField "String", "FIXED_REGION_PAYMENT_WARNING_TITLE", "null" buildConfigField "String", "FIXED_REGION_PAYMENT_WARNING_BODY", "null" - buildConfigField "boolean", "FIXED_REGION_TRAVEL_BEHAVIOR_DATA_COLLECTION", "false" - buildConfigField "boolean", "FIXED_REGION_ENROLL_PARTICIPANTS_IN_STUDY", "false" buildConfigField "String", "FIXED_REGION_SIDECAR_BASE_URL", "null" buildConfigField "String", "FIXED_REGION_PLAUSIBLE_ANALYTICS_SERVER_URL", "null" } diff --git a/onebusaway-android/flavors/kiedybus.gradle b/onebusaway-android/flavors/kiedybus.gradle index c3d1ce9c5c..ce1d97737e 100644 --- a/onebusaway-android/flavors/kiedybus.gradle +++ b/onebusaway-android/flavors/kiedybus.gradle @@ -40,8 +40,6 @@ android.productFlavors { buildConfigField "String", "FIXED_REGION_PAYMENT_ANDROID_APP_ID", "null" buildConfigField "String", "FIXED_REGION_PAYMENT_WARNING_TITLE", "null" buildConfigField "String", "FIXED_REGION_PAYMENT_WARNING_BODY", "null" - buildConfigField "boolean", "FIXED_REGION_TRAVEL_BEHAVIOR_DATA_COLLECTION", "false" - buildConfigField "boolean", "FIXED_REGION_ENROLL_PARTICIPANTS_IN_STUDY", "false" buildConfigField "String", "FIXED_REGION_SIDECAR_BASE_URL", "null" buildConfigField "String", "FIXED_REGION_PLAUSIBLE_ANALYTICS_SERVER_URL", "null" } diff --git a/onebusaway-android/flavors/oba.gradle b/onebusaway-android/flavors/oba.gradle index 26743b8e97..32be61f315 100644 --- a/onebusaway-android/flavors/oba.gradle +++ b/onebusaway-android/flavors/oba.gradle @@ -40,8 +40,6 @@ android.productFlavors { buildConfigField "String", "FIXED_REGION_PAYMENT_ANDROID_APP_ID", "null" buildConfigField "String", "FIXED_REGION_PAYMENT_WARNING_TITLE", "null" buildConfigField "String", "FIXED_REGION_PAYMENT_WARNING_BODY", "null" - buildConfigField "boolean", "FIXED_REGION_TRAVEL_BEHAVIOR_DATA_COLLECTION", "false" - buildConfigField "boolean", "FIXED_REGION_ENROLL_PARTICIPANTS_IN_STUDY", "false" buildConfigField "String", "FIXED_REGION_SIDECAR_BASE_URL", "\"https://onebusaway.co\"" buildConfigField "String", "FIXED_REGION_PLAUSIBLE_ANALYTICS_SERVER_URL", "null" } diff --git a/onebusaway-android/schemas/org.onebusaway.android.database.AppDatabase/3.json b/onebusaway-android/schemas/org.onebusaway.android.database.AppDatabase/3.json new file mode 100644 index 0000000000..b9abd13be9 --- /dev/null +++ b/onebusaway-android/schemas/org.onebusaway.android.database.AppDatabase/3.json @@ -0,0 +1,785 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "441a8118e4ad3479b769cdd5011957ee", + "entities": [ + { + "tableName": "studies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`study_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `is_subscribed` INTEGER NOT NULL, PRIMARY KEY(`study_id`))", + "fields": [ + { + "fieldPath": "study_id", + "columnName": "study_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "is_subscribed", + "columnName": "is_subscribed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "study_id" + ] + } + }, + { + "tableName": "surveys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`survey_id` INTEGER NOT NULL, `study_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`survey_id`), FOREIGN KEY(`study_id`) REFERENCES `studies`(`study_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "survey_id", + "columnName": "survey_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "study_id", + "columnName": "study_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "survey_id" + ] + }, + "foreignKeys": [ + { + "table": "studies", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "study_id" + ], + "referencedColumns": [ + "study_id" + ] + } + ] + }, + { + "tableName": "alerts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "stops", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` TEXT NOT NULL, `code` TEXT NOT NULL, `name` TEXT NOT NULL, `direction` TEXT NOT NULL, `use_count` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `user_name` TEXT, `access_time` INTEGER, `favorite` INTEGER, `region_id` INTEGER, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useCount", + "columnName": "use_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "user_name", + "affinity": "TEXT" + }, + { + "fieldPath": "accessTime", + "columnName": "access_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "regionId", + "columnName": "region_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "routes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` TEXT NOT NULL, `short_name` TEXT NOT NULL, `long_name` TEXT, `use_count` INTEGER NOT NULL, `user_name` TEXT, `access_time` INTEGER, `favorite` INTEGER, `url` TEXT, `region_id` INTEGER, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "useCount", + "columnName": "use_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "user_name", + "affinity": "TEXT" + }, + { + "fieldPath": "accessTime", + "columnName": "access_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "regionId", + "columnName": "region_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "trips", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` TEXT NOT NULL, `stop_id` TEXT NOT NULL, `route_id` TEXT, `departure` INTEGER NOT NULL, `headsign` TEXT, `name` TEXT NOT NULL, `reminder` INTEGER NOT NULL, `alarm_delete_path` TEXT NOT NULL, `service_date` INTEGER NOT NULL, `stop_sequence` INTEGER NOT NULL, `trip_id` TEXT NOT NULL, `vehicle_id` TEXT, PRIMARY KEY(`_id`, `stop_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stopId", + "columnName": "stop_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "routeId", + "columnName": "route_id", + "affinity": "TEXT" + }, + { + "fieldPath": "departure", + "columnName": "departure", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "headsign", + "columnName": "headsign", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reminder", + "columnName": "reminder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alarmDeletePath", + "columnName": "alarm_delete_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serviceDate", + "columnName": "service_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stopSequence", + "columnName": "stop_sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tripId", + "columnName": "trip_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vehicleId", + "columnName": "vehicle_id", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id", + "stop_id" + ] + } + }, + { + "tableName": "stop_routes_filter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stop_id` TEXT NOT NULL, `route_id` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stopId", + "columnName": "stop_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "routeId", + "columnName": "route_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "trip_alerts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trip_id` TEXT NOT NULL, `stop_id` TEXT NOT NULL, `start_time` INTEGER NOT NULL, `state` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tripId", + "columnName": "trip_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stopId", + "columnName": "stop_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTime", + "columnName": "start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "service_alerts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` TEXT NOT NULL, `marked_read_time` INTEGER, `hidden` INTEGER, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markedReadTime", + "columnName": "marked_read_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + } + }, + { + "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, `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", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "obaBaseUrl", + "columnName": "oba_base_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "siriBaseUrl", + "columnName": "siri_base_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmail", + "columnName": "contact_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "supportsObaDiscovery", + "columnName": "supports_api_discovery", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supportsObaRealtime", + "columnName": "supports_api_realtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supportsSiriRealtime", + "columnName": "supports_siri_realtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twitterUrl", + "columnName": "twitter_url", + "affinity": "TEXT" + }, + { + "fieldPath": "experimental", + "columnName": "experimental", + "affinity": "INTEGER" + }, + { + "fieldPath": "stopInfoUrl", + "columnName": "stop_info_url", + "affinity": "TEXT" + }, + { + "fieldPath": "otpBaseUrl", + "columnName": "otp_base_url", + "affinity": "TEXT" + }, + { + "fieldPath": "otpContactEmail", + "columnName": "otp_contact_email", + "affinity": "TEXT" + }, + { + "fieldPath": "supportsOtpBikeshare", + "columnName": "supports_otp_bikeshare", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportsEmbeddedSocial", + "columnName": "supports_embedded_social", + "affinity": "INTEGER" + }, + { + "fieldPath": "paymentAndroidAppId", + "columnName": "payment_android_app_id", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentWarningTitle", + "columnName": "payment_warning_title", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentWarningBody", + "columnName": "payment_warning_body", + "affinity": "TEXT" + }, + { + "fieldPath": "sidecarBaseUrl", + "columnName": "sidecar_base_url", + "affinity": "TEXT", + "defaultValue": "'https://onebusaway.co'" + }, + { + "fieldPath": "plausibleAnalyticsServerUrl", + "columnName": "plausible_analytics_server_url", + "affinity": "TEXT" + }, + { + "fieldPath": "umamiAnalyticsUrl", + "columnName": "umami_analytics_url", + "affinity": "TEXT" + }, + { + "fieldPath": "umamiAnalyticsId", + "columnName": "umami_analytics_id", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "region_bounds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `region_id` INTEGER NOT NULL, `lat` REAL NOT NULL, `lon` REAL NOT NULL, `lat_span` REAL NOT NULL, `lon_span` REAL NOT NULL, FOREIGN KEY(`region_id`) REFERENCES `regions`(`_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "regionId", + "columnName": "region_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "lat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "lon", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "latSpan", + "columnName": "lat_span", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lonSpan", + "columnName": "lon_span", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + }, + "indices": [ + { + "name": "index_region_bounds_region_id", + "unique": false, + "columnNames": [ + "region_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_region_bounds_region_id` ON `${TABLE_NAME}` (`region_id`)" + } + ], + "foreignKeys": [ + { + "table": "regions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "region_id" + ], + "referencedColumns": [ + "_id" + ] + } + ] + }, + { + "tableName": "open311_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `region_id` INTEGER NOT NULL, `jurisdiction` TEXT, `api_key` TEXT NOT NULL, `open311_base_url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "regionId", + "columnName": "region_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "jurisdiction", + "columnName": "jurisdiction", + "affinity": "TEXT" + }, + { + "fieldPath": "apiKey", + "columnName": "api_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "open311_base_url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "route_headsign_favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `route_id` TEXT NOT NULL, `headsign` TEXT NOT NULL, `stop_id` TEXT NOT NULL, `exclude` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "routeId", + "columnName": "route_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headsign", + "columnName": "headsign", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stopId", + "columnName": "stop_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exclude", + "columnName": "exclude", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "nav_stops", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nav_id` TEXT NOT NULL, `start_time` INTEGER NOT NULL, `trip_id` TEXT NOT NULL, `destination_id` TEXT NOT NULL, `before_id` TEXT NOT NULL, `seq_num` INTEGER NOT NULL, `is_active` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "navId", + "columnName": "nav_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTime", + "columnName": "start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tripId", + "columnName": "trip_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationId", + "columnName": "destination_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beforeId", + "columnName": "before_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sequence", + "columnName": "seq_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '441a8118e4ad3479b769cdd5011957ee')" + ] + } +} \ No newline at end of file diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/api/test/RegionsTest.java b/onebusaway-android/src/androidTest/java/org/onebusaway/android/api/test/RegionsTest.java deleted file mode 100644 index d79ef424b8..0000000000 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/api/test/RegionsTest.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (C) 2014-2017 Paul Watts, - * University of South Florida (sjbarbeau@gmail.com) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.onebusaway.android.api.test; - -import org.junit.Test; -import org.onebusaway.android.region.Region; -import org.onebusaway.android.provider.ObaContract; - -import android.content.ContentResolver; -import android.content.ContentValues; - -import static androidx.test.InstrumentationRegistry.getTargetContext; -import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.assertNotNull; - -/** - * Instrumented coverage for region ContentProvider persistence. The wire parsing of the `regions` - * endpoint moved to the JVM RegionsDecodeTest when the endpoint migrated to the Retrofit client; - * what remains here is the DB round-trip, which genuinely needs the provider. - */ -public class RegionsTest extends ObaTestCase { - - @Test - public void testUmamiAnalyticsPersistenceRoundTrip() { - ContentResolver cr = getTargetContext().getContentResolver(); - int id = 987654; - - ContentValues values = new ContentValues(); - values.put(ObaContract.Regions._ID, id); - values.put(ObaContract.Regions.NAME, "Umami Persist Region"); - values.put(ObaContract.Regions.OBA_BASE_URL, "https://api.example.com/"); - values.put(ObaContract.Regions.SIRI_BASE_URL, ""); - values.put(ObaContract.Regions.LANGUAGE, "en_US"); - values.put(ObaContract.Regions.CONTACT_EMAIL, "test@example.com"); - values.put(ObaContract.Regions.SUPPORTS_OBA_DISCOVERY, 1); - values.put(ObaContract.Regions.SUPPORTS_OBA_REALTIME, 1); - values.put(ObaContract.Regions.SUPPORTS_SIRI_REALTIME, 0); - values.put(ObaContract.Regions.UMAMI_ANALYTICS_URL, "https://umami.example.com"); - values.put(ObaContract.Regions.UMAMI_ANALYTICS_ID, "uuid-persist-1"); - ObaContract.Regions.insertOrUpdate(getTargetContext(), id, values); - - Region region = ObaContract.Regions.get(cr, id); - assertNotNull(region); - assertEquals("https://umami.example.com", region.getUmamiAnalyticsUrl()); - assertEquals("uuid-persist-1", region.getUmamiAnalyticsId()); - - cr.delete(ObaContract.Regions.buildUri(id), null, null); - } -} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/AppDatabaseMigrationTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/AppDatabaseMigrationTest.kt new file mode 100644 index 0000000000..f60feba52b --- /dev/null +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/AppDatabaseMigrationTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database + +import androidx.room.testing.MigrationTestHelper +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Validates that [MIGRATION_2_3] produces a schema matching the exported `3.json` and that the dead + * recentStops `stops`/`regions` tables are dropped and replaced with the legacy-schema tables (created + * empty — the data import from the legacy ContentProvider DB is a separate slice). + */ +@RunWith(AndroidJUnit4::class) +class AppDatabaseMigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java + ) + + @Test + fun migrate2To3_dropsRecentStopsTables_andCreatesLegacyTables() { + // v2: seed the recentStops regions/stops tables (their schema, not the legacy one). + helper.createDatabase(TEST_DB, 2).use { db -> + db.execSQL("INSERT INTO regions (regionId) VALUES (1)") + db.execSQL( + "INSERT INTO stops (stop_id, name, regionId, timestamp) VALUES ('1_1', 'A', 1, 123)" + ) + } + + // runMigrationsAndValidate asserts the resulting schema matches the entities/3.json. + val db = helper.runMigrationsAndValidate(TEST_DB, 3, true, MIGRATION_2_3) + + // The recentStops rows are gone (tables dropped + recreated with the legacy schema), and the + // new legacy tables exist and are empty. + for (table in listOf("stops", "routes", "trips", "regions", "service_alerts", "nav_stops")) { + db.query("SELECT count(*) FROM $table").use { c -> + c.moveToFirst() + assertEquals("expected empty $table", 0, c.getInt(0)) + } + } + // The new stops table has the legacy column the recentStops one lacked. + db.query("SELECT favorite, user_name, region_id FROM stops").use { /* no-op: column resolves */ } + db.close() + } + + private companion object { + const val TEST_DB = "migration-test-db" + } +} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/LegacyDataImporterTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/LegacyDataImporterTest.kt new file mode 100644 index 0000000000..37da6a3b06 --- /dev/null +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/LegacyDataImporterTest.kt @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.onebusaway.android.database.AppDatabase +import org.onebusaway.android.preferences.PreferencesRepository + +/** + * Verifies the one-time legacy-to-Room data import preserves user data and is idempotent. The seeded + * legacy `regions` table deliberately omits the late (v31–v33) columns to also exercise the importer's + * defensive missing-column reads (an old legacy file must import without a "no such column" failure). + */ +@RunWith(AndroidJUnit4::class) +class LegacyDataImporterTest { + + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + private lateinit var room: AppDatabase + private lateinit var legacyFile: File + private lateinit var importer: LegacyDataImporter + + @Before + fun setUp() { + room = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + legacyFile = File.createTempFile("legacy-oba", ".db", context.cacheDir) + importer = LegacyDataImporter(context, room, NoopPreferences) + seedLegacy(legacyFile) + } + + @After + fun tearDown() { + room.close() + legacyFile.delete() + } + + @Test + fun importsAllTablesAndPreservesUserData() = runBlocking { + importer.importFrom(legacyFile) + + assertEquals(1, count("stops")) + assertEquals(1, count("routes")) + assertEquals(1, count("trips")) + assertEquals(1, count("stop_routes_filter")) + assertEquals(1, count("trip_alerts")) + assertEquals(1, count("service_alerts")) + assertEquals(1, count("regions")) + assertEquals(1, count("region_bounds")) + assertEquals(1, count("open311_servers")) + assertEquals(1, count("route_headsign_favorites")) + assertEquals(1, count("nav_stops")) + + // Load-bearing user state survives. + assertEquals(1, scalarInt("SELECT favorite FROM stops WHERE _id='1_100'")) + assertEquals("My Stop", scalarStr("SELECT user_name FROM stops WHERE _id='1_100'")) + assertEquals(5, scalarInt("SELECT reminder FROM trips WHERE _id='trip1'")) + assertEquals(1, scalarInt("SELECT hidden FROM service_alerts WHERE _id='sit1'")) + // regions._id = 0 is preserved (not reassigned) and region_bounds.region_id still points at it. + assertEquals(0, scalarInt("SELECT _id FROM regions LIMIT 1")) + assertEquals(0, scalarInt("SELECT region_id FROM region_bounds LIMIT 1")) + // A late column missing from the seeded legacy file imported as NULL, not a crash. + assertEquals(null, scalarStr("SELECT umami_analytics_url FROM regions LIMIT 1")) + } + + @Test + fun importFailure_rollsBackLeavingRoomEmpty_soRetryIsClean() = runBlocking { + // Corrupt the seeded file so a region_bounds row references a region that isn't present: the + // import will hit a FOREIGN KEY failure part-way through. + SQLiteDatabase.openDatabase(legacyFile.path, null, SQLiteDatabase.OPEN_READWRITE).use { + it.execSQL("UPDATE region_bounds SET region_id = 999") + } + + var threw = false + try { + importer.importFrom(legacyFile) + } catch (e: Exception) { + threw = true + } + + // The whole replaceAll is one transaction, so a mid-import failure rolls everything back — no + // partial/corrupt state. That is what lets ImportGate swallow the failure and leave the legacy + // file in place for a clean retry next launch instead of crash-looping. See ImportGate. + assertEquals(true, threw) + assertEquals(0, count("regions")) + assertEquals(0, count("stops")) + assertEquals(0, count("region_bounds")) + } + + @Test + fun importsSurveyDb() = runBlocking { + val surveyFile = File.createTempFile("study-survey", ".db", context.cacheDir) + try { + SQLiteDatabase.openOrCreateDatabase(surveyFile, null).use { db -> + db.execSQL( + "CREATE TABLE studies (study_id INTEGER PRIMARY KEY, name TEXT, description TEXT, " + + "is_subscribed INTEGER)" + ) + db.execSQL("INSERT INTO studies VALUES (7,'Study','Desc',1)") + db.execSQL( + "CREATE TABLE surveys (survey_id INTEGER PRIMARY KEY, study_id INTEGER, name TEXT, " + + "state INTEGER)" + ) + db.execSQL("INSERT INTO surveys VALUES (3,7,'Survey',1)") + } + importer.importSurveyFrom(surveyFile) + + assertEquals(1, count("studies")) + assertEquals(1, count("surveys")) + assertEquals(7, scalarInt("SELECT study_id FROM surveys WHERE survey_id=3")) + + // Idempotent: a second import (e.g. an ImportGate retry) must not duplicate rows — the + // primary keys dedupe to one row (studies via IGNORE, surveys via REPLACE). + importer.importSurveyFrom(surveyFile) + assertEquals(1, count("studies")) + assertEquals(1, count("surveys")) + } finally { + surveyFile.delete() + } + } + + @Test + fun importSurveyFrom_skipsSurveysWithMissingStudy() = runBlocking { + val surveyFile = File.createTempFile("study-survey-orphan", ".db", context.cacheDir) + try { + SQLiteDatabase.openOrCreateDatabase(surveyFile, null).use { db -> + db.execSQL( + "CREATE TABLE studies (study_id INTEGER PRIMARY KEY, name TEXT, description TEXT, " + + "is_subscribed INTEGER)" + ) + db.execSQL("INSERT INTO studies VALUES (7,'Study','Desc',1)") + db.execSQL( + "CREATE TABLE surveys (survey_id INTEGER PRIMARY KEY, study_id INTEGER, name TEXT, " + + "state INTEGER)" + ) + db.execSQL("INSERT INTO surveys VALUES (3,7,'Valid',1)") // references the imported study + db.execSQL("INSERT INTO surveys VALUES (4,99,'Orphan',1)") // references a missing study + } + importer.importSurveyFrom(surveyFile) + + // The orphan (study_id=99) is dropped rather than FK-aborting the whole import; the valid + // survey survives. + assertEquals(1, count("surveys")) + assertEquals(0, scalarInt("SELECT count(*) FROM surveys WHERE survey_id=4")) + } finally { + surveyFile.delete() + } + } + + @Test + fun reimportIsIdempotent() = runBlocking { + importer.importFrom(legacyFile) + importer.importFrom(legacyFile) + + assertEquals(1, count("stops")) + assertEquals(1, count("stop_routes_filter")) + assertEquals(1, count("route_headsign_favorites")) + } + + private fun count(table: String): Int = scalarInt("SELECT count(*) FROM $table") + + private fun scalarInt(sql: String): Int = + room.query(SimpleSQLiteQuery(sql)).use { it.moveToFirst(); it.getInt(0) } + + private fun scalarStr(sql: String): String? = + room.query(SimpleSQLiteQuery(sql)).use { + it.moveToFirst() + if (it.isNull(0)) null else it.getString(0) + } + + /** Builds a legacy ObaProvider-shaped SQLite file with one meaningful row per table. */ + private fun seedLegacy(file: File) { + SQLiteDatabase.openOrCreateDatabase(file, null).use { db -> + db.execSQL( + "CREATE TABLE stops (_id VARCHAR PRIMARY KEY, code VARCHAR, name VARCHAR, " + + "direction CHAR[2], use_count INTEGER, latitude DOUBLE, longitude DOUBLE, " + + "user_name VARCHAR, access_time INTEGER, favorite INTEGER, region_id INTEGER)" + ) + db.execSQL( + "INSERT INTO stops VALUES ('1_100','100','Main St & 1st','N',5,47.6,-122.3," + + "'My Stop',1000,1,1)" + ) + db.execSQL( + "CREATE TABLE routes (_id VARCHAR PRIMARY KEY, short_name VARCHAR, long_name VARCHAR, " + + "use_count INTEGER, user_name VARCHAR, access_time INTEGER, favorite INTEGER, " + + "url VARCHAR, region_id INTEGER)" + ) + db.execSQL( + "INSERT INTO routes VALUES ('1_10','10','Downtown',3,NULL,2000,1,'http://r',1)" + ) + db.execSQL( + "CREATE TABLE trips (_id VARCHAR, stop_id VARCHAR, route_id VARCHAR, departure INTEGER, " + + "headsign VARCHAR, name VARCHAR, reminder INTEGER, alarm_delete_path VARCHAR, " + + "service_date INTEGER, stop_sequence INTEGER, trip_id VARCHAR, vehicle_id VARCHAR)" + ) + db.execSQL( + "INSERT INTO trips VALUES ('trip1','1_100','1_10',480,'Downtown','My Trip',5," + + "'/delete',123456,2,'trip1','veh1')" + ) + db.execSQL( + "CREATE TABLE stop_routes_filter (stop_id VARCHAR, route_id VARCHAR)" + ) + db.execSQL("INSERT INTO stop_routes_filter VALUES ('1_100','1_10')") + db.execSQL( + "CREATE TABLE trip_alerts (_id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id VARCHAR, " + + "stop_id VARCHAR, start_time INTEGER, state INTEGER)" + ) + db.execSQL("INSERT INTO trip_alerts (trip_id, stop_id, start_time, state) " + + "VALUES ('trip1','1_100',999,1)") + db.execSQL( + "CREATE TABLE service_alerts (_id VARCHAR PRIMARY KEY, marked_read_time INTEGER, " + + "hidden INTEGER)" + ) + db.execSQL("INSERT INTO service_alerts VALUES ('sit1',5000,1)") + // Regions: v17-era columns only (no sidecar/plausible/umami) to exercise defensive reads. + db.execSQL( + "CREATE TABLE regions (_id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR, " + + "oba_base_url VARCHAR, siri_base_url VARCHAR, lang VARCHAR, contact_email VARCHAR, " + + "supports_api_discovery INTEGER, supports_api_realtime INTEGER, " + + "supports_siri_realtime INTEGER)" + ) + db.execSQL( + // _id = 0 on purpose: Tampa Bay is region 0 in production, and a 0-valued autoGenerate + // PK would be reassigned by SQLite, orphaning the region_bounds FK. Regression for that. + "INSERT INTO regions (_id, name, oba_base_url, siri_base_url, lang, contact_email, " + + "supports_api_discovery, supports_api_realtime, supports_siri_realtime) " + + "VALUES (0,'Puget Sound','http://oba','http://siri','en','e@x.com',1,1,0)" + ) + db.execSQL( + "CREATE TABLE region_bounds (_id INTEGER PRIMARY KEY AUTOINCREMENT, region_id INTEGER, " + + "lat REAL, lon REAL, lat_span REAL, lon_span REAL)" + ) + db.execSQL("INSERT INTO region_bounds (region_id, lat, lon, lat_span, lon_span) " + + "VALUES (0,47.6,-122.3,0.5,0.5)") + db.execSQL( + "CREATE TABLE open311_servers (_id INTEGER PRIMARY KEY AUTOINCREMENT, region_id INTEGER, " + + "jurisdiction VARCHAR, api_key VARCHAR, open311_base_url VARCHAR)" + ) + db.execSQL("INSERT INTO open311_servers (region_id, jurisdiction, api_key, open311_base_url) " + + "VALUES (1,'jur','key','http://311')") + db.execSQL( + "CREATE TABLE route_headsign_favorites (route_id VARCHAR, headsign VARCHAR, " + + "stop_id VARCHAR, exclude INTEGER)" + ) + db.execSQL("INSERT INTO route_headsign_favorites VALUES ('1_10','Downtown','1_100',0)") + db.execSQL( + "CREATE TABLE nav_stops (_id INTEGER PRIMARY KEY AUTOINCREMENT, nav_id VARCHAR, " + + "start_time INTEGER, trip_id VARCHAR, destination_id VARCHAR, before_id VARCHAR, " + + "seq_num INTEGER, is_active INTEGER)" + ) + db.execSQL("INSERT INTO nav_stops (nav_id, start_time, trip_id, destination_id, before_id, " + + "seq_num, is_active) VALUES ('nav',111,'trip1','1_100','1_99',0,1)") + } + } +} + +/** Minimal stub — [LegacyDataImporter.importFrom] under test does not touch preferences. */ +private object NoopPreferences : PreferencesRepository { + override fun observeBoolean(keyRes: Int, default: Boolean) = throw NotImplementedError() + override fun observeString(keyRes: Int, default: String?) = throw NotImplementedError() + override fun observeChanges() = throw NotImplementedError() + override fun getBoolean(keyRes: Int, default: Boolean) = default + override fun getBoolean(key: String, default: Boolean) = default + override fun getString(keyRes: Int, default: String?) = default + override fun getString(key: String, default: String?) = default + override fun getInt(keyRes: Int, default: Int) = default + override fun getInt(key: String, default: Int) = default + override fun getLong(keyRes: Int, default: Long) = default + override fun getLong(key: String, default: Long) = default + override fun getFloat(keyRes: Int, default: Float) = default + override fun getFloat(key: String, default: Float) = default + override fun setBoolean(keyRes: Int, value: Boolean) = Unit + override fun setBoolean(key: String, value: Boolean) = Unit + override fun setString(keyRes: Int, value: String?) = Unit + override fun setString(key: String, value: String?) = Unit + override fun setInt(keyRes: Int, value: Int) = Unit + override fun setInt(key: String, value: Int) = Unit + override fun setLong(keyRes: Int, value: Long) = Unit + override fun setLong(key: String, value: Long) = Unit + override fun setFloat(keyRes: Int, value: Float) = Unit + override fun setFloat(key: String, value: Float) = Unit +} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/RegionDaoReplaceAllTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/RegionDaoReplaceAllTest.kt new file mode 100644 index 0000000000..1aba33f262 --- /dev/null +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/RegionDaoReplaceAllTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.onebusaway.android.database.AppDatabase + +/** + * Verifies [RegionDao.replaceAll] against a real Room DB: it clears all three region tables and + * reinserts each region with its `@Relation` children, `open311_servers` is cleared explicitly (no FK) + * while `region_bounds` cascades, and region id 0 (Tampa) survives as a real, non-reassigned key. + */ +@RunWith(AndroidJUnit4::class) +class RegionDaoReplaceAllTest { + + private lateinit var db: AppDatabase + private lateinit var dao: RegionDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java + ).build() + dao = db.regionDao() + } + + @After + fun tearDown() = db.close() + + @Test + fun replaceAll_insertsRegionWithChildren_andPreservesRegionZero() = runBlocking { + dao.replaceAll(listOf(regionWithChildren(0))) + + val hydrated = dao.getRegion(0)!! + assertEquals(0L, hydrated.region.id) // Tampa id 0 not reassigned + assertEquals(1, hydrated.bounds.size) + assertEquals(1, hydrated.open311Servers.size) + } + + @Test + fun replaceAll_clearsPreviousRegionsAndChildren() = runBlocking { + dao.replaceAll(listOf(regionWithChildren(0), regionWithChildren(1))) + + // Replace with a different set: region 5, and a region with no open311 servers. + dao.replaceAll(listOf(regionWithChildren(5, servers = 0))) + + assertNull(dao.getRegion(0)) // old regions gone + assertNull(dao.getRegion(1)) + assertEquals(1, dao.getAllRegions().size) + val r5 = dao.getRegion(5)!! + assertEquals(1, r5.bounds.size) // region_bounds reinserted + assertEquals(0, r5.open311Servers.size) // open311 cleared, none reinserted + } + + private fun regionWithChildren(id: Long, servers: Int = 1) = RegionWithChildren( + region = RegionRecord( + id = id, + name = "R$id", + obaBaseUrl = "http://oba/$id", + siriBaseUrl = "http://siri/$id", + language = "en", + contactEmail = "e@x", + supportsObaDiscovery = 1, + supportsObaRealtime = 1, + supportsSiriRealtime = 0, + ), + bounds = listOf( + RegionBoundRecord(regionId = id, latitude = 1.0, longitude = 2.0, latSpan = 0.1, lonSpan = 0.1) + ), + open311Servers = if (servers == 0) emptyList() else listOf( + Open311ServerRecord(regionId = id, apiKey = "k$id", baseUrl = "http://311/$id") + ), + ) +} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/RouteDaoTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/RouteDaoTest.kt new file mode 100644 index 0000000000..75f9d3bb23 --- /dev/null +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/RouteDaoTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.onebusaway.android.database.AppDatabase + +/** + * Verifies [RouteDao]'s `@Transaction` merge-on-write helpers against a real (in-memory) Room DB — the + * classic silent-data-loss shape where re-recording a route must not clobber columns another path set. + */ +@RunWith(AndroidJUnit4::class) +class RouteDaoTest { + + private lateinit var db: AppDatabase + private lateinit var dao: RouteDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java + ).build() + dao = db.routeDao() + } + + @After + fun tearDown() = db.close() + + @Test + fun markRouteUsed_freshRoute_startsAtUseCountOne() = runBlocking { + dao.markRouteUsed("r2", "20", "Route 20", regionId = 2L, now = 50) + + val r = dao.getRoute("r2")!! + assertEquals(1, r.useCount) + assertEquals(50L, r.accessTime) + assertEquals(2L, r.regionId) + assertNull(r.url) + } + + @Test + fun markRouteUsed_incrementsAndPreservesUrlAndRegion() = runBlocking { + // storeRouteDetails sets the URL; a later markRouteUsed must not wipe it. + dao.storeRouteDetails("r1", "10", "Route 10", "http://u", regionId = 1L, now = 100) + assertEquals("http://u", dao.getRoute("r1")!!.url) + + dao.markRouteUsed("r1", "10", "Route 10 renamed", regionId = null, now = 200) + + val r = dao.getRoute("r1")!! + assertEquals(2, r.useCount) + assertEquals(200L, r.accessTime) + assertEquals("http://u", r.url) // NOT clobbered by markRouteUsed + assertEquals(1L, r.regionId) // regionId = null keeps the existing region + assertEquals("10", r.shortName) // short/long names refreshed + assertEquals("Route 10 renamed", r.longName) + } + + @Test + fun refreshRouteShortName_onlyTouchesShortName() = runBlocking { + dao.storeRouteDetails("r3", "30", "Route 30", "http://u", regionId = 3L, now = 100) + + dao.refreshRouteShortName("r3", "30-express") + + val r = dao.getRoute("r3")!! + assertEquals("30-express", r.shortName) + assertEquals("http://u", r.url) // untouched + assertEquals("Route 30", r.longName) // untouched + assertEquals(1, r.useCount) // did not mark used + } +} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteWriterTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteWriterTest.kt new file mode 100644 index 0000000000..93bbdb879d --- /dev/null +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteWriterTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.onebusaway.android.database.AppDatabase + +/** + * Verifies the route/headsign favorite write-side reconciliation ([applyRouteHeadsignFavorite]) against + * a real Room DB — the branchiest storage-write logic, whose read counterpart ([computeRouteHeadsignFavorite]) + * is separately unit-tested. Exercises the favorite/exclude/all-stops/route-star matrix end to end. + */ +@RunWith(AndroidJUnit4::class) +class RouteHeadsignFavoriteWriterTest { + + private lateinit var db: AppDatabase + private lateinit var headsignDao: RouteHeadsignFavoriteDao + private lateinit var routeDao: RouteDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java + ).build() + headsignDao = db.routeHeadsignFavoriteDao() + routeDao = db.routeDao() + // The route row must exist for the star-flag reconciliation to update it. + runBlocking { routeDao.upsert(RouteRecord(id = "r1", shortName = "10", useCount = 0)) } + } + + @After + fun tearDown() = db.close() + + private suspend fun apply(headsign: String?, stopId: String?, favorite: Boolean) = + applyRouteHeadsignFavorite(headsignDao, routeDao, "r1", headsign, stopId, favorite) + + @Test + fun favoriteSingleStop_insertsExcludeZeroRow_andStarsRoute() = runBlocking { + apply(headsign = "Downtown", stopId = "s1", favorite = true) + + val rows = headsignDao.favoritesForStopOrAll("s1") + assertEquals(1, rows.size) + assertEquals(0, rows[0].exclude) + assertEquals(1, routeDao.getRoute("r1")!!.favorite) + assertTrue(headsignDao.routeHasFavorite("r1")) + } + + @Test + fun favoritesForStopOrAll_matchesStopAndWildcard_butNotOtherStop() = runBlocking { + apply("D", "s1", favorite = true) + apply("D", stopId = null, favorite = true) // all stops + apply("D", "s2", favorite = true) + + val forS1 = headsignDao.favoritesForStopOrAll("s1").map { it.stopId }.toSet() + assertEquals(setOf("s1", "all"), forS1) // s2 not visible from s1 + } + + @Test + fun unfavoriteLastStop_clearsRouteStar() = runBlocking { + apply("D", "s1", favorite = true) + assertEquals(1, routeDao.getRoute("r1")!!.favorite) + + apply("D", "s1", favorite = false) + + assertFalse(headsignDao.routeHasFavorite("r1")) + assertEquals(0, routeDao.getRoute("r1")!!.favorite) + } + + @Test + fun unstarSingleStop_whileAllStarred_recordsExclusion_keepsRouteStar() = runBlocking { + apply("D", stopId = null, favorite = true) // star the whole route + assertTrue(computeRouteHeadsignFavorite(headsignDao.favoritesForStopOrAll("s1"), "r1", "D", "s1")) + + apply("D", "s1", favorite = false) // unstar just s1 + + val rows = headsignDao.favoritesForStopOrAll("s1") + assertTrue(rows.any { it.stopId == "s1" && it.exclude == 1 }) // exclusion recorded + assertFalse(computeRouteHeadsignFavorite(rows, "r1", "D", "s1")) // s1 no longer favorite + assertEquals(1, routeDao.getRoute("r1")!!.favorite) // route still starred + } + + @Test + fun unfavoriteAllStops_removesEveryHeadsignRow() = runBlocking { + apply("D", "s1", favorite = true) + apply("D", stopId = null, favorite = true) + + apply("D", stopId = null, favorite = false) // unstar all stops + + assertTrue(headsignDao.favoritesForStopOrAll("s1").isEmpty()) + assertFalse(headsignDao.routeHasFavorite("r1")) + assertEquals(0, routeDao.getRoute("r1")!!.favorite) + } +} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/ServiceAlertDaoTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/ServiceAlertDaoTest.kt new file mode 100644 index 0000000000..2a2ee641ac --- /dev/null +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/ServiceAlertDaoTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.onebusaway.android.database.AppDatabase + +/** + * Verifies [ServiceAlertDao]'s insert-then-update `@Transaction` helpers and the reactive `hideDecisions` + * stream against a real Room DB. The tri-state hidden column (NULL = no decision, 0 = shown, 1 = hidden) + * is #1593-critical: marking read must not fabricate a hide decision, and only explicit decisions stream. + */ +@RunWith(AndroidJUnit4::class) +class ServiceAlertDaoTest { + + private lateinit var db: AppDatabase + private lateinit var dao: ServiceAlertDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java + ).build() + dao = db.serviceAlertDao() + } + + @After + fun tearDown() = db.close() + + @Test + fun markRead_stampsRead_withoutFabricatingAHideDecision() = runBlocking { + dao.markRead("a1", now = 500) + + assertFalse(dao.isHidden("a1")) + assertTrue(dao.hideDecisions().first().isEmpty()) // hidden stayed NULL -> not a decision + } + + @Test + fun setHidden_recordsDecision_andHideDecisionsEmitsIt() = runBlocking { + dao.setHidden("a1", true) + assertTrue(dao.isHidden("a1")) + assertEquals(listOf("a1"), dao.hideDecisions().first().map { it.id }) + + // Flip to shown: still an explicit decision (hidden = 0, not NULL), so it still streams. + dao.setHidden("a1", false) + assertFalse(dao.isHidden("a1")) + assertEquals(1, dao.hideDecisions().first().size) + } + + @Test + fun markReadThenHide_updateSameRow_bothStatesCoexist() = runBlocking { + dao.markRead("a1", now = 100) // inserts row, read stamped, hidden NULL + dao.setHidden("a1", true) // updates the same row's hidden decision + + assertTrue(dao.isHidden("a1")) + assertEquals(1, dao.hideDecisions().first().size) // one row, not a duplicate insert + } +} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/StopDaoMergeTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/StopDaoMergeTest.kt new file mode 100644 index 0000000000..44d1af1c1a --- /dev/null +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/StopDaoMergeTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.onebusaway.android.database.AppDatabase + +/** + * Verifies [StopDao]'s merge-on-write and the region-scoped/`UI_NAME` list queries against a real Room + * DB. The load-bearing invariant: re-recording a stop (arrivals load) must never clobber the user's + * favorite flag or custom name — that's why the row is recorded before a favorite toggle. + */ +@RunWith(AndroidJUnit4::class) +class StopDaoMergeTest { + + private lateinit var db: AppDatabase + private lateinit var dao: StopDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java + ).build() + dao = db.stopDao() + } + + @After + fun tearDown() = db.close() + + @Test + fun markStopUsed_preservesFavoriteAndUserName() = runBlocking { + dao.markStopUsed("s1", "100", "Main St", "N", 47.0, -122.0, regionId = 1L, now = 100) + dao.setFavorite("s1", 1) + dao.upsert(dao.getStop("s1")!!.copy(userName = "Home")) + + // Record again (e.g. a later arrivals load) — favorite + custom name must survive. + dao.markStopUsed("s1", "100", "Main Street", "N", 47.0, -122.0, regionId = 1L, now = 200) + + val s = dao.getStop("s1")!! + assertEquals(1, s.favorite) + assertEquals("Home", s.userName) + assertEquals(2, s.useCount) + assertEquals(200L, s.accessTime) + assertEquals("Main Street", s.name) // identity/coords still refreshed + } + + @Test + fun starredByName_scopesToRegion_andProjectsUiName() = runBlocking { + // region 1, with a custom name (UI_NAME should be the user name) + dao.markStopUsed("s1", "1", "Alpha", "N", 1.0, 1.0, regionId = 1L, now = 1) + dao.setFavorite("s1", 1) + dao.upsert(dao.getStop("s1")!!.copy(userName = "Zulu")) + // region 2 (should be excluded when scoping to region 1) + dao.markStopUsed("s2", "2", "Beta", "N", 2.0, 2.0, regionId = 2L, now = 2) + dao.setFavorite("s2", 1) + // no region (always visible) + dao.markStopUsed("s3", "3", "Gamma", "N", 3.0, 3.0, regionId = null, now = 3) + dao.setFavorite("s3", 1) + + val rows = dao.starredByName(regionId = 1L).first() + + assertEquals(setOf("s1", "s3"), rows.map { it.id }.toSet()) // region 2 excluded + assertEquals("Zulu", rows.first { it.id == "s1" }.uiName) // user_name wins over name + assertEquals("Gamma", rows.first { it.id == "s3" }.uiName) // falls back to name + } + + @Test + fun recents_excludesUnusedAndHonorsRegionScope() = runBlocking { + dao.markStopUsed("s1", "1", "Alpha", "N", 1.0, 1.0, regionId = 1L, now = 10) + dao.markStopUsed("s2", "2", "Beta", "N", 2.0, 2.0, regionId = 2L, now = 20) + dao.markUnused("s1") // use_count -> 0, access_time -> null + + val rows = dao.recents(cutoff = 0, regionId = 2L).first() + + assertEquals(listOf("s2"), rows.map { it.id }) // s1 unused; s2 in region + } +} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/extrapolation/test/ExtrapolatedVehiclesTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/extrapolation/test/ExtrapolatedVehiclesTest.kt index 9afcb1e09e..cc3a0cce46 100644 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/extrapolation/test/ExtrapolatedVehiclesTest.kt +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/extrapolation/test/ExtrapolatedVehiclesTest.kt @@ -62,14 +62,14 @@ class ExtrapolatedVehiclesTest { fun skipsCanceledMissingRefAndPositionlessVehiclesWithoutCrashing() { // route_1 + route_2 requested: only trip_A and trip_B survive; the canceled, ref-less, and // positionless trips are dropped rather than throwing. - val vehicles = extrapolatedVehicles(response().asRouteTrips(),setOf("route_1", "route_2"), nowMs = 1_000_000L, noState) + val vehicles = extrapolatedVehicles(response().asRouteTrips(),setOf("route_1", "route_2"), nowMs = 1_000_000L, lookupState = noState) assertEquals(listOf("trip_A", "trip_B"), vehicles.map { it.status.activeTripId }) } @Test fun placesVehicleAtLastKnownLocationAndReportsFixTimeWhenNoState() { - val vehicles = extrapolatedVehicles(response().asRouteTrips(),setOf("route_1"), nowMs = 1_000_000L, noState) + val vehicles = extrapolatedVehicles(response().asRouteTrips(),setOf("route_1"), nowMs = 1_000_000L, lookupState = noState) assertEquals(1, vehicles.size) val vehicle = vehicles[0] @@ -85,7 +85,7 @@ class ExtrapolatedVehiclesTest { @Test fun fallsBackToPositionWhenNoLastKnownLocation() { - val vehicles = extrapolatedVehicles(response().asRouteTrips(),setOf("route_2"), nowMs = 1_000_000L, noState) + val vehicles = extrapolatedVehicles(response().asRouteTrips(),setOf("route_2"), nowMs = 1_000_000L, lookupState = noState) assertEquals(1, vehicles.size) val vehicle = vehicles[0] @@ -97,9 +97,40 @@ class ExtrapolatedVehiclesTest { @Test fun filtersToRequestedRoutesOnly() { // route_3 serves none of the trips. - assertTrue(extrapolatedVehicles(response().asRouteTrips(),setOf("route_3"), nowMs = 1_000_000L, noState).isEmpty()) + assertTrue(extrapolatedVehicles(response().asRouteTrips(),setOf("route_3"), nowMs = 1_000_000L, lookupState = noState).isEmpty()) // Empty request -> nothing. - assertTrue(extrapolatedVehicles(response().asRouteTrips(),emptySet(), nowMs = 1_000_000L, noState).isEmpty()) + assertTrue(extrapolatedVehicles(response().asRouteTrips(),emptySet(), nowMs = 1_000_000L, lookupState = noState).isEmpty()) + } + + // A trips-for-route response with two vehicles on route_1 heading opposite directions (GTFS + // directionId 0 vs 1) — the "show vehicles on map" direction filter's fixture. + private fun directionResponse(): ObaEnvelope> = + Resources.read(getTargetContext(), Resources.getTestUri("trips_for_route_direction_filter")) + .use { json.decodeFromString(it.readText()) } + + @Test + fun keepsOnlyTheRequestedDirectionWhenDirectionIdGiven() { + val trips = directionResponse().asRouteTrips() + // directionId 0 -> only the outbound vehicle. + assertEquals( + listOf("trip_out"), + extrapolatedVehicles(trips, setOf("route_1"), nowMs = 1_000_000L, directionId = 0, lookupState = noState) + .map { it.status.activeTripId } + ) + // directionId 1 -> only the inbound vehicle (the opposite-direction bus the old view showed). + assertEquals( + listOf("trip_in"), + extrapolatedVehicles(trips, setOf("route_1"), nowMs = 1_000_000L, directionId = 1, lookupState = noState) + .map { it.status.activeTripId } + ) + } + + @Test + fun keepsBothDirectionsWhenDirectionIdNull() { + val vehicles = extrapolatedVehicles( + directionResponse().asRouteTrips(), setOf("route_1"), nowMs = 1_000_000L, lookupState = noState + ) + assertEquals(listOf("trip_out", "trip_in"), vehicles.map { it.status.activeTripId }) } @Test @@ -110,7 +141,7 @@ class ExtrapolatedVehiclesTest { if (tripId == "trip_A") TripState("trip_A", anchorLocalTimeMs = 999_000L) else null } - val vehicle = extrapolatedVehicles(response().asRouteTrips(),setOf("route_1"), nowMs = 1_000_000L, anchored).single() + val vehicle = extrapolatedVehicles(response().asRouteTrips(),setOf("route_1"), nowMs = 1_000_000L, lookupState = anchored).single() assertEquals(999_000L, vehicle.fixTimeMs) assertEquals(47.20, vehicle.point.latitude, 1e-6) diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/mock/ArrivalsFixtures.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/mock/ArrivalsFixtures.kt index a3c1595e7f..0fd80b3482 100644 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/mock/ArrivalsFixtures.kt +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/mock/ArrivalsFixtures.kt @@ -45,14 +45,22 @@ object ArrivalsFixtures { private fun snapshot(env: ObaEnvelope>): StopArrivals = StopArrivals(env.data!!, env.currentTime, 0) - /** The fixture's arrivals projected to display [ArrivalInfo] via the production [convertArrivals]. */ + /** + * The fixture's arrivals projected to display [ArrivalInfo] via the production [convertArrivals]. + * [favorite] supplies each row's route/headsign favorite state (defaults to none); the favorite + * store is no longer a ContentProvider, so tests pass the favorites in directly. + */ @JvmStatic + @JvmOverloads fun convert( context: Context, env: ObaEnvelope>, includeArriveDepartLabels: Boolean, + favorite: (routeId: String, headsign: String?, stopId: String) -> Boolean = { _, _, _ -> false }, ): ArrayList = ArrayList( - convertArrivals(context, snapshot(env).arrivals, null, env.currentTime, includeArriveDepartLabels) + convertArrivals( + context, snapshot(env).arrivals, null, env.currentTime, includeArriveDepartLabels, favorite + ) ) /** All situations (stop/agency + route alerts) for the fixture, via the production aggregation. */ diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/mock/MockRegion.java b/onebusaway-android/src/androidTest/java/org/onebusaway/android/mock/MockRegion.java index 1ee2ce0a5b..ab1f1283ab 100644 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/mock/MockRegion.java +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/mock/MockRegion.java @@ -93,8 +93,6 @@ public static Region getRegionWithPathNoSeparator(Context context) { "co.bytemark.hart", null, null, - false, - false, "https://onebusaway.co", null, null); // UmamiAnalyticsConfig — disabled in test regions @@ -136,8 +134,6 @@ public static Region getRegionNoSeparator(Context context) { null, null, null, - false, - false, "https://onebusaway.co", null, null); // UmamiAnalyticsConfig — disabled in test regions @@ -179,8 +175,6 @@ public static Region getRegionWithPort(Context context) { "co.bytemark.hart", null, null, - false, - false, "https://onebusaway.co", null, null); // UmamiAnalyticsConfig — disabled in test regions @@ -222,8 +216,6 @@ public static Region getRegionNoScheme(Context context) { "co.bytemark.hart", null, null, - false, - false, "https://onebusaway.co", null, null); // UmamiAnalyticsConfig — disabled in test regions @@ -265,8 +257,6 @@ public static Region getRegionWithHttps() { "co.bytemark.hart", null, null, - false, - false, "https://onebusaway.co", null, null); // UmamiAnalyticsConfig — disabled in test regions @@ -308,8 +298,6 @@ public static Region getRegionWithHttpsAndPort() { "co.bytemark.hart", null, null, - false, - false, "https://onebusaway.co", null, null); // UmamiAnalyticsConfig — disabled in test regions @@ -349,8 +337,6 @@ public static Region getRegionWithoutObaApis(Context context) { "co.bytemark.hart", null, null, - false, - false, "https://onebusaway.co", null, null); // UmamiAnalyticsConfig — disabled in test regions @@ -390,8 +376,6 @@ public static Region getInactiveRegion(Context context) { "co.bytemark.hart", null, null, - false, - false, "https://onebusaway.co", null, null); // UmamiAnalyticsConfig — disabled in test regions diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/ProviderTest.java b/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/ProviderTest.java deleted file mode 100644 index 41781fc952..0000000000 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/ProviderTest.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright (C) 2010 Paul Watts (paulcwatts@gmail.com) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.onebusaway.android.provider.test; - -import org.junit.Test; -import org.onebusaway.android.provider.ObaContract; -import org.onebusaway.android.provider.ObaProvider; - -import android.content.ContentResolver; -import android.content.ContentValues; -import android.database.Cursor; -import android.net.Uri; -import android.test.ProviderTestCase2; - -/** - * Tests the provider that stores and reads persistent OBA data on the device - */ -public class ProviderTest extends ProviderTestCase2 { - - public ProviderTest() { - super(ObaProvider.class, ObaContract.AUTHORITY); - } - - public void testStops() { - ContentResolver cr = getMockContentResolver(); - // - // Create - // - final String stopId = "1_11060-TEST"; - ContentValues values = new ContentValues(); - values.put(ObaContract.Stops._ID, stopId); - values.put(ObaContract.Stops.CODE, "11060"); - values.put(ObaContract.Stops.NAME, "Broadway & E Denny Way"); - values.put(ObaContract.Stops.DIRECTION, "S"); - values.put(ObaContract.Stops.USE_COUNT, 0); - values.put(ObaContract.Stops.LATITUDE, 47.617676); - values.put(ObaContract.Stops.LONGITUDE, -122.314523); - - Uri uri = cr.insert(ObaContract.Stops.CONTENT_URI, values); - assertNotNull(uri); - assertEquals(uri, Uri.withAppendedPath(ObaContract.Stops.CONTENT_URI, stopId)); - - // - // Read - // - Cursor c = cr.query(ObaContract.Stops.CONTENT_URI, - new String[]{ObaContract.Stops._ID, ObaContract.Stops.DIRECTION}, - null, null, null); - assertNotNull(c); - assertEquals(1, c.getCount()); - c.moveToNext(); - assertEquals(stopId, c.getString(0)); - c.close(); - - // Test counting - c = cr.query(ObaContract.Stops.CONTENT_URI, - new String[]{ObaContract.Stops._COUNT}, - null, null, null); - assertNotNull(c); - assertEquals(1, c.getCount()); - c.moveToNext(); - assertEquals(1, c.getInt(0)); - c.close(); - // Get the one that we care about - c = cr.query(uri, new String[]{ObaContract.Stops.CODE}, null, null, null); - assertNotNull(c); - assertEquals(1, c.getCount()); - c.moveToNext(); - assertEquals("11060", c.getString(0)); - c.close(); - - // - // Update - // - values = new ContentValues(); - values.put(ObaContract.Stops.USE_COUNT, 1); - int result = cr.update(uri, values, null, null); - assertEquals(1, result); - - // - // Delete - // - result = cr.delete(uri, null, null); - assertEquals(1, result); - result = cr.delete(uri, null, null); - assertEquals(0, result); - } - - @Test - public void testLimit() { - ContentResolver cr = getMockContentResolver(); - // - // Create - // - final String stopId = "1_11060-TEST"; - ContentValues values = new ContentValues(); - values.put(ObaContract.Stops._ID, stopId); - values.put(ObaContract.Stops.CODE, "11060"); - values.put(ObaContract.Stops.NAME, "Broadway & E Denny Way"); - values.put(ObaContract.Stops.DIRECTION, "S"); - values.put(ObaContract.Stops.USE_COUNT, 0); - values.put(ObaContract.Stops.LATITUDE, 47.617676); - values.put(ObaContract.Stops.LONGITUDE, -122.314523); - - Uri uri = cr.insert(ObaContract.Stops.CONTENT_URI, values); - assertNotNull(uri); - - final String stopId2 = "1_1010101-TEST"; - values.put(ObaContract.Stops._ID, stopId2); - uri = cr.insert(ObaContract.Stops.CONTENT_URI, values); - assertNotNull(uri); - - Cursor c = cr.query(ObaContract.Stops.CONTENT_URI, - new String[]{ObaContract.Stops._COUNT}, - null, null, null); - assertNotNull(c); - assertEquals(1, c.getCount()); - c.moveToNext(); - assertEquals(2, c.getInt(0)); - c.close(); - - c = cr.query(ObaContract.Stops.CONTENT_URI - .buildUpon() - .appendQueryParameter("limit", "1") - .build(), - new String[]{ObaContract.Stops._ID}, - null, null, null - ); - assertNotNull(c); - assertEquals(1, c.getCount()); - - // Delete - // - int result = cr.delete(uri, null, null); - assertEquals(1, result); - - c.close(); - } -} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/RegionsLoaderTest.java b/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/RegionsLoaderTest.java deleted file mode 100644 index af27286c35..0000000000 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/RegionsLoaderTest.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (C) 2012 Paul Watts (paulcwatts@gmail.com) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.onebusaway.android.provider.test; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.onebusaway.android.region.Region; -import org.onebusaway.android.util.RegionUtils; - -import androidx.test.runner.AndroidJUnit4; - -import java.util.ArrayList; - -import static androidx.test.InstrumentationRegistry.getTargetContext; -import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.assertNotNull; -import static junit.framework.Assert.assertNull; - -/** - * Tests loading regions from the bundled resources and round-tripping them through the - * ContentProvider (no network — exercises {@link RegionUtils} + the provider directly). - */ -@RunWith(AndroidJUnit4.class) -public class RegionsLoaderTest { - - @Test - public void testLoader() { - // Load regions from resources - ArrayList regionsFromResources = RegionUtils - .getRegionsFromResources(getTargetContext()); - - // Save to provider - RegionUtils.saveToProvider(getTargetContext(), regionsFromResources); - - // Retrieve from provider - ArrayList regions = RegionUtils.getRegionsFromProvider(getTargetContext()); - assertNotNull(regions); - assertEquals(5, regions.size()); // Number of production regions - - // Production regions - _assertTampa(regions.get(0)); - _assertPugetSound(regions.get(1)); - } - - private void assertBounds(Region.Bounds bound, - double lat, double lon, double latSpan, double lonSpan) { - assertEquals(lat, bound.getLat()); - assertEquals(lon, bound.getLon()); - assertEquals(latSpan, bound.getLatSpan()); - assertEquals(lonSpan, bound.getLonSpan()); - } - - private void _assertTampa(Region tampa) { - assertEquals(0, tampa.getId()); - assertEquals("Tampa Bay", tampa.getName()); - Region.Bounds[] bounds = tampa.getBounds(); - assertNotNull(bounds); - assertEquals(2, bounds.length); - // 27.9769105:-82.445851:0.542461:0.576358 - assertBounds(bounds[0], - 27.9769105, - -82.445851, - 0.542461, - 0.576358); - // 27.91925:-82.652145:0.47208:0.39677 - assertBounds(bounds[1], - 27.91925, - -82.652145, - 0.47208, - 0.39677); - assertEquals("https://otp.prod.obahart.org/otp/", tampa.getOtpBaseUrl()); - assertEquals("otp-tampa@onebusaway.org", tampa.getOtpContactEmail()); - assertEquals("co.bytemark.flamingo", tampa.getPaymentAndroidAppId()); - assertNull(tampa.getPaymentWarningTitle()); - assertNull(tampa.getPaymentWarningBody()); - } - - private void _assertPugetSound(Region ps) { - assertEquals(1, ps.getId()); - assertEquals("Puget Sound", ps.getName()); - Region.Bounds[] bounds = ps.getBounds(); - assertNotNull(bounds); - assertEquals(9, bounds.length); - // 47.221315:-122.4051325:0.33704:0.440483 - assertBounds(bounds[0], - 47.221315, - -122.4051325, - 0.33704, - 0.440483); - // 47.5607395:-122.1462785:0.743251:0.720901 - assertBounds(bounds[1], - 47.5607395, - -122.1462785, - 0.743251, - 0.720901); - // 47.556288:-122.4013255:0.090694:0.126793 - assertBounds(bounds[2], - 47.556288, - -122.4013255, - 0.090694, - 0.126793); - assertEquals("https://otp.prod.sound.obaweb.org/otp/routers/default/", ps.getOtpBaseUrl()); - assertEquals("co.bytemark.tgt", ps.getPaymentAndroidAppId()); - assertEquals("Check before you buy!", ps.getPaymentWarningTitle()); - assertEquals("The mobile fare payment app for Puget Sound does not support all transit service shown in OneBusAway. Please check that a ticket is eligible for your agency and route before you purchase!", ps.getPaymentWarningBody()); - } -} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/RegionsTest.java b/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/RegionsTest.java deleted file mode 100644 index 9ee99f8b82..0000000000 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/RegionsTest.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (C) 2012-2017 Paul Watts (paulcwatts@gmail.com), - * University of South Florida (sjbarbeau@gmail.com), - * Microsoft Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.onebusaway.android.provider.test; - -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.ContentValues; -import android.database.Cursor; -import android.net.Uri; -import android.test.ProviderTestCase2; - -import org.onebusaway.android.provider.ObaContract; -import org.onebusaway.android.provider.ObaContract.RegionBounds; -import org.onebusaway.android.provider.ObaContract.Regions; -import org.onebusaway.android.provider.ObaProvider; - -public class RegionsTest extends ProviderTestCase2 { - - public RegionsTest() { - super(ObaProvider.class, ObaContract.AUTHORITY); - } - - public void testInsertOrUpdate() { - ContentResolver cr = getMockContentResolver(); - ContentValues values = new ContentValues(); - values.put(Regions.NAME, "Test region"); - values.put(Regions.OBA_BASE_URL, "https://test.onebusaway.org/"); - values.put(Regions.SIRI_BASE_URL, ""); - values.put(Regions.LANGUAGE, "en_US"); - values.put(Regions.CONTACT_EMAIL, "contact@onebusaway.org"); - values.put(Regions.SUPPORTS_OBA_DISCOVERY, true); - values.put(Regions.SUPPORTS_OBA_REALTIME, false); - values.put(Regions.SUPPORTS_SIRI_REALTIME, false); - values.put(Regions.SUPPORTS_EMBEDDED_SOCIAL, false); - - Uri uri1 = cr.insert(Regions.CONTENT_URI, values); - - final String[] PROJECTION = { - ObaContract.Regions._ID, - ObaContract.Regions.NAME, - ObaContract.Regions.SUPPORTS_OBA_DISCOVERY, - ObaContract.Regions.SUPPORTS_SIRI_REALTIME, - ObaContract.Regions.SUPPORTS_EMBEDDED_SOCIAL - }; - - // Query - Cursor c1 = cr.query(uri1, PROJECTION, null, null, null); - assertNotNull(c1); - assertEquals(1, c1.getCount()); - c1.moveToFirst(); - int id = c1.getInt(0); - c1.close(); - - values = new ContentValues(); - values.put(Regions.SUPPORTS_SIRI_REALTIME, 1); - Uri uri2 = Regions.insertOrUpdate(cr, id, values); - - assertEquals(uri1, uri2); - - Cursor c2 = cr.query(uri2, PROJECTION, null, null, null); - assertNotNull(c2); - assertEquals(1, c2.getCount()); - c2.moveToFirst(); - assertEquals(id, c2.getInt(0)); - assertEquals("Test region", c2.getString(1)); - assertEquals(1, c2.getInt(2)); - assertEquals(1, c2.getInt(3)); - c2.close(); - - // Delete this alert - cr.delete(uri1, null, null); - } - - public void testBounds() { - ContentResolver cr = getMockContentResolver(); - ContentValues values = new ContentValues(); - values.put(Regions.NAME, "Test region"); - values.put(Regions.OBA_BASE_URL, "https://test.onebusaway.org/"); - values.put(Regions.SIRI_BASE_URL, ""); - values.put(Regions.LANGUAGE, "en_US"); - values.put(Regions.CONTACT_EMAIL, "contact@onebusaway.org"); - values.put(Regions.SUPPORTS_OBA_DISCOVERY, true); - values.put(Regions.SUPPORTS_OBA_REALTIME, false); - values.put(Regions.SUPPORTS_SIRI_REALTIME, false); - values.put(Regions.SUPPORTS_EMBEDDED_SOCIAL, false); - - Uri uri1 = cr.insert(Regions.CONTENT_URI, values); - long regionId = ContentUris.parseId(uri1); - - // Insert bounds - values = new ContentValues(); - values.put(RegionBounds.REGION_ID, regionId); - values.put(RegionBounds.LATITUDE, 47.5607395); - values.put(RegionBounds.LONGITUDE, -122.1462785); - values.put(RegionBounds.LAT_SPAN, 0.7432510000000008); - values.put(RegionBounds.LON_SPAN, 0.720901000000012); - cr.insert(RegionBounds.CONTENT_URI, values); - - // Get the bounds count - Cursor c1 = cr.query(RegionBounds.CONTENT_URI, null, null, null, null); - assertNotNull(c1); - assertEquals(1, c1.getCount()); - - cr.delete(uri1, null, null); - - // Make sure there are no regions - Cursor c2 = cr.query(RegionBounds.CONTENT_URI, null, null, null, null); - assertNotNull(c2); - assertEquals(0, c2.getCount()); - } -} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/TripAlertsTest.java b/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/TripAlertsTest.java deleted file mode 100644 index d030849625..0000000000 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/provider/test/TripAlertsTest.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (C) 2010 Paul Watts (paulcwatts@gmail.com) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.onebusaway.android.provider.test; - -import org.onebusaway.android.provider.ObaContract; -import org.onebusaway.android.provider.ObaContract.TripAlerts; -import org.onebusaway.android.provider.ObaProvider; - -import android.content.ContentResolver; -import android.database.Cursor; -import android.net.Uri; -import android.test.ProviderTestCase2; - -public class TripAlertsTest extends ProviderTestCase2 { - - public TripAlertsTest() { - super(ObaProvider.class, ObaContract.AUTHORITY); - } - - public void testInsertIfNotExists() { - ContentResolver cr = getMockContentResolver(); - final Uri uri1 = TripAlerts.insertIfNotExists(cr, "1_12345", "1_STOP", 1000); - assertNotNull(uri1); - - Cursor c1 = cr.query(ObaContract.TripAlerts.CONTENT_URI, - new String[]{ObaContract.TripAlerts._ID}, - null, null, null); - assertNotNull(c1); - assertEquals(1, c1.getCount()); - - final Uri uri2 = TripAlerts.insertIfNotExists(cr, "1_12345", "1_STOP", 1000); - assertNotNull(uri2); - c1.close(); - - Cursor c2 = cr.query(ObaContract.TripAlerts.CONTENT_URI, - new String[]{ObaContract.TripAlerts._ID}, - null, null, null); - assertNotNull(c2); - assertEquals(1, c2.getCount()); - assertEquals(uri1, uri2); - c2.close(); - - // Delete this alert - cr.delete(uri1, null, null); - } - - public void testSetState() { - ContentResolver cr = getMockContentResolver(); - final Uri uri = TripAlerts.insertIfNotExists(cr, "1_12345", "1_STOP", 1000); - assertNotNull(uri); - - final String[] PROJECTION = {TripAlerts.STATE}; - - // Ensure it's created with a *scheduled* state - Cursor c = cr.query(uri, PROJECTION, null, null, null); - assertNotNull(c); - c.moveToNext(); - assertEquals(TripAlerts.STATE_SCHEDULED, c.getInt(0)); - c.close(); - - TripAlerts.setState(cr, uri, TripAlerts.STATE_POLLING); - - c = cr.query(uri, PROJECTION, null, null, null); - assertNotNull(c); - c.moveToNext(); - assertEquals(TripAlerts.STATE_POLLING, c.getInt(0)); - c.close(); - - cr.delete(uri, null, null); - } -} diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/util/test/UIUtilTest.java b/onebusaway-android/src/androidTest/java/org/onebusaway/android/util/test/UIUtilTest.java index 7849a063fd..7e0dd6ecbe 100644 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/util/test/UIUtilTest.java +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/util/test/UIUtilTest.java @@ -32,7 +32,6 @@ import org.onebusaway.android.api.test.ObaTestCase; import org.onebusaway.android.mock.ArrivalsFixtures; import org.onebusaway.android.mock.MockRegion; -import org.onebusaway.android.provider.ObaContract; import org.onebusaway.android.ui.arrivals.ArrivalInfo; import org.onebusaway.android.ui.arrivals.dialogs.StopDetailsDialog; import org.onebusaway.android.util.ArrivalInfoUtils; @@ -235,74 +234,16 @@ public void testArrivalTimeIndexSearch() throws Exception { ArrivalsFixtures.load(getTargetContext(), "arrivals_and_departures_for_stop_hart_6497"); String stopId = "Hillsborough Area Regional Transit_6497"; - // First de-select any existing route favorites, to make sure the test returns correct results - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_2", - "UATC to Downtown via Nebraska Ave", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_1", - "UATC to Downtown via Florida Ave", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_18", - "North to UATC/Livingston", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_5", - "South to Downtown/MTC", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_2", - "UATC to Downtown via Nebraska Ave", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_18", - "South to UATC/Downtown/MTC", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_12", - "North to University Area TC", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_9", - "UATC to Downtown via 15th St", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_12", - "South to Downtown/MTC", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_5", - "North to University Area TC", - stopId, - false); - - // Now mark 2 favorites - first non-negative index for this route/headsign will be index 11 - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_6", - "North to University Area TC", - stopId, - true); - - // First non-negative index for this route/headsign will be index 13 - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_6", - "South to Downtown/MTC", - stopId, - true); - - // Project the fixture's arrivals via the production path. - ArrayList arrivalInfo = ArrivalsFixtures.convert(getTargetContext(), env, true); + // The two favorited route/headsign combos. Favorite state is supplied to convert() directly + // now (the favorites store is no longer a ContentProvider); the favorite precedence itself is + // unit-tested in RouteHeadsignFavoriteLogicTest. + java.util.Set favorites = new java.util.HashSet<>(java.util.Arrays.asList( + "Hillsborough Area Regional Transit_6|North to University Area TC", + "Hillsborough Area Regional Transit_6|South to Downtown/MTC")); + + // Project the fixture's arrivals via the production path, with those two marked favorite. + ArrayList arrivalInfo = ArrivalsFixtures.convert(getTargetContext(), env, true, + (routeId, headsign, sid) -> favorites.contains(routeId + "|" + headsign)); // Now confirm that we have the correct number of elements, and values for ETAs for the test validateUatcArrivalInfo(arrivalInfo); @@ -318,23 +259,9 @@ public void testArrivalTimeIndexSearch() throws Exception { assertEquals(11, preferredArrivalIndexes.get(0).intValue()); assertEquals(13, preferredArrivalIndexes.get(1).intValue()); - // Now clear the favorites - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_6", - "North to University Area TC", - stopId, - false); - ObaContract.RouteHeadsignFavorites.markAsFavorite(getTargetContext(), - "Hillsborough Area Regional Transit_6", - "South to Downtown/MTC", - stopId, - false); - - // Process again (re-reading the now-cleared favorite info from the DB). + // With no favorites, the first two non-negative arrival times are returned - indexes 5 and 6. arrivalInfo = ArrivalsFixtures.convert(getTargetContext(), env, true); preferredArrivalIndexes = ArrivalInfoUtils.findPreferredArrivalIndexes(arrivalInfo); - - // Now the first two non-negative arrival times should be returned - indexes 5 and 6 assertEquals(5, preferredArrivalIndexes.get(0).intValue()); assertEquals(6, preferredArrivalIndexes.get(1).intValue()); } diff --git a/onebusaway-android/src/androidTest/res/raw/trips_for_route_direction_filter.json b/onebusaway-android/src/androidTest/res/raw/trips_for_route_direction_filter.json new file mode 100644 index 0000000000..6fb332e418 --- /dev/null +++ b/onebusaway-android/src/androidTest/res/raw/trips_for_route_direction_filter.json @@ -0,0 +1,42 @@ +{ + "code": 200, + "currentTime": 1444073094612, + "text": "OK", + "version": 2, + "data": { + "limitExceeded": false, + "outOfRange": false, + "list": [ + { + "tripId": "trip_out", + "serviceDate": 1444017600000, + "status": { + "activeTripId": "trip_out", + "lastUpdateTime": 1000, + "status": "default", + "position": { "lat": 47.10, "lon": -122.10 } + } + }, + { + "tripId": "trip_in", + "serviceDate": 1444017600000, + "status": { + "activeTripId": "trip_in", + "lastUpdateTime": 2000, + "status": "default", + "position": { "lat": 47.20, "lon": -122.20 } + } + } + ], + "references": { + "agencies": [], + "routes": [], + "situations": [], + "stops": [], + "trips": [ + { "id": "trip_out", "routeId": "route_1", "directionId": "0" }, + { "id": "trip_in", "routeId": "route_1", "directionId": "1" } + ] + } + } +} diff --git a/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/GoogleMapRenderer.kt b/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/GoogleMapRenderer.kt index 24f5838a20..9ca3de63fa 100644 --- a/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/GoogleMapRenderer.kt +++ b/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/GoogleMapRenderer.kt @@ -40,6 +40,8 @@ import org.onebusaway.android.map.render.CorrectionSmoother import org.onebusaway.android.map.render.GeoPoint import org.onebusaway.android.map.render.MapRenderState import org.onebusaway.android.map.render.MapVehicles +import org.onebusaway.android.map.render.StopBand +import org.onebusaway.android.map.render.StopIconKind import org.onebusaway.android.map.render.StopMarker import org.onebusaway.android.map.render.TripMarkerBitmaps import org.onebusaway.android.map.render.TripOverlay @@ -47,6 +49,7 @@ import org.onebusaway.android.map.render.TripStopBitmaps import org.onebusaway.android.map.render.VehicleBitmaps import org.onebusaway.android.map.render.VehicleMarker import org.onebusaway.android.map.render.bikeZoomBand +import org.onebusaway.android.map.render.stopIconKind import org.onebusaway.android.util.MyTextUtils import org.onebusaway.android.util.getRouteDisplayName import java.util.concurrent.TimeUnit @@ -58,9 +61,10 @@ import java.util.concurrent.TimeUnit * `ObaMapContent` + the `VehicleMarkerLayer`/`TripMarkerLayer` Compose overlays. * * Two redraw paths, the same two recomposition boundaries the Compose flavor had: - * - [renderStatic] (stops / route polylines / bikes / generics / trip-stop dots) is a clear-and-redraw - * of just the static annotations, driven by snapshot/trip-stop changes (viewport loads, the vehicle - * poll, focus) — a bounded cost. + * - [renderStatic] clear-and-redraws the static annotations (route polylines / bikes / generics / + * trip-stop dots) and reconciles the stop markers in place ([reconcileStopMarkers], so unchanged + * stops don't blink), driven by snapshot/trip-stop changes (viewport loads, the vehicle poll, + * focus) — a bounded cost. * - [renderDynamic] (the live route vehicles + the trip-focus band/estimate markers) is pulled at * ~20Hz by the adapter's frame loop. It moves native markers **in place** to their freshly * extrapolated positions (so the icons glide with the map, an open info window survives, and there's @@ -76,6 +80,15 @@ class GoogleMapRenderer( ) { private val stopByMarker = HashMap() + // Stop markers tracked by stop id so [reconcileStopMarkers] can diff them in place (add new, + // remove gone, re-icon only on a focus flip) instead of clear-and-redraw — keeping unchanged stops + // from blinking on every static redraw. Like the vehicle markers, these are NOT in [staticMarkers] + // and so survive [clearStatic]; [renderedFocusedStopId] is the focus the icons were last drawn for. + private val stopMarkersByStopId = HashMap() + private var renderedFocusedStopId: String? = null + // The zoom band the stop icons were last drawn for (full icon vs dot); see [reconcileStopMarkers]. + private var renderedStopBand = StopBand.FULL + private val bikeByMarker = HashMap() private val vehicleByMarker = HashMap() @@ -147,15 +160,16 @@ class GoogleMapRenderer( private val descriptorCache = BitmapDescriptorCache(DESCRIPTOR_CACHE_SIZE) { BitmapDescriptorFactory.fromBitmap(it) } - // Remove only our own static annotations (not map.clear(), which would also wipe the per-frame - // dynamic layer) and clear their tap-routing maps. Shared by [renderStatic] (before it redraws) and - // [dispose]. + // Remove the redrawn static annotations — polylines, trip-stop dots, bikes, generic markers (not + // map.clear(), which would also wipe the per-frame dynamic layer) — and clear the bike tap map + // [bikeByMarker]. The reconciled stop markers are tracked apart in [stopMarkersByStopId] and + // deliberately survive (their tap map [stopByMarker] is left intact too). Shared by [renderStatic] + // (before it redraws) and [dispose]. private fun clearStatic() { staticMarkers.forEach { it.remove() } staticMarkers.clear() staticPolylines.forEach { it.remove() } staticPolylines.clear() - stopByMarker.clear() bikeByMarker.clear() } @@ -189,22 +203,7 @@ class GoogleMapRenderer( ) } - for (stop in snapshot.stops) { - val icon = if (stop.id == snapshot.focusedStopId) { - StopIconFactory.focusedStopIcon(stop.direction, stop.routeType) - } else { - StopIconFactory.stopIcon(stop.direction, stop.routeType) - } - val marker = map.addMarker( - MarkerOptions() - .position(stop.point.toLatLng()) - .icon(icon) - .flat(true) - .anchor(StopIconFactory.anchorX(stop.direction), StopIconFactory.anchorY(stop.direction)) - )!! - staticMarkers.add(marker) - stopByMarker[marker] = stop - } + reconcileStopMarkers(snapshot.stops, snapshot.focusedStopId, snapshot.stopBand) if (snapshot.bikeshareVisible) { val band = bikeZoomBand(map.cameraPosition.zoom) @@ -236,6 +235,63 @@ class GoogleMapRenderer( } } + /** + * Diff the stop markers against [stops] in place (the [reconcileVehicleMarkers] pattern): remove + * markers whose id has left, add markers for new ids, and re-icon an existing marker only when its + * icon kind changes — a focus flip or a zoom-band crossing ([band], full icon ⇄ dot). Unchanged + * stops keep their native marker, so they don't blink on a static redraw. Tracked in + * [stopMarkersByStopId] (not [staticMarkers]) so [clearStatic] leaves them be. + */ + private fun reconcileStopMarkers(stops: List, focusedStopId: String?, band: StopBand) { + val liveIds = stops.mapTo(HashSet()) { it.id } + val gone = stopMarkersByStopId.iterator() + while (gone.hasNext()) { + val entry = gone.next() + if (entry.key !in liveIds) { + stopByMarker.remove(entry.value) + entry.value.remove() + gone.remove() + } + } + for (stop in stops) { + val kind = stopIconKind(stop.id == focusedStopId, band) + val existing = stopMarkersByStopId[stop.id] + if (existing == null) { + val (anchorX, anchorY) = stopAnchor(stop, kind) + val marker = map.addMarker( + MarkerOptions() + .position(stop.point.toLatLng()) + .icon(stopIcon(stop, kind)) + .flat(true) + .anchor(anchorX, anchorY) + )!! + stopMarkersByStopId[stop.id] = marker + stopByMarker[marker] = stop + } else if (stopIconKind(stop.id == renderedFocusedStopId, renderedStopBand) != kind) { + // Only the markers whose icon kind changed need a new icon (and matching anchor: the + // full icon is anchored on its circle per direction, the dot at the marker center). + existing.setIcon(stopIcon(stop, kind)) + val (anchorX, anchorY) = stopAnchor(stop, kind) + existing.setAnchor(anchorX, anchorY) + } + } + renderedFocusedStopId = focusedStopId + renderedStopBand = band + } + + private fun stopIcon(stop: StopMarker, kind: StopIconKind): BitmapDescriptor = when (kind) { + StopIconKind.FULL -> StopIconFactory.stopIcon(stop.direction, stop.routeType) + StopIconKind.FULL_FOCUSED -> StopIconFactory.focusedStopIcon(stop.direction, stop.routeType) + StopIconKind.DOT -> StopIconFactory.dotStopIcon() + StopIconKind.DOT_FOCUSED -> StopIconFactory.focusedDotStopIcon() + } + + private fun stopAnchor(stop: StopMarker, kind: StopIconKind): Pair = + when (kind) { + StopIconKind.DOT, StopIconKind.DOT_FOCUSED -> 0.5f to 0.5f + else -> StopIconFactory.anchorX(stop.direction) to StopIconFactory.anchorY(stop.direction) + } + /** * Tear down every native annotation and drop all marker-smoothing state. The adapter calls this from * its onDispose, before `MapView.onDestroy()`. Forget the smoother state first, then remove the @@ -247,6 +303,11 @@ class GoogleMapRenderer( clearStatic() + stopMarkersByStopId.values.forEach { it.remove() } + stopMarkersByStopId.clear() + stopByMarker.clear() + renderedFocusedStopId = null + vehicleMarkersByTripId.values.forEach { it.remove() } vehicleMarkersByTripId.clear() vehicleByMarker.clear() diff --git a/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/StopIconFactory.java b/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/StopIconFactory.java index 7dd7dc68b7..acdbf4d5a9 100644 --- a/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/StopIconFactory.java +++ b/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/StopIconFactory.java @@ -41,6 +41,7 @@ import org.onebusaway.android.R; import org.onebusaway.android.app.Application; +import org.onebusaway.android.map.render.StopBitmaps; import org.onebusaway.android.models.ObaRoute; import java.util.HashMap; @@ -97,12 +98,22 @@ private StopIconFactory() { private static final int NUM_DIRECTIONS = 9; // 8 directions + undirected mStops /** - * Icon cache keyed by route type, each containing a Bitmap array of size NUM_DIRECTIONS. + * Descriptor cache keyed by route type, each a BitmapDescriptor array of size NUM_DIRECTIONS. The + * descriptors (native texture wrappers) are built once so re-iconing markers — e.g. swapping every + * stop full icon ⇄ dot on a zoom-band crossing, up to the full stop cache at once (default 200, up + * to 2000) — reuses them instead of minting a fresh texture per marker (the cost that made the + * transition stutter). */ - private static final SparseArray sStopIcons = new SparseArray<>(); + private static final SparseArray sStopDescriptors = new SparseArray<>(); - /** Focused (selected) variant of {@link #sStopIcons}. */ - private static final SparseArray sStopIconsFocused = new SparseArray<>(); + /** Focused (selected) variant of {@link #sStopDescriptors}. */ + private static final SparseArray sStopDescriptorsFocused = new SparseArray<>(); + + /** The small directionless dot shown in place of the full icon at distant zoom (declutter). */ + private static BitmapDescriptor sDotDescriptor; + + /** The focused (accent) variant of {@link #sDotDescriptor}, so a selection stays visible far out. */ + private static BitmapDescriptor sDotDescriptorFocused; /** * Route types that get distinct stop icons on the map. @@ -172,6 +183,21 @@ public static synchronized BitmapDescriptor focusedStopIcon(String direction, in return getFocusedStopBitmapDescriptor(direction, routeType); } + /** + * The small dot shown in place of the full icon at distant zoom. Directionless and route-type + * agnostic (a neutral themed point), so the caller anchors it at the marker center (0.5, 0.5). + */ + public static synchronized BitmapDescriptor dotStopIcon() { + ensureLoaded(); + return sDotDescriptor; + } + + /** The focused (accent) dot, shown for the selected stop at distant zoom. */ + public static synchronized BitmapDescriptor focusedDotStopIcon() { + ensureLoaded(); + return sDotDescriptorFocused; + } + /** Marker anchor X for the given direction (positions the pin tip on the circle center). */ public static float anchorX(String direction) { return getXPercentOffsetForDirection(direction); @@ -225,9 +251,23 @@ private static final void loadIcons() { (int) (bmp.getWidth() * FOCUS_ICON_SCALE), (int) (bmp.getHeight() * FOCUS_ICON_SCALE), true); } - sStopIcons.put(routeType, icons); - sStopIconsFocused.put(routeType, iconsFocused); + sStopDescriptors.put(routeType, toDescriptors(icons)); + sStopDescriptorsFocused.put(routeType, toDescriptors(iconsFocused)); } + + sDotDescriptor = BitmapDescriptorFactory.fromBitmap( + StopBitmaps.dot(mPx, r.getColor(R.color.theme_primary))); + sDotDescriptorFocused = BitmapDescriptorFactory.fromBitmap( + StopBitmaps.dot(mPx, r.getColor(R.color.map_stop_focus))); + } + + /** Wraps each pre-rendered bitmap into a BitmapDescriptor once, so callers can reuse them. */ + private static BitmapDescriptor[] toDescriptors(Bitmap[] bitmaps) { + BitmapDescriptor[] descriptors = new BitmapDescriptor[bitmaps.length]; + for (int i = 0; i < bitmaps.length; i++) { + descriptors[i] = BitmapDescriptorFactory.fromBitmap(bitmaps[i]); + } + return descriptors; } /** @@ -623,19 +663,19 @@ private static int normalizeRouteType(int routeType) { } /** - * Looks up a stop icon from the given cache based on direction and route type. + * Looks up a cached stop descriptor by direction and route type. * Falls back to TYPE_BUS if the requested type is not found, then to the default marker. * - * @param cache icon cache to look up from (normal or focused) + * @param cache descriptor cache to look up from (normal or focused) * @param direction stop direction string * @param routeType one of ObaRoute.TYPE_* constants * @return BitmapDescriptor for the stop icon */ @NonNull - private static BitmapDescriptor lookupStopIcon(SparseArray cache, String direction, - int routeType) { + private static BitmapDescriptor lookupStopIcon(SparseArray cache, + String direction, int routeType) { int normalizedType = normalizeRouteType(routeType); - Bitmap[] icons = cache.get(normalizedType); + BitmapDescriptor[] icons = cache.get(normalizedType); if (icons == null) { icons = cache.get(ObaRoute.TYPE_BUS); } @@ -643,16 +683,16 @@ private static BitmapDescriptor lookupStopIcon(SparseArray cache, Stri Log.w(TAG, "Stop icons not initialized for type " + routeType); return BitmapDescriptorFactory.defaultMarker(); } - return BitmapDescriptorFactory.fromBitmap(icons[getDirectionIndex(direction)]); + return icons[getDirectionIndex(direction)]; } private static BitmapDescriptor getStopBitmapDescriptor(String direction, int routeType) { - return lookupStopIcon(sStopIcons, direction, routeType); + return lookupStopIcon(sStopDescriptors, direction, routeType); } @NonNull private static BitmapDescriptor getFocusedStopBitmapDescriptor(String direction, int routeType) { - return lookupStopIcon(sStopIconsFocused, direction, routeType); + return lookupStopIcon(sStopDescriptorsFocused, direction, routeType); } } diff --git a/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/compose/GoogleComposeAdapter.kt b/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/compose/GoogleComposeAdapter.kt index abcaf88a17..69ae929449 100644 --- a/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/compose/GoogleComposeAdapter.kt +++ b/onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/compose/GoogleComposeAdapter.kt @@ -17,9 +17,7 @@ package org.onebusaway.android.map.googlemapsv2.compose import android.annotation.SuppressLint import android.content.Context -import android.content.res.Configuration import android.view.ViewGroup -import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.runtime.Composable @@ -65,6 +63,7 @@ import org.onebusaway.android.map.render.MapProjector import org.onebusaway.android.map.render.ScreenOffset import org.onebusaway.android.ui.compose.findActivity import org.onebusaway.android.util.PermissionUtils +import org.onebusaway.android.util.ThemeUtils /** * The dynamic-layer redraw interval: ~20Hz, the proven upstream cadence. Downsampling from the display @@ -376,7 +375,7 @@ private fun applyMyLocation(map: GoogleMap, context: Context, enabled: Boolean) /** The map style for the current night-mode state: the dark theme, or POI removal in light mode. */ private fun resolveMapStyle(context: Context): MapStyleOptions = - if (isInDarkMode(context)) { + if (ThemeUtils.isInDarkMode(context)) { MapStyleOptions.loadRawResourceStyle(context, R.raw.dark_map) } else { // Light mode: just hide POIs (ported from GoogleMapHost.onMapReady). @@ -384,16 +383,3 @@ private fun resolveMapStyle(context: Context): MapStyleOptions = "[{\"featureType\":\"poi\",\"elementType\":\"all\",\"stylers\":[{\"visibility\":\"off\"}]}]" ) } - -/** Mirrors the former GoogleMapHost.inDarkMode: app night-mode override, else the system config. */ -private fun isInDarkMode(context: Context): Boolean { - val mode = AppCompatDelegate.getDefaultNightMode() - if (mode == AppCompatDelegate.MODE_NIGHT_YES) { - return true - } - if (mode == AppCompatDelegate.MODE_NIGHT_NO) { - return false - } - return (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == - Configuration.UI_MODE_NIGHT_YES -} diff --git a/onebusaway-android/src/main/AndroidManifest.xml b/onebusaway-android/src/main/AndroidManifest.xml index 3266e0a0a2..a09a79ac8b 100644 --- a/onebusaway-android/src/main/AndroidManifest.xml +++ b/onebusaway-android/src/main/AndroidManifest.xml @@ -37,13 +37,6 @@ - - - - - - @@ -266,11 +259,6 @@ - - - - \ No newline at end of file + diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/adapters/RegionAdapters.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/adapters/RegionAdapters.kt index d0d05adb57..00eb7d4ad2 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/adapters/RegionAdapters.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/adapters/RegionAdapters.kt @@ -50,8 +50,6 @@ fun RegionDto.toObaRegion(): Region = Region( paymentAndroidAppId, paymentWarningTitle, paymentWarningBody, - travelBehaviorDataCollectionEnabled, - enrollParticipantsInStudy, sidecarBaseUrl, plausibleAnalyticsServerUrl, // Match getRegionsFromProvider: only build a config when something is actually set. diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/ObaApiModels.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/ObaApiModels.kt index a06806ddc4..0cb019cb26 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/ObaApiModels.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/ObaApiModels.kt @@ -173,9 +173,13 @@ data class StopGrouping( val stopGroups: List = emptyList(), ) -/** One directional group: a display [name] and the ordered [stopIds] it contains. */ +/** + * One directional group: its [id] (the GTFS direction id, "0"/"1", for a `type: "direction"` + * grouping), a display [name], and the ordered [stopIds] it contains. + */ @Serializable data class StopGroup( + val id: String? = null, val name: StopGroupName = StopGroupName(), val stopIds: List = emptyList(), ) { @@ -347,7 +351,14 @@ data class SituationText( val value: String? = null, ) -/** A situation active window; [from]/[to] are epoch seconds (to == 0 means no end). */ +/** + * A situation active window (to == 0 means no end). [from]/[to] are epoch timestamps whose **unit is + * not fixed**: GTFS-RT `active_period` is seconds per spec, but the OBA server converts it to millis on + * ingestion (`GtfsRealtimeAlertLibrary.toMillis`, magnitude threshold 1e12), while older servers/feeds + * still emit seconds — so a response's values may be either seconds or millis. The wire→domain adapter + * (`DtoSituation`) normalizes them to millis via `situationEpochToMillis`, so the domain model + * (`ObaSituation.ActiveWindow`) is always millis; downstream code never re-guesses. + */ @Serializable data class SituationWindow( val from: Long = 0, @@ -439,8 +450,6 @@ data class RegionDto( val paymentAndroidAppId: String? = null, val paymentWarningTitle: String? = null, val paymentWarningBody: String? = null, - val travelBehaviorDataCollectionEnabled: Boolean = false, - val enrollParticipantsInStudy: Boolean = false, ) /** One bounding box of a region (center [lat]/[lon] and its [latSpan]/[lonSpan]). */ diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/ObaWebService.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/ObaWebService.kt index 6d1d84efc3..1f3d72c51d 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/ObaWebService.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/ObaWebService.kt @@ -82,6 +82,10 @@ interface ObaWebService { * stops-for-location — stops near [lat]/[lon], optionally filtered by a code/name [query] and * bounded by [radius] (meters). Omitted (null) parameters are dropped from the request. * {http://developer.onebusaway.org/.../api/where/methods/stops-for-location.html} + * + * [maxCount] caps how many stops the server returns; it applies its own hard upper limit no matter + * how large this is set, and reports `limitExceeded` when more stops matched than were returned. + * When null the server's per-method default applies. */ @GET("api/where/stops-for-location.json") suspend fun stopsForLocation( @@ -92,6 +96,7 @@ interface ObaWebService { // The map fetches by bounding-box span instead of radius; both are optional and dropped when null. @Query("latSpan") latSpan: Double? = null, @Query("lonSpan") lonSpan: Double? = null, + @Query("maxCount") maxCount: Int? = null, ): ObaEnvelope> /** diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/data/MapDataSource.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/data/MapDataSource.kt index c71d9558b9..ffffe2503a 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/data/MapDataSource.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/data/MapDataSource.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.onebusaway.android.models.NearbyStops import org.onebusaway.android.models.RouteMapData +import org.onebusaway.android.models.RouteMapStop import org.onebusaway.android.util.PolylineDecoder /** @@ -35,7 +36,14 @@ import org.onebusaway.android.util.PolylineDecoder */ interface MapDataSource { - suspend fun nearbyStops(lat: Double, lon: Double, latSpan: Double, lonSpan: Double): Result + /** + * [maxCount] caps how many stops the server returns (the map's LRU cache size): a dense viewport + * fills the cache in fewer pans. The server clamps this to its own hard limit and sets + * `limitExceeded` when more stops matched than were returned; null leaves the server default. + */ + suspend fun nearbyStops( + lat: Double, lon: Double, latSpan: Double, lonSpan: Double, maxCount: Int? = null, + ): Result suspend fun routeMap(routeId: String): Result } @@ -45,10 +53,11 @@ class DefaultMapDataSource @Inject constructor( ) : MapDataSource { override suspend fun nearbyStops( - lat: Double, lon: Double, latSpan: Double, lonSpan: Double, + lat: Double, lon: Double, latSpan: Double, lonSpan: Double, maxCount: Int?, ): Result = api.callOrNull { service -> - val data = service.stopsForLocation(lat = lat, lon = lon, latSpan = latSpan, lonSpan = lonSpan) - .requireData() + val data = service.stopsForLocation( + lat = lat, lon = lon, latSpan = latSpan, lonSpan = lonSpan, maxCount = maxCount, + ).requireData() NearbyStops( stops = data.list.map(::DtoStop), routes = data.references.routes.map(::DtoRoute), @@ -60,10 +69,22 @@ class DefaultMapDataSource @Inject constructor( override suspend fun routeMap(routeId: String): Result = api.callOrNull { service -> val data = service.stopsForRoute(routeId, includePolylines = true).requireData() val route = data.references.route(routeId)?.let(::DtoRoute) + // Invert the direction stop groups (type "direction") into a stop id -> direction ids map, so + // each stop can carry its own direction. Only numeric group ids are kept — they're what a + // vehicle's trip directionId matches; a stop listed under two groups gets both. + val directionsByStop = mutableMapOf>() + data.entry.stopGroupings.forEach { grouping -> + grouping.stopGroups.forEach { group -> + group.id?.toIntOrNull()?.let { dir -> + group.stopIds.forEach { directionsByStop.getOrPut(it) { mutableSetOf() }.add(dir) } + } + } + } RouteMapData( route = route, agencyName = route?.agencyId?.let { data.references.agency(it)?.name }, - stops = data.references.stops.map(::DtoStop), + stops = data.references.stops.map(::DtoStop) + .map { RouteMapStop(it, directionsByStop[it.id].orEmpty()) }, routes = data.references.routes.map(::DtoRoute), // Decoding the route's shape polylines is the one bit of non-trivial CPU work in this // layer; offload just it (the Retrofit calls are already main-safe, like the other sources). diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/data/ServerClockNormalization.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/data/ServerClockNormalization.kt new file mode 100644 index 0000000000..2903cdcf83 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/data/ServerClockNormalization.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.api.data + +/** + * The response's server `currentTime` (epoch millis) when present, else the device clock. Every OK OBA + * response carries a positive `currentTime`, but the wire model defaults it to 0 when the field is + * absent/malformed; anchoring ETAs and vehicle ages on 0 would render ~28-million-minute countdowns and + * "updated 0 sec ago". Falling back to the device clock keeps a skewed-but-sane baseline in that + * degenerate case, while the normal path stays on the server clock so skew cancels (#1612). + */ +internal fun serverNowOrDeviceClock(serverCurrentTime: Long): Long = + if (serverCurrentTime > 0L) serverCurrentTime else System.currentTimeMillis() + +/** + * Normalizes an OBA service-alert active-window timestamp to epoch milliseconds. The unit is **not + * fixed** across the OBA ecosystem: GTFS-RT `active_period` is seconds per spec, but the server converts + * it to millis on ingestion (`GtfsRealtimeAlertLibrary.toMillis`, magnitude threshold 1e12) while older + * servers/feeds still emit seconds — so a response's `from`/`to` may be either. Normalizing here, at the + * wire→domain adapter, lets the domain model ([org.onebusaway.android.models.ObaSituation.ActiveWindow]) + * be unambiguously millis so no downstream consumer has to re-guess. Mirrors the server's own `toMillis`. + * + * Failure mode (inherent to any magnitude rule): a genuine epoch-*millis* value below 1e12 (an instant + * before 2001-09-09) is misread as seconds and scaled x1000; a genuine epoch-*seconds* value at/after + * 1e12 (year ~33658) is misread as millis. Both are far outside any real alert window. Non-positive + * values (an unset `from`, or `to == 0` meaning "no end") pass through unchanged. + */ +internal fun situationEpochToMillis(timestamp: Long): Long = + if (timestamp in 1 until SECONDS_MILLIS_THRESHOLD) timestamp * 1_000L else timestamp + +/** OBA's server-side seconds-vs-millis boundary (`GtfsRealtimeAlertLibrary.toMillis`). */ +private const val SECONDS_MILLIS_THRESHOLD = 1_000_000_000_000L diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/data/StopArrivalsDataSource.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/data/StopArrivalsDataSource.kt index ca0736b0aa..afe579289b 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/data/StopArrivalsDataSource.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/data/StopArrivalsDataSource.kt @@ -110,7 +110,7 @@ class DefaultStopArrivalsDataSource @Inject constructor( override suspend fun arrivals(stopId: String, minutesAfter: Int): Result = api.call { val envelope = it.arrivalsAndDeparturesForStop(stopId, minutesAfter) - StopArrivals(envelope.requireData(), envelope.currentTime, minutesAfter) + StopArrivals(envelope.requireData(), serverNowOrDeviceClock(envelope.currentTime), minutesAfter) }.onFailure { Log.e(TAG, "arrivals($stopId) failed", it) } private companion object { @@ -132,7 +132,11 @@ internal class DtoSituation(private val s: SituationReference) : ObaSituation { get() = s.allAffects.map { Affects(it.routeId) }.toTypedArray() override val consequences: Array get() = emptyArray() override val activeWindows: Array - get() = s.activeWindows.map { Window(it.from, it.to) }.toTypedArray() + // Normalize the polymorphic seconds-or-millis wire values to millis here so the domain model + // is unambiguously millis downstream (see situationEpochToMillis). + get() = s.activeWindows + .map { Window(situationEpochToMillis(it.from), situationEpochToMillis(it.to)) } + .toTypedArray() private class Affects(override val routeId: String?) : ObaSituation.AllAffects { override val directionId: String? get() = null diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/data/TripVehiclesDataSource.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/data/TripVehiclesDataSource.kt index 688ee7a4e7..66b786ef8e 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/data/TripVehiclesDataSource.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/data/TripVehiclesDataSource.kt @@ -47,7 +47,7 @@ private fun routeTripsOf( override val trips: List = entries.map { DtoTripDetails(it) } override fun trip(tripId: String?): ObaTrip? = tripId?.let { references.trip(it) }?.let { DtoTrip(it) } override fun route(routeId: String): ObaRoute? = references.route(routeId)?.let { DtoRoute(it) } - override val currentTimeMs: Long = serverTimeMs + override val currentTimeMs: Long = serverNowOrDeviceClock(serverTimeMs) } /** Adapts a modernized trips-for-route envelope (a list of vehicles) to [RouteTrips]. */ diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/app/Application.java b/onebusaway-android/src/main/java/org/onebusaway/android/app/Application.java index 0480d5519c..f8ff61cb9d 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/app/Application.java +++ b/onebusaway-android/src/main/java/org/onebusaway/android/app/Application.java @@ -57,7 +57,9 @@ import static com.google.android.gms.location.LocationServices.getFusedLocationProviderClient; import java.nio.charset.StandardCharsets; +import dagger.hilt.android.EntryPointAccessors; import dagger.hilt.android.HiltAndroidApp; +import org.onebusaway.android.app.di.DatabaseEntryPoint; @HiltAndroidApp public class Application extends android.app.Application { @@ -92,6 +94,11 @@ public void onCreate() { mApp = this; initOba(); + + // Kick the one-time legacy ContentProvider -> Room data import (fire-and-forget) so it overlaps + // startup; every migrated repository read/write awaits this gate before touching the DB. + EntryPointAccessors.fromApplication(this, DatabaseEntryPoint.class).importGate().start(); + // The region and location repositories (which own region/location state) are now Hilt // @Singletons, constructed lazily on first injection after onCreate. The region repo seeds // itself from persistence (the saved region-id → ContentProvider lookup); the location repo diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/app/di/AppModule.kt b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/AppModule.kt index 72fe10b96a..41f8d3ddcd 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/app/di/AppModule.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/AppModule.kt @@ -16,6 +16,7 @@ package org.onebusaway.android.app.di import android.content.Context +import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.SharedPreferencesMigration import androidx.datastore.preferences.core.PreferenceDataStoreFactory @@ -27,6 +28,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -45,11 +47,20 @@ import org.onebusaway.android.util.TimeProvider @InstallIn(SingletonComponent::class) object AppModule { - /** Process-lifetime scope for fire-and-forget persistence work (DataStore + repository writes). */ + /** + * Process-lifetime scope for fire-and-forget persistence work (DataStore + repository writes). The + * [CoroutineExceptionHandler] makes it crash-safe: a failed background write (e.g. a Room/SQLite + * error from a recents/reminder write) is logged and swallowed rather than reaching the thread's + * default handler and crashing the app — these are best-effort and must never take the app down. + */ @Provides @Singleton @AppScope - fun provideAppScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun provideAppScope(): CoroutineScope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e -> + Log.e("AppScope", "Uncaught exception in a fire-and-forget coroutine; ignoring", e) + } + ) // The Preferences DataStore backing [PreferencesRepository]. A one-time SharedPreferencesMigration // imports the existing default prefs file ("_preferences"), so existing users' values carry diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/app/di/DatabaseEntryPoint.kt b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/DatabaseEntryPoint.kt new file mode 100644 index 0000000000..ada893116f --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/DatabaseEntryPoint.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.app.di + +import android.content.Context +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.LegacyDataImporter +import org.onebusaway.android.database.oba.RouteDao +import org.onebusaway.android.database.oba.RouteHeadsignFavoriteDao +import org.onebusaway.android.database.oba.RouteRecorder +import org.onebusaway.android.database.oba.StopDao +import org.onebusaway.android.database.oba.TripDao +import org.onebusaway.android.database.widealerts.AlertsRepository +import org.onebusaway.android.reminders.ReminderRepository + +/** + * A Hilt [EntryPoint] that lets code which can't be constructor-injected reach the unified Room DAOs — + * specifically the My-tab list repositories, which are hand-built from a [Context] at a Compose call + * site (`MyListScreens`). Prefer constructor injection elsewhere; this mirrors the existing + * [NetworkEntryPoint]/[RegionEntryPoint] seam for the same non-injectable spot. + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface DatabaseEntryPoint { + + fun stopDao(): StopDao + + fun routeDao(): RouteDao + + fun tripDao(): TripDao + + fun routeHeadsignFavoriteDao(): RouteHeadsignFavoriteDao + + fun importGate(): ImportGate + + fun routeRecorder(): RouteRecorder + + fun reminderRepository(): ReminderRepository + + fun legacyDataImporter(): LegacyDataImporter + + fun alertsRepository(): AlertsRepository + + companion object { + @JvmStatic + fun get(context: Context): DatabaseEntryPoint = + EntryPointAccessors.fromApplication( + context.applicationContext, DatabaseEntryPoint::class.java + ) + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/app/di/DatabaseModule.kt b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/DatabaseModule.kt new file mode 100644 index 0000000000..e2fcb2a974 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/DatabaseModule.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.app.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import org.onebusaway.android.database.AppDatabase +import org.onebusaway.android.database.DatabaseProvider +import org.onebusaway.android.database.oba.DefaultImportGate +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.LegacyDataImporter +import org.onebusaway.android.database.oba.LegacyImportDao +import org.onebusaway.android.database.oba.NavStopDao +import org.onebusaway.android.database.oba.RegionDao +import org.onebusaway.android.database.oba.RouteDao +import org.onebusaway.android.database.oba.RouteHeadsignFavoriteDao +import org.onebusaway.android.database.oba.ServiceAlertDao +import org.onebusaway.android.database.oba.StopDao +import org.onebusaway.android.database.oba.StopRouteFilterDao +import org.onebusaway.android.database.oba.TripDao +import org.onebusaway.android.database.survey.dao.StudiesDao +import org.onebusaway.android.database.survey.dao.SurveysDao +import org.onebusaway.android.database.widealerts.dao.AlertDao +import org.onebusaway.android.preferences.PreferencesRepository + +/** + * Provides the unified Room [AppDatabase] and its DAOs to Hilt-managed code (storage-modernization). + * The instance is sourced from [DatabaseProvider] so it's the same singleton reached by the few + * bare-[Context] callers (e.g. Backup, and the GTFS alert fetcher via [DatabaseEntryPoint]). + */ +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase = + DatabaseProvider.getDatabase(context) + + @Provides + fun provideLegacyImportDao(db: AppDatabase): LegacyImportDao = db.legacyImportDao() + + @Provides + fun provideServiceAlertDao(db: AppDatabase): ServiceAlertDao = db.serviceAlertDao() + + @Provides + fun provideStopRouteFilterDao(db: AppDatabase): StopRouteFilterDao = db.stopRouteFilterDao() + + @Provides + fun provideStopDao(db: AppDatabase): StopDao = db.stopDao() + + @Provides + fun provideRouteDao(db: AppDatabase): RouteDao = db.routeDao() + + @Provides + fun provideTripDao(db: AppDatabase): TripDao = db.tripDao() + + @Provides + fun provideRouteHeadsignFavoriteDao(db: AppDatabase): RouteHeadsignFavoriteDao = + db.routeHeadsignFavoriteDao() + + @Provides + fun provideRegionDao(db: AppDatabase): RegionDao = db.regionDao() + + @Provides + fun provideNavStopDao(db: AppDatabase): NavStopDao = db.navStopDao() + + @Provides + fun provideStudiesDao(db: AppDatabase): StudiesDao = db.studiesDao() + + @Provides + fun provideSurveysDao(db: AppDatabase): SurveysDao = db.surveysDao() + + @Provides + fun provideAlertDao(db: AppDatabase): AlertDao = db.alertsDao() + + @Provides + fun provideImportGate(impl: DefaultImportGate): ImportGate = impl + + @Provides + @Singleton + fun provideLegacyDataImporter( + @ApplicationContext context: Context, + db: AppDatabase, + prefs: PreferencesRepository, + ): LegacyDataImporter = LegacyDataImporter(context, db, prefs) +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/backup/Backup.java b/onebusaway-android/src/main/java/org/onebusaway/android/backup/Backup.java deleted file mode 100644 index 13e0ba4b9d..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/backup/Backup.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (C) 2012 Paul Watts (paulcwatts@gmail.com) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.onebusaway.android.backup; - -import static org.onebusaway.android.backup.BackupUtilKt.uriToTempFile; - -import android.content.ContentProviderClient; -import android.content.Context; -import android.net.Uri; -import android.util.Log; -import android.widget.Toast; - -import org.apache.commons.io.FileUtils; -import org.onebusaway.android.R; -import org.onebusaway.android.provider.ObaContract; -import org.onebusaway.android.provider.ObaProvider; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -/** - * Big note, that this is currently fairly unsafe. - * There's no thread safety at all to make sure the database file - * isn't being written on another thread while we are copying the file. - * If that becomes an issue, I would rather just dump everything - * into a CSV file, since we know that reading/writing to the - * ContentProvider interface is going to be threadsafe. - * - * @author paulw - */ -public final class Backup { - - public static final String FILE_NAME = "OneBusAway.backup"; - - private static File getDB(Context context) { - return ObaProvider.getDatabasePath(context); - } - - /** - * Initiates a backup process, allowing the user to choose a location - * (such as the Documents directory) to save the backup file. - */ - public static void backup(Context context,Uri uri) throws IOException{ - try (InputStream inputStream = new FileInputStream(getDB(context)); - OutputStream outputStream = context.getContentResolver().openOutputStream(uri)) { - byte[] buffer = new byte[1024]; - int length; - while ((length = inputStream.read(buffer)) > 0) { - if (outputStream != null) { - outputStream.write(buffer, 0, length); - } - } - if (outputStream != null) { - outputStream.flush(); - } - Toast.makeText(context, - context.getString(R.string.preferences_db_saved), - Toast.LENGTH_LONG).show(); - Log.d("Backup", "Database backup saved successfully to: " + uri); - } catch (IOException e) { - Toast.makeText(context, - context.getString(R.string.preferences_db_save_error, e.getMessage()), - Toast.LENGTH_LONG).show(); - Log.e("Backup", "Error saving database backup", e); - } - } - - /** - * Restores data from the location where the user saved the backup. - * @param uri URI to the backup file, as returned by the system UI picker. Following targeting - * Android 11 we can't access this directory and need to rely on the system UI picker. - */ - public static void restore(Context context, Uri uri) throws IOException { - File dbPath = getDB(context); - File backupPath = uriToTempFile(context, uri); - - // At least here we can decide that the database is closed. - ContentProviderClient client = null; - try { - client = context.getContentResolver() - .acquireContentProviderClient(ObaContract.AUTHORITY); - ObaProvider provider = (ObaProvider) client.getLocalContentProvider(); - provider.closeDB(); - - FileUtils.copyFile(backupPath, dbPath); - - } finally { - if (client != null) { - client.release(); - } - if (backupPath != null) { - backupPath.delete(); - } - } - } - -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/backup/Backup.kt b/onebusaway-android/src/main/java/org/onebusaway/android/backup/Backup.kt new file mode 100644 index 0000000000..6a78db3dea --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/backup/Backup.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2012 Paul Watts (paulcwatts@gmail.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.backup + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import android.util.Log +import android.widget.Toast +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import kotlinx.coroutines.runBlocking +import org.apache.commons.io.FileUtils +import org.onebusaway.android.R +import org.onebusaway.android.app.di.DatabaseEntryPoint +import org.onebusaway.android.database.DatabaseProvider + +/** + * Backs up and restores the app's Room database (`app_database`) to/from a user-picked file. + * + * Backup writes a byte copy after folding the WAL into the main file, so the copy captures committed + * writes. Restore sniffs the file: a Room-format backup replaces the database file directly, while an + * older legacy ContentProvider-format backup is routed through the same defensive [LegacyDataImporter] + * that migrates the live legacy DB, merging its rows into the current Room database. + */ +object Backup { + + const val FILE_NAME = "OneBusAway.backup" + + private const val TAG = "Backup" + + private fun dbFile(context: Context): File = + context.getDatabasePath(DatabaseProvider.DATABASE_NAME) + + @JvmStatic + fun backup(context: Context, uri: Uri) { + try { + // Fold committed WAL pages into the main file so the byte copy captures the latest state. + DatabaseProvider.getDatabase(context).openHelper.writableDatabase + .query("PRAGMA wal_checkpoint(TRUNCATE)").use { it.moveToFirst() } + // A null stream means the destination couldn't be opened — treat that as a failure, not a + // silent success (otherwise the success Toast would fire without any bytes written). + val out = context.contentResolver.openOutputStream(uri) + ?: throw IOException("Could not open output stream for backup: $uri") + out.use { FileInputStream(dbFile(context)).use { input -> input.copyTo(it) } } + Toast.makeText( + context, context.getString(R.string.preferences_db_saved), Toast.LENGTH_LONG + ).show() + Log.d(TAG, "Database backup saved successfully to: $uri") + } catch (e: IOException) { + Toast.makeText( + context, + context.getString(R.string.preferences_db_save_error, e.message), + Toast.LENGTH_LONG + ).show() + Log.e(TAG, "Error saving database backup", e) + } + } + + /** + * Restores [uri] into the app database, returning true when the caller must restart the process to + * finish. + * + * A Room-format backup is restored by swapping the database *file*, which forces a + * [DatabaseProvider.closeDatabase]/reopen and so a brand-new [org.onebusaway.android.database.AppDatabase] + * instance. The Hilt object graph still holds the old (now-closed) singleton — and every repository + * that captured a DAO from it — so those would throw on their next query. Returning true tells the + * caller to restart the process, rebuilding the whole graph against the restored file. A + * legacy-format backup is merged into the live instance in place (no swap, no close) and returns + * false. + */ + @JvmStatic + @Throws(IOException::class) + fun restore(context: Context, uri: Uri): Boolean { + val backupFile = uriToTempFile(context, uri) + ?: throw IOException("Could not read backup file") + return try { + if (isRoomBackup(backupFile)) { + restoreRoomBackup(context, backupFile) + true + } else { + // A legacy ContentProvider-format backup: merge it into the current Room DB via the + // same importer used for the one-time migration (rare, user-initiated, so blocking). + val importer = DatabaseEntryPoint.get(context).legacyDataImporter() + runBlocking { importer.importFrom(backupFile) } + false + } + } finally { + backupFile.delete() + } + } + + private fun restoreRoomBackup(context: Context, backupFile: File) { + DatabaseProvider.closeDatabase() + val dest = dbFile(context) + // Snapshot the live DB (WAL already folded in by closeDatabase) so we can roll back if the + // backup turns out to be incompatible — otherwise a mismatched-schema Room file would overwrite + // the live database and then crash on open, bricking the app with no recovery. + val previous = File(dest.path + ".restorebak") + if (dest.exists()) FileUtils.copyFile(dest, previous) + // Drop any stale WAL/SHM so the restored file isn't shadowed by the old journal. + File(dest.path + "-wal").delete() + File(dest.path + "-shm").delete() + FileUtils.copyFile(backupFile, dest) + try { + // Force Room to open and validate the restored file's schema identity now; a mismatched or + // corrupt backup throws here, while the previous DB is still recoverable. + DatabaseProvider.getDatabase(context).openHelper.readableDatabase + previous.delete() + } catch (e: Exception) { + DatabaseProvider.closeDatabase() + if (previous.exists()) FileUtils.copyFile(previous, dest) else dest.delete() + previous.delete() + DatabaseProvider.getDatabase(context) // reopen the restored-original (or fresh) DB + throw IOException("Restored backup is not a compatible database", e) + } + } + + /** True if [file] is a Room-format database (has Room's identity table), vs a legacy provider DB. */ + private fun isRoomBackup(file: File): Boolean = try { + SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name='room_master_table'", + null + ).use { it.count > 0 } + } + } catch (e: Exception) { + Log.e(TAG, "Could not sniff backup schema", e) + false + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/backup/BackupUtils.java b/onebusaway-android/src/main/java/org/onebusaway/android/backup/BackupUtils.java index e599405095..3b35de274a 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/backup/BackupUtils.java +++ b/onebusaway-android/src/main/java/org/onebusaway/android/backup/BackupUtils.java @@ -76,7 +76,15 @@ static private void doRestore(Context activityContext, Uri uri, Runnable onResto context.getString(R.string.analytics_label_button_press_restore_preference), null); try { - Backup.restore(context, uri); + boolean restartRequired = Backup.restore(context, uri); + if (restartRequired) { + // A Room-format backup swapped the database file, leaving Hilt holding the old, closed + // AppDatabase singleton (and every repository that captured a DAO from it). Relaunch the + // app so the whole object graph rebuilds against the restored file; the fresh start + // re-resolves the region, so onRestored isn't needed on this path. + restartApp(context); + return; + } Toast.makeText(context, context.getString(R.string.preferences_db_restored, context.getString(R.string.app_name)), @@ -95,6 +103,21 @@ static private void doRestore(Context activityContext, Uri uri, Runnable onResto } } + /** + * Relaunches the app in a fresh process. Used after a Room-format backup restore swaps the database + * file: the Hilt-scoped {@link org.onebusaway.android.database.AppDatabase} singleton (and every + * repository that captured a DAO from it) still points at the old, now-closed instance, and Hilt + * can't evict a singleton — a process restart is the reliable way to rebuild the graph. The activity + * start is handed to the system before the process dies, so it relaunches into a clean process. + */ + private static void restartApp(Context context) { + Intent launch = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); + if (launch != null && launch.getComponent() != null) { + context.startActivity(Intent.makeRestartActivityTask(launch.getComponent())); + } + Runtime.getRuntime().exit(0); + } + /** * Creates a backup of the current OBA database on local storage. * @param uri The URI representing the location where the backup file should be saved. @@ -108,11 +131,7 @@ public static void save(Context activityContext,Uri uri) { PlausibleAnalytics.REPORT_BACKUP_EVENT_URL, context.getString(R.string.analytics_label_button_press_save_preference), null); - try { - Backup.backup(context,uri); - } catch (IOException e) { - Log.d(TAG, Objects.requireNonNull(e.getMessage())); - } + Backup.backup(context, uri); } /** diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/AppDatabase.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/AppDatabase.kt index d4fc0ecd2e..9b99edb926 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/AppDatabase.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/AppDatabase.kt @@ -2,10 +2,26 @@ package org.onebusaway.android.database import androidx.room.Database import androidx.room.RoomDatabase -import org.onebusaway.android.database.recentStops.dao.RegionDao -import org.onebusaway.android.database.recentStops.dao.StopDao -import org.onebusaway.android.database.recentStops.entity.RegionEntity -import org.onebusaway.android.database.recentStops.entity.StopEntity +import org.onebusaway.android.database.oba.LegacyImportDao +import org.onebusaway.android.database.oba.NavStopDao +import org.onebusaway.android.database.oba.NavStopRecord +import org.onebusaway.android.database.oba.RegionDao +import org.onebusaway.android.database.oba.RouteDao +import org.onebusaway.android.database.oba.RouteHeadsignFavoriteDao +import org.onebusaway.android.database.oba.ServiceAlertDao +import org.onebusaway.android.database.oba.StopDao +import org.onebusaway.android.database.oba.StopRouteFilterDao +import org.onebusaway.android.database.oba.TripDao +import org.onebusaway.android.database.oba.Open311ServerRecord +import org.onebusaway.android.database.oba.RegionBoundRecord +import org.onebusaway.android.database.oba.RegionRecord +import org.onebusaway.android.database.oba.RouteHeadsignFavoriteRecord +import org.onebusaway.android.database.oba.RouteRecord +import org.onebusaway.android.database.oba.ServiceAlertRecord +import org.onebusaway.android.database.oba.StopRecord +import org.onebusaway.android.database.oba.StopRouteFilterRecord +import org.onebusaway.android.database.oba.TripAlertRecord +import org.onebusaway.android.database.oba.TripRecord import org.onebusaway.android.database.survey.dao.StudiesDao import org.onebusaway.android.database.survey.dao.SurveysDao import org.onebusaway.android.database.survey.entity.Study @@ -14,15 +30,31 @@ import org.onebusaway.android.database.widealerts.dao.AlertDao import org.onebusaway.android.database.widealerts.entity.AlertEntity /** - * Main database class for the app, containing `Study` and `Survey` entities. - * Provides abstract methods for accessing `StudiesDao` and `SurveysDao`. - * The `@Database` annotation sets up Room with version 1 of the schema. + * The app's single Room database. Holds the survey + wide-alert tables and — as of v3 — the 11 tables + * migrated from the legacy `ObaProvider` ContentProvider (storage-modernization). The dead recentStops + * module (its own `stops`/`regions` tables) was removed in the same change; v3's migration drops those + * and creates the legacy-schema tables in their place. These tables are now the authoritative store: + * the ContentProvider has been removed and any pre-existing data is imported once via + * [org.onebusaway.android.database.oba.LegacyDataImporter]. */ - -// Beginning from version 2 we should support auto migration @Database( - entities = [Study::class, Survey::class, RegionEntity::class, StopEntity::class, AlertEntity::class], - version = 2, + entities = [ + Study::class, + Survey::class, + AlertEntity::class, + StopRecord::class, + RouteRecord::class, + TripRecord::class, + StopRouteFilterRecord::class, + TripAlertRecord::class, + ServiceAlertRecord::class, + RegionRecord::class, + RegionBoundRecord::class, + Open311ServerRecord::class, + RouteHeadsignFavoriteRecord::class, + NavStopRecord::class, + ], + version = 3, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { @@ -30,10 +62,19 @@ abstract class AppDatabase : RoomDatabase() { abstract fun studiesDao(): StudiesDao abstract fun surveysDao(): SurveysDao - // Recent stops for region - abstract fun regionDao(): RegionDao - abstract fun stopDao(): StopDao - // Region wide alerts abstract fun alertsDao(): AlertDao + + // One-time import of the legacy ObaProvider data (storage-modernization). + abstract fun legacyImportDao(): LegacyImportDao + + // Migrated legacy-table DAOs (storage-modernization). + abstract fun serviceAlertDao(): ServiceAlertDao + abstract fun stopRouteFilterDao(): StopRouteFilterDao + abstract fun stopDao(): StopDao + abstract fun routeDao(): RouteDao + abstract fun tripDao(): TripDao + abstract fun routeHeadsignFavoriteDao(): RouteHeadsignFavoriteDao + abstract fun regionDao(): RegionDao + abstract fun navStopDao(): NavStopDao } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/DatabaseProvider.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/DatabaseProvider.kt index 2300c3cfbd..ab052a6433 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/DatabaseProvider.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/DatabaseProvider.kt @@ -8,6 +8,10 @@ import androidx.room.Room * Ensures that only one instance of the database is created and used throughout the application. */ object DatabaseProvider { + /** The Room database filename (also the backup target). */ + const val DATABASE_NAME = "app_database" + + @Volatile private var INSTANCE: AppDatabase? = null /** @@ -18,13 +22,24 @@ object DatabaseProvider { */ fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { - val instance = Room.databaseBuilder( + // Double-checked locking: two callers can both observe a null INSTANCE and serialize here, so + // re-read inside the lock and reuse whatever the first one built rather than creating a second. + INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, - "app_database" - ).addMigrations(MIGRATION_1_2).build() - INSTANCE = instance - instance + DATABASE_NAME + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { INSTANCE = it } + } + } + + /** + * Closes and forgets the singleton so the on-disk file can be replaced (a backup restore). The next + * [getDatabase] reopens it. The legacy `ObaProvider.closeDB()` analogue. + */ + fun closeDatabase() { + synchronized(this) { + INSTANCE?.close() + INSTANCE = null } } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/Migrations.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/Migrations.kt index db4157bd6c..954c33e249 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/Migrations.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/Migrations.kt @@ -7,4 +7,92 @@ val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("CREATE TABLE IF NOT EXISTS `alerts` (`id` TEXT NOT NULL, PRIMARY KEY(`id`))") } -} \ No newline at end of file +} + +/** + * Unifies the legacy `ObaProvider` ContentProvider's 11 tables into this Room database + * (storage-modernization). The dead recentStops module owned `stops`/`regions` tables that collide by + * name with the legacy tables, so they are dropped first. The CREATE statements are copied verbatim + * from the exported `3.json` schema so Room's identity validation passes. The tables are created empty; + * the one-time data import from the legacy database file lands in a later slice. + */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // Drop the dead recentStops tables (zero consumers) that collide by name. + db.execSQL("DROP TABLE IF EXISTS `stops`") + db.execSQL("DROP TABLE IF EXISTS `regions`") + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `stops` (`_id` TEXT NOT NULL, `code` TEXT NOT NULL, " + + "`name` TEXT NOT NULL, `direction` TEXT NOT NULL, `use_count` INTEGER NOT NULL, " + + "`latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `user_name` TEXT, " + + "`access_time` INTEGER, `favorite` INTEGER, `region_id` INTEGER, PRIMARY KEY(`_id`))" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `routes` (`_id` TEXT NOT NULL, `short_name` TEXT NOT NULL, " + + "`long_name` TEXT, `use_count` INTEGER NOT NULL, `user_name` TEXT, " + + "`access_time` INTEGER, `favorite` INTEGER, `url` TEXT, `region_id` INTEGER, " + + "PRIMARY KEY(`_id`))" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `trips` (`_id` TEXT NOT NULL, `stop_id` TEXT NOT NULL, " + + "`route_id` TEXT, `departure` INTEGER NOT NULL, `headsign` TEXT, `name` TEXT NOT NULL, " + + "`reminder` INTEGER NOT NULL, `alarm_delete_path` TEXT NOT NULL, " + + "`service_date` INTEGER NOT NULL, `stop_sequence` INTEGER NOT NULL, " + + "`trip_id` TEXT NOT NULL, `vehicle_id` TEXT, PRIMARY KEY(`_id`, `stop_id`))" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `stop_routes_filter` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT " + + "NOT NULL, `stop_id` TEXT NOT NULL, `route_id` TEXT NOT NULL)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `trip_alerts` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT " + + "NOT NULL, `trip_id` TEXT NOT NULL, `stop_id` TEXT NOT NULL, " + + "`start_time` INTEGER NOT NULL, `state` INTEGER NOT NULL DEFAULT 0)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `service_alerts` (`_id` TEXT NOT NULL, " + + "`marked_read_time` INTEGER, `hidden` INTEGER, PRIMARY KEY(`_id`))" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `regions` (`_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, `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`))" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `region_bounds` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT " + + "NOT NULL, `region_id` INTEGER NOT NULL, `lat` REAL NOT NULL, `lon` REAL NOT NULL, " + + "`lat_span` REAL NOT NULL, `lon_span` REAL NOT NULL, " + + "FOREIGN KEY(`region_id`) REFERENCES `regions`(`_id`) ON UPDATE NO ACTION " + + "ON DELETE CASCADE )" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_region_bounds_region_id` ON `region_bounds` (`region_id`)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `open311_servers` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT " + + "NOT NULL, `region_id` INTEGER NOT NULL, `jurisdiction` TEXT, `api_key` TEXT NOT NULL, " + + "`open311_base_url` TEXT NOT NULL)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `route_headsign_favorites` (`_id` INTEGER PRIMARY KEY " + + "AUTOINCREMENT NOT NULL, `route_id` TEXT NOT NULL, `headsign` TEXT NOT NULL, " + + "`stop_id` TEXT NOT NULL, `exclude` INTEGER NOT NULL)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `nav_stops` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`nav_id` TEXT NOT NULL, `start_time` INTEGER NOT NULL, `trip_id` TEXT NOT NULL, " + + "`destination_id` TEXT NOT NULL, `before_id` TEXT NOT NULL, `seq_num` INTEGER NOT NULL, " + + "`is_active` INTEGER NOT NULL)" + ) + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/ImportGate.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/ImportGate.kt new file mode 100644 index 0000000000..3f32936e32 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/ImportGate.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import android.util.Log +import java.util.concurrent.CancellationException +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import org.onebusaway.android.app.di.AppScope + +/** + * Readiness gate for the one-time legacy-ContentProvider → Room import (storage-modernization). The + * import must finish before the first Room read/write of the migrated tables, or a reader would see an + * empty table and a writer's row could be wiped by the importer's clear-then-insert. Every repository + * awaits [awaitReady] as the first line of each migrated read AND write; Room-backed flows gate via + * `onStart { gate.awaitReady() }`. + * + * The import runs exactly once: the [Deferred] is started lazily on first [awaitReady] (or kicked + * eagerly from `Application.onCreate` so it overlaps startup) and every caller awaits that same job. + * + * CRASH-IMMUNITY: an import failure must NEVER prevent the app from opening. Each import is wrapped so + * [awaitReady] always completes normally — a caller can't crash on the gate. A failed import leaves the + * done-flag unset and the legacy DB file in place (the importer only advances those on success), so the + * app simply opens without the migrated data AND self-heals: the import is retried on the next launch, + * and a later app version that fixes the cause recovers the data. Failing to open would be + * unrecoverable; opening-without-data is not. + * + * IMPORTANT: never await this from inside a DAO — the importer itself writes via [LegacyImportDao], so + * gating a DAO method would deadlock the import on itself. + * + * Extracted as an interface so gated repositories can be unit-tested with a no-op fake (the production + * implementation, [DefaultImportGate], needs an app scope and the importer, which aren't constructible + * in a JVM test). + */ +interface ImportGate { + /** Suspends until the one-time import has completed (starting it on first call). Never throws. */ + suspend fun awaitReady() + + /** + * Eagerly begins the one-time import (fire-and-forget), so it overlaps app startup instead of + * first blocking a repository read. Idempotent — the import still runs exactly once. + */ + fun start() +} + +@Singleton +class DefaultImportGate @Inject constructor( + @AppScope private val appScope: CoroutineScope, + private val importer: LegacyDataImporter, +) : ImportGate { + private val ready: Deferred by lazy { + appScope.async { + // Independent + individually guarded: a failure of either import must not crash the app or + // block the other. On failure nothing is marked done, so the next launch retries. + guarded("Legacy data import") { importer.importIfNeeded() } + guarded("Survey DB import") { importer.importSurveyDbIfNeeded() } + } + } + + /** Runs [block], letting cancellation propagate but swallowing any other failure (crash-immunity). */ + private suspend fun guarded(label: String, block: suspend () -> Unit) { + try { + block() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (e: Exception) { + Log.e(TAG, "$label failed; opening without it (will retry next launch)", e) + } + } + + override suspend fun awaitReady() = ready.await() + + override fun start() { + ready.start() + } + + private companion object { + const val TAG = "ImportGate" + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/LegacyDataImporter.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/LegacyDataImporter.kt new file mode 100644 index 0000000000..5d094a74b2 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/LegacyDataImporter.kt @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import androidx.room.withTransaction +import java.io.File +import org.onebusaway.android.BuildConfig +import org.onebusaway.android.database.AppDatabase +import org.onebusaway.android.database.survey.entity.Study +import org.onebusaway.android.database.survey.entity.Survey +import org.onebusaway.android.preferences.PreferencesRepository + +/** + * One-time migration of the legacy `ObaProvider` ContentProvider data (the separate + * `.db` SQLite file) into the unified Room database (storage-modernization). + * + * Reads the legacy file defensively — each column is looked up by name and missing columns default — + * so a file left at any historical schema version (e.g. a user who skipped releases) imports without a + * "no such column" failure, and no copy of the legacy 34-step upgrade chain is needed. The only + * fidelity caveat is a pre-v30 trips table (which the app itself dropped-and-recreated on upgrade): + * such rows import with their newer columns defaulted, which is harmless for the stale reminders they'd + * represent. + * + * The write is one transaction ([LegacyImportDao.replaceAll]); the legacy file is deleted afterward and + * a DataStore flag records completion. Both the file-existence guard and the clear-then-insert make a + * crashed/retried import idempotent, so the flag is an optimization, not a correctness dependency. + */ +class LegacyDataImporter( + private val context: Context, + private val database: AppDatabase, + private val prefs: PreferencesRepository, +) { + + /** Imports the legacy data once, then deletes the legacy file. No-op if already done or absent. */ + suspend fun importIfNeeded() { + if (prefs.getBoolean(IMPORT_DONE_KEY, false)) return + val legacyFile = context.getDatabasePath(LEGACY_DB_NAME) + if (legacyFile.exists()) { + importFrom(legacyFile) + SQLiteDatabase.deleteDatabase(legacyFile) + } + prefs.setBoolean(IMPORT_DONE_KEY, true) + } + + /** Reads every legacy table and writes it into Room in one transaction. Visible for testing. */ + suspend fun importFrom(legacyFile: File) { + SQLiteDatabase.openDatabase(legacyFile.path, null, SQLiteDatabase.OPEN_READWRITE).use { legacy -> + database.legacyImportDao().replaceAll(readAll(legacy)) + } + } + + /** + * Imports the rogue `study-survey-db` file (the old separate survey Room database) into the unified + * database once, then deletes it. Read raw so the old file's Room schema version is irrelevant (it + * had no migrations, so opening it as the now-v3 [AppDatabase] would otherwise crash). + */ + suspend fun importSurveyDbIfNeeded() { + if (prefs.getBoolean(SURVEY_IMPORT_DONE_KEY, false)) return + val surveyFile = context.getDatabasePath(SURVEY_DB_NAME) + if (surveyFile.exists()) { + importSurveyFrom(surveyFile) + SQLiteDatabase.deleteDatabase(surveyFile) + } + prefs.setBoolean(SURVEY_IMPORT_DONE_KEY, true) + } + + /** Reads studies/surveys from a legacy survey DB file and writes them into Room. Visible for testing. */ + suspend fun importSurveyFrom(surveyFile: File) { + val (studies, surveys) = + SQLiteDatabase.openDatabase(surveyFile.path, null, SQLiteDatabase.OPEN_READWRITE).use { db -> + val studies = db.read("studies") { + Study( + study_id = int("study_id") ?: return@read null, + name = str("name").orEmpty(), + description = str("description").orEmpty(), + is_subscribed = (int("is_subscribed") ?: 0) != 0, + ) + } + val surveys = db.read("surveys") { + Survey( + survey_id = int("survey_id") ?: return@read null, + study_id = int("study_id") ?: return@read null, + name = str("name").orEmpty(), + state = int("state") ?: 0, + ) + } + studies to surveys + } + // Studies before surveys (the surveys -> studies foreign key). Drop any survey whose study was + // skipped by the defensive read (a malformed study row) so one orphan can't FK-abort the whole + // survey import. + val studyIds = studies.mapTo(HashSet()) { it.study_id } + database.withTransaction { + studies.forEach { database.studiesDao().insertStudy(it) } + surveys.filter { it.study_id in studyIds } + .forEach { database.surveysDao().insertSurvey(it) } + } + } + + private fun readAll(db: SQLiteDatabase) = LegacyData( + stops = db.read("stops") { + StopRecord( + id = str("_id") ?: return@read null, + code = str("code").orEmpty(), + name = str("name").orEmpty(), + direction = str("direction").orEmpty(), + useCount = int("use_count") ?: 0, + latitude = dbl("latitude") ?: 0.0, + longitude = dbl("longitude") ?: 0.0, + userName = str("user_name"), + accessTime = long("access_time"), + favorite = int("favorite"), + regionId = long("region_id"), + ) + }, + routes = db.read("routes") { + RouteRecord( + id = str("_id") ?: return@read null, + shortName = str("short_name").orEmpty(), + longName = str("long_name"), + useCount = int("use_count") ?: 0, + userName = str("user_name"), + accessTime = long("access_time"), + favorite = int("favorite"), + url = str("url"), + regionId = long("region_id"), + ) + }, + trips = db.read("trips") { + TripRecord( + id = str("_id") ?: return@read null, + stopId = str("stop_id") ?: return@read null, + routeId = str("route_id"), + departure = int("departure") ?: 0, + headsign = str("headsign"), + name = str("name").orEmpty(), + reminder = int("reminder") ?: 0, + alarmDeletePath = str("alarm_delete_path").orEmpty(), + serviceDate = long("service_date") ?: 0, + stopSequence = int("stop_sequence") ?: 0, + tripId = str("trip_id").orEmpty(), + vehicleId = str("vehicle_id"), + ) + }, + stopRouteFilters = db.read("stop_routes_filter") { + StopRouteFilterRecord( + stopId = str("stop_id") ?: return@read null, + routeId = str("route_id") ?: return@read null, + ) + }, + tripAlerts = db.read("trip_alerts") { + TripAlertRecord( + id = long("_id") ?: 0, + tripId = str("trip_id") ?: return@read null, + stopId = str("stop_id") ?: return@read null, + startTime = long("start_time") ?: 0, + state = int("state") ?: 0, + ) + }, + serviceAlerts = db.read("service_alerts") { + ServiceAlertRecord( + id = str("_id") ?: return@read null, + markedReadTime = long("marked_read_time"), + hidden = int("hidden"), + ) + }, + regions = db.read("regions") { + RegionRecord( + id = long("_id") ?: return@read null, + name = str("name").orEmpty(), + obaBaseUrl = str("oba_base_url").orEmpty(), + siriBaseUrl = str("siri_base_url").orEmpty(), + language = str("lang").orEmpty(), + contactEmail = str("contact_email").orEmpty(), + supportsObaDiscovery = int("supports_api_discovery") ?: 0, + supportsObaRealtime = int("supports_api_realtime") ?: 0, + supportsSiriRealtime = int("supports_siri_realtime") ?: 0, + twitterUrl = str("twitter_url"), + experimental = int("experimental"), + stopInfoUrl = str("stop_info_url"), + otpBaseUrl = str("otp_base_url"), + otpContactEmail = str("otp_contact_email"), + supportsOtpBikeshare = int("supports_otp_bikeshare"), + supportsEmbeddedSocial = int("supports_embedded_social"), + paymentAndroidAppId = str("payment_android_app_id"), + paymentWarningTitle = str("payment_warning_title"), + paymentWarningBody = str("payment_warning_body"), + sidecarBaseUrl = str("sidecar_base_url"), + plausibleAnalyticsServerUrl = str("plausible_analytics_server_url"), + umamiAnalyticsUrl = str("umami_analytics_url"), + umamiAnalyticsId = str("umami_analytics_id"), + ) + }, + regionBounds = db.read("region_bounds") { + RegionBoundRecord( + id = long("_id") ?: 0, + regionId = long("region_id") ?: return@read null, + latitude = dbl("lat") ?: 0.0, + longitude = dbl("lon") ?: 0.0, + latSpan = dbl("lat_span") ?: 0.0, + lonSpan = dbl("lon_span") ?: 0.0, + ) + }, + open311Servers = db.read("open311_servers") { + Open311ServerRecord( + id = long("_id") ?: 0, + regionId = long("region_id") ?: return@read null, + jurisdiction = str("jurisdiction"), + apiKey = str("api_key").orEmpty(), + baseUrl = str("open311_base_url").orEmpty(), + ) + }, + routeHeadsignFavorites = db.read("route_headsign_favorites") { + RouteHeadsignFavoriteRecord( + routeId = str("route_id") ?: return@read null, + headsign = str("headsign").orEmpty(), + stopId = str("stop_id").orEmpty(), + exclude = int("exclude") ?: 0, + ) + }, + navStops = db.read("nav_stops") { + NavStopRecord( + id = long("_id") ?: 0, + navId = str("nav_id") ?: return@read null, + startTime = long("start_time") ?: 0, + tripId = str("trip_id").orEmpty(), + destinationId = str("destination_id").orEmpty(), + beforeId = str("before_id").orEmpty(), + sequence = int("seq_num") ?: 0, + active = int("is_active") ?: 0, + ) + }, + ) + + private companion object { + val LEGACY_DB_NAME = "${BuildConfig.APPLICATION_ID}.db" + const val SURVEY_DB_NAME = "study-survey-db" + const val IMPORT_DONE_KEY = "legacy_oba_import_done" + const val SURVEY_IMPORT_DONE_KEY = "legacy_survey_import_done" + } +} + +/** + * Reads every row of [table] (skipping the table entirely if it doesn't exist), mapping each row with + * [map]; a null return from [map] drops that row. Returns an empty list for a missing table so a + * legacy file at any historical schema version imports cleanly. + */ +private inline fun SQLiteDatabase.read(table: String, map: Cursor.() -> T?): List { + if (!tableExists(table)) return emptyList() + return query(table, null, null, null, null, null, null).use { c -> + buildList { while (c.moveToNext()) c.map()?.let { add(it) } } + } +} + +private fun SQLiteDatabase.tableExists(table: String): Boolean = + rawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name=?", arrayOf(table)) + .use { it.moveToFirst() } + +private fun Cursor.str(name: String): String? = + columnIndex(name)?.takeIf { !isNull(it) }?.let { getString(it) } + +private fun Cursor.int(name: String): Int? = + columnIndex(name)?.takeIf { !isNull(it) }?.let { getInt(it) } + +private fun Cursor.long(name: String): Long? = + columnIndex(name)?.takeIf { !isNull(it) }?.let { getLong(it) } + +private fun Cursor.dbl(name: String): Double? = + columnIndex(name)?.takeIf { !isNull(it) }?.let { getDouble(it) } + +/** The column's index, or null when the legacy file's schema version predates that column. */ +private fun Cursor.columnIndex(name: String): Int? = getColumnIndex(name).takeIf { it >= 0 } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/LegacyImportDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/LegacyImportDao.kt new file mode 100644 index 0000000000..bfe5f201ea --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/LegacyImportDao.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction + +/** The 11 legacy tables' rows, read from the legacy ContentProvider DB, ready to insert into Room. */ +data class LegacyData( + val stops: List, + val routes: List, + val trips: List, + val stopRouteFilters: List, + val tripAlerts: List, + val serviceAlerts: List, + val regions: List, + val regionBounds: List, + val open311Servers: List, + val routeHeadsignFavorites: List, + val navStops: List, +) + +/** + * Inserts the legacy ContentProvider data into the unified Room database (one-time migration). Used + * only by the importer; consumer DAOs are added per-table where they're consumed. [replaceAll] clears + * the destination tables and re-inserts in a single transaction so a crashed/retried import is + * idempotent (the synthetic-rowid tables would otherwise duplicate rows on re-run). + */ +@Dao +interface LegacyImportDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertStops(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRoutes(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTrips(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertStopRouteFilters(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTripAlerts(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertServiceAlerts(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRegions(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRegionBounds(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOpen311Servers(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRouteHeadsignFavorites(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertNavStops(rows: List) + + @Query("DELETE FROM stops") suspend fun clearStops() + @Query("DELETE FROM routes") suspend fun clearRoutes() + @Query("DELETE FROM trips") suspend fun clearTrips() + @Query("DELETE FROM stop_routes_filter") suspend fun clearStopRouteFilters() + @Query("DELETE FROM trip_alerts") suspend fun clearTripAlerts() + @Query("DELETE FROM service_alerts") suspend fun clearServiceAlerts() + @Query("DELETE FROM region_bounds") suspend fun clearRegionBounds() + @Query("DELETE FROM open311_servers") suspend fun clearOpen311Servers() + @Query("DELETE FROM regions") suspend fun clearRegions() + @Query("DELETE FROM route_headsign_favorites") suspend fun clearRouteHeadsignFavorites() + @Query("DELETE FROM nav_stops") suspend fun clearNavStops() + + /** + * Replaces all legacy-table data atomically. Children are cleared before parents and parents + * inserted before children to satisfy the region_bounds -> regions foreign key. + */ + @Transaction + suspend fun replaceAll(data: LegacyData) { + clearRegionBounds() + clearOpen311Servers() + clearRegions() + clearStops() + clearRoutes() + clearTrips() + clearStopRouteFilters() + clearTripAlerts() + clearServiceAlerts() + clearRouteHeadsignFavorites() + clearNavStops() + + insertRegions(data.regions) + insertRegionBounds(data.regionBounds) + insertOpen311Servers(data.open311Servers) + insertStops(data.stops) + insertRoutes(data.routes) + insertTrips(data.trips) + insertStopRouteFilters(data.stopRouteFilters) + insertTripAlerts(data.tripAlerts) + insertServiceAlerts(data.serviceAlerts) + insertRouteHeadsignFavorites(data.routeHeadsignFavorites) + insertNavStops(data.navStops) + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/NavStopDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/NavStopDao.kt new file mode 100644 index 0000000000..656b6abd15 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/NavStopDao.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction + +/** + * Room access for the turn-by-turn navigation state (the legacy `nav_stops` table). Only ever one + * active trip: the legacy insert deleted all rows first, mirrored here by [replaceActive]. + */ +@Dao +interface NavStopDao { + + @Query("DELETE FROM nav_stops") + suspend fun clearAll() + + @Insert + suspend fun insert(row: NavStopRecord) + + /** Stores the single active navigation trip, clearing any prior one (legacy delete-then-insert). */ + @Transaction + suspend fun replaceActive(row: NavStopRecord) { + clearAll() + insert(row) + } + + /** The trip/destination/before stop ids for a navigation id (the legacy `getDetails`). */ + @Query( + "SELECT trip_id AS tripId, destination_id AS destinationId, before_id AS beforeId " + + "FROM nav_stops WHERE nav_id = :navId LIMIT 1" + ) + suspend fun getDetails(navId: String): NavStopDetailsRow? +} + +/** The three stop ids a navigation session needs (the legacy `NavStops.getDetails` projection). */ +data class NavStopDetailsRow( + val tripId: String, + val destinationId: String, + val beforeId: String, +) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/ObaEntities.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/ObaEntities.kt new file mode 100644 index 0000000000..136f367bb3 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/ObaEntities.kt @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room entities mirroring the 11 tables of the legacy `ObaProvider` ContentProvider, unified into the + * app's single Room database (storage-modernization). Column names match the legacy SQLite schema (via + * [ColumnInfo]) so the one-time importer can copy rows faithfully and the names stay stable as the + * intent/backup vocabulary. + * + * Nullability deliberately mirrors the loose legacy schema: columns added by `ALTER TABLE ADD COLUMN` + * (favorites, custom names, access times, URLs, later region fields) are nullable and frequently NULL + * in the wild, with computed helpers preserving the legacy "NULL reads as false/absent" semantics. + * Two legacy tables (stop_routes_filter, route_headsign_favorites) had no primary key — duplicate rows + * are allowed by design — so they get a synthetic auto-increment row id that is behaviorally inert. + */ + +@Entity(tableName = "stops") +data class StopRecord( + @PrimaryKey @ColumnInfo(name = "_id") val id: String, + @ColumnInfo(name = "code") val code: String, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "direction") val direction: String, + @ColumnInfo(name = "use_count") val useCount: Int, + @ColumnInfo(name = "latitude") val latitude: Double, + @ColumnInfo(name = "longitude") val longitude: Double, + @ColumnInfo(name = "user_name") val userName: String? = null, + @ColumnInfo(name = "access_time") val accessTime: Long? = null, + @ColumnInfo(name = "favorite") val favorite: Int? = null, + @ColumnInfo(name = "region_id") val regionId: Long? = null, +) { + /** The legacy projected UI_NAME: the user's custom name when set, otherwise the stop name. */ + val uiName: String get() = if (userName != null) userName else name + val isFavorite: Boolean get() = favorite == 1 +} + +@Entity(tableName = "routes") +data class RouteRecord( + @PrimaryKey @ColumnInfo(name = "_id") val id: String, + @ColumnInfo(name = "short_name") val shortName: String, + @ColumnInfo(name = "long_name") val longName: String? = null, + @ColumnInfo(name = "use_count") val useCount: Int, + @ColumnInfo(name = "user_name") val userName: String? = null, + @ColumnInfo(name = "access_time") val accessTime: Long? = null, + @ColumnInfo(name = "favorite") val favorite: Int? = null, + @ColumnInfo(name = "url") val url: String? = null, + @ColumnInfo(name = "region_id") val regionId: Long? = null, +) { + val isFavorite: Boolean get() = favorite == 1 +} + +/** + * The legacy trips table had no declared primary key; uniqueness was enforced by app logic on + * (_id, stop_id). That composite key is made explicit here. route_id/headsign/vehicle_id are nullable + * because the save path supplies nullable values (the legacy NOT NULL was never actually exercised + * with nulls). + */ +@Entity(tableName = "trips", primaryKeys = ["_id", "stop_id"]) +data class TripRecord( + @ColumnInfo(name = "_id") val id: String, + @ColumnInfo(name = "stop_id") val stopId: String, + @ColumnInfo(name = "route_id") val routeId: String? = null, + @ColumnInfo(name = "departure") val departure: Int, + @ColumnInfo(name = "headsign") val headsign: String? = null, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "reminder") val reminder: Int, + @ColumnInfo(name = "alarm_delete_path") val alarmDeletePath: String, + @ColumnInfo(name = "service_date") val serviceDate: Long, + @ColumnInfo(name = "stop_sequence") val stopSequence: Int, + @ColumnInfo(name = "trip_id") val tripId: String, + @ColumnInfo(name = "vehicle_id") val vehicleId: String? = null, +) + +@Entity(tableName = "stop_routes_filter") +data class StopRouteFilterRecord( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "_id") val id: Long = 0, + @ColumnInfo(name = "stop_id") val stopId: String, + @ColumnInfo(name = "route_id") val routeId: String, +) + +@Entity(tableName = "trip_alerts") +data class TripAlertRecord( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "_id") val id: Long = 0, + @ColumnInfo(name = "trip_id") val tripId: String, + @ColumnInfo(name = "stop_id") val stopId: String, + @ColumnInfo(name = "start_time") val startTime: Long, + @ColumnInfo(name = "state", defaultValue = "0") val state: Int, +) + +@Entity(tableName = "service_alerts") +data class ServiceAlertRecord( + @PrimaryKey @ColumnInfo(name = "_id") val id: String, + @ColumnInfo(name = "marked_read_time") val markedReadTime: Long? = null, + @ColumnInfo(name = "hidden") val hidden: Int? = null, +) { + val isHidden: Boolean get() = hidden == 1 +} + +@Entity(tableName = "regions") +data class RegionRecord( + // NOT autoGenerate: region ids are assigned by the OBA regions directory (Tampa is _id=0), never by + // the DB. With autoGenerate, Room binds NULL for a 0-valued id and SQLite reassigns it, orphaning + // the region_bounds/open311 foreign keys that reference _id=0. + @PrimaryKey @ColumnInfo(name = "_id") val id: Long = 0, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "oba_base_url") val obaBaseUrl: String, + @ColumnInfo(name = "siri_base_url") val siriBaseUrl: String, + @ColumnInfo(name = "lang") val language: String, + @ColumnInfo(name = "contact_email") val contactEmail: String, + @ColumnInfo(name = "supports_api_discovery") val supportsObaDiscovery: Int, + @ColumnInfo(name = "supports_api_realtime") val supportsObaRealtime: Int, + @ColumnInfo(name = "supports_siri_realtime") val supportsSiriRealtime: Int, + @ColumnInfo(name = "twitter_url") val twitterUrl: String? = null, + @ColumnInfo(name = "experimental") val experimental: Int? = null, + @ColumnInfo(name = "stop_info_url") val stopInfoUrl: String? = null, + @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 = "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, + @ColumnInfo(name = "payment_warning_body") val paymentWarningBody: String? = null, + @ColumnInfo(name = "sidecar_base_url", defaultValue = "https://onebusaway.co") + val sidecarBaseUrl: String? = null, + @ColumnInfo(name = "plausible_analytics_server_url") val plausibleAnalyticsServerUrl: String? = null, + @ColumnInfo(name = "umami_analytics_url") val umamiAnalyticsUrl: String? = null, + @ColumnInfo(name = "umami_analytics_id") val umamiAnalyticsId: String? = null, +) + +@Entity( + tableName = "region_bounds", + foreignKeys = [ + ForeignKey( + entity = RegionRecord::class, + parentColumns = ["_id"], + childColumns = ["region_id"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [Index("region_id")], +) +data class RegionBoundRecord( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "_id") val id: Long = 0, + @ColumnInfo(name = "region_id") val regionId: Long, + @ColumnInfo(name = "lat") val latitude: Double, + @ColumnInfo(name = "lon") val longitude: Double, + @ColumnInfo(name = "lat_span") val latSpan: Double, + @ColumnInfo(name = "lon_span") val lonSpan: Double, +) + +/** + * The legacy open311_servers table had no cleanup trigger (RegionUtils deletes all three region tables + * manually), so — unlike region_bounds — no foreign key is declared here, preserving that behavior. + */ +@Entity(tableName = "open311_servers") +data class Open311ServerRecord( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "_id") val id: Long = 0, + @ColumnInfo(name = "region_id") val regionId: Long, + @ColumnInfo(name = "jurisdiction") val jurisdiction: String? = null, + @ColumnInfo(name = "api_key") val apiKey: String, + @ColumnInfo(name = "open311_base_url") val baseUrl: String, +) + +@Entity(tableName = "route_headsign_favorites") +data class RouteHeadsignFavoriteRecord( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "_id") val id: Long = 0, + @ColumnInfo(name = "route_id") val routeId: String, + @ColumnInfo(name = "headsign") val headsign: String, + @ColumnInfo(name = "stop_id") val stopId: String, + @ColumnInfo(name = "exclude") val exclude: Int, +) + +@Entity(tableName = "nav_stops") +data class NavStopRecord( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "_id") val id: Long = 0, + @ColumnInfo(name = "nav_id") val navId: String, + @ColumnInfo(name = "start_time") val startTime: Long, + @ColumnInfo(name = "trip_id") val tripId: String, + @ColumnInfo(name = "destination_id") val destinationId: String, + @ColumnInfo(name = "before_id") val beforeId: String, + @ColumnInfo(name = "seq_num") val sequence: Int, + @ColumnInfo(name = "is_active") val active: Int, +) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RegionDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RegionDao.kt new file mode 100644 index 0000000000..7f4e09002e --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RegionDao.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Dao +import androidx.room.Embedded +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Relation +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +/** A region and its bounds + Open311 servers, the whole region-cache row for one deployment. */ +data class RegionWithChildren( + @Embedded val region: RegionRecord, + @Relation(parentColumn = "_id", entityColumn = "region_id") + val bounds: List, + @Relation(parentColumn = "_id", entityColumn = "region_id") + val open311Servers: List, +) + +/** + * Room access for the region cache (the legacy `regions` + `region_bounds` + `open311_servers` + * tables). Replaces `RegionUtils.getRegionsFromProvider`/`saveToProvider`. + */ +@Dao +interface RegionDao { + + @Transaction + @Query("SELECT * FROM regions WHERE _id = :id LIMIT 1") + suspend fun getRegion(id: Long): RegionWithChildren? + + @Transaction + @Query("SELECT * FROM regions") + suspend fun getAllRegions(): List + + /** The region cache as a reactive stream, so cache refreshes propagate like the other domains. */ + @Transaction + @Query("SELECT * FROM regions") + fun regionsFlow(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRegion(region: RegionRecord) + + @Insert + suspend fun insertBounds(bounds: List) + + @Insert + suspend fun insertOpen311Servers(servers: List) + + @Query("DELETE FROM regions") + suspend fun clearRegions() + + /** + * The legacy open311_servers table has no foreign key (see [Open311ServerRecord]), so it must be + * cleared explicitly; region_bounds cascades off [clearRegions]. + */ + @Query("DELETE FROM open311_servers") + suspend fun clearOpen311Servers() + + /** + * Atomically replaces the whole region cache (the legacy `saveToProvider`): clear all three + * tables, then insert each region with its children. Callers filter to usable regions first. + */ + @Transaction + suspend fun replaceAll(regions: List) { + clearOpen311Servers() + clearRegions() // region_bounds cascades + for (entry in regions) { + insertRegion(entry.region) + insertBounds(entry.bounds) + insertOpen311Servers(entry.open311Servers) + } + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteDao.kt new file mode 100644 index 0000000000..635b834bd1 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteDao.kt @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +/** A route row projected for the My-tab lists. */ +data class RouteListRow( + val id: String, + @ColumnInfo(name = "short_name") val shortName: String, + @ColumnInfo(name = "long_name") val longName: String?, + val url: String?, +) + +private const val ROUTE_REGION_SCOPE = + "(:regionId IS NULL OR region_id = :regionId OR region_id IS NULL)" + +/** Room access for routes + the My-tab recent/starred lists (the legacy `routes` table). */ +@Dao +interface RouteDao { + + @Query("SELECT * FROM routes WHERE _id = :routeId LIMIT 1") + suspend fun getRoute(routeId: String): RouteRecord? + + @Query("SELECT short_name FROM routes WHERE _id = :routeId LIMIT 1") + suspend fun shortName(routeId: String): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(route: RouteRecord) + + @Query("UPDATE routes SET favorite = :favorite WHERE _id = :routeId") + suspend fun setFavorite(routeId: String, favorite: Int) + + // --- Usage/metadata writes (the legacy partial upsert; see RoutesStore) --- + + /** + * Records a route's short/long name and marks it used, leaving an existing URL untouched. Merges + * onto the existing row so unset columns aren't clobbered. [now] is supplied by the caller so this + * stays a stateless helper. + */ + @Transaction + suspend fun markRouteUsed( + routeId: String, + shortName: String?, + longName: String?, + regionId: Long?, + now: Long, + ) { + val existing = getRoute(routeId) + upsert( + existing?.copy( + shortName = shortName.orEmpty(), + longName = longName, + regionId = regionId ?: existing.regionId, + useCount = existing.useCount + 1, + accessTime = now, + ) ?: RouteRecord( + id = routeId, + shortName = shortName.orEmpty(), + longName = longName, + useCount = 1, + accessTime = now, + regionId = regionId, + ) + ) + } + + /** Stores a route's full short/long name + URL and marks it used. */ + @Transaction + suspend fun storeRouteDetails( + routeId: String, + shortName: String?, + longName: String?, + url: String?, + regionId: Long?, + now: Long, + ) { + val existing = getRoute(routeId) + upsert( + existing?.copy( + shortName = shortName.orEmpty(), + longName = longName, + url = url, + regionId = regionId ?: existing.regionId, + useCount = existing.useCount + 1, + accessTime = now, + ) ?: RouteRecord( + id = routeId, + shortName = shortName.orEmpty(), + longName = longName, + url = url, + useCount = 1, + accessTime = now, + regionId = regionId, + ) + ) + } + + /** Refreshes only a route's short name (does not mark it used; leaves other columns untouched). */ + @Transaction + suspend fun refreshRouteShortName(routeId: String, shortName: String?) { + val existing = getRoute(routeId) + upsert( + existing?.copy(shortName = shortName.orEmpty()) + ?: RouteRecord(id = routeId, shortName = shortName.orEmpty(), useCount = 0) + ) + } + + // --- Recents/starred lists (reactive) --- + + @Query( + "SELECT _id AS id, short_name, long_name, url FROM routes " + + "WHERE ((access_time IS NOT NULL AND access_time > :cutoff) OR use_count > 0) " + + "AND $ROUTE_REGION_SCOPE ORDER BY access_time DESC, use_count DESC LIMIT 20" + ) + fun recents(cutoff: Long, regionId: Long?): Flow> + + @Query( + "SELECT _id AS id, short_name, long_name, url FROM routes " + + "WHERE favorite = 1 AND $ROUTE_REGION_SCOPE ORDER BY length(short_name), short_name ASC" + ) + fun starredByName(regionId: Long?): Flow> + + @Query( + "SELECT _id AS id, short_name, long_name, url FROM routes " + + "WHERE favorite = 1 AND $ROUTE_REGION_SCOPE ORDER BY use_count DESC" + ) + fun starredByFrequency(regionId: Long?): Flow> + + // --- Recents/starred mutations --- + + @Query("UPDATE routes SET use_count = 0, access_time = NULL WHERE _id = :routeId") + suspend fun markUnused(routeId: String) + + @Query("UPDATE routes SET use_count = 0, access_time = NULL") + suspend fun markAllUnused() + + @Query("UPDATE routes SET favorite = 0") + suspend fun clearAllFavorites() +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteDao.kt new file mode 100644 index 0000000000..28baf09ab4 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteDao.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query + +/** + * Room access for route/headsign favorites (the legacy `route_headsign_favorites` table). A null + * headsign argument matches any headsign (the legacy WHERE omits the headsign clause in that case). + */ +@Dao +interface RouteHeadsignFavoriteDao { + + @Query( + "DELETE FROM route_headsign_favorites WHERE route_id = :routeId AND stop_id = :stopId " + + "AND (:headsign IS NULL OR headsign = :headsign)" + ) + suspend fun deleteMatch(routeId: String, headsign: String?, stopId: String) + + @Query( + "DELETE FROM route_headsign_favorites WHERE route_id = :routeId " + + "AND (:headsign IS NULL OR headsign = :headsign)" + ) + suspend fun deleteForRoute(routeId: String, headsign: String?) + + @Insert + suspend fun insert(row: RouteHeadsignFavoriteRecord) + + @Query("SELECT EXISTS(SELECT 1 FROM route_headsign_favorites WHERE route_id = :routeId AND exclude = 0)") + suspend fun routeHasFavorite(routeId: String): Boolean + + /** + * All favorite/exclude rows that could apply to a stop: rows for that stop plus the wildcard + * `all`-stops rows. One query for a whole arrivals list, so [computeRouteHeadsignFavorite] can + * resolve each row's favorite state in memory instead of a per-row DB lookup. + */ + @Query("SELECT * FROM route_headsign_favorites WHERE stop_id = :stopId OR stop_id = 'all'") + suspend fun favoritesForStopOrAll(stopId: String): List + + @Query("DELETE FROM route_headsign_favorites") + suspend fun clearAll() +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteLogic.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteLogic.kt new file mode 100644 index 0000000000..a7a6b4e121 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteLogic.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +/** The sentinel stop id a route/headsign favorite uses to mean "favorited for every stop". */ +const val ALL_STOPS_FAVORITE = "all" + +/** + * Whether a route/headsign is a favorite at [stopId], resolved in memory from the favorite/exclude + * rows for that stop (as returned by [RouteHeadsignFavoriteDao.favoritesForStopOrAll]). This is the + * pure port of the legacy per-row `ObaContract.RouteHeadsignFavorites.isFavorite(routeId, headsign, + * stopId)` so a whole arrivals list resolves from one query instead of a DB round-trip per row. + * + * The legacy precedence: a favorite recorded for this exact stop wins; otherwise an "all stops" + * favorite applies unless this specific stop was explicitly excluded (unstarred after starring the + * whole route). A null [headsign] is treated as the empty string, matching the legacy query. + */ +fun computeRouteHeadsignFavorite( + rows: List, + routeId: String, + headsign: String?, + stopId: String, +): Boolean { + val hs = headsign ?: "" + + val favoritedForThisStop = rows.any { + it.routeId == routeId && it.headsign == hs && it.stopId == stopId && it.exclude == 0 + } + if (favoritedForThisStop) return true + + val favoritedForAllStops = rows.any { + it.routeId == routeId && it.headsign == hs && it.stopId == ALL_STOPS_FAVORITE + } + if (!favoritedForAllStops) return false + + val excludedAtThisStop = rows.any { + it.routeId == routeId && it.headsign == hs && it.stopId == stopId && it.exclude == 1 + } + return !excludedAtThisStop +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteWriter.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteWriter.kt new file mode 100644 index 0000000000..01c7bbd96a --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteWriter.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +/** + * Applies (or clears) a route/headsign/stop favorite and reconciles the route's overall favorite flag, + * faithfully reproducing the legacy `ObaContract.RouteHeadsignFavorites.markAsFavorite` semantics: + * favoriting inserts a `(route, headsign, stop|all, exclude=0)` row and stars the route; unfavoriting + * deletes the matching rows, clears the route star when no non-excluded favorites remain, and — when a + * single stop is unstarred while the whole route is starred — records an `exclude=1` row. A null + * [stopId] means "all stops". + * + * The reconciliation is inherently DB-semantic (delete, then re-query `routeHasFavorite` / + * `favoritesForStopOrAll`, then conditionally insert), so it's hoisted here as a self-contained, + * DAO-only unit — no Context/analytics/import gating — that an in-memory Room test can drive directly + * (see `RouteHeadsignFavoriteWriterTest`). [DefaultArrivalsRepository] calls this, then reports analytics. + */ +suspend fun applyRouteHeadsignFavorite( + headsignDao: RouteHeadsignFavoriteDao, + routeDao: RouteDao, + routeId: String, + headsign: String?, + stopId: String?, + favorite: Boolean, +) { + val stopIdInternal = stopId ?: ALL_STOPS_FAVORITE + if (favorite) { + if (stopIdInternal != ALL_STOPS_FAVORITE) { + headsignDao.deleteMatch(routeId, headsign, stopIdInternal) + } + headsignDao.insert( + RouteHeadsignFavoriteRecord( + routeId = routeId, headsign = headsign.orEmpty(), stopId = stopIdInternal, exclude = 0 + ) + ) + routeDao.setFavorite(routeId, 1) + } else { + headsignDao.deleteMatch(routeId, headsign, stopIdInternal) + if (stopIdInternal == ALL_STOPS_FAVORITE) { + headsignDao.deleteForRoute(routeId, headsign) + } + if (!headsignDao.routeHasFavorite(routeId)) { + routeDao.setFavorite(routeId, 0) + } + // A single stop unstarred while the whole route is starred -> record an exclusion. Resolve the + // (post-delete) favorite state through the same pure helper the read path uses. + val stillFavorite = stopId != null && computeRouteHeadsignFavorite( + headsignDao.favoritesForStopOrAll(stopId), routeId, headsign, stopId + ) + if (stillFavorite) { + headsignDao.insert( + RouteHeadsignFavoriteRecord( + routeId = routeId, headsign = headsign.orEmpty(), stopId = stopIdInternal, exclude = 1 + ) + ) + } + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteRecorder.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteRecorder.kt new file mode 100644 index 0000000000..b899dba47f --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteRecorder.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.onebusaway.android.app.di.AppScope +import org.onebusaway.android.region.RegionRepository +import org.onebusaway.android.util.MyTextUtils + +/** + * Records a route in the recents/search table so it shows up later (the legacy `DBUtil.addRouteToDB`). + * Fire-and-forget on the application scope so the write survives the navigation that immediately + * follows the call; a no-op when no region is set (legacy parity). Runs after the one-time import gate. + */ +@Singleton +class RouteRecorder @Inject constructor( + @AppScope private val appScope: CoroutineScope, + private val routeDao: RouteDao, + private val regionRepository: RegionRepository, + private val importGate: ImportGate, +) { + + /** Records a route from an arrival row (no URL); falls back to the headsign for an empty long name. */ + fun recordFromArrival(routeId: String, shortName: String?, routeLongName: String?, headsign: String?) { + val longName = if (routeLongName.isNullOrEmpty()) { + MyTextUtils.formatDisplayText(headsign) + } else { + routeLongName + } + appScope.launch { + val regionId = regionRepository.region.value?.id ?: return@launch + importGate.awaitReady() + routeDao.markRouteUsed(routeId, shortName, longName, regionId, System.currentTimeMillis()) + } + } + + /** Records a route's full details incl. URL (the search-result route rows). */ + fun recordDetails(routeId: String, shortName: String?, longName: String?, url: String?) { + appScope.launch { + val regionId = regionRepository.region.value?.id ?: return@launch + importGate.awaitReady() + routeDao.storeRouteDetails(routeId, shortName, longName, url, regionId, System.currentTimeMillis()) + } + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/ServiceAlertDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/ServiceAlertDao.kt new file mode 100644 index 0000000000..1efd25f626 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/ServiceAlertDao.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +/** Room access for the service-alert read/hidden state (the legacy `service_alerts` table). */ +@Dao +interface ServiceAlertDao { + + /** Inserts the row only if absent (leaves an existing row's read/hidden state untouched). */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIfAbsent(row: ServiceAlertRecord) + + /** + * The rows with an explicit hide decision (HIDDEN non-null), re-emitting on every table change. + * The reactive single-source-of-truth for hidden state the arrivals screen derives from. See #1593. + */ + @Query("SELECT * FROM service_alerts WHERE hidden IS NOT NULL") + fun hideDecisions(): Flow> + + @Query("UPDATE service_alerts SET marked_read_time = :time WHERE _id = :id") + suspend fun updateMarkedReadTime(id: String, time: Long) + + @Query("UPDATE service_alerts SET hidden = :hidden WHERE _id = :id") + suspend fun updateHidden(id: String, hidden: Int) + + @Query("SELECT EXISTS(SELECT 1 FROM service_alerts WHERE _id = :id AND hidden = 1)") + suspend fun isHidden(id: String): Boolean + + @Query("UPDATE service_alerts SET hidden = :hidden") + suspend fun setAllHidden(hidden: Int) + + /** + * Records/updates [id] and stamps it read, without touching its hide decision (the legacy + * mark-as-read). [now] is supplied by the caller so this stays a stateless helper. + */ + @Transaction + suspend fun markRead(id: String, now: Long) { + insertIfAbsent(ServiceAlertRecord(id = id, hidden = null)) + updateMarkedReadTime(id, now) + } + + /** Records an explicit hide decision for [id] (true = hidden, false = shown). */ + @Transaction + suspend fun setHidden(id: String, hidden: Boolean) { + insertIfAbsent(ServiceAlertRecord(id = id, hidden = null)) + updateHidden(id, if (hidden) 1 else 0) + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/StopDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/StopDao.kt new file mode 100644 index 0000000000..cccf8367c2 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/StopDao.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.onebusaway.android.models.ObaStop +import org.onebusaway.android.util.MyTextUtils + +/** A stop row projected for the My-tab lists; [uiName] is the legacy UI_NAME (user name or name). */ +data class StopListRow( + val id: String, + @ColumnInfo(name = "ui_name") val uiName: String?, + val direction: String?, + val latitude: Double, + val longitude: Double, + val favorite: Int?, +) + +/** The legacy projected UI_NAME expression, reused across the stop list queries. */ +private const val UI_NAME = "(CASE WHEN user_name IS NOT NULL THEN user_name ELSE name END)" + +/** The legacy region scope: rows for the active region, or with no region, or (when none active) all. */ +private const val REGION_SCOPE = "(:regionId IS NULL OR region_id = :regionId OR region_id IS NULL)" + +/** Room access for stop user-state + the My-tab recent/starred lists (the legacy `stops` table). */ +@Dao +interface StopDao { + + /** Sets the favorite flag. A no-op when the row doesn't exist (matches the legacy UPDATE). */ + @Query("UPDATE stops SET favorite = :favorite WHERE _id = :stopId") + suspend fun setFavorite(stopId: String, favorite: Int) + + @Query("SELECT favorite, user_name FROM stops WHERE _id = :stopId LIMIT 1") + suspend fun userInfo(stopId: String): StopUserInfoRow? + + /** Every stop the user has favorited or renamed (the legacy StopUserInfoMap query). */ + @Query( + "SELECT _id AS stopId, favorite, user_name AS userName FROM stops " + + "WHERE user_name IS NOT NULL OR favorite = 1" + ) + suspend fun userInfoMap(): List + + @Query("SELECT latitude, longitude FROM stops WHERE _id = :stopId LIMIT 1") + suspend fun location(stopId: String): StopLocationRow? + + @Query("SELECT name FROM stops WHERE _id = :stopId LIMIT 1") + suspend fun nameForStop(stopId: String): String? + + @Query("SELECT * FROM stops WHERE _id = :stopId LIMIT 1") + suspend fun getStop(stopId: String): StopRecord? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(stop: StopRecord) + + /** + * Records a stop's identity/coordinates and marks it used (increment use_count, stamp + * access_time), the legacy [org.onebusaway.android.util.DBUtil] `addToDB` write. Merges onto the + * existing row so the user's favorite flag and custom name are never clobbered. [now] is supplied + * by the caller so this stays a stateless helper. + */ + @Transaction + suspend fun markStopUsed( + id: String, + code: String, + name: String, + direction: String, + latitude: Double, + longitude: Double, + regionId: Long?, + now: Long, + ) { + val existing = getStop(id) + upsert( + existing?.copy( + code = code, + name = name, + direction = direction, + latitude = latitude, + longitude = longitude, + regionId = regionId ?: existing.regionId, + useCount = existing.useCount + 1, + accessTime = now, + ) ?: StopRecord( + id = id, + code = code, + name = name, + direction = direction, + useCount = 1, + latitude = latitude, + longitude = longitude, + accessTime = now, + regionId = regionId, + ) + ) + } + + // --- Recents/starred lists (reactive) --- + + @Query( + "SELECT _id AS id, $UI_NAME AS ui_name, direction, latitude, longitude, favorite FROM stops " + + "WHERE ((access_time IS NOT NULL AND access_time > :cutoff) OR use_count > 0) " + + "AND $REGION_SCOPE ORDER BY access_time DESC, use_count DESC LIMIT 20" + ) + fun recents(cutoff: Long, regionId: Long?): Flow> + + @Query( + "SELECT _id AS id, $UI_NAME AS ui_name, direction, latitude, longitude, favorite FROM stops " + + "WHERE favorite = 1 AND $REGION_SCOPE ORDER BY $UI_NAME ASC" + ) + fun starredByName(regionId: Long?): Flow> + + @Query( + "SELECT _id AS id, $UI_NAME AS ui_name, direction, latitude, longitude, favorite FROM stops " + + "WHERE favorite = 1 AND $REGION_SCOPE ORDER BY use_count DESC" + ) + fun starredByFrequency(regionId: Long?): Flow> + + // --- Recents/starred mutations --- + + @Query("UPDATE stops SET use_count = 0, access_time = NULL WHERE _id = :stopId") + suspend fun markUnused(stopId: String) + + @Query("UPDATE stops SET use_count = 0, access_time = NULL") + suspend fun markAllUnused() + + @Query("UPDATE stops SET favorite = 0") + suspend fun clearAllFavorites() +} + +/** + * Records a stop from its [ObaStop] model (the legacy DBUtil.addToDB shape): applies the display-name + * formatting and marks it used. Shared by the arrivals load and the trip-details reminder persist so + * the ObaStop -> row mapping lives in one place. + */ +suspend fun StopDao.markStopUsed(stop: ObaStop, regionId: Long?, now: Long) = + markStopUsed( + id = stop.id, + code = stop.stopCode.orEmpty(), + name = MyTextUtils.formatDisplayText(stop.name).orEmpty(), + direction = stop.direction.orEmpty(), + latitude = stop.latitude, + longitude = stop.longitude, + regionId = regionId, + now = now, + ) + +/** The favorite flag + custom name for a single stop (the legacy StopUserInfo read). */ +data class StopUserInfoRow( + val favorite: Int?, + @ColumnInfo(name = "user_name") val userName: String?, +) + +/** A stop id with its favorite flag + custom name (the legacy StopUserInfoMap rows). */ +data class StopUserInfoMapRow( + val stopId: String, + val favorite: Int?, + val userName: String?, +) + +/** A stop's coordinates (the legacy Stops.getLocation read). */ +data class StopLocationRow( + val latitude: Double, + val longitude: Double, +) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/StopRouteFilterDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/StopRouteFilterDao.kt new file mode 100644 index 0000000000..920c2f6ada --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/StopRouteFilterDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction + +/** Room access for per-stop route filters (the legacy `stop_routes_filter` table). */ +@Dao +interface StopRouteFilterDao { + + @Query("SELECT route_id FROM stop_routes_filter WHERE stop_id = :stopId") + suspend fun routeIdsForStop(stopId: String): List + + @Query("DELETE FROM stop_routes_filter WHERE stop_id = :stopId") + suspend fun deleteForStop(stopId: String) + + @Insert + suspend fun insert(rows: List) + + /** Replaces the filter for [stopId] (delete then insert), matching the legacy set(). */ + @Transaction + suspend fun replaceForStop(stopId: String, routeIds: List) { + deleteForStop(stopId) + insert(routeIds.map { StopRouteFilterRecord(stopId = stopId, routeId = it) }) + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/TripDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/TripDao.kt new file mode 100644 index 0000000000..2a4aa3b00b --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/TripDao.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * A reminder row projected for the My Reminders list, with the route's short name resolved by a join + * (the legacy list looked it up per-row via ReminderUtils.getRouteShortName). [departure] is the + * legacy minutes-to-midnight value. + */ +data class ReminderRow( + @ColumnInfo(name = "trip_id") val tripId: String, + @ColumnInfo(name = "stop_id") val stopId: String, + @ColumnInfo(name = "route_id") val routeId: String?, + val name: String?, + val headsign: String?, + val departure: Int, + @ColumnInfo(name = "route_short_name") val routeShortName: String?, +) + +/** Room access for trip reminders (the legacy `trips` table). */ +@Dao +interface TripDao { + + @Query("SELECT * FROM trips WHERE _id = :tripId AND stop_id = :stopId LIMIT 1") + suspend fun getTrip(tripId: String, stopId: String): TripRecord? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(trip: TripRecord) + + /** Deletes a single reminder trip (the legacy delete on `content://.../trips/{tripId#stopId}`). */ + @Query("DELETE FROM trips WHERE _id = :tripId AND stop_id = :stopId") + suspend fun delete(tripId: String, stopId: String) + + @Query( + "SELECT t._id AS trip_id, t.stop_id, t.route_id, t.name, t.headsign, t.departure, " + + "r.short_name AS route_short_name FROM trips t " + + "LEFT JOIN routes r ON r._id = t.route_id ORDER BY t.name ASC" + ) + fun remindersByName(): Flow> + + @Query( + "SELECT t._id AS trip_id, t.stop_id, t.route_id, t.name, t.headsign, t.departure, " + + "r.short_name AS route_short_name FROM trips t " + + "LEFT JOIN routes r ON r._id = t.route_id ORDER BY t.departure ASC" + ) + fun remindersByDeparture(): Flow> +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/TripDepartureTime.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/TripDepartureTime.kt new file mode 100644 index 0000000000..9939d3a320 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/oba/TripDepartureTime.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import androidx.annotation.VisibleForTesting +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Encodes/decodes the legacy `trips.departure` column, which stores a reminder's departure as + * "minutes after midnight" (in the device's local time zone) rather than an absolute time — carried + * over from the ObaProvider schema. + * + * Implemented on `java.time` (was the deprecated `android.text.format.Time`) so the math is pure JVM + * and unit-testable off-device; the public methods use the system default zone, matching the original + * "current day, local time" behavior. `java.time` resolves the two annual DST-transition days via the + * zone rules (`atStartOfDay`), which is at worst more correct than the legacy `Time.toMillis` there. + */ +object TripDepartureTime { + + /** Decodes a stored "minutes after midnight" value into an epoch-millis time in the current day. */ + fun toEpochMillis(minutesAfterMidnight: Int): Long = + toEpochMillis(minutesAfterMidnight, ZoneId.systemDefault()) + + /** Encodes an epoch-millis time into the stored "minutes after midnight" value. */ + fun fromEpochMillis(epochMillis: Long): Int = + fromEpochMillis(epochMillis, ZoneId.systemDefault()) + + /** [toEpochMillis] with an explicit [zone] (test seam; avoids DST flakiness from the device zone). */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun toEpochMillis(minutesAfterMidnight: Int, zone: ZoneId): Long = + LocalDate.now(zone) + .atStartOfDay(zone) + .plusMinutes(minutesAfterMidnight.toLong()) + .toInstant() + .toEpochMilli() + + /** [fromEpochMillis] with an explicit [zone] (test seam). */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun fromEpochMillis(epochMillis: Long, zone: ZoneId): Int { + val time = Instant.ofEpochMilli(epochMillis).atZone(zone).toLocalTime() + return time.hour * 60 + time.minute + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/RecentStopsManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/RecentStopsManager.kt deleted file mode 100644 index cd6ff4df94..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/RecentStopsManager.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.onebusaway.android.database.recentStops - -import android.content.Context -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import org.onebusaway.android.app.Application -import org.onebusaway.android.database.DatabaseProvider -import org.onebusaway.android.database.recentStops.entity.RegionEntity -import org.onebusaway.android.database.recentStops.entity.StopEntity -import org.onebusaway.android.models.ObaStop - -/** - * Manages recent stops data by interacting with the database. - * Handles saving new stops, and retrieving recent stops. - */ -object RecentStopsManager { - - // Maximum stops count to save - private var MAX_STOP_COUNT = 5 - - /** - * Inserts a region into the database if it does not already exist. - * - * @param regionId The ID of the region to insert. - */ - private suspend fun insertRegion(context: Context, regionId: Int) { - val db = DatabaseProvider.getDatabase(context) - val regionDao = db.regionDao() - - withContext(Dispatchers.IO) { - val existingRegion = regionDao.getRegionByID(regionId) - if (existingRegion == null) { - regionDao.insertRegion(RegionEntity(regionId)) - } - } - } - - /** - * Saves a new stop to the database. If the maximum number of stops is exceeded, deletes the oldest stop. - * - * @param stop The `ObaStop` object to save. - */ - @JvmStatic - fun saveStop(context: Context, stop: ObaStop) { - val regionId = Application.get().currentRegion?.id?.toInt() ?: return - - val db = DatabaseProvider.getDatabase(context) - val stopDao = db.stopDao() - - CoroutineScope(Dispatchers.IO).launch { - val currentTime = System.currentTimeMillis() - val stopCount = stopDao.getStopCount(regionId) - - if (stopCount >= MAX_STOP_COUNT) { - // Delete the oldest stop and insert the new one - stopDao.deleteOldestStop(regionId) - } - - insertRegion(context, regionId) - stopDao.insertStop(StopEntity(stop.id, stop.name.orEmpty(), regionId, currentTime)) - } - } - - /** - * Retrieves a list of recent stop IDs for the current region. - * - * @return A list of recent stop IDs or an empty list if none are found. - */ - fun getRecentStops(context: Context): List { - return runBlocking { - val db = DatabaseProvider.getDatabase(context) - val stopDao = db.stopDao() - - withContext(Dispatchers.IO) { - val regionId = Application.get().currentRegion.id.toInt() - val stops = stopDao.getRecentStopsForRegion(regionId) - stops.map { it.stop_id } - } - } - } -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/dao/RegionDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/dao/RegionDao.kt deleted file mode 100644 index c42f3cffe3..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/dao/RegionDao.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.onebusaway.android.database.recentStops.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import org.onebusaway.android.database.recentStops.entity.RegionEntity - -/** - * DAO interface for accessing and modifying the `regions` table in the database. - */ - -@Dao -interface RegionDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertRegion(region: RegionEntity): Long - - @Query("SELECT * FROM regions WHERE :regionId = regionId") - suspend fun getRegionByID(regionId:Int):Int? -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/dao/StopDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/dao/StopDao.kt deleted file mode 100644 index 52970b184b..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/dao/StopDao.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.onebusaway.android.database.recentStops.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import org.onebusaway.android.database.recentStops.entity.StopEntity -/** - * DAO interface for accessing and managing the `stops` table in the database. - */ -@Dao -interface StopDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertStop(stop: StopEntity): Long - - @Query("SELECT * FROM stops WHERE regionId = :regionId") - suspend fun getRecentStopsForRegion(regionId: Int): List - - @Query("SELECT COUNT(*) FROM stops WHERE regionId = :regionId") - suspend fun getStopCount(regionId: Int): Int - - @Query("DELETE FROM stops WHERE stop_id = (SELECT stop_id FROM stops WHERE regionId = :regionId ORDER BY timestamp ASC LIMIT 1)") - suspend fun deleteOldestStop(regionId: Int) -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/entity/RegionEntity.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/entity/RegionEntity.kt deleted file mode 100644 index c60cc9b311..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/entity/RegionEntity.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.onebusaway.android.database.recentStops.entity - -import androidx.room.Entity -import androidx.room.PrimaryKey - -/** - * Entity class representing a region in the `regions` table. - * Contains a primary key `regionId` for identifying regions. - */ - -@Entity(tableName = "regions") -data class RegionEntity( - @PrimaryKey val regionId:Int -) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/entity/StopEntity.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/entity/StopEntity.kt deleted file mode 100644 index e1d95e8b8b..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/recentStops/entity/StopEntity.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.onebusaway.android.database.recentStops.entity - -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey - -/** - * Entity class representing a stop in the `stops` table. - * Includes a primary key `stop_id`, foreign key `regionId`, stop name, and timestamp. - */ - -@Entity( - tableName = "stops", - foreignKeys = [ - ForeignKey( - entity = RegionEntity::class, - parentColumns = ["regionId"], - childColumns = ["regionId"], - onDelete = ForeignKey.CASCADE - ) - ] -) -data class StopEntity( - @PrimaryKey val stop_id: String, - val name: String, - val regionId: Int, - val timestamp: Long -) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/survey/SurveyDbHelper.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/survey/SurveyDbHelper.kt deleted file mode 100644 index e24d49eac7..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/survey/SurveyDbHelper.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.onebusaway.android.database.survey - -import android.content.Context -import android.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.onebusaway.android.database.survey.entity.Study -import org.onebusaway.android.database.survey.entity.Survey -import org.onebusaway.android.models.Survey as SurveyModel - -/** - * Utility class for handling operations related to surveys in the database. - */ -class SurveyDbHelper { - - companion object { - // Coroutine scope for performing background operations. - private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - // Constants representing survey states. - const val SURVEY_COMPLETED = 1 - const val SURVEY_SKIPPED = 2 - - /** - * Marks a survey as completed or skipped and updates the database. - * - * @param context - * @param curSurvey The current survey data to be updated. - * @param state The state of the survey (e.g., completed or skipped). - */ - @JvmStatic - fun markSurveyAsCompletedOrSkipped(context: Context, curSurvey: SurveyModel, state: Int) { - val surveyRepo = SurveyRepository(context) - - val study = curSurvey.study ?: return - val newStudy = Study( - study.id, study.name.orEmpty(), study.description.orEmpty(), true - ) - val newSurvey = Survey(curSurvey.id, study.id, curSurvey.name.orEmpty(), state) - - coroutineScope.launch { - try { - surveyRepo.addOrUpdateStudy(newStudy) - surveyRepo.addSurvey(newSurvey) - Log.d("All Saved Surveys", surveyRepo.getAllSurveys().toString()) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - /** - * Checks if a survey has been completed based on its ID. - * - * @param context - * @param surveyId The ID of the survey to check. - * @return True if the survey is completed, false otherwise. - */ - @JvmStatic - fun isSurveyCompleted(context: Context, surveyId: Int): Boolean { - val surveyRepo = SurveyRepository(context) - return runBlocking { - try { - surveyRepo.checkSurveyCompleted(surveyId) - } catch (e: Exception) { - e.printStackTrace() - false - } - } - } - } -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/survey/SurveyRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/survey/SurveyRepository.kt index 0df372b4ca..c42aacf4f6 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/survey/SurveyRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/survey/SurveyRepository.kt @@ -1,50 +1,59 @@ package org.onebusaway.android.database.survey -import android.content.Context -import androidx.room.Room -import org.onebusaway.android.database.AppDatabase +import javax.inject.Inject +import javax.inject.Singleton +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.survey.dao.StudiesDao +import org.onebusaway.android.database.survey.dao.SurveysDao import org.onebusaway.android.database.survey.entity.Study import org.onebusaway.android.database.survey.entity.Survey +import org.onebusaway.android.models.Survey as SurveyModel /** - * Repository class for managing Study and Survey data operations. - * Provides methods for interacting with Study and Survey entities. + * Repository for the survey Study/Survey tables in the unified [org.onebusaway.android.database.AppDatabase] + * (storage-modernization). Hilt-injected; the one-time import of any pre-existing `study-survey-db` + * data is handled by [org.onebusaway.android.database.oba.LegacyDataImporter.importSurveyDbIfNeeded]. + * Reads/writes await [ImportGate] first so a query can't race that import — returning an empty completed + * set (which would re-show an already-answered survey) or writing a row the import then collides with. */ +@Singleton +class SurveyRepository @Inject constructor( + private val studiesDao: StudiesDao, + private val surveysDao: SurveysDao, + private val importGate: ImportGate, +) { + + /** + * The set of survey ids that already have a persisted response (completed or skipped), so a survey + * isn't shown again. Fetched once per study response and consulted in-memory rather than querying + * per-survey (see [org.onebusaway.android.ui.survey.utils.SurveyUtils.getCurrentSurveyIndex]). + */ + suspend fun completedSurveyIds(): Set { + importGate.awaitReady() + return surveysDao.getAllSurveys().map { it.survey_id }.toSet() + } -class SurveyRepository(context: Context) { - private val db: AppDatabase = Room.databaseBuilder( - context.applicationContext, AppDatabase::class.java, "study-survey-db" - ).build() - - private val studiesDao = db.studiesDao() - private val surveysDao = db.surveysDao() + /** + * Persists a survey as completed or skipped (formerly `SurveyDbHelper.markSurveyAsCompletedOrSkipped`): + * upserts the parent study, then records the survey row with the given [state]. + */ + suspend fun markCompletedOrSkipped(curSurvey: SurveyModel, state: Int) { + importGate.awaitReady() + val study = curSurvey.study ?: return + upsertStudy(Study(study.id, study.name.orEmpty(), study.description.orEmpty(), true)) + surveysDao.insertSurvey(Survey(curSurvey.id, study.id, curSurvey.name.orEmpty(), state)) + } - suspend fun addOrUpdateStudy(study: Study) { - val existingStudy = studiesDao.getStudyById(study.study_id) - if (existingStudy == null) { + private suspend fun upsertStudy(study: Study) { + if (studiesDao.getStudyById(study.study_id) == null) { studiesDao.insertStudy(study) } else { studiesDao.updateStudy(study) } } - suspend fun getAllStudies(): List { - return studiesDao.getAllStudies() - } - - suspend fun addSurvey(survey: Survey) { - surveysDao.insertSurvey(survey) - } - - suspend fun getSurveysForStudy(studyId: Int): List { - return surveysDao.getSurveysByStudyId(studyId) - } - - suspend fun checkSurveyCompleted(surveyId: Int): Boolean { - return surveysDao.isSurveyIdExists(surveyId) - } - - suspend fun getAllSurveys(): List { - return surveysDao.getAllSurveys(); + companion object { + const val SURVEY_COMPLETED = 1 + const val SURVEY_SKIPPED = 2 } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/widealerts/AlertsRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/widealerts/AlertsRepository.kt index 53025e2709..dba489c5b0 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/widealerts/AlertsRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/widealerts/AlertsRepository.kt @@ -1,49 +1,24 @@ package org.onebusaway.android.database.widealerts -import android.content.Context -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import org.onebusaway.android.database.DatabaseProvider +import javax.inject.Inject +import javax.inject.Singleton +import org.onebusaway.android.database.widealerts.dao.AlertDao import org.onebusaway.android.database.widealerts.entity.AlertEntity -/** Provides methods to interact with the alerts database. */ -object AlertsRepository { +/** + * Reads/writes the region wide-alert "already shown" markers. Hilt-injected (storage-modernization + * replaced the former `object` that reached [org.onebusaway.android.database.DatabaseProvider] directly). + * The GTFS alert fetcher reaches it via [org.onebusaway.android.app.di.DatabaseEntryPoint] from its + * background fetch thread, so the [AlertDao] calls here are synchronous. + */ +@Singleton +class AlertsRepository @Inject constructor( + private val alertDao: AlertDao, +) { - /** - * Checks if an alert exists in the database. - * - * @param context The context to access the database. - * @param alertId The ID of the alert to check. - * @return True if the alert exists, false otherwise. - */ - @JvmStatic - fun isAlertExists(context: Context, alertId: String): Boolean { - val db = DatabaseProvider.getDatabase(context) - val alertDao = db.alertsDao() + /** Whether an alert with [alertId] has already been recorded (shown). */ + fun isAlertExists(alertId: String): Boolean = alertDao.getAlertById(alertId) != null - return runBlocking { - withContext(Dispatchers.IO) { - alertDao.getAlertById(alertId) != null - } - } - } - - /** - * Inserts a new alert into the database. - * - * @param context The context to access the database. - * @param alert The `AlertEntity` object to insert. - */ - @JvmStatic - fun insertAlert(context: Context, alert: AlertEntity) { - val db = DatabaseProvider.getDatabase(context) - val alertDao = db.alertsDao() - - CoroutineScope(Dispatchers.IO).launch { - alertDao.insertAlert(alert) - } - } -} \ No newline at end of file + /** Records an alert as shown so it isn't surfaced again. */ + fun insertAlert(alert: AlertEntity) = alertDao.insertAlert(alert) +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/database/widealerts/dao/AlertDao.kt b/onebusaway-android/src/main/java/org/onebusaway/android/database/widealerts/dao/AlertDao.kt index d6dd95c311..9bfd045b46 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/database/widealerts/dao/AlertDao.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/database/widealerts/dao/AlertDao.kt @@ -6,16 +6,18 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import org.onebusaway.android.database.widealerts.entity.AlertEntity -/** Data Access Object (DAO) for the `AlertEntity` class. */ - +/** + * DAO for the region wide-alert "already shown" markers (`alerts` table). + * + * These methods are intentionally blocking (not `suspend`): the only caller is the `GtfsAlerts` fetcher, + * which runs them on its own background fetch thread. They must never be invoked on the main thread. + */ @Dao interface AlertDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAlert(alert: AlertEntity) + fun insertAlert(alert: AlertEntity) @Query("SELECT * FROM alerts WHERE id = :alertId") - suspend fun getAlertById(alertId: String): AlertEntity? - - @Query("SELECT * FROM alerts") - suspend fun getAllAlerts(): List -} \ No newline at end of file + fun getAlertById(alertId: String): AlertEntity? +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/extrapolation/TripExtrapolationBuilder.kt b/onebusaway-android/src/main/java/org/onebusaway/android/extrapolation/TripExtrapolationBuilder.kt index 1d847881de..8c26217d8c 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/extrapolation/TripExtrapolationBuilder.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/extrapolation/TripExtrapolationBuilder.kt @@ -85,6 +85,10 @@ private fun extrapolatedVehicleProjection(state: TripState, nowMs: Long): Vehicl * pure function the route controller's vehicle sampler calls each display frame (and that unit tests * can call directly without Android). * + * When [directionId] is non-null, only vehicles whose currently-served trip runs that GTFS direction + * are kept — the "show vehicles on map" filter that drops the opposite-direction buses. Null keeps + * both directions (the whole-route view). + * * Each vehicle is dead-reckoned forward from its last fix via [extrapolatedVehicleProjection] when the * trip's shape is known, else placed at the reported last-known/position point. [ExtrapolatedVehicle.fixTimeMs] * is the store's anchor instant (constant between fixes) when extrapolating, else the reported update @@ -94,12 +98,14 @@ fun extrapolatedVehicles( routeTrips: RouteTrips, routeIds: Set, nowMs: Long, + directionId: Int? = null, lookupState: (String?) -> TripState?, ): List = routeTrips.trips.mapNotNull { trip -> val status = trip.status ?: return@mapNotNull null - val activeRoute = routeTrips.trip(status.activeTripId)?.routeId ?: return@mapNotNull null - if (activeRoute !in routeIds || Status.CANCELED == status.status) return@mapNotNull null + val activeTrip = routeTrips.trip(status.activeTripId) ?: return@mapNotNull null + if (activeTrip.routeId !in routeIds || Status.CANCELED == status.status) return@mapNotNull null + if (directionId != null && activeTrip.directionId != directionId) return@mapNotNull null val state = lookupState(status.activeTripId) val projection = state?.let { extrapolatedVehicleProjection(it, nowMs) } val point = projection?.point diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/MapDecisions.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/MapDecisions.kt index d6ccdaa8d2..59360be677 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/MapDecisions.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/MapDecisions.kt @@ -110,17 +110,21 @@ internal fun retainOnlyFocusedStop(accum: LinkedHashMap, foc } /** - * The stop-accumulation cap: once [accum] reaches [cap] stops, clear it (keeping only the focused - * stop) so the next batch starts fresh — bounding the marker count as the user pans. No-op below the - * cap. Pure, so the (easily-broken) keep-the-focused-stop-on-cap behavior is unit-testable. + * The stop-accumulation LRU trim: evicts least-recently-used stops until [accum] holds at most [cap], + * in place. [accum] is access-ordered, so its iterator walks eldest (least-recently-used) first; the + * entry for [focusedId] is always skipped so the focused stop is never evicted. No-op at/under the + * cap. Replaces the old clear-everything cap; pure, so the keep-the-focused-stop eviction is + * unit-testable. */ -internal fun capStopAccumulation( +internal fun trimStopCache( accum: LinkedHashMap, focusedId: String?, cap: Int, ) { - if (accum.size >= cap) { - retainOnlyFocusedStop(accum, focusedId) + if (accum.size <= cap) return + val it = accum.entries.iterator() + while (accum.size > cap && it.hasNext()) { + if (it.next().key != focusedId) it.remove() } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/MapHost.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/MapHost.kt index cff7312c76..40163b22b8 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/MapHost.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/MapHost.kt @@ -128,6 +128,20 @@ class MapHost( _progress.value = inProgress } + private val _moreStopsAvailable = MutableStateFlow(false) + + /** + * Whether the last viewport stop load was truncated (the API's `limitExceeded`): more stops match + * the viewport than were returned, so the UI can prompt the user to zoom in. Set by the stop + * loader on each load; cleared when leaving the nearby-stops view. + */ + val moreStopsAvailable: StateFlow = _moreStopsAvailable.asStateFlow() + + /** Set by the stop loader to the last response's `limitExceeded`; cleared on view changes. */ + fun setMoreStopsAvailable(available: Boolean) { + _moreStopsAvailable.value = available + } + private val _effects = MutableSharedFlow(extraBufferCapacity = 8) /** One-shot events that need an Activity (e.g. the out-of-range prompt). */ diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/MapParams.java b/onebusaway-android/src/main/java/org/onebusaway/android/map/MapParams.java index 5361716e3f..bbaae8a355 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/MapParams.java +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/MapParams.java @@ -26,6 +26,9 @@ public class MapParams { public static final String ROUTE_ID = ".RouteId"; + // The stop a "show vehicles on map" launch anchored to, so route mode restores its direction filter. + public static final String ROUTE_DIRECTION_STOP_ID = ".RouteDirectionStopId"; + public static final String CENTER_LAT = ".MapCenterLat"; public static final String CENTER_LON = ".MapCenterLon"; diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/MapViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/MapViewModel.kt index 89d4155620..b18ab92783 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/MapViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/MapViewModel.kt @@ -126,6 +126,12 @@ class MapViewModel @Inject constructor( locationRepository = locationRepository, scope = viewModelScope, routeActive = { routeController.isActive }, + cacheSize = { + prefsRepository.getInt( + R.string.preference_key_map_stop_cache_size, + StopsMapController.DEFAULT_STOP_CACHE_SIZE, + ) + }, ) // ----- Map-host surface (delegated) ----- @@ -140,6 +146,9 @@ class MapViewModel @Inject constructor( /** Whether a viewport/route load is in flight (the old `Callback.showProgress`). */ val progress: StateFlow get() = mapHost.progress + /** Whether the last nearby-stops load was truncated (drives the "zoom in to see more stops" banner). */ + val moreStopsAvailable: StateFlow get() = mapHost.moreStopsAvailable + /** One-shot events that need an Activity (e.g. the out-of-range prompt). */ val effects: SharedFlow get() = mapHost.effects @@ -227,19 +236,21 @@ class MapViewModel @Inject constructor( * restore). Callers ([toRoute] and the restore in `init`) only ever pass a route different from the * current one — re-selecting the active route is handled (re-framed) by [toRoute]. */ - private fun enterRoute(routeId: String, zoomToRoute: Boolean) { + private fun enterRoute(routeId: String, zoomToRoute: Boolean, directionStopId: String?) { leaveCurrentView() - persistRoute(routeId) - routeController.start(routeId, zoomToRoute) + persistRoute(routeId, directionStopId) + routeController.start(routeId, zoomToRoute, directionStopId) bikeController.start(directions = false, selectedBikeStationIds = null) } // Persist which route (if any) to restore across process death — null means nearby stops. This is // the whole "which view" state (the route controller is the single live source of truth), so there's - // no separate mode to save. The transient trip-focus view deliberately doesn't touch this, so a - // back-press restores the prior view. - private fun persistRoute(routeId: String?) { + // no separate mode to save. [directionStopId] rides along so a restored route keeps its direction + // filter. The transient trip-focus view deliberately doesn't touch this, so a back-press restores + // the prior view. + private fun persistRoute(routeId: String?, directionStopId: String? = null) { savedStateHandle[MapParams.ROUTE_ID] = routeId + savedStateHandle[MapParams.ROUTE_DIRECTION_STOP_ID] = directionStopId } /** @@ -253,6 +264,7 @@ class MapViewModel @Inject constructor( bikeController.stop() stopsController.clearStops(false) mapHost.setProgress(false) + mapHost.setMoreStopsAvailable(false) } // ----- Focus + taps (delegated to [stopsController], except the vehicle selection) ----- @@ -298,17 +310,21 @@ class MapViewModel @Inject constructor( // ----- "Show route on map" / leave route mode (replaces ShowRoute / ExitRouteMode commands) ----- /** - * Focus the map on [routeId] — the single entry every "show route on map" caller funnels through (the - * recent/starred lists, route search, the arrivals "show vehicles on map", RouteInfo). Enters route - * mode (loading its shape, stops, and live vehicles) and frames the route's bounding box. Re-frames - * even when the map is already parked on [routeId]: re-tapping it in the recent-routes list (the routes - * you most recently viewed) snaps the camera back to the route's extent instead of no-op'ing. + * Focus the map on [request]'s route — the single entry every "show route on map" caller funnels + * through (the recent/starred lists, route search, the arrivals "show vehicles on map", RouteInfo). + * Enters route mode (loading its shape, stops, and live vehicles) and frames the route's bounding + * box. Re-frames even when the map is already parked on that route + direction: re-tapping it in the + * recent-routes list snaps the camera back to the route's extent instead of no-op'ing. */ - fun toRoute(routeId: String) { - if (routeController.routeId == routeId) { + fun toRoute(request: ShowRouteRequest) { + // Same route AND same direction anchor: just reframe (the recent-routes re-tap). A different + // route, or the same route from a different-direction stop, re-enters with the new filter. + if (routeController.routeId == request.routeId && + routeController.directionStopId == request.directionStopId + ) { mapHost.frameRoute() } else { - enterRoute(routeId, zoomToRoute = true) + enterRoute(request.routeId, zoomToRoute = true, directionStopId = request.directionStopId) } } @@ -398,7 +414,11 @@ class MapViewModel @Inject constructor( // ZOOM_TO_ROUTE is a launching-intent framing flag (consumed once); [persistRoute] // deliberately doesn't write it, so a process-death restore keeps the saved camera rather // than re-framing the route. - enterRoute(restoreRouteId, zoomToRoute = savedStateHandle[MapParams.ZOOM_TO_ROUTE] ?: false) + enterRoute( + restoreRouteId, + zoomToRoute = savedStateHandle[MapParams.ZOOM_TO_ROUTE] ?: false, + directionStopId = savedStateHandle[MapParams.ROUTE_DIRECTION_STOP_ID], + ) } else { showNearbyStops() } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/RouteMapController.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/RouteMapController.kt index 00e865a7ba..c41987e2c5 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/RouteMapController.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/RouteMapController.kt @@ -77,6 +77,21 @@ class RouteMapController( var routeId: String? = null private set + // The stop the "show vehicles on map" launch anchored to (null for a whole-route launch). Set in + // start(); resolved against the loaded route's direction grouping in onRouteLoaded to the + // directionFilter below. Exposed so a re-tap that keeps the route but changes the direction anchor + // re-enters (rather than just reframing). + var directionStopId: String? = null + private set + + // What direction the vehicle layer should show — the single source of truth the per-frame sampler + // reads (so it's a field, not a captured value; the sampler is installed in start() before the route + // loads). [DirectionState.Pending] holds the layer back while a direction-anchored launch waits for + // the (slower) stops-for-route load — otherwise every vehicle would flash for those seconds and then + // the opposite direction would cull. [DirectionState.Resolved] carries the filter to apply (null = + // both directions). One value, not a filter plus a "resolved yet?" boolean, so the two can't disagree. + private var directionState: DirectionState = DirectionState.Resolved(null) + /** Whether a route is currently shown (drives the home map's route-header focus bias). */ val isActive: Boolean get() = routeId != null @@ -93,22 +108,34 @@ class RouteMapController( /** * Show route [routeId]: load the route + header and start the vehicle poll. [zoomToRoute] frames - * the shape once it loads (consumed once). + * the shape once it loads (consumed once). [directionStopId], when non-null, narrows the overlay to + * the single direction that serves that stop (the arrivals "show vehicles on map" launch); null + * shows the whole route. */ - fun start(routeId: String, zoomToRoute: Boolean) { + fun start(routeId: String, zoomToRoute: Boolean, directionStopId: String? = null) { this.routeId = routeId + this.directionStopId = directionStopId + // A whole-route launch has no direction to wait for, so its vehicles show as soon as they poll; + // a direction-anchored launch stays Pending until the route load resolves the filter (below). + this.directionState = + if (directionStopId == null) DirectionState.Resolved(null) else DirectionState.Pending _loadedRoute.value = LoadedRoute.Loading host.setProgress(true) // The live vehicle layer: the renderer pulls a frame from this sampler each display frame, // dead-reckoning every vehicle in the latest poll forward to the frame's clock (the icon // decision lives here, not in the producer). Yields nothing until the first poll lands. The - // route-id filter set is built once here, not per frame, since it's constant for this route. + // route-id filter set is built once here, not per frame, since it's constant for this route; + // directionState is read live since it's resolved only once the route loads. val routeIds = setOf(routeId) renderState.setVehiclesSampler { nowMs -> + // Pending holds the layer back until the direction is known, so a direction launch never + // flashes the opposite-direction vehicles before the (slower) route load lands to cull them. + val resolved = directionState as? DirectionState.Resolved ?: return@setVehiclesSampler null latestPoll?.let { poll -> MapVehicles( markers = extrapolatedVehicles( - poll.response, routeIds, nowMs, tripObservationRepository::lookupTripState + poll.response, routeIds, nowMs, resolved.directionId, + tripObservationRepository::lookupTripState, ).map { it.toMarker() }, response = poll.response, ) @@ -125,6 +152,8 @@ class RouteMapController( routeJob = null vehicleJob = null routeId = null + directionStopId = null + directionState = DirectionState.Resolved(null) latestPoll = null renderState.clearRoutePolylines() renderState.setVehiclesSampler(null) @@ -148,7 +177,9 @@ class RouteMapController( val id = routeId ?: return routeJob?.cancel() routeJob = scope.launch { - val result = routeRepository.getRoute(id) + // The repository narrows the stops (and the vehicle direction id) to directionStopId's + // direction when set; a whole-route launch passes null and gets the full route back. + val result = routeRepository.getRoute(id, directionStopId) // Clear the spinner once the load resolves — before dispatching, so it's cleared on the // error path too (mirrors StopsMapController). A cancelled load never reaches here; the // view transition that cancelled it clears progress via leaveCurrentView(). @@ -158,6 +189,10 @@ class RouteMapController( } private fun onRouteLoaded(result: Result, zoomToRoute: Boolean) { + // The route load has resolved (success or failure), so release the vehicle layer the sampler + // held back in direction mode. Default to Resolved(null) here so an error path below falls back + // to showing vehicles unfiltered rather than hidden forever; the success path narrows it. + directionState = DirectionState.Resolved(null) val routeMap = result.getOrElse { MapUtils.showMapError((it as? ObaApiException)?.code ?: ObaApi.OBA_IO_EXCEPTION) return @@ -173,6 +208,9 @@ class RouteMapController( renderState.setRoutePolylines( routeMap.polylines.map { points -> RoutePolyline(route.color, points) } ) + // The repository already narrowed the stops to the stop-relevant direction (whole route when the + // launch had no anchor); its resolved direction id is what the vehicle sampler filters by. + directionState = DirectionState.Resolved(routeMap.directionId) stopsController.showStops(routeMap.stops, routeMap.routes) if (zoomToRoute) { host.frameRoute() @@ -222,6 +260,18 @@ class RouteMapController( /** The latest trips-for-route [response] and the device clock ([loadNanos]) when it landed. */ private data class VehiclePoll(val response: RouteTrips, val loadNanos: Long) +/** + * What the vehicle layer should show while in route mode — the single value the per-frame sampler reads + * to decide whether (and how) to draw vehicles, so a filter and a "resolved yet?" flag can't disagree. + */ +private sealed interface DirectionState { + /** A direction-anchored launch is still waiting for the route load; the sampler draws nothing yet. */ + data object Pending : DirectionState + + /** The direction is known: keep only [directionId] (null = both directions / whole route). */ + data class Resolved(val directionId: Int?) : DirectionState +} + /** * The raw route-load state [RouteMapController] publishes (null when not in route mode); [MapViewModel] * formats it into the display [RouteHeader]. diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/RouteMapRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/RouteMapRepository.kt index 9447519507..00257159e4 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/RouteMapRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/RouteMapRepository.kt @@ -20,44 +20,76 @@ import org.onebusaway.android.api.data.MapDataSource import javax.inject.Inject import org.onebusaway.android.models.ObaRoute import org.onebusaway.android.models.ObaStop +import org.onebusaway.android.models.RouteMapStop import org.onebusaway.android.map.render.GeoPoint -/** A route's stops, the serving routes (for stop-marker icons), the route + agency name, and its shape. */ +/** + * A route's stops (narrowed to a single direction when the load was direction-focused), the serving + * routes (for stop-marker icons), the route + agency name, its shape, and the resolved [directionId] + * the vehicle layer keeps (null = both directions / whole route). + */ data class RouteMap( val route: ObaRoute?, val agencyName: String?, val stops: List, val routes: List, val polylines: List>, + val directionId: Int?, ) /** * Loads a route's stops + shapes (one-shot, stops-for-route with polylines) for the route/stop * overlays, via the io.client [MapDataSource]. Turns the source's [android.location.Location] shape - * points into the render [GeoPoint]s the overlay consumes. (Route-mode real-time vehicles are polled - * via the trip-observation repository's `routeVehiclesStream`.) + * points into the render [GeoPoint]s the overlay consumes, and — when the caller passes a + * `directionStopId` — narrows the stops to that stop's direction. (Route-mode real-time vehicles are + * polled via the trip-observation repository's `routeVehiclesStream`.) */ interface RouteMapRepository { - /** @return the route's stops/shape, or `success(null)` when there is no API endpoint. */ - suspend fun getRoute(routeId: String): Result + /** + * @param directionStopId when non-null, narrow the returned stops (and [RouteMap.directionId]) to + * the single direction serving that stop; null (or an ambiguous stop) returns the whole route. + * @return the route's stops/shape, or `success(null)` when there is no API endpoint. + */ + suspend fun getRoute(routeId: String, directionStopId: String? = null): Result } class DefaultRouteMapRepository @Inject constructor( private val mapDataSource: MapDataSource, ) : RouteMapRepository { - override suspend fun getRoute(routeId: String): Result = + override suspend fun getRoute(routeId: String, directionStopId: String?): Result = mapDataSource.routeMap(routeId).map { data -> data?.let { + val focus = it.stops.focusDirection(directionStopId) RouteMap( route = it.route, agencyName = it.agencyName, - stops = it.stops, + stops = focus.stops, routes = it.routes, polylines = it.polylines.map { line -> line.map { p -> GeoPoint(p.latitude, p.longitude) } }, + directionId = focus.directionId, ) } } } + +/** The direction-narrowed [stops] plus the resolved vehicle [directionId] (null = show all). */ +internal data class DirectionFocus(val stops: List, val directionId: Int?) + +/** + * Narrows a route's [RouteMapStop]s to the single direction serving [anchorStopId] — the stop a + * "show vehicles on map" launch anchored to. Returns the whole route (all stops, null [directionId]) + * when there's no anchor, the anchor isn't among the stops, or its direction is ambiguous (it belongs + * to zero or several direction groups — e.g. an ungrouped or shared/loop stop). When the anchor sits + * in exactly one direction, the stops are filtered to that direction (shared stops that also serve it + * included) and its id returned as the vehicle filter. Pure; unit-tested. + */ +internal fun List.focusDirection(anchorStopId: String?): DirectionFocus { + val allStops = map { it.stop } + if (anchorStopId == null) return DirectionFocus(allStops, null) + val target = firstOrNull { it.stop.id == anchorStopId }?.directionIds?.singleOrNull() + ?: return DirectionFocus(allStops, null) + return DirectionFocus(filter { target in it.directionIds }.map { it.stop }, target) +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/ShowRouteRequest.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/ShowRouteRequest.kt new file mode 100644 index 0000000000..28b1adf068 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/ShowRouteRequest.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.map + +/** + * The intent to show a route on the map — the single payload every "show route on map" launcher builds + * and both UI transports carry opaquely (the navigation reveal in `MapReveal` and the home + * `MapDirective.ShowRoute`), unwrapped once at [MapViewModel.toRoute]. Bundling the parameters here + * (rather than threading them one-by-one through the callback + directive + reveal hops) keeps adding a + * new "show route on map" parameter a change to this one type instead of every layer in between. + * + * @property routeId the route to show. + * @property directionStopId when non-null (the arrivals "show vehicles on map" launch), the stop whose + * direction the map narrows to; null shows the whole route. + */ +data class ShowRouteRequest( + val routeId: String, + val directionStopId: String? = null, +) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/StopsMapController.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/StopsMapController.kt index 70848ba0cb..76cf8dd911 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/StopsMapController.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/StopsMapController.kt @@ -21,10 +21,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.onebusaway.android.api.ObaApi import org.onebusaway.android.api.ObaApiException @@ -37,6 +39,7 @@ import org.onebusaway.android.map.render.CameraCommand import org.onebusaway.android.map.render.CameraSnapshot import org.onebusaway.android.map.render.StopMarker import org.onebusaway.android.map.render.primaryRouteType +import org.onebusaway.android.map.render.stopZoomBand import org.onebusaway.android.region.RegionRepository import org.onebusaway.android.util.RegionUtils @@ -60,13 +63,16 @@ class StopsMapController( private val locationRepository: LocationRepository, private val scope: CoroutineScope, private val routeActive: () -> Boolean = { false }, + // The LRU cache size, read on each load so a change in advanced settings applies on the next pan. + private val cacheSize: () -> Int = { DEFAULT_STOP_CACHE_SIZE }, ) { private val renderState get() = host.renderState - // Stop accumulation across pans (capped, keeping the focused stop) + the routes cache used to - // resolve a stop's icon route type and to report a stop's routes to focus listeners. - private val stopAccum = LinkedHashMap() + // Stop accumulation across pans, as an access-ordered LRU (a re-seen stop bumps to most-recently- + // used; once over the configured [cacheSize] the least-recently-seen non-focused stops evict) + the + // routes cache used to resolve a stop's icon route type and to report a stop's routes to listeners. + private val stopAccum = LinkedHashMap(16, 0.75f, true) private val cachedRoutes = HashMap() @@ -76,6 +82,21 @@ class StopsMapController( private var loadJob: Job? = null + init { + // Derive the stop zoom band from the camera and publish it into the render snapshot, so a pure + // zoom (which changes no other snapshot field) re-fires the renderer to swap full icons ⇄ dots + // like any other snapshot change. Always-on (not tied to the viewport loader's start/stop), so + // it keeps updating in route mode where the loader is stopped. host.camera only emits on camera + // idle, and distinctUntilChanged collapses it to just the band crossings. + scope.launch { + host.camera + .filterNotNull() + .map { stopZoomBand(it.zoom.toFloat()) } + .distinctUntilChanged() + .collect { renderState.setStopBand(it) } + } + } + /** (Re)start the viewport stop loader (the old StopMapController's camera watch). */ fun start() { loadJob?.cancel() @@ -105,7 +126,7 @@ class StopsMapController( flow { host.setProgress(true) val result = mapDataSource - .nearbyStops(snapshot.center.latitude, snapshot.center.longitude, snapshot.latSpan, snapshot.lonSpan) + .nearbyStops(snapshot.center.latitude, snapshot.center.longitude, snapshot.latSpan, snapshot.lonSpan, cacheSize()) // Only a usable load updates the fulfillment gate: a success — OK stops, or a null // no-op (e.g. no stops endpoint, which intentionally fulfills future same-center // viewports). A failure (error code / transport) showed no stops, so leave the gate @@ -126,12 +147,17 @@ class StopsMapController( } private fun onStopsLoaded(result: Result) { + // Default the "more stops" banner off for this load; only a successful in-region result with + // limitExceeded turns it back on below. Clearing once up front means every early-out (error, + // null, out-of-range) leaves it cleared without having to remember to — the class of bug that + // previously left the banner stuck on after a truncated load was followed by a null one. + host.setMoreStopsAvailable(false) val nearby = result.getOrElse { MapUtils.showMapError((it as? ObaApiException)?.code ?: ObaApi.OBA_IO_EXCEPTION) return } if (nearby == null) { - // No endpoint yet (or a null no-op); do nothing (#615). + // No endpoint yet (or a null no-op); no stops loaded, so do nothing (#615). return } if (nearby.outOfRange) { @@ -162,10 +188,13 @@ class StopsMapController( } } + // More stops match the viewport than the API returned: prompt the user to zoom in. + host.setMoreStopsAvailable(nearby.limitExceeded) showStops(nearby.stops, nearby.routes) } private fun notifyOutOfRange() { + host.setMoreStopsAvailable(false) host.emitEffect(MapEffect.OutOfRange) } @@ -212,11 +241,18 @@ class StopsMapController( fun showStops(stops: List, routes: List) { cacheRoutes(routes) - capStopAccumulation(stopAccum, renderState.snapshot.value.focusedStopId, FUZZY_MAX_STOP_COUNT) for (stop in stops) { - if (!stopAccum.containsKey(stop.id)) { - stopAccum[stop.id] = toStopMarker(stop) - } + // getOrPut's get() on a hit bumps the entry to most-recently-used (access order) while + // keeping the same StopMarker instance, so a stationary re-poll of the same set yields an + // equal list the StateFlow conflates and the renderer never runs (reordering only breaks + // that equality when the accumulation holds more than the fetch; either way the renderer's + // reconcileStopMarkers keeps unchanged stops from blinking). A miss inserts a fresh marker. + stopAccum.getOrPut(stop.id) { toStopMarker(stop) } + } + // Route mode feeds the whole route's stops in one batch, so don't evict them against each + // other; the viewport loader (the only capped accumulator) is stopped while a route shows. + if (!routeActive()) { + trimStopCache(stopAccum, renderState.snapshot.value.focusedStopId, cacheSize()) } renderState.setStops(ArrayList(stopAccum.values)) } @@ -269,6 +305,7 @@ class StopsMapController( companion object { private const val TAG = "StopsMapController" - private const val FUZZY_MAX_STOP_COUNT = 200 + /** Default map stop LRU cache size; overridable via the advanced settings cache-size option. */ + const val DEFAULT_STOP_CACHE_SIZE = 200 } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/StopsMapViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/StopsMapViewModel.kt index ae59dc19a8..e68680644f 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/StopsMapViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/StopsMapViewModel.kt @@ -23,6 +23,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.flow.StateFlow +import org.onebusaway.android.R import org.onebusaway.android.api.data.MapDataSource import org.onebusaway.android.models.ObaStop import org.onebusaway.android.location.LocationRepository @@ -71,6 +72,12 @@ class StopsMapViewModel @Inject constructor( regionRepo = regionRepo, locationRepository = locationRepository, scope = viewModelScope, + cacheSize = { + prefsRepository.getInt( + R.string.preference_key_map_stop_cache_size, + StopsMapController.DEFAULT_STOP_CACHE_SIZE, + ) + }, ) init { diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/compose/VehicleInfoWindow.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/compose/VehicleInfoWindow.kt index a222f15fce..70117565b1 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/compose/VehicleInfoWindow.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/compose/VehicleInfoWindow.kt @@ -17,6 +17,7 @@ package org.onebusaway.android.map.compose import android.content.res.Configuration import android.content.res.Resources +import android.os.SystemClock import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -35,6 +36,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -67,7 +69,10 @@ import java.util.concurrent.TimeUnit @Composable fun VehicleInfoWindow(status: ObaTripStatus, response: RouteTrips) { val res = LocalContext.current.resources - val nowMs = rememberNowMs() + // "Now" in the server clock domain: the age is measured against the vehicle's last real-time fix + // (status.lastLocationUpdateTime, falling back to lastUpdateTime) — both server AVL timestamps — + // so a skewed device clock must not leak into it (#1612). + val nowMs = rememberServerNowMs(response.currentTimeMs) // The window only opens for an already-rendered vehicle, so its trip/route are in the refs; // guard the unreachable null instead of dereferencing (the legacy getTrip/getRoute would NPE). val trip = response.trip(status.activeTripId) ?: return @@ -262,21 +267,44 @@ private fun lastUpdatedText(res: Resources, realtime: Boolean, status: ObaTripSt } /** - * A wall-clock that updates once per second, for live "… ago" age text. Lets a marker info window tick - * even though its data (status/response) is unchanged between polls (strong skipping would otherwise - * freeze it). + * A monotonic device clock (`SystemClock.elapsedRealtime`) that updates once per second, for live + * "… ago" age text. Lets a marker info window tick even though its data (status/response) is unchanged + * between polls (strong skipping would otherwise freeze it). Monotonic, not wall-clock, so measuring an + * interval against it is immune to NTP corrections / the user changing the device clock. */ @Composable -internal fun rememberNowMs(): Long { - val now by produceState(System.currentTimeMillis()) { +internal fun rememberElapsedRealtimeMs(): Long { + val now by produceState(SystemClock.elapsedRealtime()) { while (true) { - value = System.currentTimeMillis() + value = SystemClock.elapsedRealtime() delay(1000) } } return now } +/** + * A live "now" in the **server** clock domain, anchored on [serverTimeMs] (the poll's server + * `currentTime`) and advanced by the device's elapsed time since this response was observed. Ticking + * against the server clock keeps age text (`… ago`) free of device clock skew — the age is measured + * against the same clock that stamped the vehicle timestamp (#1612). The offset is captured once per + * [serverTimeMs] (each poll) from the **monotonic** clock, so composition lag never accumulates and a + * device clock change can't corrupt it: at capture the result equals [serverTimeMs], then it counts up + * in real elapsed device time. + */ +@Composable +internal fun rememberServerNowMs(serverTimeMs: Long): Long { + val deviceStartMs = remember(serverTimeMs) { SystemClock.elapsedRealtime() } + val deviceNowMs = rememberElapsedRealtimeMs() + return serverNowMs(serverTimeMs, deviceStartMs, deviceNowMs) +} + +/** [serverTimeMs] advanced by elapsed device time. The delta is clamped at 0 so a fresh anchor (the + * synchronous [remember] capture racing the 1s-lagged ticker) never yields a "now" before the anchor. + * Extracted from [rememberServerNowMs] as a pure function so it's JVM-unit-testable. */ +internal fun serverNowMs(serverTimeMs: Long, deviceStartMs: Long, deviceNowMs: Long): Long = + serverTimeMs + (deviceNowMs - deviceStartMs).coerceAtLeast(0L) + /** * Formats [elapsedSeconds] as "Data updated N min M sec ago" (or "… M sec ago" under a minute). * When [estimating] is true the marker position is an extrapolated estimate, so the age is framed diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/render/MapRenderState.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/render/MapRenderState.kt index 86328e4bca..f88f28547d 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/map/render/MapRenderState.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/render/MapRenderState.kt @@ -133,6 +133,10 @@ data class MapRenderSnapshot( // The currently focused stop id, couriered so the vehicle info-window's "more info" tap can deep // link into TripDetails scoped to that stop (the legacy VehicleOverlay.Controller hook). val focusedStopId: String? = null, + // The zoom band the stops render in (full directional icon vs small dot). Derived from the camera + // by StopsMapController and carried here so a pure zoom re-fires the renderer like any other + // snapshot change — keeping the renderer a pure function of the snapshot (no live camera reads). + val stopBand: StopBand = StopBand.FULL, ) /** @@ -281,6 +285,11 @@ class MapRenderState { _snapshot.update { it.copy(focusedStopId = stopId) } } + /** Sets the stop zoom band (full icon vs dot); a no-op emission when unchanged (StateFlow dedups). */ + fun setStopBand(band: StopBand) { + _snapshot.update { it.copy(stopBand = band) } + } + /** Selects (or deselects with null) a vehicle by trip id; the renderer shows its data marker. */ fun setSelectedVehicle(tripId: String?) { _selectedVehicleTripId.value = tripId diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/render/StopBitmaps.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/render/StopBitmaps.kt new file mode 100644 index 0000000000..39578710ef --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/render/StopBitmaps.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.map.render + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Shared builder for the small "dot" bus-stop marker shown at distant zoom (the full-icon ⇄ dot + * collapse). Lives in src/main so both map flavors share one definition — the Google flavor wraps the + * bitmap as a `BitmapDescriptor`, maplibre as an `Icon` — mirroring [BikeBitmaps]. Directionless and + * route-type agnostic; the dot's [color][dot] distinguishes a normal vs focused stop. + */ +object StopBitmaps { + /** + * A filled circle in [fillColor] with a thin white outline for contrast against the map, sized + * from [baseIconPx] (the full stop icon size) so a dense viewport reads as points. + */ + @JvmStatic + fun dot(baseIconPx: Int, fillColor: Int): Bitmap { + val sizePx = max(6, (baseIconPx * 0.5f).roundToInt()) + val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bm) + val center = sizePx / 2f + val stroke = max(1f, sizePx / 10f) + val radius = center - stroke + + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = fillColor + } + canvas.drawCircle(center, center, radius, fill) + + val outline = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = stroke + color = Color.WHITE + } + canvas.drawCircle(center, center, radius, outline) + return bm + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/map/render/StopZoomBand.kt b/onebusaway-android/src/main/java/org/onebusaway/android/map/render/StopZoomBand.kt new file mode 100644 index 0000000000..21107bae35 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/map/render/StopZoomBand.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.map.render + +/** + * The zoom at or above which stops show their full directional icon; below it they collapse to a + * small dot to reduce clutter when a lot of stops are on screen. Tunable — mirrors the spirit of the + * bike zoom bands ([bikeZoomBand]). + */ +const val STOP_DOT_ZOOM_THRESHOLD = 15f + +/** Whether a stop renders as a small dot (distant zoom) or its full directional icon (close zoom). */ +enum class StopBand { DOT, FULL } + +/** The [StopBand] a stop falls in at [zoom]: a dot below [STOP_DOT_ZOOM_THRESHOLD], else the full icon. */ +fun stopZoomBand(zoom: Float): StopBand = + if (zoom < STOP_DOT_ZOOM_THRESHOLD) StopBand.DOT else StopBand.FULL + +/** The icon variants a stop marker can show: full icon (normal/focused) or dot (normal/focused). */ +enum class StopIconKind { FULL, FULL_FOCUSED, DOT, DOT_FOCUSED } + +/** + * The icon a stop marker should show given whether it's the [focused] stop and the current zoom + * [band]. The focused stop always gets a distinct (accent) icon — the enlarged full icon up close, + * an accent-colored dot far out — so a selection stays visible at every zoom. Pure, so the renderers' + * "did this marker's icon change?" decision is unit-testable and identical across both map flavors. + */ +fun stopIconKind(focused: Boolean, band: StopBand): StopIconKind = when { + band == StopBand.DOT -> if (focused) StopIconKind.DOT_FOCUSED else StopIconKind.DOT + focused -> StopIconKind.FULL_FOCUSED + else -> StopIconKind.FULL +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/models/MapData.kt b/onebusaway-android/src/main/java/org/onebusaway/android/models/MapData.kt index c59d4038c1..cd0bec0271 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/models/MapData.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/models/MapData.kt @@ -28,12 +28,23 @@ data class NearbyStops( /** * A route's stops, the serving routes (for stop-marker icons), the route + agency name, and its * decoded shape. Polylines are [Location] points (the neutral geo type); the map layer turns them - * into its render `GeoPoint`s. + * into its render `GeoPoint`s. Each [RouteMapStop] carries the direction(s) it serves, so the overlay + * can be narrowed to a single stop-relevant direction without a separate id index. */ data class RouteMapData( val route: ObaRoute?, val agencyName: String?, - val stops: List, + val stops: List, val routes: List, val polylines: List>, ) + +/** + * A [stop] as it appears on a route, tagged with the GTFS [directionIds] whose stop groups list it — + * usually a single direction, a two-element set for a stop shared between both directions, and empty + * when the route has no (numeric) direction grouping. + */ +data class RouteMapStop( + val stop: ObaStop, + val directionIds: Set, +) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/models/ObaSituation.kt b/onebusaway-android/src/main/java/org/onebusaway/android/models/ObaSituation.kt index f2b0f2ae75..5a4b3df970 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/models/ObaSituation.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/models/ObaSituation.kt @@ -60,10 +60,11 @@ interface ObaSituation : ObaElement { } interface ActiveWindow { - /** The starting time of the active window for this situation. */ + /** The active window's start, **epoch milliseconds** (the wire value — which may be seconds or + * millis depending on the server — is normalized to millis at the adapter). */ val from: Long - /** The ending time of the active window for this situation. */ + /** The active window's end, **epoch milliseconds**; 0 means no end (open-ended window). */ val to: Long } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/nav/NavigationService.kt b/onebusaway-android/src/main/java/org/onebusaway/android/nav/NavigationService.kt index 1bc239912b..62e8cdbfa3 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/nav/NavigationService.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/nav/NavigationService.kt @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.location.Location +import android.location.LocationManager import android.os.Build import android.os.IBinder import android.util.Log @@ -32,6 +33,8 @@ import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.auth.FirebaseAuth +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -45,9 +48,13 @@ import org.onebusaway.android.app.Application import org.onebusaway.android.app.di.LocationEntryPoint import org.onebusaway.android.analytics.ObaAnalytics import org.onebusaway.android.analytics.PlausibleAnalytics +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.NavStopDao +import org.onebusaway.android.database.oba.NavStopRecord +import org.onebusaway.android.database.oba.StopDao +import org.onebusaway.android.database.oba.StopLocationRow import org.onebusaway.android.nav.model.Path import org.onebusaway.android.nav.model.PathLink -import org.onebusaway.android.provider.ObaContract import org.onebusaway.android.ui.feedback.FeedbackLauncher import org.onebusaway.android.ui.tripdetails.TripDetailsLauncher import org.onebusaway.android.util.LocationUtils @@ -70,8 +77,13 @@ import java.util.concurrent.TimeUnit * to its [NavigationServiceProvider], which computes trip statuses and issues notifications/TTS. Once * the NavigationServiceProvider reports finished, the service stops itself. */ +@AndroidEntryPoint class NavigationService : Service() { + @Inject lateinit var navStopDao: NavStopDao + @Inject lateinit var stopDao: StopDao + @Inject lateinit var importGate: ImportGate + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var navJob: Job? = null @@ -94,72 +106,103 @@ class NavigationService : Service() { Log.d(TAG, "Starting Service") firebaseAnalytics = FirebaseAnalytics.getInstance(this) val currentTime = System.currentTimeMillis() - if (intent != null) { - destinationStopId = intent.getStringExtra(DESTINATION_ID) - beforeStopId = intent.getStringExtra(BEFORE_STOP_ID) - tripId = intent.getStringExtra(TRIP_ID) - - ObaContract.NavStops.insert( - Application.get().applicationContext, currentTime, - 1, 1, tripId, destinationStopId, beforeStopId - ) + // The nav-stop read/write is now Room-backed (suspend), so the setup runs on the service scope + // after the one-time import gate. Dispatchers.Main.immediate keeps startForeground on the main + // thread; only the fast Room reads suspend, so it is still called promptly. + serviceScope.launch { + importGate.awaitReady() + if (intent != null) { + destinationStopId = intent.getStringExtra(DESTINATION_ID) + beforeStopId = intent.getStringExtra(BEFORE_STOP_ID) + tripId = intent.getStringExtra(TRIP_ID) + + navStopDao.replaceActive( + NavStopRecord( + navId = "1", + startTime = currentTime, + tripId = tripId.orEmpty(), + destinationId = destinationStopId.orEmpty(), + beforeId = beforeStopId.orEmpty(), + sequence = 1, + active = 1, + ) + ) - navProvider = NavigationServiceProvider(tripId, destinationStopId) - } else { - val args = ObaContract.NavStops.getDetails(Application.get().applicationContext, "1") - if (args != null && args.size == 3) { - tripId = args[0] - destinationStopId = args[1] - beforeStopId = args[2] - navProvider = NavigationServiceProvider(tripId, destinationStopId, 1) + navProvider = NavigationServiceProvider(tripId, destinationStopId) + } else { + val args = navStopDao.getDetails("1") + if (args != null) { + tripId = args.tripId + destinationStopId = args.destinationId + beforeStopId = args.beforeId + navProvider = NavigationServiceProvider(tripId, destinationStopId, 1) + } } - } - // Log in anonymously via Firebase - initAnonFirebaseLogin() + // No intent and no persisted nav to resume (a system restart of the sticky service after + // the trip ended): there's nothing to navigate, so stop cleanly instead of hitting the + // navProvider!! below with an NPE. + if (navProvider == null) { + Log.w(TAG, "No navigation data to resume; stopping service") + stopSelf() + return@launch + } - // Setup file for logging. - if (logFile == null) { - setupLog() - } + // Log in anonymously via Firebase + initAnonFirebaseLogin() - val dest = ObaContract.Stops.getLocation(Application.get().applicationContext, destinationStopId) - val last = ObaContract.Stops.getLocation(Application.get().applicationContext, beforeStopId) - val pathLink = PathLink(currentTime, null, last, dest, tripId) + val dest = destinationStopId?.let { stopLocation(stopDao.location(it)) } + val last = beforeStopId?.let { stopLocation(stopDao.location(it)) } - navProvider?.let { - // TODO Support more than one path link - val links = ArrayList(1) - links.add(pathLink) - it.navigate(Path(links)) - } + // Setup file for logging. + if (logFile == null) { + setupLog(dest, last) + } + + val pathLink = PathLink(currentTime, null, last, dest, tripId) - // Collect the shared location feed (1 s cadence) instead of owning a private LocationHelper. - // Start it AFTER navigate() initializes the proximity calculator: the repository's StateFlow - // replays its seeded value immediately on collect, so handleLocation() -> locationUpdated() - // must not run before the provider is set up (the legacy LocationHelper delivered its first fix - // asynchronously, after navigate()). - if (navJob?.isActive != true) { - Log.d(TAG, "Requesting Location Updates") - navJob = serviceScope.launch { - LocationEntryPoint.get(this@NavigationService) - .locationUpdates(NAV_UPDATE_INTERVAL_SECONDS) - .collect { handleLocation(it) } + navProvider?.let { + // TODO Support more than one path link + val links = ArrayList(1) + links.add(pathLink) + it.navigate(Path(links)) } - } - val notification = navProvider!!.getForegroundStartingNotification() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground( - NavigationServiceProvider.NOTIFICATION_ID, notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION - ) - } else { - startForeground(NavigationServiceProvider.NOTIFICATION_ID, notification) + // Collect the shared location feed (1 s cadence) instead of owning a private LocationHelper. + // Start it AFTER navigate() initializes the proximity calculator: the repository's StateFlow + // replays its seeded value immediately on collect, so handleLocation() -> locationUpdated() + // must not run before the provider is set up (the legacy LocationHelper delivered its first fix + // asynchronously, after navigate()). + if (navJob?.isActive != true) { + Log.d(TAG, "Requesting Location Updates") + navJob = serviceScope.launch { + LocationEntryPoint.get(this@NavigationService) + .locationUpdates(NAV_UPDATE_INTERVAL_SECONDS) + .collect { handleLocation(it) } + } + } + + val notification = navProvider!!.getForegroundStartingNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NavigationServiceProvider.NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + ) + } else { + startForeground(NavigationServiceProvider.NOTIFICATION_ID, notification) + } } return Service.START_STICKY } + /** Converts a stop's stored coordinates to a [Location] (the legacy Stops.getLocation shape). */ + private fun stopLocation(row: StopLocationRow?): Location? = row?.let { + Location(LocationManager.GPS_PROVIDER).apply { + latitude = it.latitude + longitude = it.longitude + } + } + override fun onBind(intent: Intent?): IBinder? = null override fun onUnbind(intent: Intent?): Boolean = false @@ -238,7 +281,7 @@ class NavigationService : Service() { * Creates the log file that GPS data and navigation performance is written to - see * DESTINATION_ALERTS.md */ - private fun setupLog() { + private fun setupLog(dest: Location?, last: Location?) { try { // Get the counter that's incremented for each test val navTestId = getString(R.string.preference_key_nav_test_id) @@ -263,12 +306,10 @@ class NavigationService : Service() { Log.d(TAG, ":" + file.absolutePath) - val dest = ObaContract.Stops.getLocation(Application.get().applicationContext, destinationStopId) - val last = ObaContract.Stops.getLocation(Application.get().applicationContext, beforeStopId) - val header = String.format( Locale.US, "%s,%s,%f,%f,%s,%f,%f\n", tripId, destinationStopId, - dest.latitude, dest.longitude, beforeStopId, last.latitude, last.longitude + dest?.latitude ?: 0.0, dest?.longitude ?: 0.0, beforeStopId, + last?.latitude ?: 0.0, last?.longitude ?: 0.0 ) FileUtils.write(file, header, false) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/provider/ContentResolverFlows.kt b/onebusaway-android/src/main/java/org/onebusaway/android/provider/ContentResolverFlows.kt deleted file mode 100644 index 7fb200d914..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/provider/ContentResolverFlows.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (C) 2026 Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.onebusaway.android.provider - -import android.content.Context -import android.database.ContentObserver -import android.net.Uri -import android.os.Handler -import android.os.Looper -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -/** - * Emits once immediately, then again whenever [uri] (and its descendants) change. The single wrapper - * turning a ContentProvider URI into a Flow, so repositories can react to provider writes (the legacy - * `ContentObserver` behavior) declaratively — typically `contentChanges(uri).map { query() }`. The - * observer is unregistered when collection stops. - */ -fun Context.contentChanges(uri: Uri): Flow = callbackFlow { - val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean) { - trySend(Unit) - } - } - contentResolver.registerContentObserver(uri, true, observer) - trySend(Unit) - awaitClose { contentResolver.unregisterContentObserver(observer) } -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/provider/ObaContract.java b/onebusaway-android/src/main/java/org/onebusaway/android/provider/ObaContract.java deleted file mode 100644 index fed45e1ec5..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/provider/ObaContract.java +++ /dev/null @@ -1,1964 +0,0 @@ -/* - * Copyright (C) 2010-2017 Paul Watts (paulcwatts@gmail.com), - * University of South Florida (sjbarbeau@gmail.com), - * Benjamin Du (bendu@me.com), - * Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.onebusaway.android.provider; - -import com.google.firebase.analytics.FirebaseAnalytics; - -import org.onebusaway.android.BuildConfig; -import org.onebusaway.android.R; -import org.onebusaway.android.app.Application; -import org.onebusaway.android.analytics.ObaAnalytics; -import org.onebusaway.android.analytics.PlausibleAnalytics; -import org.onebusaway.android.region.Region; -import org.onebusaway.android.region.RegionCursor; -import org.onebusaway.android.nav.model.PathLink; - -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.location.Location; -import android.location.LocationManager; -import android.net.Uri; -import android.provider.BaseColumns; -import android.text.format.Time; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; - -/** - * The contract between clients and the ObaProvider. - * - * This really needs to be documented better. - * - * NOTE: The AUTHORITY names in this class cannot be changed. They need to stay under the - * BuildConfig.DATABASE_AUTHORITY namespace (for the original OBA brand, "com.joulespersecond.oba") - * namespace to support backwards compatibility with existing installed apps - * - * @author paulw - */ -public final class ObaContract { - - public static final String TAG = "ObaContract"; - - /** The authority portion of the URI - defined in build.gradle */ - public static final String AUTHORITY = BuildConfig.DATABASE_AUTHORITY; - - /** The base URI for the Oba provider */ - public static final Uri AUTHORITY_URI = Uri.parse("content://" + AUTHORITY); - - protected interface StopsColumns { - - /** - * The code for the stop, e.g. 001_123 - *

- * Type: TEXT - *

- */ - public static final String CODE = "code"; - - /** - * The user specified name of the stop, e.g. "13th Ave E & John St" - *

- * Type: TEXT - *

- */ - public static final String NAME = "name"; - - /** - * The stop direction, one of: N, NE, NW, E, SE, SW, S, W or null - *

- * Type: TEXT - *

- */ - public static final String DIRECTION = "direction"; - - /** - * The latitude of the stop location. - *

- * Type: DOUBLE - *

- */ - public static final String LATITUDE = "latitude"; - - /** - * The longitude of the stop location. - *

- * Type: DOUBLE - *

- */ - public static final String LONGITUDE = "longitude"; - - /** - * The region ID - *

- * Type: INTEGER - *

- */ - public static final String REGION_ID = "region_id"; - } - - protected interface RoutesColumns { - - /** - * The short name of the route, e.g. "10" - *

- * Type: TEXT - *

- */ - public static final String SHORTNAME = "short_name"; - - /** - * The long name of the route, e.g "Downtown to U-District" - *

- * Type: TEXT - *

- */ - public static final String LONGNAME = "long_name"; - - /** - * Returns the URL of the route schedule. - *

- * Type: TEXT - *

- */ - public static final String URL = "url"; - - /** - * The region ID - *

- * Type: INTEGER - *

- */ - public static final String REGION_ID = "region_id"; - } - - protected interface StopRouteKeyColumns { - - /** - * The referenced Stop ID. This may or may not represent a key in the - * Stops table. - *

- * Type: TEXT - *

- */ - public static final String STOP_ID = "stop_id"; - - /** - * The referenced Route ID. This may or may not represent a key in the - * Routes table. - *

- * Type: TEXT - *

- */ - public static final String ROUTE_ID = "route_id"; - } - - protected interface StopRouteFilterColumns extends StopRouteKeyColumns { - // No additional columns - } - - protected interface TripsColumns extends StopRouteKeyColumns { - - /** - * The scheduled departure time for the trip in milliseconds. - *

- * Type: INTEGER - *

- */ - public static final String DEPARTURE = "departure"; - - /** - * The headsign of the trip, e.g., "Capitol Hill" - *

- * Type: TEXT - *

- */ - public static final String HEADSIGN = "headsign"; - - /** - * The user specified name of the trip. - *

- * Type: TEXT - *

- */ - public static final String NAME = "name"; - - /** - * The number of minutes before the arrival to notify the user - *

- * Type: INTEGER - *

- */ - public static final String REMINDER = "reminder"; - - /** - * A String representing the ALARM_DELETE_PATH from the post request - * used for deleting an alarm. - *

- * Type: String - *

- */ - public static final String ALARM_DELETE_PATH = "alarm_delete_path"; - - /** - * A String representing the ID of a trip. - *

- * Type: String - *

- */ - public static final String TRIP_ID = "trip_id"; - - /** - * A String representing the service date for a particular trip. - *

- * Type: String - *

- */ - public static final String SERVICE_DATE = "service_date"; - - /** - * A String representing the stop sequence number in the trip. - *

- * Type: String - *

- */ - public static final String STOP_SEQUENCE = "stop_sequence"; - - /** - * A String representing the ID of a vehicle assigned to the trip. - *

- * Type: String - *

- */ - public static final String VEHICLE_ID = "vehicle_id"; - - - } - - protected interface TripAlertsColumns { - - /** - * The trip_id key of the corresponding trip. - *

- * Type: TEXT - *

- */ - public static final String TRIP_ID = "trip_id"; - /** - * The stop_id key of the corresponding trip. - *

- * Type: TEXT - *

- */ - public static final String STOP_ID = "stop_id"; - - /** - * The time in milliseconds to begin the polling. Unlike the "reminder" - * time in the Trips columns, this represents a specific time. - *

- * Type: INTEGER - *

- */ - public static final String START_TIME = "start_time"; - - /** - * The state of the the alert. Can be SCHEDULED, POLLING, NOTIFY, - * CANCELED - *

- * Type: INTEGER - *

- */ - public static final String STATE = "state"; - } - - protected interface UserColumns { - - /** - * The number of times this resource has been accessed by the user. - *

- * Type: INTEGER - *

- */ - public static final String USE_COUNT = "use_count"; - - /** - * The user specified name given to this resource. - *

- * Type: TEXT - *

- */ - public static final String USER_NAME = "user_name"; - - /** - * The last time the user accessed the resource. - *

- * Type: INTEGER - *

- */ - public static final String ACCESS_TIME = "access_time"; - - /** - * Whether or not the resource is marked as a favorite (starred) - *

- * Type: INTEGER (1 or 0) - *

- */ - public static final String FAVORITE = "favorite"; - - /** - * This returns the user specified name, or the default name. - *

- * Type: TEXT - *

- */ - public static final String UI_NAME = "ui_name"; - } - - protected interface ServiceAlertsColumns { - - /** - * The time it was marked as read. - *

- * Type: TEXT - *

- */ - public static final String MARKED_READ_TIME = "marked_read_time"; - - /** - * Whether or not the alert has been hidden by the user. - *

- * Type: BOOLEAN - *

- */ - public static final String HIDDEN = "hidden"; - } - - protected interface RegionsColumns { - - /** - * The name of the region. - *

- * Type: TEXT - *

- */ - public static final String NAME = "name"; - - /** - * The base OBA URL. - *

- * Type: TEXT - *

- */ - public static final String OBA_BASE_URL = "oba_base_url"; - - /** - * The Sidecar Base URL for the API. - *

- * Type: TEXT - *

- */ - public static final String SIDECAR_BASE_URL = "sidecar_base_url"; - - /** - * The plausible analytics server URL for the region. - *

- * Type: TEXT - *

- */ - public static final String PLAUSIBLE_ANALYTICS_SERVER_URL = "plausible_analytics_server_url"; - - /** - * The Umami analytics server URL for the region. - *

- * Type: TEXT - *

- */ - public static final String UMAMI_ANALYTICS_URL = "umami_analytics_url"; - - /** - * The Umami analytics website ID for the region. - *

- * Type: TEXT - *

- */ - public static final String UMAMI_ANALYTICS_ID = "umami_analytics_id"; - - /** - * The base SIRI URL. - *

- * Type: TEXT - *

- */ - public static final String SIRI_BASE_URL = "siri_base_url"; - - /** - * The locale of the API server. - *

- * Type: TEXT - *

- */ - public static final String LANGUAGE = "lang"; - - /** - * The email of the person responsible for this server. - *

- * Type: TEXT - *

- */ - public static final String CONTACT_EMAIL = "contact_email"; - - /** - * Whether or not the server supports OBA discovery APIs. - *

- * Type: BOOLEAN - *

- */ - public static final String SUPPORTS_OBA_DISCOVERY = "supports_api_discovery"; - - /** - * Whether or not the server supports OBA realtime APIs. - *

- * Type: BOOLEAN - *

- */ - public static final String SUPPORTS_OBA_REALTIME = "supports_api_realtime"; - - /** - * Whether or not the server supports SIRI realtime APIs. - *

- * Type: BOOLEAN - *

- */ - public static final String SUPPORTS_SIRI_REALTIME = "supports_siri_realtime"; - - /** - * The Twitter URL for the region. - *

- * Type: TEXT - *

- */ - public static final String TWITTER_URL = "twitter_url"; - - /** - * Whether or not the server is experimental (i.e., not production). - *

- * Type: BOOLEAN - *

- */ - public static final String EXPERIMENTAL = "experimental"; - - /** - * The StopInfo URL for the region (see #103) - *

- * Type: TEXT - *

- */ - public static final String STOP_INFO_URL = "stop_info_url"; - - /** - * The OpenTripPlanner URL for the region - *

- * Type: TEXT - *

- */ - public static final String OTP_BASE_URL = "otp_base_url"; - - /** - * The email of the person responsible for the OTP server. - *

- * Type: TEXT - *

- */ - public static final String OTP_CONTACT_EMAIL = "otp_contact_email"; - - /** - * Whether or not the region supports bikeshare information through integration with OTP - *

- * Type: BOOLEAN - *

- */ - public static final String SUPPORTS_OTP_BIKESHARE = "supports_otp_bikeshare"; - - /** - * Whether or not the server supports Embedded Social - *

- * Type: BOOLEAN - *

- */ - public static final String SUPPORTS_EMBEDDED_SOCIAL = "supports_embedded_social"; - - /** - * The Android App ID for the payment app used in the region - *

- * Type: TEXT - *

- */ - public static final String PAYMENT_ANDROID_APP_ID = "payment_android_app_id"; - - /** - * The title of a warning dialog that should be shown to the user the first time they select the fare payment option, or empty if no warning should be shown to the user. - *

- * Type: TEXT - *

- */ - public static final String PAYMENT_WARNING_TITLE = "payment_warning_title"; - - /** - * The body text of a warning dialog that should be shown to the user the first time they select the fare payment option, or empty if no warning should be shown to the user. - *

- * Type: TEXT - *

- */ - public static final String PAYMENT_WARNING_BODY = "payment_warning_body"; - - public static final String TRAVEL_BEHAVIOR_DATA_COLLECTION = "travel_behavior_data_collection"; - - public static final String ENROLL_PARTICIPANTS_IN_STUDY = "enroll_participants_in_study"; - } - - protected interface RegionBoundsColumns { - - /** - * The region ID - *

- * Type: INTEGER - *

- */ - public static final String REGION_ID = "region_id"; - - /** - * The latitude center of the agencies coverage area - *

- * Type: REAL - *

- */ - public static final String LATITUDE = "lat"; - - /** - * The longitude center of the agencies coverage area - *

- * Type: REAL - *

- */ - public static final String LONGITUDE = "lon"; - - /** - * The height of the agencies bounding box - *

- * Type: REAL - *

- */ - public static final String LAT_SPAN = "lat_span"; - - /** - * The width of the agencies bounding box - *

- * Type: REAL - *

- */ - public static final String LON_SPAN = "lon_span"; - - } - - protected interface RegionOpen311ServersColumns { - - /** - * The region ID - *

- * Type: INTEGER - *

- */ - public static final String REGION_ID = "region_id"; - - /** - * The jurisdiction id of the open311 server - *

- * Type: TEXT - *

- */ - public static final String JURISDICTION = "jurisdiction"; - - /** - * The api key of the open311 server - *

- * Type: TEXT - *

- */ - public static final String API_KEY = "api_key"; - - /** - * The url of the open311 server - *

- * Type: TEXT - *

- */ - public static final String BASE_URL = "open311_base_url"; - - } - - protected interface RouteHeadsignKeyColumns extends StopRouteKeyColumns { - - /** - * The referenced headsign. This may or may not represent a value in the - * Trips table. - *

- * Type: TEXT - *

- */ - public static final String HEADSIGN = "headsign"; - - /** - * Whether or not this stop should be excluded as a favorite. This is to allow a user to - * star a route/headsign for all stops, and then remove the star from selected stops. - *

- * Type: BOOLEAN - *

- */ - public static final String EXCLUDE = "exclude"; - } - - protected interface NavigationColumns { - - /** - * The Navigation ID for destination reminder - *

- * Type: INTEGER - *

- */ - public static final String NAV_ID = "nav_id"; - - /** - * The start time for the navigation of this path link - *

- * Type: INTEGER - *

- */ - public static final String START_TIME = "start_time"; - - /** - * The Trip Id - *

- * Type: TEXT - *

- */ - public static final String TRIP_ID = "trip_id"; - - /** - * The Stop ID of the destination. - *

- * Type: TEXT - *

- */ - public static final String DESTINATION_ID = "destination_id"; - - /** - * The Stop ID of the stop before the user's destination. - *

- * Type: TEXT - *

- */ - public static final String BEFORE_ID = "before_id"; - - /** - * Sequence Number of the Segment. - *

- * Type: Integer - *

- */ - public static final String SEQUENCE = "seq_num"; - - /** - * Whether this leg of the trip is still active. - *

- * Type: Integer (1 or 0) - *

- */ - public static final String ACTIVE = "is_active"; - - } - - public static class Stops implements BaseColumns, StopsColumns, UserColumns { - - // Cannot be instantiated - private Stops() { - } - - /** The URI path portion for this table */ - public static final String PATH = "stops"; - - /** The content:// style URI for this table */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_TYPE - = "vnd.android.cursor.item/" + BuildConfig.DATABASE_AUTHORITY + ".stop"; - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".stop"; - - public static Uri insertOrUpdate(String id, - ContentValues values, - boolean markAsUsed) { - ContentResolver cr = Application.get().getContentResolver(); - final Uri uri = Uri.withAppendedPath(CONTENT_URI, id); - Cursor c = cr.query(uri, new String[]{USE_COUNT}, null, null, - null); - Uri result; - if (c != null && c.getCount() > 0) { - // Update - if (markAsUsed) { - c.moveToFirst(); - int count = c.getInt(0); - values.put(USE_COUNT, count + 1); - values.put(ACCESS_TIME, System.currentTimeMillis()); - } - cr.update(uri, values, null, null); - result = uri; - } else { - // Insert - if (markAsUsed) { - values.put(USE_COUNT, 1); - values.put(ACCESS_TIME, System.currentTimeMillis()); - } else { - values.put(USE_COUNT, 0); - } - values.put(_ID, id); - result = cr.insert(CONTENT_URI, values); - } - if (c != null) { - c.close(); - } - return result; - } - - public static boolean isFavorite(Context context, String stopId) - { - final String[] PROJECTION = { - FAVORITE - }; - - ContentResolver cr = context.getContentResolver(); - Cursor c = cr.query(CONTENT_URI, PROJECTION,_ID + "=?", new String[] {stopId}, null); - if (c != null) { - try { - if (c.getCount() == 0) { - return false; - } - c.moveToFirst(); - return c.getInt(0) == 1; - - } finally { - c.close(); - } - } - return false; - } - - public static boolean markAsFavorite(Context context, - Uri uri, - boolean favorite) { - ContentResolver cr = context.getContentResolver(); - ContentValues values = new ContentValues(); - values.put(ObaContract.Stops.FAVORITE, favorite ? 1 : 0); - return cr.update(uri, values, null, null) > 0; - } - - public static boolean markAsUnused(Context context, Uri uri) { - ContentResolver cr = context.getContentResolver(); - ContentValues values = new ContentValues(); - values.put(ObaContract.Stops.USE_COUNT, 0); - values.putNull(ObaContract.Stops.ACCESS_TIME); - return cr.update(uri, values, null, null) > 0; - } - - public static Location getLocation(Context context, String id) { - return getLocation(context.getContentResolver(), id); - } - - private static Location getLocation(ContentResolver cr, String id) { - final String[] PROJECTION = { - LATITUDE, - LONGITUDE - }; - - Cursor c = cr.query(CONTENT_URI, PROJECTION,_ID + "=?", new String[] {id}, null); - if (c != null) { - try { - if (c.getCount() == 0) { - return null; - } - c.moveToFirst(); - Location l = new Location(LocationManager.GPS_PROVIDER); - l.setLatitude(c.getDouble(0)); - l.setLongitude(c.getDouble(1)); - return l; - - } finally { - c.close(); - } - } - return null; - } - } - - public static class Routes implements BaseColumns, RoutesColumns, - UserColumns { - - // Cannot be instantiated - private Routes() { - } - - /** The URI path portion for this table */ - public static final String PATH = "routes"; - - /** The content:// style URI for this table */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_TYPE - = "vnd.android.cursor.item/" + BuildConfig.DATABASE_AUTHORITY + ".route"; - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".route"; - - public static Uri insertOrUpdate(Context context, - String id, - ContentValues values, - boolean markAsUsed) { - ContentResolver cr = context.getContentResolver(); - final Uri uri = Uri.withAppendedPath(CONTENT_URI, id); - Cursor c = cr.query(uri, new String[]{USE_COUNT}, null, null, - null); - Uri result; - if (c != null && c.getCount() > 0) { - // Update - if (markAsUsed) { - c.moveToFirst(); - int count = c.getInt(0); - values.put(USE_COUNT, count + 1); - values.put(ACCESS_TIME, System.currentTimeMillis()); - } - cr.update(uri, values, null, null); - result = uri; - } else { - // Insert - if (markAsUsed) { - values.put(USE_COUNT, 1); - values.put(ACCESS_TIME, System.currentTimeMillis()); - } else { - values.put(USE_COUNT, 0); - } - values.put(_ID, id); - result = cr.insert(CONTENT_URI, values); - } - if (c != null) { - c.close(); - } - return result; - } - - public static boolean markAsFavorite(Context context, - Uri uri, - boolean favorite) { - ContentResolver cr = context.getContentResolver(); - ContentValues values = new ContentValues(); - values.put(ObaContract.Routes.FAVORITE, favorite ? 1 : 0); - return cr.update(uri, values, null, null) > 0; - } - - public static boolean markAsUnused(Context context, Uri uri) { - ContentResolver cr = context.getContentResolver(); - ContentValues values = new ContentValues(); - values.put(ObaContract.Routes.USE_COUNT, 0); - values.putNull(ObaContract.Routes.ACCESS_TIME); - return cr.update(uri, values, null, null) > 0; - } - - /** - * Returns true if this route is a favorite, false if it does not - * - * Note that this is NOT specific to headsign. If you want to know if a combination of a - * routeId and headsign is a user favorite, see - * RouteHeadsignFavorites.isFavorite(context, routeId, headsign). - * - * @param routeUri Uri for a route - * @return true if this route is a favorite, false if it does not - */ - public static boolean isFavorite(Context context, Uri routeUri) { - ContentResolver cr = context.getContentResolver(); - String[] ROUTE_USER_PROJECTION = {ObaContract.Routes.FAVORITE}; - Cursor c = cr.query(routeUri, ROUTE_USER_PROJECTION, null, null, null); - if (c != null) { - try { - if (c.moveToNext()) { - return (c.getInt(0) == 1); - } - } finally { - c.close(); - } - } - // If we get this far, assume its not - return false; - } - } - - public static class StopRouteFilters implements StopRouteFilterColumns { - - // Cannot be instantiated - private StopRouteFilters() { - } - - /** The URI path portion for this table */ - public static final String PATH = "stop_routes_filter"; - - /** The content:// style URI for this table */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".stoproutefilter"; - - private static final String FILTER_WHERE = STOP_ID + "=?"; - - /** - * Gets the filter for the specified Stop ID. - * - * @param context The context. - * @param stopId The stop ID. - * @return The filter. If there is no filter (or on error), it returns - * an empty list. - */ - public static ArrayList get(Context context, String stopId) { - final String[] selection = {ROUTE_ID}; - final String[] selectionArgs = {stopId}; - ContentResolver cr = context.getContentResolver(); - Cursor c = cr.query(CONTENT_URI, selection, FILTER_WHERE, - selectionArgs, null); - ArrayList result = new ArrayList(); - if (c != null) { - try { - while (c.moveToNext()) { - result.add(c.getString(0)); - } - } finally { - c.close(); - } - } - return result; - } - - /** - * Sets the filter for the particular stop ID. - * - * @param context The context. - * @param stopId The stop ID. - * @param filter An array of route IDs to filter. - */ - public static void set(Context context, - String stopId, - ArrayList filter) { - if (context == null) { - return; - } - // First, delete any existing rows for this stop. - // Then, insert all of these rows. - // Should we put this in a transaction? We could, - // but it's not terribly important. - final String[] selectionArgs = {stopId}; - ContentResolver cr = context.getContentResolver(); - cr.delete(CONTENT_URI, FILTER_WHERE, selectionArgs); - - ContentValues args = new ContentValues(); - args.put(STOP_ID, stopId); - final int len = filter.size(); - for (int i = 0; i < len; ++i) { - args.put(ROUTE_ID, filter.get(i)); - cr.insert(CONTENT_URI, args); - } - } - } - - public static class Trips implements BaseColumns, StopRouteKeyColumns, - TripsColumns { - - // Cannot be instantiated - private Trips() { - } - - /** The URI path portion for this table */ - public static final String PATH = "trips"; - - /** - * The content:// style URI for this table URI is of the form - * content:///trips// - */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_TYPE - = "vnd.android.cursor.item/" + BuildConfig.DATABASE_AUTHORITY + ".trip"; - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".trip"; - - public static final int DAY_MON = 0x1; - - public static final int DAY_TUE = 0x2; - - public static final int DAY_WED = 0x4; - - public static final int DAY_THU = 0x8; - - public static final int DAY_FRI = 0x10; - - public static final int DAY_SAT = 0x20; - - public static final int DAY_SUN = 0x40; - - public static final int DAY_WEEKDAY = DAY_MON | DAY_TUE | DAY_WED - | DAY_THU | DAY_FRI; - - public static final int DAY_ALL = DAY_WEEKDAY | DAY_SUN | DAY_SAT; - - public static Uri buildUri(String tripId, String stopId) { - return CONTENT_URI.buildUpon().appendPath(tripId) - .appendPath(stopId).build(); - } - - /** - * Converts a days bitmask into a boolean[] array - * - * @param days A DB compatible days bitmask. - * @return A boolean array representing the days set in the bitmask, - * Mon=0 to Sun=6 - */ - public static boolean[] daysToArray(int days) { - final boolean[] result = { - (days & ObaContract.Trips.DAY_MON) == ObaContract.Trips.DAY_MON, - (days & ObaContract.Trips.DAY_TUE) == ObaContract.Trips.DAY_TUE, - (days & ObaContract.Trips.DAY_WED) == ObaContract.Trips.DAY_WED, - (days & ObaContract.Trips.DAY_THU) == ObaContract.Trips.DAY_THU, - (days & ObaContract.Trips.DAY_FRI) == ObaContract.Trips.DAY_FRI, - (days & ObaContract.Trips.DAY_SAT) == ObaContract.Trips.DAY_SAT, - (days & ObaContract.Trips.DAY_SUN) == ObaContract.Trips.DAY_SUN,}; - return result; - } - - /** - * Converts a boolean[] array to a DB compatible days bitmask - * - * @param days boolean array as returned by daysToArray - * @return A DB compatible days bitmask - */ - public static int arrayToDays(boolean[] days) { - if (days.length != 7) { - throw new IllegalArgumentException("days.length must be 7"); - } - int result = 0; - for (int i = 0; i < days.length; ++i) { - final int bit = days[i] ? 1 : 0; - result |= bit << i; - } - return result; - } - - /** - * Converts a 'minutes-to-midnight' value into a Unix time. - * - * @param minutes from midnight in UTC. - * @return A Unix time representing the time in the current day. - */ - // Helper functions to convert the DB DepartureTime value - public static long convertDBToTime(int minutes) { - // This converts the minutes-to-midnight to a time of the current - // day. - Time t = new Time(); - t.setToNow(); - t.set(0, minutes, 0, t.monthDay, t.month, t.year); - return t.toMillis(false); - } - - /** - * Converts a Unix time into a 'minutes-to-midnight' in UTC. - * - * @param departureTime A Unix time. - * @return minutes from midnight in UTC. - */ - public static int convertTimeToDB(long departureTime) { - // This converts a time_t to minutes-to-midnight. - Time t = new Time(); - t.set(departureTime); - return t.hour * 60 + t.minute; - } - - /** - * Converts a weekday value from a android.text.format.Time to a bit. - * - * @param weekday The weekDay value from android.text.format.Time - * @return A DB compatible bit. - */ - public static int getDayBit(int weekday) { - switch (weekday) { - case Time.MONDAY: - return ObaContract.Trips.DAY_MON; - case Time.TUESDAY: - return ObaContract.Trips.DAY_TUE; - case Time.WEDNESDAY: - return ObaContract.Trips.DAY_WED; - case Time.THURSDAY: - return ObaContract.Trips.DAY_THU; - case Time.FRIDAY: - return ObaContract.Trips.DAY_FRI; - case Time.SATURDAY: - return ObaContract.Trips.DAY_SAT; - case Time.SUNDAY: - return ObaContract.Trips.DAY_SUN; - } - return 0; - } - } - - public static class TripAlerts implements BaseColumns, TripAlertsColumns { - - // Cannot be instantiated - private TripAlerts() { - } - - /** The URI path portion for this table */ - public static final String PATH = "trip_alerts"; - - /** - * The content:// style URI for this table URI is of the form - * content:///trip_alerts/ - */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_TYPE - = "vnd.android.cursor.item/" + BuildConfig.DATABASE_AUTHORITY + ".trip_alert"; - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".trip_alert"; - - public static final int STATE_SCHEDULED = 0; - - public static final int STATE_POLLING = 1; - - public static final int STATE_NOTIFY = 2; - - public static final int STATE_CANCELLED = 3; - - public static Uri buildUri(int id) { - return CONTENT_URI.buildUpon().appendPath(String.valueOf(id)) - .build(); - } - - public static Uri insertIfNotExists(Context context, - String tripId, - String stopId, - long startTime) { - return insertIfNotExists(context.getContentResolver(), tripId, - stopId, startTime); - } - - public static Uri insertIfNotExists(ContentResolver cr, - String tripId, - String stopId, - long startTime) { - Uri result; - Cursor c = cr.query(CONTENT_URI, - new String[]{_ID}, - String.format("%s=? AND %s=? AND %s=?", - TRIP_ID, STOP_ID, START_TIME), - new String[]{tripId, stopId, String.valueOf(startTime)}, - null); - if (c != null && c.moveToNext()) { - result = buildUri(c.getInt(0)); - } else { - ContentValues values = new ContentValues(); - values.put(TRIP_ID, tripId); - values.put(STOP_ID, stopId); - values.put(START_TIME, startTime); - result = cr.insert(CONTENT_URI, values); - } - if (c != null) { - c.close(); - } - return result; - } - - public static void setState(Context context, Uri uri, int state) { - setState(context.getContentResolver(), uri, state); - } - - public static void setState(ContentResolver cr, Uri uri, int state) { - ContentValues values = new ContentValues(); - values.put(STATE, state); - cr.update(uri, values, null, null); - } - } - - public static class ServiceAlerts implements BaseColumns, ServiceAlertsColumns { - - // Cannot be instantiated - private ServiceAlerts() { - } - - /** The URI path portion for this table */ - public static final String PATH = "service_alerts"; - - /** - * The content:// style URI for this table URI is of the form - * content:///service_alerts/ - */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_TYPE - = "vnd.android.cursor.item/" + BuildConfig.DATABASE_AUTHORITY + ".service_alert"; - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".service_alert"; - - /** - * @param markAsRead true if this alert should be marked as read with the timestamp of - * System.currentTimeMillis(), - * false if the alert should not be marked as read with the timestamp of - * System.currentTimeMillis() - * @param hidden true if this alert should be marked as hidden by the user, false if - * it should be marked as not - * hidden by the user, or null if the hidden value shouldn't be - * changed - */ - public static Uri insertOrUpdate(String id, - ContentValues values, - boolean markAsRead, - Boolean hidden) { - if (id == null) { - return null; - } - if (values == null) { - values = new ContentValues(); - } - ContentResolver cr = Application.get().getContentResolver(); - final Uri uri = Uri.withAppendedPath(CONTENT_URI, id); - Cursor c = cr.query(uri, new String[]{}, null, null, null); - Uri result; - if (c != null && c.getCount() > 0) { - // Update - if (markAsRead) { - c.moveToFirst(); - values.put(MARKED_READ_TIME, System.currentTimeMillis()); - } - if (hidden != null) { - c.moveToFirst(); - if (hidden) { - values.put(HIDDEN, 1); - } else { - values.put(HIDDEN, 0); - } - } - if (values.size() != 0) { - cr.update(uri, values, null, null); - } - result = uri; - } else { - // Insert - if (markAsRead) { - values.put(MARKED_READ_TIME, System.currentTimeMillis()); - } - // A null hidden leaves HIDDEN unset (undecided). The "hide all alerts" preference is - // applied in the arrivals derivation (AlertHideState.isHidden + hideAlertsByDefault), - // NOT persisted here — otherwise merely opening an alert (mark-as-read) would harden - // the preference default into a permanent per-id hide. See #1593. - if (hidden != null) { - if (hidden) { - values.put(HIDDEN, 1); - } else { - values.put(HIDDEN, 0); - } - } - values.put(_ID, id); - result = cr.insert(CONTENT_URI, values); - } - if (c != null) { - c.close(); - } - return result; - } - - /** - * Returns the explicit per-situation hide decisions the user has recorded: each mapped id is - * either hidden (value true, HIDDEN=1) or shown (value false, HIDDEN=0). Ids with no row, or a - * null HIDDEN, are absent — the arrivals screen defaults those to the "hide all alerts" - * preference. This is the single-source-of-truth read for hidden state: paired with a - * {@link android.database.ContentObserver} on {@link #CONTENT_URI}, it lets the UI derive the - * shown/hidden split from the DB alone, so a hide/un-hide from any surface (swipe, the alert - * dialog, "show hidden alerts") updates the screen with nothing to reconcile. - * - * @return id → isHidden for every situation with an explicit decision (empty if none) - */ - public static Map getHideDecisions() { - final String[] projection = {_ID, HIDDEN}; - final String WHERE = HIDDEN + " IS NOT NULL"; - ContentResolver cr = Application.get().getContentResolver(); - Cursor c = cr.query(CONTENT_URI, projection, WHERE, null, null); - Map decisions = new HashMap<>(); - if (c != null) { - try { - final int idCol = c.getColumnIndexOrThrow(_ID); - final int hiddenCol = c.getColumnIndexOrThrow(HIDDEN); - while (c.moveToNext()) { - decisions.put(c.getString(idCol), c.getInt(hiddenCol) == 1); - } - } finally { - c.close(); - } - } - return decisions; - } - - /** - * Marks all alerts as hidden, and therefore not visible - * - * @return the number of rows updated - */ - public static int hideAllAlerts() { - ContentResolver cr = Application.get().getContentResolver(); - ContentValues values = new ContentValues(); - values.put(HIDDEN, 1); - return cr.update(CONTENT_URI, values, null, null); - } - } - - public static class Regions implements BaseColumns, RegionsColumns { - - // Cannot be instantiated - private Regions() { - } - - /** The URI path portion for this table */ - public static final String PATH = "regions"; - - /** - * The content:// style URI for this table URI is of the form - * content:///regions/ - */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_TYPE - = "vnd.android.cursor.item/" + BuildConfig.DATABASE_AUTHORITY + ".region"; - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".region"; - - public static Uri buildUri(int id) { - return ContentUris.withAppendedId(CONTENT_URI, id); - } - - public static Uri insertOrUpdate(Context context, - int id, - ContentValues values) { - return insertOrUpdate(context.getContentResolver(), id, values); - } - - public static Uri insertOrUpdate(ContentResolver cr, - int id, - ContentValues values) { - final Uri uri = Uri.withAppendedPath(CONTENT_URI, String.valueOf(id)); - Cursor c = cr.query(uri, new String[]{}, null, null, null); - Uri result; - if (c != null && c.getCount() > 0) { - cr.update(uri, values, null, null); - result = uri; - } else { - values.put(_ID, id); - result = cr.insert(CONTENT_URI, values); - } - if (c != null) { - c.close(); - } - return result; - } - - public static Region get(Context context, int id) { - return get(context.getContentResolver(), id); - } - - public static Region get(ContentResolver cr, int id) { - final String[] PROJECTION = { - _ID, - NAME, - OBA_BASE_URL, - SIRI_BASE_URL, - LANGUAGE, - CONTACT_EMAIL, - SUPPORTS_OBA_DISCOVERY, - SUPPORTS_OBA_REALTIME, - SUPPORTS_SIRI_REALTIME, - TWITTER_URL, - EXPERIMENTAL, - STOP_INFO_URL, - OTP_BASE_URL, - OTP_CONTACT_EMAIL, - SUPPORTS_OTP_BIKESHARE, - SUPPORTS_EMBEDDED_SOCIAL, - PAYMENT_ANDROID_APP_ID, - PAYMENT_WARNING_TITLE, - PAYMENT_WARNING_BODY, - TRAVEL_BEHAVIOR_DATA_COLLECTION, - ENROLL_PARTICIPANTS_IN_STUDY, - SIDECAR_BASE_URL, - PLAUSIBLE_ANALYTICS_SERVER_URL, - UMAMI_ANALYTICS_URL, - UMAMI_ANALYTICS_ID - }; - - Cursor c = cr.query(buildUri((int) id), PROJECTION, null, null, null); - if (c != null) { - try { - if (c.getCount() == 0) { - return null; - } - c.moveToFirst(); - return RegionCursor.fromCursor( - c, - RegionBounds.getRegion(cr, id), - RegionOpen311Servers.getOpen311Server(cr, id)); - } finally { - c.close(); - } - } - return null; - } - } - - public static class RegionBounds implements BaseColumns, RegionBoundsColumns { - - // Cannot be instantiated - private RegionBounds() { - } - - /** The URI path portion for this table */ - public static final String PATH = "region_bounds"; - - /** - * The content:// style URI for this table URI is of the form - * content:///region_bounds/ - */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_TYPE - = "vnd.android.cursor.item/" + BuildConfig.DATABASE_AUTHORITY + ".region_bounds"; - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".region_bounds"; - - public static Uri buildUri(int id) { - return ContentUris.withAppendedId(CONTENT_URI, id); - } - - public static Region.Bounds[] getRegion(ContentResolver cr, int regionId) { - final String[] PROJECTION = { - LATITUDE, - LONGITUDE, - LAT_SPAN, - LON_SPAN - }; - Cursor c = cr.query(CONTENT_URI, PROJECTION, - "(" + RegionBounds.REGION_ID + " = " + regionId + ")", - null, null); - if (c != null) { - try { - Region.Bounds[] results = new Region.Bounds[c.getCount()]; - if (c.getCount() == 0) { - return results; - } - - int i = 0; - c.moveToFirst(); - do { - results[i] = new Region.Bounds( - c.getDouble(0), - c.getDouble(1), - c.getDouble(2), - c.getDouble(3)); - i++; - } while (c.moveToNext()); - - return results; - } finally { - c.close(); - } - } - return null; - } - } - - public static class RegionOpen311Servers implements BaseColumns, RegionOpen311ServersColumns { - - // Cannot be instantiated - private RegionOpen311Servers() { - } - - /** The URI path portion for this table */ - public static final String PATH = "open311_servers"; - - /** - * The content:// style URI for this table URI is of the form - * content:///region_open311_servers/ - */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_TYPE - = "vnd.android.cursor.item/" + BuildConfig.DATABASE_AUTHORITY + ".open311_servers"; - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".open311_servers"; - - public static Uri buildUri(int id) { - return ContentUris.withAppendedId(CONTENT_URI, id); - } - - public static Region.Open311Server[] getOpen311Server - (ContentResolver cr, int regionId) { - final String[] PROJECTION = { - JURISDICTION, - API_KEY, - BASE_URL - }; - Cursor c = cr.query(CONTENT_URI, PROJECTION, - "(" + RegionOpen311Servers.REGION_ID + " = " + regionId + ")", - null, null); - if (c != null) { - try { - Region.Open311Server[] results = new Region.Open311Server[c.getCount()]; - if (c.getCount() == 0) { - return results; - } - - int i = 0; - c.moveToFirst(); - do { - results[i] = new Region.Open311Server( - c.getString(0), - c.getString(1), - c.getString(2)); - i++; - } while (c.moveToNext()); - - return results; - } finally { - c.close(); - } - } - return null; - } - } - - /** - * Supports storing user-defined favorites for route/headsign/stop combinations. This is - * currently implemented without requiring knowledge of a full set of stops for a route. This - * allows some flexibility in terms of changes server-side without invalidating user's - * favorites - as long as the routeId/headsign combination remains consistent (and stopId, - * when a particular stop is referenced), then user favorites should survive changes in the - * composition of stops for a route. - * - * When the user favorites a route/headsign combination in the ArrivalsListFragment/Header, - * they are prompted if they would like to make it a favorite for the current stop, or for all - * stops. If they make it a favorite for the current stop, a record with - * routeId/headsign/stopId is created, with "exclude" value of false (0). If they make it a - * favorite for all stops, a record with routeId/headsign/ALL_STOPS is created with exclude - * value of false. When arrival times are displayed for a given stopId, if a record in the - * database with routeId/headsign/ALL_STOPS or routeId?headsign/stopId matches AND exclude is - * set to false, then it is shown as a favorite. Otherwise, it is not shown as a favorite. - * If the user unstars a stop, then routeId/headsign/stopId is inserted with an exclude value of - * true (1).S - */ - public static class RouteHeadsignFavorites implements RouteHeadsignKeyColumns, UserColumns { - - // Cannot be instantiated - private RouteHeadsignFavorites() { - } - - /** The URI path portion for this table */ - public static final String PATH = "route_headsign_favorites"; - - /** The content:// style URI for this table */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".routeheadsignfavorites"; - - // String used to indicate that a route/headsign combination is a favorite for all stops - private static final String ALL_STOPS = "all"; - - /** - * Set the specified route and headsign combination as a favorite, optionally for a specific - * stop. Note that this will also handle the marking/unmarking of the designated route as - * the favorite as well. The route is marked as not a favorite when no more - * routeId/headsign combinations remain. If marking the route/headsign as favorite for - * all stops, then stopId should be null. - * - * @param routeId routeId to be marked as favorite, in combination with headsign - * @param headsign headsign to be marked as favorite, in combination with routeId, or null - * if all headsigns should be marked for this routeId/stopId combo - * @param stopId stopId to be marked as a favorite, or null if all stopIds should be marked - * for this routeId/headsign combo. - * @param favorite true if this route and headsign combination should be marked as a - * favorite, false if it should not - */ - public static void markAsFavorite(Context context, String routeId, String headsign, String - stopId, boolean favorite) { - if (context == null) { - return; - } - final String WHERE; - if (headsign == null) { - WHERE = ROUTE_ID + "=? AND " + STOP_ID + "=?"; - } else { - WHERE = ROUTE_ID + "=? AND " + HEADSIGN + "=? AND " + STOP_ID + "=?"; - } - - ContentResolver cr = context.getContentResolver(); - Uri routeUri = Uri.withAppendedPath(ObaContract.Routes.CONTENT_URI, routeId); - - String stopIdInternal; - if (stopId != null) { - stopIdInternal = stopId; - } else { - stopIdInternal = ALL_STOPS; - } - - final String[] selectionArgs; - if (headsign == null) { - selectionArgs = new String[] {routeId, stopIdInternal}; - } else { - selectionArgs = new String[] {routeId, headsign, stopIdInternal}; - } - - if (favorite) { - if (stopIdInternal != ALL_STOPS) { - // First, delete any potential exclusion records for this stop by removing all records - cr.delete(CONTENT_URI, WHERE, selectionArgs); - } - - // Mark as favorite by inserting a record for this route/headsign combo - ContentValues values = new ContentValues(); - values.put(ROUTE_ID, routeId); - values.put(HEADSIGN, headsign); - values.put(STOP_ID, stopIdInternal); - values.put(EXCLUDE, 0); - cr.insert(CONTENT_URI, values); - - // Mark the route as a favorite also in the routes table - Routes.markAsFavorite(context, routeUri, true); - } else { - // Deselect it as favorite by deleting all records for this route/headsign/stopId combo - cr.delete(CONTENT_URI, WHERE, selectionArgs); - if (stopIdInternal == ALL_STOPS) { - // Also make sure we've deleted the single record for this specific stop, if it exists - // We don't have the stopId here, so we can just delete all records for this routeId/headsign - final String[] selectionArgs2; - final String WHERE2; - if (headsign == null) { - selectionArgs2 = new String[] {routeId}; - WHERE2 = ROUTE_ID + "=?"; - } else { - selectionArgs2 = new String[] {routeId, headsign}; - WHERE2 = ROUTE_ID + "=? AND " + HEADSIGN + "=?"; - } - cr.delete(CONTENT_URI, WHERE2, selectionArgs2); - } - - // If there are no more route/headsign combinations that are favorites for this route, - // then mark the route as not a favorite - if (!isFavorite(context, routeId)) { - Routes.markAsFavorite(context, routeUri, false); - } - - // If a single stop is unstarred, but isFavorite(...) == true due to starring all - // stops, insert exclusion record - if (stopIdInternal != ALL_STOPS && isFavorite(routeId, headsign, stopId)) { - // Insert an exclusion record for this single stop, in case the user is unstarring it - // after starring the entire route - ContentValues values = new ContentValues(); - values.put(ROUTE_ID, routeId); - values.put(HEADSIGN, headsign); - values.put(STOP_ID, stopIdInternal); - values.put(EXCLUDE, 1); - cr.insert(CONTENT_URI, values); - } - } - - StringBuilder analyticsEvent = new StringBuilder(); - if (favorite) { - analyticsEvent.append(context.getString(R.string.analytics_label_star_route)); - } else { - analyticsEvent.append(context.getString(R.string.analytics_label_unstar_route)); - } - StringBuilder analyticsParam = new StringBuilder(); - analyticsParam.append(routeId).append("_").append(headsign).append(" for "); - if (stopId != null) { - analyticsParam.append(stopId); - } else { - analyticsParam.append("all stops"); - } - ObaAnalytics.reportUiEvent(FirebaseAnalytics.getInstance(context), - Application.get().getPlausibleInstance(), - PlausibleAnalytics.REPORT_BOOKMARK_EVENT_URL, - analyticsEvent.toString(), - analyticsParam.toString()); - } - - /** - * Delete all saved route/headsign favorites at once. - */ - public static void clearAllFavorites(Context context) { - ContentResolver cr = context.getContentResolver(); - cr.delete(CONTENT_URI, null, null); - } - - /** - * Returns true if this combination of routeId and headsign is a favorite for this stop - * or all stops (and that stop is not excluded as a favorite), false if it is not - * - * @param routeId The routeId to check for favorite - * @param headsign The headsign to check for favorite - * @param stopId The stopId to check for favorite - * @return true if this combination of routeId and headsign is a favorite for this stop - * or all stops (and that stop is not excluded as a favorite), false if it is not - */ - public static boolean isFavorite(String routeId, String headsign, - String stopId) { - if (headsign == null) { - headsign = ""; - } - final String[] selection = {ROUTE_ID, HEADSIGN, STOP_ID, EXCLUDE}; - final String[] selectionArgs = {routeId, headsign, stopId, Integer.toString(0)}; - ContentResolver cr = Application.get().getContentResolver(); - final String FILTER_WHERE_ALL_FIELDS = ROUTE_ID + "=? AND " + HEADSIGN + "=? AND " - + STOP_ID + "=? AND " + EXCLUDE + "=?"; - Cursor c = cr.query(CONTENT_URI, selection, FILTER_WHERE_ALL_FIELDS, - selectionArgs, null); - boolean favorite; - if (c != null && c.getCount() > 0) { - favorite = true; - } else { - // Check again to see if the user has favorited this route/headsign combo for all stops - final String[] selectionArgs2 = {routeId, headsign, ALL_STOPS}; - String WHERE_PARTIAL = ROUTE_ID + "=? AND " + HEADSIGN + "=? AND " - + STOP_ID + "=?"; - Cursor c2 = cr.query(CONTENT_URI, selection, WHERE_PARTIAL, - selectionArgs2, null); - favorite = c2 != null && c2.getCount() > 0; - if (c2 != null) { - c2.close(); - } - - if (favorite) { - // Finally, make sure the user hasn't excluded this stop as a favorite - final String[] selectionArgs3 = {routeId, headsign, stopId, - Integer.toString(1)}; - Cursor c3 = cr.query(CONTENT_URI, selection, FILTER_WHERE_ALL_FIELDS, - selectionArgs3, null); - // If this query returns at least one record, it means the stop has been excluded as - // a favorite (i.e., the user explicitly de-selected it) - boolean isStopExcluded = c3 != null && c3.getCount() > 0; - favorite = !isStopExcluded; - if (c3 != null) { - c3.close(); - } - } - } - if (c != null) { - c.close(); - } - return favorite; - } - - /** - * Returns true if this routeId is listed as a favorite for at least one headsign with - * EXCLUDED set to false, or false if it is not - * - * @param routeId The routeId to check for favorite - * @return true if this routeId is listed as a favorite for at least one headsign without - * EXCLUDE being set to true, or false if it is not - */ - private static boolean isFavorite(Context context, String routeId) { - final String[] selection = {ROUTE_ID, EXCLUDE}; - final String[] selectionArgs = {routeId, Integer.toString(0)}; - final String WHERE = ROUTE_ID + "=? AND " + EXCLUDE + "=?"; - ContentResolver cr = context.getContentResolver(); - Cursor c = cr.query(CONTENT_URI, selection, WHERE, - selectionArgs, null); - boolean favorite = false; - if (c != null && c.getCount() > 0) { - favorite = true; - } else { - favorite = false; - } - if (c != null) { - c.close(); - } - return favorite; - } - } - - - public static class NavStops implements BaseColumns, NavigationColumns { - - // Cannot be instantiated - private NavStops() { - } - - /** The URI path portion for this table */ - public static final String PATH = "nav_stops"; - - /** The content:// style URI for this table */ - public static final Uri CONTENT_URI = Uri.withAppendedPath( - AUTHORITY_URI, PATH); - - public static final String CONTENT_DIR_TYPE - = "vnd.android.dir/" + BuildConfig.DATABASE_AUTHORITY + ".navstops"; - - public static Uri insert(Context context, Long startTime, Integer navId, Integer seqNum, String tripId, String destId, String beforeId) { - // TODO: Delete there since there's only one active trip. - ContentResolver cr = context.getContentResolver(); - cr.delete(CONTENT_URI, null, null); - ContentValues values = new ContentValues(); - values.put(NAV_ID, navId); - values.put(START_TIME, startTime); - values.put(TRIP_ID, tripId); - values.put(DESTINATION_ID, destId); - values.put(BEFORE_ID, beforeId); - values.put(SEQUENCE, seqNum); - values.put(ACTIVE, 1); - return cr.insert(CONTENT_URI, values); - } - - /** - * Inserts multi-leg trip into database. - * @param context Context. - * @param startTime the startTime in milliseconds for this PathLink instance - * @param navId Navigation ID for destination alert - * @param tripId Trip ID of route. - * @param legs Array of 2-string arrays, first element being stopId of second-to-last - * stop and second element being stopid of destination stop of leg. - * @return - */ - public static boolean insert(Context context, Long startTime, Integer navId, String tripId, String[][] legs) - { - ContentResolver cr = context.getContentResolver(); - // TODO: Delete there since there's only one active trip atm. - cr.delete(CONTENT_URI, null, null); - - // If inner array doesn't contain 2 values, can't insert. - if (legs.length > 1 && legs[0].length < 2) { - return false; - } - - for (int i = 0; i < legs.length;i++) { - ContentValues values = new ContentValues(); - values.put(NAV_ID, navId); - values.put(START_TIME, startTime); - values.put(TRIP_ID, tripId); - values.put(BEFORE_ID, legs[i][0]); - values.put(DESTINATION_ID, legs[i][1]); - values.put(SEQUENCE, i+1); - values.put(ACTIVE, 1); - } - return true; - } - - public static boolean update(Context context, Uri uri, boolean active) - { - ContentResolver cr = context.getContentResolver(); - ContentValues values = new ContentValues(); - values.put(ACTIVE, active ? 1 : 0); - return cr.update(uri, values, null, null) > 0; - } - - public static String[] getDetails(Context context, String id) { - final String[] PROJECTION = { - TRIP_ID, - DESTINATION_ID, - BEFORE_ID - }; - - ContentResolver cr = context.getContentResolver(); - Cursor c = cr.query(CONTENT_URI, PROJECTION, NAV_ID + "=?",new String[] { id }, null); - if (c != null) { - try { - if (c.getCount() == 0) { - return null; - } - c.moveToFirst(); - return new String[] { c.getString(0), c.getString(1), c.getString(2) }; - } finally { - c.close(); - } - } - return null; - } - - public static PathLink[] get(Context context, String navId) - { - final String[] PROJECTION = { - TRIP_ID, - DESTINATION_ID, - BEFORE_ID, - START_TIME - }; - ContentResolver cr = context.getContentResolver(); - Cursor c = cr.query(CONTENT_URI, PROJECTION, NAV_ID + "=?", new String[]{navId}, SEQUENCE + " ASC"); - if (c != null) { - try { - PathLink[] results = new PathLink[c.getCount()]; - if (c.getCount() == 0) { - return results; - } - - int i = 0; - c.moveToFirst(); - do { - results[i] = new PathLink(c.getLong(3), - null, Stops.getLocation(context, c.getString(2)), - Stops.getLocation(context, c.getString(1)), - c.getString(0) - ); - i++; - } while (c.moveToNext()); - - return results; - } finally { - c.close(); - } - } - return null; - } - } -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/provider/ObaProvider.java b/onebusaway-android/src/main/java/org/onebusaway/android/provider/ObaProvider.java deleted file mode 100644 index 06a4499734..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/provider/ObaProvider.java +++ /dev/null @@ -1,1235 +0,0 @@ -/* - * Copyright (C) 2010-2017 Paul Watts (paulcwatts@gmail.com), - * University of South Florida (sjbarbeau@gmail.com), - * Microsoft Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.onebusaway.android.provider; - -import org.onebusaway.android.BuildConfig; - -import android.content.ContentProvider; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Context; -import android.content.UriMatcher; -import android.database.Cursor; -import android.database.DatabaseUtils; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteException; -import android.database.sqlite.SQLiteOpenHelper; -import android.database.sqlite.SQLiteQueryBuilder; -import android.net.Uri; -import android.util.Log; - -import java.io.File; -import java.util.HashMap; -import java.util.List; - -public class ObaProvider extends ContentProvider { - - public static final String TAG = "ObaProvider"; - - /** - * The database name cannot be changed. It needs to remain the same to support backwards - * compatibility with existing installed apps - */ - private static final String DATABASE_NAME = BuildConfig.APPLICATION_ID + ".db"; - - private class OpenHelper extends SQLiteOpenHelper { - - private static final int DATABASE_VERSION = 34; - - public OpenHelper(Context context) { - super(context, DATABASE_NAME, null, DATABASE_VERSION); - } - - @Override - public void onCreate(SQLiteDatabase db) { - bootstrapDatabase(db); - onUpgrade(db, 12, DATABASE_VERSION); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - Log.d(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion); - if (oldVersion < 12) { - dropTables(db); - bootstrapDatabase(db); - oldVersion = 12; - } - if (oldVersion == 12) { - db.execSQL( - "CREATE TABLE " + - ObaContract.StopRouteFilters.PATH + " (" + - ObaContract.StopRouteFilters.STOP_ID + " VARCHAR NOT NULL, " + - ObaContract.StopRouteFilters.ROUTE_ID + " VARCHAR NOT NULL" + - ");"); - ++oldVersion; - } - if (oldVersion == 13) { - db.execSQL( - "ALTER TABLE " + ObaContract.Stops.PATH + - " ADD COLUMN " + ObaContract.Stops.USER_NAME); - db.execSQL( - "ALTER TABLE " + ObaContract.Stops.PATH + - " ADD COLUMN " + ObaContract.Stops.ACCESS_TIME); - db.execSQL( - "ALTER TABLE " + ObaContract.Stops.PATH + - " ADD COLUMN " + ObaContract.Stops.FAVORITE); - // These are being added to the routes database as well, - // even though some of them aren't accessible though the UI yet - // (we don't allow people to rename routes) - db.execSQL( - "ALTER TABLE " + ObaContract.Routes.PATH + - " ADD COLUMN " + ObaContract.Routes.USER_NAME); - db.execSQL( - "ALTER TABLE " + ObaContract.Routes.PATH + - " ADD COLUMN " + ObaContract.Routes.ACCESS_TIME); - db.execSQL( - "ALTER TABLE " + ObaContract.Routes.PATH + - " ADD COLUMN " + ObaContract.Routes.FAVORITE); - ++oldVersion; - } - if (oldVersion == 14) { - db.execSQL( - "ALTER TABLE " + ObaContract.Routes.PATH + - " ADD COLUMN " + ObaContract.Routes.URL); - ++oldVersion; - } - if (oldVersion == 15) { - db.execSQL( - "CREATE TABLE " + - ObaContract.TripAlerts.PATH + " (" + - ObaContract.TripAlerts._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " - + - ObaContract.TripAlerts.TRIP_ID + " VARCHAR NOT NULL, " + - ObaContract.TripAlerts.STOP_ID + " VARCHAR NOT NULL, " + - ObaContract.TripAlerts.START_TIME + " INTEGER NOT NULL, " + - ObaContract.TripAlerts.STATE + " INTEGER NOT NULL DEFAULT " + - ObaContract.TripAlerts.STATE_SCHEDULED + - ");"); - - db.execSQL("DROP TRIGGER IF EXISTS trip_alerts_cleanup"); - db.execSQL( - "CREATE TRIGGER trip_alerts_cleanup DELETE ON " + ObaContract.Trips.PATH + - " BEGIN " + - "DELETE FROM " + ObaContract.TripAlerts.PATH + - " WHERE " + ObaContract.TripAlerts.TRIP_ID + " = old." - + ObaContract.Trips._ID + - " AND " + ObaContract.TripAlerts.STOP_ID + " = old." - + ObaContract.Trips.STOP_ID + - ";" + - "END"); - ++oldVersion; - } - if (oldVersion == 16) { - db.execSQL( - "CREATE TABLE " + - ObaContract.ServiceAlerts.PATH + " (" + - ObaContract.ServiceAlerts._ID + " VARCHAR PRIMARY KEY, " + - ObaContract.ServiceAlerts.MARKED_READ_TIME + " INTEGER " + - ");"); - ++oldVersion; - } - if (oldVersion == 17) { - db.execSQL( - "CREATE TABLE " + - ObaContract.Regions.PATH + " (" + - ObaContract.Regions._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - ObaContract.Regions.NAME + " VARCHAR NOT NULL, " + - ObaContract.Regions.OBA_BASE_URL + " VARCHAR NOT NULL, " + - ObaContract.Regions.SIRI_BASE_URL + " VARCHAR NOT NULL, " + - ObaContract.Regions.LANGUAGE + " VARCHAR NOT NULL, " + - ObaContract.Regions.CONTACT_EMAIL + " VARCHAR NOT NULL, " + - ObaContract.Regions.SUPPORTS_OBA_DISCOVERY + " INTEGER NOT NULL, " + - ObaContract.Regions.SUPPORTS_OBA_REALTIME + " INTEGER NOT NULL, " + - ObaContract.Regions.SUPPORTS_SIRI_REALTIME + " INTEGER NOT NULL " + - ");"); - db.execSQL( - "CREATE TABLE " + - ObaContract.RegionBounds.PATH + " (" + - ObaContract.RegionBounds._ID - + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - ObaContract.RegionBounds.REGION_ID + " INTEGER NOT NULL, " + - ObaContract.RegionBounds.LATITUDE + " REAL NOT NULL, " + - ObaContract.RegionBounds.LONGITUDE + " REAL NOT NULL, " + - ObaContract.RegionBounds.LAT_SPAN + " REAL NOT NULL, " + - ObaContract.RegionBounds.LON_SPAN + " REAL NOT NULL " + - ");"); - db.execSQL("DROP TRIGGER IF EXISTS region_bounds_cleanup"); - db.execSQL( - "CREATE TRIGGER region_bounds_cleanup DELETE ON " + ObaContract.Regions.PATH - + - " BEGIN " + - "DELETE FROM " + ObaContract.RegionBounds.PATH + - " WHERE " + ObaContract.RegionBounds.REGION_ID + " = old." - + ObaContract.Regions._ID + - ";" + - "END"); - db.execSQL( - "ALTER TABLE " + ObaContract.Stops.PATH + - " ADD COLUMN " + ObaContract.Stops.REGION_ID + " INTEGER"); - db.execSQL( - "ALTER TABLE " + ObaContract.Routes.PATH + - " ADD COLUMN " + ObaContract.Routes.REGION_ID + " INTEGER"); - ++oldVersion; - } - if (oldVersion == 18) { - db.execSQL( - "ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.TWITTER_URL + " VARCHAR"); - ++oldVersion; - } - if (oldVersion == 19) { - db.execSQL( - "ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.EXPERIMENTAL + " INTEGER"); - ++oldVersion; - } - if (oldVersion == 20) { - db.execSQL( - "ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.STOP_INFO_URL + " VARCHAR"); - ++oldVersion; - } - if (oldVersion == 21) { - db.execSQL( - "CREATE TABLE " + - ObaContract.RouteHeadsignFavorites.PATH + " (" + - ObaContract.RouteHeadsignFavorites.ROUTE_ID + " VARCHAR NOT NULL, " - + - ObaContract.RouteHeadsignFavorites.HEADSIGN + " VARCHAR NOT NULL, " - + - ObaContract.RouteHeadsignFavorites.STOP_ID + " VARCHAR NOT NULL, " + - ObaContract.RouteHeadsignFavorites.EXCLUDE + " INTEGER NOT NULL " + - ");"); - ++oldVersion; - } - if (oldVersion == 22) { - db.execSQL( - "CREATE TABLE " + - ObaContract.RegionOpen311Servers.PATH + " (" + - ObaContract.RegionOpen311Servers._ID - + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - ObaContract.RegionOpen311Servers.REGION_ID + " INTEGER NOT NULL, " + - ObaContract.RegionOpen311Servers.JURISDICTION + " VARCHAR, " + - ObaContract.RegionOpen311Servers.API_KEY + " VARCHAR NOT NULL, " + - ObaContract.RegionOpen311Servers.BASE_URL + " VARCHAR NOT NULL " + - ");"); - ++oldVersion; - } - if (oldVersion == 23) { - db.execSQL( - "ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.OTP_BASE_URL + " VARCHAR"); - db.execSQL( - "ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.OTP_CONTACT_EMAIL + " VARCHAR"); - ++oldVersion; - } - if (oldVersion == 24) { - db.execSQL( - "ALTER TABLE " + ObaContract.ServiceAlerts.PATH + - " ADD COLUMN " + ObaContract.ServiceAlerts.HIDDEN + " INTEGER"); - ++oldVersion; - } - if (oldVersion == 25) { - db.execSQL( - "ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.SUPPORTS_OTP_BIKESHARE + " INTEGER"); - ++oldVersion; - } - if (oldVersion == 26) { - /** - * Bike share and Embedded Social both added columns in version 26; - * Bike share in Beta, Embedded Social in alpha - * We are adding extra logic here to prevent either group from breaking on update - */ - try { - db.execSQL( - "ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.SUPPORTS_OTP_BIKESHARE + " INTEGER"); - } catch (SQLiteException e) { - Log.w(TAG, "Database already has bike share column - " + e); - } - - try { - db.execSQL( - "ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.SUPPORTS_EMBEDDED_SOCIAL + " INTEGER"); - } catch (SQLiteException e) { - Log.w(TAG, "Database already has embedded social column - " + e); - } - ++oldVersion; - } - if (oldVersion == 27) { - db.execSQL("ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.PAYMENT_ANDROID_APP_ID + " VARCHAR"); - db.execSQL("ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.PAYMENT_WARNING_TITLE + " VARCHAR"); - db.execSQL("ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.PAYMENT_WARNING_BODY + " VARCHAR"); - ++oldVersion; - } - if (oldVersion == 28) { - db.execSQL( - "CREATE TABLE " + - ObaContract.NavStops.PATH + " (" + - ObaContract.NavStops._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " - + ObaContract.NavStops.NAV_ID + " VARCHAR NOT NULL, " - + ObaContract.NavStops.START_TIME + " INTEGER NOT NULL, " - + ObaContract.NavStops.TRIP_ID + " VARCHAR NOT NULL, " - + ObaContract.NavStops.DESTINATION_ID + " VARCHAR NOT NULL, " - + ObaContract.NavStops.BEFORE_ID + " VARCHAR NOT NULL, " - + ObaContract.NavStops.SEQUENCE + " INTEGER NOT NULL, " - + ObaContract.NavStops.ACTIVE + " INTEGER NOT NULL " + - ");" - ); - ++oldVersion; - } - - if (oldVersion == 29) { - db.execSQL("ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.TRAVEL_BEHAVIOR_DATA_COLLECTION + " INTEGER"); - db.execSQL("ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.ENROLL_PARTICIPANTS_IN_STUDY + " INTEGER"); - ++oldVersion; - } - - if (oldVersion == 30) { - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.Trips.PATH); - createTripsTable(db); - ++oldVersion; - } - - if(oldVersion == 31) { - db.execSQL("ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.SIDECAR_BASE_URL + " VARCHAR DEFAULT 'https://onebusaway.co'"); - ++oldVersion; - } - if (oldVersion == 32){ - db.execSQL("ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.PLAUSIBLE_ANALYTICS_SERVER_URL + " VARCHAR DEFAULT NULL"); - ++oldVersion; - } - if (oldVersion == 33){ - db.execSQL("ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.UMAMI_ANALYTICS_URL + " VARCHAR DEFAULT NULL"); - db.execSQL("ALTER TABLE " + ObaContract.Regions.PATH + - " ADD COLUMN " + ObaContract.Regions.UMAMI_ANALYTICS_ID + " VARCHAR DEFAULT NULL"); - ++oldVersion; - } - } - - @Override - public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { - /** - * See #473 - This can happen if the user does a backup of stops/routes, updates to a - * new version of the app that includes a database upgrade, and then tries to restore - * the backup. This is because our current backup/restore mechanism is simply copying - * the database. In this case, we should try to make sure the schema is current so no - * code fails on missing tables or columns. - */ - Log.d(TAG, "Downgrading database from version " + oldVersion + " to " + newVersion); - onUpgrade(db, newVersion, newVersion); - } - - private void bootstrapDatabase(SQLiteDatabase db) { - db.execSQL( - "CREATE TABLE " + - ObaContract.Stops.PATH + " (" + - ObaContract.Stops._ID + " VARCHAR PRIMARY KEY, " + - ObaContract.Stops.CODE + " VARCHAR NOT NULL, " + - ObaContract.Stops.NAME + " VARCHAR NOT NULL, " + - ObaContract.Stops.DIRECTION + " CHAR[2] NOT NULL," + - ObaContract.Stops.USE_COUNT + " INTEGER NOT NULL," + - ObaContract.Stops.LATITUDE + " DOUBLE NOT NULL," + - ObaContract.Stops.LONGITUDE + " DOUBLE NOT NULL" + - ");"); - db.execSQL( - "CREATE TABLE " + - ObaContract.Routes.PATH + " (" + - ObaContract.Routes._ID + " VARCHAR PRIMARY KEY, " + - ObaContract.Routes.SHORTNAME + " VARCHAR NOT NULL, " + - ObaContract.Routes.LONGNAME + " VARCHAR, " + - ObaContract.Routes.USE_COUNT + " INTEGER NOT NULL" + - ");"); - - createTripsTable(db); - } - - private void createTripsTable(SQLiteDatabase db){ - db.execSQL( - "CREATE TABLE " + - ObaContract.Trips.PATH + " (" + - ObaContract.Trips._ID + " VARCHAR NOT NULL, " + - ObaContract.Trips.STOP_ID + " VARCHAR NOT NULL, " + - ObaContract.Trips.ROUTE_ID + " VARCHAR NOT NULL, " + - ObaContract.Trips.DEPARTURE + " INTEGER NOT NULL, " + - ObaContract.Trips.HEADSIGN + " VARCHAR NOT NULL, " + - ObaContract.Trips.NAME + " VARCHAR NOT NULL, " + - ObaContract.Trips.REMINDER + " INTEGER NOT NULL, " + - ObaContract.Trips.ALARM_DELETE_PATH + " VARCHAR NOT NULL ," + - ObaContract.Trips.SERVICE_DATE + " INTEGER NOT NULL ," + - ObaContract.Trips.STOP_SEQUENCE + " INTEGER NOT NULL ," + - ObaContract.Trips.TRIP_ID + " VARCHAR NOT NULL ," + - ObaContract.Trips.VEHICLE_ID + " VARCHAR NOT NULL " + - ");"); - } - - private void dropTables(SQLiteDatabase db) { - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.StopRouteFilters.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.Routes.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.Stops.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.Trips.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.TripAlerts.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.ServiceAlerts.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.Regions.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.RegionBounds.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.RegionOpen311Servers.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.RouteHeadsignFavorites.PATH); - db.execSQL("DROP TABLE IF EXISTS " + ObaContract.NavStops.PATH); - } - } - - private static final int STOPS = 1; - - private static final int STOPS_ID = 2; - - private static final int ROUTES = 3; - - private static final int ROUTES_ID = 4; - - private static final int TRIPS = 5; - - private static final int TRIPS_ID = 6; - - private static final int TRIP_ALERTS = 7; - - private static final int TRIP_ALERTS_ID = 8; - - private static final int STOP_ROUTE_FILTERS = 9; - - private static final int SERVICE_ALERTS = 10; - - private static final int SERVICE_ALERTS_ID = 11; - - private static final int REGIONS = 12; - - private static final int REGIONS_ID = 13; - - private static final int REGION_BOUNDS = 14; - - private static final int REGION_BOUNDS_ID = 15; - - private static final int ROUTE_HEADSIGN_FAVORITES = 16; - - private static final int REGION_OPEN311_SERVERS = 17; - - private static final int REGION_OPEN311_SERVERS_ID = 18; - - private static final int NAV_STOPS = 19; - - private static final UriMatcher sUriMatcher; - - private static final HashMap sStopsProjectionMap; - - private static final HashMap sRoutesProjectionMap; - - private static final HashMap sTripsProjectionMap; - - private static final HashMap sTripAlertsProjectionMap; - - private static final HashMap sServiceAlertsProjectionMap; - - private static final HashMap sRegionsProjectionMap; - - private static final HashMap sRegionBoundsProjectionMap; - - private static final HashMap sRegionOpen311ProjectionMap; - - // Insert helpers are useful. - private DatabaseUtils.InsertHelper mStopsInserter; - - private DatabaseUtils.InsertHelper mRoutesInserter; - - private DatabaseUtils.InsertHelper mTripsInserter; - - private DatabaseUtils.InsertHelper mTripAlertsInserter; - - private DatabaseUtils.InsertHelper mFilterInserter; - - private DatabaseUtils.InsertHelper mServiceAlertsInserter; - - private DatabaseUtils.InsertHelper mRegionsInserter; - - private DatabaseUtils.InsertHelper mRegionBoundsInserter; - - private DatabaseUtils.InsertHelper mRegionOpen311ServersInserter; - - private DatabaseUtils.InsertHelper mRouteHeadsignFavoritesInserter; - - private DatabaseUtils.InsertHelper mNavStopsInserter; - - static { - sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.Stops.PATH, STOPS); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.Stops.PATH + "/*", STOPS_ID); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.Routes.PATH, ROUTES); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.Routes.PATH + "/*", ROUTES_ID); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.Trips.PATH, TRIPS); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.Trips.PATH + "/*/*", TRIPS_ID); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.TripAlerts.PATH, TRIP_ALERTS); - sUriMatcher - .addURI(ObaContract.AUTHORITY, ObaContract.TripAlerts.PATH + "/#", TRIP_ALERTS_ID); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.StopRouteFilters.PATH, - STOP_ROUTE_FILTERS); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.ServiceAlerts.PATH, SERVICE_ALERTS); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.ServiceAlerts.PATH + "/*", - SERVICE_ALERTS_ID); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.Regions.PATH, REGIONS); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.Regions.PATH + "/#", REGIONS_ID); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.RegionBounds.PATH, REGION_BOUNDS); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.RegionBounds.PATH + "/#", - REGION_BOUNDS_ID); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.RegionOpen311Servers.PATH, REGION_OPEN311_SERVERS); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.RegionOpen311Servers.PATH + "/#", - REGION_OPEN311_SERVERS_ID); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.RouteHeadsignFavorites.PATH, - ROUTE_HEADSIGN_FAVORITES); - sUriMatcher.addURI(ObaContract.AUTHORITY, ObaContract.NavStops.PATH, NAV_STOPS); - - sStopsProjectionMap = new HashMap(); - sStopsProjectionMap.put(ObaContract.Stops._ID, ObaContract.Stops._ID); - sStopsProjectionMap.put(ObaContract.Stops.CODE, ObaContract.Stops.CODE); - sStopsProjectionMap.put(ObaContract.Stops.NAME, ObaContract.Stops.NAME); - sStopsProjectionMap.put(ObaContract.Stops.DIRECTION, ObaContract.Stops.DIRECTION); - sStopsProjectionMap.put(ObaContract.Stops.USE_COUNT, ObaContract.Stops.USE_COUNT); - sStopsProjectionMap.put(ObaContract.Stops.LATITUDE, ObaContract.Stops.LATITUDE); - sStopsProjectionMap.put(ObaContract.Stops.LONGITUDE, ObaContract.Stops.LONGITUDE); - sStopsProjectionMap.put(ObaContract.Stops.USER_NAME, ObaContract.Stops.USER_NAME); - sStopsProjectionMap.put(ObaContract.Stops.ACCESS_TIME, ObaContract.Stops.ACCESS_TIME); - sStopsProjectionMap.put(ObaContract.Stops.FAVORITE, ObaContract.Stops.FAVORITE); - sStopsProjectionMap.put(ObaContract.Stops._COUNT, "count(*)"); - sStopsProjectionMap.put(ObaContract.Stops.UI_NAME, - "CASE WHEN " + ObaContract.Stops.USER_NAME + " IS NOT NULL THEN " + - ObaContract.Stops.USER_NAME + " ELSE " + - ObaContract.Stops.NAME + " END AS " + - ObaContract.Stops.UI_NAME); - - sRoutesProjectionMap = new HashMap(); - sRoutesProjectionMap.put(ObaContract.Routes._ID, ObaContract.Routes._ID); - sRoutesProjectionMap.put(ObaContract.Routes.SHORTNAME, ObaContract.Routes.SHORTNAME); - sRoutesProjectionMap.put(ObaContract.Routes.LONGNAME, ObaContract.Routes.LONGNAME); - sRoutesProjectionMap.put(ObaContract.Routes.USE_COUNT, ObaContract.Routes.USE_COUNT); - sRoutesProjectionMap.put(ObaContract.Routes.USER_NAME, ObaContract.Routes.USER_NAME); - sRoutesProjectionMap.put(ObaContract.Routes.ACCESS_TIME, ObaContract.Routes.ACCESS_TIME); - sRoutesProjectionMap.put(ObaContract.Routes.FAVORITE, ObaContract.Routes.FAVORITE); - sRoutesProjectionMap.put(ObaContract.Routes.URL, ObaContract.Routes.URL); - sRoutesProjectionMap.put(ObaContract.Routes._COUNT, "count(*)"); - - sTripsProjectionMap = new HashMap(); - sTripsProjectionMap.put(ObaContract.Trips._ID, ObaContract.Trips._ID); - sTripsProjectionMap.put(ObaContract.Trips.STOP_ID, ObaContract.Trips.STOP_ID); - sTripsProjectionMap.put(ObaContract.Trips.ROUTE_ID, ObaContract.Trips.ROUTE_ID); - sTripsProjectionMap.put(ObaContract.Trips.DEPARTURE, ObaContract.Trips.DEPARTURE); - sTripsProjectionMap.put(ObaContract.Trips.HEADSIGN, ObaContract.Trips.HEADSIGN); - sTripsProjectionMap.put(ObaContract.Trips.NAME, ObaContract.Trips.NAME); - sTripsProjectionMap.put(ObaContract.Trips.REMINDER, ObaContract.Trips.REMINDER); - sTripsProjectionMap.put(ObaContract.Trips.ALARM_DELETE_PATH, ObaContract.Trips.ALARM_DELETE_PATH); - sTripsProjectionMap.put(ObaContract.Trips.TRIP_ID, ObaContract.Trips.TRIP_ID); - sTripsProjectionMap.put(ObaContract.Trips.STOP_SEQUENCE, ObaContract.Trips.STOP_SEQUENCE); - sTripsProjectionMap.put(ObaContract.Trips.SERVICE_DATE, ObaContract.Trips.SERVICE_DATE); - sTripsProjectionMap.put(ObaContract.Trips.VEHICLE_ID, ObaContract.Trips.VEHICLE_ID); - - sTripsProjectionMap.put(ObaContract.Trips._COUNT, "count(*)"); - - sTripAlertsProjectionMap = new HashMap(); - sTripAlertsProjectionMap.put(ObaContract.TripAlerts._ID, ObaContract.TripAlerts._ID); - sTripAlertsProjectionMap - .put(ObaContract.TripAlerts.TRIP_ID, ObaContract.TripAlerts.TRIP_ID); - sTripAlertsProjectionMap - .put(ObaContract.TripAlerts.STOP_ID, ObaContract.TripAlerts.STOP_ID); - sTripAlertsProjectionMap - .put(ObaContract.TripAlerts.START_TIME, ObaContract.TripAlerts.START_TIME); - sTripAlertsProjectionMap.put(ObaContract.TripAlerts.STATE, ObaContract.TripAlerts.STATE); - sTripAlertsProjectionMap.put(ObaContract.TripAlerts._COUNT, "count(*)"); - - sServiceAlertsProjectionMap = new HashMap(); - sServiceAlertsProjectionMap - .put(ObaContract.ServiceAlerts._ID, ObaContract.ServiceAlerts._ID); - sServiceAlertsProjectionMap.put(ObaContract.ServiceAlerts.MARKED_READ_TIME, - ObaContract.ServiceAlerts.MARKED_READ_TIME); - sServiceAlertsProjectionMap.put(ObaContract.ServiceAlerts.HIDDEN, - ObaContract.ServiceAlerts.HIDDEN); - - sRegionsProjectionMap = new HashMap(); - sRegionsProjectionMap.put(ObaContract.Regions._ID, ObaContract.Regions._ID); - sRegionsProjectionMap.put(ObaContract.Regions.NAME, ObaContract.Regions.NAME); - sRegionsProjectionMap - .put(ObaContract.Regions.OBA_BASE_URL, ObaContract.Regions.OBA_BASE_URL); - sRegionsProjectionMap - .put(ObaContract.Regions.SIRI_BASE_URL, ObaContract.Regions.SIRI_BASE_URL); - sRegionsProjectionMap.put(ObaContract.Regions.LANGUAGE, ObaContract.Regions.LANGUAGE); - sRegionsProjectionMap - .put(ObaContract.Regions.CONTACT_EMAIL, ObaContract.Regions.CONTACT_EMAIL); - sRegionsProjectionMap.put(ObaContract.Regions.SUPPORTS_OBA_DISCOVERY, - ObaContract.Regions.SUPPORTS_OBA_DISCOVERY); - sRegionsProjectionMap.put(ObaContract.Regions.SUPPORTS_OBA_REALTIME, - ObaContract.Regions.SUPPORTS_OBA_REALTIME); - sRegionsProjectionMap.put(ObaContract.Regions.SUPPORTS_SIRI_REALTIME, - ObaContract.Regions.SUPPORTS_SIRI_REALTIME); - sRegionsProjectionMap.put(ObaContract.Regions.SUPPORTS_EMBEDDED_SOCIAL, - ObaContract.Regions.SUPPORTS_EMBEDDED_SOCIAL); - sRegionsProjectionMap.put(ObaContract.Regions.TWITTER_URL, ObaContract.Regions.TWITTER_URL); - sRegionsProjectionMap - .put(ObaContract.Regions.EXPERIMENTAL, ObaContract.Regions.EXPERIMENTAL); - sRegionsProjectionMap - .put(ObaContract.Regions.STOP_INFO_URL, ObaContract.Regions.STOP_INFO_URL); - sRegionsProjectionMap - .put(ObaContract.Regions.OTP_BASE_URL, ObaContract.Regions.OTP_BASE_URL); - sRegionsProjectionMap - .put(ObaContract.Regions.OTP_CONTACT_EMAIL, ObaContract.Regions.OTP_CONTACT_EMAIL); - sRegionsProjectionMap - .put(ObaContract.Regions.SUPPORTS_OTP_BIKESHARE, ObaContract.Regions.SUPPORTS_OTP_BIKESHARE); - sRegionsProjectionMap - .put(ObaContract.Regions.PAYMENT_ANDROID_APP_ID, ObaContract.Regions.PAYMENT_ANDROID_APP_ID); - sRegionsProjectionMap - .put(ObaContract.Regions.PAYMENT_WARNING_TITLE, ObaContract.Regions.PAYMENT_WARNING_TITLE); - sRegionsProjectionMap - .put(ObaContract.Regions.PAYMENT_WARNING_BODY, ObaContract.Regions.PAYMENT_WARNING_BODY); - sRegionsProjectionMap - .put(ObaContract.Regions.TRAVEL_BEHAVIOR_DATA_COLLECTION, ObaContract.Regions.TRAVEL_BEHAVIOR_DATA_COLLECTION); - sRegionsProjectionMap - .put(ObaContract.Regions.ENROLL_PARTICIPANTS_IN_STUDY, ObaContract.Regions.ENROLL_PARTICIPANTS_IN_STUDY); - - sRegionBoundsProjectionMap = new HashMap(); - sRegionBoundsProjectionMap.put(ObaContract.RegionBounds._ID, ObaContract.RegionBounds._ID); - sRegionBoundsProjectionMap - .put(ObaContract.RegionBounds.REGION_ID, ObaContract.RegionBounds.REGION_ID); - sRegionBoundsProjectionMap - .put(ObaContract.RegionBounds.LATITUDE, ObaContract.RegionBounds.LATITUDE); - sRegionBoundsProjectionMap - .put(ObaContract.RegionBounds.LONGITUDE, ObaContract.RegionBounds.LONGITUDE); - sRegionBoundsProjectionMap - .put(ObaContract.RegionBounds.LAT_SPAN, ObaContract.RegionBounds.LAT_SPAN); - sRegionBoundsProjectionMap - .put(ObaContract.RegionBounds.LON_SPAN, ObaContract.RegionBounds.LON_SPAN); - - sRegionOpen311ProjectionMap = new HashMap(); - sRegionOpen311ProjectionMap - .put(ObaContract.RegionOpen311Servers._ID, ObaContract.RegionOpen311Servers._ID); - sRegionOpen311ProjectionMap - .put(ObaContract.RegionOpen311Servers.REGION_ID, ObaContract.RegionOpen311Servers.REGION_ID); - sRegionOpen311ProjectionMap - .put(ObaContract.RegionOpen311Servers.JURISDICTION, ObaContract.RegionOpen311Servers.JURISDICTION); - sRegionOpen311ProjectionMap - .put(ObaContract.RegionOpen311Servers.API_KEY, ObaContract.RegionOpen311Servers.API_KEY); - sRegionOpen311ProjectionMap - .put(ObaContract.RegionOpen311Servers.BASE_URL, ObaContract.RegionOpen311Servers.BASE_URL); - sRegionsProjectionMap - .put(ObaContract.Regions.SIDECAR_BASE_URL, ObaContract.Regions.SIDECAR_BASE_URL); - sRegionsProjectionMap - .put(ObaContract.Regions.PLAUSIBLE_ANALYTICS_SERVER_URL, ObaContract.Regions.PLAUSIBLE_ANALYTICS_SERVER_URL); - sRegionsProjectionMap - .put(ObaContract.Regions.UMAMI_ANALYTICS_URL, ObaContract.Regions.UMAMI_ANALYTICS_URL); - sRegionsProjectionMap - .put(ObaContract.Regions.UMAMI_ANALYTICS_ID, ObaContract.Regions.UMAMI_ANALYTICS_ID); - } - - private SQLiteDatabase mDb; - - private OpenHelper mOpenHelper; - - public static File getDatabasePath(Context context) { - return context.getDatabasePath(DATABASE_NAME); - } - - @Override - public boolean onCreate() { - mOpenHelper = new OpenHelper(getContext()); - return true; - } - - @Override - public String getType(Uri uri) { - int match = sUriMatcher.match(uri); - switch (match) { - case STOPS: - return ObaContract.Stops.CONTENT_DIR_TYPE; - case STOPS_ID: - return ObaContract.Stops.CONTENT_TYPE; - case ROUTES: - return ObaContract.Routes.CONTENT_DIR_TYPE; - case ROUTES_ID: - return ObaContract.Routes.CONTENT_TYPE; - case TRIPS: - return ObaContract.Trips.CONTENT_DIR_TYPE; - case TRIPS_ID: - return ObaContract.Trips.CONTENT_TYPE; - case TRIP_ALERTS: - return ObaContract.TripAlerts.CONTENT_DIR_TYPE; - case TRIP_ALERTS_ID: - return ObaContract.TripAlerts.CONTENT_TYPE; - case STOP_ROUTE_FILTERS: - return ObaContract.StopRouteFilters.CONTENT_DIR_TYPE; - case SERVICE_ALERTS: - return ObaContract.ServiceAlerts.CONTENT_DIR_TYPE; - case SERVICE_ALERTS_ID: - return ObaContract.ServiceAlerts.CONTENT_TYPE; - case REGIONS: - return ObaContract.Regions.CONTENT_DIR_TYPE; - case REGIONS_ID: - return ObaContract.Regions.CONTENT_TYPE; - case REGION_BOUNDS: - return ObaContract.RegionBounds.CONTENT_DIR_TYPE; - case REGION_BOUNDS_ID: - return ObaContract.RegionBounds.CONTENT_TYPE; - case REGION_OPEN311_SERVERS: - return ObaContract.RegionOpen311Servers.CONTENT_DIR_TYPE; - case REGION_OPEN311_SERVERS_ID: - return ObaContract.RegionOpen311Servers.CONTENT_TYPE; - case ROUTE_HEADSIGN_FAVORITES: - return ObaContract.RouteHeadsignFavorites.CONTENT_DIR_TYPE; - case NAV_STOPS: - return ObaContract.NavStops.CONTENT_DIR_TYPE; - default: - throw new IllegalArgumentException("Unknown URI: " + uri); - } - } - - @Override - public Uri insert(Uri uri, ContentValues values) { - final SQLiteDatabase db = getDatabase(); - db.beginTransaction(); - try { - Uri result = insertInternal(db, uri, values); - getContext().getContentResolver().notifyChange(uri, null); - db.setTransactionSuccessful(); - return result; - } finally { - db.endTransaction(); - } - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, - String[] selectionArgs, String sortOrder) { - final SQLiteDatabase db = getDatabase(); - return queryInternal(db, uri, projection, selection, selectionArgs, sortOrder); - } - - @Override - public int update(Uri uri, ContentValues values, String selection, - String[] selectionArgs) { - final SQLiteDatabase db = getDatabase(); - db.beginTransaction(); - try { - int result = updateInternal(db, uri, values, selection, selectionArgs); - if (result > 0) { - getContext().getContentResolver().notifyChange(uri, null); - } - db.setTransactionSuccessful(); - return result; - } finally { - db.endTransaction(); - } - } - - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - final SQLiteDatabase db = getDatabase(); - db.beginTransaction(); - try { - int result = deleteInternal(db, uri, selection, selectionArgs); - if (result > 0) { - getContext().getContentResolver().notifyChange(uri, null); - } - db.setTransactionSuccessful(); - return result; - } finally { - db.endTransaction(); - } - } - - private Uri insertInternal(SQLiteDatabase db, Uri uri, ContentValues values) { - final int match = sUriMatcher.match(uri); - String id; - Uri result; - long longId; - - switch (match) { - case STOPS: - // Pull out the Stop ID from the values to construct the new URI - // (And we'd better have a stop ID) - id = values.getAsString(ObaContract.Stops._ID); - if (id == null) { - throw new IllegalArgumentException("Need a stop ID to insert! " + uri); - } - result = Uri.withAppendedPath(ObaContract.Stops.CONTENT_URI, id); - mStopsInserter.insert(values); - return result; - - case ROUTES: - // Pull out the Route ID from the values to construct the new URI - // (And we'd better have a route ID) - id = values.getAsString(ObaContract.Routes._ID); - if (id == null) { - throw new IllegalArgumentException("Need a routes ID to insert! " + uri); - } - result = Uri.withAppendedPath(ObaContract.Routes.CONTENT_URI, id); - mRoutesInserter.insert(values); - return result; - - case TRIPS: - // Pull out the Trip ID from the values to construct the new URI - // (And we'd better have a trip ID) - id = values.getAsString(ObaContract.Trips._ID); - if (id == null) { - throw new IllegalArgumentException("Need a trip ID to insert! " + uri); - } - result = Uri.withAppendedPath(ObaContract.Trips.CONTENT_URI, id); - mTripsInserter.insert(values); - return result; - - case TRIP_ALERTS: - longId = mTripAlertsInserter.insert(values); - result = ContentUris.withAppendedId(ObaContract.TripAlerts.CONTENT_URI, longId); - return result; - - case STOP_ROUTE_FILTERS: - // TODO: We should provide a "virtual" column that is an array, - // so clients don't have to call the content provider for each item to insert. - // Pull out the stop ID from the values to construct the new URI - // (And we'd better have a stop ID) - id = values.getAsString(ObaContract.StopRouteFilters.STOP_ID); - if (id == null) { - throw new IllegalArgumentException("Need a stop ID to insert! " + uri); - } - result = Uri.withAppendedPath(ObaContract.StopRouteFilters.CONTENT_URI, id); - mFilterInserter.insert(values); - return result; - - case SERVICE_ALERTS: - // Pull out the Situation ID from the values to construct the new URI - // (And we'd better have a situation ID) - id = values.getAsString(ObaContract.ServiceAlerts._ID); - if (id == null) { - throw new IllegalArgumentException("Need a situation ID to insert! " + uri); - } - result = Uri.withAppendedPath(ObaContract.ServiceAlerts.CONTENT_URI, id); - mServiceAlertsInserter.insert(values); - return result; - - case REGIONS: - longId = mRegionsInserter.insert(values); - result = ContentUris.withAppendedId(ObaContract.Regions.CONTENT_URI, longId); - return result; - - case REGION_BOUNDS: - longId = mRegionBoundsInserter.insert(values); - result = ContentUris.withAppendedId(ObaContract.RegionBounds.CONTENT_URI, longId); - return result; - - case REGION_OPEN311_SERVERS: - longId = mRegionOpen311ServersInserter.insert(values); - result = ContentUris.withAppendedId(ObaContract.RegionOpen311Servers.CONTENT_URI, longId); - return result; - - case ROUTE_HEADSIGN_FAVORITES: - id = values.getAsString(ObaContract.RouteHeadsignFavorites.ROUTE_ID); - if (id == null) { - throw new IllegalArgumentException("Need a route ID to insert! " + uri); - } - result = Uri.withAppendedPath(ObaContract.RouteHeadsignFavorites.CONTENT_URI, id); - mRouteHeadsignFavoritesInserter.insert(values); - return result; - - case NAV_STOPS: - longId = mNavStopsInserter.insert(values); - result = ContentUris.withAppendedId(ObaContract.NavStops.CONTENT_URI, longId); - return result; - - // What would these mean, anyway?? - case STOPS_ID: - case ROUTES_ID: - case TRIPS_ID: - case TRIP_ALERTS_ID: - case SERVICE_ALERTS_ID: - case REGIONS_ID: - case REGION_BOUNDS_ID: - throw new UnsupportedOperationException("Cannot insert to this URI: " + uri); - default: - throw new IllegalArgumentException("Unknown URI: " + uri); - } - } - - private Cursor queryInternal(SQLiteDatabase db, - Uri uri, String[] projection, String selection, - String[] selectionArgs, String sortOrder) { - final int match = sUriMatcher.match(uri); - final String limit = uri.getQueryParameter("limit"); - - SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); - - switch (match) { - case STOPS: - qb.setTables(ObaContract.Stops.PATH); - qb.setProjectionMap(sStopsProjectionMap); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case STOPS_ID: - qb.setTables(ObaContract.Stops.PATH); - qb.setProjectionMap(sStopsProjectionMap); - qb.appendWhere(ObaContract.Stops._ID); - qb.appendWhere("="); - qb.appendWhereEscapeString(uri.getLastPathSegment()); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case ROUTES: - qb.setTables(ObaContract.Routes.PATH); - qb.setProjectionMap(sRoutesProjectionMap); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case ROUTES_ID: - qb.setTables(ObaContract.Routes.PATH); - qb.setProjectionMap(sRoutesProjectionMap); - qb.appendWhere(ObaContract.Routes._ID); - qb.appendWhere("="); - qb.appendWhereEscapeString(uri.getLastPathSegment()); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case TRIPS: - qb.setTables(ObaContract.Trips.PATH); - qb.setProjectionMap(sTripsProjectionMap); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case TRIPS_ID: - qb.setTables(ObaContract.Trips.PATH); - qb.setProjectionMap(sTripsProjectionMap); - qb.appendWhere(tripWhere(uri)); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case TRIP_ALERTS: - qb.setTables(ObaContract.TripAlerts.PATH); - qb.setProjectionMap(sTripAlertsProjectionMap); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case TRIP_ALERTS_ID: - qb.setTables(ObaContract.TripAlerts.PATH); - qb.setProjectionMap(sTripAlertsProjectionMap); - qb.appendWhere(ObaContract.TripAlerts._ID); - qb.appendWhere("="); - qb.appendWhere(String.valueOf(ContentUris.parseId(uri))); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case STOP_ROUTE_FILTERS: - qb.setTables(ObaContract.StopRouteFilters.PATH); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case SERVICE_ALERTS: - qb.setTables(ObaContract.ServiceAlerts.PATH); - qb.setProjectionMap(sServiceAlertsProjectionMap); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case SERVICE_ALERTS_ID: - qb.setTables(ObaContract.ServiceAlerts.PATH); - qb.setProjectionMap(sServiceAlertsProjectionMap); - qb.appendWhere(ObaContract.ServiceAlerts._ID); - qb.appendWhere("="); - qb.appendWhereEscapeString(uri.getLastPathSegment()); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case REGIONS: - qb.setTables(ObaContract.Regions.PATH); - qb.setProjectionMap(sRegionsProjectionMap); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case REGIONS_ID: - qb.setTables(ObaContract.Regions.PATH); - qb.setProjectionMap(sRegionsProjectionMap); - qb.appendWhere(ObaContract.Regions._ID); - qb.appendWhere("="); - qb.appendWhere(String.valueOf(ContentUris.parseId(uri))); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case REGION_BOUNDS: - qb.setTables(ObaContract.RegionBounds.PATH); - qb.setProjectionMap(sRegionBoundsProjectionMap); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case REGION_BOUNDS_ID: - qb.setTables(ObaContract.RegionBounds.PATH); - qb.setProjectionMap(sRegionBoundsProjectionMap); - qb.appendWhere(ObaContract.RegionBounds._ID); - qb.appendWhere("="); - qb.appendWhere(String.valueOf(ContentUris.parseId(uri))); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case REGION_OPEN311_SERVERS: - qb.setTables(ObaContract.RegionOpen311Servers.PATH); - qb.setProjectionMap(sRegionOpen311ProjectionMap); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case REGION_OPEN311_SERVERS_ID: - qb.setTables(ObaContract.RegionOpen311Servers.PATH); - qb.setProjectionMap(sRegionOpen311ProjectionMap); - qb.appendWhere(ObaContract.RegionOpen311Servers._ID); - qb.appendWhere("="); - qb.appendWhere(String.valueOf(ContentUris.parseId(uri))); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - - case ROUTE_HEADSIGN_FAVORITES: - qb.setTables(ObaContract.RouteHeadsignFavorites.PATH); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - case NAV_STOPS: - qb.setTables(ObaContract.NavStops.PATH); - return qb.query(mDb, projection, selection, selectionArgs, - null, null, sortOrder, limit); - default: - throw new IllegalArgumentException("Unknown URI: " + uri); - } - } - - private int updateInternal(SQLiteDatabase db, - Uri uri, ContentValues values, String selection, - String[] selectionArgs) { - final int match = sUriMatcher.match(uri); - switch (match) { - case STOPS: - return db.update(ObaContract.Stops.PATH, values, selection, selectionArgs); - - case STOPS_ID: - return db.update(ObaContract.Stops.PATH, values, - where(ObaContract.Stops._ID, uri), selectionArgs); - - case ROUTES: - return db.update(ObaContract.Routes.PATH, values, selection, selectionArgs); - - case ROUTES_ID: - return db.update(ObaContract.Routes.PATH, values, - where(ObaContract.Routes._ID, uri), selectionArgs); - - case TRIPS: - return db.update(ObaContract.Trips.PATH, values, selection, selectionArgs); - - case TRIPS_ID: - return db.update(ObaContract.Trips.PATH, values, tripWhere(uri), selectionArgs); - - case TRIP_ALERTS: - return db.update(ObaContract.TripAlerts.PATH, values, selection, selectionArgs); - - case TRIP_ALERTS_ID: - return db.update(ObaContract.TripAlerts.PATH, values, - whereLong(ObaContract.TripAlerts._ID, uri), selectionArgs); - - // Can we do anything here?? - case STOP_ROUTE_FILTERS: - return 0; - - case SERVICE_ALERTS: - return db.update(ObaContract.ServiceAlerts.PATH, values, selection, selectionArgs); - - case SERVICE_ALERTS_ID: - return db.update(ObaContract.ServiceAlerts.PATH, values, - where(ObaContract.ServiceAlerts._ID, uri), selectionArgs); - - case REGIONS: - return db.update(ObaContract.Regions.PATH, values, selection, selectionArgs); - - case REGIONS_ID: - return db.update(ObaContract.Regions.PATH, values, - whereLong(ObaContract.Regions._ID, uri), selectionArgs); - - case REGION_BOUNDS: - return db.update(ObaContract.RegionBounds.PATH, values, selection, selectionArgs); - - case REGION_BOUNDS_ID: - return db.update(ObaContract.RegionBounds.PATH, values, - whereLong(ObaContract.RegionBounds._ID, uri), selectionArgs); - - case REGION_OPEN311_SERVERS: - return db.update(ObaContract.RegionOpen311Servers.PATH, values, selection, selectionArgs); - - case REGION_OPEN311_SERVERS_ID: - return db.update(ObaContract.RegionOpen311Servers.PATH, values, - whereLong(ObaContract.RegionOpen311Servers._ID, uri), selectionArgs); - - case ROUTE_HEADSIGN_FAVORITES: - return 0; - - case NAV_STOPS: - return db.update(ObaContract.NavStops.PATH, values, - where(ObaContract.NavStops._ID, uri), selectionArgs); - - default: - throw new IllegalArgumentException("Unknown URI: " + uri); - } - } - - private int deleteInternal(SQLiteDatabase db, - Uri uri, String selection, String[] selectionArgs) { - final int match = sUriMatcher.match(uri); - switch (match) { - case STOPS: - return db.delete(ObaContract.Stops.PATH, selection, selectionArgs); - - case STOPS_ID: - return db.delete(ObaContract.Stops.PATH, - where(ObaContract.Stops._ID, uri), selectionArgs); - - case ROUTES: - return db.delete(ObaContract.Routes.PATH, selection, selectionArgs); - - case ROUTES_ID: - return db.delete(ObaContract.Routes.PATH, - where(ObaContract.Routes._ID, uri), selectionArgs); - - case TRIPS: - return db.delete(ObaContract.Trips.PATH, selection, selectionArgs); - - case TRIPS_ID: - return db.delete(ObaContract.Trips.PATH, tripWhere(uri), selectionArgs); - - case TRIP_ALERTS: - return db.delete(ObaContract.TripAlerts.PATH, selection, selectionArgs); - - case TRIP_ALERTS_ID: - return db.delete(ObaContract.TripAlerts.PATH, - whereLong(ObaContract.TripAlerts._ID, uri), selectionArgs); - - case STOP_ROUTE_FILTERS: - return db.delete(ObaContract.StopRouteFilters.PATH, selection, selectionArgs); - - case SERVICE_ALERTS: - return db.delete(ObaContract.ServiceAlerts.PATH, selection, selectionArgs); - - case SERVICE_ALERTS_ID: - return db.delete(ObaContract.ServiceAlerts.PATH, - where(ObaContract.ServiceAlerts._ID, uri), selectionArgs); - - case REGIONS: - return db.delete(ObaContract.Regions.PATH, selection, selectionArgs); - - case REGIONS_ID: - return db.delete(ObaContract.Regions.PATH, - whereLong(ObaContract.Regions._ID, uri), selectionArgs); - - case REGION_BOUNDS: - return db.delete(ObaContract.RegionBounds.PATH, selection, selectionArgs); - - case REGION_BOUNDS_ID: - return db.delete(ObaContract.RegionBounds.PATH, - whereLong(ObaContract.RegionBounds._ID, uri), selectionArgs); - - case REGION_OPEN311_SERVERS: - return db.delete(ObaContract.RegionOpen311Servers.PATH, selection, selectionArgs); - - case REGION_OPEN311_SERVERS_ID: - return db.delete(ObaContract.RegionOpen311Servers.PATH, - whereLong(ObaContract.RegionOpen311Servers._ID, uri), selectionArgs); - - case ROUTE_HEADSIGN_FAVORITES: - return db.delete(ObaContract.RouteHeadsignFavorites.PATH, selection, selectionArgs); - - case NAV_STOPS: - return db.delete(ObaContract.NavStops.PATH, selection, selectionArgs); - - default: - throw new IllegalArgumentException("Unknown URI: " + uri); - } - } - - private String where(String column, Uri uri) { - StringBuilder sb = new StringBuilder(); - sb.append(column); - sb.append('='); - DatabaseUtils.appendValueToSql(sb, uri.getLastPathSegment()); - return sb.toString(); - } - - private String whereLong(String column, Uri uri) { - StringBuilder sb = new StringBuilder(); - sb.append(column); - sb.append('='); - sb.append(String.valueOf(ContentUris.parseId(uri))); - return sb.toString(); - } - - private String tripWhere(Uri uri) { - List segments = uri.getPathSegments(); - StringBuilder sb = new StringBuilder(); - sb.append("("); - sb.append(ObaContract.Trips._ID); - sb.append("="); - DatabaseUtils.appendValueToSql(sb, segments.get(1)); - sb.append(" AND "); - sb.append(ObaContract.Trips.STOP_ID); - sb.append("="); - DatabaseUtils.appendValueToSql(sb, segments.get(2)); - sb.append(")"); - return sb.toString(); - } - - private SQLiteDatabase getDatabase() { - if (mDb == null) { - mDb = mOpenHelper.getWritableDatabase(); - // Initialize the insert helpers - mStopsInserter = new DatabaseUtils.InsertHelper(mDb, ObaContract.Stops.PATH); - mRoutesInserter = new DatabaseUtils.InsertHelper(mDb, ObaContract.Routes.PATH); - mTripsInserter = new DatabaseUtils.InsertHelper(mDb, ObaContract.Trips.PATH); - mTripAlertsInserter = new DatabaseUtils.InsertHelper(mDb, ObaContract.TripAlerts.PATH); - mFilterInserter = new DatabaseUtils.InsertHelper(mDb, - ObaContract.StopRouteFilters.PATH); - mServiceAlertsInserter = new DatabaseUtils.InsertHelper(mDb, - ObaContract.ServiceAlerts.PATH); - mRegionsInserter = new DatabaseUtils.InsertHelper(mDb, ObaContract.Regions.PATH); - mRegionBoundsInserter = new DatabaseUtils.InsertHelper(mDb, - ObaContract.RegionBounds.PATH); - mRegionOpen311ServersInserter = new DatabaseUtils.InsertHelper(mDb, - ObaContract.RegionOpen311Servers.PATH); - mRouteHeadsignFavoritesInserter = new DatabaseUtils.InsertHelper(mDb, - ObaContract.RouteHeadsignFavorites.PATH); - mNavStopsInserter = new DatabaseUtils.InsertHelper(mDb, ObaContract.NavStops.PATH); - } - return mDb; - } - - // - // Closes the database - // - public void closeDB() { - mOpenHelper.close(); - mDb = null; - } -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/provider/ProviderQueries.kt b/onebusaway-android/src/main/java/org/onebusaway/android/provider/ProviderQueries.kt deleted file mode 100644 index c9ef82eaa7..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/provider/ProviderQueries.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (C) 2010-2017 Paul Watts (paulcwatts@gmail.com), - * University of South Florida (sjbarbeau@gmail.com), Microsoft Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.onebusaway.android.provider - -import android.content.Context -import android.net.Uri - -/** - * Small helpers for reading single values out of the content provider. - */ -object ProviderQueries { - - /** - * Returns the first string for the query URI. - */ - @JvmStatic - fun stringForQuery(context: Context, uri: Uri, column: String): String { - context.contentResolver.query(uri, arrayOf(column), null, null, null)?.use { c -> - if (c.moveToFirst()) return c.getString(0) - } - return "" - } -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/provider/StopUserInfo.kt b/onebusaway-android/src/main/java/org/onebusaway/android/provider/StopUserInfo.kt index 756a8ebb68..1e709ec9f4 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/provider/StopUserInfo.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/provider/StopUserInfo.kt @@ -15,7 +15,7 @@ */ package org.onebusaway.android.provider -import android.content.Context +import org.onebusaway.android.database.oba.StopUserInfoMapRow import org.onebusaway.android.models.ObaStop import org.onebusaway.android.util.MyTextUtils @@ -34,44 +34,9 @@ fun stopDisplayName(serverName: String?, userInfo: StopUserInfo?): String = userInfo?.userName?.takeIf { it.isNotEmpty() } ?: MyTextUtils.formatDisplayText(serverName).orEmpty() /** - * Loads a single stop's favorite/custom-name customization with a targeted query, for screens - * (like arrivals) that only need the one stop. Returns null when the stop has no saved row. + * Builds the id → [StopUserInfo] map from the Room rows (the legacy UIUtils.StopUserInfoMap), so each + * search result can show the star and the user's custom name. Callers fetch the rows via + * `StopDao.userInfoMap()` after the one-time import gate. */ -fun loadStopUserInfo(context: Context, stopId: String): StopUserInfo? = - context.contentResolver.query( - ObaContract.Stops.CONTENT_URI, - arrayOf(ObaContract.Stops.FAVORITE, ObaContract.Stops.USER_NAME), - "${ObaContract.Stops._ID} = ?", - arrayOf(stopId), - null - )?.use { cursor -> - if (cursor.moveToFirst()) { - StopUserInfo(isFavorite = cursor.getInt(0) == 1, userName = cursor.getString(1)) - } else { - null - } - } - -/** - * Loads the user's favorite and custom-named stops from the ContentProvider, keyed by stop id. - * The same query the legacy UIUtils.StopUserInfoMap ran; shared by the Compose stop repositories - * so each row can show the star and the user's name. - */ -fun loadStopUserInfo(context: Context): Map { - val map = mutableMapOf() - context.contentResolver.query( - ObaContract.Stops.CONTENT_URI, - arrayOf(ObaContract.Stops._ID, ObaContract.Stops.FAVORITE, ObaContract.Stops.USER_NAME), - "(" + ObaContract.Stops.USER_NAME + " IS NOT NULL) OR (" + ObaContract.Stops.FAVORITE + "=1)", - null, - null - )?.use { cursor -> - while (cursor.moveToNext()) { - map[cursor.getString(0)] = StopUserInfo( - isFavorite = cursor.getInt(1) == 1, - userName = cursor.getString(2) - ) - } - } - return map -} +fun List.toStopUserInfoMap(): Map = + associate { it.stopId to StopUserInfo(isFavorite = it.favorite == 1, userName = it.userName) } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/region/Region.kt b/onebusaway-android/src/main/java/org/onebusaway/android/region/Region.kt index deb494edd9..eb94a9e510 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/region/Region.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/region/Region.kt @@ -20,7 +20,7 @@ package org.onebusaway.android.region /** * A region in the OneBusAway multi-region system: the OBA/SIRI/OTP/sidecar endpoints and * capabilities for one deployment. Built from the regions-directory wire (RegionDto.toObaRegion), - * the local ContentProvider cache ([RegionCursor.fromCursor]), and tests (MockRegion). + * the local Room region cache ([RegionMapper.toRegion]), and tests (MockRegion). * * Was the io/elements `ObaRegion` interface + its sole `ObaRegionElement` implementation; collapsed * into one data class since there was never a second implementation. @@ -48,8 +48,6 @@ data class Region( val paymentAndroidAppId: String? = null, val paymentWarningTitle: String? = null, val paymentWarningBody: String? = null, - val isTravelBehaviorDataCollectionEnabled: Boolean = false, - val isEnrollParticipantsInStudy: Boolean = false, val sidecarBaseUrl: String? = "", val plausibleAnalyticsServerUrl: String? = "", val umamiAnalytics: UmamiAnalyticsConfig? = null, diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionCache.kt b/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionCache.kt new file mode 100644 index 0000000000..74dee557bc --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionCache.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.region + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import org.onebusaway.android.app.Application +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.RegionDao +import org.onebusaway.android.util.RegionUtils + +/** + * The region cache, backed by Room (the legacy `RegionUtils.getRegionsFromProvider`/`saveToProvider` + * over the ContentProvider). Every read/write awaits the one-time importer via [ImportGate] first, so + * a cached region survives the migration. Maps between the Room rows and the domain [Region] through + * the pure [RegionMapper]. The server/bundled-resource fetchers stay in [RegionUtils]; this owns only + * the local cache and the source-fallback orchestration that used to live in `RegionUtils.getRegions`. + */ +@Singleton +class RegionCache @Inject constructor( + @ApplicationContext private val context: Context, + private val regionDao: RegionDao, + private val importGate: ImportGate, +) { + + /** The cached regions, or an empty list when the cache is empty. */ + suspend fun cachedRegions(): List { + importGate.awaitReady() + return regionDao.getAllRegions().map { RegionMapper.toRegion(it) } + } + + /** The cached regions, or null when the cache is empty (for the source-fallback short-circuits). */ + private suspend fun cachedRegionsOrNull(): List? = cachedRegions().takeIf { it.isNotEmpty() } + + /** The cached region with [id], or null if it isn't cached. */ + suspend fun cachedRegion(id: Long): Region? { + importGate.awaitReady() + return regionDao.getRegion(id)?.let { RegionMapper.toRegion(it) } + } + + /** Replaces the whole cache with the usable subset of [regions] (the legacy `saveToProvider`). */ + suspend fun save(regions: List) { + importGate.awaitReady() + val usable = regions + .filter { RegionUtils.isRegionUsable(it) } + .map { RegionMapper.toEntities(it) } + regionDao.replaceAll(usable) + } + + /** + * The legacy `RegionUtils.getRegions` source-fallback: prefer the cache (unless [forceReload]), + * then the server, then — if forced — the cache again, then the bundled resource. A server or + * resource result is persisted. Null only when every source failed. + */ + suspend fun loadRegions(forceReload: Boolean): List? { + if (!forceReload) { + cachedRegionsOrNull()?.let { return it } + } + + var results: List? = RegionUtils.getRegionsFromServer(context) + if (results.isNullOrEmpty()) { + if (forceReload) { + cachedRegionsOrNull()?.let { return it } + } + results = RegionUtils.getRegionsFromResources(context) + // An empty bundled result is a total failure too: return null (so callers surface "couldn't + // load regions") instead of falling through to save([]), which would wipe the cache. + if (results.isNullOrEmpty()) return null + } else { + Application.get().setLastRegionUpdateDate(System.currentTimeMillis()) + } + + save(results) + return results + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionCursor.kt b/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionCursor.kt deleted file mode 100644 index bf749d2fa8..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionCursor.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (C) 2026 Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.onebusaway.android.region - -import android.database.Cursor - -/** - * Builds a [Region] from a ContentProvider row. The single source for the cursor→Region mapping - * shared by `RegionUtils.getRegionsFromProvider` and `ObaContract.Regions.get` (which previously - * each duplicated the 25-column positional construction). The column order matches the - * `ObaContract.Regions` projection those queries use. Bounds and Open311 servers are fetched - * separately by each caller (from a bulk map vs. a per-id sub-query) and passed in. - */ -object RegionCursor { - - @JvmStatic - fun fromCursor( - c: Cursor, - bounds: Array?, - open311Servers: Array?, - ): Region { - val umamiUrl = c.getString(23) - val umamiId = c.getString(24) - val umami = if (umamiUrl != null || umamiId != null) { - Region.UmamiAnalyticsConfig(umamiUrl, umamiId) - } else { - null - } - return Region( - id = c.getLong(0), - name = c.getString(1) ?: "", - active = true, - obaBaseUrl = c.getString(2), - siriBaseUrl = c.getString(3), - bounds = bounds ?: emptyArray(), - open311Servers = open311Servers ?: emptyArray(), - language = c.getString(4), - contactEmail = c.getString(5), - supportsObaDiscoveryApis = c.getInt(6) > 0, - supportsObaRealtimeApis = c.getInt(7) > 0, - supportsSiriRealtimeApis = c.getInt(8) > 0, - twitterUrl = c.getString(9), - experimental = c.getInt(10) > 0, - stopInfoUrl = c.getString(11), - otpBaseUrl = c.getString(12), - otpContactEmail = c.getString(13), - supportsOtpBikeshare = c.getInt(14) > 0, - supportsEmbeddedSocial = c.getInt(15) > 0, - paymentAndroidAppId = c.getString(16), - paymentWarningTitle = c.getString(17), - paymentWarningBody = c.getString(18), - isTravelBehaviorDataCollectionEnabled = c.getInt(19) > 0, - isEnrollParticipantsInStudy = c.getInt(20) > 0, - sidecarBaseUrl = c.getString(21), - plausibleAnalyticsServerUrl = c.getString(22), - umamiAnalytics = umami, - ) - } -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionMapper.kt b/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionMapper.kt new file mode 100644 index 0000000000..323561b179 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionMapper.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.region + +import org.onebusaway.android.database.oba.Open311ServerRecord +import org.onebusaway.android.database.oba.RegionBoundRecord +import org.onebusaway.android.database.oba.RegionRecord +import org.onebusaway.android.database.oba.RegionWithChildren + +/** + * Pure mapping between the region-cache Room rows ([RegionWithChildren]) and the domain [Region]. + * Replaces the legacy cursor-positional region mapping and the `RegionUtils` write projections; + * kept free of Room/Android types so it is JVM-unit-testable. Nullable integer flags read as false + * when absent, matching the legacy `Cursor.getInt` (NULL → 0) semantics. + */ +object RegionMapper { + + /** Builds a domain [Region] from a cached region row. A cached region is always [active]. */ + fun toRegion(row: RegionWithChildren): Region { + val r = row.region + val umami = if (r.umamiAnalyticsUrl != null || r.umamiAnalyticsId != null) { + Region.UmamiAnalyticsConfig(r.umamiAnalyticsUrl, r.umamiAnalyticsId) + } else { + null + } + return Region( + id = r.id, + name = r.name, + active = true, + obaBaseUrl = r.obaBaseUrl, + siriBaseUrl = r.siriBaseUrl, + bounds = row.bounds.map { + Region.Bounds(it.latitude, it.longitude, it.latSpan, it.lonSpan) + }.toTypedArray(), + open311Servers = row.open311Servers.map { + Region.Open311Server(it.jurisdiction, it.apiKey, it.baseUrl) + }.toTypedArray(), + language = r.language, + contactEmail = r.contactEmail, + supportsObaDiscoveryApis = r.supportsObaDiscovery > 0, + supportsObaRealtimeApis = r.supportsObaRealtime > 0, + supportsSiriRealtimeApis = r.supportsSiriRealtime > 0, + twitterUrl = r.twitterUrl, + experimental = (r.experimental ?: 0) > 0, + stopInfoUrl = r.stopInfoUrl, + otpBaseUrl = r.otpBaseUrl, + otpContactEmail = r.otpContactEmail, + supportsOtpBikeshare = (r.supportsOtpBikeshare ?: 0) > 0, + supportsEmbeddedSocial = (r.supportsEmbeddedSocial ?: 0) > 0, + paymentAndroidAppId = r.paymentAndroidAppId, + paymentWarningTitle = r.paymentWarningTitle, + paymentWarningBody = r.paymentWarningBody, + sidecarBaseUrl = r.sidecarBaseUrl, + plausibleAnalyticsServerUrl = r.plausibleAnalyticsServerUrl, + umamiAnalytics = umami, + ) + } + + /** Projects a domain [Region] to its cache rows (the inverse of [toRegion]; the legacy write). */ + fun toEntities(region: Region): RegionWithChildren = RegionWithChildren( + region = RegionRecord( + id = region.id, + name = region.name, + obaBaseUrl = region.obaBaseUrl.orEmpty(), + siriBaseUrl = region.siriBaseUrl.orEmpty(), + language = region.language.orEmpty(), + contactEmail = region.contactEmail.orEmpty(), + supportsObaDiscovery = region.supportsObaDiscoveryApis.toInt(), + supportsObaRealtime = region.supportsObaRealtimeApis.toInt(), + supportsSiriRealtime = region.supportsSiriRealtimeApis.toInt(), + twitterUrl = region.twitterUrl, + experimental = region.experimental.toInt(), + stopInfoUrl = region.stopInfoUrl, + otpBaseUrl = region.otpBaseUrl, + otpContactEmail = region.otpContactEmail, + supportsOtpBikeshare = region.supportsOtpBikeshare.toInt(), + supportsEmbeddedSocial = region.supportsEmbeddedSocial.toInt(), + paymentAndroidAppId = region.paymentAndroidAppId, + paymentWarningTitle = region.paymentWarningTitle, + paymentWarningBody = region.paymentWarningBody, + sidecarBaseUrl = region.sidecarBaseUrl, + plausibleAnalyticsServerUrl = region.plausibleAnalyticsServerUrl, + umamiAnalyticsUrl = region.umamiAnalyticsUrl, + umamiAnalyticsId = region.umamiAnalyticsId, + ), + bounds = region.bounds.map { + RegionBoundRecord( + regionId = region.id, + latitude = it.lat, + longitude = it.lon, + latSpan = it.latSpan, + lonSpan = it.lonSpan, + ) + }, + open311Servers = region.open311Servers.map { + Open311ServerRecord( + regionId = region.id, + jurisdiction = it.jurisdictionId, + apiKey = it.apiKey.orEmpty(), + baseUrl = it.baseUrl.orEmpty(), + ) + }, + ) + + private fun Boolean.toInt(): Int = if (this) 1 else 0 +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionRepository.kt index ff72c44099..725a88b8b8 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/region/RegionRepository.kt @@ -17,21 +17,25 @@ package org.onebusaway.android.region import android.content.Context import android.content.pm.PackageManager +import android.util.Log +import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.onebusaway.android.BuildConfig import org.onebusaway.android.R +import org.onebusaway.android.app.di.AppScope import org.onebusaway.android.region.Region import org.onebusaway.android.location.LocationRepository import org.onebusaway.android.preferences.PreferencesRepository -import org.onebusaway.android.provider.ObaContract import org.onebusaway.android.util.RegionUtils /** @@ -125,6 +129,8 @@ sealed interface RegionState { /** Mirrors `HomeActivity`'s `checkRegionVer` preference key so the same slot is read/written. */ private const val CHECK_REGION_VER = "checkRegionVer" +private const val TAG = "RegionRepository" + /** * Default implementation. The observable state lives in a [RegionStateHolder] (so its transitions stay * JVM-testable); resolution ([refresh]) is the `Context`-coupled IO ported from @@ -139,21 +145,33 @@ class DefaultRegionRepository @Inject constructor( @ApplicationContext private val context: Context, private val prefs: PreferencesRepository, private val locationRepository: LocationRepository, + private val regionCache: RegionCache, + @AppScope private val appScope: CoroutineScope, ) : RegionRepository { - // Seeded from persistence (the region-id pref → ContentProvider lookup) so the repo is the sole - // owner of the current region — there is no external store to read from anymore. - private val holder = RegionStateHolder(loadPersistedRegion()) + // Seeded null and then asynchronously from the region cache once the one-time import has run — the + // Room cache read is suspend and must wait on the importer, so the @Singleton constructor can't read + // it synchronously anymore. region.value is briefly null at cold start until the seed resolves; the + // region-derived subsystems collect the flow and re-init on emit, so they pick it up. + private val holder = RegionStateHolder(null) + + init { + appScope.launch { + val seeded = loadPersistedRegion() + // Don't clobber a region a concurrent refresh() may have already set. + if (seeded != null && holder.region.value == null) holder.activated(seeded) + } + } override val region: StateFlow get() = holder.region override val state: StateFlow get() = holder.state - /** Loads the persisted current region (by the saved region-id) on construction, or null if none. */ - private fun loadPersistedRegion(): Region? { + /** Loads the persisted current region (by the saved region-id) from the cache, or null if none. */ + private suspend fun loadPersistedRegion(): Region? { val id = prefs.getLong(R.string.preference_key_region, -1L) if (id < 0) return null - return ObaContract.Regions.get(context, id.toInt()) + return regionCache.cachedRegion(id) } override fun applyRegion(region: Region?, regionChanged: Boolean) { @@ -184,7 +202,7 @@ class DefaultRegionRepository @Inject constructor( // A build flavor may hard-code its region; set it and disable auto-selection. if (BuildConfig.USE_FIXED_REGION) { val region = RegionUtils.getRegionFromBuildFlavor() - RegionUtils.saveToProvider(context, listOf(region)) + regionCache.save(listOf(region)) applyRegion(region, true) prefs.setBoolean(R.string.preference_key_auto_select_region, false) return@withContext RegionStatus.Fixed(region) @@ -202,8 +220,14 @@ class DefaultRegionRepository @Inject constructor( ) prefs.setInt(CHECK_REGION_VER, newVer) - val results = RegionUtils.getRegions(context, force) + val results = regionCache.loadRegions(force) if (results == null) { + // Catastrophic: no network, no cache, and the server was unreachable, so region info + // couldn't be loaded from any source. Log it and record a non-fatal so the failure rate + // is trackable; the Failed state drives a retryable affordance at the picker host. + Log.e(TAG, "Region load failed: no regions available from network or cache") + FirebaseCrashlytics.getInstance() + .recordException(IllegalStateException("Region load failed: no regions available")) holder.failed() return@withContext RegionStatus.Failed } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/reminders/ReminderRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/reminders/ReminderRepository.kt new file mode 100644 index 0000000000..ee03c80310 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/reminders/ReminderRepository.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.reminders + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.onebusaway.android.api.bridge.ReminderClient +import org.onebusaway.android.app.di.AppScope +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.TripDao +import org.onebusaway.android.util.ReminderUtils + +/** + * The saved trip reminders (the legacy `trips` table), replacing the storage half of `ReminderUtils`. + * Reads/writes go through [TripDao] after the one-time import gate; the server-side alarm deletion goes + * through [ReminderClient]. Fire-and-forget variants run on the application scope so an Activity/Service + * caller (or a Compose callback that navigates immediately after) doesn't need its own coroutine scope. + */ +@Singleton +class ReminderRepository @Inject constructor( + @ApplicationContext private val context: Context, + @AppScope private val appScope: CoroutineScope, + private val tripDao: TripDao, + private val importGate: ImportGate, +) { + + /** + * Cancels the reminder for this trip/stop: server-deletes the alarm (fire-and-forget via + * [ReminderClient]) and removes the trip row (the legacy `requestDeleteAlarm`). + */ + suspend fun deleteReminder(tripId: String, stopId: String) { + importGate.awaitReady() + val alarmDeletePath = tripDao.getTrip(tripId, stopId)?.alarmDeletePath + ReminderClient.deleteAlarm(context, alarmDeletePath) + tripDao.delete(tripId, stopId) + } + + /** [deleteReminder] on the application scope, for Activity/Service/Compose callers. */ + fun deleteReminderInBackground(tripId: String, stopId: String) { + appScope.launch { deleteReminder(tripId, stopId) } + } + + /** + * Deletes the reminder referenced by an FCM `arrival_and_departure` payload (the legacy + * `handleArrivalPayload` -> `deleteSavedReminder`: removes the row only, no server call). Runs on + * the application scope. + */ + fun deleteReminderFromPayload(arrivalJson: String?) { + val tripId = ReminderUtils.getTripIdFromPayload(arrivalJson) ?: return + val stopId = ReminderUtils.getStopIdFromPayload(arrivalJson) ?: return + appScope.launch { + importGate.awaitReady() + tripDao.delete(tripId, stopId) + } + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/tripservice/MyFirebaseMessagingService.kt b/onebusaway-android/src/main/java/org/onebusaway/android/tripservice/MyFirebaseMessagingService.kt index c16c09cd32..aca4458582 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/tripservice/MyFirebaseMessagingService.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/tripservice/MyFirebaseMessagingService.kt @@ -16,6 +16,7 @@ import javax.inject.Inject import org.onebusaway.android.R import org.onebusaway.android.app.Application import org.onebusaway.android.preferences.PreferencesRepository +import org.onebusaway.android.reminders.ReminderRepository import org.onebusaway.android.ui.arrivals.ArrivalsListLauncher import org.onebusaway.android.util.ReminderUtils @@ -33,6 +34,9 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { @Inject lateinit var prefsRepository: PreferencesRepository + @Inject + lateinit var reminderRepository: ReminderRepository + companion object { private const val TAG = "FirebaseMsgService" private const val NOTIFICATION_COLOR = 0xFF4CAF50.toInt() @@ -51,7 +55,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { Log.d(TAG, "Received reminder for stopId: $stopId") val context = applicationContext - ReminderUtils.handleArrivalPayload(context, arrivalJson) + reminderRepository.deleteReminderFromPayload(arrivalJson) showNotification(context, message, stopId) } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/HomeActivity.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/HomeActivity.kt index 15896b0018..3fed6ccd40 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/HomeActivity.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/HomeActivity.kt @@ -17,6 +17,7 @@ */ package org.onebusaway.android.ui +import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle @@ -72,6 +73,9 @@ class HomeActivity : AppCompatActivity() { @Inject lateinit var regionRepository: RegionRepository + @Inject + lateinit var reminderRepository: org.onebusaway.android.reminders.ReminderRepository + // The launch-intent channel: the OS delivers external entry points (deep links, FCM, launcher // shortcuts) to this Activity before/around the composition, so they're surfaced here for the NavHost's // [LaunchIntentEffect] to translate (via IntentRouteMapper) and open once composed. Seeded on a fresh @@ -216,7 +220,7 @@ class HomeActivity : AppCompatActivity() { return } intent.getStringExtra(ReminderUtils.ARRIVAL_PAYLOAD_KEY)?.let { arrivalJson -> - ReminderUtils.handleArrivalPayload(applicationContext, arrivalJson) + reminderRepository.deleteReminderFromPayload(arrivalJson) } } @@ -325,6 +329,20 @@ class HomeActivity : AppCompatActivity() { } } +/** + * Re-enters [HomeActivity] at [route] from a launcher facade (the standalone-host fallback the launcher + * objects use when the caller holds no NavController). Adds [Intent.FLAG_ACTIVITY_NEW_TASK] only when + * [this] is not an Activity — required to `startActivity` from a service/receiver context — so an Activity + * caller keeps the normal same-task behavior. Deliberately not folded into [HomeActivity.navIntent], which + * is a pure intent factory whose result is also wrapped in PendingIntents (e.g. the trip-feedback + * notification in NavigationService): a context-dependent flag side effect would be wrong there. + */ +fun Context.startHomeActivity(route: String) { + val intent = HomeActivity.navIntent(this, route) + if (this !is Activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) +} + /** * The [FocusedStop] a launch intent deep-links into (makeIntent's STOP_ID + CENTER_LAT/LON), or null * when it carries no usable stop — an id plus a real (non-zero) location. A plain launch carries diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalActionHandlers.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalActionHandlers.kt index e9d686c96c..2776664543 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalActionHandlers.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalActionHandlers.kt @@ -15,6 +15,7 @@ */ package org.onebusaway.android.ui.arrivals +import org.onebusaway.android.map.ShowRouteRequest import org.onebusaway.android.ui.tripinfo.TripInfoLauncher import org.onebusaway.android.ui.tripdetails.TripDetailsLauncher import org.onebusaway.android.ui.arrivals.dialogs.RouteFavoriteHost @@ -24,9 +25,10 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import org.onebusaway.android.R import org.onebusaway.android.ui.nav.ReminderEditorArgs +import org.onebusaway.android.app.di.DatabaseEntryPoint import org.onebusaway.android.report.ui.InfrastructureIssueLauncher -import org.onebusaway.android.util.DBUtil import org.onebusaway.android.util.ExternalIntents +import org.onebusaway.android.util.PreferenceUtils import org.onebusaway.android.util.ReminderUtils /** @@ -39,7 +41,8 @@ fun createArrivalActionHandler( activity: AppCompatActivity, viewModel: ArrivalsViewModel, currentContent: () -> ArrivalsUiState.Content?, - onShowRouteOnMap: (routeId: String) -> Unit, + // Carries the arrival's stop in the request, so route mode can narrow to the stop-relevant direction. + onShowRouteOnMap: (ShowRouteRequest) -> Unit, // How to show the alert hide/undo snackbar — supplied by the host so the dialog isn't tied to a // specific View (the standalone activity anchors to its root; Compose hosts use a SnackbarHost). showUndoSnackbar: (messageRes: Int, actionRes: Int?, onAction: (() -> Unit)?) -> Unit, @@ -66,20 +69,33 @@ fun createArrivalActionHandler( } override fun onShowVehiclesOnMap(arrival: ArrivalInfo) { - DBUtil.addRouteToDB(activity, arrival) - onShowRouteOnMap(arrival.routeId) + recordRoute(arrival) + // Pass the arrival's stop so route mode shows only the direction (stops + vehicles) serving it. + onShowRouteOnMap(ShowRouteRequest(arrival.routeId, arrival.stopId)) } override fun onShowTripStatus(arrival: ArrivalInfo) { - DBUtil.addRouteToDB(activity, arrival) + recordRoute(arrival) onShowTrip(arrival.tripId, arrival.stopId) } + /** Records the route in recents/search before navigating (the legacy DBUtil.addRouteToDB). */ + fun recordRoute(arrival: ArrivalInfo) { + DatabaseEntryPoint.get(activity).routeRecorder() + .recordFromArrival(arrival.routeId, arrival.shortName, arrival.routeLongName, arrival.headsign) + } + override fun onSetReminder(arrival: ArrivalInfo) { if (!ReminderUtils.shouldShowReminders()) { Toast.makeText(activity, R.string.reminder_not_enabled, Toast.LENGTH_SHORT).show() return } + // A partial/omitted API response can leave tripId/stopId blank (they default to ""); the editor + // can't open without both, so bail with a toast rather than tripping ReminderEditorArgs' require(). + if (arrival.tripId.isBlank() || arrival.stopId.isBlank()) { + Toast.makeText(activity, R.string.failed_to_set_reminder, Toast.LENGTH_SHORT).show() + return + } onEditReminder( ReminderEditorArgs( tripId = arrival.tripId, @@ -122,8 +138,16 @@ fun createArrivalActionHandler( showSituationDialog( activity = activity, alert = alert, + onMarkRead = { viewModel.markAlertRead(alertId) }, + onHide = { viewModel.hideAlert(alertId) }, + onUnhide = { viewModel.unhideAlert(alertId) }, + onHideAll = { + viewModel.hideAllRecordedAlerts() + PreferenceUtils.saveBoolean( + activity.getString(R.string.preference_key_hide_alerts), true + ) + }, onDismiss = { isAlertHidden -> if (isAlertHidden) viewModel.manualRefresh() }, - onUndo = { viewModel.manualRefresh() }, showUndoSnackbar = showUndoSnackbar ) } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalInfo.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalInfo.kt index 0bbdb7ef15..695b01d2c8 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalInfo.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalInfo.kt @@ -21,7 +21,6 @@ import org.onebusaway.android.R import org.onebusaway.android.models.ArrivalData import org.onebusaway.android.models.Occupancy import org.onebusaway.android.models.Status -import org.onebusaway.android.provider.ObaContract import org.onebusaway.android.report.TripReportContext import org.onebusaway.android.util.ArrivalInfoUtils import org.onebusaway.android.util.DisplayFormat @@ -37,7 +36,8 @@ class ArrivalInfo( context: Context?, private val data: ArrivalData, now: Long, - includeArrivalDepartureInStatusLabel: Boolean + includeArrivalDepartureInStatusLabel: Boolean, + favorite: Boolean, ) { val eta: Long @@ -164,9 +164,9 @@ class ArrivalInfo( ) timeText = computeTimeLabel(context) - // Check if the user has marked this routeId/headsign/stopId as a favorite - isRouteAndHeadsignFavorite = ObaContract.RouteHeadsignFavorites - .isFavorite(data.routeId, data.headsign, data.stopId) + // Whether the user marked this routeId/headsign/stopId a favorite (precomputed by the caller + // from one favorites query, replacing the legacy per-row ContentProvider lookup). + isRouteAndHeadsignFavorite = favorite notifyText = computeNotifyText(context) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsDestinations.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsDestinations.kt index 4310c752b0..9786f6e14b 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsDestinations.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsDestinations.kt @@ -112,9 +112,7 @@ fun NavGraphBuilder.arrivalsGraph(navController: NavHostController) { activity = activity, viewModel = arrivalsVm, currentContent = { arrivalsVm.state.value as? ArrivalsUiState.Content }, - onShowRouteOnMap = { routeId -> - navController.revealRouteOnMap(routeId) - }, + onShowRouteOnMap = { request -> navController.revealRouteOnMap(request) }, showUndoSnackbar = { messageRes, actionRes, onAction -> scope.launch { val result = snackbarHostState.showSnackbar( diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsListLauncher.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsListLauncher.kt index 8792bd91ec..a964460fdc 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsListLauncher.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsListLauncher.kt @@ -20,7 +20,7 @@ import org.onebusaway.android.ui.HomeActivity import android.content.Context import android.content.Intent import android.net.Uri -import org.onebusaway.android.provider.ObaContract +import org.onebusaway.android.ui.nav.DeepLinkUris /** * Launches the real-time arrivals screen for a stop. @@ -38,7 +38,7 @@ object ArrivalsListLauncher { /** The built intent; Java callers see this as getIntent(). */ val intent: Intent = Intent(context, HomeActivity::class.java).apply { - data = Uri.withAppendedPath(ObaContract.Stops.CONTENT_URI, stopId) + data = Uri.withAppendedPath(DeepLinkUris.STOPS, stopId) } fun setStopName(stopName: String?): Builder { diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsRepository.kt index 7b55a6b90c..9c7a8d6f2f 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsRepository.kt @@ -19,32 +19,39 @@ import org.onebusaway.android.api.data.StopArrivalsDataSource import org.onebusaway.android.api.data.StopArrivals import org.onebusaway.android.api.data.RouteDataSource -import android.content.ContentValues import android.content.Context -import android.net.Uri +import android.os.SystemClock +import com.google.firebase.analytics.FirebaseAnalytics import dagger.hilt.android.qualifiers.ApplicationContext import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withContext import org.onebusaway.android.R +import org.onebusaway.android.analytics.ObaAnalytics +import org.onebusaway.android.analytics.PlausibleAnalytics import org.onebusaway.android.api.ObaApi import org.onebusaway.android.api.ObaApiException +import org.onebusaway.android.app.Application +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.RouteDao +import org.onebusaway.android.database.oba.RouteHeadsignFavoriteDao +import org.onebusaway.android.database.oba.ServiceAlertDao +import org.onebusaway.android.database.oba.StopDao +import org.onebusaway.android.database.oba.StopRouteFilterDao +import org.onebusaway.android.database.oba.applyRouteHeadsignFavorite +import org.onebusaway.android.database.oba.computeRouteHeadsignFavorite +import org.onebusaway.android.database.oba.markStopUsed import org.onebusaway.android.models.ObaRoute import org.onebusaway.android.models.ObaSituation import org.onebusaway.android.models.ObaStop import org.onebusaway.android.models.contentKey -import org.onebusaway.android.provider.ObaContract -import org.onebusaway.android.provider.contentChanges -import org.onebusaway.android.provider.loadStopUserInfo import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.region.RegionRepository import org.onebusaway.android.util.BuildFlavorUtils -import org.onebusaway.android.util.DBUtil import org.onebusaway.android.util.MyTextUtils import org.onebusaway.android.util.ObaRequestErrors import org.onebusaway.android.util.SituationUtils @@ -82,10 +89,10 @@ internal fun severityOf(severity: String?): AlertSeverity = when (severity) { } /** - * The explicit per-situation hide [decisions] recorded in the DB: true = hidden (HIDDEN=1), false = - * shown (HIDDEN=0). Ids absent from the map are undecided — the "hide all alerts" preference decides - * those. Because visibility is a total function of the map plus the preference (no write happens on - * load), a preference-hidden new alert is hidden the instant it's derived, with no flash. See #1593. + * The explicit per-situation hide [decisions] recorded in the store: true = hidden, false = shown. + * Ids absent from the map are undecided — the "hide all alerts" preference decides those. Because + * visibility is a total function of the map plus the preference (no write happens on load), a + * preference-hidden new alert is hidden the instant it's derived, with no flash. See #1593. */ data class AlertHideState(val decisions: Map = emptyMap()) { /** Whether [alert] should be hidden, given the "hide all alerts" preference [hideByDefault]: @@ -161,20 +168,30 @@ interface ArrivalsRepository { suspend fun setArrivalStyle(style: Int) /** - * The explicit hide/show decisions recorded in the DB, re-emitting on every service-alert change. - * This is the single source of truth for hidden state: the ViewModel derives the shown/hidden - * split from it (plus the per-load preference), and a hide/un-hide from any surface (swipe, the - * alert dialog, "show hidden alerts") flows back here with nothing to reconcile. See #1593. + * The explicit hide/show decisions recorded in the store, re-emitting on every service-alert + * change. This is the single source of truth for hidden state: the ViewModel derives the + * shown/hidden split from it (plus the per-load preference), and a hide/un-hide from any surface + * (swipe, the alert dialog, "show hidden alerts") flows back here with nothing to reconcile. + * See #1593. */ fun alertHideState(): Flow - /** Records the given service alerts as hidden (HIDDEN=1). */ + /** Records the given service alerts as hidden. */ suspend fun hideAlerts(ids: List) - /** Records the given service alerts as shown (HIDDEN=0) — an explicit reveal that overrides the - * "hide all alerts" preference (the "show hidden alerts" action). */ + /** Records the given service alerts as shown — an explicit reveal that overrides the "hide all + * alerts" preference (the "show hidden alerts" action). */ suspend fun showAlerts(ids: List) + /** Records a single alert hidden/shown (the alert dialog's Hide / Undo). */ + suspend fun setAlertHidden(id: String, hidden: Boolean) + + /** Stamps a single alert read (the situation dialog marks the alert read on open). */ + suspend fun markAlertRead(id: String) + + /** Hides every recorded alert (the dialog's Hide All). */ + suspend fun hideAllRecordedAlerts() + /** The service-alert dialog's content for an alert id, from the last good response, or null. */ fun alertDetails(id: String): AlertDetails? @@ -218,10 +235,24 @@ class DefaultArrivalsRepository @Inject constructor( private val regionRepository: RegionRepository, private val routeRepository: RouteDataSource, private val stopArrivals: StopArrivalsDataSource, + private val stopRouteFilterDao: StopRouteFilterDao, + private val serviceAlertDao: ServiceAlertDao, + private val stopDao: StopDao, + private val routeDao: RouteDao, + private val routeHeadsignFavoriteDao: RouteHeadsignFavoriteDao, + private val importGate: ImportGate, private val preferences: PreferencesRepository ) : ArrivalsRepository { - private var lastGood: StopArrivals? = null + /** The last good snapshot paired with the monotonic device time it was received, so the + * stale-fallback path can project that server clock forward by elapsed device time (#1612). The + * two must be read as a consistent pair; held in one `@Volatile` reference so a concurrent + * getArrivals (e.g. a user refresh overlapping the poll loop) can't mix a new snapshot with an + * old receipt time. */ + private data class LastGood(val snapshot: StopArrivals, val elapsedMs: Long) + + @Volatile + private var lastGood: LastGood? = null // Whether the viewed stop has been recorded in the Stops table this session. Recording (a) creates // the row so the favorite toggle's UPDATE actually persists, and (b) marks it used so it appears in @@ -233,8 +264,9 @@ class DefaultArrivalsRepository @Inject constructor( minutesAfter: Int, routeFilter: Set? ): Result = withContext(Dispatchers.IO) { + importGate.awaitReady() val now = System.currentTimeMillis() - val filter = routeFilter ?: ObaContract.StopRouteFilters.get(context, stopId).toSet() + val filter = routeFilter ?: stopRouteFilterDao.routeIdsForStop(stopId).toSet() var minutes = minutesAfter // Widen the window while the fetch is empty (or failing), matching the legacy loader. var result: Result @@ -246,17 +278,24 @@ class DefaultArrivalsRepository @Inject constructor( result.fold( onSuccess = { snapshot -> - lastGood = snapshot - // Record the stop once per session so favoriting persists (markAsFavorite is an + lastGood = LastGood(snapshot, SystemClock.elapsedRealtime()) + // Record the stop once per session so favoriting persists (setFavorite is an // UPDATE — it needs the row to exist) and the stop shows in Recent stops. if (!stopRecorded) { - snapshot.stop?.let { DBUtil.addToDB(it); stopRecorded = true } + snapshot.stop?.let { recordStop(it, now); stopRecorded = true } } - Result.success(toData(snapshot, filter, isStale = false, now)) + Result.success(toData(snapshot, filter, isStale = false, now = snapshot.currentTime)) }, // Refresh failed but we have prior data — keep showing it (legacy stale fallback). onFailure = { error -> - lastGood?.let { Result.success(toData(it, filter, isStale = true, now)) } + lastGood?.let { stale -> + // No fresh server time; project the last good server clock forward by the elapsed + // device time so stale ETAs/countdowns keep advancing (legacy behavior) without + // reintroducing device clock skew (#1612). + val now = stale.snapshot.currentTime + + (SystemClock.elapsedRealtime() - stale.elapsedMs) + Result.success(toData(stale.snapshot, filter, isStale = true, now = now)) + } ?: Result.failure( IOException( ObaRequestErrors.getStopErrorString( @@ -268,29 +307,37 @@ class DefaultArrivalsRepository @Inject constructor( ) } - private fun toData( + private suspend fun toData( snapshot: StopArrivals, routeFilter: Set, isStale: Boolean, + // Server clock as "now" so ETAs/countdowns and alert active-window checks cancel any device + // clock skew — the fresh response's currentTime, or (on the stale path) the last good server + // clock projected forward by elapsed device time (#1612). now: Long ): ArrivalsData { val style = BuildFlavorUtils.getArrivalInfoStyleFromPreferences(context) // Style B includes the arrival/departure word in the status label; Style A does not. val includeArrivalDepartureLabel = style == BuildFlavorUtils.ARRIVAL_INFO_STYLE_B + // One favorites query for the whole list; ArrivalInfo's favorite state resolves from it in + // memory (the legacy per-row ContentProvider lookup is gone). + val favoriteRows = routeHeadsignFavoriteDao.favoritesForStopOrAll(snapshot.stopId) val arrivals = convertArrivals( context, snapshot.arrivals, routeFilter, now, includeArrivalDepartureLabel - ) + ) { routeId, headsign, stopId -> + computeRouteHeadsignFavorite(favoriteRows, routeId, headsign, stopId) + } val stop = snapshot.stop - val userInfo = loadStopUserInfo(context, snapshot.stopId) + val userInfo = stopDao.userInfo(snapshot.stopId) val header = StopHeader( stopId = snapshot.stopId, name = MyTextUtils.formatDisplayText(stop?.name).orEmpty(), direction = stop?.direction, - isFavorite = userInfo?.isFavorite ?: false, + isFavorite = userInfo?.favorite == 1, routeCount = stop?.routeIds?.size ?: 0 ) val routeOptions = buildRouteFilterOptions(snapshot, stop, routeFilter) - // Pure grouping; no DB write. Hidden state is derived in the ViewModel from [alertHideState] + // Pure grouping; no store write. Hidden state is derived in the ViewModel from [alertHideState] // plus [ArrivalsData.hideAlertsByDefault], so nothing on the load path can race the snapshot. val activeAlerts = planActiveAlerts( situations = snapshot.situations(ArrayList(routeFilter)), @@ -316,6 +363,14 @@ class DefaultArrivalsRepository @Inject constructor( ) } + /** + * Records the viewed stop in the stops table (the legacy DBUtil.addToDB): creates the row so the + * favorite UPDATE persists and marks it used so it appears in Recent stops. Done once per session. + */ + private suspend fun recordStop(stop: ObaStop, now: Long) { + stopDao.markStopUsed(stop, regionRepository.region.value?.id, now) + } + /** Precomputes the navigation/dialog data for each arrival (legacy reads these on menu tap). */ private fun buildActions( snapshot: StopArrivals, @@ -352,10 +407,8 @@ class DefaultArrivalsRepository @Inject constructor( } override suspend fun setStopFavorite(stopId: String, favorite: Boolean) { - withContext(Dispatchers.IO) { - val uri = Uri.withAppendedPath(ObaContract.Stops.CONTENT_URI, stopId) - ObaContract.Stops.markAsFavorite(context, uri, favorite) - } + importGate.awaitReady() + stopDao.setFavorite(stopId, if (favorite) 1 else 0) } override suspend fun favoriteRoute( @@ -365,21 +418,50 @@ class DefaultArrivalsRepository @Inject constructor( shortName: String?, longName: String?, favorite: Boolean - ) = withContext(Dispatchers.IO) { + ) { + importGate.awaitReady() val regionId = regionRepository.region.value?.id // Ensure the route row exists (stamped with the current region) before marking the favorite. - val values = ContentValues().apply { - put(ObaContract.Routes.SHORTNAME, shortName) - put(ObaContract.Routes.LONGNAME, longName) - regionId?.let { put(ObaContract.Routes.REGION_ID, it) } - } - ObaContract.Routes.insertOrUpdate(context, routeId, values, true) - ObaContract.RouteHeadsignFavorites.markAsFavorite(context, routeId, headsign, stopId, favorite) + routeDao.markRouteUsed(routeId, shortName, longName, regionId, System.currentTimeMillis()) + setRouteHeadsignFavorite(routeId, headsign, stopId, favorite) // Backfill the full route details so the long name can be shown later (was an AsyncTaskLoader). fetchAndStoreRouteDetails(routeId, regionId) } + /** + * Marks (or unmarks) a route/headsign/stop combination a favorite (delegating the DB-semantic + * reconciliation to [applyRouteHeadsignFavorite]), then reports bookmark analytics. + */ + private suspend fun setRouteHeadsignFavorite( + routeId: String, + headsign: String?, + stopId: String?, + favorite: Boolean + ) { + applyRouteHeadsignFavorite(routeHeadsignFavoriteDao, routeDao, routeId, headsign, stopId, favorite) + reportBookmarkAnalytics(routeId, headsign, stopId, favorite) + } + + private fun reportBookmarkAnalytics( + routeId: String, + headsign: String?, + stopId: String?, + favorite: Boolean + ) { + val event = context.getString( + if (favorite) R.string.analytics_label_star_route else R.string.analytics_label_unstar_route + ) + val param = "${routeId}_$headsign for ${stopId ?: "all stops"}" + ObaAnalytics.reportUiEvent( + FirebaseAnalytics.getInstance(context), + Application.get().plausibleInstance, + PlausibleAnalytics.REPORT_BOOKMARK_EVENT_URL, + event, + param, + ) + } + /** Route-details fetch via the modernized client; writes name/url back. */ private suspend fun fetchAndStoreRouteDetails(routeId: String, regionId: Long?) { val route = routeRepository.getRoute(routeId).getOrNull() ?: return @@ -393,19 +475,14 @@ class DefaultArrivalsRepository @Inject constructor( longName = route.description } - val values = ContentValues().apply { - put(ObaContract.Routes.SHORTNAME, shortName) - put(ObaContract.Routes.LONGNAME, longName) - put(ObaContract.Routes.URL, route.url) - regionId?.let { put(ObaContract.Routes.REGION_ID, it) } - } - ObaContract.Routes.insertOrUpdate(context, route.id, values, true) + routeDao.storeRouteDetails( + route.id, shortName, longName, route.url, regionId, System.currentTimeMillis() + ) } override suspend fun setRouteFilter(stopId: String, filter: Set) { - withContext(Dispatchers.IO) { - ObaContract.StopRouteFilters.set(context, stopId, ArrayList(filter)) - } + importGate.awaitReady() + stopRouteFilterDao.replaceForStop(stopId, filter.toList()) } override suspend fun setArrivalStyle(style: Int) { @@ -414,38 +491,43 @@ class DefaultArrivalsRepository @Inject constructor( } } - /** - * Observes the service-alerts table and emits the hide/show decisions on registration and after - * every change. [ObaProvider] fires `notifyChange` on every alert write, so a hide/un-hide from - * any surface re-emits here — the single source of truth the ViewModel derives hidden state from. - * [distinctUntilChanged] drops re-emissions from writes that don't change the decisions (e.g. - * marking an alert read). - */ override fun alertHideState(): Flow = - context.contentChanges(ObaContract.ServiceAlerts.CONTENT_URI) - .map { AlertHideState(ObaContract.ServiceAlerts.getHideDecisions()) } - .distinctUntilChanged() - .flowOn(Dispatchers.IO) + serviceAlertDao.hideDecisions() + .onStart { importGate.awaitReady() } + .map { rows -> AlertHideState(rows.associate { it.id to (it.hidden == 1) }) } - override suspend fun hideAlerts(ids: List) = setHidden(ids, hidden = true) + override suspend fun hideAlerts(ids: List) { + importGate.awaitReady() + for (id in ids) serviceAlertDao.setHidden(id, true) + } - override suspend fun showAlerts(ids: List) = setHidden(ids, hidden = false) + override suspend fun showAlerts(ids: List) { + importGate.awaitReady() + for (id in ids) serviceAlertDao.setHidden(id, false) + } - private suspend fun setHidden(ids: List, hidden: Boolean) { - withContext(Dispatchers.IO) { - for (id in ids) { - ObaContract.ServiceAlerts.insertOrUpdate(id, ContentValues(), false, hidden) - } - } + override suspend fun setAlertHidden(id: String, hidden: Boolean) { + importGate.awaitReady() + serviceAlertDao.setHidden(id, hidden) + } + + override suspend fun markAlertRead(id: String) { + importGate.awaitReady() + serviceAlertDao.markRead(id, System.currentTimeMillis()) + } + + override suspend fun hideAllRecordedAlerts() { + importGate.awaitReady() + serviceAlertDao.setAllHidden(1) } override fun alertDetails(id: String): AlertDetails? = - lastGood?.situation(id)?.let { + lastGood?.snapshot?.situation(id)?.let { AlertDetails(it.id, it.summary, it.description, it.url) } override fun lastLoaded(): ArrivalsLoaded? { - val snapshot = lastGood ?: return null + val snapshot = lastGood?.snapshot ?: return null return ArrivalsLoaded(snapshot.stop, snapshot.routes, snapshot.hasArrivals) } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsScreen.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsScreen.kt index f969c16604..85025c4764 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsScreen.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsScreen.kt @@ -294,6 +294,7 @@ fun ArrivalsScreen( handler = handler, onLoadMore = onLoadMore, onShowAllRoutes = onShowAllRoutes, + onHideAllAlerts = onHideAllAlerts, onShowHiddenAlerts = onShowHiddenAlerts ) @@ -415,6 +416,7 @@ internal fun ArrivalsList( handler: ArrivalActionHandler, onLoadMore: () -> Unit, onShowAllRoutes: () -> Unit, + onHideAllAlerts: () -> Unit, onShowHiddenAlerts: () -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), @@ -452,6 +454,14 @@ internal fun ArrivalsList( // composition so it can't peek through the collapsed sheet fold, then reveals with the drag. if (showFullList) { if (content.alerts.isNotEmpty()) { + item(key = "hide_all_alerts") { + // A prominent "hide all" affordance above the alerts (#1534) — the toolbar + // overflow's "hide alerts" is easy to miss, so surface the count and action here. + HideAllAlertsBanner( + shownAlertCount = content.alerts.size, + onHideAllAlerts = onHideAllAlerts + ) + } item(key = "alerts") { AlertList( alerts = content.alerts, @@ -542,6 +552,36 @@ internal fun ArrivalsList( } } +/** + * A banner above the alert list showing how many active alerts are shown, with a "hide all" action + * (#1534). Mirrors the hidden-alerts footnote below the list, but the action is a distinct button so + * tapping the count doesn't hide everything by accident. + */ +@Composable +private fun HideAllAlertsBanner(shownAlertCount: Int, onHideAllAlerts: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(start = 16.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pluralStringResource( + R.plurals.alert_shown_text, + shownAlertCount, + shownAlertCount + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onHideAllAlerts) { + Text(stringResource(R.string.hide_all)) + } + } +} + /** * The stop's service alerts, capped at [ALERT_PAGE_SIZE] rows with a paged "show more" link that * reveals the next page each tap — mirrors the arrivals list's "load more" so a busy alert feed diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModel.kt index 3401f0a7a6..93be960c67 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModel.kt @@ -75,10 +75,10 @@ class ArrivalsViewModel @AssistedInject constructor( /** * The UI state: the fetched [loaded] snapshot projected through the repository's hide/show - * decisions. Those decisions are the DB itself, observed as a flow — there is no in-memory hidden - * mirror to keep in sync, so a hide/un-hide from *any* surface (the swipe gesture, the alert - * dialog and its Undo, "show hidden alerts") is reflected here with nothing to reconcile. See - * #1593. + * decisions. Those decisions are the store itself, observed as a flow — there is no in-memory + * hidden mirror to keep in sync, so a hide/un-hide from *any* surface (the swipe gesture, the + * alert dialog and its Undo, "show hidden alerts") is reflected here with nothing to reconcile. + * See #1593. */ val state: StateFlow = combine(loaded, repository.alertHideState(), fatalError) { data, hideState, error -> @@ -90,9 +90,9 @@ class ArrivalsViewModel @AssistedInject constructor( }.stateIn(viewModelScope, SharingStarted.Eagerly, ArrivalsUiState.Loading) /** - * Emits the raw response after each successful load. The map panel uses this to recenter the - * map, move the FABs, and fire arrival-info tutorials (which need the response object) without - * the ViewModel itself touching Android. Empty for the standalone screen, which ignores it. + * Emits the map-relevant snapshot after each successful load. The map panel uses this to recenter + * the map, move the FABs, and fire arrival-info tutorials without the ViewModel itself touching + * Android. Empty for the standalone screen, which ignores it. */ private val _arrivalsLoaded = MutableSharedFlow(extraBufferCapacity = 1) val arrivalsLoaded: SharedFlow = _arrivalsLoaded.asSharedFlow() @@ -154,13 +154,12 @@ class ArrivalsViewModel @AssistedInject constructor( viewModelScope.launch { refresh() } } - /** Toggles the stop favorite, updating the header optimistically and persisting. The optimistic - * flag is edited straight into [loaded] (the next load overwrites it with the persisted value). */ + /** Toggles the stop favorite, updating the header optimistically and persisting. */ fun toggleFavorite() { - val data = loaded.value ?: return - val newValue = !data.header.isFavorite - loaded.value = data.copy(header = data.header.copy(isFavorite = newValue)) - viewModelScope.launch { repository.setStopFavorite(data.header.stopId, newValue) } + val content = state.value as? ArrivalsUiState.Content ?: return + val newValue = !content.header.isFavorite + loaded.value = loaded.value?.copy(header = content.header.copy(isFavorite = newValue)) + viewModelScope.launch { repository.setStopFavorite(content.header.stopId, newValue) } } /** Opens the route-favorite dialog for [actions] (the per-arrival "favorite route" tap). */ @@ -232,14 +231,8 @@ class ArrivalsViewModel @AssistedInject constructor( setRouteFilter(emptySet()) } - /** Hides a single alert (the row's swipe-to-hide gesture) by recording every id in its content - * group hidden, so the hide holds even after the feed republishes the alert under a new id. The - * screen reacts to the repository's hide-state flow re-emitting — no optimistic state. */ - fun hideAlert(alert: AlertItem) { - viewModelScope.launch { repository.hideAlerts(alert.situationIds.toList()) } - } - - /** Hides every currently shown alert (the toolbar "hide alerts" action). */ + /** Hides every currently active alert (the toolbar "hide alerts" action). The reactive [state] + * picks up the write with no refresh. */ fun hideAllAlerts() { val ids = activeSituationIds() if (ids.isEmpty()) return @@ -247,14 +240,19 @@ class ArrivalsViewModel @AssistedInject constructor( } /** Un-hides every alert (the "show hidden alerts" affordance): records the active alerts as - * explicitly shown, which reveals them even under the "hide all alerts" preference. The hide-state - * flow re-emits when the write lands and the screen reveals them — no local override to clear. */ + * explicitly shown, which reveals them even under the "hide all alerts" preference. */ fun showHiddenAlerts() { val ids = activeSituationIds() if (ids.isEmpty()) return viewModelScope.launch { repository.showAlerts(ids) } } + /** Hides a single alert row (the swipe gesture): records every situation id folded into the row + * so a hide follows the content even as the feed rotates its id. See #1593. */ + fun hideAlert(alert: AlertItem) { + viewModelScope.launch { repository.hideAlerts(alert.situationIds.toList()) } + } + /** Every situation id across the currently loaded active alerts (all grouped ids, deduped). */ private fun activeSituationIds(): List = loaded.value?.activeAlerts.orEmpty().flatMapTo(mutableSetOf()) { it.situationIds }.toList() @@ -262,11 +260,29 @@ class ArrivalsViewModel @AssistedInject constructor( /** The service-alert dialog's content for an alert id (read from the last good response). */ fun alertDetails(id: String): AlertDetails? = repository.alertDetails(id) - /** Projects a fetched snapshot through the DB hide/show decisions: a row is hidden when the user - * hid it, shown when they showed it, else the [ArrivalsData.hideAlertsByDefault] preference - * decides. Splits [activeAlerts] into the shown list and the hidden count. */ + /** Stamps the alert read when its dialog opens. */ + fun markAlertRead(id: String) { + viewModelScope.launch { repository.markAlertRead(id) } + } + + /** Hides a single alert by id (the dialog's Hide). */ + fun hideAlert(id: String) { + viewModelScope.launch { repository.setAlertHidden(id, true) } + } + + /** Un-hides a single alert by id (the dialog's Undo action). */ + fun unhideAlert(id: String) { + viewModelScope.launch { repository.setAlertHidden(id, false) } + } + + /** Hides every recorded alert (the dialog's Hide All). */ + fun hideAllRecordedAlerts() { + viewModelScope.launch { repository.hideAllRecordedAlerts() } + } + private fun ArrivalsData.toContent(hideState: AlertHideState): ArrivalsUiState.Content { val shown = activeAlerts.filterNot { hideState.isHidden(it, hideAlertsByDefault) } + val hiddenCount = activeAlerts.size - shown.size return ArrivalsUiState.Content( header = header, arrivals = arrivals, @@ -275,7 +291,7 @@ class ArrivalsViewModel @AssistedInject constructor( isStale = isStale, actions = actions, alerts = shown, - hiddenAlertCount = activeAlerts.size - shown.size, + hiddenAlertCount = hiddenCount, routeFilterOptions = routeFilterOptions, filteredRouteCount = filteredRouteCount, stopCode = stopCode, diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ConvertArrivals.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ConvertArrivals.kt index bb896d0e3d..8a525a2aef 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ConvertArrivals.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ConvertArrivals.kt @@ -25,6 +25,10 @@ import org.onebusaway.android.util.ArrivalInfoUtils * Builds the display [ArrivalInfo]s from [arrivals]: filters to [filter] routes (empty == all), * drops past arrivals unless the user opted in, and sorts by ETA. Callers feed it the [ArrivalData] * the io.client arrivals fetch ([org.onebusaway.android.api.StopArrivals.arrivals]) produces. + * + * [isFavorite] resolves each arrival's route/headsign favorite state; the caller precomputes it from + * one favorites query (the legacy per-row ContentProvider lookup is gone). Reminder lists that don't + * render the favorite star pass a constant `false`. */ fun convertArrivals( context: Context, @@ -32,12 +36,18 @@ fun convertArrivals( filter: Collection?, ms: Long, includeArrivalDepartureInStatusLabel: Boolean, + isFavorite: (routeId: String, headsign: String?, stopId: String) -> Boolean, ): List { val showNegativeArrivals = PreferencesEntryPoint.get(context) .getBoolean(R.string.preference_key_show_negative_arrivals, true) val selected = if (!filter.isNullOrEmpty()) arrivals.filter { it.routeId in filter } else arrivals return selected - .map { ArrivalInfo(context, it, ms, includeArrivalDepartureInStatusLabel) } + .map { + ArrivalInfo( + context, it, ms, includeArrivalDepartureInStatusLabel, + isFavorite(it.routeId, it.headsign, it.stopId), + ) + } .filter { it.eta >= 0 || showNegativeArrivals } .sortedWith(ArrivalInfoUtils.InfoComparator()) } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/components/ArrivalsPanel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/components/ArrivalsPanel.kt index 92b35ff3cd..2728e5cf4b 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/components/ArrivalsPanel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/components/ArrivalsPanel.kt @@ -183,6 +183,7 @@ fun ArrivalsPanel( handler = handler, onLoadMore = viewModel::loadMore, onShowAllRoutes = viewModel::showAllRoutes, + onHideAllAlerts = viewModel::hideAllAlerts, onShowHiddenAlerts = viewModel::showHiddenAlerts, modifier = Modifier.weight(1f), listState = listState, diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/dialogs/SituationDialog.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/dialogs/SituationDialog.kt index 3d234efc5c..ebf6bfbf45 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/dialogs/SituationDialog.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/dialogs/SituationDialog.kt @@ -15,7 +15,6 @@ */ package org.onebusaway.android.ui.arrivals.dialogs -import android.content.ContentValues import android.content.Intent import android.net.Uri import android.text.Html @@ -30,16 +29,20 @@ import androidx.appcompat.app.AppCompatActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.onebusaway.android.R import org.onebusaway.android.ui.arrivals.AlertDetails -import org.onebusaway.android.provider.ObaContract -import org.onebusaway.android.util.PreferenceUtils /** * Shows a service alert (situation) in a dialog and marks it read. The buttons mirror the legacy * SituationDialogFragment: Hide flags this alert hidden (with an Undo snackbar), Hide All hides * every current alert and sets the hide-new-alerts preference, Close just dismisses. * + * The persistence of read/hidden state is delegated to the host via callbacks so this dialog stays + * pure UI (no ContentProvider/preference access) — the host wires them to the ViewModel/repository. + * + * @param onMarkRead persist that the alert was read (invoked when the dialog opens) + * @param onHide persist that this alert is hidden (the Hide button) + * @param onUnhide persist that this alert is no longer hidden, and refresh (the Undo action) + * @param onHideAll hide every current alert and suppress new ones (the Hide All button) * @param onDismiss called when the dialog closes; true when the alert was hidden by the user - * @param onUndo called when the user taps the Undo snackbar after hiding the alert * @param showUndoSnackbar shows a snackbar (optionally with an undo action). The host supplies this so * the dialog doesn't reach for a specific View — the standalone activity anchors it to its arrivals * root, while the Compose hosts (home sheet, NavHost destination) drive a Compose `SnackbarHost`. @@ -47,32 +50,29 @@ import org.onebusaway.android.util.PreferenceUtils fun showSituationDialog( activity: AppCompatActivity, alert: AlertDetails, + onMarkRead: () -> Unit, + onHide: () -> Unit, + onUnhide: () -> Unit, + onHideAll: () -> Unit, onDismiss: (isAlertHidden: Boolean) -> Unit, - onUndo: () -> Unit, showUndoSnackbar: (messageRes: Int, actionRes: Int?, onAction: (() -> Unit)?) -> Unit ) { - val situationId = alert.id - val dialog = MaterialAlertDialogBuilder(activity) .setTitle(alert.summary) .setMessage(buildMessage(activity, alert)) .setPositiveButton(R.string.hide) { d, _ -> - // Update the database to indicate that this alert has been hidden - ObaContract.ServiceAlerts.insertOrUpdate(situationId, ContentValues(), false, true) + onHide() showUndoSnackbar( R.string.alert_hidden_snackbar_text, R.string.alert_hidden_snackbar_action ) { - ObaContract.ServiceAlerts.insertOrUpdate(situationId, ContentValues(), false, false) - onUndo() + onUnhide() } d.dismiss() onDismiss(true) } .setNeutralButton(R.string.hide_all) { d, _ -> - // Hide existing alerts in the database, and the preference hides new ones - ObaContract.ServiceAlerts.hideAllAlerts() - PreferenceUtils.saveBoolean(activity.getString(R.string.preference_key_hide_alerts), true) + onHideAll() showUndoSnackbar(R.string.all_alert_hidden_snackbar_text, null, null) d.dismiss() onDismiss(true) @@ -87,8 +87,7 @@ fun showSituationDialog( dialog.findViewById(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance() - // Update the database to indicate that this alert has been read - ObaContract.ServiceAlerts.insertOrUpdate(situationId, ContentValues(), true, null) + onMarkRead() } /** diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeNavHost.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeNavHost.kt index 623a25342f..1d91c427fd 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeNavHost.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeNavHost.kt @@ -19,6 +19,7 @@ package org.onebusaway.android.ui.home import android.content.Context import android.content.Intent +import android.util.Log import android.view.accessibility.AccessibilityManager import androidx.annotation.StringRes import androidx.compose.runtime.Composable @@ -43,6 +44,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.crashlytics.FirebaseCrashlytics import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -71,9 +73,9 @@ import org.onebusaway.android.ui.nav.IntentRouteMapper import org.onebusaway.android.ui.nav.NavHelp import org.onebusaway.android.ui.nav.NavRoutes import org.onebusaway.android.ui.nav.RESULT_MAP_ROUTE_ID +import org.onebusaway.android.ui.nav.consumeRouteReveal import org.onebusaway.android.ui.nav.RESULT_MAP_STOP_ID -import org.onebusaway.android.ui.nav.RESULT_MAP_STOP_LAT -import org.onebusaway.android.ui.nav.RESULT_MAP_STOP_LON +import org.onebusaway.android.ui.nav.consumeStopReveal import org.onebusaway.android.ui.nav.navigateFromHome import org.onebusaway.android.ui.settings.settingsGraph import org.onebusaway.android.ui.survey.SurveyViewModel @@ -84,12 +86,15 @@ import org.onebusaway.android.ui.tutorial.ArrivalTutorial import org.onebusaway.android.util.ExternalIntents import org.onebusaway.android.util.PreferenceUtils +private const val TAG = "HomeNavHost" + /** * The HOME destination's dependency surface — the one destination that consumes the full home bundle - * (the feature ViewModels, the list VMs, the arrivals factory, the callbacks, and the map seed). Built - * once in [HomeActivity.onCreate] and passed to [HomeNavHost]. Every *other* destination instead - * recovers the host via `LocalContext.current.findActivity()` and reads its (non-private) members, so - * only HOME needs this holder (the six feature VMs + the list VMs are private to the activity). + * (the feature ViewModels, the list VMs, the arrivals factory, the Activity-bound [activityActions], and + * the map seed). Built once in [HomeActivity.onCreate] and passed to [HomeNavHost]. Every *other* + * destination instead recovers the host via `LocalContext.current.findActivity()` and reads its + * (non-private) members, so only HOME needs this holder (the six feature VMs + the list VMs are private + * to the activity). */ class HomeDestinationDeps( val homeViewModel: HomeViewModel, @@ -126,24 +131,30 @@ fun HomeNavHost( val revealStopId by handle.getStateFlow(RESULT_MAP_STOP_ID, null) .collectAsStateWithLifecycle() LaunchedEffect(revealRouteId) { - revealRouteId?.let { routeId -> - home.mapViewModel.toRoute(routeId) - handle[RESULT_MAP_ROUTE_ID] = null - } + if (revealRouteId == null) return@LaunchedEffect + // Read + consume the route id and its optional direction anchor atomically via the typed + // helper (which owns both key names), mirroring the stop branch below. + val request = handle.consumeRouteReveal() ?: return@LaunchedEffect + home.mapViewModel.toRoute(request) } LaunchedEffect(revealStopId) { - revealStopId?.let { stopId -> - val lat = handle.get(RESULT_MAP_STOP_LAT) - val lon = handle.get(RESULT_MAP_STOP_LON) - if (lat != null && lon != null) { - home.homeViewModel.onStopFocused(FocusedStop(stopId, null, null, lat, lon)) - home.homeViewModel.markPendingMapFocus() - } - // Consume all three keys together (STOP_ID gates application; lat/lon are nulled for - // symmetry so a stale pair can't linger past the reveal). - handle[RESULT_MAP_STOP_ID] = null - handle[RESULT_MAP_STOP_LAT] = null - handle[RESULT_MAP_STOP_LON] = null + if (revealStopId == null) return@LaunchedEffect + // Read + consume all three keys atomically via the typed helper (which owns the key names + // and Double types). A non-null result applies the focus; a null result here means STOP_ID + // was present but lat/lon were missing. + val reveal = handle.consumeStopReveal() + if (reveal != null) { + home.homeViewModel.onStopFocused( + FocusedStop(reveal.stopId, null, null, reveal.lat, reveal.lon) + ) + home.homeViewModel.markPendingMapFocus() + } else { + // Keys already consumed; record the dropped focus so the latent path is findable + // (see consumeStopReveal for why this is only reachable on a corrupted handle). + Log.w(TAG, "Dropped a partial stop reveal: stop id present but lat/lon missing") + FirebaseCrashlytics.getInstance().recordException( + IllegalStateException("Partial stop reveal: stop id present but lat/lon missing") + ) } } val state by home.homeViewModel.uiState.collectAsStateWithLifecycle() diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeScreen.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeScreen.kt index 835d684a8e..8047c9a169 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeScreen.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeScreen.kt @@ -62,6 +62,7 @@ import org.onebusaway.android.ui.arrivals.ArrivalsLoaded import org.onebusaway.android.ui.nav.ReminderEditorArgs import org.onebusaway.android.map.RouteHeader import org.onebusaway.android.map.MapViewModel +import org.onebusaway.android.map.ShowRouteRequest import org.onebusaway.android.ui.arrivals.ArrivalsViewModel import org.onebusaway.android.ui.compose.navigationBarBottomPadding import org.onebusaway.android.ui.compose.rememberSheetExpandProgress @@ -138,7 +139,7 @@ class HomeActivityActions( val onSheetSettled: (ArrivalsSheetState, Int) -> Unit, val onClearFocus: () -> Unit, val onArrivalsLoaded: (ArrivalsLoaded) -> Unit, - val onShowRouteOnMap: (String) -> Unit, + val onShowRouteOnMap: (ShowRouteRequest) -> Unit, val onToggleSheet: () -> Unit, val onCancelRouteMode: () -> Unit, ) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeViewModel.kt index bebe130182..4c4e9679be 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import org.onebusaway.android.map.ShowRouteRequest import org.onebusaway.android.region.Region import org.onebusaway.android.models.ObaRoute import org.onebusaway.android.models.ObaStop @@ -242,10 +243,13 @@ class HomeViewModel @Inject constructor( emitMapDirective(MapDirective.FocusStop(stop, routes, settledSheet == ArrivalsSheetState.Expanded)) } - /** "Show vehicles on map" — collapse the sheet (screen), then switch the map to route mode. */ - fun requestShowRouteOnMap(routeId: String) { + /** + * "Show vehicles on map" — collapse the sheet (screen), then switch the map to route mode. The + * [request] carries the route and (for the arrivals-row launch) the direction stop to focus on. + */ + fun requestShowRouteOnMap(request: ShowRouteRequest) { emit(SheetCommand.CollapseSheet) - emitMapDirective(MapDirective.ShowRoute(routeId)) + emitMapDirective(MapDirective.ShowRoute(request)) } /** @@ -392,8 +396,8 @@ sealed interface MapDirective { /** Animate the camera to recenter on the currently focused stop (sheet expanded). */ data class RecenterOnFocusedStop(val lat: Double, val lon: Double) : MapDirective - /** Enter route mode for the given route (the "show vehicles on map" action). */ - data class ShowRoute(val routeId: String) : MapDirective + /** Enter route mode for [request]'s route (the "show vehicles on map" action). */ + data class ShowRoute(val request: ShowRouteRequest) : MapDirective /** Clear the map's render focus (back-press from a peeking arrivals sheet). */ object ClearFocus : MapDirective diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/RegionPickerHost.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/RegionPickerHost.kt index 0940468368..fdb09eb491 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/RegionPickerHost.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/RegionPickerHost.kt @@ -23,8 +23,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -36,15 +40,42 @@ import org.onebusaway.android.region.Region /** * Renders the forced-choice region picker when [RegionPickerViewModel] reports the repository needs a manual - * selection. Hosted at the activity's setContent root (a sibling of the NavHost) so the dialog — which is in - * its own window — overlays whatever screen triggered the refresh (Home on cold launch, or the Advanced - * settings screen when the experimental-regions toggle forces a re-resolve). + * selection, or a retryable "couldn't load regions" dialog when resolution failed catastrophically. Hosted + * at the activity's setContent root (a sibling of the NavHost) so the dialogs — each in their own window — + * overlay whatever screen triggered the refresh (Home on cold launch, or the Advanced settings screen when + * the experimental-regions toggle forces a re-resolve). */ @Composable fun RegionPickerHost() { val viewModel: RegionPickerViewModel = hiltViewModel() val regions by viewModel.picker.collectAsStateWithLifecycle() + val failed by viewModel.failed.collectAsStateWithLifecycle() + // NeedsManualChoice and Failed are mutually exclusive repository states, so at most one shows. regions?.let { RegionChooserDialog(it, viewModel::choose) } + if (failed) RegionLoadFailedDialog(onRetry = viewModel::retry) +} + +/** + * The catastrophic-failure affordance (no network, no cache, server unreachable): a dialog explaining the + * app couldn't load region info with a Retry that re-attempts resolution. Dismissible (back/scrim) unlike + * the forced picker — a cached region may still let the app function, so we don't hard-block the user. + */ +@Composable +private fun RegionLoadFailedDialog(onRetry: () -> Unit) { + // Dismiss simply drops the dialog for this attempt; the state re-surfaces on the next failed refresh. + var dismissed by remember { mutableStateOf(false) } + if (dismissed) return + AlertDialog( + onDismissRequest = { dismissed = true }, + title = { Text(stringResource(R.string.region_load_failed_title)) }, + text = { Text(stringResource(R.string.region_load_failed_message)) }, + confirmButton = { + TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) } + }, + dismissButton = { + TextButton(onClick = { dismissed = true }) { Text(stringResource(R.string.dismiss)) } + }, + ) } /** diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/RegionPickerViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/RegionPickerViewModel.kt index 58d021713c..4111b67f84 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/RegionPickerViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/RegionPickerViewModel.kt @@ -22,19 +22,20 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.onebusaway.android.region.Region import org.onebusaway.android.region.RegionRepository import org.onebusaway.android.region.RegionState /** - * The forced-choice region picker as a self-contained reactive feature (mirrors the other - * `regionRepo`-observing home VMs, e.g. [org.onebusaway.android.ui.home.widealert.WideAlertViewModel]): - * region resolution lives entirely in [RegionRepository], which raises [RegionState.NeedsManualChoice] - * whenever no region can be auto-selected. This exposes that as the [picker] list and resolves it via - * [choose] — so the picker shows regardless of which screen triggered the refresh (it's rendered at the - * activity root), rather than being a HomeViewModel/HomeUiState concern. + * The forced-choice region picker (and its catastrophic-failure sibling) as a self-contained reactive + * feature (mirrors the other `regionRepo`-observing home VMs, e.g. + * [org.onebusaway.android.ui.home.widealert.WideAlertViewModel]): region resolution lives entirely in + * [RegionRepository], which raises [RegionState.NeedsManualChoice] whenever no region can be auto-selected + * and [RegionState.Failed] when region info couldn't be loaded at all. This exposes the former as the + * [picker] list (resolved via [choose]) and the latter as [failed] (retried via [retry]) — so both show + * regardless of which screen triggered the refresh (they're rendered at the activity root), rather than + * being a HomeViewModel/HomeUiState concern. */ @HiltViewModel class RegionPickerViewModel @Inject constructor( @@ -48,11 +49,19 @@ class RegionPickerViewModel @Inject constructor( private val _picker = MutableStateFlow?>(null) val picker: StateFlow?> = _picker.asStateFlow() + // Whether region info couldn't be loaded at all (catastrophic failure) — drives the retryable + // "couldn't load regions" affordance. A resolving/active/manual-choice transition clears it. + private val _failed = MutableStateFlow(false) + val failed: StateFlow = _failed.asStateFlow() + init { + // One collector drives both projections so the picker and the failure affordance can never + // disagree about the current state. viewModelScope.launch { - regionRepo.state - .map { (it as? RegionState.NeedsManualChoice)?.regions } - .collect { _picker.value = it } + regionRepo.state.collect { state -> + _picker.value = (state as? RegionState.NeedsManualChoice)?.regions + _failed.value = state is RegionState.Failed + } } } @@ -60,4 +69,9 @@ class RegionPickerViewModel @Inject constructor( fun choose(region: Region) { viewModelScope.launch { regionRepo.choose(region) } } + + /** Re-attempt resolution after a [failed] load; a success drives [failed] back to false. */ + fun retry() { + viewModelScope.launch { regionRepo.refresh() } + } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/arrivals/ArrivalsSheetHost.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/arrivals/ArrivalsSheetHost.kt index 3e73bca808..84d37fbe0d 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/arrivals/ArrivalsSheetHost.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/arrivals/ArrivalsSheetHost.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.flow.first import org.onebusaway.android.R import org.onebusaway.android.app.di.PreferencesEntryPoint +import org.onebusaway.android.map.ShowRouteRequest import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.ui.arrivals.ArrivalsLoaded import org.onebusaway.android.ui.arrivals.components.ArrivalsPanel @@ -73,7 +74,7 @@ internal fun ArrivalsSheetHost( expandProgress: () -> Float, arrivalsViewModelFactory: ArrivalsViewModel.Factory, onArrivalsLoaded: (ArrivalsLoaded) -> Unit, - onShowRouteOnMap: (String) -> Unit, + onShowRouteOnMap: (ShowRouteRequest) -> Unit, onShowTrip: (tripId: String, stopId: String) -> Unit, onEditReminder: (args: ReminderEditorArgs) -> Unit, onToggleSheet: () -> Unit, diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/map/MapFeature.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/map/MapFeature.kt index a24f3e7ab3..9c1a13dc56 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/map/MapFeature.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/map/MapFeature.kt @@ -21,11 +21,18 @@ import android.provider.Settings import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -37,9 +44,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.shadow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -180,7 +190,7 @@ fun MapFeature( when (directive) { is MapDirective.RecenterOnFocusedStop -> mapViewModel.recenterOnFocusedStop(directive.lat, directive.lon) - is MapDirective.ShowRoute -> mapViewModel.toRoute(directive.routeId) + is MapDirective.ShowRoute -> mapViewModel.toRoute(directive.request) MapDirective.ClearFocus -> mapViewModel.clearFocus() is MapDirective.FocusStop -> mapViewModel.focusStop(directive.stop, directive.routes, directive.overlayExpanded) @@ -270,6 +280,23 @@ fun MapFeature( initialZoom = seed.zoom, ) + // "Zoom in to see more stops" — shown when the viewport stop load was truncated (the API's + // limitExceeded), i.e. more stops match the viewport than were returned. Driven purely by map state. + val moreStops by mapViewModel.moreStopsAvailable.collectAsStateWithLifecycle() + // The map sits below HomeTopBar (which already consumes the status-bar inset), so the banner is + // flush at the map's top edge — no extra inset. clipToBounds clips the upward slide at that edge so + // the bar tucks up behind the title bar instead of drawing over it. + Box( + Modifier + .fillMaxSize() + .clipToBounds() + ) { + MoreStopsBanner( + visible = moreStops, + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), + ) + } + // The welcome tutorial's map-stop spotlight, wired from the flavor-neutral map seam (the published // projector + the shared stop list) so this host knows nothing of the underlying map SDK. A tap // focuses the chosen stop exactly like a marker tap, continuing into the arrivals tutorial. @@ -331,6 +358,45 @@ fun MapFeature( ) } +/** + * The "zoom in to see more stops" hint: a full-width, text-height bar at the top edge of the map that + * slides down to appear and up (tucking behind the home top bar) to disappear. Shown while the last + * viewport stop load was truncated by the API; purely state-driven, no dismiss button. The status-bar + * inset is already consumed by HomeTopBar above the map, so the caller's slot adds no inset — only the + * slide clipping (clipToBounds). + */ +@Composable +private fun MoreStopsBanner(visible: Boolean, modifier: Modifier = Modifier) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = slideInVertically(initialOffsetY = { -it }), + exit = slideOutVertically(targetOffsetY = { -it }), + ) { + // A plain Box (not a Surface): a non-clickable Surface still consumes pointer events across its + // bounds, so a pan/tap/pinch that starts on the banner strip would be swallowed instead of + // reaching the map underneath. A Box with just background/shadow is not hit-testable, so gestures + // fall through to the map. + Box( + modifier = Modifier + .fillMaxWidth() + .shadow(4.dp) + // A neutral, informational tint (not a warning), alpha'd so the map shows through slightly. + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f)), + ) { + Text( + text = stringResource(R.string.map_zoom_in_for_more_stops), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + /** The viewport (or device) is outside the current region (ported from GoogleMapHost.showOutOfRange). */ @Composable private fun OutOfRangeDialog(regionName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/nav/ExtraDestinations.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/nav/ExtraDestinations.kt index 2a108759d4..07332c0f3d 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/nav/ExtraDestinations.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/home/nav/ExtraDestinations.kt @@ -39,7 +39,7 @@ import org.onebusaway.android.ui.nightlight.NightLightRoute import org.onebusaway.android.ui.searchresults.SearchResultsRoute import org.onebusaway.android.ui.searchresults.SearchResultsViewModel import org.onebusaway.android.ui.survey.SurveyWebViewScreen -import org.onebusaway.android.util.DBUtil +import org.onebusaway.android.app.di.DatabaseEntryPoint /** * The standalone one-off destinations that don't belong to a larger feature graph: the donation @@ -122,15 +122,13 @@ fun NavGraphBuilder.extraDestinations(navController: NavHostController) { viewModel = searchVm, onBack = { navController.popBackStack() }, onRouteListStops = { route -> - DBUtil.addRouteToDB( - activity, route.id, route.shortName, route.longName, route.url - ) + DatabaseEntryPoint.get(activity).routeRecorder() + .recordDetails(route.id, route.shortName, route.longName, route.url) navController.navigate(NavRoutes.routeInfo(route.id)) }, onRouteShowOnMap = { route -> - DBUtil.addRouteToDB( - activity, route.id, route.shortName, route.longName, route.url - ) + DatabaseEntryPoint.get(activity).routeRecorder() + .recordDetails(route.id, route.shortName, route.longName, route.url) navController.revealRouteOnMap(route.id) }, onStopArrivals = { stop -> diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListNav.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListNav.kt index de52469799..59a6e34bd4 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListNav.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListNav.kt @@ -20,12 +20,11 @@ import org.onebusaway.android.ui.arrivals.ArrivalsListLauncher import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import org.onebusaway.android.R -import org.onebusaway.android.provider.ObaContract +import org.onebusaway.android.app.di.DatabaseEntryPoint import org.onebusaway.android.ui.common.Shortcuts import org.onebusaway.android.ui.search.RouteSearchResult import org.onebusaway.android.ui.search.StopSearchResult import org.onebusaway.android.util.ExternalIntents -import org.onebusaway.android.util.ReminderUtils /** * Shared navigation and row-action wiring for the My-tab list destinations (recent/starred × @@ -103,9 +102,8 @@ internal fun AppCompatActivity.reminderActions( RowAction(getString(R.string.trip_list_context_edit)) { editReminder(reminder, onEdit) }, RowAction(getString(R.string.trip_list_context_delete)) { confirmDeleteReminder(this) { - ReminderUtils.requestDeleteAlarm( - this, ObaContract.Trips.buildUri(reminder.tripId, reminder.stopId) - ) + DatabaseEntryPoint.get(this).reminderRepository() + .deleteReminderInBackground(reminder.tripId, reminder.stopId) } }, RowAction(getString(R.string.trip_list_context_showstop)) { diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListRepository.kt index 75ba79598f..238dd2edd6 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/mylists/MyListRepository.kt @@ -16,8 +16,6 @@ package org.onebusaway.android.ui.mylists import android.content.Context -import android.database.Cursor -import android.net.Uri import android.text.format.DateUtils import androidx.annotation.ArrayRes import androidx.annotation.ColorRes @@ -31,33 +29,39 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.withContext import org.onebusaway.android.R +import org.onebusaway.android.app.di.DatabaseEntryPoint import org.onebusaway.android.app.di.NetworkEntryPoint import org.onebusaway.android.app.di.RegionEntryPoint -import org.onebusaway.android.provider.ObaContract -import org.onebusaway.android.provider.contentChanges +import org.onebusaway.android.database.oba.ReminderRow +import org.onebusaway.android.database.oba.RouteListRow +import org.onebusaway.android.database.oba.StopListRow +import org.onebusaway.android.database.oba.TripDepartureTime import org.onebusaway.android.ui.arrivals.ArrivalInfo import org.onebusaway.android.ui.arrivals.convertArrivals import org.onebusaway.android.util.DisplayFormat import org.onebusaway.android.util.MyTextUtils import org.onebusaway.android.util.PreferenceUtils -import org.onebusaway.android.util.ReminderUtils import org.onebusaway.android.util.getRouteDisplayName /** - * A My-tab list backed by the content provider: [observe] re-emits whenever the underlying table - * changes (the legacy `ContentObserver` behavior). [remove], [clearAll], and [setSort] are optional - * capabilities — a list overrides the ones it supports and inherits a no-op otherwise (e.g. recents - * don't sort; reminders delete via the host, not the repository). All ContentResolver/cursor work is - * quarantined on [Dispatchers.IO] so [MyListViewModel] stays Context-free and JVM-testable. + * A My-tab list backed by the unified Room database: [observe] re-emits whenever the underlying table + * changes (Room's per-table invalidation, replacing the legacy `ContentObserver`). [remove], + * [clearAll], and [setSort] are optional capabilities — a list overrides the ones it supports and + * inherits a no-op otherwise (e.g. recents don't sort; reminders delete via the host, not the + * repository). DAOs are resolved from a [DatabaseEntryPoint] because these repositories are hand-built + * from a [Context] at the Compose call site; all work stays on [Dispatchers.IO] so [MyListViewModel] + * stays Context-free and JVM-testable. + * + * The recent/starred lists scope to the active region reactively (via [RegionEntryPoint]'s region + * flow), so switching regions refreshes the lists immediately — the legacy lists only refreshed on the + * next stop/route write. */ interface MyListRepository { fun observe(): Flow> @@ -73,126 +77,81 @@ const val SORT_BY_NAME = 0 const val SORT_BY_FREQUENCY = 1 /** "Recently used" = accessed within the last 7 days, or used at least once; newest first, capped at 20. */ -private const val RECENT_LIMIT = "20" private val RECENT_WINDOW_MS = 7 * DateUtils.DAY_IN_MILLIS -/** The recent-list WHERE clause (the legacy "recently used" query); [cutoffMs] is the window start. */ -private fun recentSelection(accessTime: String, useCount: String, cutoffMs: Long, regionWhere: String) = - "(($accessTime IS NOT NULL AND $accessTime > $cutoffMs) OR ($useCount > 0))$regionWhere" - -private fun regionWhere(context: Context, regionField: String): String { - val region = RegionEntryPoint.get(context).region.value ?: return "" - return " AND ($regionField=${region.id} OR $regionField IS NULL)" -} - -private val STOP_PROJECTION = arrayOf( - ObaContract.Stops._ID, - ObaContract.Stops.UI_NAME, - ObaContract.Stops.DIRECTION, - ObaContract.Stops.LATITUDE, - ObaContract.Stops.LONGITUDE, - ObaContract.Stops.FAVORITE -) +private fun recentCutoff(): Long = System.currentTimeMillis() - RECENT_WINDOW_MS -private fun Cursor.toStopItem(context: Context): StopListItem { - val rawDirection = getString(2) - val directionText = rawDirection?.takeIf { it.isNotEmpty() } +private fun StopListRow.toStopItem(context: Context): StopListItem { + val directionText = direction?.takeIf { it.isNotEmpty() } ?.let { context.getString(DisplayFormat.getStopDirectionText(it)) } ?.takeIf { it.isNotEmpty() } return StopListItem( - id = getString(0), - name = getString(1).orEmpty(), - rawDirection = rawDirection, + id = id, + name = uiName.orEmpty(), + rawDirection = direction, directionText = directionText, - lat = getDouble(3), - lon = getDouble(4), - isFavorite = getInt(5) == 1 + lat = latitude, + lon = longitude, + isFavorite = favorite == 1, ) } -private val ROUTE_PROJECTION = arrayOf( - ObaContract.Routes._ID, - ObaContract.Routes.SHORTNAME, - ObaContract.Routes.LONGNAME, - ObaContract.Routes.URL -) - -private fun Cursor.toRouteItem() = RouteListItem( - id = getString(0), - shortName = getString(1).orEmpty(), - longName = getString(2)?.takeIf { it.isNotEmpty() }, - url = getString(3)?.takeIf { it.isNotEmpty() } +private fun RouteListRow.toRouteItem() = RouteListItem( + id = id, + shortName = shortName, + longName = longName?.takeIf { it.isNotEmpty() }, + url = url?.takeIf { it.isNotEmpty() }, ) /** Recently viewed stops, marked unused on removal/clear. */ class RecentStopsRepository(private val context: Context) : MyListRepository { + private val entryPoint = DatabaseEntryPoint.get(context) + private val stopDao = entryPoint.stopDao() + private val importGate = entryPoint.importGate() + private val region = RegionEntryPoint.get(context).region + + @OptIn(ExperimentalCoroutinesApi::class) override fun observe(): Flow> = - context.contentChanges(ObaContract.Stops.CONTENT_URI) - .conflate() - .map { queryRecentStops() } + region.flatMapLatest { r -> stopDao.recents(recentCutoff(), r?.id) } + .map { rows -> rows.map { it.toStopItem(context) } } .flowOn(Dispatchers.IO) - override suspend fun remove(id: String) = withContext(Dispatchers.IO) { - ObaContract.Stops.markAsUnused( - context, Uri.withAppendedPath(ObaContract.Stops.CONTENT_URI, id) - ) - Unit - } - - override suspend fun clearAll() = withContext(Dispatchers.IO) { - ObaContract.Stops.markAsUnused(context, ObaContract.Stops.CONTENT_URI) - Unit + // Gate the writes: a remove/clear racing the one-time importer's clear-then-insert would otherwise + // be silently wiped. Reads self-heal via Room invalidation once the import commits, so they don't. + override suspend fun remove(id: String) { + importGate.awaitReady() + stopDao.markUnused(id) } - private fun queryRecentStops(): List { - val cutoff = System.currentTimeMillis() - RECENT_WINDOW_MS - val selection = recentSelection( - ObaContract.Stops.ACCESS_TIME, ObaContract.Stops.USE_COUNT, cutoff, - regionWhere(context, ObaContract.Stops.REGION_ID) - ) - val uri = ObaContract.Stops.CONTENT_URI.buildUpon() - .appendQueryParameter("limit", RECENT_LIMIT).build() - val sort = "${ObaContract.Stops.ACCESS_TIME} desc, ${ObaContract.Stops.USE_COUNT} desc" - return context.contentResolver.query(uri, STOP_PROJECTION, selection, null, sort) - ?.use { c -> buildList { while (c.moveToNext()) add(c.toStopItem(context)) } } - ?: emptyList() + override suspend fun clearAll() { + importGate.awaitReady() + stopDao.markAllUnused() } } /** Recently viewed routes, marked unused on removal/clear. */ class RecentRoutesRepository(private val context: Context) : MyListRepository { + private val entryPoint = DatabaseEntryPoint.get(context) + private val routeDao = entryPoint.routeDao() + private val importGate = entryPoint.importGate() + private val region = RegionEntryPoint.get(context).region + + @OptIn(ExperimentalCoroutinesApi::class) override fun observe(): Flow> = - context.contentChanges(ObaContract.Routes.CONTENT_URI) - .conflate() - .map { queryRecentRoutes() } + region.flatMapLatest { r -> routeDao.recents(recentCutoff(), r?.id) } + .map { rows -> rows.map { it.toRouteItem() } } .flowOn(Dispatchers.IO) - override suspend fun remove(id: String) = withContext(Dispatchers.IO) { - ObaContract.Routes.markAsUnused( - context, Uri.withAppendedPath(ObaContract.Routes.CONTENT_URI, id) - ) - Unit - } - - override suspend fun clearAll() = withContext(Dispatchers.IO) { - ObaContract.Routes.markAsUnused(context, ObaContract.Routes.CONTENT_URI) - Unit + override suspend fun remove(id: String) { + importGate.awaitReady() + routeDao.markUnused(id) } - private fun queryRecentRoutes(): List { - val cutoff = System.currentTimeMillis() - RECENT_WINDOW_MS - val selection = recentSelection( - ObaContract.Routes.ACCESS_TIME, ObaContract.Routes.USE_COUNT, cutoff, - regionWhere(context, ObaContract.Routes.REGION_ID) - ) - val uri = ObaContract.Routes.CONTENT_URI.buildUpon() - .appendQueryParameter("limit", RECENT_LIMIT).build() - val sort = "${ObaContract.Routes.ACCESS_TIME} desc, ${ObaContract.Routes.USE_COUNT} desc" - return context.contentResolver.query(uri, ROUTE_PROJECTION, selection, null, sort) - ?.use { c -> buildList { while (c.moveToNext()) add(c.toRouteItem()) } } - ?: emptyList() + override suspend fun clearAll() { + importGate.awaitReady() + routeDao.markAllUnused() } } @@ -205,6 +164,10 @@ private fun saveSortOrder(context: Context, order: Int, @ArrayRes optionsRes: In /** Starred stops, sorted by name or frequency, with live next-arrivals refreshed on a 60s poll. */ class StarredStopsRepository(private val context: Context) : MyListRepository { + private val entryPoint = DatabaseEntryPoint.get(context) + private val stopDao = entryPoint.stopDao() + private val importGate = entryPoint.importGate() + private val region = RegionEntryPoint.get(context).region private val sort = MutableStateFlow(PreferenceUtils.getStopSortOrderFromPreferences()) override fun setSort(order: Int) { @@ -214,63 +177,49 @@ class StarredStopsRepository(private val context: Context) : MyListRepository> = - combine(context.contentChanges(ObaContract.Stops.CONTENT_URI).conflate(), sort) { _, order -> - queryStarredStops(order) - }.flatMapLatest { stops -> - // Restart the arrivals poll whenever the stop set (or sort) changes: show the stops with - // their Loading badges first, then re-emit them with each poll's results merged in. - if (stops.isEmpty()) { - flowOf(stops) - } else { - arrivalsPoll(stops.map { it.id }) - .map { byStop -> - stops.map { it.copy(arrivals = StopArrivals.Loaded(byStop[it.id].orEmpty())) } - } - .onStart { emit(stops) } + combine(region, sort) { r, order -> r?.id to order } + .flatMapLatest { (regionId, order) -> + val rows = if (order == SORT_BY_FREQUENCY) { + stopDao.starredByFrequency(regionId) + } else { + stopDao.starredByName(regionId) + } + rows.map { list -> list.map { it.toStopItem(context).copy(arrivals = StopArrivals.Loading) } } } - }.flowOn(Dispatchers.IO) - - override suspend fun remove(id: String) = withContext(Dispatchers.IO) { - ObaContract.Stops.markAsFavorite( - context, Uri.withAppendedPath(ObaContract.Stops.CONTENT_URI, id), false - ) - Unit - } - - override suspend fun clearAll() = withContext(Dispatchers.IO) { - ObaContract.Stops.markAsFavorite(context, ObaContract.Stops.CONTENT_URI, false) - Unit - } - - private fun queryStarredStops(order: Int): List { - val selection = "${ObaContract.Stops.FAVORITE}=1" + regionWhere(context, ObaContract.Stops.REGION_ID) - val sortOrder = if (order == SORT_BY_FREQUENCY) { - "${ObaContract.Stops.USE_COUNT} desc" - } else { - "${ObaContract.Stops.UI_NAME} asc" - } - return context.contentResolver - .query(ObaContract.Stops.CONTENT_URI, STOP_PROJECTION, selection, null, sortOrder) - ?.use { c -> - buildList { - while (c.moveToNext()) add(c.toStopItem(context).copy(arrivals = StopArrivals.Loading)) + .flatMapLatest { stops -> + // Restart the arrivals poll whenever the stop set (or sort) changes: show the stops with + // their Loading badges first, then re-emit them with each poll's results merged in. + if (stops.isEmpty()) { + flowOf(stops) + } else { + arrivalsPoll(context, stops.map { it.id }) + .map { byStop -> + stops.map { it.copy(arrivals = StopArrivals.Loaded(byStop[it.id].orEmpty())) } + } + .onStart { emit(stops) } } } - ?: emptyList() + .flowOn(Dispatchers.IO) + + override suspend fun remove(id: String) { + importGate.awaitReady() + stopDao.setFavorite(id, 0) } - /** Emits the per-stop arrival badges immediately, then re-emits every [ARRIVALS_REFRESH_MS]. */ - private fun arrivalsPoll(stopIds: List): Flow>> = flow { - while (true) { - emit(fetchArrivals(context, stopIds, System.currentTimeMillis())) - delay(ARRIVALS_REFRESH_MS) - } + override suspend fun clearAll() { + importGate.awaitReady() + stopDao.clearAllFavorites() } } /** Starred routes, sorted by name or frequency. */ class StarredRoutesRepository(private val context: Context) : MyListRepository { + private val entryPoint = DatabaseEntryPoint.get(context) + private val routeDao = entryPoint.routeDao() + private val headsignDao = entryPoint.routeHeadsignFavoriteDao() + private val importGate = entryPoint.importGate() + private val region = RegionEntryPoint.get(context).region private val sort = MutableStateFlow(PreferenceUtils.getStopSortOrderFromPreferences()) override fun setSort(order: Int) { @@ -278,43 +227,39 @@ class StarredRoutesRepository(private val context: Context) : MyListRepository> = - combine(context.contentChanges(ObaContract.Routes.CONTENT_URI).conflate(), sort) { _, order -> - queryStarredRoutes(order) - }.flowOn(Dispatchers.IO) - - override suspend fun remove(id: String) = withContext(Dispatchers.IO) { - ObaContract.Routes.markAsFavorite( - context, Uri.withAppendedPath(ObaContract.Routes.CONTENT_URI, id), false - ) - ObaContract.RouteHeadsignFavorites.markAsFavorite(context, id, null, null, false) - Unit - } + combine(region, sort) { r, order -> r?.id to order } + .flatMapLatest { (regionId, order) -> + if (order == SORT_BY_FREQUENCY) { + routeDao.starredByFrequency(regionId) + } else { + routeDao.starredByName(regionId) + } + } + .map { rows -> rows.map { it.toRouteItem() } } + .flowOn(Dispatchers.IO) - override suspend fun clearAll() = withContext(Dispatchers.IO) { - ObaContract.Routes.markAsFavorite(context, ObaContract.Routes.CONTENT_URI, false) - ObaContract.RouteHeadsignFavorites.clearAllFavorites(context) - Unit + // Unstarring a whole route removes all its headsign favorites and clears the route's favorite flag + // (the all-stops unfavorite path creates no exclusion records). + override suspend fun remove(id: String) { + importGate.awaitReady() + headsignDao.deleteForRoute(id, null) + routeDao.setFavorite(id, 0) } - private fun queryStarredRoutes(order: Int): List { - val selection = "${ObaContract.Routes.FAVORITE}=1" + regionWhere(context, ObaContract.Routes.REGION_ID) - val sortOrder = if (order == SORT_BY_FREQUENCY) { - "${ObaContract.Routes.USE_COUNT} desc" - } else { - "length(${ObaContract.Routes.SHORTNAME}), ${ObaContract.Routes.SHORTNAME} asc" - } - return context.contentResolver - .query(ObaContract.Routes.CONTENT_URI, ROUTE_PROJECTION, selection, null, sortOrder) - ?.use { c -> buildList { while (c.moveToNext()) add(c.toRouteItem()) } } - ?: emptyList() + override suspend fun clearAll() { + importGate.awaitReady() + headsignDao.clearAll() + routeDao.clearAllFavorites() } } /** Saved trip reminders, sorted by name or departure time. Deletion is a host concern (it cancels the - * scheduled alarm), so this only observes + sorts; the ContentObserver reflects deletions. */ + * scheduled alarm), so this only observes + sorts; Room reflects deletions automatically. */ class RemindersRepository(private val context: Context) : MyListRepository { + private val tripDao = DatabaseEntryPoint.get(context).tripDao() private val sort = MutableStateFlow(PreferenceUtils.getReminderSortOrderFromPreferences()) override fun setSort(order: Int) { @@ -322,44 +267,24 @@ class RemindersRepository(private val context: Context) : MyListRepository> = - combine(context.contentChanges(ObaContract.Trips.CONTENT_URI).conflate(), sort) { _, order -> - queryReminders(order) - }.flowOn(Dispatchers.IO) - - private fun queryReminders(order: Int): List { - val sortOrder = if (order == SORT_BY_NAME) { - "${ObaContract.Trips.NAME} asc" - } else { - "${ObaContract.Trips.DEPARTURE} asc" + sort.flatMapLatest { order -> + if (order == SORT_BY_NAME) tripDao.remindersByName() else tripDao.remindersByDeparture() } - return context.contentResolver - .query(ObaContract.Trips.CONTENT_URI, TRIP_PROJECTION, null, null, sortOrder) - ?.use { c -> buildList { while (c.moveToNext()) add(c.toReminderItem(context)) } } - ?: emptyList() - } + .map { rows -> rows.map { it.toReminderItem(context) } } + .flowOn(Dispatchers.IO) } -private val TRIP_PROJECTION = arrayOf( - ObaContract.Trips._ID, - ObaContract.Trips.NAME, - ObaContract.Trips.HEADSIGN, - ObaContract.Trips.DEPARTURE, - ObaContract.Trips.ROUTE_ID, - ObaContract.Trips.STOP_ID -) - -private fun Cursor.toReminderItem(context: Context): ReminderItem { - val routeId = getString(4) - val routeName = ReminderUtils.getRouteShortName(context, routeId) - val departureMs = ObaContract.Trips.convertDBToTime(getInt(3)) +private fun ReminderRow.toReminderItem(context: Context): ReminderItem { + val departureMs = TripDepartureTime.toEpochMillis(departure) return ReminderItem( - tripId = getString(0), - stopId = getString(5), - routeId = routeId, - name = getString(1).orEmpty().ifEmpty { context.getString(R.string.trip_info_noname) }, - headsign = getString(2)?.takeIf { it.isNotEmpty() }?.let { MyTextUtils.formatDisplayText(it) }, - routeText = routeName?.let { context.getString(R.string.trip_info_route, it) }, + tripId = tripId, + stopId = stopId, + routeId = routeId.orEmpty(), + name = name.orEmpty().ifEmpty { context.getString(R.string.trip_info_noname) }, + headsign = headsign?.takeIf { it.isNotEmpty() }?.let { MyTextUtils.formatDisplayText(it) }, + routeText = routeShortName?.let { context.getString(R.string.trip_info_route, it) }, departureText = context.getString(R.string.trip_info_depart, DisplayFormat.formatTime(context, departureMs)) ) } @@ -370,26 +295,34 @@ private const val ARRIVALS_REFRESH_MS = 60_000L private const val ARRIVALS_MINUTES_AFTER = 35 private const val MAX_ARRIVALS_PER_STOP = 3 +/** Emits the per-stop arrival badges immediately, then re-emits every [ARRIVALS_REFRESH_MS]. */ +private fun arrivalsPoll(context: Context, stopIds: List): Flow>> = flow { + while (true) { + emit(fetchArrivals(context, stopIds)) + delay(ARRIVALS_REFRESH_MS) + } +} + /** Fetches each stop's next arrivals concurrently (the blocking per-stop calls fan out on IO) so the * refresh latency is the slowest single request, not their sum. */ private suspend fun fetchArrivals( context: Context, - stopIds: List, - nowMs: Long + stopIds: List ): Map> = coroutineScope { - stopIds.map { stopId -> async { stopId to fetchStopBadges(context, stopId, nowMs) } } + stopIds.map { stopId -> async { stopId to fetchStopBadges(context, stopId) } } .awaitAll() .toMap() } /** One stop's badges. [convertArrivals] already sorts by ETA; a non-OK code/error yields no badges. */ -private suspend fun fetchStopBadges(context: Context, stopId: String, nowMs: Long): List = +private suspend fun fetchStopBadges(context: Context, stopId: String): List = runCatching { - val arrivals = NetworkEntryPoint.getStopArrivals(context) + val snapshot = NetworkEntryPoint.getStopArrivals(context) .arrivals(stopId, ARRIVALS_MINUTES_AFTER) .getOrThrow() - .arrivals - convertArrivals(context, arrivals, null, nowMs, false) + // Server clock as the ETA baseline so badges cancel device clock skew (#1612). These badge + // rows don't render the favorite star, so favorite state is always false. + convertArrivals(context, snapshot.arrivals, null, snapshot.currentTime, false) { _, _, _ -> false } .take(MAX_ARRIVALS_PER_STOP) .map { it.toBadge(context) } }.getOrDefault(emptyList()) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/DeepLinkUris.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/DeepLinkUris.kt new file mode 100644 index 0000000000..13be7898a1 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/DeepLinkUris.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.ui.nav + +import android.net.Uri +import org.onebusaway.android.BuildConfig + +/** + * The `content://` deep-link vocabulary the app uses as *Intent data* to open a stop or route — pinned + * launcher shortcuts and external deep links. The arrivals/route-info launchers build these URIs and + * [IntentRouteMapper] parses the incoming path segment. + * + * Carried over from the retired ObaProvider ContentProvider (storage-modernization): the authority must + * stay under `BuildConfig.DATABASE_AUTHORITY` (originally "com.joulespersecond.oba") so already-pinned + * shortcuts and previously-shared links keep resolving. This is pure URI/string vocabulary — no + * ContentProvider or database is behind it. + */ +object DeepLinkUris { + + /** Content authority the stop/route deep-link URIs are namespaced under. */ + private val AUTHORITY: String = BuildConfig.DATABASE_AUTHORITY + + /** First path segment of a stop deep link: `content:///stops/{stopId}`. */ + const val STOPS_PATH = "stops" + + /** First path segment of a route deep link: `content:///routes/{routeId}`. */ + const val ROUTES_PATH = "routes" + + /** Base stop URI; append the stop id to target a stop. */ + val STOPS: Uri = Uri.parse("content://$AUTHORITY/$STOPS_PATH") + + /** Base route URI; append the route id to target a route. */ + val ROUTES: Uri = Uri.parse("content://$AUTHORITY/$ROUTES_PATH") +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/IntentRouteMapper.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/IntentRouteMapper.kt index 880a2b62cb..9eda270191 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/IntentRouteMapper.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/IntentRouteMapper.kt @@ -17,7 +17,6 @@ package org.onebusaway.android.ui.nav import android.app.SearchManager import android.content.Intent -import org.onebusaway.android.provider.ObaContract import org.onebusaway.android.ui.arrivals.ArrivalsIntents import org.onebusaway.android.ui.mylists.MyTabs import org.onebusaway.android.util.ReminderUtils @@ -118,10 +117,10 @@ object IntentRouteMapper { // path segment, since the authority is flavor-specific). MapParams.* focus / route-mode launches // have no data URI and resolve to None — they stay map behavior. return when (input.pathSegments.firstOrNull()) { - ObaContract.Stops.PATH -> + DeepLinkUris.STOPS_PATH -> input.pathSegments.lastOrNull() ?.let { RouteDecision.Arrivals(it, input.arrivalsStopName) } ?: RouteDecision.None - ObaContract.Routes.PATH -> + DeepLinkUris.ROUTES_PATH -> input.pathSegments.lastOrNull()?.let { RouteDecision.RouteInfo(it) } ?: RouteDecision.None else -> RouteDecision.None } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/MapReveal.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/MapReveal.kt index 8412cfff54..3b7eaa6bda 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/MapReveal.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/MapReveal.kt @@ -15,7 +15,9 @@ */ package org.onebusaway.android.ui.nav +import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController +import org.onebusaway.android.map.ShowRouteRequest /** * Navigate to an in-app [route], popping up to HOME and de-duping the top — the single navigation @@ -41,16 +43,39 @@ fun NavController.navigateFromHome(route: String) = * HOME is the NavHost start destination, so it is always on the back stack and [getBackStackEntry] is safe. */ const val RESULT_MAP_ROUTE_ID = "mapReveal.routeId" +const val RESULT_MAP_ROUTE_DIRECTION_STOP_ID = "mapReveal.routeDirectionStopId" const val RESULT_MAP_STOP_ID = "mapReveal.stopId" const val RESULT_MAP_STOP_LAT = "mapReveal.stopLat" const val RESULT_MAP_STOP_LON = "mapReveal.stopLon" -/** Reveal the map in route mode for [routeId], popping back to HOME. */ -fun NavController.revealRouteOnMap(routeId: String) { - getBackStackEntry(NavRoutes.HOME).savedStateHandle[RESULT_MAP_ROUTE_ID] = routeId +/** + * Reveal the map in route mode for [request], popping back to HOME. The [ShowRouteRequest] is serialized + * to the `RESULT_MAP_ROUTE_*` keys and read back by [consumeRouteReveal] — the one place field names live. + */ +fun NavController.revealRouteOnMap(request: ShowRouteRequest) { + val handle = getBackStackEntry(NavRoutes.HOME).savedStateHandle + handle[RESULT_MAP_ROUTE_ID] = request.routeId + handle[RESULT_MAP_ROUTE_DIRECTION_STOP_ID] = request.directionStopId popBackStack(NavRoutes.HOME, /* inclusive = */ false) } +/** Reveal the whole route [routeId] on the map (no direction focus) — the plain-route launchers. */ +fun NavController.revealRouteOnMap(routeId: String) = revealRouteOnMap(ShowRouteRequest(routeId)) + +/** + * Reads and consumes a pending route reveal from the HOME [SavedStateHandle] — the symmetric typed + * *read* for [revealRouteOnMap], keeping the `RESULT_MAP_ROUTE_*` key names in this one file rather + * than re-reading them in the consumer. The keys are cleared together (so a stale direction anchor + * can't linger past the reveal); returns null when no route id is present. + */ +fun SavedStateHandle.consumeRouteReveal(): ShowRouteRequest? { + val routeId = get(RESULT_MAP_ROUTE_ID) + val directionStopId = get(RESULT_MAP_ROUTE_DIRECTION_STOP_ID) + set(RESULT_MAP_ROUTE_ID, null) + set(RESULT_MAP_ROUTE_DIRECTION_STOP_ID, null) + return routeId?.let { ShowRouteRequest(it, directionStopId) } +} + /** Reveal the map focused on [stopId] at [lat]/[lon], popping back to HOME. */ fun NavController.revealStopOnMap(stopId: String, lat: Double, lon: Double) { getBackStackEntry(NavRoutes.HOME).savedStateHandle.apply { @@ -60,3 +85,26 @@ fun NavController.revealStopOnMap(stopId: String, lat: Double, lon: Double) { } popBackStack(NavRoutes.HOME, false) } + +/** A complete stop reveal read back off the HOME [SavedStateHandle] — the typed counterpart of the + * three [RESULT_MAP_STOP_ID]/[RESULT_MAP_STOP_LAT]/[RESULT_MAP_STOP_LON] keys [revealStopOnMap] writes. */ +data class StopReveal(val stopId: String, val lat: Double, val lon: Double) + +/** + * Reads and consumes a pending stop reveal from the HOME [SavedStateHandle] — the symmetric typed *read* + * for [revealStopOnMap], keeping the `RESULT_MAP_STOP_*` keys and their `Double` types in this one file + * rather than re-naming them in the consumer. All three keys are cleared together regardless of + * completeness (so a stale lat/lon pair can't linger past the reveal); returns null when no stop id is + * present, or — the corrupted/half-restored case the producer never writes — an id without both + * coordinates. + */ +fun SavedStateHandle.consumeStopReveal(): StopReveal? { + val stopId = get(RESULT_MAP_STOP_ID) + val lat = get(RESULT_MAP_STOP_LAT) + val lon = get(RESULT_MAP_STOP_LON) + set(RESULT_MAP_STOP_ID, null) + set(RESULT_MAP_STOP_LAT, null) + set(RESULT_MAP_STOP_LON, null) + if (stopId == null || lat == null || lon == null) return null + return StopReveal(stopId, lat, lon) +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/NavRoutes.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/NavRoutes.kt index 279808c622..a63b9e71a5 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/NavRoutes.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nav/NavRoutes.kt @@ -35,7 +35,15 @@ data class ReminderEditorArgs( val stopSequence: Int = 0, val serviceDate: Long = 0L, val vehicleId: String? = null, -) +) { + init { + // Fail fast at this construction site (the arrival "set reminder" path) rather than letting a + // blank id surface later as an unresolvable route/DB lookup in the editor. (The edit-existing path + // builds the route from raw string nav-args and doesn't go through this constructor.) + require(tripId.isNotBlank()) { "tripId must not be blank" } + require(stopId.isNotBlank()) { "stopId must not be blank" } + } +} /** * Central registry of Navigation-Compose route ids and nav-arg keys. The single diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/nightlight/NightLightLauncher.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nightlight/NightLightLauncher.kt index fee79604c5..17ea41833e 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/nightlight/NightLightLauncher.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/nightlight/NightLightLauncher.kt @@ -17,8 +17,8 @@ package org.onebusaway.android.ui.nightlight import android.content.Context -import org.onebusaway.android.ui.HomeActivity import org.onebusaway.android.ui.nav.NavRoutes +import org.onebusaway.android.ui.startHomeActivity /** * Launches the night-light flashing screen. @@ -26,12 +26,12 @@ import org.onebusaway.android.ui.nav.NavRoutes * The screen is a NavHost destination ([NightLightRoute]) hosted by HomeActivity; this is no longer an * Activity but a launcher facade. The frozen `NightLightActivity` component name is kept alive as an * activity-alias → HomeActivity (for old pinned launcher shortcuts). This [start] is the standalone-host - * fallback (it re-enters HomeActivity with the route via [HomeActivity.navIntent]); in-app callers that + * fallback (it re-enters HomeActivity with the route via [startHomeActivity]); in-app callers that * already hold a NavController navigate it directly instead. */ object NightLightLauncher { fun start(context: Context) { - context.startActivity(HomeActivity.navIntent(context, NavRoutes.NIGHT_LIGHT)) + context.startHomeActivity(NavRoutes.NIGHT_LIGHT) } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/regions/RegionsRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/regions/RegionsRepository.kt index deb01676d8..44a684a908 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/regions/RegionsRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/regions/RegionsRepository.kt @@ -29,6 +29,7 @@ import org.onebusaway.android.analytics.PlausibleAnalytics import org.onebusaway.android.region.Region import org.onebusaway.android.location.LocationRepository import org.onebusaway.android.preferences.PreferencesRepository +import org.onebusaway.android.region.RegionCache import org.onebusaway.android.region.RegionRepository import org.onebusaway.android.util.RegionUtils @@ -75,6 +76,7 @@ class DefaultRegionsRepository @Inject constructor( @ApplicationContext private val context: Context, private val prefs: PreferencesRepository, private val regionRepository: RegionRepository, + private val regionCache: RegionCache, private val locationRepository: LocationRepository, ) : RegionsRepository { @@ -86,7 +88,7 @@ class DefaultRegionsRepository @Inject constructor( override suspend fun getRegions(refresh: Boolean): Result> = withContext(Dispatchers.IO) { - val regions = RegionUtils.getRegions(context, refresh) + val regions = regionCache.loadRegions(refresh) ?: return@withContext Result.failure( IOException("Regions could not be loaded from any source") ) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoLauncher.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoLauncher.kt index 50e1fcc6e9..c5616c9192 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoLauncher.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoLauncher.kt @@ -19,7 +19,7 @@ import org.onebusaway.android.ui.HomeActivity import android.content.Context import android.content.Intent import android.net.Uri -import org.onebusaway.android.provider.ObaContract +import org.onebusaway.android.ui.nav.DeepLinkUris /** * Launches the route-info screen (a route's stops grouped by direction). @@ -41,6 +41,6 @@ object RouteInfoLauncher { @JvmStatic fun makeIntent(context: Context, routeId: String): Intent = Intent(context, HomeActivity::class.java).apply { - data = Uri.withAppendedPath(ObaContract.Routes.CONTENT_URI, routeId) + data = Uri.withAppendedPath(DeepLinkUris.ROUTES, routeId) } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoRepository.kt index b5676bca9b..0d2a869de9 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/routeinfo/RouteInfoRepository.kt @@ -18,7 +18,6 @@ package org.onebusaway.android.ui.routeinfo import org.onebusaway.android.api.data.RouteDataSource import org.onebusaway.android.api.data.RouteStopsDataSource -import android.content.ContentValues import android.content.Context import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext @@ -33,7 +32,8 @@ import org.onebusaway.android.api.ObaApiException import org.onebusaway.android.models.RouteDetails import org.onebusaway.android.models.RouteStopGroup import org.onebusaway.android.models.ObaStop -import org.onebusaway.android.provider.ObaContract +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.RouteDao import org.onebusaway.android.region.RegionRepository import org.onebusaway.android.util.MyTextUtils import org.onebusaway.android.util.ObaRequestErrors @@ -57,6 +57,8 @@ class DefaultRouteInfoRepository @Inject constructor( private val regionRepository: RegionRepository, private val routeRepository: RouteDataSource, private val routeStopsRepository: RouteStopsDataSource, + private val routeDao: RouteDao, + private val importGate: ImportGate, ) : RouteInfoRepository { override suspend fun loadRouteInfo(routeId: String): Result = @@ -83,16 +85,13 @@ class DefaultRouteInfoRepository @Inject constructor( } } - /** Records the route in the provider so it appears in recents and search (legacy parity). */ - private fun registerRouteUsage(route: RouteDetails) { - val values = ContentValues() - values.put(ObaContract.Routes.SHORTNAME, route.shortName) - values.put(ObaContract.Routes.LONGNAME, route.longName) - values.put(ObaContract.Routes.URL, route.url) - regionRepository.region.value?.let { - values.put(ObaContract.Routes.REGION_ID, it.id) - } - ObaContract.Routes.insertOrUpdate(context, route.id, values, true) + /** Records the route in the recents/search table so it appears in recents and search (legacy parity). */ + private suspend fun registerRouteUsage(route: RouteDetails) { + importGate.awaitReady() + routeDao.storeRouteDetails( + route.id, route.shortName, route.longName, route.url, + regionRepository.region.value?.id, System.currentTimeMillis() + ) } private fun toRouteInfo(route: RouteDetails, directions: List): RouteInfo { diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/search/StopSearchRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/search/StopSearchRepository.kt index 61f839300e..36cbeb345a 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/search/StopSearchRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/search/StopSearchRepository.kt @@ -21,10 +21,11 @@ import android.content.Context import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.onebusaway.android.app.di.DatabaseEntryPoint import org.onebusaway.android.models.ObaStop import org.onebusaway.android.provider.StopUserInfo -import org.onebusaway.android.provider.loadStopUserInfo import org.onebusaway.android.provider.stopDisplayName +import org.onebusaway.android.provider.toStopUserInfoMap import org.onebusaway.android.util.LocationUtils /** @@ -72,7 +73,9 @@ class DefaultStopSearchRepository( val center = LocationUtils.getSearchCenter(context) val stops = search.stopsNearOrEmpty(center.latitude, center.longitude, query, null) .getOrThrow() - val userInfo = loadStopUserInfo(context) + val db = DatabaseEntryPoint.get(context) + db.importGate().awaitReady() + val userInfo = db.stopDao().userInfoMap().toStopUserInfoMap() stops.map { it.toStopSearchResult(userInfo[it.id]) } }.onFailure { Log.e(TAG, "stop search failed", it) } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/searchresults/SearchResultsRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/searchresults/SearchResultsRepository.kt index 42338d587a..45672d31ab 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/searchresults/SearchResultsRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/searchresults/SearchResultsRepository.kt @@ -24,11 +24,13 @@ import android.location.Location import java.io.IOException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.StopDao import org.onebusaway.android.models.ObaRoute import org.onebusaway.android.models.ObaStop import org.onebusaway.android.provider.StopUserInfo -import org.onebusaway.android.provider.loadStopUserInfo import org.onebusaway.android.provider.stopDisplayName +import org.onebusaway.android.provider.toStopUserInfoMap import org.onebusaway.android.util.LocationUtils import org.onebusaway.android.util.routeDisplayNames @@ -47,6 +49,8 @@ interface SearchResultsRepository { class DefaultSearchResultsRepository @Inject constructor( @ApplicationContext private val context: Context, private val search: LocationSearchDataSource, + private val stopDao: StopDao, + private val importGate: ImportGate, ) : SearchResultsRepository { override suspend fun search(query: String): Result> = coroutineScope { @@ -67,7 +71,10 @@ class DefaultSearchResultsRepository @Inject constructor( ) } - val userInfo = loadStopUserInfo(context) + // The favourite/custom-name enrichment is best-effort: a DB hiccup here must not fail a search + // that already has route/stop results, so treat it as a soft miss (empty map) like the lookups. + importGate.awaitReady() + val userInfo = runCatching { stopDao.userInfoMap().toStopUserInfoMap() }.getOrDefault(emptyMap()) val items = buildList { routeResult.getOrNull()?.forEach { add(toRoute(it)) } stopResult.getOrNull()?.forEach { add(toStop(it, userInfo[it.id])) } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt index 03f4e5e7fe..351eafed1b 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt @@ -29,10 +29,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.onebusaway.android.R +import org.onebusaway.android.map.StopsMapController import org.onebusaway.android.ui.compose.components.ObaTopAppBar import org.onebusaway.android.ui.compose.findActivity import org.onebusaway.android.ui.settings.components.ClickPreferenceItem @@ -122,6 +124,21 @@ fun AdvancedSettingsRoute( else -> true } }, + onMapStopCacheSizeChange = { value -> + viewModel.onMapStopCacheSizeChanged(value).also { accepted -> + if (!accepted) { + Toast.makeText( + context, + resources.getString( + R.string.preferences_map_stop_cache_size_error, + MAP_STOP_CACHE_SIZE_MIN, + MAP_STOP_CACHE_SIZE_MAX, + ), + Toast.LENGTH_SHORT, + ).show() + } + } + }, onResetDonationTimestamps = viewModel::onResetDonationTimestamps, ) } @@ -134,6 +151,7 @@ fun AdvancedSettingsScreen( onDisplayTestAlerts: (Boolean) -> Unit, onObaUrlChange: (String) -> Boolean, onOtpUrlChange: (String) -> Boolean, + onMapStopCacheSizeChange: (String) -> Boolean, onResetDonationTimestamps: () -> Unit, ) { val appName = stringResource(R.string.app_name) @@ -166,6 +184,17 @@ fun AdvancedSettingsScreen( checked = state.displayTestAlerts, onCheckedChange = onDisplayTestAlerts, ) + EditTextPreferenceItem( + title = stringResource(R.string.preferences_map_stop_cache_size_title), + summary = stringResource( + R.string.preferences_map_stop_cache_size_summary, + state.mapStopCacheSize, + ), + currentValue = state.mapStopCacheSize.toString(), + hint = StopsMapController.DEFAULT_STOP_CACHE_SIZE.toString(), + onValueChange = onMapStopCacheSizeChange, + keyboardType = KeyboardType.Number, + ) EditTextPreferenceItem( title = stringResource(R.string.preferences_oba_api_servername_title, appName), summary = state.customObaApiUrlSummary, diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt index 09e3ffe172..deefca0f9a 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -34,8 +33,9 @@ import kotlinx.coroutines.launch import org.onebusaway.android.BuildConfig import org.onebusaway.android.R import org.onebusaway.android.app.Application -import org.onebusaway.android.region.Region +import org.onebusaway.android.map.StopsMapController import org.onebusaway.android.preferences.PreferencesRepository +import org.onebusaway.android.region.Region import org.onebusaway.android.region.ApiUrlValidator import org.onebusaway.android.region.RegionRepository import org.onebusaway.android.region.RegionState @@ -82,6 +82,10 @@ class AdvancedSettingsViewModel @Inject constructor( displayTestAlerts = prefs.getBoolean(R.string.preferences_display_test_alerts, false), customObaApiUrl = prefs.getString(R.string.preference_key_oba_api_url, null), customOtpApiUrl = prefs.getString(R.string.preference_key_otp_api_url, null), + mapStopCacheSize = prefs.getInt( + R.string.preference_key_map_stop_cache_size, + StopsMapController.DEFAULT_STOP_CACHE_SIZE, + ), ), region = region?.let { AdvancedRegionInfo(it.experimental) }, useFixedRegion = BuildConfig.USE_FIXED_REGION, @@ -95,6 +99,12 @@ class AdvancedSettingsViewModel @Inject constructor( fun onDisplayTestAlertsChanged(value: Boolean) = prefs.setBoolean(R.string.preferences_display_test_alerts, value) + /** + * Apply an edit to the map stop LRU cache size. Returns true if the input was a valid in-range + * number (and was persisted); false otherwise so the dialog stays open (reject-on-invalid). + */ + fun onMapStopCacheSizeChanged(text: String): Boolean = applyMapStopCacheSize(text, prefs) + /** * Apply an experimental-regions toggle (the screen owns the enable/disable confirmation dialogs). * Mirrors the legacy flow: clear the region first when turning off an experimental region, persist, @@ -174,6 +184,18 @@ internal suspend fun applyExperimentalRegionsToggle( } } +/** + * The map-stop-cache-size edit's domain effect, split from [AdvancedSettingsViewModel.onMapStopCacheSizeChanged] + * so it's free of Android dependencies and unit-tested directly (see AdvancedSettingsViewModelTest): on a + * valid in-range [text] (see [parseStopCacheSize]) persist the size and return true; on an invalid entry + * persist nothing and return false so the screen keeps the field open with the range error. + */ +internal fun applyMapStopCacheSize(text: String, prefs: PreferencesRepository): Boolean { + val size = parseStopCacheSize(text) ?: return false + prefs.setInt(R.string.preference_key_map_stop_cache_size, size) + return true +} + /** * The domain rule that a toggle-initiated experimental-region *change* resets the OTP API version to the * current default ([resetOtpVersion]). The repository's own [RegionRepository.applyRegion] reset covers @@ -189,8 +211,12 @@ internal suspend fun resetOtpVersionOnRegionChange( when (status) { is RegionStatus.Changed -> resetOtpVersion() is RegionStatus.NeedsManualSelection -> { - state.filterIsInstance().first() - resetOtpVersion() + // Await a terminal resolution rather than only [RegionState.Active]: the forced picker is + // non-dismissible so the user's pick normally resolves it, but a retried refresh that fails + // transitions to [RegionState.Failed] instead — waiting for Active alone would suspend forever. + // Reset only when the region actually became active (a Failed resolution left it unchanged). + val resolved = state.first { it is RegionState.Active || it is RegionState.Failed } + if (resolved is RegionState.Active) resetOtpVersion() } RegionStatus.Unchanged, RegionStatus.Skipped, RegionStatus.Failed, is RegionStatus.Fixed -> Unit } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt index d5414d14b6..e78a3d320f 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt @@ -137,8 +137,21 @@ data class AdvancedPrefSnapshot( val displayTestAlerts: Boolean, val customObaApiUrl: String?, val customOtpApiUrl: String?, + val mapStopCacheSize: Int, ) +/** Bounds for the map stop LRU cache size advanced option (see [parseStopCacheSize]). */ +const val MAP_STOP_CACHE_SIZE_MIN = 10 +const val MAP_STOP_CACHE_SIZE_MAX = 2000 + +/** + * Parses the advanced map-stop-cache-size input: the trimmed [text] as an integer within + * [[MAP_STOP_CACHE_SIZE_MIN], [MAP_STOP_CACHE_SIZE_MAX]], or null if it's not a number or out of + * range (the screen keeps its dialog open on null). Pure, so the validation is JVM-unit-testable. + */ +fun parseStopCacheSize(text: String): Int? = + text.trim().toIntOrNull()?.takeIf { it in MAP_STOP_CACHE_SIZE_MIN..MAP_STOP_CACHE_SIZE_MAX } + /** Region info the advanced screen needs; null means no region (custom API in use). */ data class AdvancedRegionInfo(val isExperimental: Boolean) @@ -151,6 +164,7 @@ data class AdvancedSettingsUiState( val customObaApiUrlSummary: String, val customOtpApiUrlSummary: String, val currentRegionIsExperimental: Boolean, + val mapStopCacheSize: Int, ) /** @@ -180,6 +194,7 @@ fun buildAdvancedSettingsUiState( customObaApiUrlSummary = obaSummary, customOtpApiUrlSummary = otpSummary, currentRegionIsExperimental = region?.isExperimental == true, + mapStopCacheSize = prefs.mapStopCacheSize, ) } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsViewModel.kt index 6e78a484b8..6f87c93b7b 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsViewModel.kt @@ -23,7 +23,6 @@ import com.google.firebase.analytics.FirebaseAnalytics import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -37,7 +36,8 @@ import org.onebusaway.android.R import org.onebusaway.android.analytics.ObaAnalytics import org.onebusaway.android.region.Region import org.onebusaway.android.preferences.PreferencesRepository -import org.onebusaway.android.provider.ObaContract +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.ServiceAlertDao import org.onebusaway.android.region.RegionRepository import org.onebusaway.android.util.BuildFlavorUtils import org.onebusaway.android.ui.tutorial.TutorialPrefs @@ -63,6 +63,8 @@ class SettingsViewModel @Inject constructor( @ApplicationContext private val context: Context, private val prefs: PreferencesRepository, private val regionRepository: RegionRepository, + private val serviceAlertDao: ServiceAlertDao, + private val importGate: ImportGate, ) : ViewModel() { private val env = SettingsEnvironment( @@ -141,7 +143,7 @@ class SettingsViewModel @Inject constructor( fun onHideAlertsChanged(value: Boolean) { prefs.setBoolean(R.string.preference_key_hide_alerts, value) - if (value) viewModelScope.launch(Dispatchers.IO) { ObaContract.ServiceAlerts.hideAllAlerts() } + if (value) viewModelScope.launch { importGate.awaitReady(); serviceAlertDao.setAllHidden(1) } } /** diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/SurveyPreferences.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/SurveyPreferences.kt index 3280d137ba..eccb3133b4 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/SurveyPreferences.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/SurveyPreferences.kt @@ -18,70 +18,67 @@ package org.onebusaway.android.ui.survey import android.content.Context import java.util.Date import java.util.UUID +import org.onebusaway.android.app.Application +import org.onebusaway.android.app.di.PreferencesEntryPoint +import org.onebusaway.android.preferences.PreferencesRepository /** - * Utility class for managing survey-related preferences using SharedPreferences. - * This class handles storing and retrieving user UUIDs and survey reminder dates. + * Survey-related preferences (the user's survey UUID and the reminder date). Backed by the + * DataStore [PreferencesRepository] (storage-modernization) rather than the old separate `survey_pref` + * SharedPreferences file; values from that file are migrated once on first access so existing users + * keep their survey identity. */ object SurveyPreferences { - private const val PREFS_NAME = "survey_pref" + private const val LEGACY_PREFS_NAME = "survey_pref" private const val UUID_KEY = "my_uuid" private const val SURVEY_REMINDER_DATE_KEY = "survey_reminder_day" + private const val LEGACY_MIGRATED_KEY = "survey_pref_migrated" - /** - * Saves the user's UUID to the shared preferences. - * - * @param uuid the UUID to be saved in shared preferences - */ - private fun saveUserUUIDHelper(context: Context, uuid: UUID) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putString(UUID_KEY, uuid.toString()).apply() - } + private fun prefs(): PreferencesRepository = PreferencesEntryPoint.get(Application.get()) /** - * Retrieves the user's UUID from shared preferences. - * - * @return the saved UUID as a string, or null if not found - */ - private fun getUserUUIDHelper(context: Context): String? { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val uuidString = prefs.getString(UUID_KEY, null) - return uuidString?.let { UUID.fromString(it).toString() } - } - - /** - * Saves the given survey reminder date in SharedPreferences. + * Saves the given survey reminder date. * * @param date The date to be saved as a reminder */ @JvmStatic fun setSurveyReminderDate(context: Context, date: Date) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putLong(SURVEY_REMINDER_DATE_KEY, date.time).apply() + prefs().setLong(SURVEY_REMINDER_DATE_KEY, date.time) } /** - * Retrieves the survey reminder date from SharedPreferences. + * Retrieves the survey reminder date. * * @return The stored reminder date as a long (milliseconds since epoch), or -1 if not set */ @JvmStatic fun getSurveyReminderDate(context: Context): Long { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getLong(SURVEY_REMINDER_DATE_KEY, -1) + migrateLegacyIfNeeded(context) + return prefs().getLong(SURVEY_REMINDER_DATE_KEY, -1) } /** - * Retrieves the user's UUID. If the UUID is not already stored, generates a new UUID and saves it. + * Retrieves the user's UUID, generating and storing one if absent. * * @return The user's UUID as a String. */ @JvmStatic - fun getUserUUID(context: Context): String? { - if (getUserUUIDHelper(context) == null) { - saveUserUUIDHelper(context, UUID.randomUUID()) - } - return getUserUUIDHelper(context) + fun getUserUUID(context: Context): String { + migrateLegacyIfNeeded(context) + prefs().getString(UUID_KEY, null)?.let { return it } + return UUID.randomUUID().toString().also { prefs().setString(UUID_KEY, it) } + } + + /** One-time copy of the old separate `survey_pref` SharedPreferences values into DataStore. */ + private fun migrateLegacyIfNeeded(context: Context) { + val prefs = prefs() + if (prefs.getBoolean(LEGACY_MIGRATED_KEY, false)) return + val legacy = context.getSharedPreferences(LEGACY_PREFS_NAME, Context.MODE_PRIVATE) + legacy.getString(UUID_KEY, null)?.let { prefs.setString(UUID_KEY, it) } + legacy.getLong(SURVEY_REMINDER_DATE_KEY, -1) + .takeIf { it != -1L } + ?.let { prefs.setLong(SURVEY_REMINDER_DATE_KEY, it) } + prefs.setBoolean(LEGACY_MIGRATED_KEY, true) } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/SurveyViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/SurveyViewModel.kt index 0dfc9f53be..50332c55bc 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/SurveyViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/SurveyViewModel.kt @@ -23,6 +23,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import org.onebusaway.android.app.di.AppScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -39,7 +41,7 @@ import org.onebusaway.android.models.SurveyQuestion import org.onebusaway.android.models.SurveySubmitResult import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.region.RegionRepository -import org.onebusaway.android.database.survey.SurveyDbHelper +import org.onebusaway.android.database.survey.SurveyRepository import org.onebusaway.android.ui.survey.utils.SurveyUtils /** The bottom-sheet state for the survey's remaining (non-hero) questions. */ @@ -77,7 +79,7 @@ sealed interface SurveyEffect { /** * Drives the map survey: requesting the study, showing the hero question + remaining-questions sheet, * submitting answers, and persisting completion/skip/remind-later. The network/JSON/DB/filtering - * logic is reused from the io.client [SurveyDataSource] + `SurveyUtils`/`SurveyDbHelper`. Scoped to + * logic is reused from the io.client [SurveyDataSource] + `SurveyUtils`/[SurveyRepository]. Scoped to * the map (the old `isVisibleOnStops = false` path). */ @HiltViewModel @@ -86,6 +88,8 @@ class SurveyViewModel @Inject constructor( private val regionRepository: RegionRepository, private val prefs: PreferencesRepository, private val surveyRepo: SurveyDataSource, + private val surveyStore: SurveyRepository, + @AppScope private val appScope: CoroutineScope, ) : ViewModel() { private val _state = MutableStateFlow(SurveyUiState()) @@ -133,9 +137,10 @@ class SurveyViewModel @Inject constructor( } } - private fun onStudyResponse(response: List) { + private suspend fun onStudyResponse(response: List) { surveys = response - surveyIndex = SurveyUtils.getCurrentSurveyIndex(response, context, false, null) + val completed = surveyStore.completedSurveyIds() + surveyIndex = SurveyUtils.getCurrentSurveyIndex(response, false, null) { it in completed } if (surveyIndex == -1) return val survey = response[surveyIndex] val questions = survey.questions @@ -250,9 +255,7 @@ class SurveyViewModel @Inject constructor( fun cancelDismiss() = _state.update { it.copy(showDismissDialog = false) } fun skipSurvey() { - _state.value.survey?.let { - SurveyDbHelper.markSurveyAsCompletedOrSkipped(context, it, SurveyDbHelper.SURVEY_SKIPPED) - } + _state.value.survey?.let { persistSurveyState(it, SurveyRepository.SURVEY_SKIPPED) } dismissAll() } @@ -264,10 +267,19 @@ class SurveyViewModel @Inject constructor( // --- helpers --- private fun handleCompleted(survey: Survey) { - SurveyDbHelper.markSurveyAsCompletedOrSkipped(context, survey, SurveyDbHelper.SURVEY_COMPLETED) + persistSurveyState(survey, SurveyRepository.SURVEY_COMPLETED) SurveyUtils.launchesUntilSurveyShown = Integer.MAX_VALUE } + /** + * Persists the survey's completed/skipped state off the main thread. Runs on [appScope] (not + * [viewModelScope]) so the write can't be cancelled by the ViewModel being cleared when the user + * navigates away immediately after skipping/completing. + */ + private fun persistSurveyState(survey: Survey, state: Int) { + appScope.launch { surveyStore.markCompletedOrSkipped(survey, state) } + } + private fun dismissAll() { _state.value = SurveyUiState() } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/utils/SurveyUtils.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/utils/SurveyUtils.kt index 12d85a5852..a32790d327 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/utils/SurveyUtils.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/survey/utils/SurveyUtils.kt @@ -6,7 +6,6 @@ import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import org.onebusaway.android.app.Application -import org.onebusaway.android.database.survey.SurveyDbHelper import org.onebusaway.android.models.ObaStop import org.onebusaway.android.models.Survey import org.onebusaway.android.models.SurveyQuestion @@ -41,16 +40,20 @@ object SurveyUtils { /** * Returns the index of the first uncompleted survey in the list, based on visibility settings. * - * @param studyResponse The study response containing the list of surveys. - * @param context The context used to access local data. + * @param surveys The list of candidate surveys. * @param isVisibleOnStop Indicates whether the survey view is related to stops. + * @param currentStop The current stop (used only when isVisibleOnStop is true). + * @param isCompleted Predicate telling whether a survey id already has a persisted response; + * supplied by the caller from + * [org.onebusaway.android.database.survey.SurveyRepository.completedSurveyIds] + * so this function stays pure/JVM-testable and does no DB access itself. * @return The zero-based index of the current survey, or -1 if all surveys are completed or filtered out. */ fun getCurrentSurveyIndex( surveys: List, - context: Context, isVisibleOnStop: Boolean, - currentStop: ObaStop? + currentStop: ObaStop?, + isCompleted: (Int) -> Boolean ): Int { var alwaysVisibleIndex = -1 var oneTimeSurveyIndex = -1 @@ -83,7 +86,7 @@ object SurveyUtils { if (showQuestionOnMaps != true) continue } - val isSurveyCompleted = SurveyDbHelper.isSurveyCompleted(context, survey.id) + val isSurveyCompleted = isCompleted(survey.id) if (alwaysVisible == true) { if (allowMultipleResponses == true) { diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt index 2cce721fc9..ab8b0c00ce 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt @@ -29,7 +29,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument import org.onebusaway.android.R -import org.onebusaway.android.provider.ObaContract +import org.onebusaway.android.app.di.DatabaseEntryPoint import org.onebusaway.android.app.di.PreferencesEntryPoint import org.onebusaway.android.ui.compose.findActivity import org.onebusaway.android.ui.compose.theme.ObaTheme @@ -41,7 +41,6 @@ import org.onebusaway.android.ui.tripinfo.TripInfoRoute import org.onebusaway.android.ui.tripinfo.TripInfoViewModel import org.onebusaway.android.ui.tripinfo.confirmDeleteReminder import org.onebusaway.android.util.PermissionUtils -import org.onebusaway.android.util.ReminderUtils /** * The trip navigation cluster: a trip's stops + live vehicle ([NavRoutes.TRIP_DETAILS]) and the @@ -182,10 +181,8 @@ fun NavGraphBuilder.tripGraph(navController: NavHostController) { }, onDelete = { confirmDeleteReminder(activity) { - ReminderUtils.requestDeleteAlarm( - activity, - ObaContract.Trips.buildUri(infoTripId, infoStopId) - ) + DatabaseEntryPoint.get(activity).reminderRepository() + .deleteReminderInBackground(infoTripId, infoStopId) navController.popBackStack() } }, diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDetailsRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDetailsRepository.kt index 1c2450a24e..dee9b1d90f 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDetailsRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDetailsRepository.kt @@ -36,8 +36,15 @@ import org.onebusaway.android.models.ObaTrip import org.onebusaway.android.models.ObaTripSchedule import org.onebusaway.android.models.ObaTripStatus import org.onebusaway.android.models.Status +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.onebusaway.android.app.di.AppScope +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.StopDao +import org.onebusaway.android.database.oba.markStopUsed +import org.onebusaway.android.models.ObaStop +import org.onebusaway.android.region.RegionRepository import org.onebusaway.android.util.ArrivalInfoUtils -import org.onebusaway.android.util.DBUtil import org.onebusaway.android.util.DisplayFormat import org.onebusaway.android.util.MyTextUtils import org.onebusaway.android.util.ObaRequestErrors @@ -92,6 +99,10 @@ data class DestinationReminderStops(val beforeStopId: String, val destinationSto class DefaultTripDetailsRepository @Inject constructor( @ApplicationContext private val context: Context, private val tripDetailsDataSource: TripDetailsDataSource, + private val stopDao: StopDao, + private val regionRepository: RegionRepository, + private val importGate: ImportGate, + @AppScope private val appScope: CoroutineScope, ) : TripDetailsRepository { private var lastGood: TripDetails? = null @@ -123,11 +134,22 @@ class DefaultTripDetailsRepository @Inject constructor( val destStop = td.stop(stopTimes[position].stopId) ?: return null val beforeStop = td.stop(stopTimes[position - 1].stopId) ?: return null // Persist both so NavigationService can resolve them when the reminder fires (legacy parity). - DBUtil.addToDB(beforeStop) - DBUtil.addToDB(destStop) + // Fire-and-forget on the app scope: the write only needs to land before the reminder fires + // (minutes later), so destinationStops can stay synchronous for its callers. + persistStop(beforeStop) + persistStop(destStop) return DestinationReminderStops(beforeStop.id, destStop.id) } + /** Records a stop row (the legacy DBUtil.addToDB) so NavigationService can look up its location. */ + private fun persistStop(stop: ObaStop) { + appScope.launch { + importGate.awaitReady() + val regionId = regionRepository.region.value?.id + stopDao.markStopUsed(stop, regionId, System.currentTimeMillis()) + } + } + override fun lastLoadedTime(): Long? = lastGood?.currentTime private fun toData( diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoLauncher.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoLauncher.kt index 3d26ac8fb1..b3622039f8 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoLauncher.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoLauncher.kt @@ -19,9 +19,9 @@ package org.onebusaway.android.ui.tripinfo import android.content.Context import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.onebusaway.android.R -import org.onebusaway.android.ui.HomeActivity import org.onebusaway.android.ui.nav.NavRoutes import org.onebusaway.android.ui.nav.ReminderEditorArgs +import org.onebusaway.android.ui.startHomeActivity /** * Launches the trip-reminder editor (create or edit a trip arrival reminder). @@ -29,7 +29,7 @@ import org.onebusaway.android.ui.nav.ReminderEditorArgs * The editor is a NavHost destination hosted by HomeActivity; this is no longer an Activity but a launcher * facade. The edit path carries only the trip/stop ids; the arrivals "set reminder" path also passes the * full trip context so a brand-new reminder needs no DB round-trip. This [start] is the standalone-host - * fallback (it re-enters HomeActivity with the route via [HomeActivity.navIntent], which the translator + * fallback (it re-enters HomeActivity with the route via [startHomeActivity], which the translator * turns into the [NavRoutes.TRIP_INFO] route); in-app callers that already hold a NavController navigate the * route directly instead. */ @@ -37,7 +37,7 @@ object TripInfoLauncher { /** Re-enters HomeActivity at the [NavRoutes.TRIP_INFO] reminder editor for [args]. */ fun start(context: Context, args: ReminderEditorArgs) { - context.startActivity(HomeActivity.navIntent(context, NavRoutes.tripInfo(args))) + context.startHomeActivity(NavRoutes.tripInfo(args)) } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt index e93e638916..982b8c6e1a 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt @@ -15,10 +15,7 @@ */ package org.onebusaway.android.ui.tripinfo -import android.content.ContentValues import android.content.Context -import android.database.Cursor -import android.net.Uri import android.text.format.DateUtils import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -27,9 +24,14 @@ import kotlinx.coroutines.withContext import org.onebusaway.android.R import org.onebusaway.android.app.Application import org.onebusaway.android.api.contract.ReminderWebService -import org.onebusaway.android.provider.ObaContract -import org.onebusaway.android.provider.ProviderQueries +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.oba.RouteDao +import org.onebusaway.android.database.oba.StopDao +import org.onebusaway.android.database.oba.TripDao +import org.onebusaway.android.database.oba.TripRecord +import org.onebusaway.android.database.oba.TripDepartureTime import org.onebusaway.android.region.RegionRepository +import org.onebusaway.android.reminders.ReminderRepository import org.onebusaway.android.util.PreferenceUtils import org.onebusaway.android.util.MyTextUtils import org.onebusaway.android.util.ReminderUtils @@ -53,10 +55,7 @@ data class TripInfoArgs( val stopSequence: Int = 0, val serviceDate: Long = 0, val vehicleId: String? = null -) { - - val tripUri: Uri = ObaContract.Trips.buildUri(tripId, stopId) -} +) /** * The resolved trip-reminder data: [TripInfoArgs] merged with any existing Trips row (args win), @@ -97,45 +96,44 @@ class DefaultTripInfoRepository @Inject constructor( @ApplicationContext private val context: Context, private val regionRepository: RegionRepository, private val reminderService: ReminderWebService, + private val reminderRepository: ReminderRepository, + private val routeDao: RouteDao, + private val tripDao: TripDao, + private val stopDao: StopDao, + private val importGate: ImportGate, ) : TripInfoRepository { override suspend fun load(args: TripInfoArgs): TripInfoData = withContext(Dispatchers.IO) { + importGate.awaitReady() // If the launcher passed a route name, refresh it in the Routes table (legacy behavior). if (args.routeId != null && args.routeName != null) { - val values = ContentValues().apply { put(ObaContract.Routes.SHORTNAME, args.routeName) } - ObaContract.Routes.insertOrUpdate(context, args.routeId, values, false) + routeDao.refreshRouteShortName(args.routeId, args.routeName) } - val fromDb = context.contentResolver - .query(args.tripUri, PROJECTION, null, null, null) - ?.use { if (it.moveToFirst()) it.toExistingTrip(args) else null } + val fromDb = tripDao.getTrip(args.tripId, args.stopId)?.let { toExistingTrip(it, args) } fromDb ?: newTrip(args) } - /** Merges an existing Trips row with [args] (args win), looking up any still-missing names. */ - private fun Cursor.toExistingTrip(args: TripInfoArgs): TripInfoData { - val routeId = args.routeId ?: getString(COL_ROUTE_ID) + /** Merges an existing stored reminder with [args] (args win), looking up any still-missing names. */ + private suspend fun toExistingTrip(stored: TripRecord, args: TripInfoArgs): TripInfoData { + val routeId = args.routeId ?: stored.routeId val departTime = if (args.departTime != 0L) { args.departTime } else { - ObaContract.Trips.convertDBToTime(getInt(COL_DEPARTURE)) + TripDepartureTime.toEpochMillis(stored.departure) } - val routeName = args.routeName ?: ReminderUtils.getRouteShortName(context, routeId) - val stopName = args.stopName ?: ProviderQueries.stringForQuery( - context, - Uri.withAppendedPath(ObaContract.Stops.CONTENT_URI, args.stopId), - ObaContract.Stops.NAME - ) + val routeName = args.routeName ?: routeId?.let { routeDao.shortName(it) } + val stopName = args.stopName ?: stopDao.nameForStop(args.stopId) return toTripInfoData( routeId = routeId, routeName = routeName, stopName = stopName, - headsign = args.headsign ?: getString(COL_HEADSIGN), + headsign = args.headsign ?: stored.headsign, departTime = departTime, - stopSequence = if (args.stopSequence != 0) args.stopSequence else getInt(COL_STOP_SEQUENCE), - serviceDate = if (args.serviceDate != 0L) args.serviceDate else getLong(COL_SERVICE_DATE), - vehicleId = args.vehicleId ?: getString(COL_VEHICLE_ID), - tripName = getString(COL_NAME).orEmpty(), - reminderMinutes = getInt(COL_REMINDER), + stopSequence = if (args.stopSequence != 0) args.stopSequence else stored.stopSequence, + serviceDate = if (args.serviceDate != 0L) args.serviceDate else stored.serviceDate, + vehicleId = args.vehicleId ?: stored.vehicleId, + tripName = stored.name, + reminderMinutes = stored.reminder, isNewTrip = false ) } @@ -197,15 +195,30 @@ class DefaultTripInfoRepository @Inject constructor( reminderMinutes: Int, tripName: String ): Boolean = withContext(Dispatchers.IO) { - // Replace any existing alarm for this trip before registering the new one. - if (ReminderUtils.isAlarmExist(context, args.tripUri)) { - ReminderUtils.requestDeleteAlarm(context, args.tripUri) - } + importGate.awaitReady() + // Replace any existing alarm for this trip before registering the new one. Unconditional: the + // row delete and the server delete are both no-ops when nothing is saved (one read, not two). + reminderRepository.deleteReminder(args.tripId, args.stopId) PreferenceUtils.saveInt( context.getString(R.string.preference_key_default_reminder_time), reminderMinutes ) val alarmDeletePath = requestAlarm(args, data, reminderMinutes * 60) ?: return@withContext false - saveTrip(args, data, reminderMinutes, tripName, alarmDeletePath) + tripDao.upsert( + TripRecord( + id = args.tripId, + stopId = args.stopId, + routeId = data.routeId, + departure = TripDepartureTime.fromEpochMillis(data.departTime), + headsign = data.headsign, + name = tripName, + reminder = reminderMinutes, + alarmDeletePath = alarmDeletePath, + serviceDate = data.serviceDate, + stopSequence = data.stopSequence, + tripId = args.tripId, + vehicleId = data.vehicleId, + ) + ) true } @@ -237,52 +250,8 @@ class DefaultTripInfoRepository @Inject constructor( }.getOrNull() } - private fun saveTrip( - args: TripInfoArgs, - data: TripInfoData, - reminderMinutes: Int, - tripName: String, - alarmDeletePath: String - ) { - val values = ContentValues().apply { - put(ObaContract.Trips._ID, args.tripId) - put(ObaContract.Trips.TRIP_ID, args.tripId) - put(ObaContract.Trips.STOP_ID, args.stopId) - put(ObaContract.Trips.ROUTE_ID, data.routeId) - put(ObaContract.Trips.DEPARTURE, ObaContract.Trips.convertTimeToDB(data.departTime)) - put(ObaContract.Trips.HEADSIGN, data.headsign) - put(ObaContract.Trips.NAME, tripName) - put(ObaContract.Trips.REMINDER, reminderMinutes) - put(ObaContract.Trips.ALARM_DELETE_PATH, alarmDeletePath) - put(ObaContract.Trips.SERVICE_DATE, data.serviceDate) - put(ObaContract.Trips.STOP_SEQUENCE, data.stopSequence) - put(ObaContract.Trips.VEHICLE_ID, data.vehicleId) - } - context.contentResolver.insert(ObaContract.Trips.CONTENT_URI, values) - } - companion object { private const val DEFAULT_REMINDER_MINUTES = 10 - - private val PROJECTION = arrayOf( - ObaContract.Trips.NAME, - ObaContract.Trips.REMINDER, - ObaContract.Trips.ROUTE_ID, - ObaContract.Trips.HEADSIGN, - ObaContract.Trips.DEPARTURE, - ObaContract.Trips.STOP_SEQUENCE, - ObaContract.Trips.SERVICE_DATE, - ObaContract.Trips.VEHICLE_ID - ) - - private const val COL_NAME = 0 - private const val COL_REMINDER = 1 - private const val COL_ROUTE_ID = 2 - private const val COL_HEADSIGN = 3 - private const val COL_DEPARTURE = 4 - private const val COL_STOP_SEQUENCE = 5 - private const val COL_SERVICE_DATE = 6 - private const val COL_VEHICLE_ID = 7 } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/util/DBUtil.java b/onebusaway-android/src/main/java/org/onebusaway/android/util/DBUtil.java deleted file mode 100644 index 154a7adfbc..0000000000 --- a/onebusaway-android/src/main/java/org/onebusaway/android/util/DBUtil.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.onebusaway.android.util; - -import org.onebusaway.android.app.Application; -import org.onebusaway.android.models.ObaRoute; -import org.onebusaway.android.models.ObaStop; -import org.onebusaway.android.provider.ObaContract; -import org.onebusaway.android.ui.arrivals.ArrivalInfo; - -import android.content.ContentValues; -import android.content.Context; -import android.text.TextUtils; - -/** - * Created by azizmb9494 on 2/20/16. - */ -public class DBUtil { - public static void addToDB(ObaStop stop) { - addToDB(stop.getId(), stop.getStopCode(), stop.getName(), stop.getDirection(), - stop.getLatitude(), stop.getLongitude()); - } - - /** - * Field-based overload, for callers (e.g. the modernized io/client DTOs) that don't have an - * {@link ObaStop}. The {@link ObaStop} overload delegates here. - */ - public static void addToDB(String id, String code, String name, String direction, - double latitude, double longitude) { - ContentValues values = new ContentValues(); - values.put(ObaContract.Stops.CODE, code); - values.put(ObaContract.Stops.NAME, MyTextUtils.formatDisplayText(name)); - values.put(ObaContract.Stops.DIRECTION, direction); - values.put(ObaContract.Stops.LATITUDE, latitude); - values.put(ObaContract.Stops.LONGITUDE, longitude); - if (Application.get().getCurrentRegion() != null) { - values.put(ObaContract.Stops.REGION_ID, Application.get().getCurrentRegion().getId()); - } - ObaContract.Stops.insertOrUpdate(id, values, true); - } - - public static void addRouteToDB(Context ctx, ArrivalInfo arrivalInfo){ - if (Application.get().getCurrentRegion() == null) return; - - ContentValues routeValues = new ContentValues(); - - String shortName = arrivalInfo.getShortName(); - String longName = arrivalInfo.getRouteLongName(); - - if (TextUtils.isEmpty(longName)) { - longName = MyTextUtils.formatDisplayText(arrivalInfo.getHeadsign()); - } - - routeValues.put(ObaContract.Routes.SHORTNAME, shortName); - routeValues.put(ObaContract.Routes.LONGNAME, longName); - routeValues.put(ObaContract.Routes.REGION_ID, Application.get().getCurrentRegion().getId()); - - ObaContract.Routes.insertOrUpdate(ctx, arrivalInfo.getRouteId(), routeValues, true); - } - - public static void addRouteToDB(Context ctx, ObaRoute route){ - String shortName = route.getShortName(); - String longName = route.getLongName(); - - if (TextUtils.isEmpty(shortName)) { - shortName = longName; - } - if (TextUtils.isEmpty(longName) || shortName.equals(longName)) { - longName = route.getDescription(); - } - - addRouteToDB(ctx, route.getId(), shortName, longName, route.getUrl()); - } - - /** - * Registers a route in the recents/search provider from already-resolved display fields. - * Used by callers that hold a Compose-side route model rather than an {@link ObaRoute}. - */ - public static void addRouteToDB(Context ctx, String id, String shortName, String longName, - String url) { - if (Application.get().getCurrentRegion() == null) return; - - ContentValues routeValues = new ContentValues(); - routeValues.put(ObaContract.Routes.SHORTNAME, shortName); - routeValues.put(ObaContract.Routes.LONGNAME, longName); - routeValues.put(ObaContract.Routes.URL, url); - routeValues.put(ObaContract.Routes.REGION_ID, Application.get().getCurrentRegion().getId()); - - ObaContract.Routes.insertOrUpdate(ctx, id, routeValues, true); - } -} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/util/RegionUtils.java b/onebusaway-android/src/main/java/org/onebusaway/android/util/RegionUtils.java index 4c55aa2a91..4c6d0ae3e7 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/util/RegionUtils.java +++ b/onebusaway-android/src/main/java/org/onebusaway/android/util/RegionUtils.java @@ -22,13 +22,8 @@ import org.onebusaway.android.app.Application; import org.onebusaway.android.api.bridge.RegionsClient; import org.onebusaway.android.region.Region; -import org.onebusaway.android.region.RegionCursor; -import org.onebusaway.android.provider.ObaContract; -import android.content.ContentResolver; -import android.content.ContentValues; import android.content.Context; -import android.database.Cursor; import android.location.Location; import android.util.Log; @@ -73,7 +68,7 @@ public class RegionUtils { * enforceThreshold is true and the closest region exceeded DISTANCE_LIMITER threshold or a * region couldn't be found */ - public static Region getClosestRegion(ArrayList regions, Location loc, + public static Region getClosestRegion(List regions, Location loc, boolean enforceThreshold) { if (loc == null) { return null; @@ -322,249 +317,9 @@ public static String formatOtpBaseUrl(String baseUrl) { return baseUrl.replaceFirst("/$", ""); } - /** - * Gets regions from either the server, local provider, or if both fails the regions file - * packaged - * with the APK. Includes fail-over logic to prefer sources in above order, with server being - * the first preference. - * - * @param forceReload true if a reload from the server should be forced, false if it should not - * @return a list of regions from either the server, the local provider, or the packaged - * resource file - */ - public synchronized static ArrayList getRegions(Context context, - boolean forceReload) { - ArrayList results; - if (!forceReload) { - // - // Check the DB - // - results = RegionUtils.getRegionsFromProvider(context); - if (results != null) { - Log.d(TAG, "Retrieved regions from database."); - return results; - } - Log.d(TAG, "Regions list retrieved from database was null."); - } - - results = RegionUtils.getRegionsFromServer(context); - if (results == null || results.isEmpty()) { - Log.d(TAG, "Regions list retrieved from server was null or empty."); - - if (forceReload) { - //If we tried to force a reload from the server, then we haven't tried to reload from local provider yet - results = RegionUtils.getRegionsFromProvider(context); - if (results != null) { - Log.d(TAG, "Retrieved regions from database."); - return results; - } else { - Log.d(TAG, "Regions list retrieved from database was null."); - } - } - - //If we reach this point, the call to the Regions REST API failed and no results were - //available locally from a prior server request. - //Fetch regions from local resource file as last resort (otherwise user can't use app) - results = RegionUtils.getRegionsFromResources(context); - - if (results == null) { - //This is a complete failure to load region info from all sources, app will be useless - Log.d(TAG, "Regions list retrieved from local resource file was null."); - return results; - } - - Log.d(TAG, "Retrieved regions from local resource file."); - } else { - Log.d(TAG, "Retrieved regions list from server."); - //Update local time for when the last region info was retrieved from the server - Application.get().setLastRegionUpdateDate(new Date().getTime()); - } - - //If the region info came from the server or local resource file, we need to save it to the local provider - RegionUtils.saveToProvider(context, results); - return results; - } - - public static ArrayList getRegionsFromProvider(Context context) { - // Prefetch the bounds to limit the number of DB calls. - HashMap> allBounds = getBoundsFromProvider( - context); - HashMap> allOpen311Servers = - getOpen311ServersFromProvider(context); - Cursor c = null; - try { - final String[] PROJECTION = { - ObaContract.Regions._ID, - ObaContract.Regions.NAME, - ObaContract.Regions.OBA_BASE_URL, - ObaContract.Regions.SIRI_BASE_URL, - ObaContract.Regions.LANGUAGE, - ObaContract.Regions.CONTACT_EMAIL, - ObaContract.Regions.SUPPORTS_OBA_DISCOVERY, - ObaContract.Regions.SUPPORTS_OBA_REALTIME, - ObaContract.Regions.SUPPORTS_SIRI_REALTIME, - ObaContract.Regions.TWITTER_URL, - ObaContract.Regions.EXPERIMENTAL, - ObaContract.Regions.STOP_INFO_URL, - ObaContract.Regions.OTP_BASE_URL, - ObaContract.Regions.OTP_CONTACT_EMAIL, - ObaContract.Regions.SUPPORTS_OTP_BIKESHARE, - ObaContract.Regions.SUPPORTS_EMBEDDED_SOCIAL, - ObaContract.Regions.PAYMENT_ANDROID_APP_ID, - ObaContract.Regions.PAYMENT_WARNING_TITLE, - ObaContract.Regions.PAYMENT_WARNING_BODY, - ObaContract.Regions.TRAVEL_BEHAVIOR_DATA_COLLECTION, - ObaContract.Regions.ENROLL_PARTICIPANTS_IN_STUDY, - ObaContract.Regions.SIDECAR_BASE_URL, - ObaContract.Regions.PLAUSIBLE_ANALYTICS_SERVER_URL, - ObaContract.Regions.UMAMI_ANALYTICS_URL, - ObaContract.Regions.UMAMI_ANALYTICS_ID - }; - - ContentResolver cr = context.getContentResolver(); - c = cr.query( - ObaContract.Regions.CONTENT_URI, PROJECTION, null, null, - ObaContract.Regions._ID); - if (c == null) { - return null; - } - if (c.getCount() == 0) { - c.close(); - return null; - } - ArrayList results = new ArrayList(); - - c.moveToFirst(); - do { - long id = c.getLong(0); - ArrayList bounds = allBounds.get(id); - Region.Bounds[] bounds2 = (bounds != null) ? - bounds.toArray(new Region.Bounds[]{}) : - null; - - ArrayList open311Servers = allOpen311Servers.get(id); - Region.Open311Server[] open311Servers2 = (open311Servers != null) ? - open311Servers.toArray(new Region.Open311Server[]{}) : - null; - - results.add(RegionCursor.fromCursor(c, bounds2, open311Servers2)); - - } while (c.moveToNext()); - - return results; - - } finally { - if (c != null) { - c.close(); - } - } - } - - private static HashMap> getBoundsFromProvider( - Context context) { - // Prefetch the bounds to limit the number of DB calls. - Cursor c = null; - try { - final String[] PROJECTION = { - ObaContract.RegionBounds.REGION_ID, - ObaContract.RegionBounds.LATITUDE, - ObaContract.RegionBounds.LONGITUDE, - ObaContract.RegionBounds.LAT_SPAN, - ObaContract.RegionBounds.LON_SPAN - }; - HashMap> results - = new HashMap>(); - - ContentResolver cr = context.getContentResolver(); - c = cr.query(ObaContract.RegionBounds.CONTENT_URI, PROJECTION, null, null, null); - if (c == null) { - return results; - } - if (c.getCount() == 0) { - c.close(); - return results; - } - c.moveToFirst(); - do { - long regionId = c.getLong(0); - ArrayList bounds = results.get(regionId); - Region.Bounds b = new Region.Bounds( - c.getDouble(1), - c.getDouble(2), - c.getDouble(3), - c.getDouble(4)); - if (bounds != null) { - bounds.add(b); - } else { - bounds = new ArrayList(); - bounds.add(b); - results.put(regionId, bounds); - } - - } while (c.moveToNext()); - - return results; - - } finally { - if (c != null) { - c.close(); - } - } - } - - private static HashMap> getOpen311ServersFromProvider( - Context context) { - // Prefetch the bounds to limit the number of DB calls. - Cursor c = null; - try { - final String[] PROJECTION = { - ObaContract.RegionOpen311Servers.REGION_ID, - ObaContract.RegionOpen311Servers.JURISDICTION, - ObaContract.RegionOpen311Servers.API_KEY, - ObaContract.RegionOpen311Servers.BASE_URL - }; - HashMap> results - = new HashMap>(); - - ContentResolver cr = context.getContentResolver(); - c = cr.query(ObaContract.RegionOpen311Servers.CONTENT_URI, PROJECTION, null, null, null); - if (c == null) { - return results; - } - if (c.getCount() == 0) { - c.close(); - return results; - } - c.moveToFirst(); - do { - long regionId = c.getLong(0); - ArrayList open311Servers = results.get(regionId); - Region.Open311Server b = new Region.Open311Server( - c.getString(1), - c.getString(2), - c.getString(3)); - if (open311Servers != null) { - open311Servers.add(b); - } else { - open311Servers = new ArrayList(); - open311Servers.add(b); - results.put(regionId, open311Servers); - } - - } while (c.moveToNext()); - - return results; - - } finally { - if (c != null) { - c.close(); - } - } - } - - private synchronized static ArrayList getRegionsFromServer(Context context) { + public synchronized static ArrayList getRegionsFromServer(Context context) { return new ArrayList(RegionsClient.fetchRegionsFromServer(context)); } @@ -572,7 +327,7 @@ private synchronized static ArrayList getRegionsFromServer(Context conte * Retrieves region information from a regions file bundled within the app APK * * IMPORTANT - this should be a last resort, and we should always try to pull regions - * info from the local provider or Regions REST API instead of from the bundled file. + * info from the cached regions or the Regions REST API instead of from the bundled file. * * This method is only intended to be a fail-safe in case the Regions REST API goes * offline and a user downloads and installs OBA Android during that period @@ -630,112 +385,10 @@ public static Region getRegionFromBuildFlavor() { BuildConfig.FIXED_REGION_PAYMENT_ANDROID_APP_ID, BuildConfig.FIXED_REGION_PAYMENT_WARNING_TITLE, BuildConfig.FIXED_REGION_PAYMENT_WARNING_BODY, - BuildConfig.FIXED_REGION_TRAVEL_BEHAVIOR_DATA_COLLECTION, - BuildConfig.FIXED_REGION_ENROLL_PARTICIPANTS_IN_STUDY, BuildConfig.FIXED_REGION_SIDECAR_BASE_URL, BuildConfig.FIXED_REGION_PLAUSIBLE_ANALYTICS_SERVER_URL, null); // No Umami config for the fixed-region build flavor return region; } - // - // Saving - // - public synchronized static void saveToProvider(Context context, List regions) { - // Delete all the existing regions - ContentResolver cr = context.getContentResolver(); - cr.delete(ObaContract.Regions.CONTENT_URI, null, null); - // Should be a no-op? - cr.delete(ObaContract.RegionBounds.CONTENT_URI, null, null); - // Delete all existing open311 endpoints - cr.delete(ObaContract.RegionOpen311Servers.CONTENT_URI, null, null); - - for (Region region : regions) { - if (!isRegionUsable(region)) { - Log.d(TAG, "Skipping insert of '" + region.getName() + "' to provider..."); - continue; - } - - cr.insert(ObaContract.Regions.CONTENT_URI, toContentValues(region)); - Log.d(TAG, "Saved region '" + region.getName() + "' to provider"); - long regionId = region.getId(); - // Bulk insert the bounds - Region.Bounds[] bounds = region.getBounds(); - if (bounds != null) { - ContentValues[] values = new ContentValues[bounds.length]; - for (int i = 0; i < bounds.length; ++i) { - values[i] = toContentValues(regionId, bounds[i]); - } - cr.bulkInsert(ObaContract.RegionBounds.CONTENT_URI, values); - } - - Region.Open311Server[] open311Servers = region.getOpen311Servers(); - - if (open311Servers != null) { - ContentValues[] values = new ContentValues[open311Servers.length]; - for (int i = 0; i < open311Servers.length; ++i) { - values[i] = toContentValues(regionId, open311Servers[i]); - } - cr.bulkInsert(ObaContract.RegionOpen311Servers.CONTENT_URI, values); - } - } - } - - private static ContentValues toContentValues(Region region) { - ContentValues values = new ContentValues(); - values.put(ObaContract.Regions._ID, region.getId()); - values.put(ObaContract.Regions.NAME, region.getName()); - String obaUrl = region.getObaBaseUrl(); - values.put(ObaContract.Regions.OBA_BASE_URL, obaUrl != null ? obaUrl : ""); - String siriUrl = region.getSiriBaseUrl(); - values.put(ObaContract.Regions.SIRI_BASE_URL, siriUrl != null ? siriUrl : ""); - values.put(ObaContract.Regions.LANGUAGE, region.getLanguage()); - values.put(ObaContract.Regions.CONTACT_EMAIL, region.getContactEmail()); - values.put(ObaContract.Regions.SUPPORTS_OBA_DISCOVERY, - region.getSupportsObaDiscoveryApis() ? 1 : 0); - values.put(ObaContract.Regions.SUPPORTS_OBA_REALTIME, - region.getSupportsObaRealtimeApis() ? 1 : 0); - values.put(ObaContract.Regions.SUPPORTS_SIRI_REALTIME, - region.getSupportsSiriRealtimeApis() ? 1 : 0); - values.put(ObaContract.Regions.TWITTER_URL, region.getTwitterUrl()); - values.put(ObaContract.Regions.EXPERIMENTAL, region.getExperimental()); - values.put(ObaContract.Regions.STOP_INFO_URL, region.getStopInfoUrl()); - values.put(ObaContract.Regions.OTP_BASE_URL, region.getOtpBaseUrl()); - values.put(ObaContract.Regions.OTP_CONTACT_EMAIL, region.getOtpContactEmail()); - values.put(ObaContract.Regions.SUPPORTS_OTP_BIKESHARE, - region.getSupportsOtpBikeshare() ? 1 : 0); - values.put(ObaContract.Regions.SUPPORTS_EMBEDDED_SOCIAL, - region.getSupportsEmbeddedSocial() ? 1 : 0); - values.put(ObaContract.Regions.PAYMENT_ANDROID_APP_ID, region.getPaymentAndroidAppId()); - values.put(ObaContract.Regions.PAYMENT_WARNING_TITLE, region.getPaymentWarningTitle()); - values.put(ObaContract.Regions.PAYMENT_WARNING_BODY, region.getPaymentWarningBody()); - values.put(ObaContract.Regions.TRAVEL_BEHAVIOR_DATA_COLLECTION, - region.isTravelBehaviorDataCollectionEnabled() ? 1 : 0); - values.put(ObaContract.Regions.ENROLL_PARTICIPANTS_IN_STUDY, - region.isEnrollParticipantsInStudy() ? 1 : 0); - values.put(ObaContract.Regions.SIDECAR_BASE_URL, region.getSidecarBaseUrl()); - values.put(ObaContract.Regions.PLAUSIBLE_ANALYTICS_SERVER_URL, region.getPlausibleAnalyticsServerUrl()); - values.put(ObaContract.Regions.UMAMI_ANALYTICS_URL, region.getUmamiAnalyticsUrl()); - values.put(ObaContract.Regions.UMAMI_ANALYTICS_ID, region.getUmamiAnalyticsId()); - return values; - } - - private static ContentValues toContentValues(long region, Region.Bounds bounds) { - ContentValues values = new ContentValues(); - values.put(ObaContract.RegionBounds.REGION_ID, region); - values.put(ObaContract.RegionBounds.LATITUDE, bounds.getLat()); - values.put(ObaContract.RegionBounds.LONGITUDE, bounds.getLon()); - values.put(ObaContract.RegionBounds.LAT_SPAN, bounds.getLatSpan()); - values.put(ObaContract.RegionBounds.LON_SPAN, bounds.getLonSpan()); - return values; - } - - private static ContentValues toContentValues(long region, Region.Open311Server open311Server) { - ContentValues values = new ContentValues(); - values.put(ObaContract.RegionOpen311Servers.REGION_ID, region); - values.put(ObaContract.RegionOpen311Servers.BASE_URL, open311Server.getBaseUrl()); - values.put(ObaContract.RegionOpen311Servers.JURISDICTION, open311Server.getJuridisctionId()); - values.put(ObaContract.RegionOpen311Servers.API_KEY, open311Server.getApiKey()); - return values; - } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/util/ReminderUtils.java b/onebusaway-android/src/main/java/org/onebusaway/android/util/ReminderUtils.java index dac4b28440..fb8dc83486 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/util/ReminderUtils.java +++ b/onebusaway-android/src/main/java/org/onebusaway/android/util/ReminderUtils.java @@ -17,14 +17,8 @@ import org.onebusaway.android.R; import org.onebusaway.android.app.Application; -import org.onebusaway.android.api.bridge.ReminderClient; -import org.onebusaway.android.provider.ObaContract; -import org.onebusaway.android.provider.ProviderQueries; -import android.content.ContentResolver; import android.content.Context; -import android.database.Cursor; -import android.net.Uri; import android.util.Log; import org.json.JSONException; @@ -34,7 +28,9 @@ import java.util.List; /** - * Utilities to assist in the registering of reminder alarms for arriving/departing buses + * Pure helpers for the reminder feature: FCM payload parsing, the reminder-availability check, and the + * valid-lead-time list. The reminder storage (create/delete/exists over the Trips table) moved to + * {@code org.onebusaway.android.reminders.ReminderRepository} at the storage-modernization cutover. */ public class ReminderUtils { @@ -43,90 +39,10 @@ public class ReminderUtils { /** * Key for the arrival-and-departure reminder payload (JSON): the FCM message data key on receipt * and the intent extra it is forwarded as. Shared by the FCM service, the route translator, and - * this class's payload parsers ({@link #getStopIdFromPayload}, {@link #handleArrivalPayload}). + * the payload parsers ({@link #getStopIdFromPayload}, {@link #getTripIdFromPayload}). */ public static final String ARRIVAL_PAYLOAD_KEY = "arrival_and_departure"; - /** - * Retrieves the short name of a bus route based on the provided route ID. - * - * @param context the application context - * @param id the ID of the route - * @return the short name of the route - */ - public static String getRouteShortName(Context context, String id) { - return ProviderQueries.stringForQuery(context, Uri.withAppendedPath(ObaContract.Routes.CONTENT_URI, id), ObaContract.Routes.SHORTNAME); - } - - /** - * Deletes a saved reminder for a specific trip and stop ID. - * - * @param context the application context - * @param tripId the ID of the trip - * @param stopId the ID of the stop - */ - public static void deleteSavedReminder(Context context, String tripId, String stopId) { - String selection = ObaContract.Trips.TRIP_ID + " = ? AND " + ObaContract.Trips.STOP_ID + " = ?"; - String[] selectionArgs = new String[]{tripId, stopId}; - - Uri uri = ObaContract.Trips.CONTENT_URI; - - try { - context.getContentResolver().delete(uri, selection, selectionArgs); - } catch (Exception e) { - Log.e(TAG, "Failed to delete reminder for trip=" + tripId + " stop=" + stopId, e); - } - } - - /** - * Retrieves the delete path for an alarm based on the provided trip URI. - * - * @param context the application context - * @param tripUri the URI of the trip - * @return the delete path for the alarm, or null if not found - */ - public static String getAlarmDeletePath(Context context, Uri tripUri) { - ContentResolver cr = context.getContentResolver(); - String alarmDeletePath = null; - - try (Cursor cursor = cr.query(tripUri, new String[]{ObaContract.Trips.ALARM_DELETE_PATH}, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - alarmDeletePath = cursor.getString(cursor.getColumnIndexOrThrow(ObaContract.Trips.ALARM_DELETE_PATH)); - } - } catch (Exception e) { - Log.e(TAG, "Failed to get alarm delete path", e); - } - return alarmDeletePath; - } - - /** - * Requests server-side deletion of the reminder alarm for the trip URI (fire-and-forget) and - * removes the trip row from the content provider. - * - * @param context the application context - * @param tripUri the URI of the trip - */ - public static void requestDeleteAlarm(Context context, Uri tripUri) { - String alarmDeletePath = getAlarmDeletePath(context, tripUri); - ReminderClient.deleteAlarm(context, alarmDeletePath); - - context.getContentResolver().delete(tripUri, null, null); - } - - /** - * Checks if an alarm exists for the specified trip URI. - * - * @param context the application context - * @param tripURI the URI of the trip - * @return true if the alarm exists, false otherwise - */ - public static boolean isAlarmExist(Context context, Uri tripURI) { - ContentResolver cr = context.getContentResolver(); - try (Cursor c = cr.query(tripURI, new String[]{ObaContract.Trips._ID}, null, null, null)) { - return (c != null && c.getCount() > 0); - } - } - /** * Extracts the stop_id from an FCM arrival_and_departure JSON payload. * @@ -134,35 +50,28 @@ public static boolean isAlarmExist(Context context, Uri tripURI) { * @return the stop ID, or null if not present or unparseable */ public static String getStopIdFromPayload(String arrivalJson) { - if (arrivalJson == null) return null; - try { - JSONObject arrival = new JSONObject(arrivalJson); - String stopId = arrival.optString("stop_id", ""); - return stopId.isEmpty() ? null : stopId; - } catch (JSONException e) { - Log.e(TAG, "Error parsing arrival_and_departure JSON", e); - return null; - } + return stringFromPayload(arrivalJson, "stop_id"); } /** - * Processes an FCM arrival_and_departure payload: extracts trip/stop IDs and - * deletes the corresponding saved reminder. + * Extracts the trip_id from an FCM arrival_and_departure JSON payload. * - * @param context the application context * @param arrivalJson the JSON string from the arrival_and_departure FCM data field + * @return the trip ID, or null if not present or unparseable */ - public static void handleArrivalPayload(Context context, String arrivalJson) { - if (arrivalJson == null) return; + public static String getTripIdFromPayload(String arrivalJson) { + return stringFromPayload(arrivalJson, "trip_id"); + } + + private static String stringFromPayload(String arrivalJson, String key) { + if (arrivalJson == null) return null; try { JSONObject arrival = new JSONObject(arrivalJson); - String tripId = arrival.optString("trip_id", ""); - String stopId = arrival.optString("stop_id", ""); - if (!stopId.isEmpty()) { - deleteSavedReminder(context, tripId, stopId); - } + String value = arrival.optString(key, ""); + return value.isEmpty() ? null : value; } catch (JSONException e) { Log.e(TAG, "Error parsing arrival_and_departure JSON", e); + return null; } } @@ -170,8 +79,7 @@ public static void handleArrivalPayload(Context context, String arrivalJson) { * Checks if reminders should be available by verifying an FCM push token has been obtained. * Returns false if the token has not yet been fetched or registration failed. */ - public static boolean shouldShowReminders(){ - // TODO(D4): inject + public static boolean shouldShowReminders() { String pushId = Application.getUserPushID(); return pushId != null && !pushId.isEmpty(); } @@ -182,9 +90,8 @@ public static boolean shouldShowReminders(){ * @param departTime the departure time in milliseconds * @return the valid reminder times */ - public static String[] getReminderTimes(Context context, long departTime) { - Integer[] times = {3,5,10,15,20,25,30}; + Integer[] times = {3, 5, 10, 15, 20, 25, 30}; // Convert milliseconds to minutes and calculate the time until departure long departTimeInMinutes = (long) Math.ceil((departTime - System.currentTimeMillis()) / 60000.0); String[] allTimes = context.getResources().getStringArray(R.array.reminder_time); @@ -197,7 +104,7 @@ public static String[] getReminderTimes(Context context, long departTime) { for (Integer time : times) { if (time <= departTimeInMinutes) { validTimes.add(allTimes[index]); - }else{ + } else { break; } ++index; diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/util/SituationUtils.kt b/onebusaway-android/src/main/java/org/onebusaway/android/util/SituationUtils.kt index 78adb61a6f..e783dc7c56 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/util/SituationUtils.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/util/SituationUtils.kt @@ -18,7 +18,6 @@ package org.onebusaway.android.util import org.onebusaway.android.models.ObaSituation -import java.util.concurrent.TimeUnit /** * Utility methods related to situations (service alerts). Aggregation of a response's situations @@ -29,43 +28,23 @@ object SituationUtils { /** * Returns true if [currentTime] (epoch millis) falls within one of the [situation]'s active * windows, or if the situation has no active windows (assumed always-active). + * + * [currentTime] must be the response's **server** `currentTime` whenever the active windows come + * from a server response, so the comparison cancels device clock skew (#1612). Active-window + * `from`/`to` are already epoch millis (normalized at the wire→domain adapter, see + * `situationEpochToMillis`). This function is a pure function of its inputs — it reads no clock. */ @JvmStatic fun isActiveWindowForSituation(situation: ObaSituation, currentTime: Long): Boolean { - if (situation.activeWindows.isEmpty()) { + val windows = situation.activeWindows + if (windows.isEmpty()) { // We assume a situation is active if it doesn't contain any active window information. return true } - // Active window times are in seconds or milliseconds since epoch. - var currentTimeConverted = TimeUnit.MILLISECONDS.toSeconds(currentTime) - var isActiveWindowForSituation = false - for (activeWindow in situation.activeWindows) { - val from = activeWindow.from - val to = activeWindow.to - - if (!isTimestampInSeconds(from)) { - currentTimeConverted = TimeUnit.MILLISECONDS.toMillis(currentTime) - } - // 0 is a valid end time that means no end to the window - see #990. - if (from <= currentTimeConverted && (to == 0L || currentTimeConverted <= to)) { - isActiveWindowForSituation = true - break - } + return windows.any { window -> + // The window must have started (guards future-dated windows), and either be open-ended + // (to == 0 means no end — see #990) or not yet ended. + window.from <= currentTime && (window.to == 0L || currentTime <= window.to) } - return isActiveWindowForSituation - } - - /** - * Checks if the given timestamp is in seconds. - * - * @param timestamp the timestamp to check - * @return true if the timestamp is in seconds, false if it is in milliseconds - */ - private fun isTimestampInSeconds(timestamp: Long): Boolean { - // Get the current time in milliseconds. - val currentTimeMillis = System.currentTimeMillis() - - // If the timestamp is smaller than the current time divided by 1000, it's likely in seconds. - return timestamp < currentTimeMillis / 1000L } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/util/ThemeUtils.kt b/onebusaway-android/src/main/java/org/onebusaway/android/util/ThemeUtils.kt index 028d073d35..5fd2da5437 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/util/ThemeUtils.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/util/ThemeUtils.kt @@ -16,6 +16,8 @@ */ package org.onebusaway.android.util +import android.content.Context +import android.content.res.Configuration import androidx.appcompat.app.AppCompatDelegate import org.onebusaway.android.R import org.onebusaway.android.app.Application @@ -49,4 +51,21 @@ object ThemeUtils { } AppCompatDelegate.setDefaultNightMode(mode) } + + /** + * Returns true if the app is currently in dark mode: the AppCompat night-mode + * override takes precedence, otherwise the system UI configuration decides. + */ + @JvmStatic + fun isInDarkMode(context: Context): Boolean { + val mode = AppCompatDelegate.getDefaultNightMode() + if (mode == AppCompatDelegate.MODE_NIGHT_YES) { + return true + } + if (mode == AppCompatDelegate.MODE_NIGHT_NO) { + return false + } + return (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/widealerts/GtfsAlerts.java b/onebusaway-android/src/main/java/org/onebusaway/android/widealerts/GtfsAlerts.java index 4cb2cd0a2e..7b5e60c4ea 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/widealerts/GtfsAlerts.java +++ b/onebusaway-android/src/main/java/org/onebusaway/android/widealerts/GtfsAlerts.java @@ -47,7 +47,14 @@ public void fetchAlerts(String regionId, GtfsAlertCallBack callback) { try { URL url = new URL(pathUrl); GtfsRealtime.FeedMessage feed = GtfsRealtime.FeedMessage.parseFrom(url.openStream()); - processAlerts(feed.getEntityList(), callback); + // "Now" for the alert start-date window: the feed header timestamp is this feed's + // server clock (seconds since epoch), so using it cancels device clock skew (#1612). + // Resolve the device-clock fallback here at the boundary so the downstream check stays + // a pure function of its inputs. + long nowMs = feed.hasHeader() && feed.getHeader().hasTimestamp() + ? feed.getHeader().getTimestamp() * 1000L + : System.currentTimeMillis(); + processAlerts(feed.getEntityList(), nowMs, callback); fetchedRegions.add(regionId); } catch (Exception e) { Log.e(TAG, "Error fetching GTFS alert data for region: " + regionId, e); @@ -60,11 +67,13 @@ public void fetchAlerts(String regionId, GtfsAlertCallBack callback) { * Processes the list of GTFS alerts and triggers the callback for one valid alert. * * @param alerts The list of GTFS alert entities. + * @param nowMs "Now" in epoch millis for the start-date window — the feed's server clock, or + * the device clock when the feed carried no header timestamp (resolved by the caller). * @param callback The callback to handle each alert. */ - public void processAlerts(List alerts, GtfsAlertCallBack callback) { + public void processAlerts(List alerts, long nowMs, GtfsAlertCallBack callback) { for (GtfsRealtime.FeedEntity entity : alerts) { - if (!GtfsAlertsHelper.isValidEntity(mContext, entity)) { + if (!GtfsAlertsHelper.isValidEntity(mContext, entity, nowMs)) { continue; } GtfsRealtime.Alert alert = entity.getAlert(); diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/widealerts/GtfsAlertsHelper.java b/onebusaway-android/src/main/java/org/onebusaway/android/widealerts/GtfsAlertsHelper.java index c37e2db446..8935e9f8ac 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/widealerts/GtfsAlertsHelper.java +++ b/onebusaway-android/src/main/java/org/onebusaway/android/widealerts/GtfsAlertsHelper.java @@ -2,6 +2,7 @@ import com.google.transit.realtime.GtfsRealtime; +import org.onebusaway.android.app.di.DatabaseEntryPoint; import org.onebusaway.android.database.widealerts.AlertsRepository; import org.onebusaway.android.database.widealerts.entity.AlertEntity; @@ -86,10 +87,11 @@ public static String getAlertUrl(GtfsRealtime.Alert alert) { * Checks if the entity is valid based on agency-wide, severity, and start date criteria. * * @param entity The GTFS entity. + * @param nowMs "Now" in epoch millis (the feed's server clock) for the start-date window (#1612). * @return True if the alert is valid, false otherwise. */ - public static boolean isValidEntity(Context context, GtfsRealtime.FeedEntity entity) { - return isAgencyWideAlert(entity.getAlert()) && isHighSeverity(entity.getAlert()) && isStartDateWithin24Hours(entity.getAlert()) && !isAlertRead(context, entity); + public static boolean isValidEntity(Context context, GtfsRealtime.FeedEntity entity, long nowMs) { + return isAgencyWideAlert(entity.getAlert()) && isHighSeverity(entity.getAlert()) && isStartDateWithin24Hours(entity.getAlert(), nowMs) && !isAlertRead(context, entity); } /** @@ -118,15 +120,29 @@ public static boolean isHighSeverity(GtfsRealtime.Alert alert) { } /** - * Checks if the alert start date is within the last 24 hours. + * Checks if the alert start date is within the last 24 hours of {@code nowMs}. + * + *

{@code nowMs} should be the feed's server-clock generation time so the window cancels device + * clock skew — the alert's server {@code start} and the "now" it's measured against share one clock + * (#1612). Pure function of its inputs; the caller resolves the device-clock fallback. * * @param alert The GTFS alert. + * @param nowMs "Now" in epoch millis (the feed's server clock). * @return True if the start date is within the last 24 hours, false otherwise. */ - public static boolean isStartDateWithin24Hours(GtfsRealtime.Alert alert) { - long currentTime = System.currentTimeMillis(); + public static boolean isStartDateWithin24Hours(GtfsRealtime.Alert alert, long nowMs) { + // active_period is optional in GTFS-RT (an omitted period means "always active"). With no + // period there is no start to bound, so don't surface it as a freshly-started wide alert — + // and this avoids an IndexOutOfBoundsException on getActivePeriod(0) that would otherwise + // abort the whole feed's processing via the caller's broad catch. + if (alert.getActivePeriodCount() == 0) { + return false; + } long startTime = alert.getActivePeriod(0).getStart() * 1000L; - return (currentTime - startTime) <= 24 * 60 * 60 * 1000L; + long elapsed = nowMs - startTime; + // Must have already started (guards future-dated starts, which would otherwise pass the upper + // bound with a negative elapsed) and be no older than 24 hours. + return elapsed >= 0 && elapsed <= 24 * 60 * 60 * 1000L; } /** @@ -138,7 +154,7 @@ public static boolean isStartDateWithin24Hours(GtfsRealtime.Alert alert) { */ public static boolean isAlertRead(Context context, GtfsRealtime.FeedEntity entity) { - return AlertsRepository.isAlertExists(context, entity.getId()); + return alertsRepository(context).isAlertExists(entity.getId()); } /** @@ -148,7 +164,15 @@ public static boolean isAlertRead(Context context, GtfsRealtime.FeedEntity entit * @param entity The `GtfsRealtime.FeedEntity` object representing the alert. */ public static void markAlertAsRead(Context context, GtfsRealtime.FeedEntity entity) { - AlertsRepository.insertAlert(context, new AlertEntity(entity.getId())); + alertsRepository(context).insertAlert(new AlertEntity(entity.getId())); + } + + /** + * Resolves the Hilt-provided {@link AlertsRepository} from a bare {@link Context} (this helper is a + * static, non-injectable Java utility). Callers here run on the GtfsAlerts background fetch thread. + */ + private static AlertsRepository alertsRepository(Context context) { + return DatabaseEntryPoint.get(context).alertsRepository(); } public static String getCurrentAppLanguageCode() { diff --git a/onebusaway-android/src/main/res/drawable/selected_map_stop_icon.xml b/onebusaway-android/src/main/res/drawable/selected_map_stop_icon.xml index c052ccbe44..71def0efad 100644 --- a/onebusaway-android/src/main/res/drawable/selected_map_stop_icon.xml +++ b/onebusaway-android/src/main/res/drawable/selected_map_stop_icon.xml @@ -90,7 +90,7 @@ - + diff --git a/onebusaway-android/src/main/res/raw/regions_v3.json b/onebusaway-android/src/main/res/raw/regions_v3.json index e9ede9c473..817940b42d 100644 --- a/onebusaway-android/src/main/res/raw/regions_v3.json +++ b/onebusaway-android/src/main/res/raw/regions_v3.json @@ -51,9 +51,7 @@ "paymentWarningTitle": null, "paymentWarningBody": null, "paymentiOSAppStoreIdentifier": "1487465395", - "paymentiOSAppUrlScheme": "fb313213768708402HART", - "travelBehaviorDataCollectionEnabled": false, - "enrollParticipantsInStudy": false + "paymentiOSAppUrlScheme": "fb313213768708402HART" }, { "id": 1, @@ -138,9 +136,7 @@ "paymentWarningTitle": "Check before you buy!", "paymentWarningBody": "The mobile fare payment app for Puget Sound does not support all transit service shown in OneBusAway. Please check that a ticket is eligible for your agency and route before you purchase!", "paymentiOSAppStoreIdentifier": "1131345078", - "paymentiOSAppUrlScheme": "co.bytemark.tgt", - "travelBehaviorDataCollectionEnabled": false, - "enrollParticipantsInStudy": false + "paymentiOSAppUrlScheme": "co.bytemark.tgt" }, { "id": 2, @@ -183,9 +179,7 @@ "paymentWarningTitle": null, "paymentWarningBody": null, "paymentiOSAppStoreIdentifier": null, - "paymentiOSAppUrlScheme": null, - "travelBehaviorDataCollectionEnabled": false, - "enrollParticipantsInStudy": false + "paymentiOSAppUrlScheme": null }, { "id": 4, @@ -222,9 +216,7 @@ "paymentWarningTitle": null, "paymentWarningBody": null, "paymentiOSAppStoreIdentifier": null, - "paymentiOSAppUrlScheme": null, - "travelBehaviorDataCollectionEnabled": false, - "enrollParticipantsInStudy": false + "paymentiOSAppUrlScheme": null }, { "id": 11, @@ -273,9 +265,7 @@ "paymentWarningTitle": null, "paymentWarningBody": null, "paymentiOSAppStoreIdentifier": "1577230742", - "paymentiOSAppUrlScheme": "org.sdmts.pronto", - "travelBehaviorDataCollectionEnabled": false, - "enrollParticipantsInStudy": false + "paymentiOSAppUrlScheme": "org.sdmts.pronto" }, { "id": 15, @@ -360,9 +350,7 @@ "paymentWarningTitle": null, "paymentWarningBody": null, "paymentiOSAppStoreIdentifier": null, - "paymentiOSAppUrlScheme": null, - "travelBehaviorDataCollectionEnabled": false, - "enrollParticipantsInStudy": false + "paymentiOSAppUrlScheme": null }, { "id": 16, @@ -399,9 +387,7 @@ "paymentWarningTitle": null, "paymentWarningBody": null, "paymentiOSAppStoreIdentifier": null, - "paymentiOSAppUrlScheme": null, - "travelBehaviorDataCollectionEnabled": false, - "enrollParticipantsInStudy": false + "paymentiOSAppUrlScheme": null } ] } diff --git a/onebusaway-android/src/main/res/values/colors.xml b/onebusaway-android/src/main/res/values/colors.xml index f0690bf152..9113c45faa 100644 --- a/onebusaway-android/src/main/res/values/colors.xml +++ b/onebusaway-android/src/main/res/values/colors.xml @@ -26,6 +26,9 @@ @color/brand_color_dark #5db53b + + #FF9500 + #de000000 #8a000000 #44000000 diff --git a/onebusaway-android/src/main/res/values/donottranslate.xml b/onebusaway-android/src/main/res/values/donottranslate.xml index 0ce599642b..72bb4064c1 100644 --- a/onebusaway-android/src/main/res/values/donottranslate.xml +++ b/onebusaway-android/src/main/res/values/donottranslate.xml @@ -29,6 +29,7 @@ preference_last_region_update preference_auto_select_region preference_experimental_regions + preferences_map_stop_cache_size preference_google_analytics preference_preferred_units preference_preferred_temperature_units @@ -167,9 +168,6 @@ download_app open_app - - - https:// diff --git a/onebusaway-android/src/main/res/values/strings.xml b/onebusaway-android/src/main/res/values/strings.xml index 60b8b10be3..d4c4789165 100644 --- a/onebusaway-android/src/main/res/values/strings.xml +++ b/onebusaway-android/src/main/res/values/strings.xml @@ -397,6 +397,10 @@ %1$d hidden alert %1$d hidden alerts + + %1$d active alert shown + %1$d active alerts shown + %1$d more alert %1$d more alerts @@ -514,6 +518,8 @@ selected automatically selected manually Found %1$s region + Couldn\'t load regions + Check your connection and try again. @@ -647,6 +653,10 @@ Experimental regions Enables regions that may be unstable + Zoom in to see more stops + Map stop cache size + Max bus stops kept on the map while panning (currently %1$d) + Enter a number between %1$d and %2$d Experimental regions may be unstable and without real-time info! Go ahead? diff --git a/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreRenderer.kt b/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreRenderer.kt index 1af1cf0169..9f7c186dc7 100644 --- a/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreRenderer.kt +++ b/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreRenderer.kt @@ -38,6 +38,8 @@ import org.onebusaway.android.map.render.CorrectionSmoother import org.onebusaway.android.map.render.GeoPoint import org.onebusaway.android.map.render.MapRenderState import org.onebusaway.android.map.render.MapVehicles +import org.onebusaway.android.map.render.StopBand +import org.onebusaway.android.map.render.StopIconKind import org.onebusaway.android.map.render.StopMarker import org.onebusaway.android.map.render.TripMarkerBitmaps import org.onebusaway.android.map.render.TripOverlay @@ -45,6 +47,7 @@ import org.onebusaway.android.map.render.TripStopBitmaps import org.onebusaway.android.map.render.VehicleBitmaps import org.onebusaway.android.map.render.VehicleMarker import org.onebusaway.android.map.render.bikeZoomBand +import org.onebusaway.android.map.render.stopIconKind import org.onebusaway.android.util.MyTextUtils import org.onebusaway.android.util.getRouteDisplayName @@ -55,9 +58,10 @@ import org.onebusaway.android.util.getRouteDisplayName * handlers. * * Two redraw paths, mirroring the Google flavor's two recomposition boundaries: - * - [renderStatic] (stops / route polylines / bikes / generics / trip-stop dots) is a clear-and-redraw - * of just the static annotations, driven by snapshot/trip-stop changes (viewport loads, the vehicle - * poll, focus) — a bounded cost. + * - [renderStatic] clear-and-redraws the static annotations (route polylines / bikes / generics / + * trip-stop dots) and reconciles the stop markers in place ([reconcileStopMarkers], so unchanged + * stops don't blink), driven by snapshot/trip-stop changes (viewport loads, the vehicle poll, + * focus) — a bounded cost. * - [renderDynamic] (the live vehicle markers + the trip-focus band/estimate markers) is pulled each * display frame by the adapter's vsync loop. It updates marker positions **in place** (so an open * info window survives and there's no per-frame flicker) and only adds/removes annotations as the @@ -73,6 +77,16 @@ class MapLibreRenderer( ) { private val stopByMarker = HashMap() + // Stop markers tracked by stop id so [reconcileStopMarkers] can diff them in place (add new, + // remove gone, re-icon only on a focus flip) instead of clear-and-redraw — keeping unchanged stops + // from blinking on every static redraw. Like the vehicle markers, these are NOT in + // [staticAnnotations] and so survive a static redraw; [renderedFocusedStopId] is the focus the + // icons were last drawn for. They live until MapView.onDestroy(), like vehicleMarkersByTripId. + private val stopMarkersByStopId = HashMap() + private var renderedFocusedStopId: String? = null + // The zoom band the stop icons were last drawn for (full icon vs dot); see [reconcileStopMarkers]. + private var renderedStopBand = StopBand.FULL + private val bikeByMarker = HashMap() private val vehicleByMarker = HashMap() @@ -119,7 +133,8 @@ class MapLibreRenderer( map.removeAnnotations(staticAnnotations) staticAnnotations.clear() } - stopByMarker.clear() + // Stop markers are reconciled in place (not in staticAnnotations), so they survive this; only + // the bike tap map is cleared here. bikeByMarker.clear() for (polyline in snapshot.routePolylines) { @@ -142,18 +157,7 @@ class MapLibreRenderer( ) } - for (stop in snapshot.stops) { - val icon = if (stop.id == snapshot.focusedStopId) { - MapLibreStopIcons.focusedIconForDirection(context, stop.direction) - } else { - MapLibreStopIcons.iconForDirection(context, stop.direction) - } - val marker = map.addMarker( - MarkerOptions().position(stop.point.toLatLng()).icon(icon) - ) - staticAnnotations.add(marker) - stopByMarker[marker] = stop - } + reconcileStopMarkers(snapshot.stops, snapshot.focusedStopId, snapshot.stopBand) if (snapshot.bikeshareVisible) { val band = bikeZoomBand(map.cameraPosition.zoom.toFloat()) @@ -190,6 +194,50 @@ class MapLibreRenderer( } } + /** + * Diff the stop markers against [stops] in place (the [reconcileVehicleMarkers] pattern): remove + * markers whose id has left, add markers for new ids, and re-icon an existing marker only when its + * icon kind changes — a focus flip or a zoom-band crossing ([band], full icon ⇄ dot). Unchanged + * stops keep their native marker, so they don't blink on a static redraw. Tracked in + * [stopMarkersByStopId] (not [staticAnnotations]) so a static redraw leaves them. + */ + private fun reconcileStopMarkers(stops: List, focusedStopId: String?, band: StopBand) { + val liveIds = stops.mapTo(HashSet()) { it.id } + val gone = stopMarkersByStopId.iterator() + while (gone.hasNext()) { + val entry = gone.next() + if (entry.key !in liveIds) { + map.removeAnnotation(entry.value) + stopByMarker.remove(entry.value) + gone.remove() + } + } + for (stop in stops) { + val kind = stopIconKind(stop.id == focusedStopId, band) + val existing = stopMarkersByStopId[stop.id] + if (existing == null) { + val marker = map.addMarker( + MarkerOptions().position(stop.point.toLatLng()).icon(stopIcon(stop, kind)) + ) + stopMarkersByStopId[stop.id] = marker + stopByMarker[marker] = stop + } else if (stopIconKind(stop.id == renderedFocusedStopId, renderedStopBand) != kind) { + // Only the markers whose icon kind changed need a new icon (maplibre centers the icon + // on the position, so the dot lands on the stop with no anchor change). + existing.icon = stopIcon(stop, kind) + } + } + renderedFocusedStopId = focusedStopId + renderedStopBand = band + } + + private fun stopIcon(stop: StopMarker, kind: StopIconKind): Icon = when (kind) { + StopIconKind.FULL -> MapLibreStopIcons.iconForDirection(stop.direction) + StopIconKind.FULL_FOCUSED -> MapLibreStopIcons.focusedIconForDirection(stop.direction) + StopIconKind.DOT -> MapLibreStopIcons.dotIcon() + StopIconKind.DOT_FOCUSED -> MapLibreStopIcons.focusedDotIcon() + } + /** * Update the dynamic layer for one display frame: the route's live [vehicles] (null off route mode) * and the trip-focus [overlay] (null off trip-focus). Markers move in place (smoothed across a fresh diff --git a/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreStopIcons.java b/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreStopIcons.java index 25d4648cd8..bb76e39485 100644 --- a/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreStopIcons.java +++ b/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreStopIcons.java @@ -34,6 +34,7 @@ import org.onebusaway.android.R; import org.onebusaway.android.app.Application; +import org.onebusaway.android.map.render.StopBitmaps; import java.util.HashMap; import java.util.Map; @@ -79,8 +80,18 @@ private MapLibreStopIcons() { private static final int NUM_DIRECTIONS = 9; - private static final Bitmap[] bus_stop_icons = new Bitmap[NUM_DIRECTIONS]; - private static final Bitmap[] bus_stop_icons_focused = new Bitmap[NUM_DIRECTIONS]; + // The Icons (native texture wrappers) are built once so re-iconing markers — e.g. swapping every + // stop full icon ⇄ dot on a zoom-band crossing, up to the full stop cache at once (default 200, up + // to 2000) — reuses them instead of wrapping a fresh Icon per marker (the cost that made the + // transition stutter). + private static final Icon[] bus_stop_icons = new Icon[NUM_DIRECTIONS]; + private static final Icon[] bus_stop_icons_focused = new Icon[NUM_DIRECTIONS]; + + /** The small directionless dot shown in place of the full icon at distant zoom (declutter). */ + private static Icon dot_stop_icon; + + /** The focused (accent) variant of {@link #dot_stop_icon}, so a selection stays visible far out. */ + private static Icon dot_stop_icon_focused; private static final float FOCUS_ICON_SCALE = 1.5f; @@ -100,15 +111,31 @@ public static synchronized void ensureLoaded() { } /** The normal (unfocused) stop icon for a direction string ("N".."NW" / "null"). */ - public static synchronized Icon iconForDirection(Context context, String direction) { + public static synchronized Icon iconForDirection(String direction) { ensureLoaded(); - return IconFactory.getInstance(context).fromBitmap(bus_stop_icons[indexFor(direction)]); + return bus_stop_icons[indexFor(direction)]; } /** The focused (1.5x) stop icon for a direction string. */ - public static synchronized Icon focusedIconForDirection(Context context, String direction) { + public static synchronized Icon focusedIconForDirection(String direction) { + ensureLoaded(); + return bus_stop_icons_focused[indexFor(direction)]; + } + + /** + * The small dot shown in place of the full icon at distant zoom — directionless and route-type + * agnostic (a neutral themed point). maplibre centers the icon on the position, so it lands on + * the stop without anchor math. + */ + public static synchronized Icon dotIcon() { ensureLoaded(); - return IconFactory.getInstance(context).fromBitmap(bus_stop_icons_focused[indexFor(direction)]); + return dot_stop_icon; + } + + /** The focused (accent) dot, shown for the selected stop at distant zoom. */ + public static synchronized Icon focusedDotIcon() { + ensureLoaded(); + return dot_stop_icon_focused; } private static int indexFor(String direction) { @@ -132,17 +159,19 @@ private static void loadIcons() { mArrowPaintStroke.setStrokeWidth(1.0f); mArrowPaintStroke.setAntiAlias(true); + IconFactory iconFactory = IconFactory.getInstance(Application.get()); String[] directions = {NORTH, NORTH_WEST, WEST, SOUTH_WEST, SOUTH, SOUTH_EAST, EAST, NORTH_EAST, NO_DIRECTION}; for (int i = 0; i < directions.length; i++) { - bus_stop_icons[i] = createBusStopIcon(directions[i], false); - bus_stop_icons_focused[i] = createBusStopIcon(directions[i], true); - } - for (int i = 0; i < NUM_DIRECTIONS; i++) { - Bitmap bmp = bus_stop_icons_focused[i]; - bus_stop_icons_focused[i] = Bitmap.createScaledBitmap(bmp, - (int) (bmp.getWidth() * FOCUS_ICON_SCALE), - (int) (bmp.getHeight() * FOCUS_ICON_SCALE), true); + bus_stop_icons[i] = iconFactory.fromBitmap(createBusStopIcon(directions[i], false)); + Bitmap focused = createBusStopIcon(directions[i], true); + focused = Bitmap.createScaledBitmap(focused, + (int) (focused.getWidth() * FOCUS_ICON_SCALE), + (int) (focused.getHeight() * FOCUS_ICON_SCALE), true); + bus_stop_icons_focused[i] = iconFactory.fromBitmap(focused); } + + dot_stop_icon = iconFactory.fromBitmap(StopBitmaps.dot(mPx, r.getColor(R.color.theme_primary))); + dot_stop_icon_focused = iconFactory.fromBitmap(StopBitmaps.dot(mPx, r.getColor(R.color.map_stop_focus))); } @SuppressWarnings("deprecation") diff --git a/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/compose/MapLibreComposeAdapter.kt b/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/compose/MapLibreComposeAdapter.kt index de7e87363a..fea95c8f51 100644 --- a/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/compose/MapLibreComposeAdapter.kt +++ b/onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/compose/MapLibreComposeAdapter.kt @@ -19,9 +19,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.content.res.Configuration import android.view.ViewGroup -import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.runtime.Composable @@ -59,6 +57,7 @@ import org.onebusaway.android.map.maplibre.MapLibreRenderer import org.onebusaway.android.map.render.CameraSnapshot import org.onebusaway.android.map.render.GeoPoint import org.onebusaway.android.util.PermissionUtils +import org.onebusaway.android.util.ThemeUtils import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map @@ -139,7 +138,7 @@ class MapLibreComposeAdapter : ObaComposeMapAdapter { .build() } map.uiSettings.isCompassEnabled = false - val styleUrl = if (isInDarkMode(context)) STYLE_URL_DARK else STYLE_URL_LIGHT + val styleUrl = if (ThemeUtils.isInDarkMode(context)) STYLE_URL_DARK else STYLE_URL_LIGHT map.setStyle(Style.Builder().fromUri(styleUrl)) { style -> loadedStyle = style val r = MapLibreRenderer(map, context, renderState) @@ -317,19 +316,6 @@ private fun enableLocationComponent(map: MapLibreMap, style: Style, context: Con component.isLocationComponentEnabled = true } -/** Mirrors the former MapLibreMapHost.inDarkMode: app night-mode override, else the system config. */ -private fun isInDarkMode(context: Context): Boolean { - val mode = AppCompatDelegate.getDefaultNightMode() - if (mode == AppCompatDelegate.MODE_NIGHT_YES) { - return true - } - if (mode == AppCompatDelegate.MODE_NIGHT_NO) { - return false - } - return (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == - Configuration.UI_MODE_NIGHT_YES -} - private fun Context.findActivity(): Activity { var ctx: Context = this while (ctx is ContextWrapper) { diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/api/data/SituationEpochToMillisTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/api/data/SituationEpochToMillisTest.kt new file mode 100644 index 0000000000..cd21f44d25 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/api/data/SituationEpochToMillisTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.api.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [situationEpochToMillis], the wire→domain normalization that makes active-window + * `from`/`to` unambiguously epoch millis (they arrive as seconds OR millis depending on the server; + * see the function doc). Mirrors the OBA server's own `toMillis` threshold (1e12). + */ +class SituationEpochToMillisTest { + + @Test + fun `epoch seconds are scaled to millis`() { + // 1_700_000_000 s (Nov 2023) → millis. + assertEquals(1_700_000_000_000L, situationEpochToMillis(1_700_000_000L)) + } + + @Test + fun `epoch millis pass through unchanged`() { + assertEquals(1_700_000_000_000L, situationEpochToMillis(1_700_000_000_000L)) + } + + @Test + fun `zero (unset from, or to== no end) passes through`() { + assertEquals(0L, situationEpochToMillis(0L)) + } + + @Test + fun `boundary at the threshold is treated as millis`() { + // Exactly 1e12 (year 2001 in millis) is at/above the threshold → already millis. + assertEquals(1_000_000_000_000L, situationEpochToMillis(1_000_000_000_000L)) + // Just below → treated as seconds and scaled. + assertEquals(999_999_999_999_000L, situationEpochToMillis(999_999_999_999L)) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteLogicTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteLogicTest.kt new file mode 100644 index 0000000000..44de951b12 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/database/oba/RouteHeadsignFavoriteLogicTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The legacy route/headsign favorite precedence, ported to a pure in-memory resolver. */ +class RouteHeadsignFavoriteLogicTest { + + private fun row(routeId: String, headsign: String, stopId: String, exclude: Int) = + RouteHeadsignFavoriteRecord(routeId = routeId, headsign = headsign, stopId = stopId, exclude = exclude) + + @Test + fun `no rows is not a favorite`() { + assertFalse(computeRouteHeadsignFavorite(emptyList(), "r1", "Downtown", "s1")) + } + + @Test + fun `favorited for this exact stop`() { + val rows = listOf(row("r1", "Downtown", "s1", 0)) + assertTrue(computeRouteHeadsignFavorite(rows, "r1", "Downtown", "s1")) + } + + @Test + fun `favorited for all stops applies to any stop`() { + val rows = listOf(row("r1", "Downtown", ALL_STOPS_FAVORITE, 0)) + assertTrue(computeRouteHeadsignFavorite(rows, "r1", "Downtown", "s1")) + assertTrue(computeRouteHeadsignFavorite(rows, "r1", "Downtown", "s2")) + } + + @Test + fun `all-stops favorite excluded at this stop is not a favorite here`() { + val rows = listOf( + row("r1", "Downtown", ALL_STOPS_FAVORITE, 0), + row("r1", "Downtown", "s1", 1), // exclusion for s1 + ) + assertFalse(computeRouteHeadsignFavorite(rows, "r1", "Downtown", "s1")) + assertTrue(computeRouteHeadsignFavorite(rows, "r1", "Downtown", "s2")) + } + + @Test + fun `direct stop favorite wins over an exclusion row`() { + val rows = listOf( + row("r1", "Downtown", "s1", 0), + row("r1", "Downtown", "s1", 1), + ) + assertTrue(computeRouteHeadsignFavorite(rows, "r1", "Downtown", "s1")) + } + + @Test + fun `different route or headsign is not a favorite`() { + val rows = listOf(row("r1", "Downtown", "s1", 0)) + assertFalse(computeRouteHeadsignFavorite(rows, "r2", "Downtown", "s1")) + assertFalse(computeRouteHeadsignFavorite(rows, "r1", "Uptown", "s1")) + } + + @Test + fun `null headsign matches the empty-string headsign`() { + val rows = listOf(row("r1", "", "s1", 0)) + assertTrue(computeRouteHeadsignFavorite(rows, "r1", null, "s1")) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/database/oba/TripDepartureTimeTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/database/oba/TripDepartureTimeTest.kt new file mode 100644 index 0000000000..d7c14b92d3 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/database/oba/TripDepartureTimeTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.oba + +import java.time.Instant +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * JVM unit tests for the `trips.departure` "minutes after midnight" encoding. Uses a fixed non-DST zone + * (UTC) so the round-trip is deterministic regardless of when/where the test runs. + */ +class TripDepartureTimeTest { + + private val utc = ZoneId.of("UTC") + + @Test + fun roundTrips_everyBoundaryMinuteOfDay() { + // Endpoints, hour rollover, noon, and the last minute of the day. + for (m in listOf(0, 1, 59, 60, 90, 719, 720, 1439)) { + val millis = TripDepartureTime.toEpochMillis(m, utc) + assertEquals("round-trip for $m", m, TripDepartureTime.fromEpochMillis(millis, utc)) + } + } + + @Test + fun toEpochMillis_isMinutesAfterLocalMidnight() { + val midnight = TripDepartureTime.toEpochMillis(0, utc) + // 90 minutes-after-midnight is exactly 90 minutes past the day's start. + assertEquals(90 * 60_000L, TripDepartureTime.toEpochMillis(90, utc) - midnight) + } + + @Test + fun fromEpochMillis_extractsLocalHourAndMinute() { + val instant = Instant.parse("2024-06-15T13:45:00Z").toEpochMilli() + assertEquals(13 * 60 + 45, TripDepartureTime.fromEpochMillis(instant, utc)) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/database/survey/SurveyRepositoryTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/database/survey/SurveyRepositoryTest.kt new file mode 100644 index 0000000000..090aac007d --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/database/survey/SurveyRepositoryTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.database.survey + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.onebusaway.android.database.oba.ImportGate +import org.onebusaway.android.database.survey.dao.StudiesDao +import org.onebusaway.android.database.survey.entity.Study +import org.onebusaway.android.database.survey.entity.Survey +import org.onebusaway.android.database.survey.dao.SurveysDao +import org.onebusaway.android.models.Survey as SurveyModel +import org.onebusaway.android.models.SurveyStudy + +/** + * JVM unit tests for the survey persistence logic — the completed/skipped upsert and the completed-id + * projection — driven through hand fakes of the two DAOs (no Room, matching the FakePreferencesRepository + * convention). This is the logic the modernization moved out of the untestable static SurveyDbHelper. + */ +class SurveyRepositoryTest { + + private val studies = FakeStudiesDao() + private val surveys = FakeSurveysDao() + private val repo = SurveyRepository(studies, surveys, NoopImportGate) + + private fun surveyModel(id: Int, study: SurveyStudy?) = SurveyModel( + study = study, + name = "Survey $id", + questions = emptyList(), + id = id, + showOnMap = null, + showOnStops = null, + allowsMultipleResponses = null, + alwaysVisible = null, + visibleStopList = null, + visibleRouteList = null, + ) + + @Test + fun markCompletedOrSkipped_withoutStudy_writesNothing() = runTest { + repo.markCompletedOrSkipped(surveyModel(1, study = null), SurveyRepository.SURVEY_COMPLETED) + + assertEquals(0, studies.rows.size) + assertEquals(0, surveys.rows.size) + } + + @Test + fun markCompletedOrSkipped_insertsStudyThenSurvey() = runTest { + val study = SurveyStudy(name = "Study", description = "Desc", id = 7) + + repo.markCompletedOrSkipped(surveyModel(3, study), SurveyRepository.SURVEY_SKIPPED) + + assertEquals(1, studies.inserts) + assertEquals(0, studies.updates) + assertEquals(Study(7, "Study", "Desc", true), studies.rows[7]) + assertEquals(1, surveys.rows.size) + assertEquals(Survey(3, 7, "Survey 3", SurveyRepository.SURVEY_SKIPPED), surveys.rows[0]) + } + + @Test + fun markCompletedOrSkipped_existingStudy_updatesRatherThanReinserts() = runTest { + val study = SurveyStudy(name = "Study", description = "Desc", id = 7) + repo.markCompletedOrSkipped(surveyModel(3, study), SurveyRepository.SURVEY_COMPLETED) + + // Second survey for the same study: the study is upserted via update, not a second insert. + repo.markCompletedOrSkipped(surveyModel(4, study), SurveyRepository.SURVEY_COMPLETED) + + assertEquals(1, studies.inserts) + assertEquals(1, studies.updates) + assertEquals(2, surveys.rows.size) + } + + @Test + fun markCompletedOrSkipped_sameSurveyIdTwice_replacesRatherThanDuplicating() = runTest { + val study = SurveyStudy(name = "Study", description = "Desc", id = 7) + repo.markCompletedOrSkipped(surveyModel(3, study), SurveyRepository.SURVEY_SKIPPED) + + // Re-marking the same survey id (skip -> complete) replaces the row (survey_id PK), not appends. + repo.markCompletedOrSkipped(surveyModel(3, study), SurveyRepository.SURVEY_COMPLETED) + + assertEquals(1, surveys.rows.size) + assertEquals(SurveyRepository.SURVEY_COMPLETED, surveys.rows.single().state) + assertEquals(setOf(3), repo.completedSurveyIds()) + } + + @Test + fun completedSurveyIds_returnsEveryPersistedSurveyId() = runTest { + val study = SurveyStudy(name = "Study", description = "Desc", id = 7) + repo.markCompletedOrSkipped(surveyModel(3, study), SurveyRepository.SURVEY_COMPLETED) + repo.markCompletedOrSkipped(surveyModel(9, study), SurveyRepository.SURVEY_SKIPPED) + + assertEquals(setOf(3, 9), repo.completedSurveyIds()) + } +} + +/** The import gate is already satisfied in these tests — awaitReady is a no-op. */ +private object NoopImportGate : ImportGate { + override suspend fun awaitReady() {} + override fun start() {} +} + +private class FakeStudiesDao : StudiesDao { + val rows = mutableMapOf() + var inserts = 0 + var updates = 0 + + override suspend fun insertStudy(study: Study): Long { + inserts++ + rows[study.study_id] = study + return study.study_id.toLong() + } + + override suspend fun updateStudy(study: Study) { + updates++ + rows[study.study_id] = study + } + + override suspend fun deleteStudy(study: Study) { + rows.remove(study.study_id) + } + + override suspend fun getStudyById(studyId: Int): Study? = rows[studyId] + + override suspend fun getAllStudies(): List = rows.values.toList() +} + +private class FakeSurveysDao : SurveysDao { + val rows = mutableListOf() + + override suspend fun insertSurvey(survey: Survey): Long { + // Model Room's OnConflictStrategy.REPLACE on the survey_id primary key. + rows.removeAll { it.survey_id == survey.survey_id } + rows.add(survey) + return survey.survey_id.toLong() + } + + override suspend fun updateSurvey(survey: Survey) { + val i = rows.indexOfFirst { it.survey_id == survey.survey_id } + if (i >= 0) rows[i] = survey + } + + override suspend fun deleteSurvey(survey: Survey) { + rows.removeAll { it.survey_id == survey.survey_id } + } + + override suspend fun getAllSurveys(): List = rows.toList() + + override suspend fun getSurveyById(surveyId: Int): Survey? = rows.firstOrNull { it.survey_id == surveyId } + + override suspend fun getSurveysByStudyId(studyId: Int): List = rows.filter { it.study_id == studyId } + + override suspend fun isSurveyIdExists(surveyId: Int): Boolean = rows.any { it.survey_id == surveyId } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/map/RouteDirectionFocusTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/map/RouteDirectionFocusTest.kt new file mode 100644 index 0000000000..ed24fed4c8 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/map/RouteDirectionFocusTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.map + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.onebusaway.android.api.adapters.ObaStopElement +import org.onebusaway.android.models.RouteMapStop + +/** [focusDirection] — the pure "narrow a route to the stop-relevant direction" repository helper. */ +class RouteDirectionFocusTest { + + private fun stop(id: String, vararg directions: Int) = + RouteMapStop(ObaStopElement(id = id), directions.toSet()) + + // Outbound (direction 0) serves a/b, inbound (direction 1) serves c/d. + private val stops = listOf(stop("a", 0), stop("b", 0), stop("c", 1), stop("d", 1)) + + @Test + fun nullAnchorKeepsWholeRoute() { + val focus = stops.focusDirection(anchorStopId = null) + assertEquals(listOf("a", "b", "c", "d"), focus.stops.map { it.id }) + assertNull(focus.directionId) + } + + @Test + fun anchorInOneDirectionFiltersStopsAndResolvesId() { + val focus = stops.focusDirection(anchorStopId = "c") + assertEquals(listOf("c", "d"), focus.stops.map { it.id }) + assertEquals(1, focus.directionId) + } + + @Test + fun filteredStopsPreserveSourceOrder() { + // A source order of d before c must survive the filter to direction 1. + val reordered = listOf(stop("d", 1), stop("c", 1), stop("a", 0)) + assertEquals(listOf("d", "c"), reordered.focusDirection("c").stops.map { it.id }) + } + + @Test + fun sharedStopInTargetDirectionIsIncluded() { + // A stop serving both directions shows for the tapped one (not dropped). + val withShared = stops + stop("s", 0, 1) + val focus = withShared.focusDirection(anchorStopId = "a") + assertEquals(listOf("a", "b", "s"), focus.stops.map { it.id }) + assertEquals(0, focus.directionId) + } + + @Test + fun ambiguousSharedAnchorKeepsWholeRoute() { + // Anchoring on a stop that serves both directions is ambiguous -> whole route. + val withShared = stops + stop("s", 0, 1) + val focus = withShared.focusDirection(anchorStopId = "s") + assertEquals(5, focus.stops.size) + assertNull(focus.directionId) + } + + @Test + fun anchorNotAmongStopsKeepsWholeRoute() { + val focus = stops.focusDirection(anchorStopId = "zzz") + assertEquals(4, focus.stops.size) + assertNull(focus.directionId) + } + + @Test + fun ungroupedAnchorKeepsWholeRoute() { + // A stop with no direction membership (route without a numeric direction grouping) -> whole route. + val withUngrouped = stops + stop("u") + val focus = withUngrouped.focusDirection(anchorStopId = "u") + assertEquals(5, focus.stops.size) + assertNull(focus.directionId) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/map/StopAccumulationTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/map/StopAccumulationTest.kt index 08a5639851..2c26c44d23 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/map/StopAccumulationTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/map/StopAccumulationTest.kt @@ -24,11 +24,12 @@ import org.onebusaway.android.map.render.GeoPoint import org.onebusaway.android.map.render.StopMarker /** - * Unit tests for the stop-accumulation logic [MapViewModel.showStops] / [MapViewModel.clearStops] - * delegate to — the easily-broken part of the data-shaping: the 200-cap clears the accumulation *but - * keeps the focused stop*, and the same focus-retention drives `clearStops(false)`. Split into pure - * functions ([capStopAccumulation] / [retainOnlyFocusedStop]) over plain [StopMarker]s so it runs on - * the JVM — the per-stop marker build (which touches an Android `Location`) is exercised on device. + * Unit tests for the stop-accumulation logic [StopsMapController.showStops] / [StopsMapController.clearStops] + * delegate to — the easily-broken part of the data-shaping: the LRU trim evicts least-recently-used + * stops down to the 200-cap *but never the focused stop*, and the same focus-retention drives + * `clearStops(false)`. Split into pure functions ([trimStopCache] / [retainOnlyFocusedStop]) over + * plain [StopMarker]s so it runs on the JVM — the per-stop marker build (which touches an Android + * `Location`) is exercised on device. */ class StopAccumulationTest { @@ -40,34 +41,53 @@ class StopAccumulationTest { private fun LinkedHashMap.ids() = keys.toSet() - // --- capStopAccumulation --- + // --- trimStopCache (the LRU eviction; accumOf is insertion- = eldest-first order) --- @Test - fun `below the cap is a no-op`() { - val accum = accumOf("a", "b") - capStopAccumulation(accum, focusedId = "a", cap = 5) - assertEquals(setOf("a", "b"), accum.ids()) + fun `at or below the cap is a no-op`() { + val accum = accumOf("a", "b", "c") + trimStopCache(accum, focusedId = "a", cap = 3) + assertEquals(setOf("a", "b", "c"), accum.ids()) } @Test - fun `at the cap clears the accumulation but keeps the focused stop`() { - val accum = accumOf("a", "b", "c") - capStopAccumulation(accum, focusedId = "b", cap = 3) - assertEquals(setOf("b"), accum.ids()) + fun `over the cap evicts eldest-first down to the cap`() { + val accum = accumOf("a", "b", "c", "d") + trimStopCache(accum, focusedId = null, cap = 2) + assertEquals(setOf("c", "d"), accum.ids()) } @Test - fun `at the cap with no focus clears everything`() { - val accum = accumOf("a", "b", "c") - capStopAccumulation(accum, focusedId = null, cap = 3) - assertEquals(emptySet(), accum.ids()) + fun `over the cap never evicts the focused stop`() { + val accum = accumOf("a", "b", "c", "d") + // "a" is the eldest, but being focused it's skipped; the next-eldest evict instead. + trimStopCache(accum, focusedId = "a", cap = 2) + assertEquals(setOf("a", "d"), accum.ids()) } @Test - fun `at the cap with a focus id that is not accumulated clears everything`() { + fun `over the cap with no focus evicts pure eldest`() { val accum = accumOf("a", "b", "c") - capStopAccumulation(accum, focusedId = "gone", cap = 3) - assertEquals(emptySet(), accum.ids()) + trimStopCache(accum, focusedId = null, cap = 1) + assertEquals(setOf("c"), accum.ids()) + } + + @Test + fun `the access-ordered LRU bumps a re-seen stop so it outlives an untouched eldest`() { + // The eviction is only "least-recently-USED" because stopAccum is access-ordered and showStops + // re-touches each fetched stop. Build the map exactly as StopsMapController.stopAccum (access + // order) and re-see the insertion-eldest "a" the way showStops does (getOrPut's get() on a hit + // bumps it to most-recently-used), leaving "b" the eldest. + val accum = LinkedHashMap(16, 0.75f, true) + listOf("a", "b", "c").forEach { accum[it] = marker(it) } + accum.getOrPut("a") { marker("a") } + + trimStopCache(accum, focusedId = null, cap = 2) + + // LRU: the now-eldest "b" is evicted and the bumped "a" survives. A plain (insertion-ordered) + // map — or dropping the getOrPut bump — would instead evict the first-inserted "a" and keep "b", + // so this test fails if stopAccum loses its access-order config or showStops stops re-touching. + assertEquals(setOf("a", "c"), accum.ids()) } // --- retainOnlyFocusedStop (the clearStops(false) path) --- diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/map/compose/ServerNowMsTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/map/compose/ServerNowMsTest.kt new file mode 100644 index 0000000000..6b1e52e35f --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/map/compose/ServerNowMsTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.map.compose + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [serverNowMs], the pure projection behind the vehicle info-window's live age text. + * It anchors "now" on the poll's server `currentTime` and advances it by elapsed **device** time, so + * the age it feeds is measured in the server clock domain and is immune to device clock skew (#1612). + */ +class ServerNowMsTest { + + @Test + fun `at the capture instant it equals the server time`() { + val server = 1_700_000_000_000L + val deviceStart = 5_000L + assertEquals(server, serverNowMs(server, deviceStart, deviceNowMs = deviceStart)) + } + + @Test + fun `it advances by the elapsed device time`() { + val server = 1_700_000_000_000L + val deviceStart = 5_000L + // 12s of real device time elapsed since the response was observed. + assertEquals(server + 12_000L, serverNowMs(server, deviceStart, deviceNowMs = deviceStart + 12_000L)) + } + + @Test + fun `a device-now before the anchor is clamped, never yielding a past 'now'`() { + // The synchronous remember(serverTimeMs) anchor can be captured up to ~1s ahead of the + // 1s-lagged ticker; the clamp must keep the result at serverTimeMs, not before it. + val server = 1_700_000_000_000L + val deviceStart = 5_000L + assertEquals(server, serverNowMs(server, deviceStart, deviceNowMs = deviceStart - 800L)) + } + + @Test + fun `device clock offset from server does not leak into the result`() { + // The device wall clock is skewed +10min relative to the server, but only the *delta* between + // deviceStart and deviceNow matters — the absolute device value cancels out. Two devices with + // very different clocks that observe the same server time and elapse the same real time report + // the same server-domain "now". + val server = 1_700_000_000_000L + val skewedStartA = 9_000_000L + val skewedStartB = 9_000_000L + 600_000L // +10min skew + val elapsed = 30_000L + assertEquals( + serverNowMs(server, skewedStartA, skewedStartA + elapsed), + serverNowMs(server, skewedStartB, skewedStartB + elapsed) + ) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/map/render/StopZoomBandTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/map/render/StopZoomBandTest.kt new file mode 100644 index 0000000000..e1012d6d28 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/map/render/StopZoomBandTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.map.render + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pure-logic tests for the stop dot/full zoom banding the renderers use to decide a stop marker's + * icon — the band threshold and the focus/band → icon-kind mapping, shared identically by both map + * flavors. The actual bitmap build + marker reconcile is exercised on device. + */ +class StopZoomBandTest { + + @Test + fun `below the threshold is the dot band, at or above is the full band`() { + assertEquals(StopBand.DOT, stopZoomBand(STOP_DOT_ZOOM_THRESHOLD - 0.01f)) + assertEquals(StopBand.DOT, stopZoomBand(0f)) + assertEquals(StopBand.FULL, stopZoomBand(STOP_DOT_ZOOM_THRESHOLD)) + assertEquals(StopBand.FULL, stopZoomBand(STOP_DOT_ZOOM_THRESHOLD + 0.01f)) + assertEquals(StopBand.FULL, stopZoomBand(21f)) + } + + @Test + fun `the band flips at zoom 15 (pins the threshold constant, not just the boundary)`() { + // Literal anchors (not STOP_DOT_ZOOM_THRESHOLD) so retuning the constant is a deliberate change + // that has to update this test — the ± boundary cases above would otherwise silently follow it. + assertEquals(15f, STOP_DOT_ZOOM_THRESHOLD, 0f) + assertEquals(StopBand.DOT, stopZoomBand(14.99f)) + assertEquals(StopBand.FULL, stopZoomBand(15f)) + } + + @Test + fun `at the dot band the focused stop gets the accent dot, others the plain dot`() { + assertEquals(StopIconKind.DOT, stopIconKind(focused = false, band = StopBand.DOT)) + assertEquals(StopIconKind.DOT_FOCUSED, stopIconKind(focused = true, band = StopBand.DOT)) + } + + @Test + fun `at the full band focus selects the enlarged icon`() { + assertEquals(StopIconKind.FULL, stopIconKind(focused = false, band = StopBand.FULL)) + assertEquals(StopIconKind.FULL_FOCUSED, stopIconKind(focused = true, band = StopBand.FULL)) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/region/RegionMapperTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/region/RegionMapperTest.kt new file mode 100644 index 0000000000..70a07e2e80 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/region/RegionMapperTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.region + +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 org.onebusaway.android.database.oba.Open311ServerRecord +import org.onebusaway.android.database.oba.RegionBoundRecord +import org.onebusaway.android.database.oba.RegionRecord +import org.onebusaway.android.database.oba.RegionWithChildren + +/** Parity + round-trip for the region-cache <-> domain [Region] mapping (replaces RegionCursor). */ +class RegionMapperTest { + + @Test + fun `toRegion maps scalar fields, flags, bounds and open311 servers`() { + val row = RegionWithChildren( + region = RegionRecord( + id = 42L, + name = "Puget Sound", + obaBaseUrl = "https://oba", + siriBaseUrl = "https://siri", + language = "en", + contactEmail = "a@b.c", + supportsObaDiscovery = 1, + supportsObaRealtime = 0, + supportsSiriRealtime = 1, + twitterUrl = "https://twitter", + experimental = 1, + stopInfoUrl = "https://stop", + otpBaseUrl = "https://otp", + otpContactEmail = "otp@b.c", + supportsOtpBikeshare = 1, + supportsEmbeddedSocial = 0, + paymentAndroidAppId = "app.id", + paymentWarningTitle = "title", + paymentWarningBody = "body", + sidecarBaseUrl = "https://sidecar", + plausibleAnalyticsServerUrl = "https://plausible", + umamiAnalyticsUrl = "https://umami", + umamiAnalyticsId = "umami-id", + ), + bounds = listOf(RegionBoundRecord(regionId = 42L, latitude = 47.6, longitude = -122.3, latSpan = 0.4, lonSpan = 0.5)), + open311Servers = listOf(Open311ServerRecord(regionId = 42L, jurisdiction = "j", apiKey = "k", baseUrl = "https://311")), + ) + + val region = RegionMapper.toRegion(row) + + assertEquals(42L, region.id) + assertEquals("Puget Sound", region.name) + assertTrue(region.active) + assertEquals("https://oba", region.obaBaseUrl) + assertTrue(region.supportsObaDiscoveryApis) + assertFalse(region.supportsObaRealtimeApis) + assertTrue(region.supportsSiriRealtimeApis) + assertTrue(region.experimental) + assertTrue(region.supportsOtpBikeshare) + assertFalse(region.supportsEmbeddedSocial) + assertEquals(1, region.bounds.size) + assertEquals(47.6, region.bounds[0].lat, 0.0) + assertEquals(0.5, region.bounds[0].lonSpan, 0.0) + assertEquals(1, region.open311Servers.size) + assertEquals("https://311", region.open311Servers[0].baseUrl) + assertEquals("https://umami", region.umamiAnalytics?.url) + assertEquals("umami-id", region.umamiAnalytics?.id) + } + + @Test + fun `null integer flags read as false and null umami is absent`() { + val row = RegionWithChildren( + region = RegionRecord( + id = 1L, name = "R", obaBaseUrl = "", siriBaseUrl = "", language = "", contactEmail = "", + supportsObaDiscovery = 0, supportsObaRealtime = 0, supportsSiriRealtime = 0, + experimental = null, supportsOtpBikeshare = null, supportsEmbeddedSocial = null, + umamiAnalyticsUrl = null, umamiAnalyticsId = null, + ), + bounds = emptyList(), + open311Servers = emptyList(), + ) + + val region = RegionMapper.toRegion(row) + + assertFalse(region.experimental) + assertFalse(region.supportsOtpBikeshare) + assertNull(region.umamiAnalytics) + } + + @Test + fun `region round-trips through entities preserving fields`() { + val original = Region( + id = 7L, + name = "Round Trip", + active = true, + obaBaseUrl = "https://oba", + siriBaseUrl = "https://siri", + bounds = arrayOf(Region.Bounds(1.0, 2.0, 3.0, 4.0)), + open311Servers = arrayOf(Region.Open311Server("juris", "key", "https://311")), + language = "en", + contactEmail = "a@b.c", + supportsObaDiscoveryApis = true, + supportsObaRealtimeApis = false, + supportsSiriRealtimeApis = true, + twitterUrl = "https://twitter", + experimental = false, + supportsOtpBikeshare = true, + supportsEmbeddedSocial = true, + umamiAnalytics = Region.UmamiAnalyticsConfig("https://umami", "id"), + ) + + val back = RegionMapper.toRegion(RegionMapper.toEntities(original)) + + assertEquals(original.id, back.id) + assertEquals(original.name, back.name) + assertEquals(original.obaBaseUrl, back.obaBaseUrl) + assertEquals(original.supportsObaDiscoveryApis, back.supportsObaDiscoveryApis) + assertEquals(original.supportsObaRealtimeApis, back.supportsObaRealtimeApis) + assertEquals(original.experimental, back.experimental) + assertEquals(original.supportsOtpBikeshare, back.supportsOtpBikeshare) + assertEquals(original.language, back.language) + assertEquals(original.contactEmail, back.contactEmail) + assertEquals(original.siriBaseUrl, back.siriBaseUrl) + assertEquals(original.supportsSiriRealtimeApis, back.supportsSiriRealtimeApis) + assertEquals(original.twitterUrl, back.twitterUrl) + assertEquals(original.supportsEmbeddedSocial, back.supportsEmbeddedSocial) + assertEquals(1, back.bounds.size) + assertEquals(3.0, back.bounds[0].latSpan, 0.0) + assertEquals("juris", back.open311Servers[0].jurisdictionId) + assertEquals("https://umami", back.umamiAnalytics?.url) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/region/RegionTestFixtures.kt b/onebusaway-android/src/test/java/org/onebusaway/android/region/RegionTestFixtures.kt index bb1ecfc3b0..5d098996db 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/region/RegionTestFixtures.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/region/RegionTestFixtures.kt @@ -81,8 +81,6 @@ internal fun region( null, // paymentAndroidAppId null, // paymentWarningTitle null, // paymentWarningBody - false, // travelBehaviorDataCollectionEnabled - false, // enrollParticipantsInStudy null, // sidecarBaseUrl null, // plausibleAnalyticsServerUrl null // umamiAnalytics (UmamiAnalyticsConfig) diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModelTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModelTest.kt index 1817ad0a12..7aa91dcb36 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModelTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/ArrivalsViewModelTest.kt @@ -114,6 +114,12 @@ private class FakeArrivalsRepository( hideState.update { it.copy(decisions = it.decisions + ids.associateWith { false }) } } + override suspend fun markAlertRead(id: String) {} + + override suspend fun setAlertHidden(id: String, hidden: Boolean) {} + + override suspend fun hideAllRecordedAlerts() {} + override fun alertDetails(id: String): AlertDetails? = null override fun lastLoaded(): ArrivalsLoaded? = null diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/dataview/TripTrajectoryTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/dataview/TripTrajectoryTest.kt index 98c4056931..1cdcf6cd0d 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/dataview/TripTrajectoryTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/dataview/TripTrajectoryTest.kt @@ -20,8 +20,13 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue +import org.onebusaway.android.api.adapters.StopTimeData +import org.onebusaway.android.api.adapters.TripScheduleData import org.onebusaway.android.extrapolation.data.TripState import org.onebusaway.android.extrapolation.math.prob.ProbDistribution +import org.onebusaway.android.models.ObaRoute +import org.onebusaway.android.models.ObaTripSchedule +import org.onebusaway.android.testing.testTripStatus import org.junit.Test class TripTrajectoryTest { @@ -132,6 +137,82 @@ class TripTrajectoryTest { assertEquals(1_007_000L, trajectory.nowMs) } + // --- buildTrajectory: extrapolation-overlay now line (clock skew) --- + // + // The now-line tests above only exercise TripTrajectory.nowMs. These drive a real anchor so + // extrapolate() succeeds and the overlay is non-null, guarding the ExtrapolationSeries.nowMs + // lift in extrapolationSeries() — the operand of the user-facing schedule-deviation label. + // Reverting that lift to `nowMs = nowMs` (the pre-#1594 bug) must fail these. + + /** + * A grade-separated trip whose anchor was seen at [anchorServerMs] on the server clock and + * [anchorLocalMs] on the local clock, mid-route. Grade-separated + a schedule routes + * extrapolate() through schedule replay, which succeeds mid-route, so buildTrajectory() emits a + * non-null extrapolation overlay. + */ + private fun skewedRailState(anchorServerMs: Long, anchorLocalMs: Long): TripState = + TripState.empty("trip1") + .withStatus( + testTripStatus(distanceAlongTrip = 500.0, lastUpdateTime = anchorServerMs, activeTripId = "trip1"), + serverTimeMs = anchorServerMs, + localTimeMs = anchorLocalMs, + ) + .withSchedule(railSchedule) + .withRouteType(ObaRoute.TYPE_SUBWAY) + + /** Three stops at 0/1000/3000 m — enough for schedule replay to succeed mid-route. */ + private val railSchedule = makeSchedule( + Triple(0.0, 0L, 0L), + Triple(1000.0, 100L, 130L), + Triple(3000.0, 330L, 330L), + ) + + private fun makeSchedule(vararg stops: Triple): ObaTripSchedule { + val stopTimes: Array = Array(stops.size) { i -> + val (dist, arrive, depart) = stops[i] + StopTimeData(stopId = "stop_$i", arrivalTime = arrive, departureTime = depart, distanceAlongTrip = dist) + } + return TripScheduleData(stopTimes) + } + + @Test + fun `the extrapolation overlay now line is lifted onto the server clock by the anchor skew`() { + // Anchor seen at server 105_000 / local 100_000 -> server runs 5s ahead. + val state = skewedRailState(anchorServerMs = 105_000L, anchorLocalMs = 100_000L) + val trajectory = buildTrajectory(state, nowMs = 102_000L) + + val extrapolation = requireNotNull(trajectory.extrapolation) { + "a mid-route grade-separated anchor should yield an overlay" + } + // Local "now" (102_000) plotted at its server-clock equivalent (+5s skew). Reverting the + // toServerClock lift in extrapolationSeries() to `nowMs = nowMs` would leave this at 102_000. + assertEquals(107_000L, extrapolation.nowMs) + } + + @Test + fun `the overlay now line and the trajectory now line share the server-clock axis`() { + val state = skewedRailState(anchorServerMs = 105_000L, anchorLocalMs = 100_000L) + val trajectory = buildTrajectory(state, nowMs = 102_000L) + + // Guards the "now line drifts off-axis" symptom (#1583): the overlay's now line must sit on + // the same server-clock axis as the trajectory's, not lag on the raw local clock. (A bounds + // check would be tautological — buildTrajectory derives the bounds from this very value.) + val extrapolation = requireNotNull(trajectory.extrapolation) + assertEquals(trajectory.nowMs, extrapolation.nowMs) + } + + @Test + fun `the extrapolation overlay now line drops onto the server clock when the server is behind`() { + // Anchor seen at server 97_000 / local 100_000 -> server runs 3s behind: exercises the + // subtraction branch of toServerClock the +5s cases never reach. + val state = skewedRailState(anchorServerMs = 97_000L, anchorLocalMs = 100_000L) + val trajectory = buildTrajectory(state, nowMs = 102_000L) + + val extrapolation = requireNotNull(trajectory.extrapolation) + // toServerClock(102_000) = 102_000 + (97_000 - 100_000) = 99_000. + assertEquals(99_000L, extrapolation.nowMs) + } + // --- interpolateScheduleTime --- private fun stop(dist: Double, arriveMs: Long, departMs: Long = arriveMs) = diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/home/HomeViewModelTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/home/HomeViewModelTest.kt index 752bd105de..5e001c83fc 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/home/HomeViewModelTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/home/HomeViewModelTest.kt @@ -28,6 +28,7 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.onebusaway.android.location.FakeLocationRepository +import org.onebusaway.android.map.ShowRouteRequest import org.onebusaway.android.region.FakeRegionRepository import org.onebusaway.android.region.RegionStatus import org.onebusaway.android.region.region @@ -50,7 +51,7 @@ private class MapDirectiveRecorder(private val vm: HomeViewModel) { val sent = mutableListOf() val recenters get() = sent.filterIsInstance().map { it.lat to it.lon } - val routesShown get() = sent.filterIsInstance().map { it.routeId } + val routesShown get() = sent.filterIsInstance().map { it.request.routeId } val clearFocusCount get() = sent.count { it is MapDirective.ClearFocus } val focusStops get() = sent.filterIsInstance() val lastBottomPadding get() = vm.mapBottomPadding.value @@ -269,7 +270,7 @@ class HomeViewModelTest { val eventJob = launch { vm.sheetCommands.collect { events.add(it) } } advanceUntilIdle() - vm.requestShowRouteOnMap("42") + vm.requestShowRouteOnMap(ShowRouteRequest("42")) advanceUntilIdle() assertEquals(listOf(SheetCommand.CollapseSheet), events) diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/home/RegionPickerViewModelTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/home/RegionPickerViewModelTest.kt index 9a617c7cdc..7dcc6685cd 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/home/RegionPickerViewModelTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/home/RegionPickerViewModelTest.kt @@ -19,7 +19,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.onebusaway.android.region.FakeRegionRepository @@ -29,8 +31,9 @@ import org.onebusaway.android.testing.MainDispatcherRule /** * Unit tests for [RegionPickerViewModel]: it exposes the repository's [RegionState.NeedsManualChoice] as the - * [RegionPickerViewModel.picker] list and resolves it via [RegionPickerViewModel.choose] — the forced picker - * is now driven entirely off the repository, not HomeViewModel/HomeUiState. + * [RegionPickerViewModel.picker] list (resolved via [RegionPickerViewModel.choose]) and [RegionState.Failed] + * as [RegionPickerViewModel.failed] (retried via [RegionPickerViewModel.retry]) — the forced picker and its + * failure sibling are now driven entirely off the repository, not HomeViewModel/HomeUiState. */ @OptIn(ExperimentalCoroutinesApi::class) class RegionPickerViewModelTest { @@ -71,4 +74,52 @@ class RegionPickerViewModelTest { assertEquals(listOf(chosen), repo.chosen) assertNull(vm.picker.value) } + + @Test + fun `a resolving transition clears a shown picker`() = runTest { + val repo = FakeRegionRepository().apply { + emitState(RegionState.NeedsManualChoice(listOf(region(1)))) + } + val vm = RegionPickerViewModel(repo) + advanceUntilIdle() + assertEquals(listOf(region(1)), vm.picker.value) + + // A subsequent refresh (e.g. the user retried) re-enters Resolving — the picker must clear. + repo.emitState(RegionState.Resolving) + advanceUntilIdle() + assertNull(vm.picker.value) + assertFalse(vm.failed.value) + } + + @Test + fun `a failed load clears the picker and raises the failure flag`() = runTest { + val repo = FakeRegionRepository().apply { + emitState(RegionState.NeedsManualChoice(listOf(region(1)))) + } + val vm = RegionPickerViewModel(repo) + advanceUntilIdle() + + repo.emitState(RegionState.Failed) + advanceUntilIdle() + + assertNull(vm.picker.value) + assertTrue(vm.failed.value) + } + + @Test + fun `retry re-attempts resolution and a success clears the failure flag`() = runTest { + val repo = FakeRegionRepository().apply { emitState(RegionState.Failed) } + val vm = RegionPickerViewModel(repo) + advanceUntilIdle() + assertTrue(vm.failed.value) + + // A successful retry drives the repository back to Active, so the failure flag reactively clears. + repo.refreshResult = org.onebusaway.android.region.RegionStatus.Changed(region(1)) + vm.retry() + repo.emit(region(1)) + advanceUntilIdle() + + assertEquals(1, repo.refreshCount) + assertFalse(vm.failed.value) + } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/IntentRouteMapperTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/IntentRouteMapperTest.kt index 6e0eb2d7f1..c47df71fce 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/IntentRouteMapperTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/IntentRouteMapperTest.kt @@ -17,7 +17,6 @@ package org.onebusaway.android.ui.nav import org.junit.Assert.assertEquals import org.junit.Test -import org.onebusaway.android.provider.ObaContract import org.onebusaway.android.ui.mylists.MyTabs import org.onebusaway.android.ui.nav.IntentRouteMapper.RouteDecision import org.onebusaway.android.ui.nav.IntentRouteMapper.RouteIntent @@ -66,7 +65,7 @@ class IntentRouteMapperTest { RouteIntent( isSearch = true, searchQuery = "1st ave", - pathSegments = listOf(ObaContract.Stops.PATH, "1_75403"), + pathSegments = listOf(DeepLinkUris.STOPS_PATH, "1_75403"), ) ) assertEquals(RouteDecision.Search("1st ave"), decision) @@ -149,7 +148,7 @@ class IntentRouteMapperTest { RouteDecision.Arrivals("1_75403", "Pike St & 3rd Ave"), decide( RouteIntent( - pathSegments = listOf(ObaContract.Stops.PATH, "1_75403"), + pathSegments = listOf(DeepLinkUris.STOPS_PATH, "1_75403"), arrivalsStopName = "Pike St & 3rd Ave", ) ), @@ -160,7 +159,7 @@ class IntentRouteMapperTest { fun `a routes data-URI opens route info`() { assertEquals( RouteDecision.RouteInfo("1_100224"), - decide(RouteIntent(pathSegments = listOf(ObaContract.Routes.PATH, "1_100224"))), + decide(RouteIntent(pathSegments = listOf(DeepLinkUris.ROUTES_PATH, "1_100224"))), ) } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/MapRevealTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/MapRevealTest.kt new file mode 100644 index 0000000000..fc17f5f4bf --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/MapRevealTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.ui.nav + +import androidx.lifecycle.SavedStateHandle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Unit tests for [SavedStateHandle.consumeStopReveal] — the typed read + atomic consume for the + * `RESULT_MAP_STOP_*` keys [NavController.revealStopOnMap] writes. Verifies the complete-reveal read, the + * corrupted/partial case ([StopReveal] dropped), and that all three keys are always cleared. + */ +class MapRevealTest { + + @Test + fun `reads a complete stop reveal and clears all three keys`() { + val handle = SavedStateHandle( + mapOf( + RESULT_MAP_STOP_ID to "stop_1", + RESULT_MAP_STOP_LAT to 47.6, + RESULT_MAP_STOP_LON to -122.3, + ) + ) + + val reveal = handle.consumeStopReveal() + + assertEquals(StopReveal("stop_1", 47.6, -122.3), reveal) + assertNull(handle.get(RESULT_MAP_STOP_ID)) + assertNull(handle.get(RESULT_MAP_STOP_LAT)) + assertNull(handle.get(RESULT_MAP_STOP_LON)) + } + + @Test + fun `drops a partial reveal but still consumes the keys`() { + // The corrupted / half-restored case: a stop id without its coordinates. + val handle = SavedStateHandle(mapOf(RESULT_MAP_STOP_ID to "stop_1")) + + val reveal = handle.consumeStopReveal() + + assertNull(reveal) + assertNull(handle.get(RESULT_MAP_STOP_ID)) + } + + @Test + fun `returns null when nothing was staged`() { + assertNull(SavedStateHandle().consumeStopReveal()) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/ReminderEditorArgsTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/ReminderEditorArgsTest.kt new file mode 100644 index 0000000000..43b2364465 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/nav/ReminderEditorArgsTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.ui.nav + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** Unit tests for [ReminderEditorArgs]'s required-id invariant. */ +class ReminderEditorArgsTest { + + @Test + fun `accepts non-blank required ids`() { + val args = ReminderEditorArgs(tripId = "trip_1", stopId = "stop_1") + assertEquals("trip_1", args.tripId) + assertEquals("stop_1", args.stopId) + } + + @Test + fun `rejects a blank trip id`() { + assertThrows(IllegalArgumentException::class.java) { + ReminderEditorArgs(tripId = "", stopId = "stop_1") + } + } + + @Test + fun `rejects a blank stop id`() { + assertThrows(IllegalArgumentException::class.java) { + ReminderEditorArgs(tripId = "trip_1", stopId = " ") + } + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt index 196fa67aeb..1593d6d2a8 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt @@ -40,6 +40,10 @@ import org.onebusaway.android.testing.FakePreferencesRepository * and `refresh()`'s actual result drives [resetOtpVersionOnRegionChange]. (The precedence of the reset rule * itself is covered by [RegionOtpResetTest]; the analytics report needs a Context and is left to * instrumented coverage.) + * + * Also covers [applyMapStopCacheSize] — the Context-free half of + * [AdvancedSettingsViewModel.onMapStopCacheSizeChanged]: persist + accept a valid size, reject + persist + * nothing on an invalid one. */ @OptIn(ExperimentalCoroutinesApi::class) class AdvancedSettingsViewModelTest { @@ -94,6 +98,23 @@ class AdvancedSettingsViewModelTest { assertFalse("the toggle is persisted", prefs.getBoolean(EXPERIMENTAL, true)) } + private val MAP_STOP_CACHE_SIZE = R.string.preference_key_map_stop_cache_size + + @Test + fun `a valid map stop cache size is accepted and persisted`() { + val prefs = FakePreferencesRepository() + assertTrue("a valid in-range size is accepted", applyMapStopCacheSize("250", prefs)) + assertEquals("the size is persisted", 250, prefs.getInt(MAP_STOP_CACHE_SIZE, -1)) + } + + @Test + fun `an invalid map stop cache size is rejected and persists nothing`() { + val prefs = FakePreferencesRepository() + assertFalse("an out-of-range size is rejected", applyMapStopCacheSize("99999", prefs)) + assertFalse("a non-numeric size is rejected", applyMapStopCacheSize("abc", prefs)) + assertEquals("nothing was persisted", -1, prefs.getInt(MAP_STOP_CACHE_SIZE, -1)) + } + @Test fun `a re-resolve that needs a manual pick defers the OTP reset until the user chooses`() = runTest { val regions = listOf(region(1), region(2)) diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/RegionOtpResetTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/RegionOtpResetTest.kt index 9085ed1fe6..805e4ca12f 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/RegionOtpResetTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/RegionOtpResetTest.kt @@ -78,4 +78,25 @@ class RegionOtpResetTest { assertTrue("resets once the region becomes Active", reset) job.cancel() } + + @Test + fun `a forced manual selection that fails resolves without resetting`() = runTest { + val regions = listOf(region(1), region(2)) + val state = MutableStateFlow(RegionState.NeedsManualChoice(regions)) + var reset = false + + val job = launch { + resetOtpVersionOnRegionChange(RegionStatus.NeedsManualSelection(regions), state) { reset = true } + } + advanceUntilIdle() + assertFalse("must not reset before the pick resolves", reset) + + // A retried refresh fails instead of the user picking — the await must terminate (not hang) on + // Failed, and leave the OTP version untouched since no region became active. + state.value = RegionState.Failed + advanceUntilIdle() + assertFalse("a Failed resolution leaves the OTP version untouched", reset) + assertTrue("the await must terminate on Failed rather than suspend forever", job.isCompleted) + job.cancel() + } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt index 28d2f4b4c3..9e1423b896 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt @@ -17,6 +17,7 @@ package org.onebusaway.android.ui.settings import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -128,6 +129,7 @@ class SettingsUiStateTest { displayTestAlerts = false, customObaApiUrl = null, customOtpApiUrl = null, + mapStopCacheSize = 200, ) private fun buildAdv( @@ -183,4 +185,27 @@ class SettingsUiStateTest { assertFalse(buildAdv(region = AdvancedRegionInfo(isExperimental = false)).currentRegionIsExperimental) assertFalse(buildAdv(region = null).currentRegionIsExperimental) } + + @Test + fun `map stop cache size carries through to the ui state`() { + assertEquals(500, buildAdv(prefs = advPrefs.copy(mapStopCacheSize = 500)).mapStopCacheSize) + } + + // --- parseStopCacheSize --- + + @Test + fun `parseStopCacheSize accepts an in-range number`() { + assertEquals(200, parseStopCacheSize("200")) + assertEquals(MAP_STOP_CACHE_SIZE_MIN, parseStopCacheSize(" $MAP_STOP_CACHE_SIZE_MIN ")) + assertEquals(MAP_STOP_CACHE_SIZE_MAX, parseStopCacheSize(MAP_STOP_CACHE_SIZE_MAX.toString())) + } + + @Test + fun `parseStopCacheSize rejects non-numbers and out-of-range values`() { + assertNull(parseStopCacheSize("")) + assertNull(parseStopCacheSize("abc")) + assertNull(parseStopCacheSize("12.5")) + assertNull(parseStopCacheSize((MAP_STOP_CACHE_SIZE_MIN - 1).toString())) + assertNull(parseStopCacheSize((MAP_STOP_CACHE_SIZE_MAX + 1).toString())) + } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/util/SituationUtilsTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/util/SituationUtilsTest.kt new file mode 100644 index 0000000000..439268e53f --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/util/SituationUtilsTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.onebusaway.android.models.ObaSituation + +/** + * Unit tests for [SituationUtils.isActiveWindowForSituation]. It's a pure function of the passed + * `currentTime`, immune to device clock skew (#1612). Active-window `from`/`to` reach it already in + * epoch **millis** (the wire→domain adapter normalizes the seconds-or-millis wire value; that + * normalization is covered separately by `SituationEpochToMillisTest`), so these fixtures use millis. + */ +class SituationUtilsTest { + + // A window [from, to] and three "server currentTime" probes, all epoch millis. + private val fromMs = 1_700_000_000_000L + private val toMs = 1_700_003_600_000L // +1h + private val insideMs = 1_700_001_800_000L // +30min + private val beforeMs = 1_699_999_000_000L + private val afterMs = 1_700_004_000_000L + + @Test + fun `no active windows means always active`() { + val s = situation(windows = emptyArray()) + assertTrue(SituationUtils.isActiveWindowForSituation(s, beforeMs)) + assertTrue(SituationUtils.isActiveWindowForSituation(s, afterMs)) + } + + @Test + fun `server time inside the window is active`() { + val s = situation(windows = arrayOf(window(fromMs, toMs))) + assertTrue(SituationUtils.isActiveWindowForSituation(s, insideMs)) + } + + @Test + fun `server time before the window is inactive`() { + val s = situation(windows = arrayOf(window(fromMs, toMs))) + assertFalse(SituationUtils.isActiveWindowForSituation(s, beforeMs)) + } + + @Test + fun `server time after the window is inactive`() { + val s = situation(windows = arrayOf(window(fromMs, toMs))) + assertFalse(SituationUtils.isActiveWindowForSituation(s, afterMs)) + } + + @Test + fun `zero end time is an open-ended window`() { + // to == 0 means "no end" (see #990): active at and after the start, unbounded above, but NOT + // before the start — an open-ended window with no upper bound must still respect its start. + val s = situation(windows = arrayOf(window(fromMs, 0L))) + assertTrue(SituationUtils.isActiveWindowForSituation(s, insideMs)) + assertTrue(SituationUtils.isActiveWindowForSituation(s, afterMs)) + assertFalse(SituationUtils.isActiveWindowForSituation(s, beforeMs)) + } + + @Test + fun `future start with no end is inactive`() { + // A window that starts in the future with no end must not read as active now. + val futureFromMs = 1_800_000_000_000L + val s = situation(windows = arrayOf(window(futureFromMs, 0L))) + assertFalse(SituationUtils.isActiveWindowForSituation(s, insideMs)) + } + + // --- fixtures --- + + private fun window(from: Long, to: Long): ObaSituation.ActiveWindow = + object : ObaSituation.ActiveWindow { + override val from = from + override val to = to + } + + private fun situation(windows: Array): ObaSituation = + object : ObaSituation { + override val id = "test" + override val summary: String? = null + override val description: String? = null + override val severity: String? = null + override val advice: String? = null + override val reason: String? = null + override val url: String? = null + override val creationTime = 0L + override val allAffects: Array = emptyArray() + override val consequences: Array = emptyArray() + override val activeWindows = windows + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/widealerts/GtfsAlertsHelperTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/widealerts/GtfsAlertsHelperTest.kt new file mode 100644 index 0000000000..578cb638f1 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/widealerts/GtfsAlertsHelperTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.widealerts + +import com.google.transit.realtime.GtfsRealtime +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the pure, `nowMs`-driven [GtfsAlertsHelper.isStartDateWithin24Hours]. "Now" is the + * feed's server clock (#1612), so these pin the 24h cutoff, the boundary, and the future-start guard + * against the server time passed in. ([GtfsAlertsHelper.isValidEntity] additionally reads the + * alerts DB via a Context, so it isn't a pure JVM unit and is out of scope here.) + */ +class GtfsAlertsHelperTest { + + // A round server "now": epoch seconds 1_700_000_000 expressed in millis. + private val nowSec = 1_700_000_000L + private val nowMs = nowSec * 1000L + private val hourSec = 3_600L + + @Test + fun `start one hour ago is within 24 hours`() { + assertTrue(GtfsAlertsHelper.isStartDateWithin24Hours(alertStartingAt(nowSec - hourSec), nowMs)) + } + + @Test + fun `start 25 hours ago is not within 24 hours`() { + assertFalse(GtfsAlertsHelper.isStartDateWithin24Hours(alertStartingAt(nowSec - 25 * hourSec), nowMs)) + } + + @Test + fun `start exactly 24 hours ago is still within the window`() { + val alert = alertStartingAt(nowSec - 24 * hourSec) + assertTrue(GtfsAlertsHelper.isStartDateWithin24Hours(alert, nowMs)) + // One millisecond past the boundary is out. + assertFalse(GtfsAlertsHelper.isStartDateWithin24Hours(alert, nowMs + 1)) + } + + @Test + fun `future-dated start is not within 24 hours`() { + // Regression guard: a negative elapsed time must not slip past the upper bound. + assertFalse(GtfsAlertsHelper.isStartDateWithin24Hours(alertStartingAt(nowSec + hourSec), nowMs)) + } + + @Test + fun `alert with no active period does not crash and is not surfaced`() { + // active_period is optional in GTFS-RT; getActivePeriod(0) would throw. Must return false, not crash. + assertFalse(GtfsAlertsHelper.isStartDateWithin24Hours(GtfsRealtime.Alert.newBuilder().build(), nowMs)) + } + + private fun alertStartingAt(startSec: Long): GtfsRealtime.Alert = + GtfsRealtime.Alert.newBuilder() + .addActivePeriod(GtfsRealtime.TimeRange.newBuilder().setStart(startSec).build()) + .build() +}