diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 410e40f..996f31a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,10 @@
+
+
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchConstants.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchConstants.kt
index ab02486..8ee0798 100644
--- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchConstants.kt
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchConstants.kt
@@ -26,9 +26,9 @@ object AppLaunchConstants {
routeKey: Int,
routeId: String? = null,
pathId: Int? = null,
- stopId: Int? = null,
+ stopId: Long? = null,
destinationPathId: Int? = null,
- destinationStopId: Int? = null,
+ destinationStopId: Long? = null,
): Intent {
return Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TARGET, TARGET_ROUTE_DETAIL)
@@ -100,9 +100,12 @@ object AppLaunchConstants {
if (intent.hasExtra(EXTRA_PATH_ID)) {
payload["pathId"] = intent.getIntExtra(EXTRA_PATH_ID, 0)
}
- if (intent.hasExtra(EXTRA_STOP_ID)) {
- payload["stopId"] = intent.getIntExtra(EXTRA_STOP_ID, 0)
- }
+ // Stop ids are sent at full width, because Dart keys stops by
+ // the raw feed id: 高雄's exceed Int, and narrowing them here
+ // would name a different stop than the app's models hold. The
+ // native-only fold (`foldStopId`) is for joining the live
+ // feeds, not for identifying a stop to Flutter.
+ stopIdExtra(intent, EXTRA_STOP_ID)?.let { payload["stopId"] = it }
val destinationPathId = intent.getIntExtra(
EXTRA_DESTINATION_PATH_ID,
Int.MIN_VALUE,
@@ -110,13 +113,8 @@ object AppLaunchConstants {
if (destinationPathId != Int.MIN_VALUE) {
payload["destinationPathId"] = destinationPathId
}
- val destinationStopId = intent.getIntExtra(
- EXTRA_DESTINATION_STOP_ID,
- Int.MIN_VALUE,
- )
- if (destinationStopId != Int.MIN_VALUE) {
- payload["destinationStopId"] = destinationStopId
- }
+ stopIdExtra(intent, EXTRA_DESTINATION_STOP_ID)
+ ?.let { payload["destinationStopId"] = it }
payload
}
@@ -145,6 +143,19 @@ object AppLaunchConstants {
}
}
+ /**
+ * Reads a stop id extra as a `Long` whichever width it was stored at.
+ *
+ * New intents store a `Long`. Shortcuts pinned to the home screen before
+ * that keep the `Int` they were created with for as long as they exist, and
+ * `getLongExtra` answers its default for an extra of another type — which
+ * would quietly open those shortcuts with no stop selected.
+ */
+ @Suppress("DEPRECATION")
+ private fun stopIdExtra(intent: Intent, key: String): Long? {
+ return (intent.extras?.get(key) as? Number)?.toLong()
+ }
+
private fun extractAuthCallbackPayload(uri: android.net.Uri?): Map? {
uri ?: return null
if (uri.scheme?.lowercase() != "yabus" || uri.host?.lowercase() != "auth-callback") {
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt
index 1f16c4f..9800e20 100644
--- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt
@@ -390,7 +390,8 @@ object FavoriteGroupWidgetSupport {
)
}
- private fun createItemPendingIntent(
+ /** The tap intent for one widget row. Internal so a test can read it back. */
+ internal fun createItemPendingIntent(
context: Context,
item: FavoriteWidgetItem,
): PendingIntent {
@@ -409,9 +410,12 @@ object FavoriteGroupWidgetSupport {
routeKey = item.routeKey,
routeId = item.routeId,
pathId = item.pathId.takeIf { item.type == "boarding" },
- stopId = item.stopId.takeIf { item.type == "boarding" },
+ // The full-width id, not the folded one: Flutter compares this
+ // against the ids its own stop models carry, and a folded
+ // 高雄 id matches nothing there.
+ stopId = item.launchStopId.takeIf { item.type == "boarding" },
destinationPathId = item.destinationPathId,
- destinationStopId = item.destinationStopId,
+ destinationStopId = item.launchDestinationStopId,
).apply {
data = Uri.parse(
"yabus://route/${item.provider}/${item.routeKey}/${Uri.encode(item.routeId.orEmpty())}",
@@ -434,9 +438,14 @@ object FavoriteGroupWidgetSupport {
val raw = runCatching {
preferences.getString(FAVORITE_GROUPS_KEY, null)
?: preferences.getString(FAVORITE_GROUPS_FALLBACK_KEY, null)
- }.getOrNull() ?: return emptyMap()
+ }.getOrNull()
+ return parseFavoriteGroups(raw)
+ }
+
+ /** Reads the favourites Dart saved. Internal so a test can feed it a config. */
+ internal fun parseFavoriteGroups(raw: String?): Map> {
val root = try {
- JSONObject(raw)
+ JSONObject(raw ?: return emptyMap())
} catch (_: Exception) {
return emptyMap()
}
@@ -449,7 +458,13 @@ object FavoriteGroupWidgetSupport {
val item = groupArray.optJSONObject(index) ?: continue
val type = item.optString("type", "boarding").trim().ifEmpty { "boarding" }
val routeKey = item.optInt("routeKey", 0)
- val stopId = item.optInt("stopId", 0)
+ // The config is written by Dart with the id as Dart holds it,
+ // which exceeds Int for 高雄. It is needed twice, as two
+ // different numbers: folded the way the live feed is, for the
+ // ETA lookup; and at full width, for the tap that launches
+ // back into Flutter.
+ val dartStopId = item.opt("stopId")
+ val stopId = foldStopId(dartStopId)
val routeId = item.optString("routeId", "").trim().takeIf { it.isNotEmpty() }
val stationId = item.optString("stationId", "").trim()
val valid = when (type) {
@@ -461,19 +476,15 @@ object FavoriteGroupWidgetSupport {
if (!valid) {
continue
}
- val destinationStopId = item.optInt("destinationStopId", 0)
- .takeIf { it > 0 }
- val destinationPathId = if (destinationStopId == null) {
- null
- } else {
- item.optInt("destinationPathId", item.optInt("pathId", 0))
- }
+ val dartDestinationStopId = item.opt("destinationStopId")
+ val hasDestination = foldStopId(dartDestinationStopId) > 0
groupItems += FavoriteWidgetItem(
type = type,
provider = item.optString("provider", "twn"),
routeKey = routeKey,
pathId = item.optInt("pathId", 0),
stopId = stopId,
+ launchStopId = launchStopId(dartStopId).takeIf { stopId > 0 },
routeId = routeId,
routeName = item.optString("routeName", ""),
routeDescription = item.optString("routeDescription", "")
@@ -482,8 +493,13 @@ object FavoriteGroupWidgetSupport {
stopName = item.optString("stopName", ""),
stationId = stationId,
stationName = item.optString("stationName", ""),
- destinationPathId = destinationPathId,
- destinationStopId = destinationStopId,
+ destinationPathId = if (hasDestination) {
+ item.optInt("destinationPathId", item.optInt("pathId", 0))
+ } else {
+ null
+ },
+ launchDestinationStopId = launchStopId(dartDestinationStopId)
+ .takeIf { hasDestination },
destinationStopName = item.optString("destinationStopName", "")
.trim()
.takeIf { it.isNotEmpty() },
@@ -690,21 +706,7 @@ object FavoriteGroupWidgetSupport {
return result
}
- private fun parseStopId(raw: Any?): Int {
- if (raw is Number) {
- return raw.toInt()
- }
- val text = raw?.toString()?.trim().orEmpty()
- val parsed = text.toIntOrNull()
- if (parsed != null) {
- return parsed
- }
- var hash = 17
- for (char in text) {
- hash = (hash * 31 + char.code) and 0x7fffffff
- }
- return hash
- }
+ private fun parseStopId(raw: Any?): Int = foldStopId(raw)
private fun toIntOrNull(raw: Any?): Int? {
return when (raw) {
@@ -807,7 +809,10 @@ data class FavoriteWidgetItem(
val provider: String,
val routeKey: Int,
val pathId: Int,
+ /** Folded, as the live feeds are: only usable for looking up ETAs. */
val stopId: Int,
+ /** Full-width, as Flutter holds it: what a tap is launched with. */
+ val launchStopId: Long?,
val routeId: String?,
val routeName: String,
val routeDescription: String?,
@@ -815,7 +820,7 @@ data class FavoriteWidgetItem(
val stationId: String,
val stationName: String,
val destinationPathId: Int?,
- val destinationStopId: Int?,
+ val launchDestinationStopId: Long?,
val destinationStopName: String?,
) {
val identity: String
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/MainActivity.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/MainActivity.kt
index 11a161c..b91cb62 100644
--- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/MainActivity.kt
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/MainActivity.kt
@@ -306,7 +306,10 @@ class MainActivity : FlutterActivity() {
val routeKey = call.argument("routeKey")
val routeId = call.argument("routeId")?.trim().orEmpty()
val pathId = call.argument("pathId")
- val stopId = call.argument("stopId")
+ // Stop ids can exceed Int (高雄's are 64-bit), and the channel decodes a
+ // Dart int to Integer or Long depending on its magnitude, so both are
+ // accepted rather than letting the larger ones read back as null.
+ val stopId = call.argument("stopId")?.toLong()
val routeName = call.argument("routeName")?.trim().orEmpty()
val stopName = call.argument("stopName")?.trim().orEmpty()
val stationId = call.argument("stationId")?.trim().orEmpty()
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTime.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTime.kt
new file mode 100644
index 0000000..342e52a
--- /dev/null
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTime.kt
@@ -0,0 +1,33 @@
+package tw.avianjay.taiwanbus.flutter
+
+import android.location.Location
+import android.os.SystemClock
+
+/**
+ * When [location] was measured, on the wall clock the ride detector runs on.
+ *
+ * A fix's age is read off the monotonic clock, the only one a fix and "now" are
+ * guaranteed to share: `Location.time` may come from the satellites, the
+ * network or the system clock, and none of those has to agree with
+ * `System.currentTimeMillis()`. Taking that age off the current wall-clock time
+ * puts a fix cached five minutes ago five minutes in the past — where the
+ * detector can see it is stale — however the device's clock happens to be set.
+ *
+ * A location with no monotonic stamp did not come from the platform (the
+ * session's seed position is built by hand). Its own `time` is used when it has
+ * a believable one, and it counts as current otherwise.
+ */
+internal fun rideFixTimestampMs(
+ location: Location,
+ nowWallMs: Long = System.currentTimeMillis(),
+ nowElapsedNanos: Long = SystemClock.elapsedRealtimeNanos(),
+): Long {
+ val fixElapsedNanos = location.elapsedRealtimeNanos
+ if (fixElapsedNanos > 0L) {
+ val ageMs = ((nowElapsedNanos - fixElapsedNanos) / NANOS_PER_MILLI).coerceAtLeast(0L)
+ return nowWallMs - ageMs
+ }
+ return location.time.takeIf { it in 1L..nowWallMs } ?: nowWallMs
+}
+
+private const val NANOS_PER_MILLI = 1_000_000L
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideRouteLine.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideRouteLine.kt
new file mode 100644
index 0000000..06ad82b
--- /dev/null
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideRouteLine.kt
@@ -0,0 +1,127 @@
+package tw.avianjay.taiwanbus.flutter
+
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.cos
+import kotlin.math.hypot
+
+internal data class RideProjection(
+ val alongMeters: Double,
+ val crossTrackMeters: Double,
+ val segmentIndex: Int,
+)
+
+/**
+ * The stop sequence as a polyline in a local equirectangular plane, with
+ * cumulative distance, so that the user and every bus reduce to one comparable
+ * number: metres travelled from the first stop.
+ */
+internal class RideRouteLine(private val stops: List) {
+ private val originLat = stops.first().lat
+ private val originLon = stops.first().lon
+ private val metersPerDegreeLon = EARTH_RADIUS_METERS * cos(originLat * PI / 180.0) * PI / 180.0
+ private val metersPerDegreeLat = EARTH_RADIUS_METERS * PI / 180.0
+ private val xs = DoubleArray(stops.size) { (stops[it].lon - originLon) * metersPerDegreeLon }
+ private val ys = DoubleArray(stops.size) { (stops[it].lat - originLat) * metersPerDegreeLat }
+ private val cumulativeMeters = DoubleArray(stops.size).also { cumulative ->
+ for (index in 1 until stops.size) {
+ cumulative[index] = cumulative[index - 1] +
+ hypot(xs[index] - xs[index - 1], ys[index] - ys[index - 1])
+ }
+ }
+
+ /** Metres from the first stop to [stopIndex]. */
+ fun alongOfStop(stopIndex: Int): Double = cumulativeMeters[stopIndex.coerceIn(0, stops.size - 1)]
+
+ fun segmentLengthMeters(segmentIndex: Int): Double {
+ val index = segmentIndex.coerceIn(0, (stops.size - 2).coerceAtLeast(0))
+ return if (stops.size < 2) 0.0 else cumulativeMeters[index + 1] - cumulativeMeters[index]
+ }
+
+ fun metersBetween(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ return hypot((lon2 - lon1) * metersPerDegreeLon, (lat2 - lat1) * metersPerDegreeLat)
+ }
+
+ fun metersToStop(latitude: Double, longitude: Double, stopIndex: Int): Double {
+ val stop = stops[stopIndex.coerceIn(0, stops.size - 1)]
+ return metersBetween(latitude, longitude, stop.lat, stop.lon)
+ }
+
+ /**
+ * Projects a fix onto the polyline.
+ *
+ * [hintMeters] disambiguates routes that run along the same street twice
+ * (loops, out-and-back sections): among the segments about as close as the
+ * closest one, the one nearest the previous along-route position wins.
+ */
+ fun project(latitude: Double, longitude: Double, hintMeters: Double?): RideProjection {
+ val segmentCount = stops.size - 1
+ if (segmentCount <= 0) {
+ return RideProjection(0.0, metersToStop(latitude, longitude, 0), 0)
+ }
+ val px = (longitude - originLon) * metersPerDegreeLon
+ val py = (latitude - originLat) * metersPerDegreeLat
+ val distances = DoubleArray(segmentCount)
+ val alongs = DoubleArray(segmentCount)
+ var closest = 0
+ for (index in 0 until segmentCount) {
+ val vx = xs[index + 1] - xs[index]
+ val vy = ys[index + 1] - ys[index]
+ val lengthSquared = vx * vx + vy * vy
+ val t = if (lengthSquared <= 1e-9) {
+ 0.0
+ } else {
+ (((px - xs[index]) * vx + (py - ys[index]) * vy) / lengthSquared).coerceIn(0.0, 1.0)
+ }
+ distances[index] = hypot(px - (xs[index] + t * vx), py - (ys[index] + t * vy))
+ alongs[index] = cumulativeMeters[index] +
+ t * (cumulativeMeters[index + 1] - cumulativeMeters[index])
+ if (distances[index] < distances[closest]) {
+ closest = index
+ }
+ }
+ var chosen = closest
+ if (hintMeters != null) {
+ for (index in 0 until segmentCount) {
+ if (distances[index] <= distances[closest] + HINT_SLACK_METERS &&
+ abs(alongs[index] - hintMeters) < abs(alongs[chosen] - hintMeters)
+ ) {
+ chosen = index
+ }
+ }
+ }
+ return RideProjection(alongs[chosen], distances[closest], chosen)
+ }
+
+ fun nearestStopIndex(alongMeters: Double): Int {
+ var best = 0
+ for (index in cumulativeMeters.indices) {
+ if (abs(cumulativeMeters[index] - alongMeters) < abs(cumulativeMeters[best] - alongMeters)) {
+ best = index
+ }
+ }
+ return best
+ }
+
+ /**
+ * The last stop reached at [alongMeters]. A vehicle pulling in counts as
+ * having reached the stop, hence the tolerance. Unlike the nearest stop,
+ * this only moves forward as the vehicle does, so "stops to go" counts down
+ * at the stops themselves rather than midway between them.
+ */
+ fun stopIndexAt(alongMeters: Double): Int {
+ var reached = 0
+ for (index in cumulativeMeters.indices) {
+ if (cumulativeMeters[index] <= alongMeters + STOP_REACHED_TOLERANCE_METERS) {
+ reached = index
+ }
+ }
+ return reached
+ }
+
+ private companion object {
+ const val EARTH_RADIUS_METERS = 6_371_000.0
+ const val HINT_SLACK_METERS = 60.0
+ const val STOP_REACHED_TOLERANCE_METERS = 25.0
+ }
+}
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt
index 2cad1d1..a509e92 100644
--- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt
@@ -15,6 +15,7 @@ import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
+import android.os.PowerManager
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -27,6 +28,7 @@ import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import java.net.HttpURLConnection
+import java.net.URLEncoder
import java.net.URL
import java.util.concurrent.Executors
import kotlin.math.abs
@@ -46,29 +48,25 @@ class RouteTripMonitorService : Service() {
@Volatile
private var session: TrackingSession? = null
+ @Volatile
private var appInForeground = true
private var foregroundStarted = false
+ @Volatile
private var latestLocation: Location? = null
- private var lastMovementLocation: Location? = null
- private var lastMovementNearestIndex: Int? = null
- private var lastMovementRecordedAtMs = 0L
- private var lastTransitLikeAtMs = 0L
+
+ /**
+ * Owns the "is the user on the bus" question. It is fed the user's own fixes
+ * and the live vehicle positions, and never looks at whether the app is in
+ * the foreground — a trip does not stop being a trip when the user opens
+ * the app.
+ */
+ private var detector: TripRideDetector? = null
+ private var latestRide = RideSnapshot()
+ private val detectorLock = Any()
+ /** Under detectorLock: newer app-provided stop data supersedes pending stop requests. */
+ private var stopFeedRevision = 0L
private var boardingAlertSent = false
- private var boardingCheckPromptSent = false
- private var boardingCheckPromptSentAtMs = 0L
- private var boardingWindowOpen = false
- private var boardingWindowOpenedAtMs = 0L
- private var boardingCheckSnoozeUntilMs = 0L
- private var rideConfirmed = false
- private var rideConfirmationSamples = 0
- private var lastNearestStopIndex: Int? = null
- private var lastBusStopIndex: Int? = null
- private var activeBoardingVehicleId: String? = null
- private var activeBoardingVehicleStopIndex: Int? = null
- private var activeBoardingVehicleLastSeenAtMs = 0L
- private var activeBoardingVehiclePassedAtMs = 0L
- private var trackedBusId: String? = null
- private var trackedBusLastSeenAtMs = 0L
+ private var boardingCheckNotificationId = 0L
private var destinationSetupPromptSent = false
private var arrivalDetectedAtMs = 0L
private var destinationAlertStage = 0
@@ -81,26 +79,46 @@ class RouteTripMonitorService : Service() {
private var cachedLivePathId: Int? = null
private var cachedLiveFetchedAtMs = 0L
private var cachedLiveStops: Map = emptyMap()
+ /** Under refreshLock: every family route shares the same polling policy. */
+ private val busFeedRefreshByRoute = mutableMapOf()
+ private var liveStopBackoffUntilMs = 0L
+ private var wakeLock: PowerManager.WakeLock? = null
private val pollingRunnable = object : Runnable {
override fun run() {
if (!foregroundStarted) {
return
}
- if (!appInForeground) {
- refreshNotification()
- }
+ // Refresh in the foreground too: the notification, the ETA text and
+ // the boarding question all depend on this loop, and skipping it
+ // while the app is open was why boarding went unnoticed.
+ refreshNotification()
mainHandler.postDelayed(this, POLL_INTERVAL_MS)
}
}
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
- latestLocation = locationResult.lastLocation
- if (!appInForeground) {
- refreshNotification()
- }
- }
+ val location = locationResult.lastLocation ?: return
+ latestLocation = location
+ onUserLocation(location)
+ refreshNotification()
+ }
+ }
+
+ private fun onUserLocation(location: Location): Unit = synchronized(detectorLock) {
+ val currentDetector = detector ?: return
+ currentDetector.onUserLocation(
+ RideGeoPoint(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ // The fix's real age, so that a cached one from minutes ago is
+ // recognised as stale instead of passing for the present.
+ timestampMs = rideFixTimestampMs(location),
+ accuracyMeters = if (location.hasAccuracy()) location.accuracy else 0f,
+ speedMetersPerSecond = location.speed.takeIf { location.hasSpeed() && it > 0f },
+ ),
+ )
}
override fun onCreate() {
@@ -157,11 +175,7 @@ class RouteTripMonitorService : Service() {
return START_NOT_STICKY
}
refreshNotification(force = true)
- if (appInForeground) {
- stopPolling()
- } else {
- startPolling()
- }
+ startPolling()
return START_STICKY
}
@@ -180,17 +194,13 @@ class RouteTripMonitorService : Service() {
stopTracking()
return START_NOT_STICKY
}
- rideConfirmed = true
- rideConfirmationSamples = REQUIRED_RIDE_CONFIRMATION_SAMPLES
- boardingWindowOpen = true
- boardingCheckPromptSent = true
- boardingCheckPromptSentAtMs = System.currentTimeMillis()
- normalizeVehicleId(activeBoardingVehicleId)?.let { vehicleId ->
- trackedBusId = vehicleId
- trackedBusLastSeenAtMs = System.currentTimeMillis()
+ val now = System.currentTimeMillis()
+ synchronized(detectorLock) {
+ detector?.confirmBoardedByUser(now, answeredBoardingCheckId(intent))
+ latestRide = detector?.snapshot(now) ?: RideSnapshot()
}
- activeBoardingVehiclePassedAtMs = 0L
- notificationManager.cancel(ALERT_NOTIFICATION_ID)
+ boardingAlertSent = true
+ cancelBoardingCheckPrompt()
refreshNotification(force = true)
return START_STICKY
}
@@ -200,26 +210,19 @@ class RouteTripMonitorService : Service() {
stopTracking()
return START_NOT_STICKY
}
+ val now = System.currentTimeMillis()
+ synchronized(detectorLock) {
+ // Only the bus that was asked about is set aside; the next
+ // one still gets detected and asked about in its turn.
+ detector?.declineBoarding(now, answeredBoardingCheckId(intent))
+ latestRide = detector?.snapshot(now) ?: RideSnapshot()
+ }
boardingAlertSent = false
- rideConfirmed = false
- rideConfirmationSamples = 0
- boardingCheckPromptSent = false
- boardingCheckPromptSentAtMs = 0L
- boardingWindowOpen = false
- boardingWindowOpenedAtMs = 0L
- trackedBusId = null
- trackedBusLastSeenAtMs = 0L
- activeBoardingVehicleId = null
- activeBoardingVehicleStopIndex = null
- activeBoardingVehicleLastSeenAtMs = 0L
- activeBoardingVehiclePassedAtMs = 0L
- lastMovementLocation = null
- lastMovementNearestIndex = null
- lastMovementRecordedAtMs = 0L
- lastTransitLikeAtMs = 0L
- boardingCheckSnoozeUntilMs =
- System.currentTimeMillis() + BOARDING_CHECK_SNOOZE_MS
- notificationManager.cancel(ALERT_NOTIFICATION_ID)
+ destinationSetupPromptSent = false
+ arrivalDetectedAtMs = 0L
+ destinationAlertStage = 0
+ overshootAlertSent = false
+ cancelBoardingCheckPrompt()
refreshNotification(force = true)
return START_STICKY
}
@@ -235,63 +238,61 @@ class RouteTripMonitorService : Service() {
val previousDestination = session?.destinationStopId
val previousBoarding = session?.boardingStopId
val previousRouteId = session?.routeId
+ val previousRouteKey = session?.routeKey
+ val previousPathId = session?.pathId
+ val previousStopIds = session?.stops?.map { it.stopId }
val previousFamily = session?.realtimeRouteIds?.toSet()
+ val hadSession = session != null
+ val hadDetector = session != null && detectorExists()
if (AppRuntimeStateStore.isTripMonitorPausedFor(this, parsedSession)) {
- session = parsedSession
appInForeground = parsedSession.appInForeground
stopTracking()
return START_NOT_STICKY
}
- session = parsedSession
+ val startedAtMs = System.currentTimeMillis()
+ val continuesBoardedRide = !hadDetector && continuesBoardedRide(
+ paused = AppRuntimeStateStore.loadPausedTripMonitor(this),
+ session = parsedSession,
+ nowMs = startedAtMs,
+ )
appInForeground = parsedSession.appInForeground
val providedStops = parsedSession.stops.mapNotNull { stop ->
stop.liveData?.let { stop.stopId to it }
}.toMap()
- if (providedStops.isNotEmpty()) {
- synchronized(refreshLock) {
- cachedLiveRouteIds = parsedSession.realtimeRouteIds
- cachedLivePathId = parsedSession.pathId
- cachedLiveFetchedAtMs = SystemClock.elapsedRealtime()
- cachedLiveStops = providedStops
- }
- }
if (!appInForeground && lastWentBackgroundAtMs == 0L) {
lastWentBackgroundAtMs = System.currentTimeMillis()
}
AppRuntimeStateStore.setAppInForeground(this, appInForeground)
val tripOriginChanged =
previousBoarding != parsedSession.boardingStopId ||
- previousRouteId != parsedSession.routeId
+ previousRouteId != parsedSession.routeId ||
+ // Some providers leave routeId blank, so the route key
+ // is what actually tells two routes apart.
+ previousRouteKey != parsedSession.routeKey ||
+ // The other direction of the same route can share a
+ // boarding stop, yet it is another line of stops: the
+ // detector measures everything along the one it was
+ // built with.
+ previousPathId != parsedSession.pathId ||
+ previousStopIds != parsedSession.stops.map { it.stopId }
val destinationChanged =
previousDestination != parsedSession.destinationStopId
val familyChanged = previousFamily != parsedSession.realtimeRouteIds.toSet()
// A preliminary primary-route response must not keep a waiting
- // reminder locked to that bus once the family arrives.
- if (tripOriginChanged || (familyChanged && !rideConfirmed)) {
+ // reminder locked to that bus once the family arrives, and the
+ // detector must exist before anything can consult it. A ride the
+ // user already confirmed is never rebuilt out from under them.
+ val rideAlreadyConfirmed = currentRideSnapshot().hasBoarded
+ val startsNewTrip = tripOriginChanged ||
+ (familyChanged && !rideAlreadyConfirmed) ||
+ !hadDetector
+ if (startsNewTrip) {
boardingAlertSent = false
- boardingCheckPromptSent = false
- boardingCheckPromptSentAtMs = 0L
- boardingWindowOpen = false
- boardingWindowOpenedAtMs = 0L
- boardingCheckSnoozeUntilMs = 0L
- rideConfirmed = false
- rideConfirmationSamples = 0
- lastNearestStopIndex = null
- lastBusStopIndex = null
- activeBoardingVehicleId = null
- activeBoardingVehicleStopIndex = null
- activeBoardingVehicleLastSeenAtMs = 0L
- activeBoardingVehiclePassedAtMs = 0L
- lastMovementLocation = null
- lastMovementNearestIndex = null
- lastMovementRecordedAtMs = 0L
- lastTransitLikeAtMs = 0L
- trackedBusId = null
- trackedBusLastSeenAtMs = 0L
destinationSetupPromptSent = false
arrivalDetectedAtMs = 0L
destinationAlertStage = 0
overshootAlertSent = false
+ cancelBoardingCheckPrompt()
} else if (destinationChanged) {
// Choosing a destination after boarding must not unlock the
// current vehicle and fall back to the next bus at the
@@ -301,32 +302,203 @@ class RouteTripMonitorService : Service() {
destinationAlertStage = 0
overshootAlertSent = false
}
- latestLocation = createSessionLocation(parsedSession) ?: latestLocation
+ // The session and the detector built for it change hands in one
+ // step: a refresh still fetching for the previous trip looks
+ // for exactly this before it applies what it brought back.
+ synchronized(detectorLock) {
+ session = parsedSession
+ val tracking = if (startsNewTrip) {
+ createDetector(parsedSession).also { created ->
+ if (continuesBoardedRide) {
+ // Choosing where to get off, in answer to "you
+ // have boarded", is the rider confirming it —
+ // but of a ride that began some stops back.
+ created.resumeConfirmedRide()
+ }
+ detector = created
+ latestRide = RideSnapshot()
+ // It starts knowing nothing, so both feeds are due
+ // at once, and the last trip's failures are not its
+ // own to sit out.
+ forgetLiveFeeds()
+ }
+ } else {
+ if (familyChanged) {
+ synchronized(refreshLock) {
+ busFeedRefreshByRoute.keys.retainAll(parsedSession.realtimeRouteIds.toSet())
+ }
+ }
+ detector?.also { it.updateDestination(parsedSession.destinationStopId) }
+ }
+ if (providedStops.isNotEmpty()) {
+ stopFeedRevision++
+ synchronized(refreshLock) {
+ cachedLiveRouteIds = parsedSession.realtimeRouteIds
+ cachedLivePathId = parsedSession.pathId
+ cachedLiveFetchedAtMs = SystemClock.elapsedRealtime()
+ cachedLiveStops = providedStops
+ }
+ // While the app is open it does the fetching, and this is
+ // all the detector hears of the boarding stop's ETA.
+ tracking?.let { feedBoardingEta(it, parsedSession, providedStops) }
+ }
+ }
+ if (startsNewTrip) {
+ if (continuesBoardedRide) {
+ boardingAlertSent = true
+ AppRuntimeStateStore.clearPausedTripMonitor(this)
+ }
+ refreshRideSnapshot(System.currentTimeMillis())
+ // Only the very first update seeds the detector from the
+ // session's stale position; a later re-seed would replay an
+ // old fix as if it were current.
+ if (!hadSession) {
+ latestLocation = createSessionLocation(parsedSession) ?: latestLocation
+ latestLocation?.let(::onUserLocation)
+ }
+ }
createAlertNotificationChannel()
requestLocationUpdates()
+ acquireWakeLock()
refreshNotification(force = true)
- if (appInForeground) {
- stopPolling()
- } else {
- startPolling()
- }
+ startPolling()
}
}
return START_STICKY
}
+ private fun detectorExists(): Boolean = synchronized(detectorLock) { detector != null }
+
+ /** A trip as one refresh sees it: the session, and the detector built for it. */
+ private class TrackedTrip(
+ val session: TrackingSession,
+ val detector: TripRideDetector,
+ val stopFeedRevision: Long,
+ )
+
+ private data class BusFeedRefresh(
+ val fetchedAtElapsedMs: Long? = null,
+ val retryAtElapsedMs: Long = 0L,
+ )
+
+ private fun currentTrip(): TrackedTrip? = synchronized(detectorLock) {
+ val currentSession = session ?: return null
+ val currentDetector = detector ?: return null
+ TrackedTrip(currentSession, currentDetector, stopFeedRevision)
+ }
+
+ /**
+ * Applies what a request brought back for [trip], unless the trip has been
+ * replaced while the request was out; returns whether it was applied.
+ *
+ * The rider can open another route, or the other direction, while a request
+ * is in flight. The check and [apply] share the lock the detector is
+ * replaced under, so what was fetched for one trip never lands in the next.
+ */
+ private fun applyToTrip(trip: TrackedTrip, apply: () -> Unit): Boolean =
+ synchronized(detectorLock) {
+ if (detector !== trip.detector) {
+ return false
+ }
+ apply()
+ true
+ }
+
+ /**
+ * Runs [action] on the main thread unless by then [trackedSession] has been
+ * replaced, or the ride has moved on from [ride] — the rider has answered
+ * the boarding question, say. Sessions are only ever replaced on the main
+ * thread and answers arrive there, so once the check has passed neither can
+ * happen before [action] has run.
+ */
+ private fun postIfStillTracking(
+ trackedSession: TrackingSession,
+ ride: RideSnapshot? = null,
+ action: () -> Unit,
+ ) {
+ mainHandler.post {
+ if (session === trackedSession && (ride == null || currentRideSnapshot() === ride)) {
+ action()
+ }
+ }
+ }
+
+ /**
+ * Whether [session] is the ride that was paused for want of a destination,
+ * now given one.
+ *
+ * On board with nowhere to get off there is nothing to remind the rider of,
+ * so the monitor asks them to choose a stop and pauses. Their answer starts
+ * it again — on the same bus. Coming back as "waiting to board" would throw
+ * the ride away at the very moment the reminders it was chosen for become
+ * possible, and mid-route there may be nothing left to detect it from.
+ */
+ private fun continuesBoardedRide(
+ paused: TripMonitorPauseState?,
+ session: TrackingSession,
+ nowMs: Long,
+ ): Boolean {
+ paused ?: return false
+ return paused.reason == PAUSE_REASON_BOARDED_NO_DESTINATION &&
+ paused.destinationStopId == null &&
+ session.destinationStopId != null &&
+ paused.provider == session.provider &&
+ paused.routeKey == session.routeKey &&
+ paused.pathId == session.pathId &&
+ paused.boardingStopId == session.boardingStopId &&
+ nowMs - paused.pausedAtMs in 0L..BOARDED_RIDE_RESUME_WINDOW_MS
+ }
+
override fun onDestroy() {
+ // A refresh still in flight has no trip left to apply itself to.
+ synchronized(detectorLock) {
+ session = null
+ detector = null
+ }
mainHandler.removeCallbacksAndMessages(null)
runCatching {
if (::fusedLocationClient.isInitialized) {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
}
+ releaseWakeLock()
ioExecutor.shutdownNow()
super.onDestroy()
}
+ /**
+ * A partial wake lock for as long as a trip is being tracked.
+ *
+ * With the screen off and the phone still, the CPU suspends between wakeups
+ * and the 15-second refresh loop stalls — the app cannot then tell "still
+ * waiting at the stop" from "already on the bus". Holding this keeps the
+ * loop and the detector's timers running for the length of the trip.
+ *
+ * This covers the idle/screen-off case. Deep Doze ignores wake locks
+ * outright, so a phone that has been motionless for half an hour is still
+ * at the mercy of the platform and the OEM; the lock is a mitigation, not
+ * a guarantee.
+ */
+ private fun acquireWakeLock() {
+ val manager = getSystemService(PowerManager::class.java) ?: return
+ if (wakeLock?.isHeld == true) {
+ return
+ }
+ val lock = runCatching {
+ manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
+ }.getOrNull() ?: return
+ lock.setReferenceCounted(false)
+ runCatching { lock.acquire(WAKE_LOCK_TIMEOUT_MS) }
+ wakeLock = lock
+ }
+
+ private fun releaseWakeLock() {
+ val lock = wakeLock ?: return
+ wakeLock = null
+ runCatching { if (lock.isHeld) lock.release() }
+ }
+
private fun buildStartupSnapshot(session: TrackingSession): TrackingSnapshot {
val startupLocation = latestLocation ?: createSessionLocation(session)
val nearestIndex = startupLocation?.let { location ->
@@ -367,11 +539,24 @@ class RouteTripMonitorService : Service() {
Location("trip_monitor_session").apply {
this.latitude = latitude
this.longitude = longitude
+ // The route screen's last position can be minutes old by
+ // the time the trip starts; carrying when it was taken lets
+ // the detector judge that for itself.
+ session.initialLocationTimestampMs?.let { time = it }
}
}
}
}
+ private fun createDetector(session: TrackingSession): TripRideDetector {
+ return TripRideDetector(
+ stops = session.stops,
+ pathId = session.pathId,
+ boardingStopId = session.boardingStopId,
+ destinationStopId = session.destinationStopId,
+ )
+ }
+
private fun ensureForegroundStarted(): Boolean {
if (foregroundStarted) {
return true
@@ -417,7 +602,13 @@ class RouteTripMonitorService : Service() {
}
private fun requestLocationUpdates() {
- if (session?.backgroundLocationAlwaysGranted != true) {
+ // Detection needs the user's own track, so updates are requested
+ // whenever any location permission is held. Requiring the "allow all
+ // the time" grant here is what used to leave the whole boarding state
+ // machine inert: a location-type foreground service started while the
+ // app was visible keeps while-in-use access after it goes to the
+ // background, so the stricter grant is not needed to keep tracking.
+ if (!hasLocationPermission()) {
return
}
if (!::fusedLocationClient.isInitialized) {
@@ -426,8 +617,12 @@ class RouteTripMonitorService : Service() {
val request = LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY,
LOCATION_UPDATE_INTERVAL_MS,
- ).setMinUpdateDistanceMeters(20f)
- .setMinUpdateIntervalMillis(LOCATION_MIN_UPDATE_INTERVAL_MS)
+ ).setMinUpdateIntervalMillis(LOCATION_MIN_UPDATE_INTERVAL_MS)
+ // No distance filter: a phone waiting at the stop must keep
+ // reporting, both so the app can tell the user is still there and
+ // so a fix is ready the moment they board.
+ .setMinUpdateDistanceMeters(0f)
+ .setWaitForAccurateLocation(false)
.build()
runCatching {
@@ -441,14 +636,24 @@ class RouteTripMonitorService : Service() {
fusedLocationClient.lastLocation.addOnSuccessListener { location ->
if (location != null) {
latestLocation = location
- if (!appInForeground) {
- refreshNotification()
- }
+ onUserLocation(location)
+ refreshNotification()
}
}
}
}
+ private fun hasLocationPermission(): Boolean {
+ return ContextCompat.checkSelfPermission(
+ this,
+ android.Manifest.permission.ACCESS_FINE_LOCATION,
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED ||
+ ContextCompat.checkSelfPermission(
+ this,
+ android.Manifest.permission.ACCESS_COARSE_LOCATION,
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ }
+
private fun startPolling() {
mainHandler.removeCallbacks(pollingRunnable)
if (!foregroundStarted) {
@@ -462,7 +667,9 @@ class RouteTripMonitorService : Service() {
}
private fun refreshNotification(force: Boolean = false) {
- val currentSession = session ?: return
+ if (session == null) {
+ return
+ }
synchronized(refreshLock) {
val now = SystemClock.elapsedRealtime()
if (refreshInFlight) {
@@ -482,25 +689,7 @@ class RouteTripMonitorService : Service() {
val submitted = runCatching {
ioExecutor.execute {
try {
- runCatching {
- if (session !== currentSession) return@runCatching
- if (force) {
- // Rich notifications (including OEM integrations) are
- // built off the main thread, after foreground promotion.
- notificationManager.notify(
- TRACKING_NOTIFICATION_ID,
- buildTrackingNotification(buildStartupSnapshot(currentSession)),
- )
- }
- val trackingSnapshot = buildSnapshot(
- currentSession,
- latestLocation ?: createSessionLocation(currentSession),
- )
- if (session !== currentSession) return@runCatching
- val notification = buildTrackingNotification(trackingSnapshot)
- notificationManager.notify(TRACKING_NOTIFICATION_ID, notification)
- maybeSendTripAlerts(currentSession, trackingSnapshot)
- }
+ runCatching { refreshTrip(force) }
} finally {
var shouldRefreshAgain = false
synchronized(refreshLock) {
@@ -530,306 +719,407 @@ class RouteTripMonitorService : Service() {
}
}
- private fun buildSnapshot(
+ /**
+ * One refresh, on the IO thread: fetch, let the detector look again, and
+ * hand what is to be shown and alerted to the main thread.
+ *
+ * The trip is read here rather than where the refresh was asked for, and is
+ * checked again after every wait, because the main thread can replace it at
+ * any moment.
+ */
+ private fun refreshTrip(force: Boolean) {
+ val trip = currentTrip() ?: return
+ val trackedSession = trip.session
+ if (force) {
+ // Rich notifications (including OEM integrations) are built off the
+ // main thread, after foreground promotion.
+ val startup = buildTrackingNotification(buildStartupSnapshot(trackedSession))
+ postIfStillTracking(trackedSession) {
+ notificationManager.notify(TRACKING_NOTIFICATION_ID, startup)
+ }
+ }
+ // Fetched before the snapshot: everything the detector learns arrives
+ // through here.
+ if (!updateLiveData(trip)) {
+ return
+ }
+ // Advanced on every refresh, not just when the bus feed came back,
+ // because the question timing and the user's own progress move
+ // independently of it.
+ val ride = advanceRide(trip, System.currentTimeMillis()) ?: return
+ val trackingSnapshot = buildSnapshot(
+ trackedSession,
+ latestLocation ?: createSessionLocation(trackedSession),
+ ride,
+ )
+ val notification = buildTrackingNotification(trackingSnapshot)
+ // Alerts can pause or stop the trip, so they are decided where trips
+ // are replaced and the rider's answers arrive: one worked out for the
+ // last trip, or from before "I am not on it", must not be acted on.
+ postIfStillTracking(trackedSession, ride) {
+ notificationManager.notify(TRACKING_NOTIFICATION_ID, notification)
+ maybeSendTripAlerts(trackedSession, trackingSnapshot)
+ }
+ }
+
+ /**
+ * Pulls whatever the detector needs from the backend: the boarding stop's
+ * ETA, and the live positions of every vehicle on the route.
+ *
+ * Both feeds share one rate-limit bucket with the rest of the app, so they
+ * are throttled by age rather than fetched on every refresh, and a failed
+ * request backs that endpoint off instead of hammering the server. Every
+ * family route uses that same interval/back-off policy independently, so a
+ * failed stop feed or variant does not silence healthy vehicle endpoints.
+ *
+ * Returns false when [trip] was replaced while a request was out; nothing
+ * fetched after that point has been applied, and the caller should stop.
+ */
+ private fun updateLiveData(trip: TrackedTrip): Boolean {
+ val session = trip.session
+ val routeIds = session.realtimeRouteIds
+ if (routeIds.isEmpty()) {
+ return true
+ }
+ val nowElapsed = SystemClock.elapsedRealtime()
+ val (wantsStops, busRouteIdsDue) = synchronized(refreshLock) {
+ val stopsDue = nowElapsed >= liveStopBackoffUntilMs && (
+ cachedLiveRouteIds != routeIds ||
+ cachedLivePathId != session.pathId ||
+ nowElapsed - cachedLiveFetchedAtMs >= LIVE_STOP_REFRESH_INTERVAL_MS
+ )
+ val busesDue = routeIds.filter { routeId ->
+ val state = busFeedRefreshByRoute[routeId]
+ state == null || (nowElapsed >= state.retryAtElapsedMs &&
+ (state.fetchedAtElapsedMs == null ||
+ nowElapsed - state.fetchedAtElapsedMs >= LIVE_BUS_REFRESH_INTERVAL_MS))
+ }
+ stopsDue to busesDue
+ }
+
+ if (wantsStops) {
+ val liveStops = fetchLiveStopMapFromBackend(session)
+ val stillTracked = applyToTrip(trip) {
+ // The same detector can receive a newer foreground update while
+ // this request is out. Discard its stop data and back-off only;
+ // the independently fetched vehicle positions remain useful.
+ if (stopFeedRevision != trip.stopFeedRevision) {
+ return@applyToTrip
+ }
+ // familyRealtime folds a failed request into an empty map, and a
+ // route with stops to look up that answers with nothing is a
+ // failure rather than an empty feed — so back off rather than
+ // spend the shared rate limit on retries.
+ if (liveStops.isEmpty()) {
+ synchronized(refreshLock) {
+ liveStopBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS
+ }
+ } else {
+ synchronized(refreshLock) {
+ cachedLiveRouteIds = routeIds
+ cachedLivePathId = session.pathId
+ cachedLiveFetchedAtMs = nowElapsed
+ cachedLiveStops = liveStops
+ }
+ feedBoardingEta(trip.detector, session, liveStops)
+ }
+ }
+ if (!stillTracked) {
+ return false
+ }
+ }
+
+ val reportsByRoute = linkedMapOf>()
+ for (routeId in busRouteIdsDue) {
+ val reports = fetchBusReportsFromBackend(routeId)
+ if (!applyToTrip(trip) {
+ // A confirmed ride survives family updates, but an endpoint
+ // removed from its family must not publish an in-flight result.
+ if (routeId !in this.session?.realtimeRouteIds.orEmpty()) {
+ return@applyToTrip
+ }
+ synchronized(refreshLock) {
+ val receivedAt = SystemClock.elapsedRealtime()
+ busFeedRefreshByRoute[routeId] = if (reports == null) {
+ (busFeedRefreshByRoute[routeId] ?: BusFeedRefresh()).copy(
+ retryAtElapsedMs = receivedAt + LIVE_FETCH_BACKOFF_MS,
+ )
+ } else {
+ BusFeedRefresh(fetchedAtElapsedMs = receivedAt)
+ }
+ }
+ reports?.let { reportsByRoute[routeId] = it }
+ }) {
+ return false
+ }
+ }
+ return applyToTrip(trip) {
+ val currentRoutes = this.session?.realtimeRouteIds.orEmpty().toSet()
+ // A plate can occur in more than one endpoint; it is still one bus.
+ // Keep its newest report, rather than letting route iteration order
+ // choose which position drives a ride with no phone location.
+ val reports = reportsByRoute.filterKeys { it in currentRoutes }.values.flatten()
+ .groupBy { it.vehicleId }.values.mapNotNull { track -> track.maxByOrNull { it.gpsTimeMs } }
+ trip.detector.onBusReports(reports)
+ }
+ }
+
+ private fun feedBoardingEta(
+ target: TripRideDetector,
session: TrackingSession,
- location: Location?,
- ): TrackingSnapshot {
- val liveStops = fetchLiveStopMap(session)
- if (this.session !== session) return buildStartupSnapshot(session)
- if (location == null) {
- return TrackingSnapshot(
- title = session.routeName,
- content = "等待目前位置...",
- subText = session.pathName.ifBlank { "背景乘車提醒進行中" },
- progressMax = null,
- progressValue = null,
- shortCriticalText = "定位中",
+ liveStops: Map,
+ ) {
+ val boardingStopId = session.boardingStopId ?: return
+ val liveStop = liveStops[boardingStopId] ?: return
+ val etaEntry = liveStop.etaEntries.firstOrNull()
+ // A plate on an ETA entry is a bus still on its way, possibly ten
+ // minutes out; only a bus the feed places at the stop counts as here.
+ val arriving = liveStop.seconds?.let { it <= 0 } == true ||
+ liveStop.busesAtStop.isNotEmpty() ||
+ liveStop.message?.contains("進站") == true ||
+ liveStop.message?.contains("到站") == true
+ // Which bus that is, where the feed leaves no doubt: its leaving is
+ // what the ETA rolling over will report next.
+ val atStop = liveStop.busesAtStop.ifEmpty {
+ liveStop.etaEntries.filter { (it.seconds ?: 1) <= 0 }.mapNotNull { it.vehicleId }
+ }.mapNotNull(::normalizeVehicleId).distinct().singleOrNull()
+ val now = System.currentTimeMillis()
+ synchronized(detectorLock) {
+ target.onBoardingStopEta(
+ etaSeconds = liveStop.seconds ?: etaEntry?.seconds,
+ arriving = arriving,
+ nowMs = now,
+ arrivingVehicleId = atStop.takeIf { arriving },
)
}
+ }
- val nearestIndex = findNearestStopIndex(session.stops, location)
- if (nearestIndex == -1) {
- return TrackingSnapshot(
- title = session.routeName,
- content = "暫時無法判斷最近站牌",
- subText = session.pathName.ifBlank { "背景乘車提醒進行中" },
- progressMax = null,
- progressValue = null,
- shortCriticalText = "更新中",
- )
+ private fun refreshRideSnapshot(nowMs: Long) {
+ synchronized(detectorLock) {
+ latestRide = detector?.snapshot(nowMs) ?: RideSnapshot()
}
+ }
- val nearestStop = session.stops[nearestIndex]
- val nearestStopDistanceMeters = distanceMeters(
- location.latitude,
- location.longitude,
- nearestStop.lat,
- nearestStop.lon,
- )
- val nearestLiveStop = liveStops[nearestStop.stopId]
+ /**
+ * Lets [trip]'s detector look again and returns what it now makes of the
+ * ride; null when the trip has been replaced.
+ */
+ private fun advanceRide(trip: TrackedTrip, nowMs: Long): RideSnapshot? = synchronized(detectorLock) {
+ if (detector !== trip.detector) {
+ return null
+ }
+ trip.detector.snapshot(nowMs).also { latestRide = it }
+ }
+
+ private fun currentRideSnapshot(): RideSnapshot = synchronized(detectorLock) { latestRide }
+
+ /**
+ * Builds the notification from the detector's view of the trip plus the
+ * live stop feed. The detector owns every "where is the user in the trip"
+ * decision; this only renders it.
+ */
+ private fun buildSnapshot(
+ session: TrackingSession,
+ location: Location?,
+ ride: RideSnapshot,
+ ): TrackingSnapshot {
+ val liveStops = fetchLiveStopMap(session)
+ if (this.session !== session) return buildStartupSnapshot(session)
+ // A missing fix is not an early exit: the detector can still raise the
+ // boarding question without any location at all, and the notification
+ // is built from what it knows rather than from a nearest-stop guess.
+ val nearestIndex = location?.let { findNearestStopIndex(session.stops, it) } ?: -1
+ val nearestStop = session.stops.getOrNull(nearestIndex)
+ val nearestLiveStop = nearestStop?.let { liveStops[it.stopId] }
val nearestEtaText = displayEtaText(nearestLiveStop)
- val busIndex = findClosestBusIndex(session.stops, liveStops, nearestIndex)
- val destinationIndex = session.destinationStopId?.let { destinationStopId ->
- session.stops.indexOfFirst { stop -> stop.stopId == destinationStopId }
- .takeIf { it >= 0 }
- }
-
- if (destinationIndex == null && session.boardingStopId != null) {
- val boardingIndex = session.stops.indexOfFirst { stop ->
- stop.stopId == session.boardingStopId
- }.takeIf { it >= 0 } ?: nearestIndex
- val boardingStop = session.stops[boardingIndex]
- val boardingLiveStop = liveStops[boardingStop.stopId]
- val boardingVehicleCandidate = resolveBoardingVehicleCandidate(
- stops = session.stops,
- liveStops = liveStops,
- location = location,
- nearestIndex = nearestIndex,
- boardingIndex = boardingIndex,
- )
- val boardingVehicleId = boardingVehicleCandidate?.vehicleId ?: activeBoardingVehicleId
- val boardingEtaText = displayEtaText(boardingLiveStop, boardingVehicleId)
- val boardingEtaSeconds = resolveEtaSeconds(boardingLiveStop, boardingVehicleId)
- val boardingDistanceMeters = distanceMeters(
- location.latitude,
- location.longitude,
- boardingStop.lat,
- boardingStop.lon,
- )
- val fallbackBoardingBusIndex = findClosestBusIndexForBoarding(
- stops = session.stops,
- liveStops = liveStops,
- boardingIndex = boardingIndex,
- )
- val boardingBusIndex =
- boardingVehicleCandidate?.stopIndex ?: activeBoardingVehicleStopIndex ?: fallbackBoardingBusIndex
- val busStopsUntilBoarding = boardingBusIndex?.let {
- (boardingIndex - it).coerceAtLeast(0)
- }
- val rideState = if (session.backgroundLocationAlwaysGranted) {
- updateRideState(
- location = location,
- nearestIndex = nearestIndex,
- boardingIndex = boardingIndex,
- boardingBusIndex = boardingBusIndex,
- boardingVehicleId = boardingVehicleId,
- boardingEtaText = boardingEtaText,
- boardingEtaSeconds = boardingEtaSeconds,
- boardingDistanceMeters = boardingDistanceMeters,
- )
- } else {
- resolveNoLocationRideState(
- busStopsUntilBoarding = busStopsUntilBoarding,
- boardingEtaText = boardingEtaText,
- boardingEtaSeconds = boardingEtaSeconds,
- )
- }
- val userBusLiveStop = busIndex?.let { liveStops[session.stops[it].stopId] }
- trackedBusId = if (rideState.hasBoarded) {
- selectTrackedBusId(
+ val nearestStopDistanceMeters = if (location != null && nearestStop != null) {
+ distanceMeters(location.latitude, location.longitude, nearestStop.lat, nearestStop.lon)
+ } else {
+ null
+ }
+ val destinationIndex = session.destinationStopId
+ ?.let { id -> session.stops.indexOfFirst { it.stopId == id } }
+ ?.takeIf { it >= 0 }
+ ?: -1
+ val destinationStop = session.stops.getOrNull(destinationIndex)
+ val boardingIndex = ride.boardingStopIndex.takeIf { it >= 0 }
+ ?: session.boardingStopId
+ ?.let { id -> session.stops.indexOfFirst { it.stopId == id } }
+ ?.takeIf { it >= 0 }
+ ?: -1
+ val boardingStop = session.stops.getOrNull(boardingIndex)
+
+ val boardingLiveStop = boardingStop?.let { liveStops[it.stopId] }
+ // Prefer the vehicle the detector is following; fall back to whatever
+ // the stop-level feed says so the countdown still works where the ETA
+ // entries carry no plate (雙北).
+ val boardingVehicleId = ride.nextBusVehicleId
+ val boardingEtaText = displayEtaText(boardingLiveStop, boardingVehicleId)
+ val boardingEtaSeconds = resolveEtaSeconds(boardingLiveStop, boardingVehicleId)
+ val boardingEtaShort = displayShortEtaText(boardingLiveStop, boardingVehicleId)
+ val busStopsUntilBoarding = ride.busStopsUntilBoarding
+ val busStopsAway = nearestIndex.takeIf { it >= 0 }?.let { index ->
+ ride.trackedVehicleId?.let { tracked ->
+ findTrackedBusIndex(
stops = session.stops,
liveStops = liveStops,
- currentTrackedBusId = trackedBusId ?: normalizeVehicleId(boardingVehicleId),
- fallbackLiveStops = listOf(
- userBusLiveStop,
- nearestLiveStop,
- boardingLiveStop,
- ),
+ preferredVehicleId = tracked,
)
- } else {
- trackedBusLastSeenAtMs = 0L
- null
- }
- val busStopsAway = busIndex?.let { (nearestIndex - it).coerceAtLeast(0) }
- val boardingPointCount = boardingIndex + 1
- val boardingProgressPoint = if (rideState.hasBoarded) {
- val trackedPos = boardingBusIndex ?: nearestIndex
- (trackedPos + 1).coerceIn(1, boardingPointCount)
- } else {
- boardingBusIndex?.plus(1)?.coerceAtMost(boardingPointCount) ?: 0
- }
+ }?.let { (index - it).coerceAtLeast(0) }
+ }
+
+ if (ride.hasBoarded && destinationStop == null) {
+ // On board with nowhere to get off yet. This comes before every
+ // waiting branch — including the one for a trip with neither end
+ // chosen — because a confirmed ride has to be reported as one:
+ // that is what makes the destination-setup prompt fire.
+ val travelIndex = ride.progressStopIndex.takeIf { it >= 0 }
+ ?: ride.nearestStopIndexOnBoard(liveStops, session.stops)
+ ?: nearestIndex
+ val currentStop = session.stops.getOrNull(travelIndex)
return TrackingSnapshot(
title = session.routeName,
- content = "${nearestStop.stopName} 約 $nearestEtaText",
- subText = buildNearestStatusText(
- session = session,
- nearestStop = nearestStop,
- nearestEtaText = nearestEtaText,
- busStopsAway = busStopsAway,
- ),
- progressMax = toProgressMax(boardingPointCount),
- progressValue = toProgressValue(boardingProgressPoint, boardingPointCount),
- shortCriticalText = buildShortCriticalText(
- busStopsAway,
- displayShortEtaText(nearestLiveStop),
+ content = if (currentStop != null) {
+ "已上車 · 最近站牌 ${currentStop.stopName}"
+ } else {
+ "已上車 · 追蹤中"
+ },
+ subText = buildOnboardTripSubText(
+ currentStop?.stopName ?: session.pathName.ifBlank { "乘車中" },
),
- hasBoarded = rideState.hasBoarded,
- boardingName = boardingStop.stopName,
- boardingEtaText = boardingEtaText,
- boardingEtaSeconds = boardingEtaSeconds,
- boardingStopsAway = busStopsUntilBoarding,
- boardingDistanceMeters = boardingDistanceMeters,
- boardingVehicleId = boardingVehicleId,
- boardingVehiclePassedStop = rideState.boardingVehiclePassedStop,
- boardingPromptEligible = rideState.boardingPromptEligible,
+ progressMax = null,
+ progressValue = null,
+ shortCriticalText = null,
+ hasBoarded = true,
+ boardingName = boardingStop?.stopName,
+ boardingVehicleId = ride.trackedVehicleId ?: boardingVehicleId,
nearestStopDistanceMeters = nearestStopDistanceMeters,
)
}
- if (destinationIndex == null) {
- trackedBusId = null
- trackedBusLastSeenAtMs = 0L
- val busStopsAway = busIndex?.let { (nearestIndex - it).coerceAtLeast(0) }
+ if (boardingStop == null && destinationStop == null) {
+ // Neither end of the trip is known: just report the nearest stop.
return TrackingSnapshot(
title = session.routeName,
- content = "${nearestStop.stopName} · $nearestEtaText",
- subText = buildNearestStatusText(
- session = session,
- nearestStop = nearestStop,
- nearestEtaText = nearestEtaText,
- busStopsAway = busStopsAway,
- ),
+ content = if (nearestStop != null) {
+ "${nearestStop.stopName} · $nearestEtaText"
+ } else {
+ session.pathName.ifBlank { "乘車提醒進行中" }
+ },
+ subText = if (nearestStop != null) {
+ buildNearestStatusText(session, nearestStop, nearestEtaText, busStopsAway)
+ } else {
+ session.pathName.ifBlank { "背景乘車提醒進行中" }
+ },
progressMax = null,
progressValue = null,
shortCriticalText = buildShortCriticalText(
busStopsAway,
displayShortEtaText(nearestLiveStop),
),
+ nearestStopDistanceMeters = nearestStopDistanceMeters,
)
}
- val destinationStop = session.stops[destinationIndex]
- val boardingIndex = resolveBoardingIndex(session, nearestIndex, destinationIndex)
- val boardingStop = session.stops[boardingIndex]
- val boardingLiveStop = liveStops[boardingStop.stopId]
- val boardingVehicleCandidate = resolveBoardingVehicleCandidate(
- stops = session.stops,
- liveStops = liveStops,
- location = location,
- nearestIndex = nearestIndex,
- boardingIndex = boardingIndex,
- )
- val boardingVehicleId = boardingVehicleCandidate?.vehicleId ?: activeBoardingVehicleId
- val boardingEtaText = displayEtaText(boardingLiveStop, boardingVehicleId)
- val boardingEtaShort = displayShortEtaText(boardingLiveStop, boardingVehicleId)
- val boardingEtaSeconds = resolveEtaSeconds(boardingLiveStop, boardingVehicleId)
- val boardingDistanceMeters = distanceMeters(
- location.latitude,
- location.longitude,
- boardingStop.lat,
- boardingStop.lon,
- )
- val fallbackBoardingBusIndex = findClosestBusIndexForBoarding(
- stops = session.stops,
- liveStops = liveStops,
- boardingIndex = boardingIndex,
- )
- val boardingBusIndex =
- boardingVehicleCandidate?.stopIndex ?: activeBoardingVehicleStopIndex ?: fallbackBoardingBusIndex
- val busStopsUntilBoarding = boardingBusIndex?.let {
- (boardingIndex - it).coerceAtLeast(0)
- }
- val rideState = if (session.backgroundLocationAlwaysGranted) {
- updateRideState(
- location = location,
- nearestIndex = nearestIndex,
- boardingIndex = boardingIndex,
- boardingBusIndex = boardingBusIndex,
- boardingVehicleId = boardingVehicleId,
- boardingEtaText = boardingEtaText,
- boardingEtaSeconds = boardingEtaSeconds,
- boardingDistanceMeters = boardingDistanceMeters,
- )
- } else {
- resolveNoLocationRideState(
- busStopsUntilBoarding = busStopsUntilBoarding,
- boardingEtaText = boardingEtaText,
- boardingEtaSeconds = boardingEtaSeconds,
- )
- }
-
- if (!rideState.hasBoarded) {
- trackedBusId = null
- trackedBusLastSeenAtMs = 0L
- val boardingPointCount = boardingIndex + 1
- val boardingProgressPoint =
- boardingBusIndex?.plus(1)?.coerceAtMost(boardingPointCount) ?: 0
+ if (destinationStop == null || !ride.hasBoarded) {
+ val waitingStop = boardingStop ?: nearestStop
+ if (waitingStop == null) {
+ return TrackingSnapshot(
+ title = session.routeName,
+ content = "等待目前位置...",
+ subText = session.pathName.ifBlank { "背景乘車提醒進行中" },
+ progressMax = null,
+ progressValue = null,
+ shortCriticalText = "定位中",
+ )
+ }
+ val boardingPointCount = boardingIndex.takeIf { it >= 0 }?.plus(1) ?: 1
+ val busPoint = ride.nextBusVehicleId
+ ?.let { tracked ->
+ findTrackedBusIndex(
+ stops = session.stops,
+ liveStops = liveStops,
+ preferredVehicleId = tracked,
+ )
+ }
+ ?.plus(1)
+ ?.coerceAtMost(boardingPointCount)
+ ?: 0
return TrackingSnapshot(
- title = "${session.routeName} · ${boardingStop.stopName}",
- content = "公車到 ${boardingStop.stopName} 約 $boardingEtaText",
- subText = buildWaitingBoardingText(
- session = session,
- boardingStop = boardingStop,
- destinationStop = destinationStop,
- busStopsUntilBoarding = busStopsUntilBoarding,
- ),
+ title = if (destinationStop == null) {
+ session.routeName
+ } else {
+ "${session.routeName} · ${waitingStop.stopName}"
+ },
+ content = if (destinationStop == null) {
+ "${waitingStop.stopName} 約 ${displayEtaText(liveStops[waitingStop.stopId])}"
+ } else {
+ "公車到 ${waitingStop.stopName} 約 $boardingEtaText"
+ },
+ subText = if (destinationStop == null) {
+ buildNearestStatusText(
+ session = session,
+ nearestStop = waitingStop,
+ nearestEtaText = displayEtaText(liveStops[waitingStop.stopId]),
+ busStopsAway = busStopsUntilBoarding,
+ )
+ } else {
+ buildWaitingBoardingText(
+ session = session,
+ boardingStop = waitingStop,
+ destinationStop = destinationStop,
+ busStopsUntilBoarding = busStopsUntilBoarding,
+ )
+ },
progressMax = toProgressMax(boardingPointCount),
- progressValue = toProgressValue(boardingProgressPoint, boardingPointCount),
+ progressValue = toProgressValue(busPoint, boardingPointCount),
shortCriticalText = buildShortCriticalText(
busStopsUntilBoarding,
- boardingEtaShort,
+ if (destinationStop == null) {
+ displayShortEtaText(liveStops[waitingStop.stopId])
+ } else {
+ boardingEtaShort
+ },
),
- boardingName = boardingStop.stopName,
+ hasBoarded = false,
+ boardingName = waitingStop.stopName,
boardingEtaText = boardingEtaText,
boardingEtaSeconds = boardingEtaSeconds,
boardingStopsAway = busStopsUntilBoarding,
- boardingDistanceMeters = boardingDistanceMeters,
- hasBoarded = false,
+ boardingDistanceMeters = ride.boardingStopDistanceMeters,
boardingVehicleId = boardingVehicleId,
- boardingVehiclePassedStop = rideState.boardingVehiclePassedStop,
- boardingPromptEligible = rideState.boardingPromptEligible,
- destinationName = destinationStop.stopName,
+ boardingVehiclePassedStop = false,
+ boardingPromptEligible = ride.boardingPromptDue,
+ destinationName = destinationStop?.stopName,
nearestStopDistanceMeters = nearestStopDistanceMeters,
)
}
+ // On board: progress comes from the user's own position, falling back to
+ // the vehicle they are riding when the phone stops reporting.
+ val travelIndex = ride.progressStopIndex.takeIf { it >= 0 }
+ ?: ride.nearestStopIndexOnBoard(liveStops, session.stops)
+ ?: nearestIndex
+ val journeyStartIndex = boardingIndex.coerceAtLeast(0).coerceAtMost(destinationIndex)
+ val journeyPointCount = (destinationIndex - journeyStartIndex + 1).coerceAtLeast(1)
+ val currentProgressPoint = if (travelIndex >= 0) {
+ (travelIndex.coerceAtLeast(journeyStartIndex) - journeyStartIndex) + 1
+ } else {
+ 1
+ }.coerceIn(1, journeyPointCount)
val destinationLive = liveStops[destinationStop.stopId]
- val userBusLiveStop = busIndex?.let { liveStops[session.stops[it].stopId] }
- trackedBusId = selectTrackedBusId(
- stops = session.stops,
- liveStops = liveStops,
- currentTrackedBusId = trackedBusId ?: normalizeVehicleId(boardingVehicleId),
- fallbackLiveStops = listOf(
- userBusLiveStop,
- nearestLiveStop,
- boardingLiveStop,
- destinationLive,
- ),
- )
- val trackedBusIndex =
- findTrackedBusIndex(
- stops = session.stops,
- liveStops = liveStops,
- preferredVehicleId = trackedBusId,
- )
- // Once on board the user travels with the bus, so their own position is
- // a better stand-in than some other vehicle when the tracked one drops
- // out of the feed.
- val travelIndex = trackedBusIndex ?: nearestIndex
- val rawRemainingStops = destinationIndex - travelIndex
- val remainingStops = rawRemainingStops.coerceAtLeast(0)
- val allowAnyDestinationVehicle = trackedBusId == null
val destinationEtaText = displayEtaText(
destinationLive,
- trackedBusId,
- fallbackToAnyVehicle = allowAnyDestinationVehicle,
+ ride.trackedVehicleId,
+ fallbackToAnyVehicle = ride.trackedVehicleId == null,
)
val destinationEtaShort = displayShortEtaText(
destinationLive,
- trackedBusId,
- fallbackToAnyVehicle = allowAnyDestinationVehicle,
+ ride.trackedVehicleId,
+ fallbackToAnyVehicle = ride.trackedVehicleId == null,
)
- val destinationDistanceMeters = distanceMeters(
- location.latitude,
- location.longitude,
- destinationStop.lat,
- destinationStop.lon,
- )
- val journeyStartIndex = boardingIndex.coerceAtMost(destinationIndex)
- val journeyPointCount = (destinationIndex - journeyStartIndex + 1).coerceAtLeast(1)
- val currentProgressPoint =
- ((travelIndex.coerceAtLeast(journeyStartIndex) - journeyStartIndex) + 1)
- .coerceIn(1, journeyPointCount)
+ val remainingStops = ride.remainingStops ?: (destinationIndex - journeyStartIndex)
+ .coerceAtLeast(0)
return TrackingSnapshot(
title = "${session.routeName} · ${destinationStop.stopName}",
@@ -837,519 +1127,53 @@ class RouteTripMonitorService : Service() {
remainingStops == 0 -> "已接近 ${destinationStop.stopName}"
else -> "距離 ${destinationStop.stopName} 還有 $remainingStops 站 · $destinationEtaText"
},
- subText = buildOnboardTripSubText(nearestStop.stopName),
+ subText = buildOnboardTripSubText(
+ nearestStop?.stopName ?: destinationStop.stopName,
+ ),
progressMax = toProgressMax(journeyPointCount),
progressValue = toProgressValue(currentProgressPoint, journeyPointCount),
- shortCriticalText = buildShortCriticalText(
- remainingStops,
- destinationEtaShort,
- ),
+ shortCriticalText = buildShortCriticalText(remainingStops, destinationEtaShort),
hasBoarded = true,
destinationName = destinationStop.stopName,
remainingStops = remainingStops,
- destinationDistanceMeters = destinationDistanceMeters,
- boardingName = boardingStop.stopName,
- boardingVehicleId = trackedBusId ?: boardingVehicleId,
- passedDestinationByStops = (-rawRemainingStops).takeIf { it > 0 },
+ destinationDistanceMeters = ride.destinationDistanceMeters,
+ boardingName = boardingStop?.stopName,
+ boardingVehicleId = ride.trackedVehicleId ?: boardingVehicleId,
+ passedDestinationByStops = ride.passedDestinationByStops,
nearestStopDistanceMeters = nearestStopDistanceMeters,
+ hasRouteValidProgress = ride.hasRouteValidProgress,
)
}
- private fun buildSnapshotLegacy(
- session: TrackingSession,
- location: Location?,
- ): TrackingSnapshot {
- val liveStops = fetchLiveStopMap(session)
- if (location == null) {
- return TrackingSnapshot(
- title = session.routeName,
- content = "等待目前位置...",
- subText = session.pathName.ifBlank { "背景乘車提醒進行中" },
- progressMax = null,
- progressValue = null,
- shortCriticalText = "定位中",
- )
- }
-
- val nearestIndex = session.stops
- .indices
- .minByOrNull { index ->
- val stop = session.stops[index]
- distanceMeters(
- location.latitude,
- location.longitude,
- stop.lat,
- stop.lon,
- )
- } ?: -1
- if (nearestIndex == -1) {
- return TrackingSnapshot(
- title = session.routeName,
- content = "暫時無法判斷最近站牌",
- subText = session.pathName.ifBlank { "背景乘車提醒進行中" },
- progressMax = null,
- progressValue = null,
- shortCriticalText = "更新中",
- )
- }
-
- val nearestStop = session.stops[nearestIndex]
- val nearestLiveStop = liveStops[nearestStop.stopId]
- val busIndex = findClosestBusIndex(session.stops, liveStops, nearestIndex)
- val busStopsAway = busIndex?.let { (nearestIndex - it).coerceAtLeast(0) }
- val nearestEtaText = formatEtaText(nearestLiveStop)
- val nearestEtaShort = formatShortEtaText(nearestLiveStop)
- val nearestSubText = buildNearestSubText(
- session = session,
- nearestStop = nearestStop,
- busStopsAway = busStopsAway,
- nearestEtaText = nearestEtaText,
- )
-
- val destinationIndex = session.destinationStopId?.let { destinationStopId ->
- session.stops.indexOfFirst { stop -> stop.stopId == destinationStopId }
- .takeIf { it >= 0 }
- }
- if (destinationIndex == null) {
- return TrackingSnapshot(
- title = session.routeName,
- content = "${nearestStop.stopName} · $nearestEtaText",
- subText = nearestSubText,
- progressMax = null,
- progressValue = null,
- shortCriticalText = buildShortCriticalText(busStopsAway, nearestEtaShort),
- )
- }
-
- val destinationStop = session.stops[destinationIndex]
- val destinationLive = liveStops[destinationStop.stopId]
- val remainingStops = (destinationIndex - nearestIndex).coerceAtLeast(0)
- val destinationEtaText = formatEtaText(destinationLive)
- val destinationEtaShort = formatShortEtaText(destinationLive)
- val destinationDistanceMeters = distanceMeters(
- location.latitude,
- location.longitude,
- destinationStop.lat,
- destinationStop.lon,
- )
- val pointCount = destinationIndex + 1
- val currentProgressPoint = (busIndex ?: nearestIndex).plus(1).coerceAtMost(pointCount)
-
- return TrackingSnapshot(
- title = "${session.routeName} · ${destinationStop.stopName}",
- content = when {
- remainingStops == 0 -> "已接近 ${destinationStop.stopName}"
- else -> "${destinationStop.stopName} 還有 $remainingStops 站 · $destinationEtaText"
- },
- subText = "最近站牌 ${nearestStop.stopName} · $nearestEtaText",
- progressMax = toProgressMax(pointCount),
- progressValue = toProgressValue(currentProgressPoint, pointCount),
- destinationName = destinationStop.stopName,
- remainingStops = remainingStops,
- destinationDistanceMeters = destinationDistanceMeters,
- shortCriticalText = buildShortCriticalText(remainingStops, destinationEtaShort),
- )
- }
-
- private fun resolveBoardingIndex(
- session: TrackingSession,
- nearestIndex: Int,
- destinationIndex: Int,
- ): Int {
- val explicitBoardingIndex = session.boardingStopId?.let { boardingStopId ->
- session.stops.indexOfFirst { stop -> stop.stopId == boardingStopId }
- .takeIf { it >= 0 }
- }
- return (explicitBoardingIndex ?: nearestIndex).coerceAtMost(destinationIndex)
- }
-
- private fun findNearestStopIndex(
- stops: List,
- location: Location,
- ): Int {
- return stops.indices.minByOrNull { index ->
- val stop = stops[index]
- distanceMeters(
- location.latitude,
- location.longitude,
- stop.lat,
- stop.lon,
- )
- } ?: -1
- }
-
- private fun findClosestStopIndices(
- stops: List,
- location: Location,
- count: Int,
- ): List {
- if (count <= 0) {
- return emptyList()
- }
- return stops.indices
- .sortedBy { index ->
- val stop = stops[index]
- distanceMeters(
- location.latitude,
- location.longitude,
- stop.lat,
- stop.lon,
- )
- }
- .take(count)
- }
-
- private fun resolveBoardingVehicleCandidate(
- stops: List,
+ /**
+ * Last resort for on-board progress when the user's own fixes are gone: the
+ * stop the tracked vehicle has reached, found through the live feed.
+ */
+ private fun RideSnapshot.nearestStopIndexOnBoard(
liveStops: Map,
- location: Location,
- nearestIndex: Int,
- boardingIndex: Int,
- ): VehicleCandidate? {
- val now = System.currentTimeMillis()
- val nearbyVehicleIds = linkedSetOf()
- findClosestStopIndices(stops, location, USER_NEARBY_STOP_COUNT).forEach { index ->
- nearbyVehicleIds += extractNormalizedVehicleIds(liveStops[stops[index].stopId])
- }
- val normalizedActiveVehicleId = normalizeVehicleId(activeBoardingVehicleId)
- val activeVehicleStillFresh = normalizedActiveVehicleId != null &&
- activeBoardingVehicleLastSeenAtMs != 0L &&
- now - activeBoardingVehicleLastSeenAtMs < ACTIVE_BOARDING_VEHICLE_STALE_MS
- if (activeVehicleStillFresh) {
- nearbyVehicleIds += normalizedActiveVehicleId!!
- }
-
- val candidates = nearbyVehicleIds.mapNotNull { vehicleId ->
- buildVehicleCandidate(
- stops = stops,
- liveStops = liveStops,
- location = location,
- vehicleId = vehicleId,
- )
- }
- val activeCandidate = candidates.firstOrNull { it.vehicleId == normalizedActiveVehicleId }
- if (activeCandidate != null) {
- activeBoardingVehicleStopIndex = activeCandidate.stopIndex
- activeBoardingVehicleLastSeenAtMs = now
- return activeCandidate
- }
-
- // The locked vehicle dropped out of the live feed (bus GPS hiccups are
- // routine). Hold the lock during the grace window instead of jumping
- // to the next bus, so the original bus re-locks when it reappears.
- if (activeVehicleStillFresh) {
- return null
- }
- if (normalizedActiveVehicleId != null) {
- activeBoardingVehicleId = null
- activeBoardingVehicleStopIndex = null
- activeBoardingVehicleLastSeenAtMs = 0L
- activeBoardingVehiclePassedAtMs = 0L
- }
-
- val selectedCandidate = candidates.minWithOrNull(
- compareBy { if (it.stopIndex > boardingIndex) 1 else 0 }
- .thenBy {
- if (it.stopIndex <= boardingIndex) {
- boardingIndex - it.stopIndex
- } else {
- it.stopIndex - boardingIndex
- }
- }
- .thenBy { abs(it.stopIndex - nearestIndex) }
- .thenBy { it.distanceMeters }
- .thenBy { it.etaSeconds ?: Int.MAX_VALUE },
- ) ?: return null
-
- val nextVehicleId = selectedCandidate.vehicleId
- if (normalizedActiveVehicleId != null && normalizedActiveVehicleId != nextVehicleId && !rideConfirmed) {
- boardingAlertSent = false
- boardingCheckPromptSent = false
- boardingCheckPromptSentAtMs = 0L
- boardingWindowOpen = false
- activeBoardingVehiclePassedAtMs = 0L
- rideConfirmationSamples = 0
- notificationManager.cancel(ALERT_NOTIFICATION_ID)
- }
- activeBoardingVehicleId = nextVehicleId
- activeBoardingVehicleStopIndex = selectedCandidate.stopIndex
- activeBoardingVehicleLastSeenAtMs = now
- return selectedCandidate
- }
-
- private fun buildVehicleCandidate(
stops: List,
- liveStops: Map,
- location: Location,
- vehicleId: String,
- ): VehicleCandidate? {
- val normalizedVehicleId = normalizeVehicleId(vehicleId) ?: return null
- val stopIndex = findTrackedBusIndex(
- stops = stops,
- liveStops = liveStops,
- preferredVehicleId = normalizedVehicleId,
- ) ?: return null
- val stop = stops[stopIndex]
- val stopState = liveStops[stop.stopId]
- return VehicleCandidate(
- vehicleId = normalizedVehicleId,
- stopIndex = stopIndex,
- distanceMeters = distanceMeters(
- location.latitude,
- location.longitude,
- stop.lat,
- stop.lon,
- ),
- etaSeconds = resolveEtaSeconds(stopState, normalizedVehicleId),
- )
- }
-
- private fun extractNormalizedVehicleIds(stopState: LiveStopState?): Set {
- val result = linkedSetOf()
- stopState?.etaEntries?.forEach { etaEntry ->
- normalizeVehicleId(etaEntry.vehicleId)?.let(result::add)
- }
- stopState?.vehicleIds?.forEach { vehicleId ->
- normalizeVehicleId(vehicleId)?.let(result::add)
- }
- return result
- }
-
- private fun updateRideState(
- location: Location,
- nearestIndex: Int,
- boardingIndex: Int,
- boardingBusIndex: Int?,
- boardingVehicleId: String?,
- boardingEtaText: String,
- boardingEtaSeconds: Int?,
- boardingDistanceMeters: Double,
- ): RideStateUpdate {
- val userNearBoardingStop =
- boardingDistanceMeters <= BOARDING_STOP_RADIUS_METERS || nearestIndex == boardingIndex
- val busNearBoardingStop =
- (
- boardingBusIndex != null &&
- boardingBusIndex <= boardingIndex &&
- boardingIndex - boardingBusIndex <= 1 &&
- isLikelyBoardingArrival(
- etaSeconds = boardingEtaSeconds,
- etaText = boardingEtaText,
- )
- ) ||
- isImmediateEtaText(boardingEtaText)
- if (userNearBoardingStop && busNearBoardingStop) {
- if (!boardingWindowOpen) {
- boardingWindowOpenedAtMs = System.currentTimeMillis()
- }
- boardingWindowOpen = true
- } else if (boardingWindowOpen && boardingWindowOpenedAtMs != 0L) {
- val windowAge = System.currentTimeMillis() - boardingWindowOpenedAtMs
- if (windowAge >= BOARDING_WINDOW_MAX_DURATION_MS) {
- boardingWindowOpen = false
- boardingWindowOpenedAtMs = 0L
- }
- }
-
- val movement = updateUserMovement(location, nearestIndex)
- val previousNearest = lastNearestStopIndex
- val previousBusIndex = lastBusStopIndex
- if (movement.sampleAdvanced) {
- lastNearestStopIndex = nearestIndex
- }
- lastBusStopIndex = boardingBusIndex
- val movedForward = previousNearest != null && nearestIndex > previousNearest
- val busMovedForward =
- previousBusIndex != null &&
- boardingBusIndex != null &&
- boardingBusIndex > previousBusIndex
- val busNearUser = boardingBusIndex != null && abs(nearestIndex - boardingBusIndex) <= 1
- val vehiclePassedBoarding =
- normalizeVehicleId(boardingVehicleId) != null &&
- boardingBusIndex != null &&
- boardingBusIndex > boardingIndex
- if (vehiclePassedBoarding) {
- if (activeBoardingVehiclePassedAtMs == 0L) {
- activeBoardingVehiclePassedAtMs = System.currentTimeMillis()
- }
- } else if (boardingBusIndex != null && boardingBusIndex <= boardingIndex) {
- activeBoardingVehiclePassedAtMs = 0L
- }
- val userReachedBoarding = nearestIndex >= boardingIndex
- val userAdvancedPastBoardingStop = nearestIndex > boardingIndex
- val userMovingWithBus =
- movement.isTransitLike &&
- (busNearUser || vehiclePassedBoarding) &&
- (movedForward || movement.routeIndexDelta > 0 || boardingDistanceMeters > BOARDING_CONFIRM_DISTANCE_METERS)
- val strongBoardingSignal =
- boardingWindowOpen &&
- userReachedBoarding &&
- vehiclePassedBoarding &&
- (
- userMovingWithBus ||
- (
- busMovedForward &&
- userAdvancedPastBoardingStop &&
- boardingDistanceMeters <= BOARDING_CONFIRM_DISTANCE_METERS
- )
- )
-
- if (!rideConfirmed && movement.sampleAdvanced) {
- // Count evidence only on fresh GPS samples, and decay instead of
- // hard-resetting so a single noisy fix cannot erase progress.
- rideConfirmationSamples = if (strongBoardingSignal) {
- rideConfirmationSamples + 1
- } else {
- (rideConfirmationSamples - 1).coerceAtLeast(0)
- }
- if (rideConfirmationSamples >= REQUIRED_RIDE_CONFIRMATION_SAMPLES) {
- rideConfirmed = true
- boardingCheckPromptSent = true
- boardingCheckPromptSentAtMs = System.currentTimeMillis()
- normalizeVehicleId(boardingVehicleId)?.let { vehicleId ->
- trackedBusId = vehicleId
- trackedBusLastSeenAtMs = System.currentTimeMillis()
- }
- notificationManager.cancel(ALERT_NOTIFICATION_ID)
- }
- }
-
- val boardingPromptEligible =
- !rideConfirmed &&
- boardingWindowOpen &&
- vehiclePassedBoarding &&
- !movement.isTransitLike &&
- activeBoardingVehiclePassedAtMs != 0L &&
- System.currentTimeMillis() - activeBoardingVehiclePassedAtMs >=
- BOARDING_CHECK_AFTER_PASS_DELAY_MS
-
- return RideStateUpdate(
- hasBoarded = rideConfirmed,
- boardingVehiclePassedStop = vehiclePassedBoarding,
- boardingPromptEligible = boardingPromptEligible,
- )
- }
-
- private fun updateUserMovement(
- location: Location,
- nearestIndex: Int,
- ): UserMovementState {
- val now = SystemClock.elapsedRealtime()
- val previousLocation = lastMovementLocation
-
- // Re-processing the same fix (refreshes happen more often than GPS
- // updates) or a junk fix must not count as "the user stopped moving" —
- // keep the previous verdict instead of resetting it.
- val isSameFix = previousLocation != null &&
- previousLocation.time == location.time &&
- previousLocation.latitude == location.latitude &&
- previousLocation.longitude == location.longitude
- val isPoorAccuracy = location.hasAccuracy() &&
- location.accuracy > MAX_USABLE_LOCATION_ACCURACY_METERS
- if (isSameFix || isPoorAccuracy) {
- return UserMovementState(
- routeIndexDelta = 0,
- isTransitLike = isTransitLikeStillFresh(now),
- sampleAdvanced = false,
- )
- }
-
- val previousNearestIndex = lastMovementNearestIndex
- val previousRecordedAtMs = lastMovementRecordedAtMs
- val distanceMovedMeters = if (previousLocation == null) {
- 0.0
- } else {
- distanceMeters(
- previousLocation.latitude,
- previousLocation.longitude,
- location.latitude,
- location.longitude,
- )
- }
- val elapsedMs = if (previousRecordedAtMs == 0L) 0L else now - previousRecordedAtMs
- val estimatedSpeedMetersPerSecond =
- elapsedMs.takeIf { it > 0L }?.let { distanceMovedMeters / (it / 1_000.0) }
- // GPS chips report instantaneous speed far more reliably than the
- // distance between two throttled notification refreshes.
- val reportedSpeedMetersPerSecond = location.speed
- .takeIf { location.hasSpeed() && it > 0f }
- ?.toDouble()
- val routeIndexDelta = if (previousNearestIndex == null) {
- 0
- } else {
- nearestIndex - previousNearestIndex
- }
- lastMovementLocation = Location(location)
- lastMovementNearestIndex = nearestIndex
- lastMovementRecordedAtMs = now
-
- val transitLikeEvidence =
- (
- reportedSpeedMetersPerSecond != null &&
- reportedSpeedMetersPerSecond >= USER_TRANSIT_LIKE_SPEED_MPS
- ) ||
- (
- estimatedSpeedMetersPerSecond != null &&
- distanceMovedMeters >= USER_MOVEMENT_MIN_DISTANCE_METERS &&
- estimatedSpeedMetersPerSecond >= USER_TRANSIT_LIKE_SPEED_MPS
- ) ||
- (
- routeIndexDelta > 0 &&
- distanceMovedMeters >= USER_ROUTE_PROGRESS_MIN_DISTANCE_METERS
- )
- if (transitLikeEvidence) {
- lastTransitLikeAtMs = now
- }
- return UserMovementState(
- routeIndexDelta = routeIndexDelta,
- // Buses stop at lights and stations; hold the transit verdict for a
- // short window instead of flapping on every slow sample.
- isTransitLike = transitLikeEvidence || isTransitLikeStillFresh(now),
- sampleAdvanced = true,
- )
- }
-
- private fun isTransitLikeStillFresh(nowElapsedMs: Long): Boolean {
- return lastTransitLikeAtMs != 0L &&
- nowElapsedMs - lastTransitLikeAtMs < TRANSIT_LIKE_STICKY_MS
- }
-
- private fun hasBusReachedBoardingStop(
- busStopsUntilBoarding: Int?,
- boardingEtaText: String,
- boardingEtaSeconds: Int?,
- ): Boolean {
- if (busStopsUntilBoarding == null || busStopsUntilBoarding > 0) {
- return false
- }
- if (isImmediateEtaText(boardingEtaText)) {
- return true
- }
- // Without background location we cannot verify the user actually got
- // on, so only treat the bus as boarded when it is genuinely at the
- // stop — a 3-minute ETA is way too early.
- return boardingEtaSeconds != null &&
- boardingEtaSeconds <= BOARDING_NO_LOCATION_MAX_ETA_SECONDS
+ ): Int? {
+ val vehicleId = trackedVehicleId ?: return null
+ return findTrackedBusIndex(
+ stops = stops,
+ liveStops = liveStops,
+ preferredVehicleId = vehicleId,
+ )
}
- private fun resolveNoLocationRideState(
- busStopsUntilBoarding: Int?,
- boardingEtaText: String,
- boardingEtaSeconds: Int?,
- ): RideStateUpdate {
- // Latch the boarded state: once the tracked bus reaches the boarding
- // stop the ETA feed rolls over to the NEXT bus, which used to flip the
- // notification back to "not boarded" until the user re-confirmed.
- if (!rideConfirmed &&
- hasBusReachedBoardingStop(
- busStopsUntilBoarding = busStopsUntilBoarding,
- boardingEtaText = boardingEtaText,
- boardingEtaSeconds = boardingEtaSeconds,
+ private fun findNearestStopIndex(
+ stops: List,
+ location: Location,
+ ): Int {
+ return stops.indices.minByOrNull { index ->
+ val stop = stops[index]
+ distanceMeters(
+ location.latitude,
+ location.longitude,
+ stop.lat,
+ stop.lon,
)
- ) {
- rideConfirmed = true
- }
- return RideStateUpdate(hasBoarded = rideConfirmed)
+ } ?: -1
}
private fun buildNearestStatusText(
@@ -1419,70 +1243,6 @@ class RouteTripMonitorService : Service() {
)
}
- private fun selectTrackedBusId(
- stops: List,
- liveStops: Map,
- currentTrackedBusId: String?,
- fallbackLiveStops: List,
- ): String? {
- val now = System.currentTimeMillis()
- val normalizedCurrent = normalizeVehicleId(currentTrackedBusId)
- if (normalizedCurrent != null) {
- val seenAnywhereOnRoute = findTrackedBusIndex(
- stops = stops,
- liveStops = liveStops,
- preferredVehicleId = normalizedCurrent,
- ) != null
- if (seenAnywhereOnRoute) {
- trackedBusLastSeenAtMs = now
- return normalizedCurrent
- }
- if (trackedBusLastSeenAtMs == 0L) {
- trackedBusLastSeenAtMs = now
- return normalizedCurrent
- }
- // Vehicle temporarily missing from the live feed — keep the lock
- // during the grace window so it re-attaches when the feed recovers
- // instead of hopping to the next bus.
- if (now - trackedBusLastSeenAtMs < TRACKED_BUS_STALE_MS) {
- return normalizedCurrent
- }
- }
-
- fallbackLiveStops.forEach { stopState ->
- firstKnownVehicleId(stopState)?.let { vehicleId ->
- trackedBusLastSeenAtMs = now
- return vehicleId
- }
- }
-
- trackedBusLastSeenAtMs = 0L
- return null
- }
-
- private fun firstKnownVehicleId(stopState: LiveStopState?): String? {
- stopState ?: return null
- stopState.etaEntries.firstNotNullOfOrNull { entry ->
- normalizeVehicleId(entry.vehicleId)
- }?.let { return it }
- return stopState.vehicleIds.firstNotNullOfOrNull { vehicleId ->
- normalizeVehicleId(vehicleId)
- }
- }
-
- private fun isVehicleSeenAtStop(
- stopState: LiveStopState?,
- normalizedVehicleId: String,
- ): Boolean {
- stopState ?: return false
- if (stopState.vehicleIds.any { normalizeVehicleId(it) == normalizedVehicleId }) {
- return true
- }
- return stopState.etaEntries.any { etaEntry ->
- normalizeVehicleId(etaEntry.vehicleId) == normalizedVehicleId
- }
- }
-
private fun findEtaForVehicle(
stopState: LiveStopState?,
preferredVehicleId: String?,
@@ -1512,16 +1272,6 @@ class RouteTripMonitorService : Service() {
return selectedEta?.seconds ?: stopState?.seconds
}
- private fun isLikelyBoardingArrival(
- etaSeconds: Int?,
- etaText: String?,
- ): Boolean {
- if (isImmediateEtaText(etaText)) {
- return true
- }
- return etaSeconds != null && etaSeconds <= BOARDING_ARRIVAL_MAX_ETA_SECONDS
- }
-
private fun composeEtaText(seconds: Int?, message: String?): String {
val trimmedMessage = message?.trim().orEmpty()
if (trimmedMessage.isNotEmpty()) {
@@ -1571,22 +1321,22 @@ class RouteTripMonitorService : Service() {
return value.contains("進站") || value.contains("即將")
}
- private fun buildNearestSubText(
- session: TrackingSession,
- nearestStop: TrackingStop,
- busStopsAway: Int?,
- nearestEtaText: String,
- ): String {
- val parts = mutableListOf()
- if (session.pathName.isNotBlank()) {
- parts += session.pathName
- }
- parts += "最近站牌 ${nearestStop.stopName}"
- if (nearestEtaText != "--") {
- parts += nearestEtaText
+ /**
+ * The button that lets the user answer the boarding question by hand. It is
+ * offered while waiting for the bus as well, so detection is never the only
+ * way to start a trip.
+ */
+ private fun TrackingSnapshot.boardingActions(): String? {
+ return when {
+ hasBoarded -> null
+ boardingName != null -> "我已上車"
+ else -> null
}
- buildBusDistanceText(busStopsAway)?.let(parts::add)
- return parts.joinToString(" · ")
+ }
+
+ /** The counterpart button, offered once a ride is being tracked. */
+ private fun TrackingSnapshot.notBoardedAction(): String? {
+ return if (hasBoarded) "還沒上車" else null
}
private fun buildTrackingNotification(snapshot: TrackingSnapshot): Notification {
@@ -1705,6 +1455,17 @@ class RouteTripMonitorService : Service() {
).build(),
)
+ snapshot.boardingActions()?.let { action ->
+ builder.addAction(
+ NotificationCompat.Action.Builder(0, action, markBoardedIntent).build(),
+ )
+ }
+ snapshot.notBoardedAction()?.let { action ->
+ builder.addAction(
+ NotificationCompat.Action.Builder(0, action, notBoardedIntent).build(),
+ )
+ }
+
applyShortCriticalPresentation(builder, snapshot.shortCriticalText)
requestPromotedOngoing(builder)
@@ -1780,6 +1541,13 @@ class RouteTripMonitorService : Service() {
).build(),
)
+ snapshot.boardingActions()?.let { action ->
+ builder.addAction(Notification.Action.Builder(null, action, markBoardedIntent).build())
+ }
+ snapshot.notBoardedAction()?.let { action ->
+ builder.addAction(Notification.Action.Builder(null, action, notBoardedIntent).build())
+ }
+
applyShortCriticalPresentation(builder, snapshot.shortCriticalText)
requestPromotedOngoing(builder)
@@ -1849,79 +1617,16 @@ class RouteTripMonitorService : Service() {
)
}
- private fun buildLegacyTrackingNotification(snapshot: TrackingSnapshot): android.app.Notification {
- val currentSession = session ?: return NotificationCompat.Builder(
- this,
- TRACKING_CHANNEL_ID,
- ).setSmallIcon(R.drawable.ic_status_bus)
- .setContentTitle("YABus")
- .setOnlyAlertOnce(true)
- .setPriority(NotificationCompat.PRIORITY_DEFAULT)
- .build()
-
- val builder = NotificationCompat.Builder(this, TRACKING_CHANNEL_ID)
- .setSmallIcon(R.drawable.ic_status_bus)
- .setContentTitle(snapshot.title)
- .setContentText(snapshot.content)
- .setContentIntent(createOpenRoutePendingIntent(currentSession))
- .setOngoing(true)
- .setOnlyAlertOnce(true)
- .setPriority(NotificationCompat.PRIORITY_DEFAULT)
- .setCategory(NotificationCompat.CATEGORY_NAVIGATION)
- .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
- .setRequestPromotedOngoing(true)
- .addAction(
- NotificationCompat.Action.Builder(
- 0,
- "停止",
- createStopPendingIntent(),
- ).build(),
- )
-
- if (snapshot.subText.isNotBlank()) {
- builder.setSubText(snapshot.subText)
- }
- applyShortCriticalPresentation(builder, snapshot.shortCriticalText)
-
- val progressMax = snapshot.progressMax
- val progressValue = snapshot.progressValue
- if (progressMax != null && progressValue != null && progressMax > 0) {
- val clampedValue = progressValue.coerceIn(0, progressMax)
- val progressPoints = buildProgressPointPositions(progressMax)
- builder.setProgress(progressMax, clampedValue, false)
- val progressStyle = NotificationCompat.ProgressStyle()
- .setStyledByProgress(true)
- .setProgress(clampedValue)
- .setProgressTrackerIcon(
- IconCompat.createWithResource(this, R.drawable.ic_progress_bus),
- )
- .setProgressSegments(
- mutableListOf(
- NotificationCompat.ProgressStyle.Segment(progressMax),
- ),
- )
- .setProgressPoints(
- progressPoints.mapTo(mutableListOf()) { position ->
- NotificationCompat.ProgressStyle.Point(position)
- .setColor(PROGRESS_POINT_COLOR)
- },
- )
- .setProgressEndIcon(
- IconCompat.createWithResource(this, R.drawable.ic_progress_flag),
- )
- builder.setStyle(progressStyle)
- } else {
- builder.setProgress(0, 0, false)
- }
-
- return builder.build()
- }
-
private fun maybeSendTripAlerts(
session: TrackingSession,
snapshot: TrackingSnapshot,
) {
if (snapshot.passedDestinationByStops != null) {
+ // Fixes can skip the destination entirely. Passing it is also an
+ // arrival baseline, without restarting a countdown already running.
+ if (arrivalDetectedAtMs == 0L) {
+ arrivalDetectedAtMs = System.currentTimeMillis()
+ }
if (maybeSendOvershotAlert(session, snapshot)) {
return
}
@@ -1944,11 +1649,11 @@ class RouteTripMonitorService : Service() {
return
}
if (!snapshot.hasBoarded) {
- maybeExpireBoardingCheckPrompt()
maybeSendBoardingAlert(session, snapshot)
maybeSendBoardingCheckPrompt(session, snapshot)
return
}
+ cancelBoardingCheckPrompt()
maybeSendDestinationAlert(session, snapshot)
maybePauseAfterArrivalGrace()
}
@@ -1999,49 +1704,30 @@ class RouteTripMonitorService : Service() {
)
}
- private fun maybeExpireBoardingCheckPrompt() {
- if (!boardingCheckPromptSent || boardingCheckPromptSentAtMs == 0L) {
- return
- }
- val now = System.currentTimeMillis()
- if (now - boardingCheckPromptSentAtMs < BOARDING_CHECK_PROMPT_MAX_AGE_MS) {
- return
- }
- if (!boardingWindowOpen) {
- boardingCheckPromptSent = false
- boardingCheckPromptSentAtMs = 0L
- boardingAlertSent = false
- notificationManager.cancel(ALERT_NOTIFICATION_ID)
- }
- }
-
private fun maybeSendBoardingCheckPrompt(
session: TrackingSession,
snapshot: TrackingSnapshot,
) {
- if (!session.backgroundLocationAlwaysGranted || snapshot.hasBoarded) {
- return
- }
- if (boardingCheckPromptSent) {
- return
- }
- if (!snapshot.boardingPromptEligible) {
+ val ride = currentRideSnapshot()
+ if (!ride.boardingPromptDue || ride.boardingCheckId == 0L) {
+ // The question was answered, retired or overtaken by a detection.
+ if (snapshot.hasBoarded || ride.boardingCheckId == 0L) {
+ cancelBoardingCheckPrompt()
+ }
return
}
- val now = System.currentTimeMillis()
- if (now < boardingCheckSnoozeUntilMs) {
+ if (boardingCheckNotificationId == ride.boardingCheckId) {
return
}
val boardingName = snapshot.boardingName ?: return
- boardingCheckPromptSent = true
- boardingCheckPromptSentAtMs = System.currentTimeMillis()
+ boardingCheckNotificationId = ride.boardingCheckId
notificationManager.notify(
- ALERT_NOTIFICATION_ID,
+ BOARDING_CHECK_NOTIFICATION_ID,
NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_status_bus)
.setContentTitle("你有上車嗎?")
- .setContentText("$boardingName 的那台車已經過站了。")
+ .setContentText("$boardingName 的公車剛剛離站了。")
.setSubText(session.pathName)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
@@ -2052,20 +1738,25 @@ class RouteTripMonitorService : Service() {
NotificationCompat.Action.Builder(
0,
"我已上車",
- createMarkBoardedPendingIntent(),
+ createMarkBoardedPendingIntent(ride.boardingCheckId),
).build(),
)
.addAction(
NotificationCompat.Action.Builder(
0,
"還沒上車",
- createNotBoardedPendingIntent(),
+ createNotBoardedPendingIntent(ride.boardingCheckId),
).build(),
)
.build(),
)
}
+ private fun cancelBoardingCheckPrompt() {
+ boardingCheckNotificationId = 0L
+ notificationManager.cancel(BOARDING_CHECK_NOTIFICATION_ID)
+ }
+
private fun maybeSendDestinationSetupPrompt(
session: TrackingSession,
snapshot: TrackingSnapshot,
@@ -2096,6 +1787,12 @@ class RouteTripMonitorService : Service() {
)
}
+ /**
+ * Two heads-up alerts. "Stops remaining" counts the destination itself, so
+ * one remaining means the next stop is the user's — that, rather than a
+ * fixed distance, is what should prompt them to get ready, because a
+ * long segment can easily run past a couple of hundred metres.
+ */
private fun maybeSendDestinationAlert(
session: TrackingSession,
snapshot: TrackingSnapshot,
@@ -2104,6 +1801,7 @@ class RouteTripMonitorService : Service() {
val remainingStops = snapshot.remainingStops ?: return
val distanceMeters = snapshot.destinationDistanceMeters ?: return
val now = System.currentTimeMillis()
+ val nextStopIsDestination = remainingStops <= 1
if (remainingStops <= 2 && destinationAlertStage < 1) {
destinationAlertStage = 1
@@ -2136,9 +1834,10 @@ class RouteTripMonitorService : Service() {
)
}
- if ((remainingStops == 0 || distanceMeters <= 120.0) && destinationAlertStage < 2) {
+ if ((nextStopIsDestination || distanceMeters <= DESTINATION_ARRIVED_DISTANCE_METERS) &&
+ destinationAlertStage < 2
+ ) {
destinationAlertStage = 2
- arrivalDetectedAtMs = now
notificationManager.notify(
ALERT_NOTIFICATION_ID,
NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
@@ -2166,7 +1865,13 @@ class RouteTripMonitorService : Service() {
.setContentIntent(createOpenRoutePendingIntent(session))
.build(),
)
- } else if (destinationAlertStage >= 2 && arrivalDetectedAtMs == 0L) {
+ }
+
+ // Arrival — not "the next stop is yours" — is what starts the countdown
+ // to the auto-pause and the overshoot check.
+ if (arrivalDetectedAtMs == 0L &&
+ (remainingStops == 0 || distanceMeters <= DESTINATION_ARRIVED_DISTANCE_METERS)
+ ) {
arrivalDetectedAtMs = now
}
}
@@ -2187,10 +1892,9 @@ class RouteTripMonitorService : Service() {
val destinationName = snapshot.destinationName ?: return false
val passedByStops = snapshot.passedDestinationByStops ?: return false
val destinationDistanceMeters = snapshot.destinationDistanceMeters ?: return false
- val nearestStopDistanceMeters = snapshot.nearestStopDistanceMeters ?: return false
if (
destinationDistanceMeters <= DESTINATION_OVERSHOOT_DISTANCE_METERS ||
- nearestStopDistanceMeters > ROUTE_PROXIMITY_MAX_DISTANCE_METERS
+ !snapshot.hasRouteValidProgress
) {
return false
}
@@ -2273,7 +1977,11 @@ class RouteTripMonitorService : Service() {
provider = session.provider,
routeKey = session.routeKey,
pathId = session.pathId,
- stopId = session.destinationStopId ?: session.stops.firstOrNull()?.stopId ?: 0,
+ // The full-width id, because Dart compares it against the ids its
+ // own stop models carry: a folded 高雄 id matches nothing there, so
+ // tapping the notification would not select the stop.
+ stopId = session.destinationLaunchStopId
+ ?: session.stops.firstOrNull()?.launchStopId?.takeIf { it > 0L },
)
return PendingIntent.getActivity(
this,
@@ -2295,29 +2003,42 @@ class RouteTripMonitorService : Service() {
)
}
- private fun createMarkBoardedPendingIntent(): PendingIntent {
+ /**
+ * "I have boarded". [answeredCheckId] is set only on the buttons of the
+ * boarding question, which answer that question; the same words on the
+ * tracking notification are the rider speaking unprompted, and must not be
+ * read as an answer to a question they may not have been shown.
+ */
+ private fun createMarkBoardedPendingIntent(answeredCheckId: Long = 0L): PendingIntent =
+ createBoardingAnswerPendingIntent(ACTION_MARK_BOARDED, 405, 407, answeredCheckId)
+
+ private fun createNotBoardedPendingIntent(answeredCheckId: Long = 0L): PendingIntent =
+ createBoardingAnswerPendingIntent(ACTION_NOT_BOARDED, 406, 408, answeredCheckId)
+
+ private fun createBoardingAnswerPendingIntent(
+ action: String,
+ unpromptedRequestCode: Int,
+ answerRequestCode: Int,
+ answeredCheckId: Long,
+ ): PendingIntent {
val intent = Intent(this, RouteTripMonitorService::class.java).apply {
- action = ACTION_MARK_BOARDED
+ this.action = action
+ if (answeredCheckId != 0L) {
+ putExtra(EXTRA_BOARDING_CHECK_ID, answeredCheckId)
+ }
}
+ // Extras do not tell two PendingIntents apart, so the answer gets a
+ // request code of its own rather than rewriting the unprompted button.
return PendingIntent.getService(
this,
- 405,
+ if (answeredCheckId != 0L) answerRequestCode else unpromptedRequestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
- private fun createNotBoardedPendingIntent(): PendingIntent {
- val intent = Intent(this, RouteTripMonitorService::class.java).apply {
- action = ACTION_NOT_BOARDED
- }
- return PendingIntent.getService(
- this,
- 406,
- intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
- }
+ private fun answeredBoardingCheckId(intent: Intent): Long? =
+ intent.getLongExtra(EXTRA_BOARDING_CHECK_ID, 0L).takeIf { it != 0L }
override fun onTaskRemoved(rootIntent: Intent?) {
AppRuntimeStateStore.setAppInForeground(this, false)
@@ -2342,39 +2063,25 @@ class RouteTripMonitorService : Service() {
// involve other processes (location and notification services).
stopSelf()
foregroundStarted = false
- session = null
latestLocation = null
- lastMovementLocation = null
- lastMovementNearestIndex = null
- lastMovementRecordedAtMs = 0L
- lastTransitLikeAtMs = 0L
boardingAlertSent = false
- boardingCheckPromptSent = false
- boardingCheckPromptSentAtMs = 0L
- boardingWindowOpen = false
- boardingWindowOpenedAtMs = 0L
- boardingCheckSnoozeUntilMs = 0L
- rideConfirmed = false
- rideConfirmationSamples = 0
- lastNearestStopIndex = null
- lastBusStopIndex = null
- activeBoardingVehicleId = null
- activeBoardingVehicleStopIndex = null
- activeBoardingVehicleLastSeenAtMs = 0L
- activeBoardingVehiclePassedAtMs = 0L
- trackedBusId = null
- trackedBusLastSeenAtMs = 0L
destinationSetupPromptSent = false
arrivalDetectedAtMs = 0L
destinationAlertStage = 0
overshootAlertSent = false
- refreshInFlight = false
- refreshPending = false
- lastRefreshStartedAtMs = 0L
- cachedLiveRouteIds = emptyList()
- cachedLivePathId = null
- cachedLiveFetchedAtMs = 0L
- cachedLiveStops = emptyMap()
+ synchronized(detectorLock) {
+ session = null
+ detector = null
+ latestRide = RideSnapshot()
+ }
+ synchronized(refreshLock) {
+ refreshInFlight = false
+ refreshPending = false
+ lastRefreshStartedAtMs = 0L
+ }
+ forgetLiveFeeds()
+ cancelBoardingCheckPrompt()
+ releaseWakeLock()
mainHandler.removeCallbacksAndMessages(null)
runCatching {
if (::fusedLocationClient.isInitialized) {
@@ -2393,49 +2100,92 @@ class RouteTripMonitorService : Service() {
}
}
+ /** Forgets what was fetched and when, which makes both feeds due at once. */
+ private fun forgetLiveFeeds() = synchronized(refreshLock) {
+ cachedLiveRouteIds = emptyList()
+ cachedLivePathId = null
+ cachedLiveFetchedAtMs = 0L
+ cachedLiveStops = emptyMap()
+ busFeedRefreshByRoute.clear()
+ liveStopBackoffUntilMs = 0L
+ }
+
+ /**
+ * The cached stop feed. Fetching happens in [updateLiveData] so that the
+ * snapshot builder never blocks on the network and both feeds are
+ * throttled together.
+ */
private fun fetchLiveStopMap(session: TrackingSession): Map {
- val routeIds = session.realtimeRouteIds
- if (routeIds.isEmpty()) {
+ if (session.realtimeRouteIds.isEmpty()) {
return emptyMap()
}
- val now = SystemClock.elapsedRealtime()
- synchronized(refreshLock) {
- if (
- appInForeground &&
- cachedLiveRouteIds == routeIds &&
- cachedLivePathId == session.pathId
- ) {
- return cachedLiveStops
- }
- if (
- cachedLiveRouteIds == routeIds &&
- cachedLivePathId == session.pathId &&
- cachedLiveFetchedAtMs != 0L &&
- now - cachedLiveFetchedAtMs < LIVE_STOP_CACHE_TTL_MS
- ) {
- return cachedLiveStops
+ return synchronized(refreshLock) {
+ if (cachedLiveRouteIds == session.realtimeRouteIds && cachedLivePathId == session.pathId) {
+ cachedLiveStops
+ } else {
+ emptyMap()
}
}
- if (appInForeground) {
+ }
+
+ /** The live feed for the route and its variants; empty when fetching failed. */
+ private fun fetchLiveStopMapFromBackend(
+ session: TrackingSession,
+ ): Map {
+ if (session.realtimeRouteIds.isEmpty()) {
return emptyMap()
}
- val liveStops = familyRealtime.fetch(session)
- synchronized(refreshLock) {
- if (this.session === session) {
- cachedLiveRouteIds = routeIds
- cachedLivePathId = session.pathId
- cachedLiveFetchedAtMs = now
- cachedLiveStops = liveStops
+ // The family fetch covers the route and its variants; a single-route
+ // session is just the one-id case of it.
+ return familyRealtime.fetch(session)
+ }
+
+ /** Live positions of every vehicle on the route; null when the request failed. */
+ private fun fetchBusReportsFromBackend(routeId: String): List? {
+ val encodedRouteId = URLEncoder.encode(routeId, Charsets.UTF_8.name())
+ val array = fetchBackendJsonArray("/api/v1/routes/$encodedRouteId/realtime/buses")
+ ?: return null
+ val receivedAtMs = System.currentTimeMillis()
+ return runCatching { parseBusReports(array, receivedAtMs) }.getOrNull()
+ }
+
+ private fun parseBusReports(
+ array: JSONArray,
+ receivedAtMs: Long,
+ ): List {
+ val reports = ArrayList(array.length())
+ for (index in 0 until array.length()) {
+ val bus = array.optJSONObject(index) ?: continue
+ val vehicleId = normalizeVehicleId(bus.opt("id")?.toString())
+ ?: normalizeVehicleId(bus.opt("plate")?.toString())
+ ?: continue
+ val latitude = bus.optDouble("lat", Double.NaN)
+ val longitude = bus.optDouble("lon", Double.NaN)
+ if (latitude.isNaN() || longitude.isNaN()) {
+ continue
}
+ // GPSTime is unix seconds; a missing one makes the report unusable
+ // because there is nothing to line the user's track up against.
+ val gpsTimeMs = (toIntOrNull(bus.opt("time"))?.toLong() ?: continue) * 1000L
+ reports += RideBusReport(
+ vehicleId = vehicleId,
+ pathId = toIntOrNull(bus.opt("direction")),
+ latitude = latitude,
+ longitude = longitude,
+ gpsTimeMs = gpsTimeMs,
+ receivedAtMs = receivedAtMs,
+ )
}
- return liveStops
+ return reports
}
- private fun fetchBackendJson(path: String): JSONObject? {
+ /** Raw body of a backend GET; null when the request failed. Replaced in tests. */
+ internal var fetchBackendJsonText: (String) -> String? = ::httpGetBackend
+
+ private fun httpGetBackend(path: String): String? {
var connection: HttpURLConnection? = null
return try {
- connection = URL("https://bus.avianjay.sbs/api/v1/$path")
- .openConnection() as HttpURLConnection
+ connection = URL("$BACKEND_BASE_URL$path").openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.requestMethod = "GET"
@@ -2446,10 +2196,9 @@ class RouteTripMonitorService : Service() {
if (connection.responseCode !in 200..299) {
return null
}
- val jsonText = connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
+ connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
reader.readText()
}
- JSONObject(jsonText)
} catch (_: Exception) {
null
} finally {
@@ -2457,66 +2206,24 @@ class RouteTripMonitorService : Service() {
}
}
- private fun formatEtaText(liveStopState: LiveStopState?): String {
- liveStopState ?: return "--"
- val message = normalizeEtaMessage(liveStopState.message)
- if (message != null) {
- return message
- }
-
- val seconds = liveStopState.seconds ?: return "--"
- if (seconds <= 0) {
- return "進站中"
- }
- if (seconds < 60) {
- return "即將進站"
- }
- return "${seconds / 60} 分"
- }
-
- private fun formatShortEtaText(liveStopState: LiveStopState?): String {
- liveStopState ?: return "--"
- val message = liveStopState.message?.trim().orEmpty()
- if (message.isNotEmpty()) {
- return when {
- message.contains("進站") || message.contains("到站") -> "進站"
- message.contains("即將") -> "即將"
- message.contains("未發車") -> "未發"
- message.contains("末班") -> "末班"
- else -> message.take(6)
- }
- }
-
- val seconds = liveStopState.seconds ?: return "--"
- if (seconds <= 0) {
- return "進站"
- }
- if (seconds < 60) {
- return "<1分"
+ /** A JSON object response, addressed relative to `/api/v1/`. */
+ private fun fetchBackendJson(path: String): JSONObject? {
+ return fetchBackendJsonText("/api/v1/$path")?.let { text ->
+ runCatching { JSONObject(text) }.getOrNull()
}
- return "${seconds / 60}分"
}
- private fun normalizeEtaMessage(message: String?): String? {
- val trimmed = message?.trim().orEmpty()
- if (trimmed.isEmpty()) {
- return null
- }
-
- return when {
- trimmed.contains("進站") || trimmed.contains("到站") -> "進站中"
- trimmed.contains("即將") -> "即將進站"
- trimmed.contains("未發車") -> "未發車"
- trimmed.contains("末班") -> "末班已過"
- else -> trimmed
+ /** A JSON array response; `/realtime/buses` returns one. */
+ private fun fetchBackendJsonArray(path: String): JSONArray? {
+ return fetchBackendJsonText(path)?.let { text ->
+ runCatching { JSONArray(text) }.getOrNull()
}
}
- private fun buildBusDistanceText(busStopsAway: Int?): String? {
- return when (busStopsAway) {
- null -> null
- 0 -> "公車就在附近"
- else -> "公車還有 $busStopsAway 站"
+ private fun toIntOrNull(raw: Any?): Int? {
+ return when (raw) {
+ is Number -> raw.toInt()
+ else -> raw?.toString()?.trim()?.toIntOrNull()
}
}
@@ -2548,67 +2255,7 @@ class RouteTripMonitorService : Service() {
return trimmed.take(7)
}
- private fun buildLegacyShortCriticalText(stopsAway: Int?, etaText: String): String? {
- if (stopsAway == null && etaText == "--") {
- return null
- }
-
- if (stopsAway == 0 && etaText != "--") {
- return etaText.take(7)
- }
-
- val left = when (stopsAway) {
- null -> null
- 0 -> "到站"
- else -> "${stopsAway}站"
- }
- if (left == null) {
- return etaText
- }
- if (etaText == "--") {
- return left
- }
- return "$left | $etaText"
- }
-
- private fun findClosestBusIndex(
- stops: List,
- liveStops: Map,
- nearestIndex: Int,
- ): Int? {
- val busIndexes = stops.mapIndexedNotNull { index, stop ->
- val liveStop = liveStops[stop.stopId] ?: return@mapIndexedNotNull null
- if (isBusApproachingStop(liveStop)) {
- index
- } else {
- null
- }
- }
- val behindOrAtUser = busIndexes.filter { it <= nearestIndex }
- if (behindOrAtUser.isNotEmpty()) {
- return behindOrAtUser.maxOrNull()
- }
- return busIndexes.minOrNull()
- }
-
- private fun findClosestBusIndexForBoarding(
- stops: List,
- liveStops: Map,
- boardingIndex: Int,
- ): Int? {
- val busIndexes = stops.mapIndexedNotNull { index, stop ->
- val liveStop = liveStops[stop.stopId] ?: return@mapIndexedNotNull null
- if (isBusApproachingStop(liveStop)) {
- index
- } else {
- null
- }
- }
- return busIndexes
- .filter { it <= boardingIndex }
- .maxOrNull()
- }
-
+ /** Index of the stop where the live feed last saw [preferredVehicleId]. */
private fun findTrackedBusIndex(
stops: List,
liveStops: Map,
@@ -2616,21 +2263,17 @@ class RouteTripMonitorService : Service() {
): Int? {
val normalizedVehicleId = normalizeVehicleId(preferredVehicleId) ?: return null
return stops.indexOfFirst { stop ->
- isVehicleSeenAtStop(
- stopState = liveStops[stop.stopId],
- normalizedVehicleId = normalizedVehicleId,
- )
+ val stopState = liveStops[stop.stopId]
+ stopState != null &&
+ (
+ stopState.vehicleIds.any { normalizeVehicleId(it) == normalizedVehicleId } ||
+ stopState.etaEntries.any {
+ normalizeVehicleId(it.vehicleId) == normalizedVehicleId
+ }
+ )
}.takeIf { it >= 0 }
}
- private fun isBusApproachingStop(liveStop: LiveStopState): Boolean {
- val message = liveStop.message?.trim().orEmpty()
- return liveStop.vehicleIds.isNotEmpty() ||
- (liveStop.seconds != null && liveStop.seconds <= 0) ||
- message.contains("進站") ||
- message.contains("到站")
- }
-
private fun distanceMeters(
lat1: Double,
lon1: Double,
@@ -2678,44 +2321,41 @@ class RouteTripMonitorService : Service() {
private const val EXTRA_SESSION_JSON = "session_json"
private const val EXTRA_APP_IN_FOREGROUND = "app_in_foreground"
private const val EXTRA_PAUSE_REASON = "pause_reason"
+ private const val EXTRA_BOARDING_CHECK_ID = "boarding_check_id"
private const val LEGACY_TRACKING_CHANNEL_ID = "trip_monitor_tracking"
private const val TRACKING_CHANNEL_ID = "trip_monitor_tracking_v2"
private const val ALERT_CHANNEL_ID = "trip_monitor_alerts"
private const val TRACKING_NOTIFICATION_ID = 6021
private const val ALERT_NOTIFICATION_ID = 6022
+ private const val BOARDING_CHECK_NOTIFICATION_ID = 6023
+ private const val BACKEND_BASE_URL = "https://bus.avianjay.sbs"
private const val POLL_INTERVAL_MS = 15_000L
+ private const val LIVE_STOP_REFRESH_INTERVAL_MS = 20_000L
+ private const val LIVE_BUS_REFRESH_INTERVAL_MS = 15_000L
+ private const val LIVE_FETCH_BACKOFF_MS = 60_000L
private const val MIN_REFRESH_INTERVAL_MS = 2_000L
- private const val LIVE_STOP_CACHE_TTL_MS = 2_000L
private const val LOCATION_UPDATE_INTERVAL_MS = 12_000L
private const val LOCATION_MIN_UPDATE_INTERVAL_MS = 6_000L
- private const val BOARDING_STOP_RADIUS_METERS = 180.0
- private const val BOARDING_CONFIRM_DISTANCE_METERS = 250.0
- private const val BOARDING_ARRIVAL_MAX_ETA_SECONDS = 180
- private const val BOARDING_NO_LOCATION_MAX_ETA_SECONDS = 40
- private const val BOARDING_CHECK_PROMPT_MAX_ETA_SECONDS = 180
- private const val REQUIRED_RIDE_CONFIRMATION_SAMPLES = 2
private const val BACKGROUND_AUTO_PAUSE_GRACE_MS = 90_000L
- private const val BOARDING_CHECK_PROMPT_DELAY_MS = 45_000L
- private const val BOARDING_CHECK_SNOOZE_MS = 180_000L
- private const val BOARDING_CHECK_AFTER_PASS_DELAY_MS = 20_000L
- private const val ACTIVE_BOARDING_VEHICLE_STALE_MS = 90_000L
- private const val TRACKED_BUS_STALE_MS = 120_000L
- private const val BOARDING_WINDOW_MAX_DURATION_MS = 300_000L
- private const val BOARDING_CHECK_PROMPT_MAX_AGE_MS = 300_000L
- private const val USER_NEARBY_STOP_COUNT = 2
- private const val USER_MOVEMENT_MIN_DISTANCE_METERS = 35.0
- private const val USER_ROUTE_PROGRESS_MIN_DISTANCE_METERS = 45.0
- private const val USER_TRANSIT_LIKE_SPEED_MPS = 4.2
- private const val MAX_USABLE_LOCATION_ACCURACY_METERS = 100f
- private const val TRANSIT_LIKE_STICKY_MS = 90_000L
+
+ /**
+ * How long after pausing for a destination the same ride may still be
+ * picked up. A city ride is rarely longer; past this the rider is
+ * re-detected from their movement like anyone else.
+ */
+ private const val BOARDED_RIDE_RESUME_WINDOW_MS = 60 * 60 * 1000L
+ private const val WAKE_LOCK_TAG = "YABus::TripMonitor"
+ // The lock is renewed on every start, so this only bounds a session that
+ // was never cleaned up; it is not the expected trip length.
+ private const val WAKE_LOCK_TIMEOUT_MS = 6 * 60 * 60 * 1000L
private const val PROGRESS_POINT_UNIT = 100
private const val PROGRESS_POINT_COLOR = 0x80000000.toInt()
private const val OVERSHOOT_CONFIRM_DELAY_MS = 45_000L
private const val ARRIVAL_AUTO_PAUSE_DELAY_MS = 60_000L
- private const val ROUTE_PROXIMITY_MAX_DISTANCE_METERS = 100.0
private const val DESTINATION_OVERSHOOT_DISTANCE_METERS = 100.0
+ private const val DESTINATION_ARRIVED_DISTANCE_METERS = 120.0
private const val LIVE_UPDATE_SDK_INT = 36
private const val PAUSE_REASON_USER = "user"
private const val PAUSE_REASON_ARRIVED = "arrived"
@@ -2732,8 +2372,16 @@ class RouteTripMonitorService : Service() {
val stops = mutableListOf()
for (index in 0 until stopsJson.length()) {
val stop = stopsJson.optJSONObject(index) ?: continue
+ // The id as Dart holds it, which is 64-bit for 高雄. It is
+ // needed twice, as two different numbers: folded, to key
+ // the live feeds; and at full width, for anything a
+ // notification tap carries back into Flutter.
+ val dartStopId = stop.opt("stopId")
stops += TrackingStop(
- stopId = stop.optInt("stopId", 0),
+ // Must match how the live feed folds the same id, or the
+ // two maps never line up for 64-bit ids (高雄).
+ stopId = foldStopId(dartStopId),
+ launchStopId = launchStopId(dartStopId),
rawStopId = stop.optString("rawStopId", "")
.trim().takeIf { it.isNotEmpty() && it != "null" },
stopName = stop.optString("stopName", ""),
@@ -2767,14 +2415,20 @@ class RouteTripMonitorService : Service() {
.takeUnless { it.isNaN() },
initialLongitude = root.optDouble("initialLongitude", Double.NaN)
.takeUnless { it.isNaN() },
- boardingStopId = root.optInt("boardingStopId", -1)
+ initialLocationTimestampMs = root.optLong("initialLocationTimestampMs", 0L)
+ .takeIf { it > 0L },
+ boardingStopId = foldStopId(root.opt("boardingStopId"))
.takeIf { it > 0 },
boardingStopName = root.optString("boardingStopName", "")
.takeIf { it.isNotBlank() },
- destinationStopId = root.optInt("destinationStopId", -1)
+ destinationStopId = foldStopId(root.opt("destinationStopId"))
.takeIf { it > 0 },
destinationStopName = root.optString("destinationStopName", "")
.takeIf { it.isNotBlank() },
+ boardingLaunchStopId = launchStopId(root.opt("boardingStopId"))
+ .takeIf { it > 0L },
+ destinationLaunchStopId = launchStopId(root.opt("destinationStopId"))
+ .takeIf { it > 0L },
stops = stops.sortedBy { stop -> stop.sequence },
)
} catch (_: Exception) {
@@ -2864,6 +2518,11 @@ data class TrackingSession(
val destinationStopName: String?,
val stops: List,
val familyRouteIds: List = emptyList(),
+ /** Full-width ids as Flutter holds them; only for launch payloads. */
+ val boardingLaunchStopId: Long? = null,
+ val destinationLaunchStopId: Long? = null,
+ /** Epoch milliseconds of the fix behind the initial position, when known. */
+ val initialLocationTimestampMs: Long? = null,
) {
val realtimeRouteIds: List
get() = (listOfNotNull(routeId) + familyRouteIds).map { it.trim() }
@@ -2878,13 +2537,18 @@ data class TrackingStop(
val lon: Double,
val rawStopId: String? = null,
val liveData: LiveStopState? = null,
+ /** Full-width id as Flutter holds it; only for launch payloads. */
+ val launchStopId: Long = 0L,
)
data class LiveStopState(
val seconds: Int?,
val message: String?,
+ /** Every vehicle the stop's feed names, including buses still on their way. */
val vehicleIds: List,
val etaEntries: List = emptyList(),
+ /** Only the vehicles the feed places at this stop right now. */
+ val busesAtStop: List = emptyList(),
)
data class LiveEtaEntry(
@@ -2914,6 +2578,7 @@ data class TrackingSnapshot(
val destinationDistanceMeters: Double? = null,
val passedDestinationByStops: Int? = null,
val nearestStopDistanceMeters: Double? = null,
+ val hasRouteValidProgress: Boolean = false,
)
data class VehicleCandidate(
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt
index db3b5e7..f9bf43c 100644
--- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt
@@ -369,6 +369,7 @@ internal object SmartRouteNotificationSupport {
bestStop = SmartNearestStop(
pathId = stop.pathId,
stopId = stop.stopId,
+ launchStopId = stop.launchStopId,
stopName = stop.stopName,
distanceMeters = distance,
)
@@ -452,7 +453,9 @@ internal object SmartRouteNotificationSupport {
provider = profile.provider,
routeKey = profile.routeKey,
pathId = nearestStop.pathId,
- stopId = nearestStop.stopId,
+ // Full-width: Flutter matches this against its own stop ids, and
+ // the folded key would select nothing there.
+ stopId = nearestStop.launchStopId,
),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
@@ -620,9 +623,15 @@ internal object SmartRouteNotificationSupport {
arrayOf(routeKey.toString()),
).use { cursor ->
while (cursor.moveToNext()) {
+ // stop_id is a TEXT column and 高雄 ids exceed Int. It is needed
+ // twice, as two different numbers: folded, to key the live
+ // feed; and at full width, for the notification tap that goes
+ // back into Flutter, whose stop models hold the unfolded id.
+ val storedStopId = cursor.getString(1)
result += SmartRouteStop(
pathId = cursor.getInt(0),
- stopId = cursor.getInt(1),
+ stopId = foldStopId(storedStopId),
+ launchStopId = launchStopId(storedStopId),
stopName = cursor.getString(2).orEmpty(),
sequence = cursor.getInt(3),
lon = cursor.getDouble(4),
@@ -678,21 +687,7 @@ internal object SmartRouteNotificationSupport {
return result
}
- private fun parseStopId(raw: Any?): Int {
- if (raw is Number) {
- return raw.toInt()
- }
- val text = raw?.toString()?.trim().orEmpty()
- val parsed = text.toIntOrNull()
- if (parsed != null) {
- return parsed
- }
- var hash = 17
- for (char in text) {
- hash = (hash * 31 + char.code) and 0x7fffffff
- }
- return hash
- }
+ private fun parseStopId(raw: Any?): Int = foldStopId(raw)
private fun toIntOrNull(raw: Any?): Int? {
return when (raw) {
@@ -801,7 +796,10 @@ internal data class SmartRouteData(
internal data class SmartRouteStop(
val pathId: Int,
+ /** Folded, as the live feeds are: only usable for looking up ETAs. */
val stopId: Int,
+ /** Full-width, as Flutter holds it: what a notification tap may carry. */
+ val launchStopId: Long,
val stopName: String,
val sequence: Int,
val lon: Double,
@@ -811,6 +809,7 @@ internal data class SmartRouteStop(
internal data class SmartNearestStop(
val pathId: Int,
val stopId: Int,
+ val launchStopId: Long,
val stopName: String,
val distanceMeters: Double,
)
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt
new file mode 100644
index 0000000..4c3d031
--- /dev/null
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt
@@ -0,0 +1,70 @@
+package tw.avianjay.taiwanbus.flutter
+
+/**
+ * Folds a stop id from any source into the single positive `Int` the app keys
+ * stops by.
+ *
+ * Stop ids are 64-bit in the feeds (高雄's look like `100100128221`) but every
+ * stop map in the app is keyed by `Int`, and the two sides of that key used to
+ * disagree: the session payload was read with `optInt`, which wraps to the low
+ * 32 bits, while the live feed fell back to hashing the id as text. The two
+ * values can never be equal, so a 高雄 stop's live ETA was unreachable — and
+ * ids that wrapped negative were dropped by the `> 0` filters as well.
+ *
+ * Both sides call this now. Values that already fit in 31 bits pass through
+ * untouched, so 台中 and 雙北 ids are unaffected, and the fold is idempotent,
+ * so a value that has been through it stays put. It never returns a negative
+ * number, which is what makes the `> 0` filters safe. Real ids do not collide
+ * under it, and the hash fallback still covers the non-numeric ids some
+ * providers use (`101-11009`).
+ *
+ * Returns 0 for a missing or blank id so callers can keep filtering on `> 0`.
+ */
+internal fun foldStopId(raw: Any?): Int {
+ val text = when (raw) {
+ // JSON numbers can arrive as any boxed type; going through Long keeps
+ // an integral id from being mangled into exponent notation.
+ is Number -> raw.toLong().toString()
+ else -> raw?.toString()?.trim().orEmpty()
+ }
+ // org.json hands back JSONObject.NULL rather than null for absent fields,
+ // and hashing its text would invent an id out of nothing.
+ if (text.isEmpty() || text.equals("null", ignoreCase = true)) {
+ return 0
+ }
+ text.toLongOrNull()?.let { value ->
+ if (value >= 0L) {
+ return (value and 0x7FFFFFFFL).toInt()
+ }
+ }
+ var hash = 17
+ for (char in text) {
+ hash = (hash * 31 + char.code) and 0x7fffffff
+ }
+ return hash
+}
+
+/**
+ * The stop id as Flutter holds it, for launch payloads (widget taps, notification
+ * taps, pinned shortcuts).
+ *
+ * Dart keys stops by the id it read from the feed: numeric ids stay 64-bit
+ * (高雄's `100100128221` would not fit in an `Int`), non-numeric ones hash. The
+ * native fold is only for joining the live feeds and must not be sent to Dart —
+ * a folded id names no stop the Flutter side knows, so the tap would silently
+ * open the wrong stop or none at all. This returns the value Dart itself would
+ * have computed for the same raw id.
+ */
+internal fun launchStopId(raw: Any?): Long {
+ val text = when (raw) {
+ is Number -> raw.toLong().toString()
+ else -> raw?.toString()?.trim().orEmpty()
+ }
+ if (text.isEmpty() || text.equals("null", ignoreCase = true)) {
+ return 0L
+ }
+ text.toLongOrNull()?.let { return it }
+ // Non-numeric ids hash on both sides, and both hashes start at 17, so the
+ // folded value already is what Dart computed.
+ return foldStopId(raw).toLong()
+}
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt
index d4e0a70..ef86fbd 100644
--- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt
@@ -116,6 +116,7 @@ internal fun parseTripMonitorLiveStop(stop: JSONObject): LiveStopState {
message = text(stop, "message"),
vehicleIds = (buses + etas.mapNotNull { it.vehicleId }).distinct(),
etaEntries = etas,
+ busesAtStop = buses,
)
}
@@ -131,6 +132,7 @@ private fun mergeStops(stops: List): LiveStopState {
message = if (earliest == null) stops.firstNotNullOfOrNull { it.message } else null,
vehicleIds = (entries.mapNotNull { it.vehicleId } + stops.flatMap { it.vehicleIds }).distinct(),
etaEntries = entries,
+ busesAtStop = stops.flatMap { it.busesAtStop }.distinct(),
)
}
@@ -139,15 +141,7 @@ private fun JSONArray?.objects(): List =
private fun integer(raw: Any?): Int? = if (raw is Number) raw.toInt() else raw?.toString()?.trim()?.toIntOrNull()
-internal fun stopId(raw: Any?): Int {
- if (raw is Number) return raw.toInt()
- val value = raw?.takeUnless { it == JSONObject.NULL }?.toString()?.trim().orEmpty()
- if (value.isEmpty()) return 0
- value.toIntOrNull()?.let { return it }
- var hash = 17
- for (char in value) hash = (hash * 31 + char.code) and 0x7fffffff
- return hash
-}
+internal fun stopId(raw: Any?): Int = foldStopId(raw)
private fun text(json: JSONObject, key: String): String? = json.opt(key)
?.takeUnless { it == JSONObject.NULL }?.toString()?.trim()
diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt
new file mode 100644
index 0000000..d67c2b9
--- /dev/null
+++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt
@@ -0,0 +1,1190 @@
+package tw.avianjay.taiwanbus.flutter
+
+import kotlin.math.abs
+
+/**
+ * One user position fix. Deliberately free of android.location types so the
+ * detection logic can be replayed against recorded feeds in plain JVM tests.
+ */
+internal data class RideGeoPoint(
+ val latitude: Double,
+ val longitude: Double,
+ val timestampMs: Long,
+ val accuracyMeters: Float = 0f,
+ /** Doppler speed from the GPS chip, when the fix has one. */
+ val speedMetersPerSecond: Float? = null,
+)
+
+/**
+ * One vehicle position from `/realtime/buses`.
+ *
+ * [gpsTimeMs] is the vehicle's own clock and is what gets compared against the
+ * user's track. It reaches the app 20-40 s late at the median and up to ~200 s
+ * late in the tail, so "is this vehicle still reporting" is judged by
+ * [receivedAtMs] instead.
+ */
+internal data class RideBusReport(
+ val vehicleId: String,
+ val pathId: Int?,
+ val latitude: Double,
+ val longitude: Double,
+ val gpsTimeMs: Long,
+ val receivedAtMs: Long,
+)
+
+internal enum class BoardingEvidence {
+ NONE,
+
+ /** The user covered the same stretch of route as a live vehicle, at the same time. */
+ BUS_CO_MOVEMENT,
+
+ /** The user's own track left the boarding stop along the route at vehicle pace. */
+ SELF_PROGRESS,
+
+ /** The user tapped "我已上車". */
+ USER_CONFIRMED,
+}
+
+internal data class RideSnapshot(
+ val hasBoarded: Boolean = false,
+ val evidence: BoardingEvidence = BoardingEvidence.NONE,
+ val trackedVehicleId: String? = null,
+ val hasUsableLocation: Boolean = false,
+ val boardingStopIndex: Int = -1,
+ val boardingStopDistanceMeters: Double? = null,
+ /** Last stop reached by the rider (their own fix, else the tracked vehicle); -1 when unknown. */
+ val progressStopIndex: Int = -1,
+ val nextBusVehicleId: String? = null,
+ val busStopsUntilBoarding: Int? = null,
+ /** Identifies the pending "did you get on?" question; 0 when there is none. */
+ val boardingCheckId: Long = 0L,
+ val boardingPromptDue: Boolean = false,
+ val remainingStops: Int? = null,
+ val passedDestinationByStops: Int? = null,
+ val destinationDistanceMeters: Double? = null,
+ /** Validity of the same fresh phone/bus sample that supplied the progress. */
+ val hasRouteValidProgress: Boolean = false,
+)
+
+/**
+ * Decides whether the user is riding the monitored route.
+ *
+ * Three layers, strongest evidence first:
+ *
+ * A. **Co-movement.** For every live vehicle, look up where the user was at the
+ * instants that vehicle reported its position. Riding together keeps the gap
+ * between the two roughly constant while both advance. This needs no plate
+ * in the ETA feed (雙北 ETAs carry none; `/realtime/buses` always does).
+ *
+ * B. **Self progress.** The user's own track leaving the boarding stop along the
+ * route at vehicle pace. Covers a boarded vehicle with no position feed.
+ *
+ * C. **The user's answer.** Once a bus has left the boarding stop and nothing
+ * shows the user stayed behind, ask. Works with no location at all.
+ *
+ * Everything is measured in metres along the stop sequence. A 2-D proximity
+ * test would be dominated by cross-track error: against the stop-chord line,
+ * bus fixes sit 8-16 m off at the median but 65-295 m at p90.
+ */
+internal class TripRideDetector(
+ stops: List,
+ private val pathId: Int,
+ boardingStopId: Int?,
+ destinationStopId: Int?,
+) {
+ private val stopIds = stops.map { it.stopId }
+ private val line = RideRouteLine(stops)
+
+ private val userTrack = ArrayDeque()
+ private val busTracks = LinkedHashMap>()
+ /** Vehicles last seen short of the boarding stop, with when that report arrived. */
+ private val seenBeforeBoardingStopAt = HashMap()
+
+ private val explicitBoardingIndex = indexOfStop(boardingStopId)
+ private var anchoredBoardingIndex = -1
+ private var destinationIndex = indexOfStop(destinationStopId)
+
+ private var userAlongHint: Double? = null
+ private var boarded = false
+ private var evidence = BoardingEvidence.NONE
+ private var trackedVehicleId: String? = null
+ private var manualConfirmationAtMs: Long? = null
+ private var manualVehicleAmbiguous = false
+
+ /**
+ * Vehicles the rider has answered "not this one" about, each with the moment
+ * of that answer. The answer is about one bus, not about the trip: only the
+ * vehicle it named is set aside, and only for [REJECTED_VEHICLE_TTL_MS], so
+ * the next bus both raises the question and gets detected as normal. See
+ * [declineBoarding]. A later "I have boarded" leaves these standing: it is
+ * said of some other bus, and they are how that bus is told from these.
+ */
+ private val rejectedAt = HashMap()
+ private var lastDeclineAtMs = 0L
+
+ /**
+ * The departures the last "not yet" answered, kept for as long as the other
+ * feed may still report them; and when a detected ride was last turned down.
+ */
+ private val declinedDepartures = ArrayList()
+ private var declinedRideAtMs = 0L
+
+ private var etaArrivingAtMs = 0L
+ private var etaArrivingVehicleId: String? = null
+
+ /**
+ * The pending "did you get on?" question: its id, and every bus that has
+ * left the boarding stop while it has stood. The question says only that a
+ * bus has just left, so it is about all of them.
+ */
+ private var pendingCheckSinceMs = 0L
+ private val pendingDepartures = ArrayList()
+ private var pendingCheckAnchor: UserSample? = null
+
+ private val boardingIndex: Int
+ get() {
+ val index = if (explicitBoardingIndex >= 0) explicitBoardingIndex else anchoredBoardingIndex
+ return if (index >= 0 && destinationIndex >= 0) index.coerceAtMost(destinationIndex) else index
+ }
+
+ /** Choosing a destination mid-ride must not disturb what is already known about the ride. */
+ fun updateDestination(destinationStopId: Int?) {
+ destinationIndex = indexOfStop(destinationStopId)
+ }
+
+ /** Feeds one user fix. Returns false when the fix was rejected as unusable. */
+ fun onUserLocation(point: RideGeoPoint): Boolean {
+ if (point.accuracyMeters > MAX_USABLE_ACCURACY_METERS) {
+ return false
+ }
+ val previous = userTrack.lastOrNull()
+ if (previous != null) {
+ val elapsedSeconds = (point.timestampMs - previous.timestampMs) / 1000.0
+ if (elapsedSeconds <= 0.0) {
+ // The same fix delivered again, or a clock step backwards.
+ return false
+ }
+ val stepMeters = line.metersBetween(
+ previous.latitude,
+ previous.longitude,
+ point.latitude,
+ point.longitude,
+ )
+ // Faster than any bus: a GPS jump, not travel. Keeping it would
+ // corrupt every window that spans it.
+ if (stepMeters / elapsedSeconds > MAX_PLAUSIBLE_SPEED_MPS) {
+ return false
+ }
+ }
+ val projection = line.project(point.latitude, point.longitude, userAlongHint)
+ userAlongHint = projection.alongMeters
+ userTrack += UserSample(
+ latitude = point.latitude,
+ longitude = point.longitude,
+ alongMeters = projection.alongMeters,
+ timestampMs = point.timestampMs,
+ accuracyMeters = point.accuracyMeters,
+ speedMetersPerSecond = point.speedMetersPerSecond,
+ )
+ while (userTrack.size > MAX_USER_SAMPLES) {
+ userTrack.removeFirst()
+ }
+ return true
+ }
+
+ fun onBusReports(reports: List) {
+ reports.forEach { report ->
+ if (report.vehicleId.isBlank() || report.gpsTimeMs <= 0L) {
+ return@forEach
+ }
+ val track = busTracks.getOrPut(report.vehicleId) { ArrayDeque() }
+ val last = track.lastOrNull()
+ // Vehicles report every ~20 s but the feed is polled more often, so
+ // most polls repeat the previous fix.
+ if (last != null && report.gpsTimeMs <= last.gpsTimeMs) {
+ return@forEach
+ }
+ val projection = line.project(report.latitude, report.longitude, last?.alongMeters)
+ track += BusSample(
+ latitude = report.latitude,
+ longitude = report.longitude,
+ alongMeters = projection.alongMeters,
+ gpsTimeMs = report.gpsTimeMs,
+ receivedAtMs = report.receivedAtMs,
+ samePath = report.pathId == null || report.pathId == pathId,
+ // Parked and deadheading vehicles project onto the route from
+ // far away; only vehicles actually on it may announce arrivals.
+ onRoute = projection.crossTrackMeters <= maxOf(
+ ON_ROUTE_MIN_CROSS_TRACK_METERS,
+ ON_ROUTE_SEGMENT_SHARE * line.segmentLengthMeters(projection.segmentIndex),
+ ),
+ )
+ while (track.size > MAX_BUS_SAMPLES) {
+ track.removeFirst()
+ }
+ }
+ }
+
+ /**
+ * Feeds the boarding stop's ETA. An estimate that reaches zero and then
+ * rolls over to the next bus means a vehicle has left, which is the only
+ * departure signal available when that vehicle has no position feed.
+ */
+ fun onBoardingStopEta(
+ etaSeconds: Int?,
+ arriving: Boolean,
+ nowMs: Long,
+ arrivingVehicleId: String? = null,
+ ) {
+ if (arriving || (etaSeconds != null && etaSeconds <= ETA_ARRIVING_SECONDS)) {
+ if (etaArrivingAtMs == 0L || nowMs - etaArrivingAtMs > ETA_ROLLOVER_WINDOW_MS) {
+ etaArrivingVehicleId = null
+ }
+ etaArrivingAtMs = nowMs
+ // Where the stop feed says which bus is standing there, the
+ // rollover that follows is that bus leaving — the one thing that
+ // ties an ETA departure to a vehicle.
+ if (arrivingVehicleId != null) {
+ etaArrivingVehicleId = arrivingVehicleId
+ }
+ return
+ }
+ // Once the estimate reaches zero it either jumps to the next bus or
+ // disappears entirely; both mean the vehicle that was arriving has gone.
+ val rolledOver = etaArrivingAtMs != 0L &&
+ nowMs - etaArrivingAtMs <= ETA_ROLLOVER_WINDOW_MS &&
+ (etaSeconds == null || etaSeconds >= ETA_ROLLOVER_SECONDS)
+ if (rolledOver) {
+ val departed = etaArrivingVehicleId
+ etaArrivingAtMs = 0L
+ etaArrivingVehicleId = null
+ registerDeparture(vehicleId = departed, nowMs = nowMs, byEta = true)
+ }
+ }
+
+ /**
+ * The rider says they are on board.
+ *
+ * [answeredCheckId] is the [RideSnapshot.boardingCheckId] of the question
+ * being answered, and null when nothing was asked: "I have boarded" is
+ * always on offer, and a rider who uses it may never have seen a question —
+ * one can be pending, about a bus that just left, before it is ever shown.
+ *
+ * Either way this confirms the ride, not a plate. A vehicle is named only
+ * when the feeds leave one bus the rider can be on: in answer to the
+ * question, the one bus that left while it stood ([busTheQuestionIsAbout]);
+ * unprompted, the one bus standing at the stop ([identifyBoardingVehicle]).
+ * Following the wrong bus sounds the destination reminders for a journey
+ * the rider is not making, so anything less leaves the vehicle unknown
+ * until the rider's own position picks it out.
+ */
+ fun confirmBoardedByUser(nowMs: Long, answeredCheckId: Long? = null) {
+ noteDepartures(nowMs)
+ val identifiedVehicle = trackedVehicleId.takeIf { boarded }
+ // An answer to a question since replaced by another names nothing.
+ val askedAbout = if (answeredCheckId != null && answeredCheckId == pendingCheckSinceMs) {
+ busTheQuestionIsAbout(nowMs)
+ } else {
+ null
+ }
+ if (!boarded || evidence != BoardingEvidence.USER_CONFIRMED) {
+ // Only unprompted is "I have boarded" read against what is standing
+ // at the stop; an answer names a bus through its question or not
+ // at all.
+ manualConfirmationAtMs = nowMs.takeIf { answeredCheckId == null }
+ // And not even unprompted while a bus that has left is still to be
+ // answered for: the rider may be on that one, and has not said.
+ manualVehicleAmbiguous = departuresTheQuestionCovers(nowMs).isNotEmpty()
+ }
+ boarded = true
+ evidence = BoardingEvidence.USER_CONFIRMED
+ // An already identified vehicle remains the ride even if another bus
+ // is nearer the stop now.
+ trackedVehicleId = identifiedVehicle ?: askedAbout ?: identifyBoardingVehicle(nowMs)
+ clearPendingCheck()
+ }
+
+ /**
+ * Counts the buses that have left the boarding stop according to the
+ * reports received so far. What an answer means depends on that count, and
+ * it must not depend on whether a snapshot happened to be taken between
+ * the last report coming in and the rider answering.
+ */
+ private fun noteDepartures(nowMs: Long) {
+ val boardingStop = boardingIndex
+ if (boardingStop >= 0) {
+ trackDepartures(liveVehicles(nowMs), line.alongOfStop(boardingStop), nowMs)
+ }
+ }
+
+ /** The buses that have left the boarding stop while the pending question has stood. */
+ private fun departuresTheQuestionCovers(nowMs: Long): List =
+ pendingDepartures.filter { nowMs - it.atMs in 0L..BOARDING_CHECK_MAX_AGE_MS }
+
+ /**
+ * The one bus a "yes" to the pending question can mean; null when that
+ * cannot be told.
+ *
+ * The question does not name a bus — riders do not know plates — only says
+ * that one has just left. So the answer names a vehicle when exactly one
+ * has left while the question stood, a feed named it, no rollover of the
+ * ETA is unaccounted for beside it, and no other bus is at the stop for the
+ * rider to have boarded since. Two that
+ * left close together, or one that left as another pulled in, leave "yes"
+ * true of either.
+ */
+ private fun busTheQuestionIsAbout(nowMs: Long): String? {
+ val departure = departuresTheQuestionCovers(nowMs).singleOrNull() ?: return null
+ if (departure.mayBeTwoBuses) {
+ return null
+ }
+ val vehicleId = departure.vehicleId ?: return null
+ val boardingStop = boardingIndex
+ if (boardingStop < 0 || anotherBusIsBoardable(nowMs, line.alongOfStop(boardingStop), vehicleId)) {
+ return null
+ }
+ return vehicleId
+ }
+
+ /**
+ * Takes up a ride the rider confirmed earlier, on a detector built since.
+ *
+ * The confirmation was given some way back along the route, so nothing at
+ * the boarding stop now has anything to do with it: the vehicle stays
+ * unknown until the rider's own position picks it out.
+ */
+ fun resumeConfirmedRide() {
+ boarded = true
+ evidence = BoardingEvidence.USER_CONFIRMED
+ trackedVehicleId = null
+ manualConfirmationAtMs = null
+ manualVehicleAmbiguous = false
+ clearPendingCheck()
+ }
+
+ /**
+ * The user says they are not on the bus the app thinks they are on.
+ *
+ * The answer is about the buses that have gone, not about the trip, so only
+ * what was just asserted is set aside: every bus that left while the
+ * pending question stood — a rider still waiting is on none of them — else
+ * the vehicle the detected ride named, for [REJECTED_VEHICLE_TTL_MS]; and
+ * the rider's own movement up to this moment, which no longer counts as
+ * evidence of a ride. The next bus raises its own question and is detected
+ * exactly as these were.
+ *
+ * Switching detection off for the rest of the trip instead would leave the
+ * rider sitting on their real bus with no boarding alert and no destination
+ * reminders, so one repeated question is the better failure.
+ *
+ * [answeredCheckId] is the question being answered, as for
+ * [confirmBoardedByUser]. "Not yet" to a question that has since been
+ * replaced says nothing about the buses the new one is about.
+ */
+ fun declineBoarding(nowMs: Long, answeredCheckId: Long? = null) {
+ noteDepartures(nowMs)
+ if (!boarded && answeredCheckId != null && answeredCheckId != pendingCheckSinceMs) {
+ // Still true of the rider's movement so far, and of nothing else.
+ lastDeclineAtMs = nowMs
+ return
+ }
+ val askedAbout = pendingDepartures.toList()
+ val rejected = if (askedAbout.isEmpty()) {
+ listOfNotNull(trackedVehicleId)
+ } else {
+ askedAbout.mapNotNull { it.vehicleId }
+ }
+ boarded = false
+ evidence = BoardingEvidence.NONE
+ trackedVehicleId = null
+ manualConfirmationAtMs = null
+ manualVehicleAmbiguous = false
+ lastDeclineAtMs = nowMs
+ declinedDepartures.clear()
+ declinedDepartures += askedAbout
+ declinedRideAtMs = if (askedAbout.isEmpty()) nowMs else 0L
+ rejected.forEach { rejectedAt[it] = nowMs }
+ clearPendingCheck()
+ }
+
+ /**
+ * Whether [vehicleId] is a bus the rider has already turned down and whose
+ * rejection is still in force. Entries lapse after
+ * [REJECTED_VEHICLE_TTL_MS] so a vehicle that comes round again is treated
+ * as new.
+ */
+ private fun isRejected(vehicleId: String, nowMs: Long): Boolean {
+ val declinedAt = rejectedAt[vehicleId] ?: return false
+ return nowMs - declinedAt <= REJECTED_VEHICLE_TTL_MS
+ }
+
+ fun snapshot(nowMs: Long): RideSnapshot {
+ prune(nowMs)
+ val latestUser = userTrack.lastOrNull()
+ val usableUser = latestUser?.takeIf { nowMs - it.timestampMs <= LOCATION_STALE_MS }
+ if (anchoredBoardingIndex < 0 && usableUser != null) {
+ // Used only when no boarding stop was chosen: the trip starts
+ // wherever the user actually is. Only a current fix can say that —
+ // a cached one from minutes ago would pin the whole trip to a stop
+ // the user may have left long before.
+ anchoredBoardingIndex = line.nearestStopIndex(usableUser.alongMeters)
+ }
+ val boardingStop = boardingIndex
+ val boardingAlong = if (boardingStop >= 0) line.alongOfStop(boardingStop) else null
+ val vehicles = liveVehicles(nowMs)
+
+ if (boardingAlong != null) {
+ trackDepartures(vehicles, boardingAlong, nowMs)
+ }
+ if (!boarded) {
+ val coMovingVehicleId = detectCoMovement(nowMs)
+ if (coMovingVehicleId != null) {
+ markBoarded(BoardingEvidence.BUS_CO_MOVEMENT, coMovingVehicleId)
+ } else if (boardingAlong != null && usableUser != null &&
+ detectSelfProgress(nowMs, boardingAlong, usableUser)
+ ) {
+ markBoarded(BoardingEvidence.SELF_PROGRESS, vehicleId = null)
+ }
+ }
+ trackedVehicleId = resolveTrackedVehicle(vehicles, usableUser, nowMs)
+ val trackedVehicle = vehicles.firstOrNull {
+ it.vehicleId == trackedVehicleId && it.samePath && it.onRoute
+ }
+
+ val nextBus = if (boardingAlong == null) {
+ null
+ } else {
+ vehicles
+ .filter { it.samePath && it.onRoute && it.alongMeters <= boardingAlong + AT_STOP_MARGIN_METERS }
+ .maxByOrNull { it.alongMeters }
+ }
+
+ val progressAlong = usableUser?.alongMeters
+ ?: trackedVehicle?.alongMeters?.takeIf { boarded }
+ val progressStopIndex = progressAlong?.let(line::stopIndexAt) ?: -1
+ val destinationStop = destinationIndex
+ val stopsToGo = if (destinationStop >= 0 && progressStopIndex >= 0) {
+ destinationStop - progressStopIndex
+ } else {
+ null
+ }
+ val destinationDistanceMeters = when {
+ destinationStop < 0 -> null
+ usableUser != null -> line.metersToStop(usableUser.latitude, usableUser.longitude, destinationStop)
+ progressAlong != null -> abs(line.alongOfStop(destinationStop) - progressAlong)
+ else -> null
+ }
+ // Resolved before the snapshot is assembled: it may retire the pending
+ // question, and the id reported alongside must agree with it.
+ val boardingPromptDue = resolveBoardingPromptDue(nowMs, usableUser, boardingStop)
+
+ return RideSnapshot(
+ hasBoarded = boarded,
+ evidence = evidence,
+ trackedVehicleId = trackedVehicleId,
+ hasUsableLocation = usableUser != null,
+ boardingStopIndex = boardingStop,
+ boardingStopDistanceMeters = if (usableUser != null && boardingStop >= 0) {
+ line.metersToStop(usableUser.latitude, usableUser.longitude, boardingStop)
+ } else {
+ null
+ },
+ progressStopIndex = progressStopIndex,
+ nextBusVehicleId = nextBus?.vehicleId,
+ busStopsUntilBoarding = nextBus?.let {
+ (boardingStop - line.stopIndexAt(it.alongMeters)).coerceAtLeast(0)
+ },
+ boardingCheckId = pendingCheckSinceMs,
+ boardingPromptDue = boardingPromptDue,
+ remainingStops = stopsToGo?.coerceAtLeast(0),
+ passedDestinationByStops = stopsToGo?.let { -it }?.takeIf { it > 0 },
+ destinationDistanceMeters = destinationDistanceMeters,
+ // Keep the phone's existing near-stop guard. Without a usable phone
+ // fix, the tracked bus's direction and route geometry are the guard;
+ // an unrelated cached phone location must not veto its progress.
+ hasRouteValidProgress = when {
+ usableUser != null -> line.metersToStop(
+ usableUser.latitude, usableUser.longitude, line.nearestStopIndex(usableUser.alongMeters),
+ ) <= USER_STOP_PROXIMITY_MAX_METERS
+ trackedVehicle != null -> trackedVehicle.samePath && trackedVehicle.onRoute
+ else -> false
+ },
+ )
+ }
+
+ private fun markBoarded(evidence: BoardingEvidence, vehicleId: String?) {
+ boarded = true
+ this.evidence = evidence
+ trackedVehicleId = vehicleId
+ manualConfirmationAtMs = null
+ manualVehicleAmbiguous = false
+ clearPendingCheck()
+ }
+
+ /**
+ * Layer A. The gap between the user and a vehicle is sampled at each of the
+ * vehicle's reports, newest first, and must stay within
+ * [COMOVE_MAX_GAP_DRIFT_METERS] over a stretch in which both really moved.
+ *
+ * Standing at the stop while the bus drives off makes the gap grow by the
+ * bus's whole travel, and walking beside a bus stuck in traffic fails the
+ * speed floor, so neither counts.
+ *
+ * Only an unbroken run of reports made on this trip's direction, on the
+ * route, is evidence. The feed lists every vehicle of the route — the other
+ * direction's, and ones parked or deadheading beside it — and each of those
+ * still projects to some position along this line.
+ */
+ private fun detectCoMovement(nowMs: Long): String? {
+ if (userTrack.size < 2) {
+ return null
+ }
+ val pairs = ArrayList()
+ busTracks.forEach { (vehicleId, track) ->
+ if (isRejected(vehicleId, nowMs)) {
+ return@forEach
+ }
+ pairs.clear()
+ track.forEach { report ->
+ if (!report.samePath || !report.onRoute) {
+ // Whatever came before is not joined to what follows as
+ // though the bus had run on through.
+ pairs.clear()
+ return@forEach
+ }
+ if (report.receivedAtMs < nowMs - COMOVE_WINDOW_MS) {
+ return@forEach
+ }
+ val userAlong = userAlongAt(report.gpsTimeMs) ?: return@forEach
+ pairs += CoMovementPair(userAlong, report.alongMeters, report.gpsTimeMs)
+ }
+ if (isCoMovement(pairs)) {
+ return vehicleId
+ }
+ }
+ return null
+ }
+
+ private fun isCoMovement(pairs: List): Boolean {
+ val newest = pairs.lastOrNull() ?: return false
+ var minGap = Double.MAX_VALUE
+ var maxGap = -Double.MAX_VALUE
+ for (start in pairs.indices.reversed()) {
+ val oldest = pairs[start]
+ val gap = oldest.userAlongMeters - oldest.busAlongMeters
+ minGap = minOf(minGap, gap)
+ maxGap = maxOf(maxGap, gap)
+ if (maxGap - minGap > COMOVE_MAX_GAP_DRIFT_METERS) {
+ // Reaching further back can only widen the drift.
+ return false
+ }
+ if (pairs.size - start < COMOVE_MIN_REPORTS) {
+ continue
+ }
+ val spanSeconds = (newest.gpsTimeMs - oldest.gpsTimeMs) / 1000.0
+ if (spanSeconds < COMOVE_MIN_SPAN_SECONDS) {
+ continue
+ }
+ val busTravel = newest.busAlongMeters - oldest.busAlongMeters
+ val userTravel = newest.userAlongMeters - oldest.userAlongMeters
+ if (busTravel < COMOVE_MIN_TRAVEL_METERS ||
+ userTravel < busTravel * COMOVE_MIN_USER_TRAVEL_SHARE ||
+ userTravel / spanSeconds < COMOVE_MIN_USER_SPEED_MPS
+ ) {
+ continue
+ }
+ // A fixed offset between the two clocks shows up as a fixed number
+ // of metres at a given speed, so the allowance grows with speed.
+ val gapLimit = COMOVE_MAX_GAP_METERS +
+ COMOVE_CLOCK_ALLOWANCE_SECONDS * (busTravel / spanSeconds)
+ if (maxGap > gapLimit || minGap < -gapLimit) {
+ continue
+ }
+ if (!movedAtVehicleSpeed(oldest.gpsTimeMs, newest.gpsTimeMs)) {
+ continue
+ }
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Layer B. 500 m in 200 s is 2.5 m/s — above a brisk walk and above a bus
+ * that sat in traffic for the whole window — so a pedestrian following the
+ * same street from the stop is not mistaken for a passenger.
+ *
+ * Travel that happened before the rider last answered "not yet" does not
+ * count: that is the movement the answer was about, and letting it count
+ * again would overrule the answer a moment after it was given. Evidence
+ * starts accumulating from scratch instead.
+ */
+ private fun detectSelfProgress(
+ nowMs: Long,
+ boardingAlong: Double,
+ latestUser: UserSample,
+ ): Boolean {
+ if (latestUser.timestampMs < lastDeclineAtMs) {
+ return false
+ }
+ val windowStartMs = maxOf(nowMs - SELF_PROGRESS_WINDOW_MS, lastDeclineAtMs)
+ val origin = userTrack.firstOrNull { sample ->
+ sample.timestampMs >= windowStartMs &&
+ sample.accuracyMeters <= SELF_PROGRESS_MAX_ACCURACY_METERS &&
+ abs(sample.alongMeters - boardingAlong) <= BOARDING_ORIGIN_RADIUS_METERS
+ } ?: return false
+ val travel = latestUser.alongMeters - origin.alongMeters
+ val elapsedSeconds = (latestUser.timestampMs - origin.timestampMs) / 1000.0
+ if (travel < SELF_PROGRESS_MIN_METERS || elapsedSeconds <= 0.0) {
+ return false
+ }
+ return travel / elapsedSeconds >= SELF_PROGRESS_MIN_SPEED_MPS &&
+ movedAtVehicleSpeed(origin.timestampMs, latestUser.timestampMs)
+ }
+
+ /**
+ * Doppler speed is immune to the position wander that makes a stationary
+ * phone look like it travelled, so when the fixes carry it, real movement
+ * must show up there too.
+ */
+ private fun movedAtVehicleSpeed(fromMs: Long, toMs: Long): Boolean {
+ var withSpeed = 0
+ var fast = 0
+ userTrack.forEach { sample ->
+ if (sample.timestampMs < fromMs || sample.timestampMs > toMs) {
+ return@forEach
+ }
+ val speed = sample.speedMetersPerSecond ?: return@forEach
+ withSpeed++
+ if (speed >= VEHICLE_SPEED_MPS) {
+ fast++
+ }
+ }
+ return withSpeed < MIN_SPEED_SAMPLES || fast >= MIN_FAST_SAMPLES
+ }
+
+ private fun trackDepartures(
+ vehicles: List,
+ boardingAlong: Double,
+ nowMs: Long,
+ ) {
+ vehicles.forEach { vehicle ->
+ if (!vehicle.samePath || !vehicle.onRoute) {
+ return@forEach
+ }
+ val offset = vehicle.alongMeters - boardingAlong
+ if (offset <= AT_STOP_MARGIN_METERS) {
+ seenBeforeBoardingStopAt[vehicle.vehicleId] = vehicle.receivedAtMs
+ } else if (offset > LEFT_STOP_MARGIN_METERS) {
+ val seenAt = seenBeforeBoardingStopAt.remove(vehicle.vehicleId) ?: return@forEach
+ // A vehicle that went quiet short of the stop and turns up past
+ // it much later left long ago; nobody is boarding it now.
+ if (vehicle.receivedAtMs - seenAt <= DEPARTURE_MAX_REPORT_GAP_MS) {
+ registerDeparture(vehicle.vehicleId, nowMs)
+ }
+ }
+ }
+ }
+
+ /**
+ * Counts a bus leaving the boarding stop, which is what the question is
+ * asked about.
+ *
+ * One departure can reach the app twice, up to a minute and a half apart:
+ * from the position feed, which names the vehicle, and from the ETA rolling
+ * over, which names it only where the stop feed says which bus was at the
+ * stop. Two reports naming the same vehicle are one departure, and two
+ * naming different vehicles are two, however close together.
+ *
+ * A rollover that names nothing, beside a departure the position feed
+ * reported, is not asked about twice — it is most often the same bus — but
+ * nothing shows that it is. It may be a second bus with no position feed,
+ * so the pair is remembered as possibly two ([Departure.mayBeTwoBuses])
+ * and an answer does not name the one that happens to have a plate.
+ */
+ private fun registerDeparture(vehicleId: String?, nowMs: Long, byEta: Boolean = false) {
+ if (boarded) {
+ return
+ }
+ if (vehicleId != null && isRejected(vehicleId, nowMs)) {
+ // The rider already said this bus was not theirs. Asking again
+ // about the same one is the noise the rejection exists to avoid;
+ // every other vehicle is untouched.
+ if (byEta) {
+ declinedDepartures.firstOrNull { it.vehicleId == vehicleId }?.reportedByEta = true
+ }
+ return
+ }
+ if (answeredByTheLastDecline(vehicleId, nowMs, byEta)) {
+ return
+ }
+ val boardingStop = boardingIndex
+ val recentUser = userTrack.filter { nowMs - it.timestampMs <= DEPARTURE_USER_LOOKBACK_MS }
+ // With no fix at all there is no telling where the user is, and asking
+ // is the only tool left.
+ val userWasAtStop = recentUser.isEmpty() || recentUser.any { sample ->
+ boardingStop >= 0 &&
+ line.metersToStop(sample.latitude, sample.longitude, boardingStop) <=
+ BOARDING_STOP_RADIUS_METERS
+ }
+ if (pendingCheckSinceMs != 0L) {
+ if (coveredByThePendingQuestion(vehicleId, nowMs, byEta)) {
+ return
+ }
+ // Another bus has left with the question unanswered. It joins what
+ // the question is about rather than taking it over: the rider may
+ // be on either, and "yes" will not say which.
+ pendingDepartures.removeAll { nowMs - it.atMs > BOARDING_CHECK_MAX_AGE_MS }
+ pendingDepartures += Departure(nowMs, vehicleId, reportedByEta = byEta)
+ if (userWasAtStop && nowMs - pendingCheckSinceMs >= DEPARTURE_DEDUPE_MS) {
+ // Long enough after the last time of asking to ask afresh.
+ pendingCheckSinceMs = nowMs
+ pendingCheckAnchor = recentUser.lastOrNull()
+ }
+ return
+ }
+ if (!userWasAtStop) {
+ return
+ }
+ pendingCheckSinceMs = nowMs
+ pendingDepartures.clear()
+ pendingDepartures += Departure(nowMs, vehicleId, reportedByEta = byEta)
+ pendingCheckAnchor = recentUser.lastOrNull()
+ }
+
+ /**
+ * Whether this report is of a departure the rider's last "not yet" already
+ * covered, reaching the app a second time.
+ */
+ private fun answeredByTheLastDecline(vehicleId: String?, nowMs: Long, byEta: Boolean): Boolean {
+ // The window runs from the departure, not from the answer: a rider may
+ // take minutes to reply, and what leaves the stop after that is the
+ // next bus.
+ declinedDepartures.removeAll { nowMs - it.atMs >= DEPARTURE_DEDUPE_MS }
+ if (vehicleId == null) {
+ // The ETA catching up with a bus the rider has answered for. Each
+ // bus rolls the ETA over once, so each is caught up with once: a
+ // rollover beyond that is another bus leaving.
+ val awaited = declinedDepartures.firstOrNull { !it.reportedByEta }
+ if (awaited != null) {
+ awaited.reportedByEta = true
+ return true
+ }
+ if (declinedRideAtMs != 0L && nowMs - declinedRideAtMs < DEPARTURE_DEDUPE_MS) {
+ declinedRideAtMs = 0L
+ return true
+ }
+ return false
+ }
+ if (byEta) {
+ // A second rollover, with a plate: not the echo of the first.
+ return false
+ }
+ // The question came from the ETA alone, so the answer had no vehicle to
+ // be held against. This is that bus, now named — unless it was still
+ // at the stop after that one had gone.
+ val unnamed = declinedDepartures.firstOrNull { it.vehicleId == null } ?: return false
+ if (wasAtTheStopAfter(vehicleId, unnamed.atMs)) {
+ return false
+ }
+ unnamed.vehicleId = vehicleId
+ rejectedAt[vehicleId] = lastDeclineAtMs
+ return true
+ }
+
+ /**
+ * Whether this report is the other feed's account, or a repeat, of a
+ * departure the pending question already covers.
+ */
+ private fun coveredByThePendingQuestion(vehicleId: String?, nowMs: Long, byEta: Boolean): Boolean {
+ if (vehicleId != null) {
+ val same = pendingDepartures.firstOrNull { it.vehicleId == vehicleId }
+ if (same != null) {
+ same.reportedByEta = same.reportedByEta || byEta
+ return true
+ }
+ }
+ val recent = pendingDepartures.filter { nowMs - it.atMs < DEPARTURE_DEDUPE_MS }
+ if (vehicleId == null) {
+ // A rollover naming nothing, beside a bus the position feed saw
+ // leave: probably that bus, possibly another.
+ val unreported = recent.firstOrNull { !it.reportedByEta } ?: return false
+ unreported.reportedByEta = true
+ unreported.mayBeTwoBuses = true
+ return true
+ }
+ if (byEta) {
+ return false
+ }
+ // The position feed reporting a bus beside a rollover that named
+ // nothing. One still at the stop after the rollover is another bus.
+ val unnamed = recent.firstOrNull { it.vehicleId == null } ?: return false
+ if (wasAtTheStopAfter(vehicleId, unnamed.atMs)) {
+ return false
+ }
+ unnamed.vehicleId = vehicleId
+ unnamed.mayBeTwoBuses = true
+ return true
+ }
+
+ /** Whether [vehicleId] reported itself at or short of the boarding stop later than [atMs]. */
+ private fun wasAtTheStopAfter(vehicleId: String, atMs: Long): Boolean {
+ val boardingStop = boardingIndex
+ if (boardingStop < 0) {
+ return false
+ }
+ val boardingAlong = line.alongOfStop(boardingStop)
+ return busTracks[vehicleId].orEmpty().any { sample ->
+ sample.samePath && sample.onRoute && sample.gpsTimeMs > atMs &&
+ sample.alongMeters - boardingAlong <= AT_STOP_MARGIN_METERS
+ }
+ }
+
+ private fun resolveBoardingPromptDue(
+ nowMs: Long,
+ usableUser: UserSample?,
+ boardingStop: Int,
+ ): Boolean {
+ if (boarded || pendingCheckSinceMs == 0L) {
+ return false
+ }
+ val ageMs = nowMs - pendingCheckSinceMs
+ if (ageMs > BOARDING_CHECK_MAX_AGE_MS) {
+ clearPendingCheck()
+ return false
+ }
+ if (boardingStop >= 0 && usableUser != null &&
+ line.metersToStop(usableUser.latitude, usableUser.longitude, boardingStop) >
+ BOARDING_STOP_ABANDONED_RADIUS_METERS
+ ) {
+ // The user is nowhere near the stop any more, so this bus was never
+ // their ride; stop waiting for an answer.
+ clearPendingCheck()
+ return false
+ }
+ if (ageMs < BOARDING_PROMPT_DELAY_MS) {
+ return false
+ }
+ // A stationary phone is the one thing that reliably means "still
+ // waiting", so while the user has not moved there is nothing to ask.
+ return !stayedBehind(nowMs, usableUser)
+ }
+
+ private fun stayedBehind(nowMs: Long, user: UserSample?): Boolean {
+ user ?: return false
+ if (nowMs - user.timestampMs > STAYED_BEHIND_MAX_FIX_AGE_MS ||
+ user.accuracyMeters > STAYED_BEHIND_MAX_ACCURACY_METERS
+ ) {
+ return false
+ }
+ val anchor = pendingCheckAnchor ?: return false
+ return line.metersBetween(anchor.latitude, anchor.longitude, user.latitude, user.longitude) <
+ STAYED_BEHIND_MAX_MOVE_METERS
+ }
+
+ private fun clearPendingCheck() {
+ pendingCheckSinceMs = 0L
+ pendingDepartures.clear()
+ pendingCheckAnchor = null
+ }
+
+ /**
+ * Once on board, the vehicle to follow is the one the user is inside. Its
+ * reported position trails the user's own fix by the feed's delay, so it is
+ * expected a little behind and never far ahead. It is also on this trip's
+ * direction and on the route: a vehicle parked beside the route projects
+ * onto it too, and can be the nearest one there is.
+ */
+ private fun resolveTrackedVehicle(
+ vehicles: List,
+ usableUser: UserSample?,
+ nowMs: Long,
+ ): String? {
+ if (!boarded) {
+ return null
+ }
+ val locked = vehicles.firstOrNull { it.vehicleId == trackedVehicleId && it.samePath && it.onRoute }
+ if (usableUser == null) {
+ // Remember the ride through an outage or an invalid report, but
+ // only a fresh, valid report supplies progress in snapshot(). A
+ // manual confirmation may also precede the first vehicle response.
+ return trackedVehicleId ?: if (evidence == BoardingEvidence.USER_CONFIRMED) {
+ identifyBoardingVehicle(nowMs)
+ } else {
+ null
+ }
+ }
+ if (locked != null &&
+ usableUser.alongMeters - locked.alongMeters in -TRACK_MAX_AHEAD_METERS..TRACK_KEEP_BEHIND_METERS
+ ) {
+ return locked.vehicleId
+ }
+ return vehicles
+ .filter { it.samePath && it.onRoute }
+ .filter { usableUser.alongMeters - it.alongMeters in -TRACK_MAX_AHEAD_METERS..TRACK_PICK_BEHIND_METERS }
+ .minByOrNull { abs(usableUser.alongMeters - it.alongMeters) }
+ ?.vehicleId
+ }
+
+ /**
+ * Associates an unprompted "I have boarded" with a vehicle, not just a
+ * route.
+ *
+ * The bus must have an unbroken run on this direction and route containing
+ * a fix physically at the boarding stop, and still be at most 250 m ahead.
+ * Projection alone would clamp a bus before the first stop to zero
+ * progress, or put a bus on a parallel street level with the stop.
+ *
+ * That fix must have been made in the minute and a half up to the moment
+ * the rider confirmed, by the vehicle's own clock. The report carrying it
+ * can reach the app afterwards — the feed runs half a minute behind — which
+ * is what the two-minute window is for. A bus that pulls in after the
+ * rider said they were already on one is the next bus, whenever it is seen.
+ *
+ * Nor is it a bus the app had already watched leave by then: whether the
+ * rider was on that one is what the boarding question asks.
+ *
+ * And it must be the only bus there. With another at the stop, just gone
+ * from it, or near enough to have pulled in, the rider can be on either.
+ */
+ private fun identifyBoardingVehicle(nowMs: Long): String? {
+ val confirmedAt = manualConfirmationAtMs ?: return null
+ if (nowMs - confirmedAt !in 0L..MANUAL_IDENTIFICATION_WINDOW_MS || manualVehicleAmbiguous) {
+ return null
+ }
+ val boardingStop = boardingIndex
+ val hasUsableUser = userTrack.lastOrNull()?.let { nowMs - it.timestampMs <= LOCATION_STALE_MS } == true
+ if (boardingStop < 0 || hasUsableUser) {
+ // With a usable location the nearest vehicle is resolved from the user.
+ return null
+ }
+ val boardingAlong = line.alongOfStop(boardingStop)
+ val candidates = liveVehicles(nowMs).filter { vehicle ->
+ if (!vehicle.samePath || !vehicle.onRoute ||
+ vehicle.alongMeters - boardingAlong !in -AT_STOP_MARGIN_METERS..MANUAL_VEHICLE_MAX_AHEAD_METERS
+ ) {
+ return@filter false
+ }
+ val track = busTracks[vehicle.vehicleId] ?: return@filter false
+ val latest = track.lastOrNull() ?: return@filter false
+ if (nowMs - latest.receivedAtMs !in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS) {
+ return@filter false
+ }
+ val run = track.reversed().takeWhile { it.samePath && it.onRoute }
+ // A bus already seen pulling away before the rider spoke up is one
+ // the app knew had gone: whether they were on it is what the
+ // boarding question asks, and only an answer to that settles it.
+ val hadLeftBeforeConfirming = run.any { sample ->
+ sample.gpsTimeMs <= confirmedAt && sample.alongMeters - boardingAlong > LEFT_STOP_MARGIN_METERS
+ }
+ !hadLeftBeforeConfirming && run.any { sample ->
+ confirmedAt - sample.gpsTimeMs in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS &&
+ abs(sample.alongMeters - boardingAlong) <= AT_STOP_MARGIN_METERS &&
+ line.metersToStop(sample.latitude, sample.longitude, boardingStop) <=
+ MANUAL_AT_STOP_RADIUS_METERS
+ }
+ }
+ val candidate = candidates.singleOrNull()
+ if (candidates.size > 1 ||
+ (candidate != null && anotherBusIsBoardable(confirmedAt, boardingAlong, candidate.vehicleId))
+ ) {
+ // Which of them the rider took is not something a later report
+ // settles: one leaving the distance bound first proves nothing. A
+ // usable phone fix can still identify the ride through
+ // resolveTrackedVehicle().
+ manualVehicleAmbiguous = true
+ return null
+ }
+ return candidate?.vehicleId
+ }
+
+ /**
+ * Whether the feeds place a vehicle other than [except] where the rider
+ * could have boarded it around [atMs]: at the boarding stop, just pulling
+ * away from it, or near enough short of it to have pulled in since — a
+ * report is half a minute old by the time it arrives. A bus the rider has
+ * turned down is not one of them.
+ */
+ private fun anotherBusIsBoardable(atMs: Long, boardingAlong: Double, except: String): Boolean {
+ return busTracks.any { (vehicleId, track) ->
+ if (vehicleId == except || isRejected(vehicleId, atMs)) {
+ return@any false
+ }
+ val last = track.lastOrNull { it.gpsTimeMs <= atMs } ?: return@any false
+ last.samePath && last.onRoute &&
+ atMs - last.receivedAtMs <= BUS_REPORT_STALE_MS &&
+ last.alongMeters - boardingAlong in -BOARDABLE_APPROACH_METERS..MANUAL_VEHICLE_MAX_AHEAD_METERS
+ }
+ }
+
+ private fun liveVehicles(nowMs: Long): List {
+ return busTracks.entries.mapNotNull { (vehicleId, track) ->
+ val last = track.lastOrNull() ?: return@mapNotNull null
+ if (nowMs - last.receivedAtMs > BUS_REPORT_STALE_MS) {
+ return@mapNotNull null
+ }
+ LiveVehicle(vehicleId, last.alongMeters, last.samePath, last.onRoute, last.receivedAtMs)
+ }
+ }
+
+ /** The user's along-route position at [timestampMs], linearly interpolated. */
+ private fun userAlongAt(timestampMs: Long): Double? {
+ var previous: UserSample? = null
+ userTrack.forEach { sample ->
+ if (sample.timestampMs >= timestampMs) {
+ val before = previous
+ if (before == null) {
+ return sample.alongMeters.takeIf {
+ sample.timestampMs - timestampMs <= INTERPOLATION_TOLERANCE_MS
+ }
+ }
+ val spanMs = sample.timestampMs - before.timestampMs
+ if (spanMs > MAX_INTERPOLATION_SPAN_MS) {
+ // No fixes for too long to guess where the user was.
+ return null
+ }
+ val fraction = (timestampMs - before.timestampMs).toDouble() / spanMs
+ return before.alongMeters + fraction * (sample.alongMeters - before.alongMeters)
+ }
+ previous = sample
+ }
+ return previous?.alongMeters?.takeIf {
+ timestampMs - (previous?.timestampMs ?: 0L) <= INTERPOLATION_TOLERANCE_MS
+ }
+ }
+
+ private fun prune(nowMs: Long) {
+ while (userTrack.isNotEmpty() && nowMs - userTrack.first().timestampMs > USER_TRACK_MAX_AGE_MS) {
+ userTrack.removeFirst()
+ }
+ busTracks.values.forEach { track ->
+ while (track.isNotEmpty() && nowMs - track.first().receivedAtMs > BUS_TRACK_MAX_AGE_MS) {
+ track.removeFirst()
+ }
+ }
+ busTracks.entries.removeAll { it.value.isEmpty() }
+ rejectedAt.entries.removeAll { nowMs - it.value > REJECTED_VEHICLE_TTL_MS }
+ seenBeforeBoardingStopAt.keys.retainAll(busTracks.keys)
+ }
+
+ private fun indexOfStop(stopId: Int?): Int {
+ return if (stopId == null) -1 else stopIds.indexOf(stopId)
+ }
+
+ private data class UserSample(
+ val latitude: Double,
+ val longitude: Double,
+ val alongMeters: Double,
+ val timestampMs: Long,
+ val accuracyMeters: Float,
+ val speedMetersPerSecond: Float?,
+ )
+
+ private data class BusSample(
+ val latitude: Double,
+ val longitude: Double,
+ val alongMeters: Double,
+ val gpsTimeMs: Long,
+ val receivedAtMs: Long,
+ val samePath: Boolean,
+ val onRoute: Boolean,
+ )
+
+ /**
+ * One bus leaving the boarding stop. [vehicleId] is set once a feed has
+ * named it, [reportedByEta] once the ETA rolling over has reported it.
+ * [mayBeTwoBuses] marks a position report and an unnamed rollover taken
+ * together without anything to show they were the same bus.
+ */
+ private class Departure(
+ val atMs: Long,
+ var vehicleId: String?,
+ var reportedByEta: Boolean,
+ var mayBeTwoBuses: Boolean = false,
+ )
+
+ private data class LiveVehicle(
+ val vehicleId: String,
+ val alongMeters: Double,
+ val samePath: Boolean,
+ val onRoute: Boolean,
+ val receivedAtMs: Long,
+ )
+
+ private data class CoMovementPair(
+ val userAlongMeters: Double,
+ val busAlongMeters: Double,
+ val gpsTimeMs: Long,
+ )
+
+ private companion object {
+ const val MAX_USER_SAMPLES = 240
+ const val MAX_BUS_SAMPLES = 60
+ const val USER_TRACK_MAX_AGE_MS = 400_000L
+ const val BUS_TRACK_MAX_AGE_MS = 360_000L
+
+ const val MAX_USABLE_ACCURACY_METERS = 100f
+ const val LOCATION_STALE_MS = 90_000L
+ const val USER_STOP_PROXIMITY_MAX_METERS = 100.0
+ const val MAX_PLAUSIBLE_SPEED_MPS = 55.0
+ const val INTERPOLATION_TOLERANCE_MS = 6_000L
+ const val MAX_INTERPOLATION_SPAN_MS = 30_000L
+
+ const val COMOVE_WINDOW_MS = 180_000L
+ const val COMOVE_MIN_REPORTS = 3
+ const val COMOVE_MIN_SPAN_SECONDS = 30.0
+ const val COMOVE_MIN_TRAVEL_METERS = 150.0
+ const val COMOVE_MIN_USER_TRAVEL_SHARE = 0.7
+ const val COMOVE_MIN_USER_SPEED_MPS = 2.2
+ const val COMOVE_MAX_GAP_DRIFT_METERS = 110.0
+ const val COMOVE_MAX_GAP_METERS = 200.0
+ const val COMOVE_CLOCK_ALLOWANCE_SECONDS = 12.0
+
+ const val SELF_PROGRESS_WINDOW_MS = 200_000L
+ const val SELF_PROGRESS_MIN_METERS = 500.0
+ const val SELF_PROGRESS_MIN_SPEED_MPS = 2.5
+ const val SELF_PROGRESS_MAX_ACCURACY_METERS = 60f
+ const val BOARDING_ORIGIN_RADIUS_METERS = 250.0
+
+ const val VEHICLE_SPEED_MPS = 3f
+ const val MIN_SPEED_SAMPLES = 3
+ const val MIN_FAST_SAMPLES = 2
+
+ const val BUS_REPORT_STALE_MS = 180_000L
+ const val ON_ROUTE_MIN_CROSS_TRACK_METERS = 150.0
+ const val ON_ROUTE_SEGMENT_SHARE = 0.6
+ const val AT_STOP_MARGIN_METERS = 40.0
+ const val LEFT_STOP_MARGIN_METERS = 120.0
+ const val DEPARTURE_MAX_REPORT_GAP_MS = 180_000L
+ const val MANUAL_AT_STOP_RADIUS_METERS = 80.0
+ const val MANUAL_VEHICLE_MAX_AHEAD_METERS = 250.0
+ const val MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS = 90_000L
+ const val MANUAL_IDENTIFICATION_WINDOW_MS = 120_000L
+ const val BOARDABLE_APPROACH_METERS = 300.0
+
+ const val ETA_ARRIVING_SECONDS = 30
+ const val ETA_ROLLOVER_SECONDS = 240
+ const val ETA_ROLLOVER_WINDOW_MS = 150_000L
+
+ const val DEPARTURE_DEDUPE_MS = 90_000L
+ const val DEPARTURE_USER_LOOKBACK_MS = 150_000L
+ const val BOARDING_STOP_RADIUS_METERS = 300.0
+ const val BOARDING_STOP_ABANDONED_RADIUS_METERS = 400.0
+ const val BOARDING_PROMPT_DELAY_MS = 40_000L
+ const val BOARDING_CHECK_MAX_AGE_MS = 300_000L
+
+ /**
+ * How long a turned-down vehicle stays set aside. Long enough to cover
+ * the wait for the next bus being asked about, short enough that a
+ * vehicle which really is the rider's — a late arrival, or the same
+ * vehicle on its next trip — is not written off for the rest of the day.
+ */
+ const val REJECTED_VEHICLE_TTL_MS = 1_200_000L
+ const val STAYED_BEHIND_MAX_FIX_AGE_MS = 30_000L
+ const val STAYED_BEHIND_MAX_ACCURACY_METERS = 50f
+ const val STAYED_BEHIND_MAX_MOVE_METERS = 60.0
+
+ const val TRACK_MAX_AHEAD_METERS = 250.0
+ const val TRACK_KEEP_BEHIND_METERS = 2_500.0
+ const val TRACK_PICK_BEHIND_METERS = 1_500.0
+ }
+}
diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchStopIdTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchStopIdTest.kt
new file mode 100644
index 0000000..d87f3a2
--- /dev/null
+++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchStopIdTest.kt
@@ -0,0 +1,228 @@
+package tw.avianjay.taiwanbus.flutter
+
+import android.app.Application
+import android.app.NotificationManager
+import android.content.Intent
+import android.database.sqlite.SQLiteDatabase
+import android.location.Location
+import java.io.File
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+/**
+ * A stop the user taps has to arrive in Flutter as the id Flutter knows.
+ *
+ * Stop ids are 64-bit in some feeds (高雄's are 11-12 digits), and Dart keys its
+ * stops by the raw value. The native side folds ids to a positive `Int` to join
+ * the live feeds, and that folded number used to be what widget and
+ * notification taps carried too — naming no stop the Flutter side has, so a
+ * 高雄 favourite opened its route with nothing selected.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], application = Application::class)
+class AppLaunchStopIdTest {
+ private val application: Application get() = RuntimeEnvironment.getApplication()
+
+ @Test
+ fun theFullWidthIdSurvivesTheLaunchPayload() {
+ assertNotEquals(
+ "this id only proves anything if folding changes it",
+ RAW_STOP_ID,
+ foldStopId(RAW_STOP_ID).toLong(),
+ )
+
+ val payload = launchPayloadOf(
+ AppLaunchConstants.createRouteDetailIntent(
+ context = application,
+ provider = "khh",
+ routeKey = 100,
+ routeId = "KHH100",
+ pathId = 0,
+ stopId = RAW_STOP_ID,
+ destinationPathId = 0,
+ destinationStopId = RAW_STOP_ID + 30,
+ ),
+ )
+
+ assertEquals("khh", payload["provider"])
+ assertEquals(RAW_STOP_ID, payload.stopId("stopId"))
+ assertEquals(RAW_STOP_ID + 30, payload.stopId("destinationStopId"))
+ }
+
+ @Test
+ fun idsThatFitInAnIntAreUnchanged() {
+ // 台中 and 雙北 ids are small, and must keep arriving exactly as before.
+ val payload = launchPayloadOf(
+ AppLaunchConstants.createRouteDetailIntent(
+ context = application,
+ provider = "txg",
+ routeKey = 1,
+ pathId = 0,
+ stopId = 13567L,
+ ),
+ )
+
+ assertEquals(13567L, payload.stopId("stopId"))
+ }
+
+ @Test
+ fun aMissingStopIdIsStillAbsentFromThePayload() {
+ val payload = launchPayloadOf(
+ AppLaunchConstants.createRouteDetailIntent(
+ context = application,
+ provider = "txg",
+ routeKey = 1,
+ ),
+ )
+
+ assertFalse("no stop chosen means no stop in the payload", payload.containsKey("stopId"))
+ assertFalse(payload.containsKey("destinationStopId"))
+ }
+
+ @Test
+ fun aShortcutPinnedBeforeTheIdsWereWidenedStillOpensItsStop() {
+ // A pinned shortcut keeps the intent it was created with for as long as
+ // it sits on the home screen, so the extras older builds wrote — these
+ // names, holding Ints — are a format that has to stay readable.
+ val pinnedByAnOlderBuild = Intent()
+ .putExtra("launch_target", "route_detail")
+ .putExtra("provider", "txg")
+ .putExtra("route_key", 1)
+ .putExtra("path_id", 0)
+ .putExtra("stop_id", 13567)
+ .putExtra("destination_path_id", 0)
+ .putExtra("destination_stop_id", 13570)
+
+ val payload = launchPayloadOf(pinnedByAnOlderBuild)
+
+ assertEquals(13567L, payload.stopId("stopId"))
+ assertEquals(13570L, payload.stopId("destinationStopId"))
+ }
+
+ @Test
+ fun tappingAWidgetRowLaunchesWithTheFullWidthId() {
+ // The config is what Dart saves: the ids as Dart holds them.
+ val config = JSONObject().put(
+ "通勤",
+ JSONArray().put(
+ JSONObject()
+ .put("type", "boarding")
+ .put("provider", "khh")
+ .put("routeKey", 100)
+ .put("pathId", 0)
+ .put("stopId", RAW_STOP_ID)
+ .put("routeId", "KHH100")
+ .put("stopName", "測試站")
+ .put("destinationPathId", 0)
+ .put("destinationStopId", RAW_STOP_ID + 30),
+ ),
+ ).toString()
+ val item = FavoriteGroupWidgetSupport.parseFavoriteGroups(config).getValue("通勤").single()
+
+ assertEquals(
+ "the live feed is still looked up by the folded key",
+ foldStopId(RAW_STOP_ID),
+ item.stopId,
+ )
+
+ val tap = FavoriteGroupWidgetSupport.createItemPendingIntent(application, item)
+ val payload = launchPayloadOf(shadowOf(tap).savedIntent)
+
+ assertEquals(RAW_STOP_ID, payload.stopId("stopId"))
+ assertEquals(RAW_STOP_ID + 30, payload.stopId("destinationStopId"))
+ }
+
+ @Test
+ fun tappingASmartRouteNotificationLaunchesWithTheFullWidthId() {
+ // The stop comes out of the route database Flutter syncs, where the id
+ // is stored as text.
+ val databaseFile = File(
+ application.filesDir.parentFile,
+ "app_flutter/.yabus_backend/bus_khh_v2.sqlite",
+ )
+ databaseFile.parentFile!!.mkdirs()
+ SQLiteDatabase.openOrCreateDatabase(databaseFile, null).use { database ->
+ database.execSQL("CREATE TABLE routes (route_key INTEGER, route_id TEXT, route_name TEXT)")
+ database.execSQL("CREATE TABLE paths (route_key INTEGER, path_id INTEGER, path_name TEXT)")
+ database.execSQL(
+ "CREATE TABLE stops (route_key INTEGER, path_id INTEGER, stop_id TEXT, " +
+ "stop_name TEXT, sequence INTEGER, lon REAL, lat REAL)",
+ )
+ database.execSQL("INSERT INTO routes VALUES (100, 'KHH100', '100')")
+ database.execSQL("INSERT INTO paths VALUES (100, 0, '去程')")
+ database.execSQL(
+ "INSERT INTO stops VALUES (100, 0, '$RAW_STOP_ID', '測試站', 1, 120.338401, 22.60612)",
+ )
+ }
+ val routeData = requireNotNull(
+ SmartRouteNotificationSupport.loadRouteData(application, "khh", 100, "100"),
+ )
+ val here = Location("test").apply {
+ latitude = 22.60612
+ longitude = 120.338401
+ }
+ val nearestStop = requireNotNull(
+ SmartRouteNotificationSupport.findNearestStop(here, routeData, pathId = 0),
+ )
+
+ assertEquals(
+ "the live feed is still looked up by the folded key",
+ foldStopId(RAW_STOP_ID),
+ nearestStop.stopId,
+ )
+
+ SmartRouteNotificationSupport.ensureChannel(application)
+ SmartRouteNotificationSupport.showNotification(
+ context = application,
+ profile = SmartRouteProfile(
+ provider = "khh",
+ routeKey = 100,
+ pathId = 0,
+ routeName = "100",
+ totalOpens = 5,
+ lastOpenedAtMs = 0L,
+ totalSelections = 0,
+ lastSelectedAtMs = 0L,
+ hourlyOpens = mapOf(8 to 5),
+ hourlySelections = emptyMap(),
+ ),
+ routeData = routeData,
+ nearestStop = nearestStop,
+ liveStop = SmartLiveStop(sec = 120, msg = null),
+ distanceMeters = nearestStop.distanceMeters,
+ nowMs = 1L,
+ )
+ val posted = shadowOf(application.getSystemService(NotificationManager::class.java))
+ .allNotifications
+ .single()
+ val payload = launchPayloadOf(shadowOf(posted.contentIntent).savedIntent)
+
+ assertEquals(RAW_STOP_ID, payload.stopId("stopId"))
+ }
+
+ private fun launchPayloadOf(intent: Intent): Map {
+ val payload = AppLaunchConstants.extractLaunchPayload(intent)
+ assertTrue("expected a launch payload", payload != null)
+ return payload!!
+ }
+
+ private fun Map.stopId(key: String): Long? = (this[key] as Number?)?.toLong()
+
+ private companion object {
+ /**
+ * A real 高雄 stop id. Its low 32 bits are negative, so any path that
+ * narrows it to an `Int` — by folding or by truncating — changes it.
+ */
+ const val RAW_STOP_ID = 101111144601L
+ }
+}
diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTimeTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTimeTest.kt
new file mode 100644
index 0000000..e92fc85
--- /dev/null
+++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTimeTest.kt
@@ -0,0 +1,68 @@
+package tw.avianjay.taiwanbus.flutter
+
+import android.app.Application
+import android.location.Location
+import java.util.concurrent.TimeUnit
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * A fix handed to the ride detector has to carry when it was really measured.
+ *
+ * It used to be aged by subtracting `Location.time` (Unix epoch) from
+ * `SystemClock.elapsedRealtime()` (time since boot). That difference is hugely
+ * negative and was clamped to zero, so every fix — including a position cached
+ * minutes earlier — reached the detector stamped "now".
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], application = Application::class)
+class RideFixTimeTest {
+ private val nowWallMs = 1_700_000_000_000L
+ private val nowElapsedNanos = TimeUnit.HOURS.toNanos(3)
+
+ @Test
+ fun aCachedFixIsPlacedItsRealAgeInThePast() {
+ val cached = Location("fused").apply {
+ elapsedRealtimeNanos = nowElapsedNanos - TimeUnit.MINUTES.toNanos(5)
+ // A provider's clock need not agree with the device's: the age has
+ // to come from the monotonic stamp, not from this.
+ time = nowWallMs + TimeUnit.DAYS.toMillis(1)
+ }
+
+ assertEquals(nowWallMs - 300_000L, rideFixTimestampMs(cached, nowWallMs, nowElapsedNanos))
+ }
+
+ @Test
+ fun aFixStampedAheadOfTheClockCountsAsCurrent() {
+ val ahead = Location("fused").apply {
+ elapsedRealtimeNanos = nowElapsedNanos + TimeUnit.SECONDS.toNanos(2)
+ }
+
+ assertEquals(nowWallMs, rideFixTimestampMs(ahead, nowWallMs, nowElapsedNanos))
+ }
+
+ @Test
+ fun aHandBuiltPositionIsAgedByItsOwnTime() {
+ // The session's starting position is built from what Flutter sent, and
+ // has no monotonic stamp; Flutter says when it was measured instead.
+ val seed = Location("trip_monitor_session").apply {
+ time = nowWallMs - 180_000L
+ }
+
+ assertEquals(nowWallMs - 180_000L, rideFixTimestampMs(seed, nowWallMs, nowElapsedNanos))
+ }
+
+ @Test
+ fun aHandBuiltPositionWithNoUsableTimeCountsAsCurrent() {
+ val untimed = Location("trip_monitor_session")
+ val fromTheFuture = Location("trip_monitor_session").apply {
+ time = nowWallMs + 60_000L
+ }
+
+ assertEquals(nowWallMs, rideFixTimestampMs(untimed, nowWallMs, nowElapsedNanos))
+ assertEquals(nowWallMs, rideFixTimestampMs(fromTheFuture, nowWallMs, nowElapsedNanos))
+ }
+}
diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt
index 20b3dbc..a6858a6 100644
--- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt
+++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt
@@ -3,11 +3,20 @@ package tw.avianjay.taiwanbus.flutter
import android.app.Application
import android.app.Notification
import android.app.NotificationManager
+import android.app.PendingIntent
import android.app.Service
+import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
+import android.location.Location
+import android.os.Looper
+import android.os.SystemClock
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.shadows.ShadowSystemClock
+import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutorService
import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -39,19 +48,23 @@ class RouteTripMonitorServiceTest {
private lateinit var service: RouteTripMonitorService
private lateinit var shadow: RecordingTripServiceShadow
+ @Volatile
+ private var backend: (String) -> String? = { null }
+
@Before
fun setUp() {
controller = Robolectric.buildService(RouteTripMonitorService::class.java).create()
service = controller.get()
shadow = Shadow.extract(service)
+ // No test reaches the real backend; each one says what it answers.
+ service.fetchBackendJsonText = { path -> backend(path) }
}
@After
fun tearDown() {
service.onStartCommand(command("STOP_TRIP_MONITOR"), 0, 99)
controller.destroy()
- val executor = ReflectionHelpers.getField(service, "ioExecutor")
- assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
+ assertTrue(ioExecutor().awaitTermination(5, TimeUnit.SECONDS))
}
@Test
@@ -150,7 +163,7 @@ class RouteTripMonitorServiceTest {
}
@Test
- fun addingDestinationAfterBoardingKeepsRideAndTrackedVehicle() {
+ fun addingDestinationAfterBoardingKeepsTheRide() {
val initialSession = sessionPayload() + mapOf(
"routeId" to "TXG1",
"boardingStopId" to 1,
@@ -173,18 +186,20 @@ class RouteTripMonitorServiceTest {
),
)
service.onStartCommand(startIntent(initialSession), 0, 1)
- ReflectionHelpers.setField(service, "rideConfirmed", true)
- ReflectionHelpers.setField(service, "trackedBusId", "KKA-1234")
+ // The user confirms the ride while waiting, exactly as the notification
+ // action does.
+ service.onStartCommand(command("MARK_BOARDED"), 0, 2)
+ assertTrue(ride(service).hasBoarded)
ReflectionHelpers.setField(service, "destinationAlertStage", 2)
val destinationSession = initialSession + mapOf(
"destinationStopId" to 2,
"destinationStopName" to "下車站",
)
- service.onStartCommand(startIntent(destinationSession), 0, 2)
+ service.onStartCommand(startIntent(destinationSession), 0, 3)
- assertTrue(ReflectionHelpers.getField(service, "rideConfirmed"))
- assertEquals("KKA-1234", ReflectionHelpers.getField(service, "trackedBusId"))
+ assertTrue("choosing a destination must not drop the ride", ride(service).hasBoarded)
+ assertEquals(BoardingEvidence.USER_CONFIRMED, ride(service).evidence)
assertEquals(0, ReflectionHelpers.getField(service, "destinationAlertStage"))
}
@@ -200,8 +215,6 @@ class RouteTripMonitorServiceTest {
)),
)
service.onStartCommand(startIntent(payload(600, emptyList(), "MAIN-001")), 0, 1)
- ReflectionHelpers.setField(service, "activeBoardingVehicleId", "MAIN-001")
- ReflectionHelpers.setField(service, "boardingAlertSent", true)
service.onStartCommand(startIntent(payload(60, listOf("TXG1", "TXG2"), "FAMILY-001")), 0, 2)
val session = ReflectionHelpers.getField(service, "session")
@@ -210,21 +223,1250 @@ class RouteTripMonitorServiceTest {
val live = ReflectionHelpers.getField