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(); + } +}