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:
@@ -114,6 +114,10 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
|||||||
INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
|
INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
|
||||||
the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
|
the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
|
||||||
`kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
|
`kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
|
||||||
|
- 2026-10-17 — App 2.7.1: the voice key works. The car's key report, read off the on-screen log:
|
||||||
|
`android.intent.extra.hardkey.keycode` (Integer), `.down` (Boolean, a report per press and per
|
||||||
|
release), `.longpress` (Boolean). 2.7.0 looked only for short names and never found the code.
|
||||||
|
Floating button removed (it did not show over the MG4 launcher).
|
||||||
- 2026-10-17 — App 2.7.0: a double press of the steering wheel's voice key (code 287) opens the
|
- 2026-10-17 — App 2.7.0: a double press of the steering wheel's voice key (code 287) opens the
|
||||||
app, from the head unit's `com.saic.keyevent.hardkey.report` broadcast. Its extras are not
|
app, from the head unit's `com.saic.keyevent.hardkey.report` broadcast. Its extras are not
|
||||||
documented; the usual names are tried and every report is listed raw on the Logs tab. The
|
documented; the usual names are tried and every report is listed raw on the Logs tab. The
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 64
|
versionCode 65
|
||||||
versionName "2.7.0"
|
versionName "2.7.1"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||||
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<!-- The floating button that opens the app; the head unit's launcher does not list it. -->
|
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
|
||||||
|
|
||||||
<!-- Wi-Fi auto-join. CHANGE_WIFI_STATE covers enabling the radio and asking the
|
<!-- Wi-Fi auto-join. CHANGE_WIFI_STATE covers enabling the radio and asking the
|
||||||
supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
|
supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ public class AbrpUploadService extends Service {
|
|||||||
private ConsumptionCloudClient consumptionCloudClient;
|
private ConsumptionCloudClient consumptionCloudClient;
|
||||||
private ChargeSessionTracker chargeSessionTracker;
|
private ChargeSessionTracker chargeSessionTracker;
|
||||||
private TelemetryUploader telemetryUploader;
|
private TelemetryUploader telemetryUploader;
|
||||||
private FloatingButton floatingButton;
|
|
||||||
private SteeringKeyListener steeringKeys;
|
private SteeringKeyListener steeringKeys;
|
||||||
private WifiAutoConnect wifi;
|
private WifiAutoConnect wifi;
|
||||||
private ScheduledExecutorService scheduler;
|
private ScheduledExecutorService scheduler;
|
||||||
@@ -100,9 +99,6 @@ public class AbrpUploadService extends Service {
|
|||||||
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
|
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
|
||||||
wifi = new WifiAutoConnect(getApplicationContext());
|
wifi = new WifiAutoConnect(getApplicationContext());
|
||||||
telemetryUploader = new TelemetryUploader(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 = new SteeringKeyListener(this, prefs);
|
||||||
steeringKeys.register();
|
steeringKeys.register();
|
||||||
|
|
||||||
@@ -146,7 +142,6 @@ public class AbrpUploadService extends Service {
|
|||||||
prefs.edit().putBoolean("service_running", false).apply();
|
prefs.edit().putBoolean("service_running", false).apply();
|
||||||
if (scheduler != null) scheduler.shutdownNow();
|
if (scheduler != null) scheduler.shutdownNow();
|
||||||
if (telemetryUploader != null) telemetryUploader.stop();
|
if (telemetryUploader != null) telemetryUploader.stop();
|
||||||
if (floatingButton != null) FloatingButton.detach(floatingButton);
|
|
||||||
if (steeringKeys != null) steeringKeys.unregister();
|
if (steeringKeys != null) steeringKeys.unregister();
|
||||||
Log.i(TAG, "Service stopped");
|
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;
|
private TextView connectionStatusText;
|
||||||
|
|
||||||
/** Upload page: live telemetry switch and cadence. */
|
/** Upload page: live telemetry switch and cadence. */
|
||||||
private SwitchMaterial liveTelemetrySwitch, boostSwitch, floatingButtonSwitch, voiceKeySwitch;
|
private SwitchMaterial liveTelemetrySwitch, boostSwitch, voiceKeySwitch;
|
||||||
private TextView steeringKeysText;
|
private TextView steeringKeysText;
|
||||||
private View overlayPermissionNote;
|
|
||||||
private android.widget.Spinner intervalSpinner;
|
private android.widget.Spinner intervalSpinner;
|
||||||
private TextInputLayout lowSocLayout;
|
private TextInputLayout lowSocLayout;
|
||||||
private TextInputEditText lowSocInput;
|
private TextInputEditText lowSocInput;
|
||||||
@@ -307,8 +306,6 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
super.onResume();
|
super.onResume();
|
||||||
// Poll state and log only while the screen is up; onPause cancels it.
|
// Poll state and log only while the screen is up; onPause cancels it.
|
||||||
uiHandler.post(uiRefresh);
|
uiHandler.post(uiRefresh);
|
||||||
FloatingButton.setAppInForeground(true);
|
|
||||||
refreshOverlayPermissionNote();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -316,7 +313,6 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
super.onPause();
|
super.onPause();
|
||||||
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
||||||
uiHandler.removeCallbacks(uiRefresh);
|
uiHandler.removeCallbacks(uiRefresh);
|
||||||
FloatingButton.setAppInForeground(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Update check ----------
|
// ---------- Update check ----------
|
||||||
@@ -802,22 +798,6 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs));
|
voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs));
|
||||||
voiceKeySwitch.setOnCheckedChangeListener((btn, checked) ->
|
voiceKeySwitch.setOnCheckedChangeListener((btn, checked) ->
|
||||||
prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply());
|
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);
|
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
|
||||||
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
|
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
|
||||||
boostSwitch = servicePane.findViewById(R.id.boost_switch);
|
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. */
|
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
|
||||||
private void saveLowSocThreshold() {
|
private void saveLowSocThreshold() {
|
||||||
CharSequence raw = lowSocInput.getText();
|
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.
|
* 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
|
* 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
|
* voice key (see EVHardware's PhysicalButtonEventDecoder). The extras' format was read off the
|
||||||
* and the press is not documented, so the usual extras are tried, and every broadcast is kept
|
* car (see {@link SteeringKeyReport}); every report is still kept raw for the Logs tab, in case
|
||||||
* raw for the Logs tab — if the guess misses, that list says exactly what the car sends.
|
* another firmware sends them differently.
|
||||||
*
|
*
|
||||||
* Registered by the always-on service on the main thread; never throws into the head unit.
|
* 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) {
|
private void handle(Intent intent) {
|
||||||
Bundle extras = intent.getExtras();
|
Bundle extras = intent.getExtras();
|
||||||
Parsed key = parse(extras);
|
SteeringKeyReport key = read(extras);
|
||||||
boolean opens = false;
|
boolean opens = false;
|
||||||
if (key.code != null && UploadSettings.voiceKeyOpensApp(prefs)) {
|
if (key.code != null && UploadSettings.voiceKeyOpensApp(prefs)) {
|
||||||
opens = detector.accept(key.code, key.down, key.longPress, android.os.SystemClock.elapsedRealtime());
|
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 {
|
/** An embedded KeyEvent if there is one, otherwise the extras by name (SteeringKeyReport). */
|
||||||
Integer code;
|
static SteeringKeyReport read(Bundle extras) {
|
||||||
Boolean down;
|
if (extras == null) return SteeringKeyReport.parse(null);
|
||||||
boolean longPress;
|
java.util.Map<String, Object> values = new java.util.HashMap<>();
|
||||||
}
|
|
||||||
|
|
||||||
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()) {
|
for (String k : extras.keySet()) {
|
||||||
Object v = extras.get(k);
|
Object v = extras.get(k);
|
||||||
if (v instanceof KeyEvent) {
|
if (v instanceof KeyEvent) {
|
||||||
KeyEvent e = (KeyEvent) v;
|
KeyEvent e = (KeyEvent) v;
|
||||||
p.code = e.getKeyCode();
|
return new SteeringKeyReport(e.getKeyCode(), e.getAction() == KeyEvent.ACTION_DOWN, e.isLongPress());
|
||||||
p.down = e.getAction() == KeyEvent.ACTION_DOWN;
|
|
||||||
p.longPress = e.isLongPress();
|
|
||||||
return p;
|
|
||||||
}
|
}
|
||||||
|
values.put(k, v);
|
||||||
}
|
}
|
||||||
p.code = firstInt(extras, CODE_KEYS);
|
return SteeringKeyReport.parse(values);
|
||||||
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. */
|
/** 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_LOW_SOC_PERCENT = "low_soc_percent";
|
||||||
static final String KEY_AUTOSTART = "autostart";
|
static final String KEY_AUTOSTART = "autostart";
|
||||||
static final String KEY_ABRP_UPLOAD_ENABLED = "abrp_upload_enabled";
|
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. */
|
/** 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 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);
|
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
|
* 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
|
* starts, and nobody opens this app before setting off. Kept as a switch because a
|
||||||
|
|||||||
@@ -8,12 +8,6 @@
|
|||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/floating_button_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_floating_button" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_floating_button" android:textAppearance="@style/Text.EV.Secondary" />
|
|
||||||
<LinearLayout android:id="@+id/overlay_permission_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:layout_marginTop="8dp" android:visibility="gone">
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/overlay_permission_missing" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_error" />
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/overlay_permission_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/btn_overlay_permission" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||||
|
|||||||
@@ -8,12 +8,6 @@
|
|||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/floating_button_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_floating_button" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_floating_button" android:textAppearance="@style/Text.EV.Secondary" />
|
|
||||||
<LinearLayout android:id="@+id/overlay_permission_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:layout_marginTop="8dp" android:visibility="gone">
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/overlay_permission_missing" android:textAppearance="@style/Text.EV.Secondary" android:textColor="@color/ev_error" />
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/overlay_permission_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/btn_overlay_permission" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<Space android:layout_width="1dp" android:layout_height="20dp" />
|
<Space android:layout_width="1dp" android:layout_height="20dp" />
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
||||||
|
|||||||
@@ -112,12 +112,6 @@
|
|||||||
<string name="label_steering_keys">Steering-wheel keys</string>
|
<string name="label_steering_keys">Steering-wheel keys</string>
|
||||||
<string name="hint_steering_keys">The last key reports the car sent, newest first. If double-pressing the voice key does not open the app, a photo of this list shows how the car reports it.</string>
|
<string name="hint_steering_keys">The last key reports the car sent, newest first. If double-pressing the voice key does not open the app, a photo of this list shows how the car reports it.</string>
|
||||||
<string name="steering_keys_empty">No key reports received yet. Press a steering-wheel key.</string>
|
<string name="steering_keys_empty">No key reports received yet. Press a steering-wheel key.</string>
|
||||||
<string name="label_floating_button">Show floating button</string>
|
|
||||||
<string name="hint_floating_button">A small round button over the car\'s screens that opens this app. Drag it to move it; it hides while the app is open.</string>
|
|
||||||
<string name="overlay_permission_missing">This build may not draw over other apps, so the button cannot appear.</string>
|
|
||||||
<string name="btn_overlay_permission">Allow display over other apps</string>
|
|
||||||
<string name="overlay_settings_missing">The system screen for this permission is not available here</string>
|
|
||||||
<string name="floating_button_description">Open Nils\' MG4</string>
|
|
||||||
<string name="label_abrp_upload_enabled">Send live telemetry to the server</string>
|
<string name="label_abrp_upload_enabled">Send live telemetry to the server</string>
|
||||||
<string name="hint_abrp_upload_enabled">The server stores it and forwards it to ABRP. Turn this off when another app feeds ABRP; consumption and charging tracking keep running.</string>
|
<string name="hint_abrp_upload_enabled">The server stores it and forwards it to ABRP. Turn this off when another app feeds ABRP; consumption and charging tracking keep running.</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package com.evsuite.abrp;
|
|
||||||
|
|
||||||
import static org.junit.Assert.assertArrayEquals;
|
|
||||||
import static org.junit.Assert.assertEquals;
|
|
||||||
import static org.junit.Assert.assertFalse;
|
|
||||||
import static org.junit.Assert.assertTrue;
|
|
||||||
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
/** The floating button's position and tap rules — pure JVM. */
|
|
||||||
public class FloatingButtonGeometryTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void startsAtTheRightEdgeAboutFortyPercentDown() {
|
|
||||||
// The MG4 head unit is 1920 x 720; 64 px button, 16 px margin.
|
|
||||||
assertArrayEquals(new int[]{1840, 288}, FloatingButtonGeometry.defaultPosition(1920, 720, 64, 16));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void staysFullyOnScreen() {
|
|
||||||
assertEquals(0, FloatingButtonGeometry.clamp(-50, 64, 1920));
|
|
||||||
assertEquals(1856, FloatingButtonGeometry.clamp(5000, 64, 1920));
|
|
||||||
assertEquals(700, FloatingButtonGeometry.clamp(700, 64, 1920));
|
|
||||||
// A saved position from a larger screen still lands inside a smaller one.
|
|
||||||
assertEquals(720 - 64, FloatingButtonGeometry.clamp(1000, 64, 720));
|
|
||||||
// A screen smaller than the button pins it to the corner instead of going negative.
|
|
||||||
assertEquals(0, FloatingButtonGeometry.clamp(10, 64, 40));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void aTouchWithinTheSlopIsATapAnythingFurtherADrag() {
|
|
||||||
assertTrue(FloatingButtonGeometry.isTap(0, 0, 8));
|
|
||||||
assertTrue(FloatingButtonGeometry.isTap(5, -6, 8)); // 7.8 px
|
|
||||||
assertFalse(FloatingButtonGeometry.isTap(6, 6, 8)); // 8.5 px
|
|
||||||
assertFalse(FloatingButtonGeometry.isTap(0, 40, 8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/** Reading the head unit's key reports, as seen on the MG4 — pure JVM. */
|
||||||
|
public class SteeringKeyReportTest {
|
||||||
|
|
||||||
|
private static Map<String, Object> car(int code, boolean down, boolean longPress) {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("android.intent.extra.hardkey.keycode", code);
|
||||||
|
m.put("android.intent.extra.hardkey.down", down);
|
||||||
|
m.put("android.intent.extra.hardkey.longpress", longPress);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void readsTheMg4Format() {
|
||||||
|
SteeringKeyReport r = SteeringKeyReport.parse(car(287, true, false));
|
||||||
|
assertEquals(Integer.valueOf(287), r.code);
|
||||||
|
assertEquals(Boolean.TRUE, r.down);
|
||||||
|
assertFalse(r.longPress);
|
||||||
|
assertEquals(Boolean.FALSE, SteeringKeyReport.parse(car(287, false, false)).down);
|
||||||
|
assertTrue(SteeringKeyReport.parse(car(287, true, true)).longPress);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theDoublePressFromTheCarOpensTheApp() {
|
||||||
|
// The sequence logged on the MG4 at 23:31:02: down .300, up .442, down .545, up .721.
|
||||||
|
DoublePressDetector d = new DoublePressDetector(SteeringKeyListener.VOICE_KEY);
|
||||||
|
long[] at = {300, 442, 545, 721};
|
||||||
|
boolean[] down = {true, false, true, false};
|
||||||
|
boolean opened = false;
|
||||||
|
for (int i = 0; i < at.length; i++) {
|
||||||
|
SteeringKeyReport r = SteeringKeyReport.parse(car(287, down[i], false));
|
||||||
|
opened |= d.accept(r.code, r.down, r.longPress, at[i]);
|
||||||
|
}
|
||||||
|
assertTrue(opened);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void simpleNamesStillWork() {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("keyCode", 17);
|
||||||
|
m.put("action", 1);
|
||||||
|
SteeringKeyReport r = SteeringKeyReport.parse(m);
|
||||||
|
assertEquals(Integer.valueOf(17), r.code);
|
||||||
|
assertEquals(Boolean.FALSE, r.down);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void nothingUsableStaysUnknown() {
|
||||||
|
SteeringKeyReport r = SteeringKeyReport.parse(new HashMap<>());
|
||||||
|
assertNull(r.code);
|
||||||
|
assertNull(r.down);
|
||||||
|
assertFalse(r.longPress);
|
||||||
|
assertNull(SteeringKeyReport.parse(null).code);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user