App 2.14.0: accent colour after the MG4 paints; car colour probe

The accent is selectable in Settings → Display: Dynamic Red (default),
Volcano Orange, Holborn Blue, Racing Green, Camden Grey, Monument
Silver, Arctic White and Black Pearl. White, silver and black cannot be
an accent on the black screen as they are, so they become light greys
and a pearl blue-grey.

The accent palette is now theme attributes (attrs_accent.xml) with one
overlay per paint (accents.xml), applied before the activity inflates;
layouts, colour lists, drawables and the views that draw in code use
them, including the red tints that were hard-coded. A change recreates
the activity on the same page. Android 9 crashes on gradients with
theme colours, so the battery card is built from layers.

Logs → "Look for the car colour" lists the head unit's system
properties that mention colour, paint or car model, to find out whether
the paint colour can be read from the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-18 13:52:57 +02:00
co-authored by Claude Opus 5.5
parent 40906ec4a7
commit 9b5fcc0734
41 changed files with 425 additions and 74 deletions
@@ -0,0 +1,51 @@
package com.evsuite.abrp;
import android.content.Context;
import android.util.TypedValue;
/**
* The app's accent colour, after the MG4 paint colours; Dynamic Red unless another is chosen
* (Settings → Display). Each is a theme overlay in res/values/accents.xml that MainActivity lays
* over the app theme before it inflates anything; a change recreates the activity.
*/
enum AccentColor {
DYNAMIC_RED("Dynamic Red", R.style.ThemeOverlay_EV_Accent_DynamicRed),
VOLCANO_ORANGE("Volcano Orange", R.style.ThemeOverlay_EV_Accent_VolcanoOrange),
HOLBORN_BLUE("Holborn Blue", R.style.ThemeOverlay_EV_Accent_HolbornBlue),
RACING_GREEN("Racing Green", R.style.ThemeOverlay_EV_Accent_RacingGreen),
CAMDEN_GREY("Camden Grey", R.style.ThemeOverlay_EV_Accent_CamdenGrey),
MONUMENT_SILVER("Monument Silver", R.style.ThemeOverlay_EV_Accent_MonumentSilver),
ARCTIC_WHITE("Arctic White", R.style.ThemeOverlay_EV_Accent_ArcticWhite),
BLACK_PEARL("Black Pearl", R.style.ThemeOverlay_EV_Accent_BlackPearl);
static final String PREF_KEY = "accent_color";
static final AccentColor DEFAULT = DYNAMIC_RED;
final String label;
final int overlay;
AccentColor(String label, int overlay) {
this.label = label;
this.overlay = overlay;
}
/** The stored choice; anything unknown falls back to Dynamic Red. */
static AccentColor fromPref(String name) {
for (AccentColor c : values()) {
if (c.name().equals(name)) return c;
}
return DEFAULT;
}
/** A colour of the current accent palette, e.g. {@code R.attr.evAccent}, for code that draws. */
static int resolve(Context context, int attr) {
TypedValue value = new TypedValue();
context.getTheme().resolveAttribute(attr, value, true);
return value.data;
}
/** The colour with its alpha replaced, for glows and grids drawn in code. */
static int withAlpha(int color, int alpha) {
return (color & 0x00FFFFFF) | (alpha << 24);
}
}
@@ -0,0 +1,59 @@
package com.evsuite.abrp;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Looks for the car's paint colour among the head unit's system properties, for the Logs tab.
*
* The MG launcher draws the car in its own colour, so the head unit knows it somewhere; where is
* not documented. This lists every property whose name or value mentions a colour or paint, so
* a photo of the Logs tab shows whether (and how) it can be read. Read-only: it runs the
* platform's own getprop, which only reads.
*/
final class CarColorProbe {
private static final Pattern LINE = Pattern.compile("^\\[([^\\]]*)\\]: \\[(.*)\\]$");
private static final Pattern INTERESTING = Pattern.compile(
"(?i)colou?r|paint|body_?col|exterior|car_?model|vehicle_?(config|type|model)");
private CarColorProbe() { }
/** getprop's output, filtered; at most {@code max} lines. Never throws. */
static List<String> run(int max) {
try {
Process p = new ProcessBuilder("getprop").redirectErrorStream(true).start();
StringBuilder out = new StringBuilder();
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null && out.length() < 512 * 1024) out.append(line).append('\n');
}
p.waitFor();
return filter(out.toString(), max);
} catch (Exception e) {
List<String> failed = new ArrayList<>();
failed.add("getprop failed: " + e.getClass().getSimpleName());
return failed;
}
}
/** The "name = value" lines whose name or value looks colour- or model-related. Pure. */
static List<String> filter(String getpropOutput, int max) {
List<String> found = new ArrayList<>();
for (String line : getpropOutput.split("\n")) {
Matcher m = LINE.matcher(line.trim());
if (!m.matches()) continue;
String name = m.group(1), value = m.group(2);
if (INTERESTING.matcher(name).find() || INTERESTING.matcher(value).find()) {
found.add(name + " = " + value);
if (found.size() >= max) break;
}
}
return found;
}
}
@@ -14,7 +14,7 @@ public final class ChargingGraphView extends View {
private final Paint grid = new Paint(1), line = new Paint(1), text = new Paint(1);
private final List<float[]> points = new ArrayList<>();
private final float[] socPower = new float[101];
public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(0x55E0858A); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); }
public ChargingGraphView(Context c, AttributeSet a) { super(c,a); java.util.Arrays.fill(socPower, Float.NaN); grid.setColor(AccentColor.withAlpha(AccentColor.resolve(c, R.attr.evAccentLight), 0x55)); line.setColor(0xFFFFC857); line.setStrokeWidth(4); text.setColor(0xFFBDC4C6); text.setTextSize(20); }
synchronized void add(Float soc, Float power) {
if (soc == null || power == null || power >= 0) return;
float p = Math.abs(power);
@@ -227,11 +227,13 @@ public class MainActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
super.onCreate(savedInstanceState);
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
// Before anything is inflated: every ?attr/evAccent… resolves against this overlay.
getTheme().applyStyle(AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null)).overlay, true);
setContentView(R.layout.activity_main);
((TextView) findViewById(R.id.version_badge)).setText("v" + BuildConfig.VERSION_NAME);
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
// The pages are inflated here, once, and handed to the pager as fixed,
// non-recycled items. That is what lets every widget below be looked up now and
@@ -259,9 +261,20 @@ public class MainActivity extends AppCompatActivity {
statusText = servicePane.findViewById(R.id.status_text);
callLogText = logPane.findViewById(R.id.call_log_text);
steeringKeysText = logPane.findViewById(R.id.steering_keys_text);
TextView carColorText = logPane.findViewById(R.id.car_color_text);
logPane.findViewById(R.id.car_color_button).setOnClickListener(v -> new Thread(() -> {
java.util.List<String> found = CarColorProbe.run(60);
String shown = found.isEmpty() ? getString(R.string.car_color_none)
: android.text.TextUtils.join("\n", found);
runOnUiThread(() -> carColorText.setText(shown));
}, "car-color-probe").start());
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
setUpPager();
// Back on the page that was open when the activity was recreated (accent colour change).
if (savedInstanceState != null) {
((ViewPager2) findViewById(R.id.content)).setCurrentItem(savedInstanceState.getInt(STATE_PAGE, 0), false);
}
findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v));
@@ -275,6 +288,14 @@ public class MainActivity extends AppCompatActivity {
startForegroundService(new Intent(this, AbrpUploadService.class));
}
private static final String STATE_PAGE = "page";
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt(STATE_PAGE, ((ViewPager2) findViewById(R.id.content)).getCurrentItem());
}
private void showAbout() {
String version;
try {
@@ -710,7 +731,7 @@ public class MainActivity extends AppCompatActivity {
code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD);
code.setTextSize(36);
code.setLetterSpacing(0.15f);
code.setTextColor(getColor(R.color.ev_cyan));
code.setTextColor(AccentColor.resolve(MainActivity.this, R.attr.evAccentLight));
status = new TextView(MainActivity.this);
status.setTextAppearance(R.style.Text_EV_Body);
status.setText(startingText);
@@ -978,6 +999,7 @@ public class MainActivity extends AppCompatActivity {
voiceKeySwitch.setOnCheckedChangeListener((btn, checked) ->
prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply());
bindTireUnit();
bindAccentColor();
fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch);
fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs));
fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> {
@@ -1058,6 +1080,29 @@ public class MainActivity extends AppCompatActivity {
}, "abrp-test").start();
}
/** The accent colour; a change recreates the activity so every view takes it. */
private void bindAccentColor() {
android.widget.Spinner spinner = settingsPane.findViewById(R.id.accent_spinner);
AccentColor[] colors = AccentColor.values();
String[] labels = new String[colors.length];
for (int i = 0; i < colors.length; i++) labels[i] = colors[i].label;
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
this, R.layout.spinner_value, labels);
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
spinner.setAdapter(adapter);
AccentColor current = AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null));
spinner.setSelection(current.ordinal());
spinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(android.widget.AdapterView<?> parent, View view, int position, long id) {
if (colors[position] == current) return;
prefs.edit().putString(AccentColor.PREF_KEY, colors[position].name()).apply();
recreate();
}
@Override public void onNothingSelected(android.widget.AdapterView<?> parent) { }
});
}
/** Tyre pressure unit: display only, read on every refresh of the Overview. */
private void bindTireUnit() {
android.widget.Spinner spinner = settingsPane.findViewById(R.id.tire_unit_spinner);
@@ -1388,9 +1433,10 @@ public class MainActivity extends AppCompatActivity {
MaterialButton button = consumptionPane.findViewById(id);
boolean selected = id == selectedId;
button.setSelected(selected);
button.setTextColor(selected ? 0xFFE0424C : COLOR_PENDING);
int accent = AccentColor.resolve(this, R.attr.evAccent);
button.setTextColor(selected ? accent : COLOR_PENDING);
button.setStrokeColor(android.content.res.ColorStateList.valueOf(
selected ? 0xFFE0424C : 0xFF3A4143));
selected ? accent : 0xFF3A4143));
}
}
@@ -8,7 +8,10 @@ import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
/** Static, low-opacity light behind the glass panels; no blur passes or animation loop. */
/**
* Static, low-opacity light behind the glass panels, in the accent colour; no blur passes or
* animation loop.
*/
public final class PremiumBackdropView extends View {
private final Paint cyan = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint gold = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -20,10 +23,12 @@ public final class PremiumBackdropView extends View {
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {
float radius = Math.max(1, Math.max(w, h) * 0.65f);
int deep = AccentColor.resolve(getContext(), R.attr.evAccentDeep);
int stroke = AccentColor.resolve(getContext(), R.attr.evAccentStroke);
cyan.setShader(new RadialGradient(w * 0.70f, 0, radius,
new int[]{0x245B191D, 0x00000000}, null, Shader.TileMode.CLAMP));
new int[]{AccentColor.withAlpha(deep, 0x48), 0x00000000}, null, Shader.TileMode.CLAMP));
gold.setShader(new RadialGradient(w, h, radius * 0.65f,
new int[]{0x1C8B3A3F, 0x00000000}, null, Shader.TileMode.CLAMP));
new int[]{AccentColor.withAlpha(stroke, 0x1C), 0x00000000}, null, Shader.TileMode.CLAMP));
}
@Override protected void onDraw(Canvas canvas) {