Repository navigation
Conversation
Boarding was often missed, and the prompt asking whether the user got on sometimes never appeared. Four independent causes: - Commit 813be81 gated the refresh loop, the location callback and the live fetch on `!appInForeground`. Because keepScreenAwakeOnRouteDetail defaults to true, the whole state machine stopped running while the route page was open — the opposite of what the setting suggests. - 雙北 boards could never be detected: the ETA feed there carries no plates, and both strong signals required one. Vehicle positions come from /realtime/buses, which does carry plates and GPS for every city. - The boarding window only existed while the user was still near the stop and expired after five minutes, after which it never reopened. - A vehicle that had already passed stayed locked, so the next bus could not be tracked. Tracking now follows the rider's own position. The decision moves to TripRideDetector, a pure Kotlin state machine with no Android dependencies, comparing the rider and each vehicle 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. Rides are recognised three ways, strongest first: co-movement with a live vehicle, the rider's own progress along the route, and finally asking the user — so boarding is detected even with no location at all, and the notification offers "我已上車" while waiting. Vehicle GPS trails the fetch by 22-39 s (p50), so co-movement is measured as a stable offset between the two tracks rather than proximity, and feed recency is judged by when the app received it. The ride is rebuilt when the route family changes, which is how a preliminary primary-route response is replaced once the variants arrive; a ride the user already confirmed is never dropped. Separately, 高雄 stop ids exceed 32 bits and the two sides of that join folded them differently: the session payload wrapped the low 32 bits while the live feed hashed the digits, so a 高雄 stop's live ETA could never be found, and ids that wrapped negative were dropped outright. Both now call foldStopId(), including the family fetch's own copy of that parser. Verified by 15 replay tests over real recorded feeds from TXG300, KHH100 and TPE157463: the ride is detected within 180 s while waiting at the stop and walking the same street are both rejected. The 高雄 fixture boards at a stop whose id needs folding, and the fold is checked against the ids whose low 32 bits are negative, since an id that wraps positive would agree by accident.
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary
Requesting changes for four regressions affecting stale location handling, trips without a destination, subsequent boarding detection, and large stop IDs in widget launches. See inline comments.
Checks: git diff --check passed. Local Android unit tests could not run because the checkout has no android/gradlew; Flutter tests could not run because Flutter is not installed. GitHub Actions Verify (including Flutter analyze/tests), Linux, Web, Windows, iOS, macOS, and Wear OS builds passed; Android APK build was still running when checked.
| // cached position; ageing them here keeps the whole detector on | ||
| // the wall clock the rest of the service uses. | ||
| timestampMs = System.currentTimeMillis() - | ||
| (SystemClock.elapsedRealtime() - location.time).coerceAtLeast(0L), |
There was a problem hiding this comment.
Blocking — mismatched clocks make every cached fix appear fresh. Location.time is Unix epoch milliseconds, whereas SystemClock.elapsedRealtime() is time since boot, so their difference is negative and is clamped to zero. For example, a five-minute-old lastLocation becomes a current fix; it can anchor a trip at the wrong stop and be treated as usable for destination/boarding decisions. Use location.elapsedRealtimeNanos with the matching monotonic clock (and handle synthetic session locations with no timestamp), or use the epoch location.time directly. Add a service-level test for an old cached fix.
There was a problem hiding this comment.
已在 5987fb5 修正。
- 新增
rideFixTimestampMs():用location.elapsedRealtimeNanos對SystemClock.elapsedRealtimeNanos()算出 fix 的年齡,再從目前 wall clock 扣掉;沒有 monotonic 時間戳的手工 Location(session 的初始位置)改用它自己的 epochtime。 - Flutter 端的 session 現在會帶
initialLocationTimestampMs(_lastPosition.timestamp),所以路線頁幾分鐘前的定位不會再被當成現在。 - 順帶修掉同一個問題的另一半:未選上車站時,原本「收到任何 fix 就永久錨定上車站」,現在只有仍新鮮的 fix 才會錨定。
測試:RideFixTimeTest(4 個)以及 service 層的 aCachedFixFromMinutesAgoIsNotTreatedAsWhereTheRiderIs、theSessionsStartingPositionIsAgedByWhenFlutterMeasuredIt。把這行改回原本的寫法,兩個 service 測試都會失敗。
| val boardingPointCount = boardingIndex + 1 | ||
| val boardingProgressPoint = | ||
| boardingBusIndex?.plus(1)?.coerceAtMost(boardingPointCount) ?: 0 | ||
| if (destinationStop == null || !ride.hasBoarded) { |
There was a problem hiding this comment.
Blocking — a trip without a destination never exposes boarding. This branch runs whenever destinationStop == null, even after the detector or the user confirms boarding, and returns hasBoarded = false below. Consequently maybeSendTripAlerts never reaches its hasBoarded && destinationName == null branch, so it does not prompt the rider to choose a destination or pause as intended; the ongoing notification remains in waiting mode as well. Render an onboard snapshot when ride.hasBoarded is true even without a destination, and test the full confirm/detect → destination setup flow.
There was a problem hiding this comment.
已在 5987fb5 修正。
buildSnapshot在所有 waiting 分支之前(包含「上下車站都沒選」那個提前 return)先處理ride.hasBoarded && destinationStop == null,回傳hasBoarded = true的 onboard snapshot,所以maybeSendTripAlerts會走到提示設定下車站並暫停。- 追下去發現流程後半段也是斷的:暫停時 detector 被清掉,使用者選了下車站後會變回「等待上車」;而且 Dart 端的 paused 旗標會讓新的 session 根本送不到 native。現在同一趟(provider/route/path/上車站相同、原因是
boarded_no_destination、一小時內)補上目的地時會以已上車狀態接續,路線頁選目的地時也會重新比對暫停狀態。
測試:boardingWithoutADestinationAsksForOneAndPicksTheRideBackUp(有/沒有選上車站兩種都跑:確認上車 → 提示通知 → 暫停 → 選目的地 → 仍是已上車)、onlyTheRidePausedForADestinationIsPickedBackUp(使用者自己暫停、不同上車站、超過一小時都不會接續),以及 Dart 的 choosing a destination restarts a monitor paused without one。
| boarded = false | ||
| evidence = BoardingEvidence.NONE | ||
| trackedVehicleId = null | ||
| autoDetectionSuppressed = true |
There was a problem hiding this comment.
Blocking — declining one bus disables detection for all later buses. If the rider taps 還沒上車 on the question for a bus they did not take, this flag permanently disables both automatic detection and future departure questions until the trip is rebuilt. When their actual bus arrives, boarding and therefore destination alerts will never start unless they discover and press the manual action. Scope suppression to the rejected vehicle/evidence or a bounded snooze, then re-arm for the next departure; add a regression test covering decline of bus A followed by boarding bus B.
There was a problem hiding this comment.
已在 5987fb5 修正,拿掉整趟的 autoDetectionSuppressed。
- 「還沒上車」只排除被問到(或被自動判定)的那輛車,20 分鐘後失效。
- 回答之前的移動不再算 self-progress 證據,避免剛回答完又立刻被判上車。
- 下一班離站會重新詢問。同一次離站被位置 feed 與 ETA 重複回報時的去重視窗從「離站」起算而不是從回答起算,所以使用者晚幾分鐘才回答也不會吃掉下一班。
測試:TripRideDetectorDeclineTest(6 個),包含 decliningOneBusStillDetectsTheNext(拒絕 A 車後搭上 B 車,且鎖定的是 B)、theBoardingQuestionComesBackForTheNextBus、aLateAnswerDoesNotSwallowTheNextDeparture。把永久 suppression 加回去會讓其中 3 個失敗。
| val stopId = item.optInt("stopId", 0) | ||
| // The config is written by Dart with the raw feed id, which can | ||
| // exceed Int for 高雄; fold it the same way the live feed does. | ||
| val stopId = foldStopId(item.opt("stopId")) |
There was a problem hiding this comment.
Blocking — folding the stored ID also changes the ID sent back to Flutter. createItemPendingIntent passes item.stopId and item.destinationStopId into AppLaunchConstants, while Dart route stops retain the full 64-bit IDs and compare them directly. For example, stop 101111144601 is sent as 179413145, so tapping a Kaohsiung favorite widget cannot select the requested boarding/destination stop. The same issue applies to the folded IDs sent from the trip-monitor and smart-route notification intents. Keep a full-width/raw ID for launch payloads (including changing the intent extra types), and use folded values only for native feed joins. Add a widget/notification launch round-trip test with a 64-bit ID.
There was a problem hiding this comment.
已在 5987fb5 修正。折疊後的 Int 現在只用在 native 的 feed join,送回 Flutter 的一律是完整寬度的 ID。
createRouteDetailIntent的stopId/destinationStopId改成Long,extras 也存Long。- widget item、trip monitor session/stop、smart route stop 各自多帶一個
launchStopId,三個產生 intent 的地方都改用它。 MainActivity釘選捷徑原本用call.argument<Int>讀 stop id,高雄的 ID 會讀成 null,一併改掉。- 讀取端相容舊版已釘選捷徑裡的
Intextra(getLongExtra遇到型別不同的 extra 會回傳預設值)。
測試:AppLaunchStopIdTest 用 101111144601 實際走完 widget 點擊與智慧推薦通知的 PendingIntent → extractLaunchPayload,RouteTripMonitorServiceTest.tappingATripNotificationLaunchesWithTheFullWidthStopId 涵蓋乘車通知,另有舊 Int extra 的相容測試與 Dart 端 AppLaunchAction.fromMap 的測試。改回送折疊值時會得到你提到的 179413145 而失敗。
Four regressions from the boarding-detection rewrite, each with a regression test that fails when the fix is reverted. - Fix age: Location.time (epoch) was subtracted from elapsedRealtime (since boot), so every cached fix looked current. Age is now read off the monotonic clock, the session's seed position carries the time Flutter measured it, and only a fresh fix may anchor the boarding stop. - Boarded without a destination: the snapshot always reported "waiting", so the destination prompt never fired. It now reports the ride, and choosing a stop afterwards continues that ride instead of starting a new wait. The route screen re-checks the paused state when a destination is chosen so the new session is actually sent. - Declining a bus no longer disables detection for the trip. Only the declined vehicle is set aside (20 min), movement before the answer no longer counts, and the next departure is asked about again. - Launch payloads carry the full-width stop id. The folded Int is kept for native feed joins only; widget, trip and smart-route notification taps and pinned shortcuts send the id Flutter holds. Int extras from shortcuts pinned by older builds are still read.
|
Review 的四項都已在 5987fb5 處理,細節回在各個 thread。 本機驗證(CI 的 Verify 不跑 Android 單元測試):
每個修正都有暫時改回錯誤版本、確認對應測試會失敗。 另外兩個已知但不在這次範圍內的限制:離開路線頁( |
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary
The previous four reported issues have targeted fixes, but I found three additional blocking cases affecting boarding detection and direction changes. Please see the inline comments.
Checks: git diff --check passed. Node runtime tests passed (3/3) using Node 22.23.4; the host's default Node 22.23.1 is rejected by the project's security guard. GitHub Actions Verify passed (including flutter analyze, Flutter tests, and the Chrome geometry test), and the Android, Wear OS, Linux, Web, Windows, iOS and macOS builds passed. I could not run Android unit tests locally: Flutter and the generated android/gradlew are absent. The CI workflow does not run the Android unit tests.
| val liveStop = liveStops[boardingStopId] ?: return | ||
| val etaEntry = liveStop.etaEntries.firstOrNull() | ||
| val arriving = liveStop.seconds?.let { it <= 0 } == true || | ||
| liveStop.vehicleIds.isNotEmpty() || |
There was a problem hiding this comment.
Blocking — a vehicle with a future ETA is treated as already arriving. parseTripMonitorLiveStop includes vehicle IDs from ETA entries regardless of their ETA, and family merging likewise retains IDs for later buses. Thus when the current bus leaves and the next plated bus has, say, a 600-second ETA, vehicleIds.isNotEmpty() keeps arriving true, so onBoardingStopEta resets its arrival timestamp instead of detecting the rollover. This removes the fallback boarding question exactly when the departed bus has no usable GPS feed. Determine arrival from the selected ETA/message or an actual at-stop bus, not any future vehicle ID; add a service-level test for 0 → 600 seconds with a plate on the next ETA.
There was a problem hiding this comment.
已在 2c66fac 修正。LiveStopState 多了 busesAtStop,只放 feed 的 buses(實際在站的車),family 合併時取聯集;vehicleIds 維持原樣給車輛追蹤用。feedBoardingEta 的 arriving 改看 ETA ≤ 0、進站/到站訊息,或 busesAtStop,不再看 ETA entry 上的車牌。
測試:theEtaRollingOverToAPlatedLaterBusStillRaisesTheQuestion——service 層餵 0 秒(帶車牌)→ 600 秒(下一班帶車牌),確認離站有被登記。改回 vehicleIds.isNotEmpty() 會失敗。
| // 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()) { |
There was a problem hiding this comment.
Blocking — a missing stop feed also disables the independent vehicle feed. If the stop endpoint fails or returns an empty map, this return (and the shared backoff above) prevents /realtime/buses from being fetched at all, even if that endpoint is healthy. A rider with limited/no phone fixes then loses bus-based departure questions and vehicle tracking for every retry, despite the live positions being available. Back off the failing stop fetch independently and still attempt the bus fetch; cover an empty/failed stop response with a successful bus response.
There was a problem hiding this comment.
已在 2c66fac 修正。站牌 feed 與車輛 feed 各自有退避(liveStopBackoffUntilMs/liveBusBackoffUntilMs),站牌 feed 失敗或回空只退避自己,/realtime/buses 照常抓。車輛 feed 原本失敗時沒有退避、每次 refresh 都會重試,也一併補上。
測試:aFailingStopFeedDoesNotSilenceTheVehicleFeed——站牌請求失敗、車輛請求成功,確認車輛報告有進來,且站牌退避期間車輛 feed 仍持續抓、站牌不重試。還原成共用退避會失敗。
| @@ -261,72 +270,148 @@ class RouteTripMonitorService : Service() { | |||
| AppRuntimeStateStore.setAppInForeground(this, appInForeground) | |||
| val tripOriginChanged = | |||
There was a problem hiding this comment.
Blocking — switching direction can reuse the old detector and geometry. The rebuild identity compares boarding stop, route ID and route key, but not pathId or the stop sequence. When the rider switches direction on the same route and the boarding stop is shared (or both boarding IDs are null), neither this condition nor familyChanged changes. The detector continues projecting onto the previous direction's stops with its previous pathId, potentially retaining a confirmed ride and using wrong stop counts/alerts on the new tab. Include direction (and changed route topology where relevant) in detector identity and test switching paths with the same boarding stop.
There was a problem hiding this comment.
已在 2c66fac 修正。重建 detector 的判斷加入 pathId 與站序(各站 folded id 的順序),所以同路線切換方向、或站序變了,都會重建而不是沿用舊方向的幾何;站序相同的一般 refresh 不受影響。
測試:switchingDirectionRebuildsTheRideEvenWithTheSameBoardingStop——上車站相同、切到反向後已確認的乘車被清掉,上車站 index 依新方向的站序重新算;另外驗證 pathId 不變但站序改變也會重建。
Three further review findings, each with a service-level test. - A plate on an ETA entry is a bus still on its way, not one at the stop. Counting it as "arriving" hid the ETA rolling over to the next bus, which is the only departure signal when the bus that left has no position feed. Only buses the feed places at the stop count now. - The stop feed and the vehicle feed back off separately, so a failing stop feed no longer stops vehicle positions from being fetched. - Direction and stop order are part of what makes a trip. Switching to the other direction with the same boarding stop rebuilds the detector instead of measuring along the previous direction's stops. The service tests no longer reach the real backend, and tests about a continuing ride give it a destination: a ride confirmed without one is paused to ask for it, which they had been racing.
|
第二輪的三項已在 2c66fac 處理,細節回在各 thread。本機 另外:service 測試現在不會再打到真正的後端(每個測試自己決定後端回什麼);幾個「已確認乘車後續行為」的測試補上了下車站——沒有下車站的已上車行程會被暫停去詢問,原本這些測試是靠時序僥倖通過的。 |
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary
Requesting changes for two boarding-state regressions: co-movement can confirm a bus explicitly marked as the other direction, and an in-flight fetch for an old trip can feed its data into a newly rebuilt detector. Details and suggested regression tests are inline.
Checks: git diff --check passed; Node 22.23.4 npm run test:node and npm run test:web passed (3 tests each). I compiled the actual pure-Kotlin detector and route line with a minimal TrackingStop shim and reproduced the opposite-direction false boarding. GitHub Actions on this head passed Verify (Flutter analysis/tests and Web geometry) and Android, Wear OS, Linux, Web, Windows, iOS and macOS builds. Android unit tests were not run locally: Flutter and the generated Gradle wrapper are unavailable; the CI workflow does not run those unit tests.
| return@forEach | ||
| } | ||
| pairs.clear() | ||
| track.forEach { report -> |
There was a problem hiding this comment.
Blocking — co-movement ignores direction and on-route validity. onBusReports records samePath and onRoute, but this loop feeds every report to isCoMovement without checking either. A vehicle reporting the opposite direction can therefore confirm boarding and be locked as the rider's bus if its projected track moves alongside the phone. I reproduced this with the actual detector: a path-0 trip and a path-1 vehicle at the same coordinates produced hasBoarded=true, BUS_CO_MOVEMENT, and trackedVehicleId=WRONG-DIRECTION at 40 seconds. If the phone then loses fixes, that wrong bus drives destination alerts. Exclude wrong-path and off-route samples before matching (and ensure the resulting track is coherent); add negative tests for both.
There was a problem hiding this comment.
已在 2cc01e7 修正。
detectCoMovement在配對前先排除!samePath || !onRoute的回報,而且這種回報會中斷該車的連續紀錄(pairs.clear()):只有「同方向、在路線上、沒被打斷」的那一段才算證據。這是你提到的 track 要 coherent 的部分——反方向/偏離路線那段前後的回報不會被接成同一段。- 你重現的情境(path 0 的行程、path 1 的車在相同座標)寫成了
aBusOfTheOtherDirectionIsNotTheRidersBus:不會hasBoarded,也不會有trackedVehicleId。 - 另外補了一處這則 comment 沒提到的:上車後挑「正在搭的車」的
resolveTrackedVehicle原本只看samePath,現在也要求onRoute,停在路線旁的車不會因為投影位置最近而被選中。
測試在 TripRideDetectorCoMovementTest(5 個):
ridingABusOfThisDirectionIsDetected:正向對照組,確認下面的負向測試不是因為情境本身就測不到aBusOfTheOtherDirectionIsNotTheRidersBusaVehicleOffTheRouteIsNotTheRidersBus(偏離路線 350 m)reportsEitherSideOfAStretchOnTheOtherDirectionDoNotAddUpaVehicleParkedBesideTheRouteIsNotPickedAsTheBusBeingRidden
把修正改回去確認測試會紅:
| 改回去的內容 | 變紅的測試 |
|---|---|
| 拿掉過濾 | aBusOfTheOtherDirection…、aVehicleOffTheRoute…、reportsEitherSide… |
| 保留過濾但不中斷連續紀錄 | reportsEitherSide… |
挑車不看 onRoute |
aVehicleParkedBeside…(拿到 PARKED 而不是 AAA-001) |
對正常偵測的影響:TripRideDetectorReplayTest 用的三段實際錄製 feed,偵測延遲改前改後相同(TXG300 130 s、KHH100 111 s、TPE157463 73 s)。同一批錄製資料裡,同方向營運中車輛被判 off-route 的比例是 0–7%,連續紀錄中間孤立的 off-route 只有 1 筆;58 輛車約 2900 筆回報中方向改變 12 次,沒有單筆 A→B→A 的抖動,所以「中斷就重來」不會讓正常行駛的車測不到。
沒變的一點:車輛沒回報方向(pathId == null)時仍當作同方向,因為無從判斷。
| cachedBusReports = reports | ||
| } | ||
| synchronized(detectorLock) { | ||
| detector?.onBusReports(reports) |
There was a problem hiding this comment.
Blocking — stale fetches can mutate a different trip's detector. refreshNotification checks the session before updateLiveData, but this method performs blocking HTTP requests; meanwhile the main thread can replace session and rebuild detector after the user changes routes or direction. When the old request completes, this line feeds the old route's buses into the new detector; feedBoardingEta above has the same issue for the old boarding stop. The later identity check at snapshot rendering is too late to undo that state. Check the session/generation again atomically with detector mutation and cache updates (also after each network wait), and add a test that holds an old fetch open, switches the trip, then releases it and verifies the new detector receives none of the old data.
There was a problem hiding this comment.
已在 2cc01e7 修正,作法就是你建議的:與 detector 的變更放在同一把鎖內再檢查一次,每次網路等待之後都檢查。
- 行程交接:
session與為它建立的detector現在在主執行緒、同一個detectorLock區塊內一起替換(原本是session先換、detector 之後才重建)。 - 套用前檢查:
refreshTrip一開始在鎖內取得TrackedTrip(session, detector)。每個請求回來後,結果都經過applyToTrip(trip):在同一把鎖內確認detector === trip.detector,接著才寫 cache、退避、餵 detector,檢查與寫入之間不可能被換掉。站牌與車輛兩個請求各檢查一次;行程一旦被換掉,這次 refresh 就停止,不再發下一個請求,也不產生快照。 - 資料只給擷取到的那個 detector:
onBusReports/onBoardingStopEta的對象是trip.detector,不再是「目前的detector」。所以就算拿掉檢查,舊資料也進不了新 detector;檢查另外保護的是新行程會讀到的 cache 與退避時間。 - 新行程從乾淨狀態開始:重建 detector 時呼叫
forgetLiveFeeds(),兩個 feed 立刻到期。取捨:切換行程後不承接上一趟的失敗退避,原本在退避中的 feed 會立刻重試一次。 - 結尾的動作(超出這則 comment,但是同一類問題):通知與 alert 在 IO 執行緒算好後改丟回主執行緒,執行前比對 session 與 ride 快照是否仍是計算時的那一份(
postIfStillTracking)。原本 IO 執行緒上的pauseTracking/stopTracking可能作用到已經換掉的新行程;現在為舊行程決定的暫停,或是使用者回答「還沒上車」、選了下車站之前算出來的暫停,都會被丟棄。
測試(RouteTripMonitorServiceTest):
aFetchStillInFlightForTheLastTripDoesNotReachTheNextOne:照你描述的寫法——用 latch 卡住舊行程的站牌請求、切換行程、放行,再驗證新行程第一次 refresh 看到的是自己的車(NEW-001),而且舊行程的 ETA=0 沒有變成「公車離站」的詢問。「換成另一條路線」與「同路線換方向」各跑一次,兩種情況的站牌都相同,舊資料完全套得上。aRebuiltTripHearsOfTheVehiclesOnItsFirstRefreshwhatARefreshDecidedForAReplacedTripIsNotActedOnwhatARefreshDecidedBeforeTheRiderAnsweredIsNotActedOnwhatARefreshDecidedBeforeADestinationWasChosenIsNotActedOn
把修正改回去確認測試會紅:
| 改回去的內容 | 結果 |
|---|---|
| 還原成你回報的行為(不檢查、結果餵給「目前的」detector) | stale-fetch 測試紅,新行程拿到 OLD-001 |
只拿掉 applyToTrip 的身分檢查 |
同一個測試紅:新行程第一次 refresh 被舊行程寫入的 cache 時間擋住,拿到 null |
| 新行程不重設 feed 狀態 | aRebuiltTrip… 紅 |
| 拿掉結尾的 session 比對 | …BeforeADestinationWasChosen… 紅 |
| 拿掉結尾的 ride 比對 | …BeforeTheRiderAnswered… 紅 |
| 結尾兩個比對都拿掉 | 三個 whatARefreshDecided… 全紅 |
Two further review findings, plus what turned up beside them. - Co-movement counted any vehicle of the route: the other direction's, and ones parked or deadheading beside it, all project to some position along the line. Only an unbroken run of reports on this trip's direction and on the route is evidence now, and a report that is neither breaks the run instead of being bridged over. The vehicle followed once on board has to be on the route as well. - A fetch still out when the rider switched trip was applied to whatever trip was current when it came back. The session and its detector now change hands together under the detector lock, and each response is applied under that lock only if the detector it was fetched for is still the current one — caches and back-off included. A new trip starts with both feeds due and without the last trip's back-off. - Notifications and alerts are worked out on the IO thread but acted on on the main thread, and only if neither the session nor the ride has changed since: a pause decided for the last trip, or from before the rider answered or chose a destination, is dropped. - While the app is open it does the fetching, so the detector never heard the boarding stop's ETA. The stop feed the app hands over now reaches it. Each has a test that fails when the fix is reverted.
|
第三輪的兩項都在 2cc01e7 處理,細節回在各自的 thread。 本機驗證
超出兩則 comment 的變更(請一併看)
已知、這個 PR 沒處理
|
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary
Requesting changes for two remaining trip-monitor regressions: an overshot destination can bypass arrival/overshoot alerts, and an older in-flight stop fetch can overwrite a newer foreground update for the same detector. Details are inline.
Checks: git diff --check passed; Node 22.23.4 npm run test:node and npm run test:web passed (3 tests each). GitHub Actions for this head passed Verify (including Flutter analysis/tests and Web geometry) and Android, Wear OS, Linux, Web, Windows, iOS and macOS builds. Android unit tests were not run locally: Flutter and the generated Gradle wrapper are absent, and CI Verify does not run them.
| boardingCheckId = pendingCheckSinceMs, | ||
| boardingPromptDue = boardingPromptDue, | ||
| remainingStops = stopsToGo?.coerceAtLeast(0), | ||
| passedDestinationByStops = stopsToGo?.let { -it }?.takeIf { it > 0 }, |
There was a problem hiding this comment.
Blocking — skipping directly past the destination can suppress all destination alerts. The new detector uses the last stop reached, so between two refreshes a fix can jump from two stops before the destination to the next stop after it (for example when fixes resume after a gap, or on short stop spacing). passedDestinationByStops is then non-null, so this branch returns before maybeSendDestinationAlert can set arrivalDetectedAtMs; maybeSendOvershotAlert also refuses to run while that timestamp is zero, and maybePauseAfterArrivalGrace does the same. Subsequent overshot snapshots follow the same branch forever: no arrival or overshoot alert and no auto-pause. Establish an arrival/overshoot baseline when progress has already passed the destination, and test a direct pre-destination → past-destination jump without an intermediate arrival snapshot.
There was a problem hiding this comment.
已在 c8d42d1 修正。
maybeSendTripAlerts收到已越過目的站的 snapshot 時,若還沒有arrivalDetectedAtMs,先建立到站基準,再走原有的過站確認與到站寬限流程;後續 snapshot 不會重設已開始的倒數。- 保留原有門檻:45 秒後,符合路線距離條件時發出「可能坐過站了」並以
overshot暫停;無法確認過站時,60 秒到站寬限仍可結束並以arrived暫停。
回歸測試(RouteTripMonitorServiceTest,SDK 28/34):
skippingTheDestinationStillRaisesTheOvershootAlert:定位直接從目的站前跳到下一站,沒有中間到站 snapshot;驗證建立基準、倒數不重設,最後確實發出通知並暫停。skippingTheDestinationStillPausesAfterArrivalGraceWhenOffRoute:跳過目的站後距路線約 150 m,無法確認過站時仍會在寬限後暫停。
本機 :app:testDebugUnitTest:122 個測試全過;git diff --check 通過。
| */ | ||
| private fun applyToTrip(trip: TrackedTrip, apply: () -> Unit): Boolean = | ||
| synchronized(detectorLock) { | ||
| if (detector !== trip.detector) { |
There was a problem hiding this comment.
Blocking — a stale fetch can still overwrite a newer update for the same trip. The guard compares only detector identity, but ACTION_START_OR_UPDATE replaces session and feeds/caches the foreground screen's providedStops without rebuilding the detector on ordinary updates. If an earlier stop request is in flight, the new foreground update can record ETA=0, then the older response (e.g. ETA=600 from before arrival) passes this check, overwrites the newer cache and is fed into onBoardingStopEta, which registers a false departure. This can raise a spurious boarding question while the bus has only just arrived. Guard stop-feed results against session/update generations or compare fetch/update freshness before applying them (while still allowing valid vehicle results), and add a latch-based test for an in-flight old fetch followed by a newer same-trip foreground ETA update.
There was a problem hiding this comment.
已在 c8d42d1 修正。
- 增加由
detectorLock保護的stopFeedRevision;每次套用 App 提供的站牌資料時遞增,currentTrip()在同一把鎖內擷取當時的版本。 - 舊站牌請求回來後,在
applyToTrip的鎖內同時檢查 detector 身分與站牌版本。版本已變時,不寫 cache、不餵 ETA,也不套用舊請求失敗的退避。 - 只丟棄過時的站牌回應,不結束整個 refresh;同趟仍有效的車輛定位照常抓取並送入 detector。
新增兩個 latch-based service 測試(SDK 28/34):
anOldStopFetchCannotOverwriteANewerAppUpdateForTheSameTrip:卡住舊請求,同一 detector 收到前景 ETA=0 更新後才放回舊 ETA=600;驗證新 cache 保留、不產生假離站,而且有效車輛資料仍被接收。anOldStopFetchFailureCannotBackOffANewerAppUpdateForTheSameTrip:同樣的交錯順序,舊請求改為失敗,驗證不套用過時退避且車輛定位不受影響。
本機 :app:testDebugUnitTest:122 個測試全過;git diff --check 通過。
Start the arrival baseline when progress skips directly past the destination, retaining overshoot reminders and the arrival grace pause. Version app-provided stop data so older in-flight responses cannot overwrite its cache, invent departures, or apply obsolete backoff; keep valid vehicle results. Add four service regression tests across SDK 28 and 34. All 122 Android unit tests pass.
|
最新兩項 Request changes 已在 c8d42d1 修正並推送。
本機驗證
|
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary — changes requested
-
Blocking — family-variant buses never reach the detector (
RouteTripMonitorService.kt:820–832). The stop feed mergessession.realtimeRouteIds, but the vehicle branch fetches onlyprimaryRouteId. If a rider takes a family variant and the phone has no usable fixes, ETA rollover can still lead to manual confirmation, butvehicleJustPastBoardingStopcannot find the variant bus;trackedVehicleIdand progress remain unknown, so destination reminders never fire. Fetch and merge positions for the same family routes, with direction/geometry validation and shared throttling; add a service test with only a variant vehicle, confirmation, and no phone fixes. -
Blocking — a bus-only overshoot is silently paused instead of alerted (
RouteTripMonitorService.kt:1589–1599,:1857–1864). The detector falls back to the tracked vehicle without phone fixes, so it can report an overshoot and vehicle-based destination distance, butmaybeSendOvershotAlertalso requiresnearestStopDistanceMeters, whichbuildSnapshotderives only fromlatestLocation. With no fix it is null (or can be stale and far away), so the overshoot check fails and the trip is paused asarrivedafter 60 seconds without an overshoot alert. Carry a route-validity/proximity signal from the tracked bus or otherwise handle missing/stale phone fixes; test a confirmed tracked bus passing the destination without usable phone location.
Checks: git diff --check passed. Node 24.22.1 runtime tests and web worker tests passed (3/3 each); the host default Node 22.23.1 was rejected by the project's version guard. GitHub Actions Verify passed (Flutter analyze/tests and Web geometry), as did Linux, Web, and Windows builds; Android, Wear OS, iOS, and macOS builds were pending when checked. Android unit tests were not run locally: Flutter and the generated Gradle wrapper are unavailable, and the Verify job does not run them.
GitHub rejected the attempted inline review with HTTP 422 Line could not be resolved; its files API omits the patch for this large file, so the findings are included here with exact line references instead.
Fetch every realtime family route with per-endpoint throttling and backoff, merge each vehicle's newest report, and reject results from removed variants. Carry route validity with the actual progress source so missing or stale phone fixes cannot suppress a valid bus-only overshoot alert. Preserve ride identity through invalid reports or outages without trusting their positions, and support confirmation before vehicle reports arrive. Add service and detector regression coverage. All 140 Android unit tests pass.
|
回覆這輪 review:兩項已在 209fd7b 修正並推送。 1. 家族變體車輛沒有進入 detector
2. 只有公車定位時,過站被靜默當成到站暫停
同一路徑補齊的邊界
本機驗證
|
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary — changes requested
Manual confirmation without a usable phone fix can lock onto an unrelated bus far ahead of the boarding stop, causing false arrival/overshoot alerts and pausing the trip. See inline finding.
Checks: git diff --check passed. With Node 24.22.1, npm run test:node and npm run test:web passed (3/3 each). Compiled the actual pure-Kotlin detector and route-line classes with a minimal TrackingStop shim and reproduced the far-ahead-bus selection. GitHub Actions Verify (Flutter analyze/tests and Chrome geometry), Android, Wear OS, Linux, Web, Windows, iOS and macOS builds passed. Android unit tests were not run locally: Flutter and the generated Gradle wrapper are absent; CI Verify does not run those Android unit tests.
| } | ||
| val boardingAlong = line.alongOfStop(boardingStop) | ||
| return liveVehicles(nowMs) | ||
| .filter { it.samePath && it.onRoute && it.alongMeters >= boardingAlong - AT_STOP_MARGIN_METERS } |
There was a problem hiding this comment.
Blocking — manual confirmation can lock onto a bus already far down the route. With no usable phone fix and no identified departure, vehicleJustPastBoardingStop has only a lower bound; if the only fresh same-direction vehicle is multiple stops past the boarding stop, confirming "我已上車" selects it as the rider's bus. Its progress then drives destination reminders even though it may be an earlier bus: I compiled the actual detector with six stops and a destination at stop 3; a single bus at stop 5 produced trackedVehicleId=AHEAD-ONLY, passedDestinationByStops=2, and hasRouteValidProgress=true immediately after confirmation. The service can consequently issue a false overshoot alert and pause the real trip. Require evidence that the vehicle was at/just departed the boarding stop (and an upper distance/time bound), otherwise retain an unknown vehicle until it can be identified. Add a regression test for an earlier bus several stops ahead with no phone fix, including a later actual boarding bus.
There was a problem hiding this comment.
已在 3d607f6 修正。確認「已上車」與確認「是哪輛車」現在是分開的;沒有足夠證據時保留 hasBoarded=true、trackedVehicleId=null,不讓前方舊車的進度觸發目的站提醒。
車輛關聯條件
- 必須在最近 90 秒收到過實際在站觀測:距上車站不超過 80 m、沿線位置在站點 ±40 m 內;從那筆觀測到現在的回報須連續保持同方向、在路線上。
- 最新回報也須在 90 秒內,且目前沿線位置不得超前上車站 250 m。原本直接採用
pendingCheckVehicleId的捷徑也移除;具名離站提示不能繞過相同的距離/時間條件。 - 延後辨識只在首次手動確認後 120 秒內進行,重按不會刷新窗口;已辨識的乘車也不會因重按而換成另一輛較靠近站牌的車。
- 多個候選且無有效具名離站證據時保留未知,不會因其中一輛駛出距離界線就轉鎖另一輛;手機定位恢復後仍能辨識。
- 在站證據使用實際座標距離,不只看投影:站前車輛會被 polyline 夾到進度 0、平行街道的車也可能投影到站點,這兩種都另有負向測試。
回歸驗證
TripRideDetectorManualBoardingTest:15 個測試,包含你提供的六站路線/前方車已過目的站兩站,以及稍後真正上車的車出現後才開始追蹤;另涵蓋距離/時間邊界、重複舊 GPS、反向/離線路中斷證據、過期具名提示、候選歧義與重複確認。RouteTripMonitorServiceTest.manualBoardingWaitsForTheActualBusInsteadOfAnEarlierBusPastTheDestination(SDK 28/34):確認前方舊車不會建立到站計時或暫停行程;真正車輛抵站後才追蹤,隨它前進並發出正常的「快到了」。修正前,遠車誤鎖與這個 service 回歸案例都會失敗。- 三個原有正向測試的首筆車位改為實際在上車站,保留後續異常/斷線/恢復斷言;另有僅在站前方 100/200 m、沒有在站證據時拒絕辨識的測試。
本機 :app:testDebugUnitTest:157 個測試全過(新增 17 個案例),0 failure/error/skip;git diff --check 通過。本輪僅修改 Kotlin 與 Android 測試,未重跑 Flutter analyze/test。
Manual confirmation preserves boarding without assigning an unrelated vehicle far along the route. Require a recent physical at-stop observation, a bounded uninterrupted route-valid track, and a short identification window; validate named departures against the same bounds. Keep ambiguous rides unidentified, preserve an already identified vehicle on repeated confirmation, and do not refresh the identification window by repeated taps. Add pure detector and service regressions for the earlier bus followed by the actual boarding bus, spatial/time boundaries, stale evidence and ambiguity. All 157 Android unit tests pass.
|
本輪「沒手機定位時,手動確認誤鎖前方舊車」已在 3d607f6 修正並推送,細節與測試回覆在 review thread。
|
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary — changes requested
The bounded manual-identification window still lets a later, unrelated bus be selected after the rider has already confirmed boarding, causing false destination/overshoot alerts when phone location is unavailable. See the inline finding.
Checks: git diff --check passed. Node 24.22.1 npm run test:node and npm run test:web passed (3/3 each); the host's default Node 22.23.1 is rejected by the version guard. Compiled the actual pure-Kotlin detector and route line with a minimal TrackingStop shim and reproduced the later-bus selection and false overshoot. GitHub Actions Verify (Flutter analyze/tests and Chrome geometry), Android, Wear OS, Linux, Web, Windows, iOS and macOS builds passed for this head. Android unit tests were not run locally: Flutter and the generated Gradle wrapper are unavailable; the CI Verify workflow does not run them.
| if (nowMs - latest.receivedAtMs !in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS) { | ||
| return@filter false | ||
| } | ||
| track.reversed().takeWhile { it.samePath && it.onRoute }.any { sample -> |
There was a problem hiding this comment.
Blocking — a later bus can still be mistaken for the confirmed ride. The candidate's at-stop sample may be recorded after manualConfirmationAtMs, and it does not need to be tied to a named departure or any pre-confirmation observation. With no usable phone fix, if the rider confirms at t=0 while their actual bus has no feed, then a different bus arrives at the boarding stop at t=60s, this picks and permanently locks that later bus. I reproduced this with the actual detector: the t=60 snapshot tracked LATER-BUS, and its t=120 progress beyond the destination produced passedDestinationByStops=1 and hasRouteValidProgress=true; the service can then issue false alerts and pause the trip. Please require evidence that ties the vehicle to the departure the rider confirmed, rather than accepting an arbitrary future stop visit, and add a regression test for a later bus arriving during the identification window.
There was a problem hiding this comment.
已在 5665a40 修正。
這則的問題:原本只要求「最近 90 秒內看過這輛車在站上」,用的是 app 收到回報的時間,所以確認之後才進站的車也符合。現在改成:能認定車輛的那筆在站定位,必須是車機時間落在使用者確認前 90 秒內(confirmedAt - gpsTimeMs in 0..90s)。回報本身可以晚到(feed 本來就落後半分鐘),兩分鐘的認定視窗只為這件事保留;確認之後才進站的車,不管什麼時候被看到都不算。
你重現的情境寫成 aLaterBusCallingAtTheStopIsNotTheConfirmedRide:t=0 確認、實際搭的車沒有 feed,另一輛車在確認後 1/20/60/110 秒進站,之後開過目的地——每一種都維持 trackedVehicleId == null、passedDestinationByStops == null、hasRouteValidProgress == false。
被問到的那輛車:你提到要「綁到使用者確認的那次離站」。「你有上車嗎?」如果是針對某輛被看著進站又離站的車,回答「我已上車」現在直接鎖定那輛車(answeringTheQuestionAboutANamedBusFollowsThatBus)。這裡我改掉了上一輪自己加的限制:上一輪讓被問到的車也受 250 m 上限約束,但問題要在離站 40 秒後才顯示,那時車幾乎一定超過 250 m,等於回答了也認不到車。現在的上限改用時間:問題超過 5 分鐘就不再指向任何車(aQuestionThatHasLapsedNamesNoBus),而且離站本身也多了下面第 1 點的限制。
我自己重新審這段時另外找到、一併修掉的:
- 車輛在站前被看到、沉默很久之後出現在站後,會被當成「剛離站」並跳出詢問(集合從不過期)。現在兩筆回報相隔超過 180 秒就不算離站。
aVehicleBackAfterALongSilenceDidNotJustLeaveTheStop。 - 「已上車但沒選下車站」暫停後,選了下車站恢復行程時,是用當下時間重新確認一次——等於在使用者早就離開的上車站重開認定視窗,會把此刻剛好在那一站的車當成他搭的車。現在恢復時車輛維持未知,等手機定位來認。
aRidePickedBackUpDoesNotAdoptTheBusNowAtTheBoardingStop,service 層是boardingWithoutADestinationAsksForOneAndPicksTheRideBackUp新增的斷言。
把修正改回去確認測試會紅(6 個都紅):
| 改回去的內容 | 變紅的測試 |
|---|---|
| 在站證據改回看收到時間 | aLaterBusCallingAtTheStop… |
| 在站證據不限 90 秒 | atStopEvidenceMustBeFromTheNinetySecondsBeforeConfirming |
| 回答不綁被問到的車 | answeringTheQuestion…、aValidNamedDeparture… |
| 問題不會失效 | aQuestionThatHasLapsedNamesNoBus |
| 離站不看回報間隔 | aVehicleBackAfterALongSilence… |
| 恢復行程時重新確認 | boardingWithoutADestination…(拿到 NEXT-BUS) |
改動到的既有測試(請確認這些預期的改變你同意):
anUnknownRideWaitsForItsActualBoardingBus、lateIdentificationExpires…、service 的manualBoardingWaitsForTheActualBus…:原本是「確認之後才出現在站上的車」被認定,這正是這則 comment 指出的行為;改成「確認前就在站上、回報晚到」。- 移除
anIdentifiedDepartureStillHasAnUpperDistanceBound與anExpiredDepartureQuestionCannotDisambiguateFreshVehicles,由上面兩個關於問題的測試取代。 - service 測試的車輛回報時間改用目前時間(原本寫死 2023 年的時間戳,現在會被判定為太舊)。
取捨與已知限制:
- 沒有手機定位、實際搭的車也沒有 feed 時,行程會維持「已上車、車輛未知」,不會有到站提醒。這是刻意選的失敗方式:不提醒,好過跟著別的車誤報過站並暫停行程。
- 手機時鐘與車機時鐘的誤差沒有留容許值。如果使用者在車還沒回報在站定位前就按下確認,那筆之後的定位不算數。
- 本機 Android 單元測試 161 個全過;CI 不跑這組測試。這輪沒動 Dart。
The review's finding, and what a pass over the same code turned up. - Confirming by hand could adopt a bus that called at the boarding stop afterwards. The at-stop fix that identifies a vehicle must now have been made, by the vehicle's clock, in the ninety seconds up to the confirmation; the report carrying it may still arrive later. - Answering the question about a named departure follows that bus. It used to be held to the 250 m bound, which a bus has nearly always passed by the time the question is shown, so the answer named nothing. A question that has lapsed names nothing either. - A vehicle last seen short of the stop and next seen past it more than three minutes later did not just leave: no departure, no question. - A ride picked back up after choosing a destination was confirmed again at that moment, opening identification at a stop the rider had left minutes before. It now resumes with the vehicle unknown until the rider's own position finds it. - latestLocation and appInForeground are read off the main thread and are now volatile. Service tests stamp vehicle reports with the current time, as the feed does. All 161 Android unit tests pass; each fix fails its test when reverted.
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary — changes requested
The new named-departure shortcut also applies to the independent, always-available manual boarding action, so it can lock an unrelated departed bus. See inline finding.
Checks: git diff --check passed; npm run test:web passed (3/3). Local npm run test:node failed because the host's Node 22.23.1 is rejected by the project's version guard. GitHub Actions Verify passed, including patched-Node runtime checks, Flutter analysis/tests and the Web geometry test; Web and Linux builds passed, and the other builds were still running when checked. Android unit tests could not be run locally because Flutter and the generated Android Gradle wrapper are absent; the CI Verify job does not run those unit tests.
| // up to the stop and leaving it, and "yes" is the rider's answer to | ||
| // exactly that. Nothing seen afterwards can be tied to the ride as | ||
| // firmly, however near the stop it is. | ||
| val askedAbout = pendingCheckVehicleId.takeIf { |
There was a problem hiding this comment.
Blocking — the generic manual action can lock the wrong bus. confirmBoardedByUser treats any pending departure as the bus the rider explicitly answered about, but the persistent tracking notification's independent 我已上車 action and the boarding-question action both call this same method. For example, bus A leaves and creates a pending check; before its 40-second prompt is even shown, the rider boards bus B (which has no position feed) and taps the persistent manual action. askedAbout is A, so with no usable phone fix A is permanently followed, potentially triggering false destination/overshoot alerts and pausing the real trip. Pass the question identity/action source separately and only adopt its named bus when the user actually answers that still-current question; the generic action must not use an unseen pending check. Please add a regression test for a manual confirmation before the prompt with a different bus pending.
There was a problem hiding this comment.
已在 4fb8c82 修正,作法照你說的:把「回答的是哪一個問題」另外傳進來。
- 「你有上車嗎?」通知自己的兩個按鈕,PendingIntent 現在帶著該問題的
boardingCheckId(另外的 request code,不會跟常駐通知的按鈕共用同一個 PendingIntent)。常駐通知(以及 Hyper island)的「我已上車」不帶任何 id。 confirmBoardedByUser(nowMs, answeredCheckId):只有answeredCheckId等於目前仍待回答的問題時,才採用該問題指名的車。沒帶 id、或帶的是已經被取代的舊問題 id,都不指名任何車。
你的情境寫成 confirmingUnpromptedDoesNotAdoptABusAboutToBeAskedAbout(A 離站產生待問、提示還沒顯示、使用者按常駐按鈕 → 車輛未知,A 之後開過目的地也不會有 passedDestinationByStops),service 層是 onlyTheQuestionsOwnButtonsAnswerTheQuestion:實際從貼出的通知取出按鈕的 intent 送回 service,常駐按鈕 → 未知;問題按鈕 → BUS-A;並確認顯示問題後常駐按鈕的 intent 仍不帶 id。
照你的情境實測時發現光這樣不夠,所以多修了一處:拿掉 askedAbout 之後,A 還是被選中——走的是另一條路:A 30 秒前在站上、現在在 250 m 內,符合「未被詢問時的在站證據」。所以再加一條:使用者確認之前就已經被看到駛離(超過離站門檻 120 m)的車,不能用先前的在站定位來認定;那種情況只有回答問題才算。車到目前為止回報都還在站上的,仍然認定(aBusStillAtTheStopWhenTheRiderConfirmsIsTheirs)。
同一類、順手修掉的:舊問題的通知還在畫面上時,新的離站已經變成待問(新通知要 40 秒後才貼出)。這時按舊通知的「還沒上車」,原本會把新問題那輛車排除掉。現在對已被取代的問題回答「還沒上車」不影響新問題。anAnswerToAQuestionSinceReplacedNamesNoBus 涵蓋「已上車/還沒上車」兩種回答。
把修正改回去確認測試會紅(5 個都紅):
| 改回去的內容 | 變紅的測試 |
|---|---|
| 任何確認都採用待問的車(你回報的行為) | confirmingUnprompted… ×2、anAnswerToAQuestionSinceReplaced…、service 測試 |
| 未被詢問時可採用已駛離的車 | confirmingUnpromptedDoesNotAdopt…、anAnswerToAQuestionSinceReplaced… |
| 舊問題的「還沒上車」照樣生效 | anAnswerToAQuestionSinceReplaced… |
| 問題按鈕不帶 id | service 測試(拿不到 BUS-A) |
| 兩種按鈕共用 request code | service 測試(常駐按鈕變成帶 id) |
改動到的既有測試:aRecentBoundedDepartureCanBeIdentified(車已開到 200 m 才按常駐確認 → 認定)與新規則相反,改成 aBusStillAtTheStopWhenTheRiderConfirmsIsTheirs。
取捨:使用者真的搭上 A、但在 A 被看到駛離之後、問題顯示之前按了常駐按鈕,現在會是「已上車、車輛未知」,要靠手機定位才有到站提醒。這是往保守的方向錯。
本機 Android 單元測試 166 個全過;CI 不跑這組。這輪沒動 Dart。
"I have boarded" is always on offer on the tracking notification, and a question about a bus that just left can be pending for 40 s before it is shown. Confirming unprompted in that window adopted the pending bus, though the rider had not been asked about it and could be on another. - The boarding question's own buttons now carry the id of the question they answer, on PendingIntents of their own; the tracking notification's button carries none. The named bus is adopted only when the answer is to the question still pending. - Unprompted, a bus already seen pulling away before the rider confirmed is not adopted from its earlier fix at the stop either: whether they were on it is what the question is for. A bus still standing at the stop, as far as the feed has said, still is. - "Not yet" to a question since replaced by another no longer sets aside the bus the new question is about. All 166 Android unit tests pass; each change fails a test when reverted.
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary — changes requested
A 90-second deduplication rule conflates two distinct buses and can associate a boarding answer with the wrong vehicle, leading to false destination/overshoot alerts. Details are in the inline finding.
Checks: git diff --check passed. With Node 24.22.1, npm run test:node and npm run test:web passed (3 tests each); the host's default Node 22.23.1 is rejected by the version guard. GitHub Actions Verify (Flutter analysis/tests and Web geometry) and Android, Wear OS, Linux, Web, Windows, iOS and macOS builds passed. Android unit tests could not be run locally: Flutter and the generated Gradle wrapper are absent; the CI workflow does not run those Android tests. An attempt to compile a small pure-Kotlin reproduction timed out, so this finding is based on code inspection rather than a successful local reproduction.
| // A different vehicle this close behind is a second bus, and gets | ||
| // a question of its own. | ||
| } | ||
| if (pendingCheckSinceMs != 0L && nowMs - pendingCheckSinceMs < DEPARTURE_DEDUPE_MS) { |
There was a problem hiding this comment.
Blocking — distinct buses leaving within 90 seconds are treated as one departure. If A and B are both seen at the stop, A leaves first, and B leaves 40 seconds later, registerDeparture(B, ...) enters this branch and keeps the pending question's pendingCheckVehicleId as A (the Elvis assignment only fills null). The prompt shown after B departs still carries A's check ID; a rider who boarded B and answers yes is permanently associated with A. With no usable phone location, A's subsequent progress can generate false destination/overshoot alerts and pause the trip. Deduplicate only observations that can be tied to the same departure; preserve/queue or replace the question when two known vehicle IDs differ, and add a regression test for two buses departing within the dedupe window and the rider boarding the second.
There was a problem hiding this comment.
已在 8901d97 修正。
問題本身:90 秒內的離站回報原本不管是哪輛車都當成同一次離站,所以 A、B 先後離站時,問題一直指著 A。現在「同一次離站」只在能對得上的時候才合併:
- 位置 feed(有車號)與 ETA 跳號(沒車號)各一筆 → 同一次離站。
- 同一輛車再被回報一次(定位飄回站上又離開)→ 同一次離站。
- 兩輛不同的車 → 兩次離站,間隔多短都一樣。第二次 ETA 跳號也算另一輛。
為什麼是「兩輛都記著」而不是把問題換成 B:你給的兩個選項(preserve/queue 或 replace)我選了前者。通知上只寫「公車剛剛離站了」,使用者不知道車號,所以 A、B 都離站之後的「我已上車」對兩輛都成立。換成 B,你的情境(搭 B)會對,但搭 A 的人回答同一個問題就會被鎖到 B。所以現在:
- 「我已上車」只有在問題存續期間恰好一輛離站、位置 feed 有給車號、而且當下沒有別的車在使用者可能上車的位置時,才指名那輛車。否則維持「已上車、車輛未知」。
- 「還沒上車」把這段期間離站的車全部排除(還在等車的人不在其中任何一輛上)。
- 第二輛在上次發問 90 秒內離站 → 併入同一個問題(同一個 id,不重複跳通知);超過 90 秒 → 重新發問(新 id),但仍涵蓋 5 分鐘內還沒被回答的離站。重新發問跟第一次發問一樣,要求使用者最近在站牌附近。
你要求的回歸測試:twoBusesLeavingCloseTogetherAreNotOneDeparture(A、B 同在站上,A 先走、B 40 秒後走,使用者搭 B 回答「我已上車」→ 車輛未知;A 之後開過目的地也不會有 passedDestinationByStops)。service 層是 theAnswerAfterTwoBusesLeftCloseTogetherFollowsNeither,實際按問題通知上的按鈕,並確認沒有設定到站基準、沒有暫停行程。
我自己重審這段時另外找到、一併修掉的(都是「可能跟錯車」的同類問題):
- 只有一輛離站,但站上已經有另一輛:A 走了、B 進站,使用者上了 B 才回答「我已上車」,原本會指名 A。現在只要有另一輛車在站上、剛離站 250 m 內、或在站前 300 m 內(回報大約落後半分鐘,這個距離內的車可能已經進站),就不指名。
aBusTheRiderCouldHaveBoardedSinceLeavesTheAnswerOpen。 - 常駐按鈕也有同樣的問題:A 已離站還沒被回答、B 停在站上,使用者按常駐的「我已上車」,原本會認定 B,但他也可能在 A 上。現在有未回答的離站時,常駐確認不認定站上的車(
unpromptedTheBusAtTheStopIsNotAdoptedWhileOneThatLeftIsUnanswered);站牌附近有第二輛車時也不認定(unpromptedASecondBusNearTheStopLeavesTheRideUnidentified)。 - 回答與 snapshot 的先後:離站是在
snapshot()裡才計數的。service 套用新回報與重算 snapshot 是兩個分開的鎖區塊,使用者的回答可能剛好插在中間,那時第二輛車已經回報離站卻還沒被算進去。現在回答(確認與拒絕)會先依目前收到的回報把離站補算完。aSecondBusAlreadyReportedGoneCountsBeforeAnySnapshotIsTaken。 - 手動確認不再清掉先前的「還沒上車」:原本
confirmBoardedByUser會rejectedAt.clear()。這樣一來,剛被拒絕、還卡在站牌前方的 A 會被當成「可能上車的另一輛」,使用者接著上 B 並按確認時反而認不到 B。現在保留拒絕紀錄。unpromptedABusAlreadyTurnedDownDoesNotLeaveTheRideUnidentified(含 B 的在站回報晚到的情況)。
把每條規則改回去確認測試會紅(19 個全紅,以下是代表性的幾個):
| 改回去的內容 | 變紅的測試 |
|---|---|
| 你回報的行為(不同車也併成同一次離站) | twoBusesLeavingCloseTogether…、notYetSetsAsideEveryBus…、aSecondBusAlreadyReportedGone…,service 測試拿到 BUS-A |
| 多輛離站時取第一輛 | twoBusesLeavingCloseTogether…、theEtaCatchingUp…,service 測試拿到 BUS-A |
| 回答時不看站上有沒有別的車 | aBusTheRiderCouldHaveBoardedSince… |
| 常駐確認不看未回答的離站 | unpromptedTheBusAtTheStopIsNotAdopted… |
| 「還沒上車」只排除第一輛 | notYetSetsAsideEveryBusThatHasLeft |
| ETA 補報算成第二輛/第二次跳號被吞掉 | theEtaCatchingUpWithABusAlreadySeenLeavingIsNotASecondBus(兩個方向各一) |
| 同一輛車離站兩次算兩輛 | aBusSeenLeavingTwiceIsOneDeparture |
| 永不重新發問/每次都重新發問/不管人在不在站牌都重新發問 | anAnswerToAQuestionSinceReplaced…/twoBusesLeavingCloseTogether…/aRiderNoLongerAtTheStopIsNotAskedAfresh |
| 確認時清掉拒絕紀錄 | unpromptedABusAlreadyTurnedDown… |
| 回答時不先補算離站 | aSecondBusAlreadyReportedGone…(確認與拒絕各一) |
關於 service 測試,有一點要講清楚:這個測試的第一版在 bug 還在的時候也會通過。原因是 service 讀的是 JVM 真實時鐘,而測試把車機時間每步往後加 20 秒,車機時間跑到手機時鐘前面,B 被當成「還在站上」,結果碰巧一樣。現在這個測試把回報時間戳放在現在之前的一分鐘內,用你回報的行為驗證過會失敗(拿到 BUS-A)。其他既有的 service 測試仍有車機時間超前的寫法,它們斷言的內容不依賴這個比較,這輪沒有動。
改動到的既有測試(請確認這些預期的改變你同意):
- 移除
aValidNamedDepartureCanDisambiguateNearbyVehicles:它的預期是「A 已離站、另一輛車停在站上時回答,指名 A」,跟上面第 1 點相反,由aBusTheRiderCouldHaveBoardedSinceLeavesTheAnswerOpen取代。 answeringTheQuestionAboutANamedBusFollowsThatBus:同樣原因,拿掉了回答當下停在站上的另一輛車,只留「恰好一輛離站」的情況。anAnswerToAQuestionSinceReplacedNamesNoBus的後半:新問題現在涵蓋 A、B 兩輛,所以對新問題回答「我已上車」也是未知。aQuestionThatHasLapsedNamesNoBus:多放了一輛停在站上的車,確認對過期問題的回答不會改去認定站上的車。aLateFamilyVariantIsFetchedWithoutRefetchingTheFreshPrimary:車機時間改成固定在確認之前。原本用的是回應當下的時間,確認與回應跨到不同秒時會偶發失敗。
取捨與已知限制:
- 這輪每一條規則都往「不指名」的方向收。沒有手機定位時,班次密的路線會更常落在「已上車、車輛未知」,那種情況不會有靠公車位置的到站提醒;有手機定位時不受影響(進度用使用者自己的位置)。
- 「站前 300 m」是依回報延遲估的距離,不是保證:回報延遲特別久時,更遠的車也可能已經進站。
- 常駐確認當下只看已收到的回報。確認後才送達、顯示當時站牌附近還有另一輛車的回報,不會把已經認定的車撤銷。
- ETA 跳號與一輛有車號的離站在 90 秒內仍視為同一次離站;如果它們其實是兩輛(其中一輛沒有位置 feed),會指名有車號的那輛。
- 本機 Android 單元測試 178 個全過;CI 不跑這組。這輪只動
TripRideDetector.kt與測試,沒有動 service 與 Dart。
A departure within ninety seconds of the pending question was taken for the same bus whichever vehicle it was. Two buses leaving close together left the question naming the first, and "yes" from a rider on the second followed the wrong bus. - Two reports are one departure only when they can be: one from the position feed and one from the ETA rolling over, or the same vehicle again. A different vehicle is another bus, and joins what the question is about. Ninety seconds or more after the last time of asking, a rider still at the stop is asked afresh. - "Yes" names a bus only when exactly one has left while the question stood, the position feed named it, and no other bus is where the rider could have boarded it since. "Not yet" sets aside every bus that left. - Unprompted, the bus at the stop is adopted only when it is the only one there and no bus that has left is still unanswered for. - An answer is read against the reports received so far rather than the last snapshot, and a manual confirmation no longer forgets the buses the rider turned down. The new service test stamps its vehicle reports in the past: the service reads the JVM wall clock, and reports dated ahead of it hid the bug. All 178 Android unit tests pass; each rule fails a test when reverted.
Av1anJay
left a comment
There was a problem hiding this comment.
Review summary — changes requested
Two remaining ETA/GPS reconciliation cases can either attach a rider to the wrong bus or suppress the next bus's boarding question. See inline findings.
Checks: git diff --check passed. With Node 24.22.1, npm run test:node and npm run test:web passed (3 tests each). GitHub Actions for this exact head passed Verify (Node, Flutter analysis/tests and Chrome geometry) and Android, Wear OS, Linux, Web, Windows, iOS and macOS builds. Local Android unit tests could not run: Flutter and the generated Gradle wrapper are unavailable; CI Verify does not run them. An attempt to compile a small pure-Kotlin reproduction timed out, so the findings are based on code inspection, not a successful reproduction.
| val recent = pendingDepartures.filter { nowMs - it.atMs < DEPARTURE_DEDUPE_MS } | ||
| if (vehicleId == null) { | ||
| // The ETA's account of a bus the position feed has reported. | ||
| val unreported = recent.firstOrNull { !it.reportedByEta } ?: return false |
There was a problem hiding this comment.
Blocking — an ETA-only second bus is identified as the first GPS bus. A is seen at the stop and leaving, creating a named departure. If a distinct B has no usable position feed, arrives and causes the ETA to roll over within 90 seconds, this branch sets reportedByEta on A instead of recording B; the feeds provide no evidence that the rollover was A rather than B. With no usable phone fix or B vehicle report, the rider can board B and answer the prompt, and busTheQuestionIsAbout returns A as the only departure. A's subsequent progress can then trigger false destination/overshoot alerts and pause the trip. I see the author has noted this limitation; please keep the identity ambiguous unless the two reports can actually be correlated (or otherwise preserve the possibility of two buses), and add a test with GPS-only A followed by ETA-only B and the rider boarding B.
| declinedDepartures.removeAll { nowMs - it.atMs >= DEPARTURE_DEDUPE_MS } | ||
| if (vehicleId == null) { | ||
| // The ETA catching up with what the rider has just answered for. | ||
| return declinedDepartures.isNotEmpty() || |
There was a problem hiding this comment.
Blocking — declining an ETA-only departure can swallow the next ETA-only departure. When A is reported only by ETA and the rider answers 還沒上車, declinedDepartures contains A with reportedByEta=true. If another bus B, also without usable GPS, arrives and its ETA rolls over within 90 seconds of A, this branch drops B merely because declinedDepartures is nonempty, even though the earlier ETA report cannot be its own second-feed echo. No new question is raised, so with no usable phone location there is no automatic way to confirm B. Only suppress an ETA echo for a declined departure that has not already been reported by ETA, consuming that echo once, and add a regression test for two ETA-only rollovers separated by a decline.
問題
背景乘車偵測常常沒偵測到上車,「你上車了嗎」的通知也有機率不跳出來。查到四個各自獨立的原因:
App 開著就完全不偵測。 commit 813be81 在輪詢迴圈、定位回呼、即時資料抓取三處加了
if (!appInForeground)。而keepScreenAwakeOnRouteDetail預設開啟,所以只要停在路線頁面上,整個狀態機就不會跑——跟設定名稱暗示的正好相反。雙北根本不可能偵測到。 那裡的 ETA feed 不帶車牌,而舊程式的兩個強訊號都要求車牌。但
/realtime/buses在所有城市都帶車牌與 GPS。判斷視窗太脆。 只在使用者還站在站牌附近時存在,5 分鐘後過期就不再開。
過站的車會卡住。 鎖定一輛車之後就不放,後面的車來了也不會重新判斷。
做法
判斷邏輯移到
TripRideDetector:純 Kotlin、無 Android 依賴、可完整重播的狀態機。比較方式是把使用者和每輛車都換算成「沿站序的距離(公尺)」,因為 2D 鄰近判斷會被橫向誤差主宰——對站牌連線而言,公車定位誤差中位數 8–16 m,但 p90 達 65–295 m。三層判斷,由強到弱:
公車 GPS 比抓取時間落後 22–39 秒(p50),所以同步位移是量測兩條軌跡之間的固定位移差,而不是鄰近程度;資料新鮮度則以「App 收到的時間」判斷,不是 GPS 時間。
路線家族(family)變更時會重建這趟車,這是「先回主要路線、之後變體才到」的收斂方式;但使用者已確認的乘車不會被重建掉。
另外,等待時通知就有「我已上車」可按,偵測不再是唯一路徑。
順帶修掉的第二個 bug
高雄的站牌 ID 超過 32 bit(像
100100128221),而 App 兩邊把它折成 32 bit 的方式不一致:session 端取低 32 bit(可能變負數並被> 0濾掉),即時 feed 端數字太大就改用 hash。兩者永遠不可能相等,所以高雄站牌的即時到站時刻一直查不到。現在統一走foldStopId(),也涵蓋TripMonitorFamilyRealtime自己複製的那一份 parser。後端
bus.db的 114,903 個 distinct stop id 裡有 18,203 個超過 int32,其中 5,821 個折完會變負數(高雄 172 條路線受影響)。驗證
56 個 Android 測試全綠,其中 15 個是重播測試——用 TXG300、KHH100、TPE157463 的真實錄製資料回放:
與 family 功能的整合
origin/main上 6c6c8d7(itousouta15)已經先加了 family 路線處理。兩者動到同一條即時資料路徑,這個 PR 是疊在它上面整合的:updateLiveData的節流/退避現在包住familyRealtime.fetch,家族變體一起抓但共用同一個 rate-limit 桶fetchBackendJson拆成fetchBackendJsonText/fetchBackendJson(物件) /fetchBackendJsonArray(陣列),因為/realtime/buses回傳的是陣列rideConfirmed/trackedBusId已被 detector 取代)