feat: add ota and ui changes
This commit is contained in:
@@ -39,9 +39,6 @@ public class BootReceiver extends BroadcastReceiver {
|
||||
String action = intent.getAction();
|
||||
if (action == null || !START_ACTIONS.contains(action)) return;
|
||||
|
||||
// Window closing has its own switch and never requires ABRP credentials.
|
||||
WindowCloseService.startIfEnabled(context);
|
||||
|
||||
SharedPreferences prefs = context.getSharedPreferences("abrp_prefs", Context.MODE_PRIVATE);
|
||||
boolean enabled = prefs.getBoolean("service_enabled", false);
|
||||
boolean autostart = prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART);
|
||||
|
||||
@@ -12,7 +12,6 @@ import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.CompoundButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
@@ -21,6 +20,7 @@ import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
@@ -44,17 +44,26 @@ public class MainActivity extends AppCompatActivity {
|
||||
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
|
||||
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/MG4ABRP";
|
||||
|
||||
/** Top-bar tabs, in page order. Parallel to {@link #panes}. */
|
||||
/** Navigation destinations in page order. Parallel to {@link #panes}. */
|
||||
private static final int[] TAB_IDS =
|
||||
{ R.id.tabAbrp, R.id.tabService, R.id.tabVehicle, R.id.tabWindows, R.id.tabWifi,
|
||||
{ R.id.tabVehicle, R.id.tabAbrp, R.id.tabService, R.id.tabWifi,
|
||||
R.id.tabLog };
|
||||
|
||||
private static final int[] PAGE_TITLES = {
|
||||
R.string.design_overview_title, R.string.design_abrp_title,
|
||||
R.string.design_service_title, R.string.design_wifi_title, R.string.design_log_title
|
||||
};
|
||||
private static final int[] PAGE_SUBTITLES = {
|
||||
R.string.design_overview_subtitle, R.string.design_abrp_subtitle,
|
||||
R.string.design_service_subtitle, R.string.design_wifi_subtitle, R.string.design_log_subtitle
|
||||
};
|
||||
|
||||
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
|
||||
// #F44336 sit around 4:1 on this background, which disappears behind a sunlit
|
||||
// reflection. The palette entries are the lightened variants (7:1 and above).
|
||||
private static final int COLOR_OK = 0xFF8FE6A6;
|
||||
private static final int COLOR_ERROR = 0xFFFF9A9A;
|
||||
private static final int COLOR_PENDING = 0xFFC6CFD8;
|
||||
private static final int COLOR_OK = 0xFFA4DCC1;
|
||||
private static final int COLOR_ERROR = 0xFFFFB4AB;
|
||||
private static final int COLOR_PENDING = 0xFFBDC4C6;
|
||||
|
||||
private TextInputLayout apiKeyLayout;
|
||||
private TextInputEditText apiKeyInput;
|
||||
@@ -62,10 +71,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
private TextInputEditText tokenInput;
|
||||
private SwitchMaterial serviceSwitch;
|
||||
private SwitchMaterial autostartSwitch;
|
||||
private SwitchMaterial windowCloseSwitch;
|
||||
private CompoundButton.OnCheckedChangeListener windowCloseListener;
|
||||
private TextView windowCloseStatus;
|
||||
private TextView windowCloseLog;
|
||||
private TextView statusText;
|
||||
private Button testButton;
|
||||
private View connectionStatusRow;
|
||||
@@ -80,10 +85,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
private View servicePane;
|
||||
private View logPane;
|
||||
private View vehiclePane;
|
||||
private View windowsPane;
|
||||
private View wifiPane;
|
||||
|
||||
/** The six pages, in the order the top bar lists them. Parallel to {@link #TAB_IDS}. */
|
||||
/** The five pages in navigation order. Parallel to {@link #TAB_IDS}. */
|
||||
private View[] panes;
|
||||
|
||||
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
|
||||
@@ -117,7 +121,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the top bar to the pager: every tab is a page, and every page is a tab.
|
||||
* Wires the navigation to the pager: every tab is a page, and every page is a tab.
|
||||
*
|
||||
* Each page has its own view type, so the pager asks for it once and keeps it: these
|
||||
* views are the activity's own, held in {@link #panes}, not rows to be recycled.
|
||||
@@ -140,10 +144,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
// state, exactly as they did when they were siblings in the layout.
|
||||
}
|
||||
});
|
||||
// All of them stay alive. They are cheap, and the service switch, the call log, the
|
||||
// window-close status and the vehicle readings are refreshed by the tick whether or
|
||||
// not their page is on screen — refreshWindowClose in particular dereferences views
|
||||
// the pager would otherwise be free to detach.
|
||||
// Keep all pages alive: the service switch, log and vehicle readings are refreshed
|
||||
// by the tick even when their page is off screen.
|
||||
pager.setOffscreenPageLimit(panes.length - 1);
|
||||
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
||||
@Override public void onPageSelected(int position) { markCurrentPage(position); }
|
||||
@@ -159,7 +161,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the top-bar tab of the page on screen.
|
||||
* Marks the destination and labels the page on screen.
|
||||
*
|
||||
* Only isSelected is set: fill, text, icon and stroke come from the
|
||||
* res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected
|
||||
@@ -169,6 +171,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
for (int i = 0; i < TAB_IDS.length; i++) {
|
||||
findViewById(TAB_IDS[i]).setSelected(i == position);
|
||||
}
|
||||
((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]);
|
||||
((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]);
|
||||
}
|
||||
|
||||
/** Refreshes state + call log while the screen is visible. */
|
||||
@@ -179,7 +183,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
refreshCallLog();
|
||||
refreshVehicle();
|
||||
refreshWifi();
|
||||
refreshWindowClose();
|
||||
uiHandler.postDelayed(this, 2_000L);
|
||||
}
|
||||
};
|
||||
@@ -200,13 +203,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||
securePrefs = SecurePrefs.get(this);
|
||||
|
||||
// The three pages are inflated here, once, and handed to the pager as fixed,
|
||||
// The pages are inflated here, once, and handed to the pager as fixed,
|
||||
// non-recycled items. That is what lets every widget below be looked up now and
|
||||
// held for the life of the activity, the way it was when the panes were siblings
|
||||
// in activity_main.xml — a recycled page would invalidate these references as
|
||||
@@ -214,14 +218,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
abrpPane = inflatePane(R.layout.pane_abrp);
|
||||
servicePane = inflatePane(R.layout.pane_service);
|
||||
vehiclePane = inflatePane(R.layout.pane_vehicle);
|
||||
windowsPane = inflatePane(R.layout.pane_windows);
|
||||
wifiPane = inflatePane(R.layout.pane_wifi);
|
||||
logPane = inflatePane(R.layout.pane_log);
|
||||
panes = new View[] { abrpPane, servicePane, vehiclePane, windowsPane, wifiPane, logPane };
|
||||
panes = new View[] { vehiclePane, abrpPane, servicePane, wifiPane, logPane };
|
||||
|
||||
bindVehiclePane();
|
||||
bindWifiPane();
|
||||
bindWindowClose();
|
||||
|
||||
apiKeyLayout = abrpPane.findViewById(R.id.api_key_layout);
|
||||
apiKeyInput = abrpPane.findViewById(R.id.api_key_input);
|
||||
@@ -327,51 +329,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
dialog.show();
|
||||
if (dialog.getWindow() != null) {
|
||||
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
|
||||
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
|
||||
int width = Math.min((int) (760 * metrics.density),
|
||||
metrics.widthPixels - (int) (32 * metrics.density));
|
||||
dialog.getWindow().setLayout(width, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
private void bindWindowClose() {
|
||||
windowCloseSwitch = windowsPane.findViewById(R.id.window_close_switch);
|
||||
windowCloseStatus = windowsPane.findViewById(R.id.window_close_status);
|
||||
windowCloseLog = windowsPane.findViewById(R.id.window_close_log);
|
||||
windowCloseListener = (button, enabled) -> {
|
||||
prefs.edit().putBoolean(WindowCloseService.KEY_ENABLED, enabled).apply();
|
||||
if (enabled) WindowCloseService.startIfEnabled(this);
|
||||
else stopService(new Intent(this, WindowCloseService.class));
|
||||
refreshWindowClose();
|
||||
};
|
||||
windowCloseSwitch.setChecked(prefs.getBoolean(WindowCloseService.KEY_ENABLED, false));
|
||||
windowCloseSwitch.setOnCheckedChangeListener(windowCloseListener);
|
||||
// A build that cannot reach the vehicle service would accept the toggle and then fail
|
||||
// every command, which reads as a broken feature rather than an inapplicable one.
|
||||
if (!WindowCloseService.canWriteToVehicle()) windowCloseSwitch.setEnabled(false);
|
||||
WindowCloseService.startIfEnabled(this);
|
||||
refreshWindowClose();
|
||||
}
|
||||
|
||||
private void refreshWindowClose() {
|
||||
if (windowCloseSwitch == null) return;
|
||||
boolean enabled = prefs.getBoolean(WindowCloseService.KEY_ENABLED, false);
|
||||
if (windowCloseSwitch.isChecked() != enabled) {
|
||||
// Detached first: setChecked re-enters the listener, which would write the pref
|
||||
// and restart the service on every one of these two-second refreshes.
|
||||
windowCloseSwitch.setOnCheckedChangeListener(null);
|
||||
windowCloseSwitch.setChecked(enabled);
|
||||
windowCloseSwitch.setOnCheckedChangeListener(windowCloseListener);
|
||||
}
|
||||
FirmwareInfo.Gen generation = FirmwareInfo.INSTANCE.getGeneration();
|
||||
if (!WindowCloseService.canWriteToVehicle()) {
|
||||
windowCloseStatus.setText(R.string.window_close_no_system_uid);
|
||||
} else if (!enabled) {
|
||||
windowCloseStatus.setText(R.string.window_close_stopped);
|
||||
} else if (generation != FirmwareInfo.Gen.UNKNOWN && generation != FirmwareInfo.Gen.SWI69) {
|
||||
windowCloseStatus.setText(R.string.window_close_unsupported);
|
||||
} else {
|
||||
windowCloseStatus.setText(WindowCloseService.statusResource());
|
||||
}
|
||||
windowCloseLog.setText(WindowCloseService.eventLog());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
@@ -638,8 +602,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (sec == current.intervalSec) selected = i;
|
||||
}
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(
|
||||
this, android.R.layout.simple_spinner_item, labels);
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
this, R.layout.spinner_value, labels);
|
||||
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
|
||||
intervalSpinner.setAdapter(adapter);
|
||||
intervalSpinner.setSelection(selected);
|
||||
intervalSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@@ -690,7 +654,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
* Checks for a newer build, and installs one if it is there.
|
||||
*
|
||||
* The button is disabled for the duration rather than guarded by a flag: the whole
|
||||
* sequence — API call, download, {@code pm install} — runs on a background thread, and
|
||||
* sequence — API call, download, installer staging — runs on a background thread, and
|
||||
* a second press would start a second download of the same APK. It comes back on when
|
||||
* the result lands, whatever that result is.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RadialGradient;
|
||||
import android.graphics.Shader;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
/** Static, low-opacity light behind the glass panels; no blur passes or animation loop. */
|
||||
public final class PremiumBackdropView extends View {
|
||||
private final Paint cyan = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint gold = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
public PremiumBackdropView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
}
|
||||
|
||||
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
float radius = Math.max(1, Math.max(w, h) * 0.65f);
|
||||
cyan.setShader(new RadialGradient(w * 0.70f, 0, radius,
|
||||
new int[]{0x2419565B, 0x00000000}, null, Shader.TileMode.CLAMP));
|
||||
gold.setShader(new RadialGradient(w, h, radius * 0.65f,
|
||||
new int[]{0x1C8B693A, 0x00000000}, null, Shader.TileMode.CLAMP));
|
||||
}
|
||||
|
||||
@Override protected void onDraw(Canvas canvas) {
|
||||
canvas.drawRect(0, 0, getWidth(), getHeight(), cyan);
|
||||
canvas.drawRect(0, 0, getWidth(), getHeight(), gold);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ final class WifiAutoConnect {
|
||||
String name = c.SSID.replace("\"", "").trim();
|
||||
if (!name.isEmpty() && !names.contains(name)) names.add(name);
|
||||
}
|
||||
} catch (SecurityException e) {
|
||||
Log.w(TAG, "Saved networks unavailable: permission denied");
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "getConfiguredNetworks refused: " + t.getMessage());
|
||||
}
|
||||
@@ -127,6 +129,9 @@ final class WifiAutoConnect {
|
||||
}
|
||||
}
|
||||
return Result.REFUSED;
|
||||
} catch (SecurityException e) {
|
||||
Log.w(TAG, "Wi-Fi connection refused: permission denied");
|
||||
return Result.REFUSED;
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "connect attempt failed: " + t.getMessage());
|
||||
return Result.REFUSED;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/** WinClose's TX_DOOR_SENSOR=6: zero is open; suppress repeated identical callbacks. */
|
||||
final class WinCloseDoorSignal {
|
||||
private int previous = -1;
|
||||
|
||||
boolean opening(int raw) {
|
||||
if (raw < 0) return false;
|
||||
boolean opening = raw == 0 && previous != 0;
|
||||
previous = raw;
|
||||
return opening;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a reading that can never count as an opening.
|
||||
*
|
||||
* A fresh listener registration replays the door's existing state. Treating that replay
|
||||
* as an edge would close the windows on a driver who is halfway out when the binder
|
||||
* reconnects — and the binder reconnects every five seconds while it is down.
|
||||
*/
|
||||
void seed(int raw) {
|
||||
if (raw >= 0) previous = raw;
|
||||
}
|
||||
|
||||
void reset() { previous = -1; }
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package com.evsuite.abrp
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Binder
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.IBinder
|
||||
import android.os.Parcel
|
||||
import android.os.SystemClock
|
||||
import com.evsuite.hardware.EVHardware
|
||||
import com.evsuite.hardware.FirmwareInfo
|
||||
import com.evsuite.hardware.VehicleWriteGate
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
/**
|
||||
* Adapted from ../winclose/app/src/main/java/com/mg4/winclose/WindowHardware.kt.
|
||||
* Keeps WinClose's SWI69 CarAdapterClient clients, CarState transaction 6 (0=open),
|
||||
* and setVehicleWindowStatus(area, command). No alternative door property is used.
|
||||
* Speed/time arming, delay, beeps, AUTO and per-window settings are intentionally absent.
|
||||
* WinClose identifies this event as the driver door; it carries no per-door area ID.
|
||||
*/
|
||||
internal class WinCloseHardware(
|
||||
context: Context,
|
||||
private val onOpening: () -> Unit,
|
||||
private val report: (String) -> Unit,
|
||||
) : AutoCloseable {
|
||||
private val context = context.applicationContext
|
||||
private val thread = HandlerThread("winclose-hardware").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
@Volatile private var closed = false
|
||||
@Volatile private var vsm: Any? = null
|
||||
@Volatile private var state: Any? = null
|
||||
private var vsmBinder: IBinder? = null
|
||||
private var stateBinder: IBinder? = null
|
||||
private var callbackProxy: Any? = null
|
||||
private val doorSignal = WinCloseDoorSignal()
|
||||
@Volatile private var registeredAt = Long.MIN_VALUE
|
||||
private var initialized = false
|
||||
private val retry = object : Runnable {
|
||||
override fun run() {
|
||||
if (closed) return
|
||||
try {
|
||||
if (!initialized) {
|
||||
EVHardware.init(context)
|
||||
initialized = true
|
||||
}
|
||||
if (FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI69) connect()
|
||||
} catch (e: Exception) {
|
||||
report("WinClose connection: ${e.javaClass.simpleName}")
|
||||
}
|
||||
if (!closed) handler.postDelayed(this, 5_000L)
|
||||
}
|
||||
}
|
||||
|
||||
fun start() { handler.post(retry) }
|
||||
|
||||
private fun connect() {
|
||||
if (vsmBinder?.isBinderAlive == true && stateBinder?.isBinderAlive == true) return
|
||||
val launcher = context.createPackageContext("com.saicmotor.launcher",
|
||||
Context.CONTEXT_INCLUDE_CODE or Context.CONTEXT_IGNORE_SECURITY)
|
||||
val loader = launcher.classLoader
|
||||
val adapterClass = loader.loadClass("com.saicmotor.carapi.CarAdapterClient")
|
||||
val adapter = adapterClass.getMethod("getInstance", Context::class.java)
|
||||
.invoke(null, context) ?: return
|
||||
// The same adapter startup and queryClient codes as WinClose.
|
||||
adapterClass.getMethod("start").invoke(adapter)
|
||||
val query = adapterClass.getMethod("queryClient", INT)
|
||||
if (vsmBinder?.isBinderAlive != true) {
|
||||
vsm = null
|
||||
val binder = query.invoke(adapter, 0x8) as? IBinder
|
||||
if (binder != null) {
|
||||
val cls = loader.loadClass("com.saicmotor.carapi.client.CarVehicleSettingClient")
|
||||
vsm = cls.getConstructor(IBinder::class.java).newInstance(binder)
|
||||
vsmBinder = binder
|
||||
report("WinClose VSM ready")
|
||||
}
|
||||
}
|
||||
if (stateBinder?.isBinderAlive == true) return
|
||||
detachListener()
|
||||
val binder = query.invoke(adapter, 0xb) as? IBinder ?: return
|
||||
val cls = loader.loadClass("com.saicmotor.carapi.client.CarStateClient")
|
||||
val client = cls.getConstructor(IBinder::class.java).newInstance(binder)
|
||||
val register = cls.methods.firstOrNull {
|
||||
it.name in listOf("registerListener", "registListener") && it.parameterCount == 1
|
||||
} ?: return
|
||||
val iface = register.parameterTypes[0]
|
||||
if (!iface.isInterface) return
|
||||
val callback = object : Binder() {
|
||||
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
|
||||
if (code == IBinder.INTERFACE_TRANSACTION) {
|
||||
reply?.writeString(DESCRIPTOR)
|
||||
return true
|
||||
}
|
||||
if (code !in IBinder.FIRST_CALL_TRANSACTION..IBinder.LAST_CALL_TRANSACTION)
|
||||
return super.onTransact(code, data, reply, flags)
|
||||
try {
|
||||
data.enforceInterface(DESCRIPTOR)
|
||||
// Acknowledge the other CarState callbacks as WinClose does, but only
|
||||
// TX_DOOR_SENSOR=6 is a trigger. It is not a generic property or bitmask.
|
||||
if (code == 6) {
|
||||
val raw = data.readInt()
|
||||
val receivedAt = SystemClock.elapsedRealtime()
|
||||
handler.post {
|
||||
if (closed || stateBinder !== binder) return@post
|
||||
val sinceRegistration = receivedAt - registeredAt
|
||||
if (sinceRegistration < REGISTRATION_REPLAY_MS) {
|
||||
// Whatever arrives this soon after registering describes the
|
||||
// door as it already is, not a door someone just opened.
|
||||
doorSignal.seed(raw)
|
||||
report("WinClose door state seeded: raw=$raw")
|
||||
return@post
|
||||
}
|
||||
val fresh = SystemClock.elapsedRealtime() - receivedAt < 1_500L
|
||||
val opening = doorSignal.opening(raw)
|
||||
if (opening) report("WinClose door opening: raw=$raw fresh=$fresh")
|
||||
if (fresh && opening) onOpening()
|
||||
}
|
||||
}
|
||||
reply?.writeNoException()
|
||||
} catch (_: Exception) {
|
||||
// An unreadable callback must not re-arm an already-open door.
|
||||
reply?.writeNoException()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
val proxy = Proxy.newProxyInstance(loader, arrayOf(iface)) { self, method, args ->
|
||||
when (method.name) {
|
||||
"asBinder" -> callback
|
||||
"hashCode" -> System.identityHashCode(self)
|
||||
"equals" -> self === args?.firstOrNull()
|
||||
"toString" -> "WinCloseCarStateListener"
|
||||
else -> when (method.returnType) {
|
||||
java.lang.Boolean.TYPE -> false
|
||||
java.lang.Integer.TYPE -> 0
|
||||
java.lang.Long.TYPE -> 0L
|
||||
java.lang.Float.TYPE -> 0f
|
||||
java.lang.Double.TYPE -> 0.0
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
doorSignal.reset()
|
||||
state = client
|
||||
stateBinder = binder
|
||||
callbackProxy = proxy
|
||||
// Stamped before the call: the replay can arrive inside register.invoke itself.
|
||||
registeredAt = SystemClock.elapsedRealtime()
|
||||
try {
|
||||
register.invoke(client, proxy)
|
||||
} catch (e: Exception) {
|
||||
detachListener()
|
||||
throw e
|
||||
}
|
||||
report("WinClose CarState door listener ready")
|
||||
}
|
||||
|
||||
fun isReady(): Boolean = vsm != null && state != null
|
||||
|
||||
fun isParked(): Boolean? {
|
||||
val client = state ?: return null
|
||||
return try {
|
||||
val gear = client.javaClass.getMethod("getGearState").invoke(client) as? Int
|
||||
gear?.takeIf { it >= 0 }?.let { it == 1 }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun closeAllWindowsPulsed(keepRunning: () -> Boolean): Boolean {
|
||||
val client = vsm ?: return false
|
||||
val setter = try {
|
||||
client.javaClass.getMethod("setVehicleWindowStatus", INT, INT)
|
||||
} catch (_: Exception) { return false }
|
||||
return WindowClosePulse.run({
|
||||
!closed && keepRunning() && FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI69
|
||||
&& isParked() == true && VehicleWriteGate.allow("WinClose setVehicleWindowStatus")
|
||||
}, { area, command ->
|
||||
try {
|
||||
setter.invoke(client, area, command)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
report("WinClose command failed: area=$area command=$command ${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun detachListener() {
|
||||
val client = state
|
||||
val proxy = callbackProxy
|
||||
if (client != null && proxy != null) runCatching {
|
||||
client.javaClass.methods.firstOrNull {
|
||||
it.name in listOf("unregisterListener", "unregistListener")
|
||||
&& it.parameterCount == 1 && it.parameterTypes[0].isInstance(proxy)
|
||||
}?.invoke(client, proxy)
|
||||
}
|
||||
state = null
|
||||
stateBinder = null
|
||||
callbackProxy = null
|
||||
registeredAt = Long.MIN_VALUE
|
||||
doorSignal.reset()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
handler.post {
|
||||
detachListener()
|
||||
vsm = null
|
||||
vsmBinder = null
|
||||
thread.quitSafely()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val INT = Int::class.javaPrimitiveType!!
|
||||
const val DESCRIPTOR = "com.saicmotor.carapi.carstate.ICarStateCallback"
|
||||
|
||||
/** How long after registering a door callback still counts as the state replay. */
|
||||
const val REGISTRATION_REPLAY_MS = 2_000L
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** One close per opening event; events during an existing close are consumed, not queued. */
|
||||
final class WindowClosePolicy {
|
||||
/**
|
||||
* WinClose's TRIGGER_COOLDOWN_MS. Getting back in to fetch something reopens the door,
|
||||
* and without this the glass would run a second full pulse each time.
|
||||
*/
|
||||
static final long COOLDOWN_MS = 60_000L;
|
||||
|
||||
private final LongSupplier clock;
|
||||
private boolean closing;
|
||||
private Long lastFinishedAt;
|
||||
|
||||
WindowClosePolicy() {
|
||||
// Elapsed time, not wall clock: a head-unit time correction must not open the gate.
|
||||
this(() -> System.nanoTime() / 1_000_000L);
|
||||
}
|
||||
|
||||
WindowClosePolicy(LongSupplier clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
synchronized boolean begin(Boolean parked, Float speedKmh) {
|
||||
if (closing || !Boolean.TRUE.equals(parked)
|
||||
|| speedKmh == null || !Float.isFinite(speedKmh) || speedKmh != 0f) {
|
||||
return false;
|
||||
}
|
||||
if (lastFinishedAt != null && clock.getAsLong() - lastFinishedAt < COOLDOWN_MS) {
|
||||
return false;
|
||||
}
|
||||
closing = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
synchronized void finish() {
|
||||
closing = false;
|
||||
lastFinishedAt = clock.getAsLong();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** WinClose's Comfort sequence: areas 0..3, UP=1 every 120 ms, then STOP=0. */
|
||||
final class WindowClosePulse {
|
||||
static final long DURATION_MS = 5_000L;
|
||||
static final long INTERVAL_MS = 120L;
|
||||
|
||||
interface Sender { boolean send(int area, int command); }
|
||||
interface Sleeper { void sleep(long millis) throws InterruptedException; }
|
||||
|
||||
static boolean run(BooleanSupplier allowed, Sender sender) {
|
||||
return run(allowed, sender, () -> System.nanoTime() / 1_000_000L, Thread::sleep);
|
||||
}
|
||||
|
||||
static boolean run(BooleanSupplier allowed, Sender sender, LongSupplier clock, Sleeper sleeper) {
|
||||
if (!allowed.getAsBoolean() || Thread.currentThread().isInterrupted()) return false;
|
||||
long deadline = clock.getAsLong() + DURATION_MS;
|
||||
boolean ok = true;
|
||||
try {
|
||||
while (clock.getAsLong() < deadline) {
|
||||
if (Thread.currentThread().isInterrupted() || !allowed.getAsBoolean()) return false;
|
||||
for (int area = 0; area < 4; area++) {
|
||||
if (!sender.send(area, 1)) ok = false;
|
||||
}
|
||||
if (!ok) break;
|
||||
long remaining = deadline - clock.getAsLong();
|
||||
if (remaining > 0) sleeper.sleep(Math.min(INTERVAL_MS, remaining));
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
ok = false;
|
||||
} finally {
|
||||
// Release every motor even after cancellation, a lost P/speed reading, or failure.
|
||||
for (int area = 0; area < 4; area++) {
|
||||
try {
|
||||
if (!sender.send(area, 0)) ok = false;
|
||||
} catch (RuntimeException e) {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
import android.os.Process;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import com.evsuite.hardware.EVHardware;
|
||||
import com.evsuite.hardware.FirmwareInfo;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import kotlin.Unit;
|
||||
|
||||
/** Independent of ABRP credentials, GPS and upload cadence. No alarms or wake locks. */
|
||||
public final class WindowCloseService extends Service {
|
||||
static final String KEY_ENABLED = "window_close_enabled";
|
||||
private static final String ACTION_STOP = "com.evsuite.abrp.WINDOW_CLOSE_STOP";
|
||||
private static final String CHANNEL = "window_close";
|
||||
private static final int NOTIFICATION = 2;
|
||||
private static final ArrayDeque<String> events = new ArrayDeque<>();
|
||||
private static volatile boolean running;
|
||||
private static volatile int status = R.string.window_close_stopped;
|
||||
|
||||
private final WindowClosePolicy policy = new WindowClosePolicy();
|
||||
private volatile boolean active;
|
||||
private SharedPreferences prefs;
|
||||
private ExecutorService worker;
|
||||
private WinCloseHardware monitor;
|
||||
|
||||
static void startIfEnabled(Context context) {
|
||||
if (!context.getSharedPreferences("abrp_prefs", MODE_PRIVATE)
|
||||
.getBoolean(KEY_ENABLED, false)) return;
|
||||
try {
|
||||
context.startForegroundService(new Intent(context, WindowCloseService.class));
|
||||
} catch (RuntimeException e) {
|
||||
status = R.string.window_close_start_failed;
|
||||
log("Service start failed: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
static int statusResource() { return status; }
|
||||
static boolean isRunning() { return running; }
|
||||
|
||||
/**
|
||||
* True when this process can actually reach the vehicle setting service.
|
||||
*
|
||||
* Only the unstable flavor declares android.uid.system, so a stable build can toggle the
|
||||
* feature on and then fail every single command. Checking the uid rather than the flavor
|
||||
* keeps the answer true for any build that is genuinely platform-signed.
|
||||
*/
|
||||
static boolean canWriteToVehicle() { return Process.myUid() == Process.SYSTEM_UID; }
|
||||
|
||||
static synchronized String eventLog() { return String.join("\n", events); }
|
||||
|
||||
private static synchronized void log(String message) {
|
||||
Log.i("WindowClose", message);
|
||||
String time = new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US)
|
||||
.format(new java.util.Date());
|
||||
events.addFirst(time + " " + message);
|
||||
while (events.size() > 30) events.removeLast();
|
||||
}
|
||||
|
||||
@Override public void onCreate() {
|
||||
super.onCreate();
|
||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||
worker = Executors.newSingleThreadExecutor(r -> new Thread(r, "window-close"));
|
||||
NotificationManager notifications = getSystemService(NotificationManager.class);
|
||||
notifications.createNotificationChannel(new NotificationChannel(
|
||||
CHANNEL, getString(R.string.window_close_title), NotificationManager.IMPORTANCE_LOW));
|
||||
Notification notification = notification();
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
startForeground(NOTIFICATION, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
|
||||
} else {
|
||||
startForeground(NOTIFICATION, notification);
|
||||
}
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
prefs.edit().putBoolean(KEY_ENABLED, false).apply();
|
||||
}
|
||||
if (!prefs.getBoolean(KEY_ENABLED, false)) {
|
||||
active = false;
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
// UNKNOWN is left running: at boot the generation is often not resolved yet, and the
|
||||
// monitor re-checks before it connects. A different known generation never will be
|
||||
// SWI69, so idling a foreground notification and a 5 s poll on it buys nothing.
|
||||
FirmwareInfo.Gen generation = FirmwareInfo.INSTANCE.getGeneration();
|
||||
if (generation != FirmwareInfo.Gen.SWI69 && generation != FirmwareInfo.Gen.UNKNOWN) {
|
||||
status = R.string.window_close_unsupported;
|
||||
log("Stopping: window closing is SWI69 only, this is " + generation);
|
||||
active = false;
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (!canWriteToVehicle()) {
|
||||
status = R.string.window_close_no_system_uid;
|
||||
log("Stopping: process uid " + Process.myUid() + " cannot write vehicle settings");
|
||||
active = false;
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (monitor == null) {
|
||||
active = true;
|
||||
status = R.string.window_close_waiting;
|
||||
log("Enabled: SWI69, P + 0 km/h, all four windows, 5 seconds");
|
||||
monitor = new WinCloseHardware(getApplicationContext(), () -> {
|
||||
onDoorOpening();
|
||||
return Unit.INSTANCE;
|
||||
}, message -> {
|
||||
log(message);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
monitor.start();
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
/** Runs on the monitor's callback thread, so it must not block on the car. */
|
||||
private void onDoorOpening() {
|
||||
if (!active || !prefs.getBoolean(KEY_ENABLED, false)) return;
|
||||
if (FirmwareInfo.INSTANCE.getGeneration() != FirmwareInfo.Gen.SWI69) return;
|
||||
if (!monitor.isReady()) {
|
||||
status = R.string.window_close_unavailable;
|
||||
log("Close skipped: window interface unavailable");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
worker.execute(this::closeNow);
|
||||
} catch (RejectedExecutionException e) {
|
||||
log("Close skipped: worker is shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The gear and speed reads are binder round trips, so they happen here rather than on the
|
||||
* callback thread — a slow CPM read there would delay the next door event behind it.
|
||||
*/
|
||||
private void closeNow() {
|
||||
Boolean parked = monitor.isParked();
|
||||
Float speed = EVHardware.INSTANCE.getVehicleSpeedKmh();
|
||||
if (!policy.begin(parked, speed)) {
|
||||
log("WinClose door: skipped (P=" + parked + ", speed=" + speed
|
||||
+ "; or a close ran in the last minute)");
|
||||
return;
|
||||
}
|
||||
// Held across the pulse because this fires as the driver leaves, which is exactly
|
||||
// when the head unit suspends. Timed out well past the pulse so a stuck close can
|
||||
// never pin the unit awake, and released in the finally regardless.
|
||||
PowerManager power = getSystemService(PowerManager.class);
|
||||
PowerManager.WakeLock wakeLock =
|
||||
power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EVABRP:windowClose");
|
||||
wakeLock.acquire(WindowClosePulse.DURATION_MS + 10_000L);
|
||||
status = R.string.window_close_closing;
|
||||
log("WinClose: closing all four windows");
|
||||
try {
|
||||
boolean ok = monitor.closeAllWindowsPulsed(() ->
|
||||
active && prefs.getBoolean(KEY_ENABLED, false));
|
||||
status = ok ? R.string.window_close_sent : R.string.window_close_interrupted;
|
||||
log(ok ? "Close pulse finished; STOP sent. Position is unknown."
|
||||
: "Close interrupted/refused or command failed; check P and speed.");
|
||||
} catch (RuntimeException e) {
|
||||
status = R.string.window_close_interrupted;
|
||||
log("Close failed: " + e.getClass().getSimpleName());
|
||||
} finally {
|
||||
if (wakeLock.isHeld()) wakeLock.release();
|
||||
policy.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private Notification notification() {
|
||||
PendingIntent open = PendingIntent.getActivity(this, 20,
|
||||
new Intent(this, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
PendingIntent stop = PendingIntent.getService(this, 21,
|
||||
new Intent(this, WindowCloseService.class).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
return new NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||
.setContentTitle(getString(R.string.window_close_title))
|
||||
.setContentText(getString(R.string.window_close_notification))
|
||||
.setContentIntent(open)
|
||||
.addAction(0, getString(R.string.window_close_disable), stop)
|
||||
.setOngoing(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override public void onDestroy() {
|
||||
active = false;
|
||||
if (monitor != null) monitor.close();
|
||||
// The pulse runner releases all windows in finally, including an interrupted sleep.
|
||||
// Waited on so those STOP commands actually go out before the process moves on.
|
||||
if (worker != null) {
|
||||
worker.shutdownNow();
|
||||
try {
|
||||
if (!worker.awaitTermination(2, TimeUnit.SECONDS)) log("Worker did not stop in 2 s");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
running = false;
|
||||
status = R.string.window_close_stopped;
|
||||
log("Service stopped");
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override public IBinder onBind(Intent intent) { return null; }
|
||||
}
|
||||
Reference in New Issue
Block a user