App 2.7.1: voice-key double press works; floating button removed
The car's key report, from the on-screen key log in 2.7.0: android.intent.extra.hardkey.keycode = 287 (Integer), android.intent.extra.hardkey.down (Boolean; one report on press, one on release) and android.intent.extra.hardkey.longpress (Boolean). 2.7.0 only looked for short names like "keyCode", so it never found the code. SteeringKeyReport now matches extras by the last part of their name, case-insensitively, and is Android-free; its tests include the exact double press logged on the car (releases 279 ms apart). Replayed in the car's format on the emulator: double press opens the app, single and long presses do not. The floating button is removed (class, geometry and test, the overlay permission, setting, switch and strings): it did not show over the MG4 launcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
@@ -67,7 +67,6 @@ public class AbrpUploadService extends Service {
|
||||
private ConsumptionCloudClient consumptionCloudClient;
|
||||
private ChargeSessionTracker chargeSessionTracker;
|
||||
private TelemetryUploader telemetryUploader;
|
||||
private FloatingButton floatingButton;
|
||||
private SteeringKeyListener steeringKeys;
|
||||
private WifiAutoConnect wifi;
|
||||
private ScheduledExecutorService scheduler;
|
||||
@@ -100,9 +99,6 @@ public class AbrpUploadService extends Service {
|
||||
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
|
||||
wifi = new WifiAutoConnect(getApplicationContext());
|
||||
telemetryUploader = new TelemetryUploader(getApplicationContext());
|
||||
// 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();
|
||||
|
||||
@@ -146,7 +142,6 @@ public class AbrpUploadService extends Service {
|
||||
prefs.edit().putBoolean("service_running", false).apply();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Settings;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewConfiguration;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
|
||||
/**
|
||||
* A small round button floating over every app, so the app can be opened on a head unit whose
|
||||
* launcher does not list it. Tap opens the app; drag moves the button, and the position is kept.
|
||||
*
|
||||
* Owned by the always-on {@link AbrpUploadService}, so it is there from boot. Hidden while the
|
||||
* app itself is in front ({@link #setAppInForeground}) and when switched off in the Upload tab.
|
||||
*
|
||||
* Drawing over other apps needs the overlay permission, which the car build holds through the
|
||||
* system UID. Without it — or if the window manager refuses — the button simply stays away:
|
||||
* a third-party app must never crash the head unit (AGENTS.md).
|
||||
*
|
||||
* All methods run on the main thread.
|
||||
*/
|
||||
final class FloatingButton {
|
||||
|
||||
private static final String TAG = "NilsMG4.Floating";
|
||||
static final String KEY_X = "floating_button_x";
|
||||
static final String KEY_Y = "floating_button_y";
|
||||
private static final int SIZE_DP = 64;
|
||||
private static final int MARGIN_DP = 16;
|
||||
private static final float GLYPH_SCALE = 1.45f;
|
||||
|
||||
private static final Handler MAIN = new Handler(Looper.getMainLooper());
|
||||
private static FloatingButton current;
|
||||
private static boolean appInForeground;
|
||||
|
||||
private final Context context;
|
||||
private final SharedPreferences prefs;
|
||||
private final WindowManager windowManager;
|
||||
private View view;
|
||||
private WindowManager.LayoutParams params;
|
||||
private boolean added;
|
||||
|
||||
FloatingButton(Context context, SharedPreferences prefs) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.prefs = prefs;
|
||||
this.windowManager = (WindowManager) this.context.getSystemService(Context.WINDOW_SERVICE);
|
||||
}
|
||||
|
||||
// ---------- Static hooks for the activity and the settings switch ----------
|
||||
|
||||
static void attach(FloatingButton button) {
|
||||
current = button;
|
||||
button.update();
|
||||
}
|
||||
|
||||
static void detach(FloatingButton button) {
|
||||
if (current == button) current = null;
|
||||
button.removeView();
|
||||
}
|
||||
|
||||
/** MainActivity's onResume/onPause: no button on top of the app itself. */
|
||||
static void setAppInForeground(boolean inForeground) {
|
||||
appInForeground = inForeground;
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** Re-reads the switch and the permission; posted, so callable from anywhere. */
|
||||
static void refresh() {
|
||||
MAIN.post(() -> {
|
||||
if (current != null) current.update();
|
||||
});
|
||||
}
|
||||
|
||||
static boolean canDraw(Context context) {
|
||||
return Settings.canDrawOverlays(context);
|
||||
}
|
||||
|
||||
// ---------- Showing and hiding ----------
|
||||
|
||||
void update() {
|
||||
boolean wanted = UploadSettings.floatingButtonEnabled(prefs) && !appInForeground && canDraw(context);
|
||||
if (wanted && !added) addView();
|
||||
else if (!wanted && added) removeView();
|
||||
}
|
||||
|
||||
private void addView() {
|
||||
try {
|
||||
if (view == null) view = createView();
|
||||
DisplayMetrics metrics = new DisplayMetrics();
|
||||
windowManager.getDefaultDisplay().getRealMetrics(metrics);
|
||||
int size = dp(SIZE_DP);
|
||||
int[] start = FloatingButtonGeometry.defaultPosition(
|
||||
metrics.widthPixels, metrics.heightPixels, size, dp(MARGIN_DP));
|
||||
params = new WindowManager.LayoutParams(size, size,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
PixelFormat.TRANSLUCENT);
|
||||
params.gravity = Gravity.TOP | Gravity.START;
|
||||
params.x = FloatingButtonGeometry.clamp(prefs.getInt(KEY_X, start[0]), size, metrics.widthPixels);
|
||||
params.y = FloatingButtonGeometry.clamp(prefs.getInt(KEY_Y, start[1]), size, metrics.heightPixels);
|
||||
windowManager.addView(view, params);
|
||||
added = true;
|
||||
} catch (RuntimeException e) {
|
||||
// BadTokenException, SecurityException, or anything else the platform throws: no button.
|
||||
Log.w(TAG, "Floating button not shown: " + e);
|
||||
added = false;
|
||||
}
|
||||
}
|
||||
|
||||
void removeView() {
|
||||
if (!added) return;
|
||||
try {
|
||||
windowManager.removeView(view);
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "Floating button not removed cleanly: " + e);
|
||||
}
|
||||
added = false;
|
||||
}
|
||||
|
||||
private View createView() {
|
||||
FrameLayout frame = new FrameLayout(context);
|
||||
GradientDrawable circle = new GradientDrawable();
|
||||
circle.setShape(GradientDrawable.OVAL);
|
||||
circle.setColor(context.getColor(R.color.ev_accent));
|
||||
circle.setStroke(dp(2), context.getColor(R.color.ev_text_primary));
|
||||
frame.setBackground(circle);
|
||||
frame.setAlpha(0.9f);
|
||||
frame.setElevation(dp(6));
|
||||
frame.setContentDescription(context.getString(R.string.floating_button_description));
|
||||
ImageView glyph = new ImageView(context);
|
||||
glyph.setImageResource(R.drawable.ic_launcher_monochrome);
|
||||
glyph.setScaleType(ImageView.ScaleType.FIT_CENTER);
|
||||
// The launcher glyph keeps the adaptive-icon safe zone (a third of it empty margin);
|
||||
// enlarged here and clipped by the circle, it fills the button instead of a corner of it.
|
||||
glyph.setScaleX(GLYPH_SCALE);
|
||||
glyph.setScaleY(GLYPH_SCALE);
|
||||
frame.addView(glyph, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
frame.setClipToOutline(true);
|
||||
frame.setOutlineProvider(android.view.ViewOutlineProvider.BACKGROUND);
|
||||
frame.setOnTouchListener(new DragOrTap());
|
||||
return frame;
|
||||
}
|
||||
|
||||
// ---------- Touch: drag moves, tap opens ----------
|
||||
|
||||
private final class DragOrTap implements View.OnTouchListener {
|
||||
private final int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
|
||||
private float downRawX, downRawY;
|
||||
private int downX, downY;
|
||||
private boolean dragging;
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility") // a tap opens the app, as a click would
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
downRawX = event.getRawX();
|
||||
downRawY = event.getRawY();
|
||||
downX = params.x;
|
||||
downY = params.y;
|
||||
dragging = false;
|
||||
return true;
|
||||
case MotionEvent.ACTION_MOVE: {
|
||||
float dx = event.getRawX() - downRawX;
|
||||
float dy = event.getRawY() - downRawY;
|
||||
if (!dragging && FloatingButtonGeometry.isTap(dx, dy, touchSlop)) return true;
|
||||
dragging = true;
|
||||
moveTo(downX + Math.round(dx), downY + Math.round(dy));
|
||||
return true;
|
||||
}
|
||||
case MotionEvent.ACTION_UP:
|
||||
if (dragging) {
|
||||
prefs.edit().putInt(KEY_X, params.x).putInt(KEY_Y, params.y).apply();
|
||||
} else {
|
||||
openApp();
|
||||
}
|
||||
return true;
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void moveTo(int x, int y) {
|
||||
DisplayMetrics metrics = new DisplayMetrics();
|
||||
windowManager.getDefaultDisplay().getRealMetrics(metrics);
|
||||
params.x = FloatingButtonGeometry.clamp(x, params.width, metrics.widthPixels);
|
||||
params.y = FloatingButtonGeometry.clamp(y, params.height, metrics.heightPixels);
|
||||
try {
|
||||
windowManager.updateViewLayout(view, params);
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "Floating button not moved: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
private void openApp() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return Math.round(value * context.getResources().getDisplayMetrics().density);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Where the floating button sits and whether a touch was a tap. Android-free, unit-tested.
|
||||
* Positions are the button's top-left corner in pixels, as WindowManager takes them.
|
||||
*/
|
||||
final class FloatingButtonGeometry {
|
||||
|
||||
/** How far down the screen the button starts, as a fraction of the height. */
|
||||
static final float DEFAULT_TOP_FRACTION = 0.4f;
|
||||
|
||||
private FloatingButtonGeometry() { }
|
||||
|
||||
/** Right edge, about 40 % down, inset by [margin] from the edge. */
|
||||
static int[] defaultPosition(int screenWidth, int screenHeight, int size, int margin) {
|
||||
return new int[]{
|
||||
clamp(screenWidth - size - margin, size, screenWidth),
|
||||
clamp(Math.round(screenHeight * DEFAULT_TOP_FRACTION), size, screenHeight)};
|
||||
}
|
||||
|
||||
/** Keeps the whole button on screen, whatever position was saved or dragged to. */
|
||||
static int clamp(int position, int size, int screen) {
|
||||
int max = Math.max(0, screen - size);
|
||||
return Math.max(0, Math.min(position, max));
|
||||
}
|
||||
|
||||
/** A touch that moved no further than the system touch slop is a tap, not a drag. */
|
||||
static boolean isTap(float dx, float dy, int touchSlop) {
|
||||
return dx * dx + dy * dy <= (float) touchSlop * touchSlop;
|
||||
}
|
||||
}
|
||||
@@ -120,9 +120,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
private TextView connectionStatusText;
|
||||
|
||||
/** Upload page: live telemetry switch and cadence. */
|
||||
private SwitchMaterial liveTelemetrySwitch, boostSwitch, floatingButtonSwitch, voiceKeySwitch;
|
||||
private SwitchMaterial liveTelemetrySwitch, boostSwitch, voiceKeySwitch;
|
||||
private TextView steeringKeysText;
|
||||
private View overlayPermissionNote;
|
||||
private android.widget.Spinner intervalSpinner;
|
||||
private TextInputLayout lowSocLayout;
|
||||
private TextInputEditText lowSocInput;
|
||||
@@ -307,8 +306,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onResume();
|
||||
// Poll state and log only while the screen is up; onPause cancels it.
|
||||
uiHandler.post(uiRefresh);
|
||||
FloatingButton.setAppInForeground(true);
|
||||
refreshOverlayPermissionNote();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -316,7 +313,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onPause();
|
||||
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
||||
uiHandler.removeCallbacks(uiRefresh);
|
||||
FloatingButton.setAppInForeground(false);
|
||||
}
|
||||
|
||||
// ---------- Update check ----------
|
||||
@@ -802,22 +798,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
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));
|
||||
floatingButtonSwitch.setOnCheckedChangeListener((btn, checked) -> {
|
||||
prefs.edit().putBoolean(UploadSettings.KEY_FLOATING_BUTTON, checked).apply();
|
||||
FloatingButton.refresh();
|
||||
refreshOverlayPermissionNote();
|
||||
});
|
||||
servicePane.findViewById(R.id.overlay_permission_button).setOnClickListener(v -> {
|
||||
try {
|
||||
startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:" + getPackageName())));
|
||||
} catch (android.content.ActivityNotFoundException e) {
|
||||
Toast.makeText(this, R.string.overlay_settings_missing, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
|
||||
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
|
||||
boostSwitch = servicePane.findViewById(R.id.boost_switch);
|
||||
@@ -874,16 +854,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The note under the floating-button switch: only when the button is wanted but this build
|
||||
* may not draw over other apps (the car build may, through the system UID).
|
||||
*/
|
||||
private void refreshOverlayPermissionNote() {
|
||||
if (overlayPermissionNote == null) return;
|
||||
boolean missing = UploadSettings.floatingButtonEnabled(prefs) && !FloatingButton.canDraw(this);
|
||||
overlayPermissionNote.setVisibility(missing ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
|
||||
private void saveLowSocThreshold() {
|
||||
CharSequence raw = lowSocInput.getText();
|
||||
|
||||
@@ -20,9 +20,9 @@ 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.
|
||||
* voice key (see EVHardware's PhysicalButtonEventDecoder). The extras' format was read off the
|
||||
* car (see {@link SteeringKeyReport}); every report is still kept raw for the Logs tab, in case
|
||||
* another firmware sends them differently.
|
||||
*
|
||||
* Registered by the always-on service on the main thread; never throws into the head unit.
|
||||
*/
|
||||
@@ -93,7 +93,7 @@ final class SteeringKeyListener {
|
||||
|
||||
private void handle(Intent intent) {
|
||||
Bundle extras = intent.getExtras();
|
||||
Parsed key = parse(extras);
|
||||
SteeringKeyReport key = read(extras);
|
||||
boolean opens = false;
|
||||
if (key.code != null && UploadSettings.voiceKeyOpensApp(prefs)) {
|
||||
opens = detector.accept(key.code, key.down, key.longPress, android.os.SystemClock.elapsedRealtime());
|
||||
@@ -113,67 +113,21 @@ final class SteeringKeyListener {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reading an undocumented broadcast ----------
|
||||
// ---------- Reading the 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;
|
||||
/** An embedded KeyEvent if there is one, otherwise the extras by name (SteeringKeyReport). */
|
||||
static SteeringKeyReport read(Bundle extras) {
|
||||
if (extras == null) return SteeringKeyReport.parse(null);
|
||||
java.util.Map<String, Object> values = new java.util.HashMap<>();
|
||||
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;
|
||||
return new SteeringKeyReport(e.getKeyCode(), e.getAction() == KeyEvent.ACTION_DOWN, e.isLongPress());
|
||||
}
|
||||
values.put(k, v);
|
||||
}
|
||||
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;
|
||||
return SteeringKeyReport.parse(values);
|
||||
}
|
||||
|
||||
/** All extras as name=value(type), so the Logs tab shows the real format. */
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Reads one steering-wheel key report from the broadcast's extras.
|
||||
*
|
||||
* Seen on the MG4 (2026-10-17, on-screen key log): the head unit sends
|
||||
* android.intent.extra.hardkey.keycode = 287 (Integer)
|
||||
* android.intent.extra.hardkey.down = true (Boolean, then false on release)
|
||||
* android.intent.extra.hardkey.longpress = false (Boolean)
|
||||
* Names are matched on their last dot-separated part, case-insensitively, so this exact form
|
||||
* and simpler ones ("keyCode", "down", …) are both understood.
|
||||
*
|
||||
* Android-free (the extras arrive as a plain map), unit-tested.
|
||||
*/
|
||||
final class SteeringKeyReport {
|
||||
|
||||
final Integer code;
|
||||
final Boolean down;
|
||||
final boolean longPress;
|
||||
|
||||
SteeringKeyReport(Integer code, Boolean down, boolean longPress) {
|
||||
this.code = code;
|
||||
this.down = down;
|
||||
this.longPress = longPress;
|
||||
}
|
||||
|
||||
private static final String[] CODE_NAMES = {"keycode", "key_code", "keyvalue", "key_value", "code", "key"};
|
||||
private static final String[] ACTION_NAMES = {"keyaction", "key_action", "action", "keystate", "key_state",
|
||||
"state", "event", "type"};
|
||||
private static final String[] DOWN_NAMES = {"down", "isdown", "pressed", "ispressed"};
|
||||
private static final String[] LONG_NAMES = {"longpress", "islongpress", "long_press", "islong", "long"};
|
||||
|
||||
static SteeringKeyReport parse(Map<String, Object> extras) {
|
||||
Map<String, Object> byName = new HashMap<>();
|
||||
if (extras != null) {
|
||||
for (Map.Entry<String, Object> e : extras.entrySet()) {
|
||||
String key = e.getKey();
|
||||
String last = key.substring(key.lastIndexOf('.') + 1).toLowerCase(Locale.US);
|
||||
byName.putIfAbsent(last, e.getValue());
|
||||
}
|
||||
}
|
||||
Integer code = firstInt(byName, CODE_NAMES);
|
||||
Boolean down = null;
|
||||
boolean longPress = false;
|
||||
// KeyEvent's own convention for a numeric action: 0 down, 1 up; anything else is long.
|
||||
Integer action = firstInt(byName, ACTION_NAMES);
|
||||
if (action != null) {
|
||||
if (action == 0) down = true;
|
||||
else if (action == 1) down = false;
|
||||
else longPress = true;
|
||||
}
|
||||
Boolean explicitDown = firstBoolean(byName, DOWN_NAMES);
|
||||
if (explicitDown != null) down = explicitDown;
|
||||
Boolean explicitLong = firstBoolean(byName, LONG_NAMES);
|
||||
if (explicitLong != null) longPress = explicitLong;
|
||||
return new SteeringKeyReport(code, down, longPress);
|
||||
}
|
||||
|
||||
private static Integer firstInt(Map<String, Object> byName, String[] names) {
|
||||
for (String n : names) {
|
||||
Object v = byName.get(n);
|
||||
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(Map<String, Object> byName, String[] names) {
|
||||
for (String n : names) {
|
||||
Object v = byName.get(n);
|
||||
if (v instanceof Boolean) return (Boolean) v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,6 @@ final class UploadSettings {
|
||||
static final String KEY_LOW_SOC_PERCENT = "low_soc_percent";
|
||||
static final String KEY_AUTOSTART = "autostart";
|
||||
static final String KEY_ABRP_UPLOAD_ENABLED = "abrp_upload_enabled";
|
||||
/** The floating button that opens the app over the head unit's own screens. */
|
||||
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";
|
||||
|
||||
@@ -27,10 +23,6 @@ final class UploadSettings {
|
||||
return prefs.getBoolean(KEY_VOICE_KEY_OPENS, true);
|
||||
}
|
||||
|
||||
static boolean floatingButtonEnabled(SharedPreferences prefs) {
|
||||
return prefs.getBoolean(KEY_FLOATING_BUTTON, DEFAULT_FLOATING_BUTTON);
|
||||
}
|
||||
|
||||
/**
|
||||
* On by default: an ABRP plan is only useful if it is fed from the moment the drive
|
||||
* starts, and nobody opens this app before setting off. Kept as a switch because a
|
||||
|
||||
Reference in New Issue
Block a user