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:
+2
-2
@@ -34,8 +34,8 @@ android {
|
||||
applicationId "com.stinnesbeck.nilsmg4"
|
||||
minSdk 27
|
||||
targetSdk 36
|
||||
versionCode 62
|
||||
versionName "2.5.0"
|
||||
versionCode 63
|
||||
versionName "2.6.0"
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<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_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
|
||||
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 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
|
||||
|
||||
@@ -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" />
|
||||
<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" />
|
||||
<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 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" />
|
||||
|
||||
@@ -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" />
|
||||
<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" />
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -107,6 +107,12 @@
|
||||
<string name="conn_err_timeout">Connection timed out</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="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