Skip to content

Commit d39878b

Browse files
malysclaude
andcommitted
fix(chargepilot): the build a release was cut from is not an update to itself
The asset name and the APK's own versionName were computed by two different formulas. The workflow published EVChargePilot-unstable-0.2.0.85.apk while the APK inside reported versionName 0.2.0-unstable, and the update check compares one against the other: every launch offered an update that was already installed, and installing it changed nothing. The run number now reaches both, so a release and the build it was cut from compare equal. A local build has no run number and becomes 0.2.0.0-unstable, older than any release, which is what a local build is. Four routing fixes ride along, all of them found while chasing a "quota spent" message on a key whose dashboard was full. A 403 was always read as a spent quota. HeiGIT answers 403 both when the day's allowance is gone and when the key is not served on that API at all, and the screen said "the daily quota for this key is spent" for both. That sends a driver away to wait until tomorrow for a key that will never work. The body now decides, and SERVER_KEY_REFUSED gets its own wording naming the key and the service address. RoutingQuota locked shut on the server's own count. A remaining of 0 closed the local gate with no way back: refusing locally means no request goes out, and only a request can bring back the header that would reopen it. The count is now believed for five minutes, and a 0 beside a response the server actually served is not kept at all, because a limiter describing a window this app never asked about reads exactly the same as an allowance this app has spent. Geocoding was throttled at a third of what the key allows. 1000 a day and 40 a minute were third-party guesses against the 3000 and 100 the key dashboard lists. A local throttle refusing requests the service would have answered is the same broken screen as one that spends a quota, minus the evidence. The unstable update check latched itself for the process after three attempts spanning about forty seconds, so a head unit whose Wi-Fi arrived a minute late left the tester on an old build until they killed the app. Giving up releases the latch now, and MainActivity.onStart checks again on the next visit. The refusal wording moves to one file. DestinationActivity and ChargeStopActivity each carried a private copy word for word, so every new reason had to be spelled twice or one screen would fail to compile, which is the good case; the bad one is the two drifting apart and a driver being told different things about the same refusal. Refs: CP-045, CP-047 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 27b7d52 commit d39878b

15 files changed

Lines changed: 249 additions & 81 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,37 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
116116

117117
### Fixed
118118

119+
- **An unstable build no longer offers the release it was cut from.** The published asset name and
120+
the APK's own `versionName` were computed by two different formulas: the workflow named the file
121+
`EVChargePilot-unstable-0.2.0.85.apk` while the APK inside it reported `0.2.0-unstable`. The
122+
update check compares one against the other, so every launch found an update that was already
123+
installed, and installing it changed nothing. The build number now reaches both, and a local
124+
build with no run number becomes `0.2.0.0-unstable` — older than any release, which is what a
125+
local build is.
126+
127+
- **A `403` is no longer always read as a spent quota.** HeiGIT answers `403` both when the day's
128+
allowance is gone and when the key is simply not served on that API, and the screen said "the
129+
daily quota for this key is spent" for both. A driver whose key was refused outright was sent
130+
away to wait until tomorrow for a key that would never work, however long they waited. The body
131+
now decides, and a refused key gets its own message naming the key and the service address.
132+
133+
- **The server's own remaining count no longer locks the local gate shut for good.** A
134+
`x-ratelimit-remaining: 0` closed `RoutingQuota` with no way back: refusing locally meant no
135+
request went out, and only a request could bring back the header that would reopen it. The count
136+
is now believed for five minutes, and a `0` sitting beside an answer the server actually served
137+
is not kept at all — a limiter describing a window this app never asked about reads exactly the
138+
same as an allowance this app has spent.
139+
140+
- **Geocoding is no longer throttled at a third of what the key allows.** The local limits were
141+
third-party guesses and the guesses were low: 1000 a day and 40 a minute against the 3000 and 100
142+
the key dashboard actually lists. A local throttle refusing requests the service would have
143+
answered is the same broken screen as one that spends a quota, minus the evidence.
144+
145+
- **The unstable update check asks again when the network was not there yet.** The check latched
146+
itself for the process after three attempts spanning about forty seconds, so a head unit whose
147+
Wi-Fi came up a minute after launch left the tester on an old build until they killed the app.
148+
Giving up now releases the latch, and returning to the dashboard checks again.
149+
119150
- **The unstable update check no longer loses its storage question to the vehicle one.** Both were
120151
asked from `onCreate`, and an activity holds one permission request at a time — the second was
121152
dropped by the framework, so on the first launch of a fresh unstable install the driver was never

‎app/build.gradle.kts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ plugins {
22
alias(libs.plugins.android.application)
33
}
44

5+
// The rolling unstable build number, passed by the unstable workflow as its run number.
6+
// It has to reach BOTH the APK's own versionName and the published asset name, because the
7+
// update check compares one against the other: a build whose versionName stops at "0.2.0"
8+
// reads as older than the very release it was cut from, so every launch offers the update
9+
// that is already installed and installing it changes nothing.
10+
val unstableBuild = (project.findProperty("unstableBuild") ?: "0").toString()
11+
512
android {
613
namespace = "com.evsuite.chargepilot"
714
compileSdk = 36
@@ -39,7 +46,10 @@ android {
3946
create("unstable") {
4047
dimension = "channel"
4148
applicationIdSuffix = ".unstable"
42-
versionNameSuffix = "-unstable"
49+
// Same shape as the asset name: "0.2.0.85-unstable" against a published
50+
// "EVChargePilot-unstable-0.2.0.85.apk". A local build has no run number and
51+
// becomes 0, which is older than any release — which is what a local build is.
52+
versionNameSuffix = ".$unstableBuild-unstable"
4353
}
4454
}
4555

@@ -101,7 +111,7 @@ if (android.signingConfigs.findByName("platform") == null) {
101111
// is always tagged "unstable" and overwritten, so the asset name carries the version.
102112
tasks.register("printUnstableVersion") {
103113
doLast {
104-
println("${android.defaultConfig.versionName}.${project.findProperty("unstableBuild") ?: "0"}")
114+
println("${android.defaultConfig.versionName}.$unstableBuild")
105115
}
106116
}
107117

‎app/src/main/java/com/evsuite/chargepilot/ChargeStopActivity.kt‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@ class ChargeStopActivity : AppCompatActivity() {
444444
)
445445
announce(
446446
when {
447-
result is RoutingTransport.Result.Refused -> refusal(result)
447+
result is RoutingTransport.Result.Refused -> routingRefusal(result)
448448
route == null -> getString(R.string.charge_stop_no_route)
449449
else -> getString(R.string.charge_stop_ready)
450450
}
@@ -1211,25 +1211,6 @@ class ChargeStopActivity : AppCompatActivity() {
12111211
} ?: getString(R.string.charge_stop_charger_source_undated, provider)
12121212
}
12131213

1214-
/**
1215-
* A refusal the driver can act on. The detail travels only where it means something to
1216-
* them — seconds to wait, a status code — never the transport's own English reason string.
1217-
*/
1218-
private fun refusal(result: RoutingTransport.Result.Refused): String = when (result.reason) {
1219-
RoutingTransport.Reason.NOT_CONFIGURED -> getString(R.string.charge_stop_not_configured)
1220-
RoutingTransport.Reason.BUSY -> getString(R.string.routing_refused_busy)
1221-
RoutingTransport.Reason.QUOTA_MINUTE ->
1222-
getString(R.string.routing_refused_quota_minute, result.detail.orEmpty())
1223-
RoutingTransport.Reason.QUOTA_DAY -> getString(R.string.routing_refused_quota_day)
1224-
RoutingTransport.Reason.TRANSPORT -> getString(R.string.routing_refused_transport)
1225-
RoutingTransport.Reason.SERVER_DAILY_LIMIT -> getString(R.string.routing_refused_server_day)
1226-
RoutingTransport.Reason.SERVER_RATE_LIMIT ->
1227-
getString(R.string.routing_refused_server_minute)
1228-
RoutingTransport.Reason.SERVER_REJECTED ->
1229-
getString(R.string.routing_refused_server, result.detail.orEmpty())
1230-
RoutingTransport.Reason.UNREADABLE -> getString(R.string.routing_refused_unreadable)
1231-
}
1232-
12331214
private fun announce(text: String) {
12341215
message = text
12351216
render()

‎app/src/main/java/com/evsuite/chargepilot/DestinationActivity.kt‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ class DestinationActivity : AppCompatActivity() {
138138
places = found.orEmpty()
139139
showPlaces()
140140
when {
141-
result is RoutingTransport.Result.Refused -> announce(refusal(result))
141+
result is RoutingTransport.Result.Refused -> announce(routingRefusal(result))
142142
places.isEmpty() && !quiet ->
143143
announce(getString(R.string.charge_stop_no_results))
144144
places.isNotEmpty() -> announce(getString(R.string.charge_stop_choose))
@@ -228,25 +228,6 @@ class DestinationActivity : AppCompatActivity() {
228228
showFavorites(typed())
229229
}
230230

231-
/**
232-
* A refusal the driver can act on. The detail travels only where it means something to
233-
* them — seconds to wait, a status code — never the transport's own English reason string.
234-
*/
235-
private fun refusal(result: RoutingTransport.Result.Refused): String = when (result.reason) {
236-
RoutingTransport.Reason.NOT_CONFIGURED -> getString(R.string.charge_stop_not_configured)
237-
RoutingTransport.Reason.BUSY -> getString(R.string.routing_refused_busy)
238-
RoutingTransport.Reason.QUOTA_MINUTE ->
239-
getString(R.string.routing_refused_quota_minute, result.detail.orEmpty())
240-
RoutingTransport.Reason.QUOTA_DAY -> getString(R.string.routing_refused_quota_day)
241-
RoutingTransport.Reason.TRANSPORT -> getString(R.string.routing_refused_transport)
242-
RoutingTransport.Reason.SERVER_DAILY_LIMIT -> getString(R.string.routing_refused_server_day)
243-
RoutingTransport.Reason.SERVER_RATE_LIMIT ->
244-
getString(R.string.routing_refused_server_minute)
245-
RoutingTransport.Reason.SERVER_REJECTED ->
246-
getString(R.string.routing_refused_server, result.detail.orEmpty())
247-
RoutingTransport.Reason.UNREADABLE -> getString(R.string.routing_refused_unreadable)
248-
}
249-
250231
private fun announce(text: String) {
251232
binding.destinationStatus.text = text
252233
}

‎app/src/main/java/com/evsuite/chargepilot/MainActivity.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ class MainActivity : PrimaryNavigationActivity() {
161161
*/
162162
override fun onStart() {
163163
super.onStart()
164+
// Unstable only, and a no-op unless the last check gave up without reaching GitHub:
165+
// the hook latches itself. This is what makes a head unit whose Wi-Fi arrived after
166+
// launch still find the update, without polling for it. Stable contains no updater.
167+
UpdateHook.checkInBackground(this)
164168
loadRecentTrips()
165169
// The consumption fit reads the whole trip history, so it happens once per visit to
166170
// this screen and only when there is actually a plan being followed.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.content.Context
4+
import com.evsuite.chargepilot.route.RoutingTransport
5+
6+
/**
7+
* A refusal the driver can act on. The detail travels only where it means something to them —
8+
* seconds to wait, a status code — never the transport's own English reason string.
9+
*
10+
* One mapping, because there was two: the destination screen and the charging-stop screen each
11+
* carried a private copy, word for word, and a reason added to [RoutingTransport.Reason] had to
12+
* be spelled twice or one screen would fail to compile — which is the good case. The bad one is
13+
* the two drifting apart and a driver being told different things about the same refusal.
14+
*/
15+
internal fun Context.routingRefusal(result: RoutingTransport.Result.Refused): String =
16+
when (result.reason) {
17+
RoutingTransport.Reason.NOT_CONFIGURED -> getString(R.string.charge_stop_not_configured)
18+
RoutingTransport.Reason.BUSY -> getString(R.string.routing_refused_busy)
19+
RoutingTransport.Reason.QUOTA_MINUTE ->
20+
getString(R.string.routing_refused_quota_minute, result.detail.orEmpty())
21+
RoutingTransport.Reason.QUOTA_DAY -> getString(R.string.routing_refused_quota_day)
22+
RoutingTransport.Reason.TRANSPORT -> getString(R.string.routing_refused_transport)
23+
RoutingTransport.Reason.SERVER_DAILY_LIMIT -> getString(R.string.routing_refused_server_day)
24+
RoutingTransport.Reason.SERVER_KEY_REFUSED -> getString(R.string.routing_refused_server_key)
25+
RoutingTransport.Reason.SERVER_RATE_LIMIT ->
26+
getString(R.string.routing_refused_server_minute)
27+
RoutingTransport.Reason.SERVER_REJECTED ->
28+
getString(R.string.routing_refused_server, result.detail.orEmpty())
29+
RoutingTransport.Reason.UNREADABLE -> getString(R.string.routing_refused_unreadable)
30+
}

‎app/src/main/java/com/evsuite/chargepilot/route/OrsGeocode.kt‎

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,14 @@ import java.util.Locale
2222
* refused, the screen says the server rejected the request and the driver keeps a key that
2323
* never leaked — which is the failure this project prefers.
2424
*
25-
* **Quota, and the honesty about it.** Directions limits were read from ORS's own documentation
26-
* (2000 a day, 40 a minute). The geocoding numbers below could not be: on 2026-09-04 the plans
27-
* page is a JavaScript application that serves no readable figures, and the numbers commonly
28-
* published for it — 1000 a day, 100 a minute — come from third parties. The day figure is used
29-
* as an assumed ceiling, safe in the direction that matters: if the real allowance is larger
30-
* nothing breaks, and [RoutingQuota.observe] corrects the count from the server's own header the
31-
* moment a request comes back. **The per-minute figure is not**, and [RoutingQuota.observe]
32-
* does not correct it — only the day count reads the server's header, because a shared key can
33-
* be spent by another client between two of our own requests, which a minute-sized window
34-
* rarely lives long enough to matter for. Typing one destination on a head-unit keyboard, with
35-
* autocomplete firing per pause, is enough distinct prefixes to reach a wrong-and-too-generous
36-
* per-minute assumption before the address is finished — the driver then spends the rest of that
37-
* minute refused. [PER_MINUTE] is kept at [RoutingQuota.DIRECTIONS_PER_MINUTE], the one number
38-
* here with real confirmation, rather than a third-party guess this app cannot check.
25+
* **Quota.** Geocoding has a budget of its own, separate from directions: the key dashboard
26+
* lists it among the micro endpoints at 3000 a day and 100 a minute, read there on 2026-09-14.
27+
* Both numbers were third-party guesses until then, and the guesses were low — a local throttle
28+
* refusing requests the service would have answered is the same broken screen as one that spends
29+
* a quota, minus the evidence. [RoutingQuota.observe] still corrects the day count from the
30+
* server's own header, because a shared key can be spent by another client between two of our
31+
* own requests; the minute count is not corrected, and at 100 a minute a driver typing one
32+
* address on a head-unit keyboard cannot reach it.
3933
*/
4034
object OrsGeocode {
4135

@@ -62,20 +56,15 @@ object OrsGeocode {
6256
* keyboard, where the gap between two letters routinely exceeds a physical keyboard's,
6357
* still coalesces into one request instead of firing after nearly every letter.
6458
*
65-
* 500ms measured as unusable: a driver typing one destination spent the whole local
66-
* per-minute allowance before the address was finished, because a touchscreen's own
67-
* typing cadence is slower than what that number assumed.
59+
* Not a quota number: at [PER_MINUTE] a driver cannot type fast enough to reach the
60+
* allowance at any debounce worth using. This is the head unit's own cadence — 500ms fires
61+
* between two letters of the same word on this keyboard, and answers a prefix nobody meant.
6862
*/
6963
const val SUGGEST_DEBOUNCE_MS = 900L
7064

71-
/**
72-
* Assumed, not verified — see the class note. Brought down to the one number this app
73-
* does have real confirmation for, [RoutingQuota.DIRECTIONS_PER_MINUTE]: the geocode
74-
* endpoint's own per-minute figure was a guess that let this local throttle wave through
75-
* requests the server was already refusing, so every one of them still spent a wait.
76-
*/
77-
const val PER_DAY = 1000
78-
const val PER_MINUTE = RoutingQuota.DIRECTIONS_PER_MINUTE
65+
/** The geocoding endpoint's own allowance, from the key dashboard on 2026-09-14. */
66+
const val PER_DAY = 3000
67+
const val PER_MINUTE = 100
7968

8069
/** More than a driver reads on a head unit while parked. */
8170
const val MAX_RESULTS = 5

‎app/src/main/java/com/evsuite/chargepilot/route/RoutingQuota.kt‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ class RoutingQuota(
2323

2424
private val spent = ArrayDeque<Long>()
2525

26-
/** Fewer than the day's count if the server has told us otherwise. */
26+
/** Fewer than the day's count if the server has told us otherwise, and when it said so. */
2727
private var remainingFromServer: Int? = null
28+
private var remainingObservedAtMs: Long = 0L
2829

2930
sealed interface Verdict {
3031
object Allowed : Verdict
@@ -43,11 +44,20 @@ class RoutingQuota(
4344
val oldest = spent.first { nowMs - it < MINUTE_MS }
4445
return Verdict.Wait(secondsUntil(oldest + MINUTE_MS, nowMs), Window.MINUTE)
4546
}
46-
val serverExhausted = remainingFromServer?.let { it <= 0 } ?: false
47-
if (spent.size >= dayLimit || serverExhausted) {
47+
if (spent.size >= dayLimit) {
4848
val oldest = spent.firstOrNull() ?: nowMs
4949
return Verdict.Wait(secondsUntil(oldest + DAY_MS, nowMs), Window.DAY)
5050
}
51+
// The server's own number closes the gate, but only for as long as it is fresh. Held
52+
// for ever it is a deadlock: refusing locally means no request goes out, and only a
53+
// request can bring back the header that would reopen it. Re-checking costs one
54+
// refusal every [SERVER_TRUST_MS] and is the only thing that ever says "you may again".
55+
val serverSaysNone = remainingFromServer?.let { it <= 0 } == true
56+
if (serverSaysNone && nowMs - remainingObservedAtMs < SERVER_TRUST_MS) {
57+
return Verdict.Wait(
58+
secondsUntil(remainingObservedAtMs + SERVER_TRUST_MS, nowMs), Window.DAY
59+
)
60+
}
5161
return Verdict.Allowed
5262
}
5363

@@ -60,8 +70,11 @@ class RoutingQuota(
6070

6171
/** The server's own count wins over ours: it knows about other clients on the same key. */
6272
@Synchronized
63-
fun observe(remaining: Int?) {
64-
if (remaining != null && remaining >= 0) remainingFromServer = remaining
73+
fun observe(remaining: Int?, nowMs: Long = System.currentTimeMillis()) {
74+
if (remaining != null && remaining >= 0) {
75+
remainingFromServer = remaining
76+
remainingObservedAtMs = nowMs
77+
}
6578
}
6679

6780
@Synchronized
@@ -82,6 +95,13 @@ class RoutingQuota(
8295
const val DIRECTIONS_PER_DAY = 2000
8396
const val DIRECTIONS_PER_MINUTE = 40
8497

98+
/**
99+
* How long the server's own remaining count is believed. Long enough that a real
100+
* exhaustion is not hammered, short enough that a number read wrong — or read from a
101+
* limiter describing a window this app never asked about — costs minutes, not a day.
102+
*/
103+
const val SERVER_TRUST_MS = 5 * 60_000L
104+
85105
const val MINUTE_MS = 60_000L
86106
const val DAY_MS = 24 * 60 * 60 * 1000L
87107
}

0 commit comments

Comments
 (0)