From 444732438d3bc0a956d1902b3ef62ca92055dbf0 Mon Sep 17 00:00:00 2001 From: TCFDONMEZ Date: Fri, 25 Sep 2026 14:00:10 +0300 Subject: [PATCH] feat: close all windows when parked and a door opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports WinClose's SWI69 window control as an opt-in foreground service that is independent of ABRP: no token, no GPS, no upload cadence. The driver opening a door while the car is in P at a readable 0 km/h pulses UP at all four areas for five seconds, then STOP. Only the parts that serve that one behaviour are taken. WinClose's speed and time arming, configurable delay, warning beep, per-window modes and position reads are all absent — the target car is a Comfort trim whose motors carry no position sensor, so a close can be neither verified nor cancelled. The pulse, the door edge detector and the eligibility policy are pure classes with unit tests. STOP is attempted from a finally block on every exit, including an interrupted sleep, a refused write and a sender that throws, because a pulse that stops arriving leaves the motors wherever they were. Guards worth naming, several of them learned from reading WinClose rather than from the port itself: - A wake lock spans the pulse. This fires as the driver leaves, which is exactly when the head unit suspends; without it the glass can stop halfway with no STOP sent. - A fresh listener registration replays the door's current state. That replay seeds the edge detector instead of triggering it, or a binder reconnect while the door stands open would close the windows on someone halfway out. - WinClose's 60-second cooldown, so reaching back into the car does not run a second pulse. - The service stops itself on a non-SWI69 generation, and the switch is disabled unless the process holds system uid — only the unstable flavor declares android.uid.system, so a stable build could otherwise offer a toggle whose every command would fail. This is the project's first write to the vehicle. README, SECURITY, AGENTS, CONTRIBUTING and DISCLAIMER all claimed the app never writes; that claim is retracted and scoped rather than quietly left standing. Untested on a car. The feature is off by default. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 11 +- CONTRIBUTING.md | 5 +- DISCLAIMER.md | 3 + README.md | 38 ++- SECURITY.md | 8 +- app/src/main/AndroidManifest.xml | 16 ++ .../java/com/evsuite/abrp/BootReceiver.java | 3 + .../java/com/evsuite/abrp/MainActivity.java | 49 ++++ .../com/evsuite/abrp/WinCloseDoorSignal.java | 26 ++ .../java/com/evsuite/abrp/WinCloseHardware.kt | 221 +++++++++++++++++ .../com/evsuite/abrp/WindowClosePolicy.java | 42 ++++ .../com/evsuite/abrp/WindowClosePulse.java | 47 ++++ .../com/evsuite/abrp/WindowCloseService.java | 227 ++++++++++++++++++ app/src/main/res/layout/pane_service.xml | 48 ++++ app/src/main/res/values/strings.xml | 13 + .../evsuite/abrp/WinCloseDoorSignalTest.java | 52 ++++ .../evsuite/abrp/WindowClosePolicyTest.java | 53 ++++ .../evsuite/abrp/WindowClosePulseTest.java | 84 +++++++ 18 files changed, 937 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/WinCloseDoorSignal.java create mode 100644 app/src/main/java/com/evsuite/abrp/WinCloseHardware.kt create mode 100644 app/src/main/java/com/evsuite/abrp/WindowClosePolicy.java create mode 100644 app/src/main/java/com/evsuite/abrp/WindowClosePulse.java create mode 100644 app/src/main/java/com/evsuite/abrp/WindowCloseService.java create mode 100644 app/src/test/java/com/evsuite/abrp/WinCloseDoorSignalTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/WindowClosePolicyTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/WindowClosePulseTest.java diff --git a/AGENTS.md b/AGENTS.md index 9d26677..642d400 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,11 @@ Fork of Leon Kernan's `ABRP_Uploader`. See `LICENSE.md` — the licence status i ## Non-negotiables -1. **Read-only towards the car.** This app must never write a vehicle property. The - sibling EVProfile project writes settings and gates every write on 0 km/h; this one has - no business writing at all. A write path is a bug. +1. **Telemetry is read-only.** The explicitly requested SWI69 WinClose integration is + the only vehicle-write exception: an opt-in service closes the four windows on the + WinClose door callback, requiring P and readable 0 km/h before and during the pulse. + It uses WinClose's CarState/VSM interfaces and the shared VehicleWriteGate; no other + vehicle settings may be written. Always send STOP when an active pulse exits. 2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null` means "could not read". Null fields are omitted from the payload. Sending 0 for SOC tells ABRP the battery is empty and wrecks a live route plan. @@ -41,6 +43,9 @@ UploadLog last 20 attempts + derived service state — pure, unit-test SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext AbrpApi HTTP; credentials in the POST body, never the query string MainActivity configuration, service state, upload log +WindowCloseService independent, opt-in foreground service; no ABRP/GPS dependency +WinCloseHardware SWI69 adapter derived from the sibling winclose/WindowHardware.kt +WindowClosePulse bounded UP/STOP sequence; pure, unit-tested ``` **Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05709da..89949b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,9 @@ This app runs on a car. That single fact drives everything below. ## Ground rules -1. **The app never writes to the vehicle.** It is read-only towards the car. A patch that - introduces a write will be rejected regardless of how useful the feature is. +1. **Telemetry never writes to the vehicle.** The opt-in SWI69 WinClose integration is + the only write exception: four-window closing, gated on P and readable 0 km/h, + with a bounded pulse and STOP on completion, interruption or failure. 2. **A property that cannot be read is omitted, never defaulted.** Sending 0 for an unreadable battery level tells ABRP the car is empty and corrupts the user's route plan. Nullable in, absent from the payload. diff --git a/DISCLAIMER.md b/DISCLAIMER.md index e7b5a21..808e557 100644 --- a/DISCLAIMER.md +++ b/DISCLAIMER.md @@ -24,6 +24,9 @@ otherwise, arising from, out of or in connection with the software or its use. data once sent. - Reading vehicle properties uses **undocumented runtime interfaces**. They can change or disappear with any firmware update. +- Optional WinClose automation sends window-closing commands. Sensorless Comfort windows + cannot confirm completion; command timing and motor/anti-pinch behavior have not been + verified on this vehicle. Check physical operation while parked before relying on it. ## Not affiliated diff --git a/README.md b/README.md index 847deae..b63c1b8 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,9 @@ same rule in time as well as in value: a GPS fix older than `max(5 min, 3 x your interval)` is dropped rather than resent, so a stale fix cannot report the car back at the place it set off from. -**The app never writes to the car.** It only reads. Any write path would be a bug; see -[`SECURITY.md`](SECURITY.md). +Telemetry only reads the car. An optional SWI69 WinClose integration can close the four +windows while parked; this is the only vehicle-write feature. See configuration below +and [`SECURITY.md`](SECURITY.md). ## Install The MG4 head unit hides Settings and APK install. The known route in: @@ -134,6 +135,39 @@ Typing a long API key and token on the car's on-screen keyboard is painful. Inst put them in a text file and tap **Import file** — see [Config file](#config-file) below. ## Configuration + +On the **Upload Service** page, **Kapı açılınca camları kapat** enables the WinClose +integration independently of ABRP. It is off by default and restarts with the head unit +once enabled; no ABRP token, GPS permission, speed threshold or driving timer is needed. + +The integration is adapted from the sibling WinClose `WindowHardware.kt`: SWI69 +`CarStateClient` door callback (transaction 6, value 0) triggers the VSM's +`setVehicleWindowStatus` on all four areas. WinClose describes this as the driver-door +signal; passenger-door coverage has not been established. No alternative door-property +source is used. The car must be in P with readable speed exactly 0 km/h. Comfort windows +receive UP (1) every 120 ms for five seconds, then STOP (0). P and speed are checked +during the pulse too. Cancellation, a failed command, or leaving P/stationary state ends +the pulse and attempts STOP on all windows. Duplicate door events and openings during +an active pulse do not extend it, and WinClose's 60-second cooldown means reaching back +into the car does not run a second pulse. A partial wake lock is held across the pulse, +because the close fires exactly as the head unit is heading for suspend. + +The feature is SWI69-only and needs the platform signature: the service stops itself on +any other known generation, and the switch is disabled when the process does not hold +system uid (only the unstable flavor declares `android.uid.system`). + +**No cancellation is possible on a Comfort trim.** Its window motors carry no position +sensor, so nothing can observe you working the switch, and there is no way to abort a +pulse once it starts. Verify the motors' pinch behaviour on a parked car before relying +on this — the windows travel while the driver's door is open. + +No position sensor is assumed: a completed command is not confirmation that the windows +closed. The existing WinClose pulse duration is the starting value, not a manufacturer +guarantee. Vehicle testing of timing, permissions and motor behavior is still required. +The APK keeps its existing application IDs and signing configuration (the unstable flavor +already uses `android.uid.system`). WinClose's source license is unspecified; see its +README before redistribution. + | Setting | Default | Notes | |---|---|---| | Upload frequency | 60 s | 15 / 30 / 60 / 120 / 300 s. Applies while driving | diff --git a/SECURITY.md b/SECURITY.md index e9e788c..9b4478a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,8 +15,9 @@ was moving. A proof of concept helps; a working exploit is not required. - Anything that lets another app on the head unit read the stored ABRP credentials. - Anything that gets attacker-controlled code installed through the unstable updater. -- Anything that makes the app write to the vehicle. **It should never write at all** — this - app is read-only towards the car — so any write path is a bug by definition. +- Any vehicle write outside the explicitly enabled SWI69 WinClose feature, or a window + close that bypasses P/readable-zero-speed checks, exceeds its five-second pulse, or + fails to attempt STOP on every window after interruption or failure. - Telemetry that leaks credentials into logs, URLs or crash reports. ## What is not in scope @@ -35,3 +36,6 @@ was moving. A proof of concept helps; a working exploit is not required. checks fail closed. The stable channel does not contain the updater at all. - The VHAL probe helpers, which enumerate the whole vehicle abstraction layer, exist only in debug builds. +- Window closing uses WinClose's SWI69 CarState callback and VSM commands. Its service is + not exported, has its own opt-in switch, and holds no wake lock or alarm. It does not + assert that a sensorless window has physically closed or that anti-pinch is available. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4155835..3d2bce4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,12 @@ + + + + @@ -38,6 +44,7 @@ outside temperature. From API 30 an explicit bind to a package this app does not declare here simply fails, and ext_temp would go back to being unreportable. --> + @@ -65,6 +72,15 @@ android:exported="false" android:foregroundServiceType="location" /> + + + + + Kapı açılınca camları kapat + P konumunda ve araç dururken WinClose kapı sinyaliyle dört cama 5 saniye kapatma komutu gönderilir. ABRP yüklemesinden bağımsız çalışır. + Cam kapatma kapalı + WinClose kapı sinyali bekleniyor + Dört cama kapatma komutu gönderiliyor… + 5 saniyelik komut tamamlandı. Cam konumu sensörsüz olduğu için doğrulanamıyor. + Kapatma durdu veya komut gönderilemedi + Cam bağlantısı henüz hazır değil + Cam servisi başlatılamadı + Bu cam entegrasyonu yalnızca SWI69 için etkin + Bu sürüm araca yazamıyor (sistem imzası gerekli) + P ve 0 km/sa: kapı açılınca dört camı kapat + Kapat Fatih\'sMG4 diff --git a/app/src/test/java/com/evsuite/abrp/WinCloseDoorSignalTest.java b/app/src/test/java/com/evsuite/abrp/WinCloseDoorSignalTest.java new file mode 100644 index 0000000..b5850dc --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/WinCloseDoorSignalTest.java @@ -0,0 +1,52 @@ +package com.evsuite.abrp; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class WinCloseDoorSignalTest { + @Test public void firstOpenEventWorksWithoutDrivingOrArming() { + assertTrue(new WinCloseDoorSignal().opening(0)); + } + + @Test public void repeatedOpenIsIgnoredButAReopeningTriggers() { + WinCloseDoorSignal signal = new WinCloseDoorSignal(); + assertFalse(signal.opening(2)); + assertTrue(signal.opening(0)); + assertFalse(signal.opening(0)); + assertFalse(signal.opening(1)); + assertFalse(signal.opening(2)); + assertTrue(signal.opening(0)); + } + + @Test public void aSeededOpenDoorIsNotTreatedAsSomeoneOpeningIt() { + WinCloseDoorSignal signal = new WinCloseDoorSignal(); + // What a fresh listener registration replays: the door is already open. + signal.seed(0); + assertFalse(signal.opening(0)); + // Closing it and opening it again is a real edge. + assertFalse(signal.opening(2)); + assertTrue(signal.opening(0)); + } + + @Test public void seedingAClosedDoorStillArmsTheNextOpening() { + WinCloseDoorSignal signal = new WinCloseDoorSignal(); + signal.seed(2); + assertTrue(signal.opening(0)); + } + + @Test public void seedIgnoresAnInvalidReadingRatherThanRecordingIt() { + WinCloseDoorSignal signal = new WinCloseDoorSignal(); + signal.seed(0); + signal.seed(-1); + assertFalse(signal.opening(0)); + } + + @Test public void invalidReadingDoesNotInventAnotherOpening() { + WinCloseDoorSignal signal = new WinCloseDoorSignal(); + assertTrue(signal.opening(0)); + assertFalse(signal.opening(-1)); + assertFalse(signal.opening(0)); + signal.reset(); + assertTrue(signal.opening(0)); + } +} diff --git a/app/src/test/java/com/evsuite/abrp/WindowClosePolicyTest.java b/app/src/test/java/com/evsuite/abrp/WindowClosePolicyTest.java new file mode 100644 index 0000000..95e0c48 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/WindowClosePolicyTest.java @@ -0,0 +1,53 @@ +package com.evsuite.abrp; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class WindowClosePolicyTest { + @Test public void onlyKnownParkAndStandstillCanStart() { + WindowClosePolicy policy = new WindowClosePolicy(); + assertFalse(policy.begin(null, 0f)); + assertFalse(policy.begin(false, 0f)); + assertFalse(policy.begin(true, null)); + assertFalse(policy.begin(true, 0.1f)); + assertFalse(policy.begin(true, -1f)); + assertFalse(policy.begin(true, Float.NaN)); + assertFalse(policy.begin(true, Float.POSITIVE_INFINITY)); + assertTrue(policy.begin(true, 0f)); + } + + @Test public void openingsDuringACloseCannotExtendOrQueueIt() { + long[] now = {0L}; + WindowClosePolicy policy = new WindowClosePolicy(() -> now[0]); + assertTrue(policy.begin(true, 0f)); + assertFalse(policy.begin(true, 0f)); + assertFalse(policy.begin(true, 0f)); + policy.finish(); + // A later, distinct opening needs no previous drive or arming timer. + now[0] += WindowClosePolicy.COOLDOWN_MS; + assertTrue(policy.begin(true, 0f)); + } + + @Test public void reopeningTheDoorWithinTheCooldownDoesNotCloseAgain() { + long[] now = {0L}; + WindowClosePolicy policy = new WindowClosePolicy(() -> now[0]); + assertTrue(policy.begin(true, 0f)); + now[0] += 5_000L; + policy.finish(); + // Reaching back into the car reopens the door; the glass must not run a second pulse. + now[0] += WindowClosePolicy.COOLDOWN_MS - 1; + assertFalse(policy.begin(true, 0f)); + now[0] += 1; + assertTrue(policy.begin(true, 0f)); + } + + @Test public void theCooldownIsMeasuredFromTheEndOfTheCloseNotItsStart() { + long[] now = {0L}; + WindowClosePolicy policy = new WindowClosePolicy(() -> now[0]); + assertTrue(policy.begin(true, 0f)); + now[0] += WindowClosePulse.DURATION_MS; + policy.finish(); + now[0] += WindowClosePolicy.COOLDOWN_MS - WindowClosePulse.DURATION_MS; + assertFalse(policy.begin(true, 0f)); + } +} diff --git a/app/src/test/java/com/evsuite/abrp/WindowClosePulseTest.java b/app/src/test/java/com/evsuite/abrp/WindowClosePulseTest.java new file mode 100644 index 0000000..805df03 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/WindowClosePulseTest.java @@ -0,0 +1,84 @@ +package com.evsuite.abrp; + +import org.junit.Test; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import static org.junit.Assert.*; + +public class WindowClosePulseTest { + private final List writes = new ArrayList<>(); + private long now; + + private boolean send(int area, int command) { + writes.add(area + ":" + command); + return true; + } + + private void assertStoppedAll() { + assertEquals(Arrays.asList("0:0", "1:0", "2:0", "3:0"), + writes.subList(writes.size() - 4, writes.size())); + } + + @Test public void comfortPulseTargetsAllFourAndEndsAtFiveSeconds() { + assertTrue(WindowClosePulse.run(() -> true, this::send, () -> now, ms -> now += ms)); + assertEquals(5_000L, now); + assertEquals(Arrays.asList("0:1", "1:1", "2:1", "3:1"), writes.subList(0, 4)); + // Forty-two UP batches at 120 ms intervals, followed by a single STOP batch. + assertEquals(42 * 4 + 4, writes.size()); + assertStoppedAll(); + } + + @Test public void refusedStartWritesNothing() { + assertFalse(WindowClosePulse.run(() -> false, this::send, () -> now, ms -> now += ms)); + assertTrue(writes.isEmpty()); + } + + @Test public void leavingParkMovingOrDisablingStopsAtNextPulse() { + assertFalse(WindowClosePulse.run(() -> now < 120L, this::send, () -> now, ms -> now += ms)); + assertEquals(8, writes.size()); + assertStoppedAll(); + } + + @Test public void interruptionReleasesEveryWindowAndPreservesInterrupt() { + try { + assertFalse(WindowClosePulse.run(() -> true, this::send, () -> now, + ms -> { throw new InterruptedException(); })); + assertTrue(Thread.currentThread().isInterrupted()); + assertStoppedAll(); + } finally { + Thread.interrupted(); + } + } + + @Test public void oneFailedCommandEndsPulseAndReleasesEveryWindow() { + assertFalse(WindowClosePulse.run(() -> true, (area, command) -> { + send(area, command); + return area != 2 || command == 0; + }, () -> now, ms -> now += ms)); + assertEquals(0L, now); + assertStoppedAll(); + } + + @Test public void unexpectedFailureStillReleasesEveryWindow() { + try { + WindowClosePulse.run(() -> true, (area, command) -> { + send(area, command); + if (area == 1 && command == 1) throw new IllegalStateException(); + return true; + }, () -> now, ms -> now += ms); + fail("Expected sender failure"); + } catch (IllegalStateException expected) { + assertStoppedAll(); + } + } + + @Test public void aFailedStopDoesNotPreventOtherStops() { + assertFalse(WindowClosePulse.run(() -> true, (area, command) -> { + send(area, command); + if (area == 0 && command == 0) throw new IllegalStateException(); + return true; + }, () -> now, ms -> now += ms)); + assertStoppedAll(); + } +}