From ec326f5cc0acf3713667c9275c6871044b8c2283 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:39:19 +0100 Subject: [PATCH 1/4] test(widget): count the requests a prefetch and a widget refresh send together --- .../pumperly/app/widget/WidgetStateTest.kt | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt b/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt index a4347e8..585d254 100644 --- a/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt +++ b/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt @@ -4,6 +4,8 @@ import com.pumperly.app.core.CheapestNearbyEntry import com.pumperly.app.core.Coordinate import com.pumperly.app.core.Fixture import com.pumperly.app.core.FuelType +import com.pumperly.app.core.NearbyPrefetcher +import com.pumperly.app.core.SharedSettings import com.pumperly.app.core.StationCache import com.pumperly.app.core.TimelineMapping import java.io.File @@ -126,7 +128,7 @@ class WidgetStateTest { @Test fun liveResultsIgnoreTheCache() = runTest { - cache.save(StationCache.Snapshot(now, FuelType.B7, 40.418, -3.705, Fixture.stations("nearest-b7"))) + cache.save(StationCache.Snapshot(now.minus(Duration.ofMinutes(5)), FuelType.B7, 40.418, -3.705, Fixture.stations("nearest-b7"))) val state = WidgetState.refresh(now, FuelType.B7, madrid, FakeNearestApi(emptyList()), cache) assertEquals(WidgetState.Kind.EMPTY, state.kind) assertNull(state.asOfMillis) @@ -134,7 +136,7 @@ class WidgetStateTest { @Test fun refreshIsHourlyAndSoonerAfterAFailureOrACachedAnswer() = runTest { - cache.save(StationCache.Snapshot(now, FuelType.B7, 40.418, -3.705, Fixture.stations("nearest-b7"))) + cache.save(StationCache.Snapshot(now.minus(Duration.ofMinutes(5)), FuelType.B7, 40.418, -3.705, Fixture.stations("nearest-b7"))) val good = WidgetState.refresh(now, FuelType.B7, madrid, FakeNearestApi(Fixture.stations("nearest-b7")), cache) val cached = WidgetState.refresh(now, FuelType.B7, madrid, FakeNearestApi(fail = true), cache) val noLocation = WidgetState.refresh(now, FuelType.B7, null, FakeNearestApi(), cache) @@ -152,6 +154,45 @@ class WidgetStateTest { assertTrue(bad.wantsRetry) } + @Test + fun aJustPrefetchedSnapshotIsReusedWithoutAnotherRequest() = runTest { + cache.save(StationCache.Snapshot(now.minusSeconds(10), FuelType.B7, 40.418, -3.705, Fixture.stations("nearest-b7"))) + val api = FakeNearestApi(fail = true) + val state = WidgetState.refresh(now, FuelType.B7, madrid, api, cache) + assertTrue(api.calls.isEmpty()) + assertEquals(WidgetState.Kind.STATIONS, state.kind) + assertEquals(listOf("4711", "3218", "4508"), state.stations.map { it.externalId }) + assertNull(state.asOfMillis) + } + + @Test + fun anOlderFarOrOtherFuelSnapshotStillAsks() = runTest { + val old = StationCache.Snapshot(now.minus(NearbyPrefetcher.MINIMUM_INTERVAL).minusSeconds(1), FuelType.B7, 40.418, -3.705, Fixture.stations("nearest-b7")) + val far = StationCache.Snapshot(now, FuelType.B7, 40.440, -3.705, Fixture.stations("nearest-b7")) + val otherFuel = StationCache.Snapshot(now, FuelType.E5, 40.418, -3.705, Fixture.stations("nearest-b7")) + for (snapshot in listOf(old, far, otherFuel)) { + cache.save(snapshot) + val api = FakeNearestApi(Fixture.stations("nearest-b7")) + WidgetState.refresh(now, FuelType.B7, madrid, api, cache) + assertEquals(1, api.calls.size) + } + } + + @Test + fun aPrefetchThenAWidgetRefreshSendOneRequest() = runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + try { + val settings = SharedSettings(SharedSettings.store(scope) { File(directory, "s.preferences_pb") }) + val api = FakeNearestApi(Fixture.stations("nearest-b7")) + NearbyPrefetcher(settings, cache, api, scope).positionDelivered(madrid.latitude, madrid.longitude, now)!!.join() + val state = WidgetState.refresh(now.plusSeconds(5), settings.fuel(), madrid, api, cache) + assertEquals(1, api.calls.size) + assertEquals(WidgetState.Kind.STATIONS, state.kind) + } finally { + scope.coroutineContext.job.cancelAndJoin() + } + } + @Test fun beforeTheFirstRefreshThereIsNoEntry() { assertNull(WidgetState().entry(english)) From 47d21d084ef6d51187c6c713c08a20b712a88727 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:55:06 +0100 Subject: [PATCH 2/4] perf(widget): reuse the prefetcher's fresh stations instead of fetching again on app open --- .../java/com/pumperly/app/MainActivity.kt | 14 +++++++---- .../com/pumperly/app/widget/WidgetState.kt | 23 +++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/pumperly/app/MainActivity.kt b/app/src/main/java/com/pumperly/app/MainActivity.kt index f10a707..af60636 100644 --- a/app/src/main/java/com/pumperly/app/MainActivity.kt +++ b/app/src/main/java/com/pumperly/app/MainActivity.kt @@ -508,7 +508,7 @@ class MainActivity : ComponentActivity() { private fun refreshSavedLocation() { if (locationJob?.isActive == true) return locationJob = lifecycleScope.launch { - when (val outcome = Locator.locate(this@MainActivity)) { + val prefetch = when (val outcome = Locator.locate(this@MainActivity)) { is Locator.Outcome.Fix -> { runCatching { settings.saveLocation(outcome.latitude, outcome.longitude) } prefetcher.positionDelivered(outcome.latitude, outcome.longitude) @@ -516,11 +516,17 @@ class MainActivity : ComponentActivity() { Locator.Outcome.Denied -> withContext(Dispatchers.IO) { runCatching { settings.clearLocation() } stationCache.clear() + null } - Locator.Outcome.Unavailable -> Unit + Locator.Outcome.Unavailable -> null + } + // The widget redraws from what was just saved. It waits for a prefetch to land so it + // reuses those stations instead of asking for them again; leaving the app while that + // fetch runs does not cancel the redraw. Without a widget the worker does nothing. + backgroundScope.launch { + prefetch?.join() + WidgetRefreshWorker.refreshNow(this@MainActivity) } - // The widget redraws from what was just saved. Without a widget the worker does nothing. - WidgetRefreshWorker.refreshNow(this@MainActivity) } } diff --git a/app/src/main/java/com/pumperly/app/widget/WidgetState.kt b/app/src/main/java/com/pumperly/app/widget/WidgetState.kt index 570cf20..cf5a10e 100644 --- a/app/src/main/java/com/pumperly/app/widget/WidgetState.kt +++ b/app/src/main/java/com/pumperly/app/widget/WidgetState.kt @@ -10,6 +10,7 @@ import androidx.glance.state.GlanceStateDefinition import com.pumperly.app.core.CheapestNearbyEntry import com.pumperly.app.core.Coordinate import com.pumperly.app.core.FuelType +import com.pumperly.app.core.NearbyPrefetcher import com.pumperly.app.core.NearestApi import com.pumperly.app.core.Station import com.pumperly.app.core.StationCache @@ -75,6 +76,8 @@ data class WidgetState( * saved in the foreground, or null when there is none or location is not allowed; then * nothing is sent. A good answer is cached on the device. A failed fetch falls back to * the cached stations when they are for this fuel, at most a day old and taken near here. + * When the app's prefetcher has just fetched this fuel within a kilometre of here, those + * stations are used as they are, so opening the app sends one request, not two. */ suspend fun refresh( now: Instant, @@ -84,14 +87,20 @@ data class WidgetState( cache: StationCache, ): WidgetState { if (position == null) return WidgetState(now.toEpochMilli(), fuel.code, Kind.NEEDS_LOCATION) - val result = try { - Result.success(api.fetchNearest(position.latitude, position.longitude, fuel)) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Result.failure(e) + val prefetched = cache.load(fuel, NearbyPrefetcher.MINIMUM_INTERVAL, now) + ?.takeIf { it.isNear(position.latitude, position.longitude, NearbyPrefetcher.DISTANCE_KM) } + val result = if (prefetched != null) { + Result.success(prefetched.stations) + } else { + try { + Result.success(api.fetchNearest(position.latitude, position.longitude, fuel)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } } - result.onSuccess { stations -> + if (prefetched == null) result.onSuccess { stations -> // Losing the offline copy is not worth failing the refresh for. runCatching { cache.save(StationCache.Snapshot(now, fuel, position.latitude, position.longitude, stations)) } } From d2be3d797f56030fd955223d5a6db9abfd661a15 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:55:39 +0100 Subject: [PATCH 3/4] test(widget): cover a fresh snapshot just over 1 km away --- app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt b/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt index 585d254..243ba75 100644 --- a/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt +++ b/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt @@ -169,8 +169,9 @@ class WidgetStateTest { fun anOlderFarOrOtherFuelSnapshotStillAsks() = runTest { val old = StationCache.Snapshot(now.minus(NearbyPrefetcher.MINIMUM_INTERVAL).minusSeconds(1), FuelType.B7, 40.418, -3.705, Fixture.stations("nearest-b7")) val far = StationCache.Snapshot(now, FuelType.B7, 40.440, -3.705, Fixture.stations("nearest-b7")) + val justOver = StationCache.Snapshot(now, FuelType.B7, 40.4305, -3.704, Fixture.stations("nearest-b7")) val otherFuel = StationCache.Snapshot(now, FuelType.E5, 40.418, -3.705, Fixture.stations("nearest-b7")) - for (snapshot in listOf(old, far, otherFuel)) { + for (snapshot in listOf(old, far, justOver, otherFuel)) { cache.save(snapshot) val api = FakeNearestApi(Fixture.stations("nearest-b7")) WidgetState.refresh(now, FuelType.B7, madrid, api, cache) From 827dbb6fe0a6e49ddcd0c90ebbafe5440b7544a2 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:58:55 +0100 Subject: [PATCH 4/4] test(widget): run the cached-fallback case before a live answer re-caches --- app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt b/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt index 243ba75..61caba8 100644 --- a/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt +++ b/app/src/test/java/com/pumperly/app/widget/WidgetStateTest.kt @@ -137,8 +137,9 @@ class WidgetStateTest { @Test fun refreshIsHourlyAndSoonerAfterAFailureOrACachedAnswer() = runTest { cache.save(StationCache.Snapshot(now.minus(Duration.ofMinutes(5)), FuelType.B7, 40.418, -3.705, Fixture.stations("nearest-b7"))) - val good = WidgetState.refresh(now, FuelType.B7, madrid, FakeNearestApi(Fixture.stations("nearest-b7")), cache) + // The failure first: the good answer re-caches at `now`, which a refresh right after would reuse. val cached = WidgetState.refresh(now, FuelType.B7, madrid, FakeNearestApi(fail = true), cache) + val good = WidgetState.refresh(now, FuelType.B7, madrid, FakeNearestApi(Fixture.stations("nearest-b7")), cache) val noLocation = WidgetState.refresh(now, FuelType.B7, null, FakeNearestApi(), cache) StationCache(directory).clear() val bad = WidgetState.refresh(now, FuelType.B7, madrid, FakeNearestApi(fail = true), cache)