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
@@ -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();