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
@@ -67,6 +67,7 @@ public class AbrpUploadService extends Service {
private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker;
private TelemetryUploader telemetryUploader;
private FloatingButton floatingButton;
private WifiAutoConnect wifi;
private ScheduledExecutorService scheduler;
private SharedPreferences prefs;
@@ -98,6 +99,9 @@ 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);
scheduler.scheduleWithFixedDelay(
this::safeUploadCycle,
@@ -139,6 +143,7 @@ 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);
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;
/** 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 TextInputLayout lowSocLayout;
private TextInputEditText lowSocInput;
@@ -303,6 +304,8 @@ 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
@@ -310,6 +313,7 @@ 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 ----------
@@ -791,6 +795,22 @@ public class MainActivity extends AppCompatActivity {
// ---------- Upload page ----------
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);
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
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. */
private void saveLowSocThreshold() {
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_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;
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