diff --git a/SPECS.md b/SPECS.md index 3e12022..c4c7e6d 100644 --- a/SPECS.md +++ b/SPECS.md @@ -114,6 +114,12 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-17 — App 2.7.0: a double press of the steering wheel's voice key (code 287) opens the + app, from the head unit's `com.saic.keyevent.hardkey.report` broadcast. Its extras are not + documented; the usual names are tried and every report is listed raw on the Logs tab. The + floating button (2.6.0) did not show over the MG4 launcher. Pages no longer jump back to the + top on every refresh (`StableScrollView`), and the Vehicle tab shows all six value tiles, + including both range estimates, in one row at the top. - 2026-10-17 — App 2.6.0: floating button to open the app, because the MG4 launcher does not list it. Drawn over other apps (needs the overlay permission, which the car build has through the system UID), draggable, hidden while the app is open, switch in the Upload tab. Tested on an diff --git a/app/build.gradle b/app/build.gradle index 0b35644..d2e59ae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 63 - versionName "2.6.0" + versionCode 64 + versionName "2.7.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. diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index 655ea9d..8431b91 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -68,6 +68,7 @@ public class AbrpUploadService extends Service { private ChargeSessionTracker chargeSessionTracker; private TelemetryUploader telemetryUploader; private FloatingButton floatingButton; + private SteeringKeyListener steeringKeys; private WifiAutoConnect wifi; private ScheduledExecutorService scheduler; private SharedPreferences prefs; @@ -102,6 +103,8 @@ public class AbrpUploadService extends Service { // onCreate runs on the main thread, where the overlay window must be added. floatingButton = new FloatingButton(this, prefs); FloatingButton.attach(floatingButton); + steeringKeys = new SteeringKeyListener(this, prefs); + steeringKeys.register(); scheduler.scheduleWithFixedDelay( this::safeUploadCycle, @@ -144,6 +147,7 @@ public class AbrpUploadService extends Service { if (scheduler != null) scheduler.shutdownNow(); if (telemetryUploader != null) telemetryUploader.stop(); if (floatingButton != null) FloatingButton.detach(floatingButton); + if (steeringKeys != null) steeringKeys.unregister(); Log.i(TAG, "Service stopped"); } diff --git a/app/src/main/java/com/evsuite/abrp/DoublePressDetector.java b/app/src/main/java/com/evsuite/abrp/DoublePressDetector.java new file mode 100644 index 0000000..1f3863f --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/DoublePressDetector.java @@ -0,0 +1,46 @@ +package com.evsuite.abrp; + +/** + * Spots a double press of one steering-wheel key from the head unit's key broadcasts. + * + * The broadcast format is not documented, so this copes with all the shapes it could take: + * - no up/down information at all: every broadcast is one press; + * - separate down and up broadcasts: only releases count. If the meaning of the action field + * is the other way round, downs count instead — still exactly one per press, so a double + * press is found either way; + * - a long press resets, so holding the key never opens anything. + * + * Android-free and unit-tested; the window is longer than the stock 300 ms, because a + * deliberate double press on a steering wheel is slower than one on a touch screen. + */ +final class DoublePressDetector { + + static final long WINDOW_MS = 500; + + private final int keyCode; + private long lastPressAt = Long.MIN_VALUE; + + DoublePressDetector(int keyCode) { + this.keyCode = keyCode; + } + + /** + * @param down true for a key-down, false for a release, null when the broadcast does not say + * @param longPress true when the broadcast marks a long press + * @return true exactly once per double press + */ + synchronized boolean accept(int code, Boolean down, boolean longPress, long atMillis) { + if (code != keyCode) return false; + if (longPress) { + lastPressAt = Long.MIN_VALUE; + return false; + } + if (Boolean.TRUE.equals(down)) return false; + if (lastPressAt != Long.MIN_VALUE && atMillis - lastPressAt <= WINDOW_MS) { + lastPressAt = Long.MIN_VALUE; // a third press starts over + return true; + } + lastPressAt = atMillis; + return false; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index ae60556..2b21fb7 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -120,7 +120,8 @@ public class MainActivity extends AppCompatActivity { private TextView connectionStatusText; /** Upload page: live telemetry switch and cadence. */ - private SwitchMaterial liveTelemetrySwitch, boostSwitch, floatingButtonSwitch; + private SwitchMaterial liveTelemetrySwitch, boostSwitch, floatingButtonSwitch, voiceKeySwitch; + private TextView steeringKeysText; private View overlayPermissionNote; private android.widget.Spinner intervalSpinner; private TextInputLayout lowSocLayout; @@ -210,6 +211,7 @@ public class MainActivity extends AppCompatActivity { @Override public void run() { refreshStatus(); refreshCallLog(); + refreshSteeringKeys(); refreshVehicle(); refreshConsumption(); refreshWifi(); @@ -254,6 +256,7 @@ public class MainActivity extends AppCompatActivity { statusText = servicePane.findViewById(R.id.status_text); callLogText = logPane.findViewById(R.id.call_log_text); + steeringKeysText = logPane.findViewById(R.id.steering_keys_text); logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush()); setUpPager(); @@ -795,6 +798,10 @@ public class MainActivity extends AppCompatActivity { // ---------- Upload page ---------- private void bindServicePane() { + voiceKeySwitch = servicePane.findViewById(R.id.voice_key_switch); + voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs)); + voiceKeySwitch.setOnCheckedChangeListener((btn, checked) -> + prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply()); floatingButtonSwitch = servicePane.findViewById(R.id.floating_button_switch); overlayPermissionNote = servicePane.findViewById(R.id.overlay_permission_note); floatingButtonSwitch.setChecked(UploadSettings.floatingButtonEnabled(prefs)); @@ -1340,6 +1347,15 @@ public class MainActivity extends AppCompatActivity { }, "cloud-refresh").start(); } + /** What the car's steering-wheel key broadcasts looked like, newest first. */ + private void refreshSteeringKeys() { + if (steeringKeysText == null) return; + java.util.List keys = SteeringKeyListener.recent(); + String text = keys.isEmpty() ? getString(R.string.steering_keys_empty) + : android.text.TextUtils.join("\n", keys); + if (!text.contentEquals(steeringKeysText.getText())) steeringKeysText.setText(text); + } + private void refreshCallLog() { java.util.List entries = AbrpUploadService.log().recent(); if (entries.isEmpty()) { diff --git a/app/src/main/java/com/evsuite/abrp/StableScrollView.java b/app/src/main/java/com/evsuite/abrp/StableScrollView.java new file mode 100644 index 0000000..d760775 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/StableScrollView.java @@ -0,0 +1,49 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ScrollView; + +/** + * A ScrollView that keeps the position the driver scrolled to. + * + * The pages live in ViewPager2's RecyclerView. When the live values change, the tiles ask for + * a new layout, and the RecyclerView lays the pages out once already while it is still + * measuring, with a provisional height at which the content seems to fit. ScrollView.onLayout + * then clamps the scroll position to 0, and the real layout that follows keeps it there: every + * refresh threw the page back to the top (seen in a stack trace on the emulator). + * + * Here only scrolling that is not part of a layout counts as the driver's position, and every + * layout restores it. ScrollView.scrollTo clamps against the real content height, so a + * position that no longer exists still ends at the bottom rather than past it. + */ +public class StableScrollView extends ScrollView { + + private int wantedScrollY; + private boolean inLayout; + + public StableScrollView(Context context) { super(context); } + + public StableScrollView(Context context, AttributeSet attrs) { super(context, attrs); } + + public StableScrollView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + if (!inLayout) wantedScrollY = t; + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + inLayout = true; + try { + super.onLayout(changed, l, t, r, b); + if (getScrollY() != wantedScrollY) scrollTo(getScrollX(), wantedScrollY); + } finally { + inLayout = false; + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/SteeringKeyListener.java b/app/src/main/java/com/evsuite/abrp/SteeringKeyListener.java new file mode 100644 index 0000000..45431e5 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/SteeringKeyListener.java @@ -0,0 +1,191 @@ +package com.evsuite.abrp; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.view.KeyEvent; + +import java.text.SimpleDateFormat; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * Opens the app on a double press of the steering wheel's voice key. + * + * The head unit reports steering-wheel keys as the broadcast {@link #ACTION}; code 287 is the + * voice key (see EVHardware's PhysicalButtonEventDecoder). How the broadcast carries the code + * and the press is not documented, so the usual extras are tried, and every broadcast is kept + * raw for the Logs tab — if the guess misses, that list says exactly what the car sends. + * + * Registered by the always-on service on the main thread; never throws into the head unit. + */ +final class SteeringKeyListener { + + static final String ACTION = "com.saic.keyevent.hardkey.report"; + static final int VOICE_KEY = 287; + private static final String TAG = "NilsMG4.SteeringKey"; + private static final int MAX_LOG = 20; + + private static final ArrayDeque RECENT = new ArrayDeque<>(); + + private final Context context; + private final SharedPreferences prefs; + private final DoublePressDetector detector = new DoublePressDetector(VOICE_KEY); + private boolean registered; + + private final BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context c, Intent intent) { + try { + handle(intent); + } catch (RuntimeException e) { + Log.w(TAG, "Key broadcast not handled: " + e); + } + } + }; + + SteeringKeyListener(Context context, SharedPreferences prefs) { + this.context = context.getApplicationContext(); + this.prefs = prefs; + } + + void register() { + try { + // Exported on purpose: the report comes from the head unit's own key service, another + // app. The flag is required from API 33 and absent on this API-28 head unit; + // ContextCompat picks the right call for the platform underneath. + androidx.core.content.ContextCompat.registerReceiver(context, receiver, new IntentFilter(ACTION), + androidx.core.content.ContextCompat.RECEIVER_EXPORTED); + registered = true; + } catch (RuntimeException e) { + Log.w(TAG, "Key broadcasts not available: " + e); + } + } + + void unregister() { + if (!registered) return; + try { + context.unregisterReceiver(receiver); + } catch (RuntimeException ignored) { + // already gone + } + registered = false; + } + + /** The last broadcasts, newest first, for the Logs tab. */ + static synchronized List recent() { + List out = new ArrayList<>(RECENT); + java.util.Collections.reverse(out); + return out; + } + + private static synchronized void remember(String line) { + if (RECENT.size() >= MAX_LOG) RECENT.removeFirst(); + RECENT.addLast(line); + } + + private void handle(Intent intent) { + Bundle extras = intent.getExtras(); + Parsed key = parse(extras); + boolean opens = false; + if (key.code != null && UploadSettings.voiceKeyOpensApp(prefs)) { + opens = detector.accept(key.code, key.down, key.longPress, android.os.SystemClock.elapsedRealtime()); + } + remember(new SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(new Date()) + + " " + describe(extras) + + "\n -> code " + (key.code == null ? "?" : key.code) + + ", down " + (key.down == null ? "?" : key.down) + + ", long " + key.longPress + (opens ? " OPENS APP" : "")); + if (opens) { + 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); + } + } + } + + // ---------- Reading an undocumented broadcast ---------- + + static final class Parsed { + Integer code; + Boolean down; + boolean longPress; + } + + private static final String[] CODE_KEYS = {"keyCode", "keycode", "key_code", "KEY_CODE", "keyValue", + "key_value", "code", "key"}; + private static final String[] ACTION_KEYS = {"keyAction", "key_action", "KEY_ACTION", "action", + "keyState", "key_state", "state", "event", "type"}; + private static final String[] DOWN_KEYS = {"down", "isDown", "pressed", "isPressed"}; + private static final String[] LONG_KEYS = {"longPress", "isLongPress", "long_press", "isLong", "long"}; + + static Parsed parse(Bundle extras) { + Parsed p = new Parsed(); + if (extras == null) return p; + for (String k : extras.keySet()) { + Object v = extras.get(k); + if (v instanceof KeyEvent) { + KeyEvent e = (KeyEvent) v; + p.code = e.getKeyCode(); + p.down = e.getAction() == KeyEvent.ACTION_DOWN; + p.longPress = e.isLongPress(); + return p; + } + } + p.code = firstInt(extras, CODE_KEYS); + Integer action = firstInt(extras, ACTION_KEYS); + // KeyEvent's own convention: 0 down, 1 up; anything else (often 2) is a long press. + if (action != null) { + if (action == 0) p.down = true; + else if (action == 1) p.down = false; + else p.longPress = true; + } + Boolean down = firstBoolean(extras, DOWN_KEYS); + if (down != null) p.down = down; + Boolean longPress = firstBoolean(extras, LONG_KEYS); + if (longPress != null) p.longPress = longPress; + return p; + } + + private static Integer firstInt(Bundle b, String[] keys) { + for (String k : keys) { + Object v = b.get(k); + if (v instanceof Integer) return (Integer) v; + if (v instanceof Long) return (int) (long) (Long) v; + if (v instanceof String) { + try { return Integer.parseInt(((String) v).trim()); } catch (NumberFormatException ignored) { } + } + } + return null; + } + + private static Boolean firstBoolean(Bundle b, String[] keys) { + for (String k : keys) { + Object v = b.get(k); + if (v instanceof Boolean) return (Boolean) v; + } + return null; + } + + /** All extras as name=value(type), so the Logs tab shows the real format. */ + static String describe(Bundle extras) { + if (extras == null || extras.isEmpty()) return "(no extras)"; + StringBuilder sb = new StringBuilder(); + for (String k : extras.keySet()) { + Object v = extras.get(k); + if (sb.length() > 0) sb.append(", "); + sb.append(k).append('=').append(v).append(" (") + .append(v == null ? "null" : v.getClass().getSimpleName()).append(')'); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/UploadSettings.java b/app/src/main/java/com/evsuite/abrp/UploadSettings.java index 82c1975..aa00d32 100644 --- a/app/src/main/java/com/evsuite/abrp/UploadSettings.java +++ b/app/src/main/java/com/evsuite/abrp/UploadSettings.java @@ -20,6 +20,13 @@ final class UploadSettings { static final String KEY_FLOATING_BUTTON = "floating_button"; static final boolean DEFAULT_FLOATING_BUTTON = true; + /** A double press of the steering wheel's voice key opens the app. */ + static final String KEY_VOICE_KEY_OPENS = "voice_key_opens_app"; + + static boolean voiceKeyOpensApp(SharedPreferences prefs) { + return prefs.getBoolean(KEY_VOICE_KEY_OPENS, true); + } + static boolean floatingButtonEnabled(SharedPreferences prefs) { return prefs.getBoolean(KEY_FLOATING_BUTTON, DEFAULT_FLOATING_BUTTON); } diff --git a/app/src/main/res/layout-w1000dp/pane_service.xml b/app/src/main/res/layout-w1000dp/pane_service.xml index 422c0fb..9506515 100644 --- a/app/src/main/res/layout-w1000dp/pane_service.xml +++ b/app/src/main/res/layout-w1000dp/pane_service.xml @@ -1,11 +1,13 @@ - + + + @@ -24,4 +26,4 @@ - + diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml index ff3cb6b..16ee22d 100644 --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml @@ -1,6 +1,36 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -37,38 +67,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/app/src/main/res/layout-w1000dp/pane_wifi.xml b/app/src/main/res/layout-w1000dp/pane_wifi.xml index a6485be..c0461ea 100644 --- a/app/src/main/res/layout-w1000dp/pane_wifi.xml +++ b/app/src/main/res/layout-w1000dp/pane_wifi.xml @@ -1,5 +1,5 @@ - + @@ -14,4 +14,4 @@ - + diff --git a/app/src/main/res/layout/pane_charging.xml b/app/src/main/res/layout/pane_charging.xml index 2a3f810..e701971 100644 --- a/app/src/main/res/layout/pane_charging.xml +++ b/app/src/main/res/layout/pane_charging.xml @@ -1,5 +1,5 @@ - + @@ -28,4 +28,4 @@ - + diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml index 1222f28..93891bb 100644 --- a/app/src/main/res/layout/pane_consumption.xml +++ b/app/src/main/res/layout/pane_consumption.xml @@ -1,5 +1,5 @@ - + @@ -45,4 +45,4 @@ - + diff --git a/app/src/main/res/layout/pane_log.xml b/app/src/main/res/layout/pane_log.xml index 31ea5d8..17f220e 100644 --- a/app/src/main/res/layout/pane_log.xml +++ b/app/src/main/res/layout/pane_log.xml @@ -1,8 +1,11 @@ - + + + + - + diff --git a/app/src/main/res/layout/pane_server.xml b/app/src/main/res/layout/pane_server.xml index 7eb14d7..850ed83 100644 --- a/app/src/main/res/layout/pane_server.xml +++ b/app/src/main/res/layout/pane_server.xml @@ -1,5 +1,5 @@ - + @@ -29,4 +29,4 @@ - + diff --git a/app/src/main/res/layout/pane_service.xml b/app/src/main/res/layout/pane_service.xml index 8692bbd..2a70e57 100644 --- a/app/src/main/res/layout/pane_service.xml +++ b/app/src/main/res/layout/pane_service.xml @@ -1,11 +1,13 @@ - + + + @@ -25,4 +27,4 @@ - + diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml index 6ce8956..f2cbfd7 100644 --- a/app/src/main/res/layout/pane_vehicle.xml +++ b/app/src/main/res/layout/pane_vehicle.xml @@ -1,6 +1,36 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -35,35 +65,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/app/src/main/res/layout/pane_wifi.xml b/app/src/main/res/layout/pane_wifi.xml index 80d7398..67796f6 100644 --- a/app/src/main/res/layout/pane_wifi.xml +++ b/app/src/main/res/layout/pane_wifi.xml @@ -1,5 +1,5 @@ - + @@ -15,4 +15,4 @@ - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dc031b3..79f6bc4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,6 +107,11 @@ Connection timed out Server error (HTTP %1$d) + Double-press the voice key to open this app + Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses. + Steering-wheel keys + The last key reports the car sent, newest first. If double-pressing the voice key does not open the app, a photo of this list shows how the car reports it. + No key reports received yet. Press a steering-wheel key. Show 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. This build may not draw over other apps, so the button cannot appear. diff --git a/app/src/test/java/com/evsuite/abrp/DoublePressDetectorTest.java b/app/src/test/java/com/evsuite/abrp/DoublePressDetectorTest.java new file mode 100644 index 0000000..6f05bd6 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/DoublePressDetectorTest.java @@ -0,0 +1,57 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** Double-press detection for the voice key (287) — pure JVM. */ +public class DoublePressDetectorTest { + + private static final int VOICE = 287; + + @Test + public void broadcastsWithoutUpDownCountAsPresses() { + DoublePressDetector d = new DoublePressDetector(VOICE); + assertFalse(d.accept(VOICE, null, false, 1_000)); + assertTrue(d.accept(VOICE, null, false, 1_350)); + } + + @Test + public void withDownAndUpOnlyReleasesCount() { + DoublePressDetector d = new DoublePressDetector(VOICE); + assertFalse(d.accept(VOICE, true, false, 1_000)); + assertFalse(d.accept(VOICE, false, false, 1_080)); + assertFalse(d.accept(VOICE, true, false, 1_250)); + assertTrue(d.accept(VOICE, false, false, 1_330)); + } + + @Test + public void tooSlowIsTwoSinglePresses() { + DoublePressDetector d = new DoublePressDetector(VOICE); + assertFalse(d.accept(VOICE, null, false, 1_000)); + assertFalse(d.accept(VOICE, null, false, 1_000 + DoublePressDetector.WINDOW_MS + 1)); + } + + @Test + public void otherKeysAndLongPressesDoNotCount() { + DoublePressDetector d = new DoublePressDetector(VOICE); + assertFalse(d.accept(17, null, false, 1_000)); + assertFalse(d.accept(VOICE, null, false, 1_100)); + assertFalse(d.accept(17, null, false, 1_200)); // another key in between is ignored + assertTrue(d.accept(VOICE, null, false, 1_300)); + + DoublePressDetector held = new DoublePressDetector(VOICE); + assertFalse(held.accept(VOICE, null, false, 1_000)); + assertFalse(held.accept(VOICE, true, true, 1_100)); // long press resets + assertFalse(held.accept(VOICE, false, false, 1_300)); + } + + @Test + public void threePressesOpenOnceAndStartOver() { + DoublePressDetector d = new DoublePressDetector(VOICE); + assertFalse(d.accept(VOICE, null, false, 1_000)); + assertTrue(d.accept(VOICE, null, false, 1_200)); + assertFalse(d.accept(VOICE, null, false, 1_400)); + } +}