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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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,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">
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user