App 2.6.0: floating button to open the app

The MG4's launcher does not list third-party apps, so the app could
only be opened by reinstalling it. Now a small round MG-red button
floats over the car's screens: tap opens the app, drag moves it (the
position is kept and clamped to the screen), and it hides while the
app itself is open. A switch in the Upload tab turns it off.

The always-on service owns the overlay, so the button is there from
boot. It needs the overlay permission (SYSTEM_ALERT_WINDOW), which the
car build has through the system UID; without it, or if the window
manager refuses, the button stays away and nothing crashes, and the
Upload tab offers the system permission screen.

Tested on an Android 9 AOSP emulator at the head unit's 1920x1080 /
160 dpi with the car APK (installs as UID 1000): button appears over
the home screen, tap opens the app, hidden while open, drag moves it
without opening the app, position survives a force-stop, the switch
works through the UI. FloatingButtonGeometry has 3 unit tests.

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 22:55:15 +02:00
co-authored by Claude Opus 5.5
parent a53ea589b2
commit 11d53e7bc7
12 changed files with 362 additions and 3 deletions
+5
View File
@@ -114,6 +114,11 @@ 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.6.0: floating button to open the app, because the MG4 launcher does not list
it. Drawn over other apps (needs the overlay permission, which the car build has through the
system UID), draggable, hidden while the app is open, switch in the Upload tab. Tested on an
Android 9 AOSP emulator at 1920x1080/160 dpi with the car APK (installs as UID 1000 there too).
- 2026-10-17 — App 2.5.0: range estimates at current and 5-minute average power (Vehicle tab).
- 2026-10-17 — Live dashboard reworked around the signals the car sends: Climate setpoint, Last - 2026-10-17 — Live dashboard reworked around the signals the car sends: Climate setpoint, Last
update and a computed Energy in pack replace the empty temperature tiles; Battery voltage and update and a computed Energy in pack replace the empty temperature tiles; Battery voltage and
current replaces the Temperatures chart. current replaces the Temperatures chart.
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 62 versionCode 63
versionName "2.5.0" versionName "2.6.0"
// 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.
+2
View File
@@ -13,6 +13,8 @@
<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,6 +67,7 @@ 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 WifiAutoConnect wifi; private WifiAutoConnect wifi;
private ScheduledExecutorService scheduler; private ScheduledExecutorService scheduler;
private SharedPreferences prefs; private SharedPreferences prefs;
@@ -98,6 +99,9 @@ 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);
scheduler.scheduleWithFixedDelay( scheduler.scheduleWithFixedDelay(
this::safeUploadCycle, this::safeUploadCycle,
@@ -139,6 +143,7 @@ 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);
Log.i(TAG, "Service stopped"); Log.i(TAG, "Service stopped");
} }
@@ -0,0 +1,224 @@
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);
}
}
@@ -0,0 +1,31 @@
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,7 +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; private SwitchMaterial liveTelemetrySwitch, boostSwitch, floatingButtonSwitch;
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;
@@ -303,6 +304,8 @@ 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
@@ -310,6 +313,7 @@ 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 ----------
@@ -791,6 +795,22 @@ public class MainActivity extends AppCompatActivity {
// ---------- Upload page ---------- // ---------- Upload page ----------
private void bindServicePane() { private void bindServicePane() {
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);
@@ -847,6 +867,16 @@ 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();
@@ -16,6 +16,13 @@ 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;
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
@@ -6,6 +6,12 @@
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" /> <TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" 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/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" 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_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/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" />
+6
View File
@@ -6,6 +6,12 @@
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" /> <TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" 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/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" 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_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/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">
+6
View File
@@ -107,6 +107,12 @@
<string name="conn_err_timeout">Connection timed out</string> <string name="conn_err_timeout">Connection timed out</string>
<string name="conn_err_http">Server error (HTTP %1$d)</string> <string name="conn_err_http">Server error (HTTP %1$d)</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>
@@ -0,0 +1,37 @@
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() {
// 1920 x 1080 head unit, 64 px button, 16 px margin.
assertArrayEquals(new int[]{1840, 432}, FloatingButtonGeometry.defaultPosition(1920, 1080, 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));
}
}