From ed8ced0af23589eb6ac24dd1643f08a3145981f4 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Tue, 6 Oct 2026 11:31:33 +0200 Subject: [PATCH 1/3] feat(performance): allow any PID on the Performance screen - PID dialog offers the full registry; profile PIDs and current selection stay listed unfiltered - Selected PIDs outside the profile lists are drawn in the grid after the profile's top PIDs, in dialog order - New "Gauge PIDs" setting (max 5); unset keeps the profile's bottom list, empty means no gauges - Grid shrinks text and adds columns above 15 items within the same 3-row height, "+N" past 24 - TripInfoMetrics.grid() parameterised by base columns/rows and shared with Performance - Plain JUnit PerformanceMetricsTest; PerformanceQueryStrategyTest covers the gauge dialog selection - Spec: doc/specs/performance-dynamic-pids.md --- CLAUDE.md | 9 + app/build.gradle | 4 +- .../pid/PidDefinitionDialogMode.kt | 2 + .../preferences/pid/PidDefinitionViewModel.kt | 35 +++- app/src/main/res/values-pl/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/preferences.xml | 9 + .../aa/screen/nav/SurfaceRendererScreen.kt | 3 + .../bl/query/PerformanceQueryStrategy.kt | 27 ++- .../bl/query/PerformanceQueryStrategyTest.kt | 22 +++ doc/specs/performance-dynamic-pids.md | 135 +++++++++++++++ doc/specs/trip-info-dynamic-pids.md | 2 +- .../obd/graphs/renderer/api/ScreenSettings.kt | 43 +---- .../renderer/performance/MetricsCache.kt | 113 ++++++------ .../renderer/performance/PerformanceDrawer.kt | 46 +++-- .../performance/PerformanceMetrics.kt | 81 +++++++++ .../performance/PerformanceSurfaceRenderer.kt | 25 ++- .../graphs/renderer/trip/TripInfoDrawer.kt | 2 +- .../graphs/renderer/trip/TripInfoMetrics.kt | 17 +- .../performance/PerformanceMetricsTest.kt | 163 ++++++++++++++++++ 20 files changed, 609 insertions(+), 133 deletions(-) create mode 100644 doc/specs/performance-dynamic-pids.md create mode 100644 screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceMetrics.kt create mode 100644 screen_renderer/src/test/java/org/obd/graphs/renderer/performance/PerformanceMetricsTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index f782807fe..e33e03b81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,11 +169,20 @@ Gradle needs JDK 17+ (Crashlytics plugin); the shell default may be JDK 11 and f added unfiltered: a selected PID hidden by the ECU/stable filters would be dropped on save. * Top grid: labels drawn by `AbstractDrawer.drawTitle`, 6 columns (`MAX_ITEM_IN_THE_ROW`), row height `1.8 × textSizeBase` — labels have no width clamp, and a 3+ line label would overlap the next row. Above 18 items `TripInfoMetrics.grid()` shrinks text and adds columns within the same 3-row height (min scale 0.75 = 8 × 4 = 32 tiles; past that the last cell is "+N" for the undrawn ones). The floor was 0.5 (72 tiles) until "select all" on an 800 × 480 DHU gave ~4 px labels — readability, not capacity, sets the floor; up to 18 the layout is unchanged. `maxItemWidth` uses `layoutCache.grid.columns` — Performance calls `drawMetric` without `drawScreen`, so it keeps 6. * Bottom row: max 4 (`MAX_BOTTOM_ITEMS`, extras go to the grid), drawn via `GiuliaDrawer.drawMetric`; text size is computed once in `calculateLayout` and cached. Any input that changes label geometry (area, top/bottom metric counts, bottom PID ids, break-label flag) must be part of `TripInfoLayoutCache.requiresLayoutUpdate` — the non-area ones live in the testable `TripInfoLabelLayout`. A count is not enough once PIDs are user-chosen: swapping a bottom PID keeps the count and left the old text size. Label width must be measured the same way it is drawn (split on `\n` only when breaking is enabled). +* `TripInfoMetrics.grid()` takes base columns/rows so Performance shares it; `drawHiddenCount` is non-private for the same reason. * AA label splitting is controlled by `pref.aa.trip_info.break_label` (default `true`), independent of Giulia virtual screens. Phone Trip Info always splits (`TripInfoSettings`). ### Performance screen (AA + phone) * `renderer/performance/PerformanceSurfaceRenderer.kt` wraps settings in the internal `PerformanceScreenSettings` delegate (same name as the `api.PerformanceScreenSettings` data class — mind the imports). Its top grid reuses `TripInfoDrawer.drawMetric`; gauges use the gauge drawer. * AA label splitting is controlled by `pref.aa.performance.break_label` (default `true`) via that delegate. Phone Performance always splits (`PerformanceSettings`). +* **Draws any selected PID, like Trip Info** (`PerformanceMetrics.plan()`, spec `doc/specs/performance-dynamic-pids.md`). + Profile lists `pref.query.performance.top`/`bottom`/`hidden` stay the backward-compatible layout: top + PIDs first in their order, other selected PIDs after in dialog order. Gauges come from + `pref.aa.performance.bottom.pids.selected` (no XML defaultValue; unset = profile `bottom`, empty = none, + max `MAX_GAUGES = 5`). `VEHICLE_STATUS` is excluded via `PERFORMANCE_STATUS_PIDS`. +* `MetricsCache` reads the profile lists from `Prefs` when it rebuilds the plan, not from listener-updated + copies: the renderer's listener only flags the plan outdated, and a copy updated by a second listener + could be read half-applied. Grid fit reuses `TripInfoMetrics.grid(count, 5, 3)` (unchanged up to 15). ### Connectors (`:datalogger/.../connectors`) * One `AdapterConnection` per transport, chosen by `ConnectionManager.obtain()` on diff --git a/app/build.gradle b/app/build.gradle index ac5f7d441..ef1c27887 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -81,7 +81,7 @@ android { resValue "string", "DEFAULT_PROFILE", "profile_8" resValue "string", "applicationId", "org.obd.graphs.my.giulia.aa" applicationId "org.obd.graphs.my.giulia.aa" - versionCode 246 + versionCode 247 } giuliaPerformanceMonitor { @@ -89,7 +89,7 @@ android { resValue "string", "DEFAULT_PROFILE", "profile_8" resValue "string", "applicationId", "org.obd.graphs.my.giulia.performance_monitor" applicationId "org.obd.graphs.my.giulia.performance_monitor" - versionCode 126 + versionCode 127 } giulia { diff --git a/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionDialogMode.kt b/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionDialogMode.kt index d47a88592..871c00ac2 100644 --- a/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionDialogMode.kt +++ b/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionDialogMode.kt @@ -22,6 +22,7 @@ sealed class PidDefinitionDialogMode { object TripInfo : PidDefinitionDialogMode() object TripInfoBottom : PidDefinitionDialogMode() object Performance : PidDefinitionDialogMode() + object PerformanceBottom : PidDefinitionDialogMode() object LowPriority : PidDefinitionDialogMode() object HighPriority : PidDefinitionDialogMode() object Dashboard : PidDefinitionDialogMode() @@ -47,6 +48,7 @@ sealed class PidDefinitionDialogMode { "trip_info" -> TripInfo "trip_info_bottom" -> TripInfoBottom "performance" -> Performance + "performance_bottom" -> PerformanceBottom "low" -> LowPriority "high" -> HighPriority "dashboard" -> Dashboard diff --git a/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionViewModel.kt b/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionViewModel.kt index 28a8649ec..d3c9a6d04 100644 --- a/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionViewModel.kt +++ b/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionViewModel.kt @@ -33,12 +33,17 @@ import org.obd.graphs.bl.datalogger.VehicleCapabilitiesManager import org.obd.graphs.bl.datalogger.dataLoggerSettings import org.obd.graphs.bl.datalogger.isUserCustom import org.obd.graphs.bl.datalogger.serialize +import org.obd.graphs.bl.query.PERFORMANCE_STATUS_PIDS +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_HIDDEN +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_SELECTED import org.obd.graphs.bl.query.PREF_QUERY_TRIP_INFO_SELECTED import org.obd.graphs.bl.query.Query import org.obd.graphs.bl.query.QueryStrategyType import org.obd.graphs.bl.query.TRIP_INFO_STATUS_PIDS +import org.obd.graphs.bl.query.performanceBottomDialogSelection import org.obd.graphs.bl.query.tripInfoBottomDialogSelection import org.obd.graphs.preferences.Prefs +import org.obd.graphs.preferences.getLongSet import org.obd.graphs.preferences.getStringSet import org.obd.graphs.preferences.updateStringSet import org.obd.graphs.sendBroadcastEvent @@ -200,17 +205,16 @@ class PidDefinitionViewModel( } } - // The bottom row dialog lists only the selected Trip Info PIDs; see tripInfoBottomDialogSelection. + // The bottom row dialogs list only the PIDs selected for their screen; see tripInfoBottomDialogSelection. private fun persistedSelection(listed: Collection): Collection = - if (dialogMode is PidDefinitionDialogMode.TripInfoBottom) { - tripInfoBottomDialogSelection( - persisted = if (Prefs.contains(key)) Prefs.getStringSet(key).map { s -> s.toLong() }.toSet() else null, - listed = listed - ) - } else { - Prefs.getStringSet(key).map { s -> s.toLong() } + when (dialogMode) { + is PidDefinitionDialogMode.TripInfoBottom -> tripInfoBottomDialogSelection(storedOrNull(), listed) + is PidDefinitionDialogMode.PerformanceBottom -> performanceBottomDialogSelection(storedOrNull(), listed) + else -> Prefs.getStringSet(key).map { s -> s.toLong() } } + private fun storedOrNull(): Set? = if (Prefs.contains(key)) Prefs.getStringSet(key).map { s -> s.toLong() }.toSet() else null + fun reorderItems(items: List) { viewModelScope.launch(Dispatchers.IO) { val serializer = viewPreferencesSerializer() @@ -274,7 +278,20 @@ class PidDefinitionViewModel( .map { PidDefinitionDetails(it, checked = false, supported = true) } } dialogMode is PidDefinitionDialogMode.Performance -> { - Query.instance(QueryStrategyType.PERFORMANCE_QUERY).getDefaultPIDs() + // Same as Trip Info: the profile's PIDs and the current selection bypass the registry filters. + val pinned = ( + Query.instance(QueryStrategyType.PERFORMANCE_QUERY).getDefaultPIDs() + + Prefs.getStringSet(key).map { s -> s.toLong() } + ).mapNotNull { pidRegistry.findBy(it) } + .map { PidDefinitionDetails(it, checked = false, supported = true) } + val pinnedIds = pinned.map { it.source.id }.toSet() + pinned + findPidDefinitionByPriority(all) { !pinnedIds.contains(it.id) } + } + dialogMode is PidDefinitionDialogMode.PerformanceBottom -> { + val hidden = Prefs.getLongSet(PREF_QUERY_PERFORMANCE_HIDDEN) + Prefs.getStringSet(PREF_QUERY_PERFORMANCE_SELECTED) + .map { it.toLong() } + .filter { !PERFORMANCE_STATUS_PIDS.contains(it) && !hidden.contains(it) } .mapNotNull { pidRegistry.findBy(it) } .map { PidDefinitionDetails(it, checked = false, supported = true) } } diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0f72cdc25..4645e555e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -375,6 +375,8 @@ Aktywuje ekran wydajności, który wyświetla predefiniowany zestaw metryk dostosowany do silników 2.0 GME. Podziel etykiety PID na 2 linie Wyświetla etykiety PID na ekranie wydajności w dwóch liniach. Po wyłączeniu etykiety są wyświetlane w jednej linii. + PID wskaźników + PID wyświetlane jako wskaźniki w dolnej części ekranu wydajności (maks. 5), wybierane spośród zaznaczonych PID. Pozostałe zaznaczone PID są wyświetlane w górnej siatce. Wybierz OBD2 PID wydajności diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bbffcb43b..24b4d6c2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -371,6 +371,8 @@ Activates the Performance screen, which displays a predefined set of metrics tailored for 2.0 GME engines. Split PIDs label into 2 lines Displays PID labels on the Performance screen in two lines. When disabled, labels are shown in a single line. + Gauge PIDs + PIDs shown as gauges at the bottom of the Performance screen (max 5), chosen from the selected PIDs. All other selected PIDs are shown in the top grid. Select Performance OBD2 PIDs diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index f0d5e87eb..ce48cd426 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -1856,6 +1856,15 @@ android:title="@string/pref.displayed_parameter_ids" app:source="performance" app:useSimpleSummaryProvider="true" /> + + + diff --git a/automotive/src/main/java/org/obd/graphs/aa/screen/nav/SurfaceRendererScreen.kt b/automotive/src/main/java/org/obd/graphs/aa/screen/nav/SurfaceRendererScreen.kt index 17540fa07..7138664d4 100644 --- a/automotive/src/main/java/org/obd/graphs/aa/screen/nav/SurfaceRendererScreen.kt +++ b/automotive/src/main/java/org/obd/graphs/aa/screen/nav/SurfaceRendererScreen.kt @@ -58,6 +58,7 @@ const val GAUGE_VIRTUAL_SCREEN_4_SETTINGS_CHANGED = "pref.aa.gauge.pids.profile_ private const val AA_TRIP_INFO_PID_SELECTION_CHANGED_EVENT = "pref.aa.trip_info.pids.selected.event.changed" private const val AA_TRIP_INFO_BOTTOM_PID_SELECTION_CHANGED_EVENT = "pref.aa.trip_info.bottom.pids.selected.event.changed" private const val AA_PERFORMANCE_PID_SELECTION_CHANGED_EVENT = "pref.aa.performance.pids.selected.event.changed" +private const val AA_PERFORMANCE_BOTTOM_PID_SELECTION_CHANGED_EVENT = "pref.aa.performance.bottom.pids.selected.event.changed" private enum class DefaultScreen( private val code: Int @@ -126,6 +127,7 @@ internal class SurfaceRendererScreen( AA_TRIP_INFO_PID_SELECTION_CHANGED_EVENT, AA_TRIP_INFO_BOTTOM_PID_SELECTION_CHANGED_EVENT, AA_PERFORMANCE_PID_SELECTION_CHANGED_EVENT, + AA_PERFORMANCE_BOTTOM_PID_SELECTION_CHANGED_EVENT, AA_HIGH_FREQ_PID_SELECTION_CHANGED_EVENT, LOW_FREQ_PID_SELECTION_CHANGED_EVENT -> { @@ -283,6 +285,7 @@ internal class SurfaceRendererScreen( it.addAction(AA_TRIP_INFO_PID_SELECTION_CHANGED_EVENT) it.addAction(AA_TRIP_INFO_BOTTOM_PID_SELECTION_CHANGED_EVENT) it.addAction(AA_PERFORMANCE_PID_SELECTION_CHANGED_EVENT) + it.addAction(AA_PERFORMANCE_BOTTOM_PID_SELECTION_CHANGED_EVENT) it.addAction(GAUGE_VIRTUAL_SCREEN_1_SETTINGS_CHANGED) it.addAction(GAUGE_VIRTUAL_SCREEN_2_SETTINGS_CHANGED) diff --git a/datalogger/src/main/java/org/obd/graphs/bl/query/PerformanceQueryStrategy.kt b/datalogger/src/main/java/org/obd/graphs/bl/query/PerformanceQueryStrategy.kt index c4f21a718..a90b75e0d 100644 --- a/datalogger/src/main/java/org/obd/graphs/bl/query/PerformanceQueryStrategy.kt +++ b/datalogger/src/main/java/org/obd/graphs/bl/query/PerformanceQueryStrategy.kt @@ -17,12 +17,35 @@ package org.obd.graphs.bl.query import android.util.Log +import org.obd.graphs.bl.datalogger.Pid import org.obd.graphs.preferences.Prefs +import org.obd.graphs.preferences.getLongList import org.obd.graphs.preferences.getLongSet -private const val PERFORMANCE_QUERY_PREF_KEY = "pref.aa.performance.pids.selected" +const val PREF_QUERY_PERFORMANCE_SELECTED = "pref.aa.performance.pids.selected" + +// The profile's layout: grid PIDs, gauge PIDs and PIDs never drawn. const val PREF_QUERY_PERFORMANCE_TOP = "pref.query.performance.top" const val PREF_QUERY_PERFORMANCE_BOTTOM = "pref.query.performance.bottom" +const val PREF_QUERY_PERFORMANCE_HIDDEN = "pref.query.performance.hidden" + +// Which of the selected PIDs are drawn as gauges. Unset means the profile's PREF_QUERY_PERFORMANCE_BOTTOM; +// an empty set means the user removed them all. +const val PREF_QUERY_PERFORMANCE_BOTTOM_SELECTED = "pref.aa.performance.bottom.pids.selected" + +// Added to every query by QueryStrategyOrchestrator when the status panel is on, never drawn. +val PERFORMANCE_STATUS_PIDS = setOf(Pid.VEHICLE_STATUS_PID_ID.id) + +/** + * The gauges as the gauge dialog shows them checked: the stored selection, or the profile's + * gauges when never set, limited to the PIDs the dialog lists. See [tripInfoBottomDialogSelection]. + * + * @param persisted the gauge pref, or null when never set. + */ +fun performanceBottomDialogSelection( + persisted: Set?, + listed: Collection +): Set = (persisted ?: Prefs.getLongList(PREF_QUERY_PERFORMANCE_BOTTOM)).filter { listed.contains(it) }.toSet() const val PREF_QUERY_PERFORMANCE_BRAKE_BOOSTING_GAS_METRIC = "pref.query.performance.break_boosting.gas_pid" @@ -40,7 +63,7 @@ internal class PerformanceQueryStrategy : QueryStrategy() { Prefs.getInt(PREF_QUERY_PERFORMANCE_BRAKE_BOOSTING_ARBITRARY_METRIC, -1).toLong() + Prefs.getInt(PREF_QUERY_PERFORMANCE_BRAKE_BOOSTING_VEHICLE_SPEED_METRIC, -1).toLong() - override fun getPIDs() = Prefs.getLongSet(PERFORMANCE_QUERY_PREF_KEY) + override fun getPIDs() = Prefs.getLongSet(PREF_QUERY_PERFORMANCE_SELECTED) init { Log.i("PerformanceQueryStrategy", "Read defaults=${getDefaultPIDs()}") diff --git a/datalogger/src/test/java/org/obd/graphs/bl/query/PerformanceQueryStrategyTest.kt b/datalogger/src/test/java/org/obd/graphs/bl/query/PerformanceQueryStrategyTest.kt index 040e3bb7c..a48d45ad8 100644 --- a/datalogger/src/test/java/org/obd/graphs/bl/query/PerformanceQueryStrategyTest.kt +++ b/datalogger/src/test/java/org/obd/graphs/bl/query/PerformanceQueryStrategyTest.kt @@ -72,4 +72,26 @@ class PerformanceQueryStrategyTest : TestSetup() { assertEquals(setOf(100L, 200L), strategy.getPIDs()) } + + @Test + fun `pref keys keep their persisted names`() { + assertEquals(PERFORMANCE_QUERY_PREF_KEY, PREF_QUERY_PERFORMANCE_SELECTED) + assertEquals("pref.aa.performance.bottom.pids.selected", PREF_QUERY_PERFORMANCE_BOTTOM_SELECTED) + assertEquals("pref.query.performance.hidden", PREF_QUERY_PERFORMANCE_HIDDEN) + } + + @Test + fun `gauge dialog shows the profile gauges limited to the listed PIDs when never set`() { + every { sharedPrefs.getStringSet(PREF_QUERY_PERFORMANCE_BOTTOM, any()) } returns setOf("20", "21", "22") + + assertEquals(setOf(20L, 22L), performanceBottomDialogSelection(persisted = null, listed = listOf(20L, 22L, 30L))) + } + + @Test + fun `gauge dialog limits a stored selection to the listed PIDs`() { + every { sharedPrefs.getStringSet(PREF_QUERY_PERFORMANCE_BOTTOM, any()) } returns setOf("20") + + assertEquals(setOf(30L), performanceBottomDialogSelection(persisted = setOf(30L, 31L), listed = listOf(20L, 30L))) + assertEquals(emptySet(), performanceBottomDialogSelection(persisted = emptySet(), listed = listOf(20L))) + } } diff --git a/doc/specs/performance-dynamic-pids.md b/doc/specs/performance-dynamic-pids.md new file mode 100644 index 000000000..1c009f52f --- /dev/null +++ b/doc/specs/performance-dynamic-pids.md @@ -0,0 +1,135 @@ +# Spec: Performance shows any selected PID + +2026-10-06 · branch `feat/performance-dynamic-pids` · Status: implemented, manual checks pending + +## Summary + +The Performance screen (Android Auto and phone) can now show any PID the user selects, the same way +Trip Info does since `trip-info-dynamic-pids.md`. The gauges become configurable, and the top grid fits +more PIDs by shrinking. Existing setups render as before. + +**Problem.** What Performance draws is decided by the profile lists `pref.query.performance.top` +(grid) and `pref.query.performance.bottom` (gauges); the PID dialog offered only those lists plus the +brake boosting PIDs. A selected PID outside them was queried but silently not drawn, and the user had +no way to choose the gauges. More than 15 grid PIDs (bundled profiles list 21) grew downwards and +pushed the gauges off the screen. + +**Scope.** `:screen_renderer` (planning, cache, drawer, JVM tests), `:datalogger` (pref constants, +dialog helper), `:app` (PID dialog, preference, EN/PL strings), `:automotive` (refresh event). + +**Constraint.** No breaking changes: same pref keys and stored values, no migration, an unchanged +layout for any selection the old dialog could produce. + +## Behaviour changes + +| Area | Before | After | +| --- | --- | --- | +| Performance PID dialog | Lists profile top + bottom + brake boosting PIDs only | Lists the full PID registry (ECU-supported/stable filters apply); the profile PIDs and the current selection are always listed | +| Selected PID outside the profile lists | Not drawn | Drawn in the top grid after the profile's grid PIDs, in the dialog's drag order | +| Gauges | The profile's bottom list, not editable | New setting **Gauge PIDs**, max 5, chosen from the selected PIDs; unset = the profile's list; empty = no gauges. Selected PIDs not chosen as gauges go to the grid | +| More than 15 grid PIDs | Rows grow down and push the gauges off screen | Text shrinks and columns are added within the same 3-row height; up to 24 PIDs at 0.75 size; beyond that 23 are drawn and the last cell shows "+N" | +| `pref.query.performance.hidden` | Never drawn | Unchanged; also excluded from the gauge dialog | +| Vehicle status (status panel / disconnect-when-off on) | Never drawn (not in the profile lists) | Unchanged: listed in `PERFORMANCE_STATUS_PIDS` | +| Brake boosting | Unchanged | Unchanged | + +## Settings + +| Key | Type | Meaning | +| --- | --- | --- | +| `pref.aa.performance.pids.selected` | StringSet of PID ids | Unchanged. What Performance queries and can draw. Shared by AA and phone | +| `pref.aa.performance.pids.selected.view.settings` | JSON order | Unchanged key, written by the dialog's drag reorder. Now orders the PIDs outside the profile's grid list | +| `pref.query.performance.top` / `.bottom` / `.hidden` | StringSet | Unchanged, set by the profile. Top: grid PIDs drawn first. Bottom: default gauges. Hidden: never drawn | +| `pref.aa.performance.bottom.pids.selected` | StringSet of PID ids | **New.** Which selected PIDs are gauges. **No XML `defaultValue`**: it would be persisted when settings open and replace the profile's gauges | +| `pref.aa.performance.bottom.pids.selected.view.settings` | JSON order | **New.** Gauge order | + +The setting is in `preferences.xml` under AA → Performance → displayed PIDs, dialog source +`performance_bottom`. Saving broadcasts `pref.aa.performance.bottom.pids.selected.event.changed`, +handled by `SurfaceRendererScreen` like a Performance selection change. New strings +`pref.aa.performance.bottom_pids` and `pref.aa.performance.bottom_pids_summary`, EN and PL. + +## Implementation + +1. **`PerformanceQueryStrategy.kt` (`:datalogger`).** Public `PREF_QUERY_PERFORMANCE_SELECTED`, + `PREF_QUERY_PERFORMANCE_HIDDEN` (moved from `ScreenSettings.kt`), `PREF_QUERY_PERFORMANCE_BOTTOM_SELECTED`, + `PERFORMANCE_STATUS_PIDS`, and `performanceBottomDialogSelection(persisted, listed)`: the stored gauges, + or the profile's when unset, limited to the PIDs the dialog lists — the same rule as + `tripInfoBottomDialogSelection`, so saving the dialog unchanged persists nothing. The query is unchanged. +2. **`PerformanceMetrics.kt` (new, `:screen_renderer`).** Pure functions: + - `plan(available, profileTop, profileBottom, hidden, bottomSelection, sortOrder, bottomSortOrder)`. + Only queried, non-hidden, non-status PIDs are placed. **Gauges:** the user's selection in gauge order, + or the profile's bottom list when unset; at most `MAX_GAUGES = 5` (bundled profiles define ≤ 5). + **Grid:** the profile's top list in its order, then every other queried PID in dialog order + (unordered last, by id); gauge PIDs are excluded. + - `grid(itemCount)` reuses `TripInfoMetrics.grid`, now parameterised by base columns/rows + (Performance: 5 × 3, so up to 15 items nothing changes). +3. **`MetricsCache`** keeps the plan and rebuilds it when the queried ids change or on `cacheReset()`. + It reads the profile lists from `Prefs` at rebuild time; `PerformanceScreenSettings` lost its own + copies of those lists and its preference listener, which only this cache used. The metrics are + looked up from the collector every frame. +4. **`PerformanceSurfaceRenderer`** listens for the profile list, gauge and order prefs and resets the + cache; `recycle()` unregisters it. +5. **`PerformanceDrawer`** draws `grid.shown` items with `textSize × grid.scale` over `grid.columns` + columns, and the "+N" cell via `TripInfoDrawer.drawHiddenCount` (now non-private). The grid is + recomputed only when the item count changes. +6. **`PidDefinitionViewModel` (`:app`).** The `Performance` source is the profile PIDs plus the current + selection (unfiltered), plus the filtered registry. The new `PerformanceBottom` source is the + selection minus status and hidden PIDs; its checked state and save check use + `performanceBottomDialogSelection`. + +## Backward compatibility + +- No pref keys renamed, no stored values reinterpreted, no migration. +- A selection the old dialog allowed contains only profile PIDs, so `plan()` gives the former grid and + gauges (the old code took the profile lists filtered by queried and hidden). Tests pin this with the + bundled profile 8 lists. +- No bundled profile lists a PID in both top and bottom, so dropping gauge PIDs from the grid changes nothing. +- Grids of more than 15 PIDs shrink instead of overflowing; that layout was broken before. +- The profile lists are StringSets, so their "order" is the set's iteration order, as before. + +## Tests + +`./gradlew :screen_renderer:testDebugUnitTest` — `PerformanceMetricsTest`: + +| Test | Asserts | +| --- | --- | +| profile PIDs keep the former layout when no gauges were ever set | Shuffled bundled selection and a stale drag order give the former grid and gauges | +| every profile PID selected keeps the former layout | Same, with all top + bottom PIDs | +| selected PIDs outside the profile follow its grid PIDs in the dialog order | Profile PIDs first, then by sort order, unordered last | +| hidden PIDs are drawn nowhere | Hidden in neither grid nor gauges, even if chosen as a gauge | +| vehicle status PID added to every query by the status panel is not drawn | Grid unchanged, never a gauge | +| selected gauges are ordered, capped and the rest moves to the grid | Gauge order honoured, max 5, overflow and former gauges go to the grid | +| empty gauge selection means no gauges | Former gauges move to the grid | +| gauges that are not queried are skipped | Only queried ids | +| grid is unchanged up to three full rows | 0–15 items: 5 columns, scale 1 | +| grid shrinks to fit every profile grid PID | 21 items fit, scale < 1, more columns | +| items that do not fit are counted in the last cell | 100 items: capacity − 1 shown, rest hidden | + +`./gradlew :datalogger:testDebugUnitTest --tests '*PerformanceQueryStrategy*'` pins the pref key +literals and `performanceBottomDialogSelection` (unset → profile gauges ∩ listed; stored ∩ listed). + +## Risks and verification + +- **Dialog changes have no unit test** (`:app` has no JVM setup); verify manually. +- **Gauge height is not budgeted.** The grid is capped at 3 rows' height as before; on very small AA + displays the gauges below may still be tight with a large font setting. +- **Phone shares the AA keys**, as for Trip Info: the gauge setting is in the AA section. +- Profile switching loads the new profile's keys; a gauge selection saved under one profile follows the + generic profile save/load like any other pref. + +Verification: + +- [x] `./gradlew :screen_renderer:testDebugUnitTest` passes (Performance 11, Trip Info 18) +- [x] `./gradlew :datalogger:testDebugUnitTest --tests '*Performance*' --tests '*TripInfo*'` passes +- [x] `./gradlew spotlessCheck assembleGiuliaDebug assembleGiuliaAADebug` builds +- [ ] Manual: with an existing selection, Performance on phone and AA looks identical to `master` +- [ ] Manual: open and close both PID dialogs without changes; nothing is persisted +- [ ] Manual: select a PID outside the profile; it appears in the grid after the profile PIDs; drag reorder applies +- [ ] Manual: set, reorder and clear the gauges on AA; the screen refreshes without reconnecting +- [ ] Manual: select more than 15 grid PIDs; the grid shrinks and the gauges stay on screen +- [ ] Manual: brake boosting still takes over the screen + +## Out of scope / follow-ups + +- Editing the profile's grid order (`pref.query.performance.top`) from the UI. +- Paging instead of the "+N" cell. +- Fitting gauge height to the remaining area. diff --git a/doc/specs/trip-info-dynamic-pids.md b/doc/specs/trip-info-dynamic-pids.md index 8247e19a3..15e6d4b72 100644 --- a/doc/specs/trip-info-dynamic-pids.md +++ b/doc/specs/trip-info-dynamic-pids.md @@ -106,6 +106,6 @@ Verification: ## Out of scope / follow-ups -- **Performance screen:** its layout is already driven by the profile's `pref.query.performance.top`/`bottom` lists. Only its dialog is still limited to those lists. +- **Performance screen:** done in `performance-dynamic-pids.md`. - **Paging** the grid via the AA virtual-screen actions instead of the "+N" cell beyond 32. - Allowing ambient temp and atm pressure in the grid as an explicit opt-in. diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/api/ScreenSettings.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/api/ScreenSettings.kt index c967d743d..5d8fb6de9 100644 --- a/screen_renderer/src/main/java/org/obd/graphs/renderer/api/ScreenSettings.kt +++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/api/ScreenSettings.kt @@ -16,22 +16,15 @@ */ package org.obd.graphs.renderer.api -import android.content.SharedPreferences import android.graphics.Color -import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_BOTTOM import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_BRAKE_BOOSTING_ARBITRARY_METRIC import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_BRAKE_BOOSTING_GAS_METRIC import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_BRAKE_BOOSTING_VEHICLE_SPEED_METRIC -import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_TOP import org.obd.graphs.preferences.Prefs -import org.obd.graphs.preferences.getLongList -import org.obd.graphs.preferences.getLongSet import org.obd.graphs.ui.common.COLOR_CARDINAL import org.obd.graphs.ui.common.COLOR_DYNAMIC_SELECTOR_SPORT import org.obd.graphs.ui.common.COLOR_RAINBOW_INDIGO -private const val PREF_QUERY_PERFORMANCE_HIDDEN = "pref.query.performance.hidden" - interface VirtualScreenConfig { val selectedPIDs: Set @@ -158,41 +151,7 @@ data class PerformanceScreenSettings( var viewEnabled: Boolean = true, var breakLabelTextEnabled: Boolean = true, var brakeBoostingSettings: BrakeBoostingSettings = BrakeBoostingSettings() -) : SharedPreferences.OnSharedPreferenceChangeListener { - - val bottomMetrics: MutableList = - Prefs.getLongList(PREF_QUERY_PERFORMANCE_BOTTOM).toMutableList() - - val topMetrics: MutableList = - Prefs.getLongList(PREF_QUERY_PERFORMANCE_TOP).toMutableList() - - val hiddenMetrics: MutableSet = Prefs.getLongSet(PREF_QUERY_PERFORMANCE_HIDDEN) - - init { - Prefs.registerOnSharedPreferenceChangeListener(this) - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (sharedPreferences == null) return - - when (key) { - PREF_QUERY_PERFORMANCE_BOTTOM -> { - bottomMetrics.clear() - bottomMetrics.addAll(sharedPreferences.getLongList(PREF_QUERY_PERFORMANCE_BOTTOM)) - } - - PREF_QUERY_PERFORMANCE_TOP -> { - topMetrics.clear() - topMetrics.addAll(sharedPreferences.getLongList(PREF_QUERY_PERFORMANCE_TOP)) - } - - PREF_QUERY_PERFORMANCE_HIDDEN -> { - hiddenMetrics.clear() - hiddenMetrics.addAll(sharedPreferences.getLongSet(PREF_QUERY_PERFORMANCE_HIDDEN)) - } - } - } -} +) data class RoutinesScreenSettings( var viewEnabled: Boolean = true diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/MetricsCache.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/MetricsCache.kt index 664af687f..5256221a8 100644 --- a/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/MetricsCache.kt +++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/MetricsCache.kt @@ -16,12 +16,21 @@ */ package org.obd.graphs.renderer.performance -import android.util.Log +import org.obd.graphs.ViewPreferencesSerializer import org.obd.graphs.bl.collector.Metric import org.obd.graphs.bl.collector.MetricsCollector +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_BOTTOM +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_BOTTOM_SELECTED +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_HIDDEN +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_SELECTED +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_TOP +import org.obd.graphs.preferences.Prefs +import org.obd.graphs.preferences.getLongList +import org.obd.graphs.preferences.getLongSet import org.obd.graphs.renderer.api.PerformanceScreenSettings -private const val TAG = "MetricCache" +internal const val PERFORMANCE_SORT_ORDER_PREF_KEY = "$PREF_QUERY_PERFORMANCE_SELECTED.view.settings" +internal const val PERFORMANCE_BOTTOM_SORT_ORDER_PREF_KEY = "$PREF_QUERY_PERFORMANCE_BOTTOM_SELECTED.view.settings" internal class BrakeBoosting( var gasMetric: Metric? = null, @@ -35,22 +44,21 @@ internal class MetricsCache { val brakeBoosting: BrakeBoosting = BrakeBoosting() - private val cachedMetrics = mutableListOf() - private var lastBottomIds: List = mutableListOf() - private var lastTopIds: List = mutableListOf() + // The plan is rebuilt only when the queried PIDs change or cacheReset() is called. + private var plannedIds = LongArray(0) + private var plan = PerformancePlan(emptyList(), emptyList()) + + @Volatile + private var planOutdated = true fun cacheReset() { - bottomMetrics.clear() - topMetrics.clear() - cachedMetrics.clear() + planOutdated = true } fun update( settings: PerformanceScreenSettings, metricsCollector: MetricsCollector ) { - val allMetrics = metricsCollector.getMetrics() - brakeBoosting.apply { gasMetric = metricsCollector.getMetric(settings.brakeBoostingSettings.getGasMetric()) arbitraryMetric = @@ -59,58 +67,51 @@ internal class MetricsCache { metricsCollector.getMetric(settings.brakeBoostingSettings.getVehicleSpeedMetric()) } - val currentBottomMetrics = settings.bottomMetrics - val currentTopMetrics = settings.topMetrics - - val cacheHit = - allMetrics.size == cachedMetrics.size && - currentBottomMetrics == lastBottomIds && - currentTopMetrics == lastTopIds - - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, "--------------------------------------------------------------") - Log.v(TAG, "LastBottomIds=$lastBottomIds") - Log.v(TAG, "LastTopIds=$lastTopIds") - Log.v(TAG, "AllMetrics=${allMetrics.map { it.pid.id }}") - Log.v(TAG, "TopMetrics=${topMetrics.map { it.pid.id }}") - Log.v(TAG, "BottomMetrics=${bottomMetrics.map { it.pid.id }}") - Log.v(TAG, "HiddenMetrics=${settings.hiddenMetrics}") - Log.v(TAG, "Cache hit=$cacheHit") - } + updatePlan(metricsCollector.getMetrics()) + fill(topMetrics, plan.top, metricsCollector) + fill(bottomMetrics, plan.bottom, metricsCollector) + } - if (cacheHit) { - return + private fun fill( + target: MutableList, + ids: List, + metricsCollector: MetricsCollector + ) { + target.clear() + for (i in ids.indices) { + metricsCollector.getMetric(ids[i])?.let { target.add(it) } } + } - cachedMetrics.clear() - cachedMetrics.addAll(allMetrics) - lastBottomIds = currentBottomMetrics - lastTopIds = currentTopMetrics - - val hiddenSet = settings.hiddenMetrics - val allMetricsSet = allMetrics.toSet() - - bottomMetrics.clear() - - for (i in 0 until currentBottomMetrics.size) { - val id = currentBottomMetrics[i] - if (!hiddenSet.contains(id)) { - val metric = metricsCollector.getMetric(id) - if (metric != null && allMetricsSet.contains(metric)) { - bottomMetrics.add(metric) + // Prefs are read here rather than in a listener, so a rebuild never sees a half-applied change. + private fun updatePlan(metrics: List) { + if (!planOutdated && plannedIds.size == metrics.size) { + var same = true + for (i in metrics.indices) { + if (plannedIds[i] != metrics[i].pid.id) { + same = false + break } } + if (same) return } - topMetrics.clear() - for (i in 0 until currentTopMetrics.size) { - val id = currentTopMetrics[i] - if (!hiddenSet.contains(id)) { - val metric = metricsCollector.getMetric(id) - if (metric != null && allMetricsSet.contains(metric)) { - topMetrics.add(metric) - } - } - } + planOutdated = false + plannedIds = LongArray(metrics.size) { i -> metrics[i].pid.id } + plan = + PerformanceMetrics.plan( + available = plannedIds.toList(), + profileTop = Prefs.getLongList(PREF_QUERY_PERFORMANCE_TOP), + profileBottom = Prefs.getLongList(PREF_QUERY_PERFORMANCE_BOTTOM), + hidden = Prefs.getLongSet(PREF_QUERY_PERFORMANCE_HIDDEN), + bottomSelection = + if (Prefs.contains(PREF_QUERY_PERFORMANCE_BOTTOM_SELECTED)) { + Prefs.getLongSet(PREF_QUERY_PERFORMANCE_BOTTOM_SELECTED) + } else { + null + }, + sortOrder = ViewPreferencesSerializer(PERFORMANCE_SORT_ORDER_PREF_KEY).getItemsSortOrder(), + bottomSortOrder = ViewPreferencesSerializer(PERFORMANCE_BOTTOM_SORT_ORDER_PREF_KEY).getItemsSortOrder() + ) } } diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceDrawer.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceDrawer.kt index 36ec8d8ec..b8ca0b21d 100644 --- a/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceDrawer.kt +++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceDrawer.kt @@ -25,11 +25,10 @@ import org.obd.graphs.renderer.api.ScreenSettings import org.obd.graphs.renderer.gauge.DrawerSettings import org.obd.graphs.renderer.gauge.GaugeDrawer import org.obd.graphs.renderer.trip.TripInfoDrawer +import org.obd.graphs.renderer.trip.TripInfoGrid import org.obd.graphs.isNumber import org.obd.metrics.pid.ValueType - private const val MAX_ITEMS_IN_ROW = 5 - @Suppress("NOTHING_TO_INLINE") internal class PerformanceDrawer(context: Context, settings: ScreenSettings) : AbstractDrawer(context, settings) { @@ -43,6 +42,9 @@ internal class PerformanceDrawer(context: Context, settings: ScreenSettings) : private val tripInfoDrawer = TripInfoDrawer(context, settings) + // Rebuilt only when the grid item count changes, not every frame. + private var grid: TripInfoGrid = PerformanceMetrics.grid(0) + private val background: Bitmap = BitmapFactory.decodeResource( context.resources, @@ -81,24 +83,30 @@ internal class PerformanceDrawer(context: Context, settings: ScreenSettings) : fontSize = performanceScreenSettings.fontSize ) - val itemWidth = area.width() / MAX_ITEMS_IN_ROW.toFloat() - var rowTop = top + 2f val topMetrics = performanceInfoDetails.topMetrics val topMetricsSize = topMetrics.size + if (grid.shown + grid.hidden != topMetricsSize) { + grid = PerformanceMetrics.grid(topMetricsSize) + } + + val gridTextSize = textSize * grid.scale + val itemWidth = area.width() / grid.columns.toFloat() + var rowTop = top + 2f + val drawnCount = grid.shown - for (i in 0 until topMetricsSize) { + for (i in 0 until drawnCount) { val metric = topMetrics[i] - val columnIndex = i % MAX_ITEMS_IN_ROW + val columnIndex = i % grid.columns if (columnIndex == 0 && i > 0) { drawDivider( canvas, left, area.width().toFloat(), - rowTop + textSize * 0.8f, + rowTop + gridTextSize * 0.8f, Color.DKGRAY ) - rowTop += 2 * textSize + rowTop += 2 * gridTextSize } val itemLeft = left + (columnIndex * itemWidth) @@ -108,33 +116,45 @@ internal class PerformanceDrawer(context: Context, settings: ScreenSettings) : rowTop, itemLeft, canvas, - textSize, + gridTextSize, statsEnabled = metric.source.isNumber(), area = area, castToInt = metric.pid.type != ValueType.DOUBLE ) - if (columnIndex < MAX_ITEMS_IN_ROW - 1 && i < topMetricsSize - 1) { + if (columnIndex < grid.columns - 1 && i < topMetricsSize - 1) { val lineX = itemLeft + itemWidth canvas.drawLine( lineX, rowTop, lineX, - rowTop + textSize * 0.8f, + rowTop + gridTextSize * 0.8f, dividerPaint ) } } + if (grid.hidden > 0) { + // shown is capacity - 1, so the marker always lands in the last cell of the current row. + val columnIndex = drawnCount % grid.columns + tripInfoDrawer.drawHiddenCount( + canvas, + grid.hidden, + left + (columnIndex * itemWidth), + rowTop, + gridTextSize * 0.8f + ) + } + if (topMetricsSize > 0) { drawDivider( canvas, left, area.width().toFloat(), - rowTop + textSize * 0.8f, + rowTop + gridTextSize * 0.8f, Color.DKGRAY ) - rowTop += 1.8f * textSize + rowTop += 1.8f * gridTextSize } rowTop -= textSize * 0.7f diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceMetrics.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceMetrics.kt new file mode 100644 index 000000000..516ac0ab3 --- /dev/null +++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceMetrics.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.renderer.performance + +import org.obd.graphs.bl.query.PERFORMANCE_STATUS_PIDS +import org.obd.graphs.renderer.trip.TripInfoGrid +import org.obd.graphs.renderer.trip.TripInfoMetrics + +// Profiles define at most 5 gauges, so the cap leaves every existing layout as it was. +internal const val MAX_GAUGES = 5 + +internal const val PERFORMANCE_GRID_COLUMNS = 5 + +// Three full rows always fitted above the gauges; more are shrunk into the same height. +private const val PERFORMANCE_GRID_ROWS = 3 + +internal class PerformancePlan( + val top: List, + val bottom: List +) + +internal object PerformanceMetrics { + /** + * Splits the queried PIDs between the top grid and the gauges. + * + * @param profileTop the profile's grid PIDs, drawn first in their order. + * @param profileBottom the profile's gauge PIDs, used when [bottomSelection] was never set. + * @param hidden the profile's PIDs that are never drawn. + * @param bottomSelection the user's gauges, or null when never set. + */ + fun plan( + available: Collection, + profileTop: List, + profileBottom: List, + hidden: Set, + bottomSelection: Set?, + sortOrder: Map?, + bottomSortOrder: Map? + ): PerformancePlan { + fun drawable(id: Long) = available.contains(id) && !hidden.contains(id) && !PERFORMANCE_STATUS_PIDS.contains(id) + + val bottom = + if (bottomSelection == null) { + profileBottom.filter { drawable(it) } + } else { + bottomSelection.filter { drawable(it) }.sortedWith(byOrder(bottomSortOrder)) + }.distinct().take(MAX_GAUGES) + + // The profile's grid PIDs keep their place so existing layouts do not move; any other PID + // follows in the order set in the PID dialog. + val profileTopIds = profileTop.toSet() + val top = + profileTop.filter { drawable(it) && !bottom.contains(it) }.distinct() + + available + .filter { drawable(it) && !bottom.contains(it) && !profileTopIds.contains(it) } + .distinct() + .sortedWith(byOrder(sortOrder)) + + return PerformancePlan(top = top, bottom = bottom) + } + + /** Up to 15 grid PIDs nothing changes; more shrink into the same three-row height. */ + fun grid(itemCount: Int): TripInfoGrid = TripInfoMetrics.grid(itemCount, PERFORMANCE_GRID_COLUMNS, PERFORMANCE_GRID_ROWS) + + private fun byOrder(sortOrder: Map?): Comparator = + compareBy({ sortOrder?.get(it) ?: Int.MAX_VALUE }, { it }) +} diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceSurfaceRenderer.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceSurfaceRenderer.kt index fee4a2e5c..da7be6432 100644 --- a/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceSurfaceRenderer.kt +++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/performance/PerformanceSurfaceRenderer.kt @@ -17,10 +17,16 @@ package org.obd.graphs.renderer.performance import android.content.Context +import android.content.SharedPreferences import android.graphics.Canvas import android.graphics.Color import android.graphics.Rect import org.obd.graphs.bl.collector.MetricsCollector +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_BOTTOM +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_BOTTOM_SELECTED +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_HIDDEN +import org.obd.graphs.bl.query.PREF_QUERY_PERFORMANCE_TOP +import org.obd.graphs.preferences.Prefs import org.obd.graphs.renderer.AbstractSurfaceRenderer import org.obd.graphs.renderer.MARGIN_TOP import org.obd.graphs.renderer.api.Fps @@ -39,7 +45,8 @@ internal class PerformanceSurfaceRenderer( settings: ScreenSettings, private val metricsCollector: MetricsCollector, private val fps: Fps -) : AbstractSurfaceRenderer(context) { +) : AbstractSurfaceRenderer(context), + SharedPreferences.OnSharedPreferenceChangeListener { private val screenSettings = PerformanceScreenSettings(settings) private val performanceInfoDetails = PerformanceInfoDetails() private val performanceDrawer: PerformanceDrawer = @@ -48,6 +55,21 @@ internal class PerformanceSurfaceRenderer( private val metricsCache = MetricsCache() + init { + Prefs.registerOnSharedPreferenceChangeListener(this) + } + + override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences?, + key: String? + ) { + when (key) { + PREF_QUERY_PERFORMANCE_TOP, PREF_QUERY_PERFORMANCE_BOTTOM, PREF_QUERY_PERFORMANCE_HIDDEN, + PREF_QUERY_PERFORMANCE_BOTTOM_SELECTED, PERFORMANCE_SORT_ORDER_PREF_KEY, PERFORMANCE_BOTTOM_SORT_ORDER_PREF_KEY + -> metricsCache.cacheReset() + } + } + override fun invalidate() { metricsCache.cacheReset() performanceDrawer.invalidate() @@ -122,6 +144,7 @@ internal class PerformanceSurfaceRenderer( } override fun recycle() { + Prefs.unregisterOnSharedPreferenceChangeListener(this) performanceDrawer.recycle() } } diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoDrawer.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoDrawer.kt index 864fc686b..01513311d 100644 --- a/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoDrawer.kt +++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoDrawer.kt @@ -184,7 +184,7 @@ internal class TripInfoDrawer( } // Selected PIDs the grid has no room for; the user has to deselect some to see them. - private fun drawHiddenCount( + fun drawHiddenCount( canvas: Canvas, hidden: Int, left: Float, diff --git a/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoMetrics.kt b/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoMetrics.kt index 1835db729..9d80c0a8d 100644 --- a/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoMetrics.kt +++ b/screen_renderer/src/main/java/org/obd/graphs/renderer/trip/TripInfoMetrics.kt @@ -124,15 +124,20 @@ internal object TripInfoMetrics { } /** - * Fits [itemCount] items into the height of the default three-row grid by shrinking the text - * and adding columns. Up to 18 items nothing changes; beyond the minimum scale the last cell - * becomes a "+N" marker for the items that are not drawn. + * Fits [itemCount] items into the height of a [baseRows]-row grid by shrinking the text and + * adding columns. Up to [baseColumns] × [baseRows] items nothing changes (18 for Trip Info); + * beyond the minimum scale the last cell becomes a "+N" marker for the items that are not drawn. + * Performance reuses it with its own base grid. */ - fun grid(itemCount: Int): TripInfoGrid { + fun grid( + itemCount: Int, + baseColumns: Int = MAX_ITEM_IN_THE_ROW, + baseRows: Int = DEFAULT_GRID_ROWS + ): TripInfoGrid { var scale = 1f while (true) { - val columns = (MAX_ITEM_IN_THE_ROW / scale + EPSILON).toInt() - val maxItems = columns * (DEFAULT_GRID_ROWS / scale + EPSILON).toInt() + val columns = (baseColumns / scale + EPSILON).toInt() + val maxItems = columns * (baseRows / scale + EPSILON).toInt() if (maxItems >= itemCount || scale - GRID_SCALE_STEP < MIN_GRID_SCALE - EPSILON) { val shown = if (itemCount > maxItems) maxItems - 1 else itemCount return TripInfoGrid( diff --git a/screen_renderer/src/test/java/org/obd/graphs/renderer/performance/PerformanceMetricsTest.kt b/screen_renderer/src/test/java/org/obd/graphs/renderer/performance/PerformanceMetricsTest.kt new file mode 100644 index 000000000..c187fd6a3 --- /dev/null +++ b/screen_renderer/src/test/java/org/obd/graphs/renderer/performance/PerformanceMetricsTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.renderer.performance + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.obd.graphs.bl.datalogger.Pid + +private const val CUSTOM_PID_1 = 6001L +private const val CUSTOM_PID_2 = 6002L +private const val CUSTOM_PID_3 = 6003L + +class PerformanceMetricsTest { + // Bundled profile_8 (alfa_2_0_gme_aa.properties). + private val profileSelection = listOf(7021L, 7047L, 7025L, 7003L, 7009L, 7016L, 7002L, 7028L, 7005L, 7036L, 7007L) + private val profileTop = + listOf( + 7021L, 7047L, 7025L, 7003L, 7009L, 7016L, 7002L, 7036L, 17079L, 7017L, 7029L, + 7026L, 7008L, 7041L, 17107L, 17108L, 7066L, 7067L, 7068L, 7069L, 17084L + ) + private val profileBottom = listOf(7028L, 7005L, 7007L, 7046L, 17111L) + private val profileHidden = setOf(7036L) + + // What the former MetricsCache drew: the profile lists, filtered by queried and hidden PIDs. + private fun legacy(available: Collection) = + Pair( + profileTop.filter { available.contains(it) && !profileHidden.contains(it) }, + profileBottom.filter { available.contains(it) && !profileHidden.contains(it) } + ) + + private fun plan( + available: Collection, + bottomSelection: Set? = null, + sortOrder: Map? = null, + bottomSortOrder: Map? = null + ) = PerformanceMetrics.plan(available, profileTop, profileBottom, profileHidden, bottomSelection, sortOrder, bottomSortOrder) + + @Test + fun `profile PIDs keep the former layout when no gauges were ever set`() { + // Shuffled and with a stale drag order: neither may move the profile's PIDs. + val result = + plan( + available = profileSelection.shuffled(), + sortOrder = profileSelection.reversed().withIndex().associate { it.value to it.index } + ) + val (top, bottom) = legacy(profileSelection) + + assertEquals(top, result.top) + assertEquals(bottom, result.bottom) + } + + @Test + fun `every profile PID selected keeps the former layout`() { + val all = profileTop + profileBottom + val (top, bottom) = legacy(all) + + val result = plan(all) + + assertEquals(top, result.top) + assertEquals(bottom, result.bottom) + } + + @Test + fun `selected PIDs outside the profile follow its grid PIDs in the dialog order`() { + val result = + plan( + available = listOf(CUSTOM_PID_3, CUSTOM_PID_1, 7021L, CUSTOM_PID_2, 7047L), + sortOrder = mapOf(CUSTOM_PID_2 to 0, CUSTOM_PID_1 to 1) + ) + + assertEquals(listOf(7021L, 7047L, CUSTOM_PID_2, CUSTOM_PID_1, CUSTOM_PID_3), result.top) + } + + @Test + fun `hidden PIDs are drawn nowhere`() { + val result = plan(available = listOf(7036L, 7021L), bottomSelection = setOf(7036L)) + + assertEquals(listOf(7021L), result.top) + assertEquals(emptyList(), result.bottom) + } + + @Test + fun `vehicle status PID added to every query by the status panel is not drawn`() { + val status = Pid.VEHICLE_STATUS_PID_ID.id + val withStatus = profileSelection + status + + assertEquals(plan(profileSelection).top, plan(withStatus).top) + assertEquals(emptyList(), plan(withStatus, bottomSelection = setOf(status)).bottom) + } + + @Test + fun `selected gauges are ordered, capped and the rest moves to the grid`() { + val selection = listOf(7021L, 7047L, 7025L, 7003L, 7009L, 7016L, 7028L) + val result = + plan( + available = profileSelection, + bottomSelection = selection.toSet(), + bottomSortOrder = selection.reversed().withIndex().associate { it.value to it.index } + ) + + assertEquals(listOf(7028L, 7016L, 7009L, 7003L, 7025L), result.bottom) + // Not chosen as gauges: the capped ones and the profile's former gauges, profile grid PIDs first. + assertEquals(listOf(7021L, 7047L, 7002L, 7005L, 7007L), result.top) + } + + @Test + fun `empty gauge selection means no gauges`() { + val result = plan(available = profileSelection, bottomSelection = emptySet()) + + assertEquals(emptyList(), result.bottom) + assertTrue(result.top.containsAll(listOf(7028L, 7005L, 7007L))) + } + + @Test + fun `gauges that are not queried are skipped`() { + val result = plan(available = listOf(7021L, 7005L), bottomSelection = setOf(7005L, 7028L)) + + assertEquals(listOf(7005L), result.bottom) + } + + @Test + fun `grid is unchanged up to three full rows`() { + for (count in 0..15) { + val grid = PerformanceMetrics.grid(count) + assertEquals(PERFORMANCE_GRID_COLUMNS, grid.columns) + assertEquals(1f, grid.scale, 0.0001f) + assertEquals(count, grid.shown) + } + } + + @Test + fun `grid shrinks to fit every profile grid PID`() { + val grid = PerformanceMetrics.grid(profileTop.size) + + assertTrue(grid.scale < 1f) + assertTrue(grid.columns > PERFORMANCE_GRID_COLUMNS) + assertEquals(profileTop.size, grid.shown) + assertEquals(0, grid.hidden) + } + + @Test + fun `items that do not fit are counted in the last cell`() { + val grid = PerformanceMetrics.grid(100) + + assertEquals(grid.maxItems - 1, grid.shown) + assertEquals(100 - grid.shown, grid.hidden) + } +} From 3908a94be70a43b3d01a807d9287a4858c9ee3fd Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Wed, 7 Oct 2026 10:42:21 +0200 Subject: [PATCH 2/3] docs(performance): note phone shares AA gauge prefs and gauge overflow Document in the spec and CLAUDE.md that the phone Performance screen follows the AA gauge selection and order, and that gauges past MAX_GAUGES fall back into the grid. Rename a test that asserts order, not layout. --- CLAUDE.md | 2 ++ doc/specs/performance-dynamic-pids.md | 8 ++++++-- .../graphs/renderer/performance/PerformanceMetricsTest.kt | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e33e03b81..fa8e6db69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,8 @@ Gradle needs JDK 17+ (Crashlytics plugin); the shell default may be JDK 11 and f * `MetricsCache` reads the profile lists from `Prefs` when it rebuilds the plan, not from listener-updated copies: the renderer's listener only flags the plan outdated, and a copy updated by a second listener could be read half-applied. Grid fit reuses `TripInfoMetrics.grid(count, 5, 3)` (unchanged up to 15). +* `MetricsCache` serves phone and AA alike, so the AA gauge pref and order keys also drive the phone + screen, which has no setting of its own. Gauges past `MAX_GAUGES` fall back into the grid, not nowhere. ### Connectors (`:datalogger/.../connectors`) * One `AdapterConnection` per transport, chosen by `ConnectionManager.obtain()` on diff --git a/doc/specs/performance-dynamic-pids.md b/doc/specs/performance-dynamic-pids.md index 1c009f52f..b442dbbce 100644 --- a/doc/specs/performance-dynamic-pids.md +++ b/doc/specs/performance-dynamic-pids.md @@ -31,6 +31,8 @@ layout for any selection the old dialog could produce. | `pref.query.performance.hidden` | Never drawn | Unchanged; also excluded from the gauge dialog | | Vehicle status (status panel / disconnect-when-off on) | Never drawn (not in the profile lists) | Unchanged: listed in `PERFORMANCE_STATUS_PIDS` | | Brake boosting | Unchanged | Unchanged | +| Phone Performance screen | Drew the profile lists | Same plan as AA: follows the AA selection, gauge choice and drag order (no phone-side gauge setting) | +| More than 5 gauges checked | n/a | The dialog does not stop at 5; the first 5 in gauge order are drawn, the rest go to the grid | ## Settings @@ -93,7 +95,7 @@ handled by `SurfaceRendererScreen` like a Performance selection change. New stri | Test | Asserts | | --- | --- | | profile PIDs keep the former layout when no gauges were ever set | Shuffled bundled selection and a stale drag order give the former grid and gauges | -| every profile PID selected keeps the former layout | Same, with all top + bottom PIDs | +| every profile PID selected keeps the former order | Same grid and gauge order, with all top + bottom PIDs (21 grid PIDs now shrink, see below) | | selected PIDs outside the profile follow its grid PIDs in the dialog order | Profile PIDs first, then by sort order, unordered last | | hidden PIDs are drawn nowhere | Hidden in neither grid nor gauges, even if chosen as a gauge | | vehicle status PID added to every query by the status panel is not drawn | Grid unchanged, never a gauge | @@ -112,7 +114,9 @@ literals and `performanceBottomDialogSelection` (unset → profile gauges ∩ li - **Dialog changes have no unit test** (`:app` has no JVM setup); verify manually. - **Gauge height is not budgeted.** The grid is capped at 3 rows' height as before; on very small AA displays the gauges below may still be tight with a large font setting. -- **Phone shares the AA keys**, as for Trip Info: the gauge setting is in the AA section. +- **Phone shares the AA keys**, as for Trip Info. `MetricsCache` serves both screens and reads + `pref.aa.performance.bottom.pids.selected` and both `.view.settings` order keys, so a gauge choice or + drag order made in the AA section also changes the phone screen; the phone has no setting of its own. - Profile switching loads the new profile's keys; a gauge selection saved under one profile follows the generic profile save/load like any other pref. diff --git a/screen_renderer/src/test/java/org/obd/graphs/renderer/performance/PerformanceMetricsTest.kt b/screen_renderer/src/test/java/org/obd/graphs/renderer/performance/PerformanceMetricsTest.kt index c187fd6a3..11aa0f1cd 100644 --- a/screen_renderer/src/test/java/org/obd/graphs/renderer/performance/PerformanceMetricsTest.kt +++ b/screen_renderer/src/test/java/org/obd/graphs/renderer/performance/PerformanceMetricsTest.kt @@ -65,7 +65,7 @@ class PerformanceMetricsTest { } @Test - fun `every profile PID selected keeps the former layout`() { + fun `every profile PID selected keeps the former order`() { val all = profileTop + profileBottom val (top, bottom) = legacy(all) From 765488c4e4a33992c123f4b20d2a901069c19d12 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Wed, 7 Oct 2026 11:55:50 +0200 Subject: [PATCH 3/3] feat(performance): warn on gauge overflow, prune deselected gauges, fix stats width - Gauge dialog shows a toast when more than 5 gauges are checked (extras still fall back into the grid). - Saving the Performance selection drops deselected PIDs from a stored gauge selection, so a reselected PID no longer comes back as a gauge. - Performance passes its own column width to TripInfoDrawer.drawMetric; min/max stats were clamped to Trip Info's 6-column width. - Move the gauge cap to PERFORMANCE_MAX_GAUGES in :datalogger. --- CLAUDE.md | 9 ++++--- .../pid/PidDefinitionDialogFragment.kt | 6 +++++ .../preferences/pid/PidDefinitionViewModel.kt | 23 ++++++++++++++++++ app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../bl/query/PerformanceQueryStrategy.kt | 16 +++++++++++++ .../bl/query/PerformanceQueryStrategyTest.kt | 21 ++++++++++++++++ doc/specs/performance-dynamic-pids.md | 24 ++++++++++++++----- .../renderer/performance/PerformanceDrawer.kt | 3 ++- .../performance/PerformanceMetrics.kt | 6 ++--- .../graphs/renderer/trip/TripInfoDrawer.kt | 11 +++++---- 11 files changed, 102 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fa8e6db69..fbdba1520 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,7 +167,7 @@ Gradle needs JDK 17+ (Crashlytics plugin); the shell default may be JDK 11 and f so it must stay in `TRIP_INFO_STATUS_PIDS`. Any PID injected into queries the same way needs adding there too. * The Trip Info PID dialog offers the full registry, but the defaults and the current selection are added unfiltered: a selected PID hidden by the ECU/stable filters would be dropped on save. -* Top grid: labels drawn by `AbstractDrawer.drawTitle`, 6 columns (`MAX_ITEM_IN_THE_ROW`), row height `1.8 × textSizeBase` — labels have no width clamp, and a 3+ line label would overlap the next row. Above 18 items `TripInfoMetrics.grid()` shrinks text and adds columns within the same 3-row height (min scale 0.75 = 8 × 4 = 32 tiles; past that the last cell is "+N" for the undrawn ones). The floor was 0.5 (72 tiles) until "select all" on an 800 × 480 DHU gave ~4 px labels — readability, not capacity, sets the floor; up to 18 the layout is unchanged. `maxItemWidth` uses `layoutCache.grid.columns` — Performance calls `drawMetric` without `drawScreen`, so it keeps 6. +* Top grid: labels drawn by `AbstractDrawer.drawTitle`, 6 columns (`MAX_ITEM_IN_THE_ROW`), row height `1.8 × textSizeBase` — labels have no width clamp, and a 3+ line label would overlap the next row. Above 18 items `TripInfoMetrics.grid()` shrinks text and adds columns within the same 3-row height (min scale 0.75 = 8 × 4 = 32 tiles; past that the last cell is "+N" for the undrawn ones). The floor was 0.5 (72 tiles) until "select all" on an 800 × 480 DHU gave ~4 px labels — readability, not capacity, sets the floor; up to 18 the layout is unchanged. `maxItemWidth` uses `layoutCache.grid.columns`, which is never laid out for Performance (it calls `drawMetric` without `drawScreen`) — so Performance passes its own column width as `drawMetric(maxWidth = …)`; any other reuse of `drawMetric` must too. * Bottom row: max 4 (`MAX_BOTTOM_ITEMS`, extras go to the grid), drawn via `GiuliaDrawer.drawMetric`; text size is computed once in `calculateLayout` and cached. Any input that changes label geometry (area, top/bottom metric counts, bottom PID ids, break-label flag) must be part of `TripInfoLayoutCache.requiresLayoutUpdate` — the non-area ones live in the testable `TripInfoLabelLayout`. A count is not enough once PIDs are user-chosen: swapping a bottom PID keeps the count and left the old text size. Label width must be measured the same way it is drawn (split on `\n` only when breaking is enabled). * `TripInfoMetrics.grid()` takes base columns/rows so Performance shares it; `drawHiddenCount` is non-private for the same reason. * AA label splitting is controlled by `pref.aa.trip_info.break_label` (default `true`), independent of Giulia virtual screens. Phone Trip Info always splits (`TripInfoSettings`). @@ -179,12 +179,15 @@ Gradle needs JDK 17+ (Crashlytics plugin); the shell default may be JDK 11 and f Profile lists `pref.query.performance.top`/`bottom`/`hidden` stay the backward-compatible layout: top PIDs first in their order, other selected PIDs after in dialog order. Gauges come from `pref.aa.performance.bottom.pids.selected` (no XML defaultValue; unset = profile `bottom`, empty = none, - max `MAX_GAUGES = 5`). `VEHICLE_STATUS` is excluded via `PERFORMANCE_STATUS_PIDS`. + max `PERFORMANCE_MAX_GAUGES = 5`). `VEHICLE_STATUS` is excluded via `PERFORMANCE_STATUS_PIDS`. + Saving the main Performance selection prunes deselected PIDs from a *stored* gauge set (never + creates one), else a gauge came back as a gauge on reselect. * `MetricsCache` reads the profile lists from `Prefs` when it rebuilds the plan, not from listener-updated copies: the renderer's listener only flags the plan outdated, and a copy updated by a second listener could be read half-applied. Grid fit reuses `TripInfoMetrics.grid(count, 5, 3)` (unchanged up to 15). * `MetricsCache` serves phone and AA alike, so the AA gauge pref and order keys also drive the phone - screen, which has no setting of its own. Gauges past `MAX_GAUGES` fall back into the grid, not nowhere. + screen, which has no setting of its own. Gauges past `PERFORMANCE_MAX_GAUGES` fall back into the grid, + not nowhere; the gauge dialog only warns (toast), it does not block. ### Connectors (`:datalogger/.../connectors`) * One `AdapterConnection` per transport, chosen by `ConnectionManager.obtain()` on diff --git a/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionDialogFragment.kt b/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionDialogFragment.kt index 4fae6e07f..040234250 100644 --- a/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionDialogFragment.kt +++ b/app/src/main/java/org/obd/graphs/preferences/pid/PidDefinitionDialogFragment.kt @@ -32,6 +32,7 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.TextView +import android.widget.Toast import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat @@ -421,6 +422,11 @@ open class PidDefinitionDialogFragment( val btnSave = root.findViewById