App 2.7.0: voice-key double press opens the app; no scroll jumps

Steering wheel: a double press of the voice key (code 287) opens the
app. The floating button from 2.6.0 does not show over the MG4
launcher, so this is the way back into the app. The head unit reports
keys as the broadcast com.saic.keyevent.hardkey.report, whose extras
are not documented: the receiver tries the usual names (and an
embedded KeyEvent), and counts one press per report whichever way the
up/down field turns out to work; long presses never open the app. The
last 20 reports are listed raw on the Logs tab, so if the guess
misses, a photo of that list gives the real format. A switch in the
Upload tab turns it off. DoublePressDetector (500 ms window) has 5
tests; on the emulator, simulated double presses open the app and
single presses, other keys and long presses do not.

Scrolling: pages jumped back to the top on every 2 s refresh. A stack
trace on the emulator showed why: ViewPager2's RecyclerView lays the
pages out while measuring, at a provisional height, and
ScrollView.onLayout clamps the position to 0. StableScrollView keeps
the driver's position across layouts and is now the root of every
page; the scrolled Charging page stays put across refreshes.

Vehicle tab: all six value tiles, including both range estimates, sit
in one row at the top, so nothing needs scrolling at 1920x720.

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-17 23:24:48 +02:00
co-authored by Claude Opus 5.5
parent fde963533d
commit af15b15db1
20 changed files with 471 additions and 86 deletions
@@ -68,6 +68,7 @@ public class AbrpUploadService extends Service {
private ChargeSessionTracker chargeSessionTracker;
private TelemetryUploader telemetryUploader;
private FloatingButton floatingButton;
private SteeringKeyListener steeringKeys;
private WifiAutoConnect wifi;
private ScheduledExecutorService scheduler;
private SharedPreferences prefs;
@@ -102,6 +103,8 @@ public class AbrpUploadService extends Service {
// onCreate runs on the main thread, where the overlay window must be added.
floatingButton = new FloatingButton(this, prefs);
FloatingButton.attach(floatingButton);
steeringKeys = new SteeringKeyListener(this, prefs);
steeringKeys.register();
scheduler.scheduleWithFixedDelay(
this::safeUploadCycle,
@@ -144,6 +147,7 @@ public class AbrpUploadService extends Service {
if (scheduler != null) scheduler.shutdownNow();
if (telemetryUploader != null) telemetryUploader.stop();
if (floatingButton != null) FloatingButton.detach(floatingButton);
if (steeringKeys != null) steeringKeys.unregister();
Log.i(TAG, "Service stopped");
}
@@ -0,0 +1,46 @@
package com.evsuite.abrp;
/**
* Spots a double press of one steering-wheel key from the head unit's key broadcasts.
*
* The broadcast format is not documented, so this copes with all the shapes it could take:
* - no up/down information at all: every broadcast is one press;
* - separate down and up broadcasts: only releases count. If the meaning of the action field
* is the other way round, downs count instead — still exactly one per press, so a double
* press is found either way;
* - a long press resets, so holding the key never opens anything.
*
* Android-free and unit-tested; the window is longer than the stock 300 ms, because a
* deliberate double press on a steering wheel is slower than one on a touch screen.
*/
final class DoublePressDetector {
static final long WINDOW_MS = 500;
private final int keyCode;
private long lastPressAt = Long.MIN_VALUE;
DoublePressDetector(int keyCode) {
this.keyCode = keyCode;
}
/**
* @param down true for a key-down, false for a release, null when the broadcast does not say
* @param longPress true when the broadcast marks a long press
* @return true exactly once per double press
*/
synchronized boolean accept(int code, Boolean down, boolean longPress, long atMillis) {
if (code != keyCode) return false;
if (longPress) {
lastPressAt = Long.MIN_VALUE;
return false;
}
if (Boolean.TRUE.equals(down)) return false;
if (lastPressAt != Long.MIN_VALUE && atMillis - lastPressAt <= WINDOW_MS) {
lastPressAt = Long.MIN_VALUE; // a third press starts over
return true;
}
lastPressAt = atMillis;
return false;
}
}
@@ -120,7 +120,8 @@ public class MainActivity extends AppCompatActivity {
private TextView connectionStatusText;
/** Upload page: live telemetry switch and cadence. */
private SwitchMaterial liveTelemetrySwitch, boostSwitch, floatingButtonSwitch;
private SwitchMaterial liveTelemetrySwitch, boostSwitch, floatingButtonSwitch, voiceKeySwitch;
private TextView steeringKeysText;
private View overlayPermissionNote;
private android.widget.Spinner intervalSpinner;
private TextInputLayout lowSocLayout;
@@ -210,6 +211,7 @@ public class MainActivity extends AppCompatActivity {
@Override public void run() {
refreshStatus();
refreshCallLog();
refreshSteeringKeys();
refreshVehicle();
refreshConsumption();
refreshWifi();
@@ -254,6 +256,7 @@ 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);
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
setUpPager();
@@ -795,6 +798,10 @@ public class MainActivity extends AppCompatActivity {
// ---------- Upload page ----------
private void bindServicePane() {
voiceKeySwitch = servicePane.findViewById(R.id.voice_key_switch);
voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs));
voiceKeySwitch.setOnCheckedChangeListener((btn, checked) ->
prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply());
floatingButtonSwitch = servicePane.findViewById(R.id.floating_button_switch);
overlayPermissionNote = servicePane.findViewById(R.id.overlay_permission_note);
floatingButtonSwitch.setChecked(UploadSettings.floatingButtonEnabled(prefs));
@@ -1340,6 +1347,15 @@ public class MainActivity extends AppCompatActivity {
}, "cloud-refresh").start();
}
/** What the car's steering-wheel key broadcasts looked like, newest first. */
private void refreshSteeringKeys() {
if (steeringKeysText == null) return;
java.util.List<String> keys = SteeringKeyListener.recent();
String text = keys.isEmpty() ? getString(R.string.steering_keys_empty)
: android.text.TextUtils.join("\n", keys);
if (!text.contentEquals(steeringKeysText.getText())) steeringKeysText.setText(text);
}
private void refreshCallLog() {
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
if (entries.isEmpty()) {
@@ -0,0 +1,49 @@
package com.evsuite.abrp;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.ScrollView;
/**
* A ScrollView that keeps the position the driver scrolled to.
*
* The pages live in ViewPager2's RecyclerView. When the live values change, the tiles ask for
* a new layout, and the RecyclerView lays the pages out once already while it is still
* measuring, with a provisional height at which the content seems to fit. ScrollView.onLayout
* then clamps the scroll position to 0, and the real layout that follows keeps it there: every
* refresh threw the page back to the top (seen in a stack trace on the emulator).
*
* Here only scrolling that is not part of a layout counts as the driver's position, and every
* layout restores it. ScrollView.scrollTo clamps against the real content height, so a
* position that no longer exists still ends at the bottom rather than past it.
*/
public class StableScrollView extends ScrollView {
private int wantedScrollY;
private boolean inLayout;
public StableScrollView(Context context) { super(context); }
public StableScrollView(Context context, AttributeSet attrs) { super(context, attrs); }
public StableScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (!inLayout) wantedScrollY = t;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
inLayout = true;
try {
super.onLayout(changed, l, t, r, b);
if (getScrollY() != wantedScrollY) scrollTo(getScrollX(), wantedScrollY);
} finally {
inLayout = false;
}
}
}
@@ -0,0 +1,191 @@
package com.evsuite.abrp;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
import android.view.KeyEvent;
import java.text.SimpleDateFormat;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* Opens the app on a double press of the steering wheel's voice key.
*
* The head unit reports steering-wheel keys as the broadcast {@link #ACTION}; code 287 is the
* voice key (see EVHardware's PhysicalButtonEventDecoder). How the broadcast carries the code
* and the press is not documented, so the usual extras are tried, and every broadcast is kept
* raw for the Logs tab — if the guess misses, that list says exactly what the car sends.
*
* Registered by the always-on service on the main thread; never throws into the head unit.
*/
final class SteeringKeyListener {
static final String ACTION = "com.saic.keyevent.hardkey.report";
static final int VOICE_KEY = 287;
private static final String TAG = "NilsMG4.SteeringKey";
private static final int MAX_LOG = 20;
private static final ArrayDeque<String> RECENT = new ArrayDeque<>();
private final Context context;
private final SharedPreferences prefs;
private final DoublePressDetector detector = new DoublePressDetector(VOICE_KEY);
private boolean registered;
private final BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context c, Intent intent) {
try {
handle(intent);
} catch (RuntimeException e) {
Log.w(TAG, "Key broadcast not handled: " + e);
}
}
};
SteeringKeyListener(Context context, SharedPreferences prefs) {
this.context = context.getApplicationContext();
this.prefs = prefs;
}
void register() {
try {
// Exported on purpose: the report comes from the head unit's own key service, another
// app. The flag is required from API 33 and absent on this API-28 head unit;
// ContextCompat picks the right call for the platform underneath.
androidx.core.content.ContextCompat.registerReceiver(context, receiver, new IntentFilter(ACTION),
androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
registered = true;
} catch (RuntimeException e) {
Log.w(TAG, "Key broadcasts not available: " + e);
}
}
void unregister() {
if (!registered) return;
try {
context.unregisterReceiver(receiver);
} catch (RuntimeException ignored) {
// already gone
}
registered = false;
}
/** The last broadcasts, newest first, for the Logs tab. */
static synchronized List<String> recent() {
List<String> out = new ArrayList<>(RECENT);
java.util.Collections.reverse(out);
return out;
}
private static synchronized void remember(String line) {
if (RECENT.size() >= MAX_LOG) RECENT.removeFirst();
RECENT.addLast(line);
}
private void handle(Intent intent) {
Bundle extras = intent.getExtras();
Parsed key = parse(extras);
boolean opens = false;
if (key.code != null && UploadSettings.voiceKeyOpensApp(prefs)) {
opens = detector.accept(key.code, key.down, key.longPress, android.os.SystemClock.elapsedRealtime());
}
remember(new SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(new Date())
+ " " + describe(extras)
+ "\n -> code " + (key.code == null ? "?" : key.code)
+ ", down " + (key.down == null ? "?" : key.down)
+ ", long " + key.longPress + (opens ? " OPENS APP" : ""));
if (opens) {
try {
context.startActivity(new Intent(context, MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT));
} catch (RuntimeException e) {
Log.w(TAG, "App not opened: " + e);
}
}
}
// ---------- Reading an undocumented broadcast ----------
static final class Parsed {
Integer code;
Boolean down;
boolean longPress;
}
private static final String[] CODE_KEYS = {"keyCode", "keycode", "key_code", "KEY_CODE", "keyValue",
"key_value", "code", "key"};
private static final String[] ACTION_KEYS = {"keyAction", "key_action", "KEY_ACTION", "action",
"keyState", "key_state", "state", "event", "type"};
private static final String[] DOWN_KEYS = {"down", "isDown", "pressed", "isPressed"};
private static final String[] LONG_KEYS = {"longPress", "isLongPress", "long_press", "isLong", "long"};
static Parsed parse(Bundle extras) {
Parsed p = new Parsed();
if (extras == null) return p;
for (String k : extras.keySet()) {
Object v = extras.get(k);
if (v instanceof KeyEvent) {
KeyEvent e = (KeyEvent) v;
p.code = e.getKeyCode();
p.down = e.getAction() == KeyEvent.ACTION_DOWN;
p.longPress = e.isLongPress();
return p;
}
}
p.code = firstInt(extras, CODE_KEYS);
Integer action = firstInt(extras, ACTION_KEYS);
// KeyEvent's own convention: 0 down, 1 up; anything else (often 2) is a long press.
if (action != null) {
if (action == 0) p.down = true;
else if (action == 1) p.down = false;
else p.longPress = true;
}
Boolean down = firstBoolean(extras, DOWN_KEYS);
if (down != null) p.down = down;
Boolean longPress = firstBoolean(extras, LONG_KEYS);
if (longPress != null) p.longPress = longPress;
return p;
}
private static Integer firstInt(Bundle b, String[] keys) {
for (String k : keys) {
Object v = b.get(k);
if (v instanceof Integer) return (Integer) v;
if (v instanceof Long) return (int) (long) (Long) v;
if (v instanceof String) {
try { return Integer.parseInt(((String) v).trim()); } catch (NumberFormatException ignored) { }
}
}
return null;
}
private static Boolean firstBoolean(Bundle b, String[] keys) {
for (String k : keys) {
Object v = b.get(k);
if (v instanceof Boolean) return (Boolean) v;
}
return null;
}
/** All extras as name=value(type), so the Logs tab shows the real format. */
static String describe(Bundle extras) {
if (extras == null || extras.isEmpty()) return "(no extras)";
StringBuilder sb = new StringBuilder();
for (String k : extras.keySet()) {
Object v = extras.get(k);
if (sb.length() > 0) sb.append(", ");
sb.append(k).append('=').append(v).append(" (")
.append(v == null ? "null" : v.getClass().getSimpleName()).append(')');
}
return sb.toString();
}
}
@@ -20,6 +20,13 @@ final class UploadSettings {
static final String KEY_FLOATING_BUTTON = "floating_button";
static final boolean DEFAULT_FLOATING_BUTTON = true;
/** A double press of the steering wheel's voice key opens the app. */
static final String KEY_VOICE_KEY_OPENS = "voice_key_opens_app";
static boolean voiceKeyOpensApp(SharedPreferences prefs) {
return prefs.getBoolean(KEY_VOICE_KEY_OPENS, true);
}
static boolean floatingButtonEnabled(SharedPreferences prefs) {
return prefs.getBoolean(KEY_FLOATING_BUTTON, DEFAULT_FLOATING_BUTTON);
}