Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91283093f5 | ||
|
|
bdb3f5e765 | ||
|
|
9d84dc6d15 | ||
|
|
418883beb0 | ||
|
|
e52d7f01fc | ||
|
|
6b55bec419 |
@@ -79,9 +79,17 @@ analyser's peak, bass, mid, treble and rms, and a result line. Levels only, no s
|
|||||||
to move for music to count as heard. Ask what was playing when the check ran, and whether the
|
to move for music to count as heard. Ask what was playing when the check ran, and whether the
|
||||||
music kept playing and any prompt came up.
|
music kept playing and any prompt came up.
|
||||||
|
|
||||||
|
## Update checks
|
||||||
|
|
||||||
|
`fetch-logs` also brings `update-check-YYYYMMDD-HHMMSS.txt` (each Settings → Check for updates,
|
||||||
|
`UpdateHook`, 2.43.5 on): the networks Android sees, whether the server's name resolves, the
|
||||||
|
result and, on a failure, whether `www.gstatic.com/generate_204` answers (204: the internet
|
||||||
|
works, so look at the server). A report written offline arrives once the car is back online, or
|
||||||
|
is on a USB stick that was plugged in.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Recordings and checks are the car's data: `logs/`, `/signal-recording-*.txt` and
|
- Recordings and checks are the car's data: `logs/`, `/signal-recording-*.txt` and
|
||||||
`/media-probe-*.txt`, `/sound-probe-*.txt` are gitignored. Never
|
`/media-probe-*.txt`, `/sound-probe-*.txt`, `/update-check-*.txt` are gitignored. Never
|
||||||
commit, publish or upload them anywhere.
|
commit, publish or upload them anywhere.
|
||||||
- Findings that change what the project knows go into a `SPECS.md` done-log entry, as usual.
|
- Findings that change what the project knows go into a `SPECS.md` done-log entry, as usual.
|
||||||
|
|||||||
@@ -47,3 +47,4 @@ mise.local.toml
|
|||||||
/signal-recording-*.txt
|
/signal-recording-*.txt
|
||||||
/media-probe-*.txt
|
/media-probe-*.txt
|
||||||
/sound-probe-*.txt
|
/sound-probe-*.txt
|
||||||
|
/update-check-*.txt
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
|
|||||||
|
|
||||||
| Tab | Contents |
|
| Tab | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`), Día de Muertos (`MuertosView`, an ofrenda: papel picado, candles, a calavera in a ring of marigolds), Halloween (`HalloweenView`, a jack-o'-lantern, a potion flask, a tombstone on a graveyard hill), both counting with `Tally`, the pumpkin's light following the music on screen (`MusicTap`, `MusicPulse`: Android's audio analyser, levels only; Bluetooth and CarPlay, not the radio); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire, Día de Muertos, Halloween; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed, and under the battery what plays: `NowPlaying` picks from the media sessions `NowPlayingWatcher` follows — listened to only, never connected to, nothing logged or sent; Tiles and Halloween, Settings → "Show what's playing", `PREF_NOW_PLAYING`, on by default), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`), Día de Muertos (`MuertosView`, an ofrenda: papel picado, candles, a calavera in a ring of marigolds), Halloween (`HalloweenView`, a jack-o'-lantern, a potion flask, a tombstone on a graveyard hill), both counting with `Tally`, the pumpkin's light flaring on the kick drum while on screen, when "Pumpkin light follows the music" is on (off by default, `PREF_MUSIC_LIGHT`; `MusicTap`, `MusicPulse`: Android's audio analyser, 40–120 Hz, jumps not levels; Bluetooth and CarPlay, not the radio); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire, Día de Muertos, Halloween; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
|
||||||
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
||||||
| Live data | on/off, ABRP API key and user token, upload frequency |
|
| Live data | on/off, ABRP API key and user token, upload frequency |
|
||||||
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
|
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
|
||||||
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
|
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
|
||||||
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app, a USB stick and the server, through `LogOutbox`, which sends it again later when the car is offline; how to find where the car publishes something), music-sources check (`MediaProbe`: audio players and the active media sessions, CarPlay's and Bluetooth's among them, with their song, and the media browser services by name — never connected to: binding them stopped the music in 2.42.0; saved and sent like a recording), sound check (`SoundProbe`, `SoundLevels`: on a tap, 10 seconds of levels from Android's audio analyser, to learn whether it hears what plays; saved and sent like a recording), update log (every install step, with the platform's words), upload log |
|
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app, a USB stick and the server, through `LogOutbox`, which sends it again later when the car is offline; how to find where the car publishes something), music-sources check (`MediaProbe`: audio players and the active media sessions, CarPlay's and Bluetooth's among them, with their song, and the media browser services by name — never connected to: binding them stopped the music in 2.42.0; saved and sent like a recording), sound check (`SoundProbe`, `SoundLevels`: on a tap, 10 seconds of levels from Android's audio analyser, to learn whether it hears what plays; saved and sent like a recording), update log (every check and install step, with the platform's words; a check also writes down the networks, the server's name lookup and, when it fails, an internet test, `UpdateNetworkCheck`, `UpdateDiagnosis`, and saves a report like a recording), upload log |
|
||||||
|
|
||||||
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
||||||
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ platform's own words, is on the Logs tab under "Updates".
|
|||||||
| `Installation failed: INSTALL_FAILED_… The APK is in Downloads.` | The package manager's own words. A signature error means the APK was not signed by `build-car`. |
|
| `Installation failed: INSTALL_FAILED_… The APK is in Downloads.` | The package manager's own words. A signature error means the APK was not signed by `build-car`. |
|
||||||
| `… is in Downloads — tap the file to install` | A session could not start; install from Downloads by hand. |
|
| `… is in Downloads — tap the file to install` | A session could not start; install from Downloads by hand. |
|
||||||
|
|
||||||
On the car's Logs tab, or with a cable: `adb logcat -s EVABRP.Update OtaUpdater`.
|
On the car's Logs tab, or with a cable: `adb logcat -s EVABRP.Update OtaUpdater`. Each check
|
||||||
|
writes down the networks Android sees, whether the server's name resolves, its result and, when
|
||||||
|
it fails, whether `www.gstatic.com/generate_204` answers, which tells a SIM without data from a
|
||||||
|
server that is down. That report also goes to a USB stick and, once online, to the server
|
||||||
|
(`update-check-*.txt`, read with `fetch-logs`). No address or token is written.
|
||||||
|
|
||||||
## Why a PackageInstaller session, not `pm install`
|
## Why a PackageInstaller session, not `pm install`
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo
|
|||||||
|
|
||||||
| Tab | What it shows |
|
| Tab | What it shows |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, lease allowance, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro, Futuristic, Sci-fi, Cartoon, MG, Minimal, Terminal, Nixie tubes, Chronograph, Grid, Grid with map, Time circuits, Quest, Visor and Empire ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, and Retro, Terminal, Grid and Visor with "Original") colours the whole app to match, e.g. turquoise for Retro. Grid with map lays the roads round the car on the Grid's floor: the app asks the car's server for the map tile it is in (not its position), the server fetches only that tile from OpenFreeMap's OpenStreetMap vector tiles and keeps it, and the GPS runs for it only while that style is on screen. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
|
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, lease allowance, climate setpoint) and tyre pressures. While music plays (Bluetooth, CarPlay or the radio), the title and artist; they stay on the screen and are never sent. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro, Futuristic, Sci-fi, Cartoon, MG, Minimal, Terminal, Nixie tubes, Chronograph, Grid, Grid with map, Time circuits, Quest, Visor and Empire ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, and Retro, Terminal, Grid and Visor with "Original") colours the whole app to match, e.g. turquoise for Retro. Grid with map lays the roads round the car on the Grid's floor: the app asks the car's server for the map tile it is in (not its position), the server fetches only that tile from OpenFreeMap's OpenStreetMap vector tiles and keeps it, and the GPS runs for it only while that style is on screen. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
|
||||||
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
|
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
|
||||||
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
|
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
|
||||||
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
|||||||
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
||||||
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
||||||
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
||||||
|
- 2026-11-02 — App 2.45.0: **now playing on the Overview** (owner: "can we also implement music titles etc in our app?"; display only, Tiles and Halloween first). `NowPlayingWatcher` follows the active media sessions as the music-sources check reads them (`MediaSessionManager`, MEDIA_CONTENT_CONTROL as the system user), with a change listener and a callback per session, so reading costs no call into another app; it never connects to a media browser service (2.42.0) and sends no command. It runs only while the Overview is on screen in Tiles or Halloween with Settings → "Show what's playing" on (**on by default**, `PREF_NOW_PLAYING`). `NowPlaying` (pure, unit-tested) picks the session that plays, the one updated last of several, else the one paused last, passing over sessions without text (Bluetooth's empty one); CarPlay's YouTube titles lose their `[videoID]`; the radio shows its text (the artist field, e.g. "BLIND MELON - NO RAIN") with the station below. Tiles: the empty slot under the battery shows source ("· paused" when paused), title, artist, a cover (the session's picture, or Bluetooth's `ALBUM_ART_URI` file, read small off the main thread) and how far the song is; invisible when nothing plays; the narrow layout has no room and shows none. Halloween: "♪ title — artist" along the foot of the hill, the title cut first. Titles stay on screen: not logged, not sent. Tested: unit tests, lint, emulator with a throwaway build publishing a fake session (CarPlay-like title with ID removed, cover, bar; Halloween line; the switch hides and brings back both). With Halloween chosen the Settings card has two switches more and scrolls further. Not tried on the car: whether the car's sessions, Bluetooth's cover file and the radio text show as in the music-sources checks.
|
||||||
|
- 2026-11-02 — App 2.44.0 published (`car-2.44.0`).
|
||||||
|
- 2026-11-02 — App 2.44.0: **the pumpkin light follows the bass, and only when switched on** (owner: "more focussed on the bass", "optional via a settings flag"). `MusicPulse` now reads the kick drum's band (40–120 Hz, was 20–250 Hz) and lights only on a jump since the last reading, against the biggest jumps of the last 4 s; a jump under a fifth of that, or under half the bass's average level, lights nothing. So kicks flash, and a held note, a wobbling bass line or a swelling note stay dark (unit tests with made-up patterns). It flares at once and falls in 0.2 s. New switch under the Overview style, shown only for Halloween: "Pumpkin light follows the music" (`PREF_MUSIC_LIGHT`), **off by default**; off, the analyser never starts. Tuned on made-up signals only: no Bluetooth or CarPlay sound report from the car has arrived yet. Next: now playing (title, artist, source) from the media sessions, in its own version. Tested: unit tests, lint, emulator (switch off by default and the analyser thread absent; on, it runs only on the Overview; a throwaway build playing a pulsing test tone: the glow flares with the switch on, and stays exactly still with it off). With Halloween chosen, the Settings card now scrolls a little to reach the tyre unit. Not tried on the car.
|
||||||
|
- 2026-11-01 — App 2.43.5 published (`car-2.43.5`).
|
||||||
|
- 2026-11-01 — App 2.43.5: **update checks log what happens** (`UpdateNetworkCheck`, `UpdateDiagnosis`; owner: updating from the car fails, "Can we log what happens? Maybe my SIM card has no data left"). Each check on Settings writes to the Logs tab: the networks Android sees (type, in use, internet, validated, roaming), whether the server's name resolves, how a failure happened, in words from its type or status only, and how many reports wait. On a failure it also asks `https://www.gstatic.com/generate_204`, the one request to anyone but the car server, to tell no data from a server that is down. A report goes to a USB stick and, when online, the server (`update-check-*.txt`; only the newest waits, so it cannot push out a recording). A check at start logs only a failure. The update log keeps 40 lines, not 10. An out-of-data SIM would also explain why the Bluetooth and CarPlay sound reports did not arrive; not confirmed. Tested: unit tests, lint, emulator with a fake server (online: the name fails, the internet test says it works; airplane mode: both fail, "none in use"; the real build installs and starts, its check not run there, having no server). Not tried on the car.
|
||||||
|
- 2026-11-01 — App 2.43.4 published (`car-2.43.4`).
|
||||||
- 2026-11-01 — App 2.43.4: **the Halloween pumpkin's light follows the music** (`MusicTap`, `MusicPulse`; owner: "make the light in the pumpkin react to the equalizer/sound of the music"). The bass, 25 times a second off the main thread, drives the glow and brightens the carving on the beat; yellow and green still mean drawing and regenerating, the embers still the power, and no signal leaves the light as it was. It listens only with the app in front, on the Overview, in Halloween. The owner reports the sound check worked with Bluetooth and CarPlay; those two reports never reached the server (why not is unexplained), so their levels are unseen and the pulse's feel is untuned for the car. Tested: unit tests, lint, emulator (a tone already playing when the analyser starts: the carving and glow move with it; no sound: unchanged; the analyser is released on another tab and when the app leaves the front; no crash). Not tried on the car; how it looks there, and its effect on the head unit over a long drive, are unknown.
|
- 2026-11-01 — App 2.43.4: **the Halloween pumpkin's light follows the music** (`MusicTap`, `MusicPulse`; owner: "make the light in the pumpkin react to the equalizer/sound of the music"). The bass, 25 times a second off the main thread, drives the glow and brightens the carving on the beat; yellow and green still mean drawing and regenerating, the embers still the power, and no signal leaves the light as it was. It listens only with the app in front, on the Overview, in Halloween. The owner reports the sound check worked with Bluetooth and CarPlay; those two reports never reached the server (why not is unexplained), so their levels are unseen and the pulse's feel is untuned for the car. Tested: unit tests, lint, emulator (a tone already playing when the analyser starts: the carving and glow move with it; no sound: unchanged; the analyser is released on another tab and when the app leaves the front; no crash). Not tried on the car; how it looks there, and its effect on the head unit over a long drive, are unknown.
|
||||||
- 2026-10-31 — Sound check on the car (2.43.3), radio: `com.saicmotor.radio` playing, `isMusicActive` false, analyser enabled without error and silent for all 10 seconds (peak 0.00, -9600 mB). The audio analyser does not hear the radio. The owner ran Bluetooth and CarPlay too; those reports have not reached the server yet (the outbox sends oldest first and stops at the first failure, retrying while the service runs).
|
- 2026-10-31 — Sound check on the car (2.43.3), radio: `com.saicmotor.radio` playing, `isMusicActive` false, analyser enabled without error and silent for all 10 seconds (peak 0.00, -9600 mB). The audio analyser does not hear the radio. The owner ran Bluetooth and CarPlay too; those reports have not reached the server yet (the outbox sends oldest first and stops at the first failure, retrying while the service runs).
|
||||||
- 2026-10-31 — App 2.43.3 published (`car-2.43.3`).
|
- 2026-10-31 — App 2.43.3 published (`car-2.43.3`).
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, Día de
|
|||||||
The app's original look, from the design it was forked from: glass cards on OLED black, one tile
|
The app's original look, from the design it was forked from: glass cards on OLED black, one tile
|
||||||
per value, with the averages over the last five minutes and since the start, vehicle info (with
|
per value, with the averages over the last five minutes and since the start, vehicle info (with
|
||||||
the lease allowance) and the tyre pressures on a little top view of the car. The most
|
the lease allowance) and the tyre pressures on a little top view of the car. The most
|
||||||
information on one screen.
|
information on one screen. While something plays over Bluetooth, CarPlay or the radio, the slot
|
||||||
|
under the battery shows it: the source, title, artist, a cover when Bluetooth sends one, and how
|
||||||
|
far the song is (Settings → "Show what's playing", on by default).
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -262,8 +264,10 @@ the range ("here lies … km"), and the bats are the range too, one per 50 km; P
|
|||||||
gravestones, the one engaged lit. The moon holds the clock, a sign hanging from a cobweb has trip,
|
gravestones, the one engaged lit. The moon holds the clock, a sign hanging from a cobweb has trip,
|
||||||
usage, climate and odometer, and the hill along the bottom is the last two minutes of power. While
|
usage, climate and odometer, and the hill along the bottom is the last two minutes of power. While
|
||||||
you drive the bats fly, the potion bubbles and the ghost floats (where there is room for it, as on
|
you drive the bats fly, the potion bubbles and the ghost floats (where there is room for it, as on
|
||||||
this screen). With Bluetooth or CarPlay music playing, the pumpkin's glow swells and its carving
|
this screen). With "Pumpkin light follows the music" on (Settings, shown with this style; off by
|
||||||
brightens on the beat; the radio cannot be heard, so it burns as usual then. The app turns the
|
default) and Bluetooth or CarPlay music playing, the pumpkin's glow flares and its carving
|
||||||
|
brightens on each kick drum; the radio cannot be heard, so it burns as usual then. What plays
|
||||||
|
(Bluetooth, CarPlay or the radio) is carved into the foot of the hill. The app turns the
|
||||||
pumpkin's orange with it.
|
pumpkin's orange with it.
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 157
|
versionCode 160
|
||||||
versionName "2.43.4"
|
versionName "2.45.0"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// 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.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -23,4 +23,6 @@ final class ClusterValues {
|
|||||||
String tyreFl = "—", tyreFr = "—", tyreRl = "—", tyreRr = "—";
|
String tyreFl = "—", tyreFr = "—", tyreRl = "—", tyreRr = "—";
|
||||||
/** Battery power over the last two minutes, oldest first, NaN where unread; null before any. */
|
/** Battery power over the last two minutes, oldest first, NaN where unread; null before any. */
|
||||||
float[] powerTrail;
|
float[] powerTrail;
|
||||||
|
/** What plays ({@link NowPlaying}): the title, null when nothing does or it is off, and the artist or null. */
|
||||||
|
String songTitle, songArtist;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import java.util.Locale;
|
|||||||
* of power, rising with it, in orange or, where it regenerated, green. Unread values show "—" and
|
* of power, rising with it, in orange or, where it regenerated, green. Unread values show "—" and
|
||||||
* light nothing. With music playing, the pumpkin's glow swells and its carving brightens on the
|
* light nothing. With music playing, the pumpkin's glow swells and its carving brightens on the
|
||||||
* beat ({@link MusicTap}); colours and the embers keep their meaning, and with no music it burns as
|
* beat ({@link MusicTap}); colours and the embers keep their meaning, and with no music it burns as
|
||||||
* before.
|
* before. What plays is carved into the foot of the hill ({@link NowPlaying}).
|
||||||
*
|
*
|
||||||
* The night sky is drawn once into a bitmap per size; only the rest goes over it. Bats, bubbles and
|
* The night sky is drawn once into a bitmap per size; only the rest goes over it. Bats, bubbles and
|
||||||
* the ghost move (about 30 frames a second) only while the car moves and the view is on screen;
|
* the ghost move (about 30 frames a second) only while the car moves and the view is on screen;
|
||||||
@@ -99,6 +99,7 @@ final class HalloweenView extends ClusterCanvasView {
|
|||||||
drawMoon(c, w, u);
|
drawMoon(c, w, u);
|
||||||
drawGhost(c, w, u);
|
drawGhost(c, w, u);
|
||||||
drawHill(c, w, h, u);
|
drawHill(c, w, h, u);
|
||||||
|
drawSong(c, w, h, u);
|
||||||
drawEmbers(c, w, u);
|
drawEmbers(c, w, u);
|
||||||
float pulse = music == null ? 0f : music.pulse();
|
float pulse = music == null ? 0f : music.pulse();
|
||||||
drawPumpkin(c, w, u, pulse);
|
drawPumpkin(c, w, u, pulse);
|
||||||
@@ -276,6 +277,26 @@ final class HalloweenView extends ClusterCanvasView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- What plays, carved into the hill ----------
|
||||||
|
|
||||||
|
private void drawSong(Canvas c, int w, int h, float u) {
|
||||||
|
if (v.songTitle == null) return;
|
||||||
|
text.setTypeface(thin);
|
||||||
|
text.setTextSize(20f * u);
|
||||||
|
float room = w * 0.44f;
|
||||||
|
String artist = v.songArtist == null ? "" : " — " + cut(v.songArtist, room / 2f);
|
||||||
|
// A long title gives way, so the artist stays.
|
||||||
|
String s = "♪ " + cut(v.songTitle, room - text.measureText("♪ " + artist)) + artist;
|
||||||
|
label(c, s, w / 2f, h - 12f * u, 20f * u, AccentColor.withAlpha(BONE, 0xC0), thin, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code s} cut with "…" to fit {@code room} in the text paint as set. */
|
||||||
|
private String cut(String s, float room) {
|
||||||
|
if (text.measureText(s) <= room) return s;
|
||||||
|
int fits = text.breakText(s, true, Math.max(0f, room - text.measureText("…")), null);
|
||||||
|
return s.substring(0, Math.max(0, fits)).trim() + "…";
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- The arch of embers: the power ----------
|
// ---------- The arch of embers: the power ----------
|
||||||
|
|
||||||
private void drawEmbers(Canvas c, int w, float u) {
|
private void drawEmbers(Canvas c, int w, float u) {
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ final class LogOutbox {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Removes what waits under names starting with [prefix]. */
|
||||||
|
static synchronized void dropWaiting(Context context, String prefix) {
|
||||||
|
File dir = new File(context.getFilesDir(), DIR);
|
||||||
|
for (String name : waiting(dir)) if (name.startsWith(prefix)) new File(dir, name).delete();
|
||||||
|
}
|
||||||
|
|
||||||
/** Sends what waits, unless the last attempt failed less than {@link #RETRY_MS} ago. Blocking:
|
/** Sends what waits, unless the last attempt failed less than {@link #RETRY_MS} ago. Blocking:
|
||||||
* call it off the main thread. */
|
* call it off the main thread. */
|
||||||
static void sendIfDue(Context context) {
|
static void sendIfDue(Context context) {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
||||||
private View[] stylePanes;
|
private View[] stylePanes;
|
||||||
private MusicTap musicTap;
|
private MusicTap musicTap;
|
||||||
|
private NowPlayingWatcher nowPlaying;
|
||||||
private View consumptionPane;
|
private View consumptionPane;
|
||||||
private View chargingPane;
|
private View chargingPane;
|
||||||
/** Display, battery, charging price and Wi-Fi. */
|
/** Display, battery, charging price and Wi-Fi. */
|
||||||
@@ -197,6 +198,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
markCurrentPage(position);
|
markCurrentPage(position);
|
||||||
applyOverviewFullScreen(position);
|
applyOverviewFullScreen(position);
|
||||||
updateMusicTap();
|
updateMusicTap();
|
||||||
|
updateNowPlaying();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen());
|
findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen());
|
||||||
@@ -215,6 +217,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
/** Whether the Overview hides the navigation (the button in the navigation's header). */
|
/** Whether the Overview hides the navigation (the button in the navigation's header). */
|
||||||
static final String PREF_OVERVIEW_FULLSCREEN = "overview_fullscreen";
|
static final String PREF_OVERVIEW_FULLSCREEN = "overview_fullscreen";
|
||||||
|
/** The Overview shows what plays (Tiles, Halloween); on by default. */
|
||||||
|
static final String PREF_NOW_PLAYING = "now_playing";
|
||||||
|
/** The Halloween pumpkin's light follows the music; off by default, so the analyser stays off. */
|
||||||
|
static final String PREF_MUSIC_LIGHT = "music_light";
|
||||||
|
|
||||||
/** True while the Overview is full screen: the navigation hidden, the edge swipe armed. */
|
/** True while the Overview is full screen: the navigation hidden, the edge swipe armed. */
|
||||||
private boolean overviewFull;
|
private boolean overviewFull;
|
||||||
@@ -357,6 +363,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
visorView = new VisorView(this);
|
visorView = new VisorView(this);
|
||||||
HalloweenView halloweenView = new HalloweenView(this);
|
HalloweenView halloweenView = new HalloweenView(this);
|
||||||
musicTap = new MusicTap(this);
|
musicTap = new MusicTap(this);
|
||||||
|
nowPlaying = new NowPlayingWatcher(this);
|
||||||
halloweenView.setMusic(musicTap);
|
halloweenView.setMusic(musicTap);
|
||||||
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
||||||
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
||||||
@@ -512,6 +519,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
inFront = true;
|
inFront = true;
|
||||||
updateMapLocation();
|
updateMapLocation();
|
||||||
updateMusicTap();
|
updateMusicTap();
|
||||||
|
updateNowPlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -562,6 +570,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
inFront = false;
|
inFront = false;
|
||||||
updateMapLocation();
|
updateMapLocation();
|
||||||
updateMusicTap();
|
updateMusicTap();
|
||||||
|
updateNowPlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1223,6 +1232,18 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
bindDriveMode();
|
bindDriveMode();
|
||||||
bindOverviewStyle();
|
bindOverviewStyle();
|
||||||
bindAccentColor();
|
bindAccentColor();
|
||||||
|
SwitchMaterial showPlaying = settingsPane.findViewById(R.id.now_playing_switch);
|
||||||
|
showPlaying.setChecked(prefs.getBoolean(PREF_NOW_PLAYING, true));
|
||||||
|
showPlaying.setOnCheckedChangeListener((btn, checked) -> {
|
||||||
|
prefs.edit().putBoolean(PREF_NOW_PLAYING, checked).apply();
|
||||||
|
updateNowPlaying();
|
||||||
|
});
|
||||||
|
SwitchMaterial musicLight = settingsPane.findViewById(R.id.music_light_switch);
|
||||||
|
musicLight.setChecked(prefs.getBoolean(PREF_MUSIC_LIGHT, false));
|
||||||
|
musicLight.setOnCheckedChangeListener((btn, checked) -> {
|
||||||
|
prefs.edit().putBoolean(PREF_MUSIC_LIGHT, checked).apply();
|
||||||
|
updateMusicTap();
|
||||||
|
});
|
||||||
fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch);
|
fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch);
|
||||||
fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs));
|
fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs));
|
||||||
fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> {
|
fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> {
|
||||||
@@ -1759,12 +1780,67 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
if (settingsPane != null) bindAccentColor();
|
if (settingsPane != null) bindAccentColor();
|
||||||
updateMapLocation();
|
updateMapLocation();
|
||||||
updateMusicTap();
|
updateMusicTap();
|
||||||
|
updateNowPlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The pumpkin listens only while it can be seen: the app in front, on the Overview, in Halloween. */
|
/** The styles that show what plays. */
|
||||||
|
private static boolean showsNowPlaying(OverviewStyle style) {
|
||||||
|
return style == OverviewStyle.TILES || style == OverviewStyle.HALLOWEEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The media sessions are watched only while what plays can be seen, and the switch is on. */
|
||||||
|
private void updateNowPlaying() {
|
||||||
|
ViewPager2 pager = findViewById(R.id.content);
|
||||||
|
nowPlaying.setRunning(inFront && showsNowPlaying(overviewStyle())
|
||||||
|
&& prefs.getBoolean(PREF_NOW_PLAYING, true)
|
||||||
|
&& pager != null && pager.getCurrentItem() == 0);
|
||||||
|
if (!nowPlaying.isRunning()) showNowPlaying(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Tiles tile: source, title, artist, a cover when there is one, and how far the song is. */
|
||||||
|
private void showNowPlaying(NowPlaying.Track t) {
|
||||||
|
if (vehiclePane == null) return;
|
||||||
|
View tile = vehiclePane.findViewById(R.id.now_playing_tile);
|
||||||
|
if (tile == null) return; // the narrow layout has no room for it
|
||||||
|
if (t == null) {
|
||||||
|
tile.setVisibility(View.INVISIBLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tile.setVisibility(View.VISIBLE);
|
||||||
|
String source = getString(t.source == NowPlaying.Source.BLUETOOTH ? R.string.now_playing_bluetooth
|
||||||
|
: t.source == NowPlaying.Source.CARPLAY ? R.string.now_playing_carplay
|
||||||
|
: t.source == NowPlaying.Source.RADIO ? R.string.now_playing_radio : R.string.now_playing_other);
|
||||||
|
setIfChanged(vehiclePane.findViewById(R.id.now_playing_source),
|
||||||
|
t.playing ? source : getString(R.string.now_playing_paused, source));
|
||||||
|
setIfChanged(vehiclePane.findViewById(R.id.now_playing_title), t.title);
|
||||||
|
TextView artist = vehiclePane.findViewById(R.id.now_playing_artist);
|
||||||
|
setIfChanged(artist, t.subtitle == null ? "" : t.subtitle);
|
||||||
|
artist.setVisibility(t.subtitle == null ? View.GONE : View.VISIBLE);
|
||||||
|
android.widget.ImageView cover = vehiclePane.findViewById(R.id.now_playing_cover);
|
||||||
|
android.graphics.Bitmap art = nowPlaying.cover(t);
|
||||||
|
if (cover.getTag() != art) {
|
||||||
|
cover.setTag(art);
|
||||||
|
cover.setImageBitmap(art);
|
||||||
|
cover.setVisibility(art == null ? View.GONE : View.VISIBLE);
|
||||||
|
}
|
||||||
|
android.widget.ProgressBar bar = vehiclePane.findViewById(R.id.now_playing_progress);
|
||||||
|
float progress = t.progressAt(android.os.SystemClock.elapsedRealtime());
|
||||||
|
bar.setVisibility(progress < 0f ? View.INVISIBLE : View.VISIBLE);
|
||||||
|
if (progress >= 0f) bar.setProgress(Math.round(progress * bar.getMax()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setIfChanged(TextView view, CharSequence text) {
|
||||||
|
if (!android.text.TextUtils.equals(view.getText(), text)) view.setText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pumpkin listens only while it can be seen (the app in front, on the Overview, in
|
||||||
|
* Halloween) and only when "Pumpkin light follows the music" is on.
|
||||||
|
*/
|
||||||
private void updateMusicTap() {
|
private void updateMusicTap() {
|
||||||
ViewPager2 pager = findViewById(R.id.content);
|
ViewPager2 pager = findViewById(R.id.content);
|
||||||
musicTap.setRunning(inFront && overviewStyle() == OverviewStyle.HALLOWEEN
|
musicTap.setRunning(inFront && overviewStyle() == OverviewStyle.HALLOWEEN
|
||||||
|
&& prefs.getBoolean(PREF_MUSIC_LIGHT, false)
|
||||||
&& pager != null && pager.getCurrentItem() == 0);
|
&& pager != null && pager.getCurrentItem() == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1828,6 +1904,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
TextView note = settingsPane.findViewById(R.id.accent_note);
|
TextView note = settingsPane.findViewById(R.id.accent_note);
|
||||||
note.setVisibility(style.usesAccent ? View.GONE : View.VISIBLE);
|
note.setVisibility(style.usesAccent ? View.GONE : View.VISIBLE);
|
||||||
note.setText(getString(R.string.accent_not_used, styleLabels()[style.ordinal()]));
|
note.setText(getString(R.string.accent_not_used, styleLabels()[style.ordinal()]));
|
||||||
|
int playing = showsNowPlaying(style) ? View.VISIBLE : View.GONE;
|
||||||
|
settingsPane.findViewById(R.id.now_playing_switch).setVisibility(playing);
|
||||||
|
settingsPane.findViewById(R.id.now_playing_hint).setVisibility(playing);
|
||||||
|
int music = style == OverviewStyle.HALLOWEEN ? View.VISIBLE : View.GONE;
|
||||||
|
settingsPane.findViewById(R.id.music_light_switch).setVisibility(music);
|
||||||
|
settingsPane.findViewById(R.id.music_light_hint).setVisibility(music);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The drop-down's labels, by {@link OverviewStyle} ordinal. */
|
/** The drop-down's labels, by {@link OverviewStyle} ordinal. */
|
||||||
@@ -1910,6 +1992,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa());
|
v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa());
|
||||||
v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa());
|
v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa());
|
||||||
v.powerTrail = cockpitTrace.snapshot();
|
v.powerTrail = cockpitTrace.snapshot();
|
||||||
|
NowPlaying.Track track = nowPlaying.isRunning() ? nowPlaying.current() : null;
|
||||||
|
v.songTitle = track == null ? null : track.title;
|
||||||
|
v.songArtist = track == null ? null : track.subtitle;
|
||||||
|
showNowPlaying(track);
|
||||||
retroView.setValues(v);
|
retroView.setValues(v);
|
||||||
flowView.setValues(v);
|
flowView.setValues(v);
|
||||||
sciFiView.setValues(v);
|
sciFiView.setValues(v);
|
||||||
|
|||||||
@@ -2,32 +2,45 @@ package com.evsuite.abrp;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* How strongly the Halloween pumpkin's light answers the music at this moment, from 0 to 1, out of
|
* How strongly the Halloween pumpkin's light answers the music at this moment, from 0 to 1, out of
|
||||||
* the bass the audio analyser reports. What counts is the beat, not the loudness: each reading is
|
* the kick drum's band the audio analyser reports. What counts is the hit, not the loudness: only
|
||||||
* placed between the bass's recent average and its recent peak, so quiet and loud songs both
|
* a jump of the bass since the last reading lights it, measured against the biggest jumps of the
|
||||||
* pulse and a steady hum settles at nothing. It rises fast and falls slowly, as a flame does. No
|
* last seconds, so quiet and loud songs both flash, and a held bass note or a steady hum does not.
|
||||||
* reading or silence falls back to 0, so without music the light stays as it is. Android-free and
|
* Jumps that are small against the bass's own level (a swelling note, noise) are ignored. It
|
||||||
* unit-tested.
|
* flares at once and dies down within a beat, as a flame does. No reading or silence falls back to
|
||||||
|
* 0, so without music the light stays as it is. Android-free and unit-tested.
|
||||||
*/
|
*/
|
||||||
final class MusicPulse {
|
final class MusicPulse {
|
||||||
|
|
||||||
/** Below this the bass counts as silence. */
|
/** Below this the bass counts as silence. */
|
||||||
static final float SILENT = 0.005f;
|
static final float SILENT = 0.005f;
|
||||||
private static final float RISE_S = 0.03f, FALL_S = 0.25f, AVERAGE_S = 2f, PEAK_S = 4f;
|
/** The kick drum's band, in Hz, that {@link MusicTap} reads. */
|
||||||
|
static final float FROM_HZ = 40f, TO_HZ = 120f;
|
||||||
|
private static final float FALL_S = 0.2f, AVERAGE_S = 2f, PEAK_S = 4f;
|
||||||
|
/** A jump below this share of the recent biggest one lights nothing. */
|
||||||
|
private static final float THRESHOLD = 0.2f;
|
||||||
|
/** A jump has to be at least this share of the bass's average level to count at all. */
|
||||||
|
private static final float LEVEL_SHARE = 0.5f;
|
||||||
|
|
||||||
private float average, peak, pulse;
|
private float last, average, peakJump, pulse;
|
||||||
|
|
||||||
/** One reading, {@code seconds} after the last; null when it failed. Returns the new pulse. */
|
/** One reading, {@code seconds} after the last; null when it failed. Returns the new pulse. */
|
||||||
float update(Float bass, float seconds) {
|
float update(Float bass, float seconds) {
|
||||||
float target = 0f;
|
float target = 0f;
|
||||||
if (bass == null || bass.isNaN() || bass < SILENT) {
|
if (bass == null || bass.isNaN() || bass < SILENT) {
|
||||||
|
last = 0f;
|
||||||
average = 0f;
|
average = 0f;
|
||||||
peak = 0f;
|
peakJump = 0f;
|
||||||
} else {
|
} else {
|
||||||
|
float jump = Math.max(0f, bass - last);
|
||||||
|
last = bass;
|
||||||
average = average == 0f ? bass : average + (bass - average) * share(seconds, AVERAGE_S);
|
average = average == 0f ? bass : average + (bass - average) * share(seconds, AVERAGE_S);
|
||||||
peak = Math.max(bass, peak - peak * share(seconds, PEAK_S));
|
peakJump = Math.max(jump, peakJump - peakJump * share(seconds, PEAK_S));
|
||||||
if (peak > average) target = Math.max(0f, Math.min(1f, (bass - average) / (peak - average)));
|
float reference = Math.max(peakJump, average * LEVEL_SHARE);
|
||||||
|
if (reference > 0f) {
|
||||||
|
target = Math.max(0f, Math.min(1f, (jump / reference - THRESHOLD) / (1f - THRESHOLD)));
|
||||||
}
|
}
|
||||||
pulse += (target - pulse) * share(seconds, target > pulse ? RISE_S : FALL_S);
|
}
|
||||||
|
pulse = target > pulse ? target : pulse + (target - pulse) * share(seconds, FALL_S);
|
||||||
if (pulse < 0.005f) pulse = 0f;
|
if (pulse < 0.005f) pulse = 0f;
|
||||||
return pulse;
|
return pulse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import android.media.audiofx.Visualizer;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Listens to what the head unit plays for the Halloween pumpkin's light: Android's audio analyser
|
* Listens to what the head unit plays for the Halloween pumpkin's light: Android's audio analyser
|
||||||
* on the output mix, its bass read 25 times a second on a thread of its own, made into a
|
* on the output mix, its kick-drum band read 25 times a second on a thread of its own, made into a
|
||||||
* {@link MusicPulse}. Runs only while MainActivity says so (the style on screen, the app in
|
* {@link MusicPulse}. Runs only while MainActivity says so (the style on screen, the app in
|
||||||
* front). Levels only: no sound is kept and no microphone used. Where the analyser hears nothing
|
* front, and "Pumpkin light follows the music" on). Levels only: no sound is kept and no microphone used. Where the analyser hears nothing
|
||||||
* (the radio) or is not available, the pulse stays 0 and the pumpkin burns as usual.
|
* (the radio) or is not available, the pulse stays 0 and the pumpkin burns as usual.
|
||||||
*/
|
*/
|
||||||
final class MusicTap {
|
final class MusicTap {
|
||||||
@@ -64,7 +64,7 @@ final class MusicTap {
|
|||||||
boolean read = analyser.getFft(fft) == Visualizer.SUCCESS;
|
boolean read = analyser.getFft(fft) == Visualizer.SUCCESS;
|
||||||
failures = read ? 0 : failures + 1;
|
failures = read ? 0 : failures + 1;
|
||||||
long now = System.nanoTime();
|
long now = System.nanoTime();
|
||||||
float value = music.update(read ? SoundLevels.band(fft, rateHz, 20f, 250f) : null, (now - last) / 1e9f);
|
float value = music.update(read ? SoundLevels.band(fft, rateHz, MusicPulse.FROM_HZ, MusicPulse.TO_HZ) : null, (now - last) / 1e9f);
|
||||||
last = now;
|
last = now;
|
||||||
if (worker == me) pulse = value;
|
if (worker == me) pulse = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Overview shows as playing, out of the media sessions the head unit publishes
|
||||||
|
* ({@link NowPlayingWatcher}): the session that plays, the one updated last when several do, and
|
||||||
|
* when none plays the one paused last. Sessions without any text are passed over (Bluetooth leaves
|
||||||
|
* an empty one behind when the phone stops). The text is cleaned: CarPlay's YouTube titles lose
|
||||||
|
* their trailing video ID, and the radio, whose title is the station and whose artist field is the
|
||||||
|
* station's own text, shows that text with the station below. Shown on screen only; nothing here
|
||||||
|
* is logged or sent. Android-free and unit-tested.
|
||||||
|
*/
|
||||||
|
final class NowPlaying {
|
||||||
|
|
||||||
|
enum Source { BLUETOOTH, CARPLAY, RADIO, OTHER }
|
||||||
|
|
||||||
|
/** android.media.session.PlaybackState's codes, mirrored to stay Android-free. */
|
||||||
|
static final int STATE_PAUSED = 2, STATE_PLAYING = 3, STATE_FAST_FORWARDING = 4, STATE_REWINDING = 5,
|
||||||
|
STATE_BUFFERING = 6, STATE_SKIPPING_TO_PREVIOUS = 9, STATE_SKIPPING_TO_NEXT = 10,
|
||||||
|
STATE_SKIPPING_TO_QUEUE_ITEM = 11;
|
||||||
|
|
||||||
|
private static final int MAX_TEXT = 200;
|
||||||
|
/** A YouTube video ID in brackets at the end of a title, as CarPlay passes them on. */
|
||||||
|
private static final Pattern VIDEO_ID = Pattern.compile("\\s*\\[[A-Za-z0-9_-]{11}\\]$");
|
||||||
|
private static final Pattern SPACES = Pattern.compile("\\s+");
|
||||||
|
|
||||||
|
private NowPlaying() {}
|
||||||
|
|
||||||
|
/** One media session as last seen. Times in the platform's elapsed-realtime milliseconds. */
|
||||||
|
static final class Session {
|
||||||
|
String packageName;
|
||||||
|
int state;
|
||||||
|
String title, artist;
|
||||||
|
/** -1 when the session says no position. */
|
||||||
|
long positionMs = -1;
|
||||||
|
long positionUpdatedAt;
|
||||||
|
float speed = 1f;
|
||||||
|
/** 0 when unknown (the radio). */
|
||||||
|
long durationMs;
|
||||||
|
/** A file or content URI for the cover, when the session names one (Bluetooth does). */
|
||||||
|
String artUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What to show. {@code subtitle} may be null; {@code session} is where it came from. */
|
||||||
|
static final class Track {
|
||||||
|
final Source source;
|
||||||
|
final String title, subtitle;
|
||||||
|
final boolean playing;
|
||||||
|
final Session session;
|
||||||
|
|
||||||
|
Track(Source source, String title, String subtitle, boolean playing, Session session) {
|
||||||
|
this.source = source;
|
||||||
|
this.title = title;
|
||||||
|
this.subtitle = subtitle;
|
||||||
|
this.playing = playing;
|
||||||
|
this.session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the song is at {@code now}, or -1 when the session gives no position. */
|
||||||
|
long positionAt(long now) {
|
||||||
|
if (session.positionMs < 0) return -1;
|
||||||
|
long at = session.positionMs;
|
||||||
|
if (playing && session.positionUpdatedAt > 0 && now > session.positionUpdatedAt) {
|
||||||
|
at += (long) ((now - session.positionUpdatedAt) * session.speed);
|
||||||
|
}
|
||||||
|
if (session.durationMs > 0) at = Math.min(at, session.durationMs);
|
||||||
|
return Math.max(0, at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far through the song, from 0 to 1, or -1 without a length or position (the radio). */
|
||||||
|
float progressAt(long now) {
|
||||||
|
long at = positionAt(now);
|
||||||
|
if (at < 0 || session.durationMs <= 0) return -1f;
|
||||||
|
return Math.min(1f, at / (float) session.durationMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Track pick(List<Session> sessions) {
|
||||||
|
Session best = null;
|
||||||
|
boolean bestPlaying = false;
|
||||||
|
if (sessions != null) {
|
||||||
|
for (Session s : sessions) {
|
||||||
|
if (s == null || (!playing(s.state) && s.state != STATE_PAUSED)) continue;
|
||||||
|
if (clean(s.title) == null && clean(s.artist) == null) continue;
|
||||||
|
boolean p = playing(s.state);
|
||||||
|
if (best == null || (p && !bestPlaying)
|
||||||
|
|| (p == bestPlaying && s.positionUpdatedAt > best.positionUpdatedAt)) {
|
||||||
|
best = s;
|
||||||
|
bestPlaying = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best == null) return null;
|
||||||
|
Source source = source(best.packageName);
|
||||||
|
String title = clean(best.title), artist = clean(best.artist);
|
||||||
|
if (source == Source.RADIO) {
|
||||||
|
// The station is the title; its own text (song, programme) comes as the artist.
|
||||||
|
if (artist != null) return new Track(source, artist, title, bestPlaying, best);
|
||||||
|
return new Track(source, title, null, bestPlaying, best);
|
||||||
|
}
|
||||||
|
if (title == null) return new Track(source, artist, null, bestPlaying, best);
|
||||||
|
return new Track(source, title, artist, bestPlaying, best);
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean playing(int state) {
|
||||||
|
switch (state) {
|
||||||
|
case STATE_PLAYING: case STATE_FAST_FORWARDING: case STATE_REWINDING: case STATE_BUFFERING:
|
||||||
|
case STATE_SKIPPING_TO_PREVIOUS: case STATE_SKIPPING_TO_NEXT: case STATE_SKIPPING_TO_QUEUE_ITEM:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which source a session's app is: the ones seen on the owner's car, the rest as "other". */
|
||||||
|
static Source source(String packageName) {
|
||||||
|
if (packageName == null) return Source.OTHER;
|
||||||
|
String p = packageName.toLowerCase(Locale.ROOT);
|
||||||
|
if (p.equals("com.android.bluetooth")) return Source.BLUETOOTH;
|
||||||
|
if (p.contains("allgo") || p.contains("carplay")) return Source.CARPLAY;
|
||||||
|
if (p.contains("radio")) return Source.RADIO;
|
||||||
|
return Source.OTHER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trimmed, one line, without a trailing video ID, at most 200 characters; null when empty. */
|
||||||
|
static String clean(String text) {
|
||||||
|
if (text == null) return null;
|
||||||
|
String s = SPACES.matcher(text).replaceAll(" ").trim();
|
||||||
|
s = VIDEO_ID.matcher(s).replaceAll("").trim();
|
||||||
|
if (s.isEmpty()) return null;
|
||||||
|
return s.length() > MAX_TEXT ? s.substring(0, MAX_TEXT - 1) + "…" : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.media.MediaMetadata;
|
||||||
|
import android.media.session.MediaController;
|
||||||
|
import android.media.session.MediaSessionManager;
|
||||||
|
import android.media.session.PlaybackState;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches the media sessions the head unit publishes (Bluetooth's, CarPlay's, the radio's) for the
|
||||||
|
* Overview's "now playing", the way the Logs tab's music-sources check reads them: through
|
||||||
|
* {@code MediaSessionManager}, which needs MEDIA_CONTENT_CONTROL (the car build has it as the
|
||||||
|
* system user). It only listens: no media browser service is connected to (that stopped the music
|
||||||
|
* in 2.42.0) and no command is sent. Each session's state and song are kept as the session reports
|
||||||
|
* changes, so reading {@link #current()} costs no call into another app. Runs only while
|
||||||
|
* MainActivity says so, on the main thread. Without the permission, or on any failure, there is
|
||||||
|
* simply nothing playing. Nothing it sees is logged.
|
||||||
|
*/
|
||||||
|
final class NowPlayingWatcher {
|
||||||
|
|
||||||
|
private static final int COVER_PX = 160;
|
||||||
|
|
||||||
|
private final Context app;
|
||||||
|
private final Handler main = new Handler(Looper.getMainLooper());
|
||||||
|
private final List<Watched> watched = new ArrayList<>();
|
||||||
|
private final ExecutorService coverLoader = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "now-playing-cover");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private MediaSessionManager sessions;
|
||||||
|
private boolean running;
|
||||||
|
private Bitmap sessionCover;
|
||||||
|
/** The file cover asked for last and what came of it, from the loader thread. */
|
||||||
|
private volatile String coverUri;
|
||||||
|
private volatile Bitmap fileCover;
|
||||||
|
|
||||||
|
private final MediaSessionManager.OnActiveSessionsChangedListener changed = this::watch;
|
||||||
|
|
||||||
|
NowPlayingWatcher(Context context) {
|
||||||
|
app = context.getApplicationContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRunning(boolean run) {
|
||||||
|
if (run == running) return;
|
||||||
|
running = run;
|
||||||
|
if (!run) {
|
||||||
|
try {
|
||||||
|
if (sessions != null) sessions.removeOnActiveSessionsChangedListener(changed);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Gone with the listener either way.
|
||||||
|
}
|
||||||
|
watch(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessions = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE);
|
||||||
|
if (sessions == null) return;
|
||||||
|
sessions.addOnActiveSessionsChangedListener(changed, null, main);
|
||||||
|
watch(sessions.getActiveSessions(null));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// No permission (the stable build) or no session service: nothing plays.
|
||||||
|
android.util.Log.w("NowPlaying", "Media sessions not readable: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isRunning() {
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What plays now, or null. */
|
||||||
|
NowPlaying.Track current() {
|
||||||
|
List<NowPlaying.Session> seen = new ArrayList<>(watched.size());
|
||||||
|
for (Watched w : watched) seen.add(w.seen);
|
||||||
|
return NowPlaying.pick(seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current track's cover, or null: the picture the session carries, else its file, once loaded. */
|
||||||
|
Bitmap cover(NowPlaying.Track track) {
|
||||||
|
if (track == null) return null;
|
||||||
|
for (Watched w : watched) {
|
||||||
|
if (w.seen == track.session && w.cover != null) return w.cover;
|
||||||
|
}
|
||||||
|
String uri = track.session.artUri;
|
||||||
|
if (uri == null) return null;
|
||||||
|
if (!uri.equals(coverUri)) {
|
||||||
|
coverUri = uri;
|
||||||
|
fileCover = null;
|
||||||
|
coverLoader.execute(() -> {
|
||||||
|
Bitmap b = load(uri);
|
||||||
|
if (uri.equals(coverUri)) fileCover = b;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fileCover;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void watch(List<MediaController> controllers) {
|
||||||
|
for (Watched w : watched) w.release();
|
||||||
|
watched.clear();
|
||||||
|
if (controllers == null || !running) return;
|
||||||
|
for (MediaController c : controllers) {
|
||||||
|
try {
|
||||||
|
watched.add(new Watched(c));
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// A session that went away while being read.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One session: its last state and song, kept up to date by its callback. */
|
||||||
|
private final class Watched extends MediaController.Callback {
|
||||||
|
final MediaController controller;
|
||||||
|
final NowPlaying.Session seen = new NowPlaying.Session();
|
||||||
|
Bitmap cover;
|
||||||
|
|
||||||
|
Watched(MediaController c) {
|
||||||
|
controller = c;
|
||||||
|
seen.packageName = c.getPackageName();
|
||||||
|
onPlaybackStateChanged(c.getPlaybackState());
|
||||||
|
onMetadataChanged(c.getMetadata());
|
||||||
|
c.registerCallback(this, main);
|
||||||
|
}
|
||||||
|
|
||||||
|
void release() {
|
||||||
|
try {
|
||||||
|
controller.unregisterCallback(this);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlaybackStateChanged(PlaybackState state) {
|
||||||
|
seen.state = state == null ? 0 : state.getState();
|
||||||
|
seen.positionMs = state == null ? -1 : state.getPosition();
|
||||||
|
seen.positionUpdatedAt = state == null ? 0 : state.getLastPositionUpdateTime();
|
||||||
|
seen.speed = state == null ? 1f : state.getPlaybackSpeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMetadataChanged(MediaMetadata m) {
|
||||||
|
seen.title = text(m, MediaMetadata.METADATA_KEY_TITLE);
|
||||||
|
if (seen.title == null) seen.title = text(m, MediaMetadata.METADATA_KEY_DISPLAY_TITLE);
|
||||||
|
seen.artist = text(m, MediaMetadata.METADATA_KEY_ARTIST);
|
||||||
|
if (seen.artist == null) seen.artist = text(m, MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE);
|
||||||
|
seen.durationMs = m == null ? 0 : Math.max(0, m.getLong(MediaMetadata.METADATA_KEY_DURATION));
|
||||||
|
cover = picture(m);
|
||||||
|
seen.artUri = text(m, MediaMetadata.METADATA_KEY_ALBUM_ART_URI);
|
||||||
|
if (seen.artUri == null) seen.artUri = text(m, MediaMetadata.METADATA_KEY_ART_URI);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSessionDestroyed() {
|
||||||
|
seen.state = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(MediaMetadata m, String key) {
|
||||||
|
if (m == null) return null;
|
||||||
|
try {
|
||||||
|
CharSequence s = m.getText(key);
|
||||||
|
return s == null ? null : s.toString();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bitmap picture(MediaMetadata m) {
|
||||||
|
if (m == null) return null;
|
||||||
|
for (String key : new String[] { MediaMetadata.METADATA_KEY_ALBUM_ART, MediaMetadata.METADATA_KEY_ART,
|
||||||
|
MediaMetadata.METADATA_KEY_DISPLAY_ICON }) {
|
||||||
|
try {
|
||||||
|
Bitmap b = m.getBitmap(key);
|
||||||
|
if (b != null && b.getWidth() > 1 && b.getHeight() > 1) return b;
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Try the next.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A cover file, made small; null when it cannot be read. Off the main thread. */
|
||||||
|
private Bitmap load(String uri) {
|
||||||
|
try {
|
||||||
|
Uri u = uri.startsWith("/") ? Uri.fromFile(new java.io.File(uri)) : Uri.parse(uri);
|
||||||
|
String scheme = u.getScheme();
|
||||||
|
if (!"file".equals(scheme) && !"content".equals(scheme)) return null;
|
||||||
|
BitmapFactory.Options size = new BitmapFactory.Options();
|
||||||
|
size.inJustDecodeBounds = true;
|
||||||
|
try (InputStream in = app.getContentResolver().openInputStream(u)) {
|
||||||
|
BitmapFactory.decodeStream(in, null, size);
|
||||||
|
}
|
||||||
|
if (size.outWidth <= 0 || size.outHeight <= 0) return null;
|
||||||
|
BitmapFactory.Options small = new BitmapFactory.Options();
|
||||||
|
small.inSampleSize = 1;
|
||||||
|
while (Math.max(size.outWidth, size.outHeight) / (small.inSampleSize * 2) >= COVER_PX) small.inSampleSize *= 2;
|
||||||
|
try (InputStream in = app.getContentResolver().openInputStream(u)) {
|
||||||
|
return BitmapFactory.decodeStream(in, null, small);
|
||||||
|
}
|
||||||
|
} catch (Exception | OutOfMemoryError e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.ConnectException;
|
||||||
|
import java.net.NoRouteToHostException;
|
||||||
|
import java.net.SocketException;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why an update check failed, in words for the update log, and what the car's networks look
|
||||||
|
* like. Built from an error's type and an HTTP status only, because an error's own text can carry
|
||||||
|
* the server's address: nothing here holds a URL or a credential. Android-free and unit-tested.
|
||||||
|
*/
|
||||||
|
final class UpdateDiagnosis {
|
||||||
|
|
||||||
|
private static final Pattern STATUS = Pattern.compile("^Release API returned (\\d{3})$");
|
||||||
|
|
||||||
|
private UpdateDiagnosis() {}
|
||||||
|
|
||||||
|
/** One line about a failed step. */
|
||||||
|
static String failure(Throwable t) {
|
||||||
|
if (t == null) return "unknown failure";
|
||||||
|
String type = " (" + t.getClass().getSimpleName() + ")";
|
||||||
|
if (t instanceof UnknownHostException) return "the name could not be looked up: no data connection, or no name service" + type;
|
||||||
|
if (t instanceof SocketTimeoutException) return "no answer in time: a very slow or blocked connection" + type;
|
||||||
|
if (t instanceof ConnectException || t instanceof NoRouteToHostException) return "no connection could be made" + type;
|
||||||
|
if (t instanceof SSLException) return "the secure connection failed: something in between may have answered, or the head unit's clock is wrong" + type;
|
||||||
|
if (t instanceof SocketException) return "the connection broke off" + type;
|
||||||
|
if (t instanceof IOException && t.getMessage() != null) {
|
||||||
|
Matcher status = STATUS.matcher(t.getMessage());
|
||||||
|
if (status.matches()) return status(Integer.parseInt(status.group(1)));
|
||||||
|
if (t.getMessage().equals("Invalid release response")) return "the answer was not a list of releases";
|
||||||
|
}
|
||||||
|
return "failed" + type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a status from the car server means here. */
|
||||||
|
static String status(int code) {
|
||||||
|
String what;
|
||||||
|
if (code >= 300 && code < 400) what = "something in between redirected it, as a SIM without data may";
|
||||||
|
else if (code == 401 || code == 403) what = "the server refuses the car's token";
|
||||||
|
else if (code == 404) what = "the server does not know this address";
|
||||||
|
else if (code >= 500) what = "the server or the forge behind it has a problem";
|
||||||
|
else what = "not what was expected";
|
||||||
|
return "the server answered HTTP " + code + ": " + what;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The outside-world test: a 204 is the internet working; anything else stands in between. */
|
||||||
|
static String internet(int code) {
|
||||||
|
if (code == 204) return "the internet works (HTTP 204)";
|
||||||
|
return "something in between answered HTTP " + code + " instead, as a SIM without data may";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One network as Android sees it. */
|
||||||
|
static String network(List<String> transports, boolean internet, boolean validated,
|
||||||
|
boolean active, boolean roaming) {
|
||||||
|
return (transports.isEmpty() ? "unknown type" : String.join("+", transports))
|
||||||
|
+ (active ? ", in use" : "")
|
||||||
|
+ (internet ? ", offers internet" : ", no internet")
|
||||||
|
+ (validated ? ", confirmed by Android" : ", not confirmed by Android")
|
||||||
|
+ (roaming ? ", roaming" : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ import java.util.Locale;
|
|||||||
*/
|
*/
|
||||||
final class UpdateLog {
|
final class UpdateLog {
|
||||||
|
|
||||||
static final int MAX_LINES = 10;
|
static final int MAX_LINES = 40;
|
||||||
private static final String PREFS = "update_log";
|
private static final String PREFS = "update_log";
|
||||||
private static final String KEY = "lines";
|
private static final String KEY = "lines";
|
||||||
private static final String KEY_VERSION = "version";
|
private static final String KEY_VERSION = "version";
|
||||||
|
|||||||
@@ -26,6 +26,12 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<TextView android:id="@+id/accent_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
<TextView android:id="@+id/accent_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
||||||
|
<!-- Only the styles that show what plays (Tiles, Halloween). -->
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/now_playing_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_now_playing" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" android:visibility="gone" />
|
||||||
|
<TextView android:id="@+id/now_playing_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_now_playing" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
||||||
|
<!-- Only Halloween listens to the music. -->
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/music_light_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_music_light" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" android:visibility="gone" />
|
||||||
|
<TextView android:id="@+id/music_light_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_music_light" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:orientation="vertical">
|
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:orientation="vertical">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||||
|
|||||||
@@ -31,7 +31,18 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:baselineAligned="false">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:baselineAligned="false">
|
||||||
<Space android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" android:layout_marginEnd="10dp" />
|
<!-- What plays (NowPlaying), in the slot under the battery; invisible when nothing does. -->
|
||||||
|
<LinearLayout android:id="@+id/now_playing_tile" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:visibility="invisible">
|
||||||
|
<TextView android:id="@+id/now_playing_source" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Secondary" android:maxLines="1" android:ellipsize="end" />
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
|
||||||
|
<ImageView android:id="@+id/now_playing_cover" android:layout_width="44dp" android:layout_height="44dp" android:layout_marginEnd="10dp" android:scaleType="centerCrop" android:importantForAccessibility="no" android:visibility="gone" />
|
||||||
|
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
|
||||||
|
<TextView android:id="@+id/now_playing_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Body" android:textSize="17sp" android:maxLines="1" android:ellipsize="end" />
|
||||||
|
<TextView android:id="@+id/now_playing_artist" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
<ProgressBar android:id="@+id/now_playing_progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="match_parent" android:layout_height="4dp" android:layout_marginTop="6dp" android:max="1000" android:progressTint="?attr/evAccent" android:progressBackgroundTint="?attr/evAccentTrack" android:visibility="invisible" />
|
||||||
|
</LinearLayout>
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
|
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_energy_trip" android:textAppearance="@style/Text.EV.Secondary" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_energy_trip" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
<TextView android:id="@+id/vehicle_energy_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
<TextView android:id="@+id/vehicle_energy_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
<TextView android:id="@+id/accent_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
<TextView android:id="@+id/accent_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
||||||
|
<!-- Only the styles that show what plays (Tiles, Halloween). -->
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/now_playing_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_now_playing" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" android:visibility="gone" />
|
||||||
|
<TextView android:id="@+id/now_playing_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_now_playing" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
||||||
|
<!-- Only Halloween listens to the music. -->
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/music_light_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_music_light" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" android:visibility="gone" />
|
||||||
|
<TextView android:id="@+id/music_light_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_music_light" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||||
|
|||||||
@@ -129,6 +129,15 @@
|
|||||||
<string name="overview_style_scifi">Sci-fi</string>
|
<string name="overview_style_scifi">Sci-fi</string>
|
||||||
<string name="overview_style_cartoon">Cartoon</string>
|
<string name="overview_style_cartoon">Cartoon</string>
|
||||||
<string name="overview_style_mg">MG</string>
|
<string name="overview_style_mg">MG</string>
|
||||||
|
<string name="label_now_playing">Show what\'s playing</string>
|
||||||
|
<string name="hint_now_playing">Title and artist from Bluetooth, CarPlay or the radio. Shown here only, never sent.</string>
|
||||||
|
<string name="now_playing_bluetooth">Bluetooth</string>
|
||||||
|
<string name="now_playing_carplay">CarPlay</string>
|
||||||
|
<string name="now_playing_radio">Radio</string>
|
||||||
|
<string name="now_playing_other">Media</string>
|
||||||
|
<string name="now_playing_paused">%1$s · paused</string>
|
||||||
|
<string name="label_music_light">Pumpkin light follows the music</string>
|
||||||
|
<string name="hint_music_light">Flares on the kick drum over Bluetooth or CarPlay, not the radio. Levels only, no sound kept.</string>
|
||||||
<string name="accent_not_used">%1$s has its own colours, and the app takes them on. To change the accent colour, pick a style that uses it.</string>
|
<string name="accent_not_used">%1$s has its own colours, and the app takes them on. To change the accent colour, pick a style that uses it.</string>
|
||||||
<string name="overview_fullscreen_enter">Overview full screen: hide the navigation</string>
|
<string name="overview_fullscreen_enter">Overview full screen: hide the navigation</string>
|
||||||
<string name="overview_fullscreen_exit">Show the navigation again</string>
|
<string name="overview_fullscreen_exit">Show the navigation again</string>
|
||||||
|
|||||||
@@ -36,6 +36,45 @@ public class MusicPulseTest {
|
|||||||
assertEquals(0f, last, 0f);
|
assertEquals(0f, last, 0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aHeldBassNoteLightsOnlyItsStart() {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
for (int i = 0; i < 50; i++) p.update(0.05f, STEP);
|
||||||
|
assertTrue(p.update(0.4f, STEP) > 0.8f);
|
||||||
|
float last = 1f;
|
||||||
|
for (int i = 0; i < 25; i++) last = p.update(0.4f, STEP);
|
||||||
|
assertEquals(0f, last, 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aWobblingBassLineStaysDark() {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
for (int i = 0; i < 50; i++) p.update(0.3f, STEP);
|
||||||
|
float top = 0f;
|
||||||
|
for (int i = 0; i < 200; i++) top = Math.max(top, p.update(i % 3 == 0 ? 0.33f : 0.3f, STEP));
|
||||||
|
assertEquals(0f, top, 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aSwellingNoteStaysDark() {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
for (int i = 0; i < 50; i++) p.update(0.1f, STEP);
|
||||||
|
float top = 0f;
|
||||||
|
for (int i = 0; i < 100; i++) top = Math.max(top, p.update(0.1f + i * 0.004f, STEP));
|
||||||
|
assertEquals(0f, top, 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aKickOverABassLineLights() {
|
||||||
|
MusicPulse p = new MusicPulse();
|
||||||
|
float top = 0f;
|
||||||
|
for (int i = 0; i < 300; i++) {
|
||||||
|
float v = p.update(i % 12 == 0 ? 0.6f : (i % 3 == 0 ? 0.33f : 0.3f), STEP);
|
||||||
|
if (i > 100) top = Math.max(top, v);
|
||||||
|
}
|
||||||
|
assertTrue("a kick lights it, got " + top, top > 0.8f);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void quietAndLoudSongsPulseAlike() {
|
public void quietAndLoudSongsPulseAlike() {
|
||||||
assertEquals(beatOf(0.5f), beatOf(0.05f), 0.02f);
|
assertEquals(beatOf(0.5f), beatOf(0.05f), 0.02f);
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class NowPlayingTest {
|
||||||
|
|
||||||
|
private static NowPlaying.Session session(String pkg, int state, String title, String artist, long updatedAt) {
|
||||||
|
NowPlaying.Session s = new NowPlaying.Session();
|
||||||
|
s.packageName = pkg;
|
||||||
|
s.state = state;
|
||||||
|
s.title = title;
|
||||||
|
s.artist = artist;
|
||||||
|
s.positionUpdatedAt = updatedAt;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void nothingToShow() {
|
||||||
|
assertNull(NowPlaying.pick(null));
|
||||||
|
assertNull(NowPlaying.pick(Collections.emptyList()));
|
||||||
|
// Bluetooth's empty session after the phone stopped, and one that only connects.
|
||||||
|
assertNull(NowPlaying.pick(Arrays.asList(
|
||||||
|
session("com.android.bluetooth", 1, "", "", 5),
|
||||||
|
session("com.android.car.radio", 8, "Radio", null, 6),
|
||||||
|
session("com.android.bluetooth", NowPlaying.STATE_PLAYING, " ", null, 7))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void whatPlaysWinsOverWhatIsPaused() {
|
||||||
|
// As on the car on 2026-10-28: CarPlay paused, Bluetooth playing.
|
||||||
|
NowPlaying.Track t = NowPlaying.pick(Arrays.asList(
|
||||||
|
session("com.allgo.remoteui.mediabrowserservice", NowPlaying.STATE_PAUSED, "A Mix", "Bass Bangers", 900),
|
||||||
|
session("com.android.bluetooth", NowPlaying.STATE_PLAYING, "Paramount", "Mark Petrie & Andrew Prahlow", 100)));
|
||||||
|
assertEquals(NowPlaying.Source.BLUETOOTH, t.source);
|
||||||
|
assertEquals("Paramount", t.title);
|
||||||
|
assertEquals("Mark Petrie & Andrew Prahlow", t.subtitle);
|
||||||
|
assertTrue(t.playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void ofTwoPlayingTheNewerWins() {
|
||||||
|
NowPlaying.Track t = NowPlaying.pick(Arrays.asList(
|
||||||
|
session("com.android.bluetooth", NowPlaying.STATE_PLAYING, "Old", null, 100),
|
||||||
|
session("com.allgo.remoteui.mediabrowserservice", NowPlaying.STATE_BUFFERING, "New", null, 200)));
|
||||||
|
assertEquals(NowPlaying.Source.CARPLAY, t.source);
|
||||||
|
assertEquals("New", t.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void withNothingPlayingThePausedOneShows() {
|
||||||
|
NowPlaying.Track t = NowPlaying.pick(Arrays.asList(
|
||||||
|
session("com.android.bluetooth", NowPlaying.STATE_PAUSED, "Earlier", null, 100),
|
||||||
|
session("com.allgo.remoteui.mediabrowserservice", NowPlaying.STATE_PAUSED, "Later", null, 300)));
|
||||||
|
assertEquals("Later", t.title);
|
||||||
|
assertFalse(t.playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theRadioShowsItsTextWithTheStationBelow() {
|
||||||
|
NowPlaying.Track t = NowPlaying.pick(Collections.singletonList(
|
||||||
|
session("com.saicmotor.radio", NowPlaying.STATE_PLAYING, "Brillux Radio", "BLIND MELON - NO RAIN", 1)));
|
||||||
|
assertEquals(NowPlaying.Source.RADIO, t.source);
|
||||||
|
assertEquals("BLIND MELON - NO RAIN", t.title);
|
||||||
|
assertEquals("Brillux Radio", t.subtitle);
|
||||||
|
NowPlaying.Track station = NowPlaying.pick(Collections.singletonList(
|
||||||
|
session("com.saicmotor.radio", NowPlaying.STATE_PLAYING, "Brillux Radio", "", 1)));
|
||||||
|
assertEquals("Brillux Radio", station.title);
|
||||||
|
assertNull(station.subtitle);
|
||||||
|
assertEquals(-1f, station.progressAt(5), 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textIsCleaned() {
|
||||||
|
assertEquals("A Letter to the Past (A Seasonal Feels Mix) Ft. Porter Robinson & Friends By SAINT",
|
||||||
|
NowPlaying.clean("A Letter to the Past (A Seasonal Feels Mix) Ft. Porter Robinson & Friends By SAINT [X4nayMeMBuQ]"));
|
||||||
|
assertEquals("Live [Remastered 2011]", NowPlaying.clean("Live [Remastered 2011]"));
|
||||||
|
assertEquals("two lines", NowPlaying.clean(" two\n lines "));
|
||||||
|
assertNull(NowPlaying.clean(" "));
|
||||||
|
assertNull(NowPlaying.clean(null));
|
||||||
|
StringBuilder longText = new StringBuilder();
|
||||||
|
for (int i = 0; i < 300; i++) longText.append('x');
|
||||||
|
assertEquals(200, NowPlaying.clean(longText.toString()).length());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onlyAnArtistStillShows() {
|
||||||
|
NowPlaying.Track t = NowPlaying.pick(Collections.singletonList(
|
||||||
|
session("com.example.player", NowPlaying.STATE_PLAYING, null, "Someone", 1)));
|
||||||
|
assertEquals(NowPlaying.Source.OTHER, t.source);
|
||||||
|
assertEquals("Someone", t.title);
|
||||||
|
assertNull(t.subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void thePositionRunsOnWhilePlayingAndStopsAtTheEnd() {
|
||||||
|
NowPlaying.Session s = session("com.android.bluetooth", NowPlaying.STATE_PLAYING, "Paramount", "M", 10_000);
|
||||||
|
s.positionMs = 3_000;
|
||||||
|
s.durationMs = 215_000;
|
||||||
|
NowPlaying.Track t = NowPlaying.pick(Collections.singletonList(s));
|
||||||
|
assertEquals(3_000, t.positionAt(10_000));
|
||||||
|
assertEquals(8_000, t.positionAt(15_000));
|
||||||
|
assertEquals(215_000, t.positionAt(10_000_000));
|
||||||
|
assertEquals(1f, t.progressAt(10_000_000), 0f);
|
||||||
|
assertEquals(8_000 / 215_000f, t.progressAt(15_000), 1e-6f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aPausedPositionStaysPut() {
|
||||||
|
NowPlaying.Session s = session("com.android.bluetooth", NowPlaying.STATE_PAUSED, "Paramount", null, 10_000);
|
||||||
|
s.positionMs = 3_000;
|
||||||
|
s.durationMs = 215_000;
|
||||||
|
assertEquals(3_000, NowPlaying.pick(Collections.singletonList(s)).positionAt(99_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void noPositionNoBar() {
|
||||||
|
NowPlaying.Session s = session("com.android.bluetooth", NowPlaying.STATE_PLAYING, "Paramount", null, 10_000);
|
||||||
|
s.durationMs = 215_000;
|
||||||
|
assertEquals(-1f, NowPlaying.pick(Collections.singletonList(s)).progressAt(20_000), 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void sourcesAreKnownByTheirApps() {
|
||||||
|
assertEquals(NowPlaying.Source.BLUETOOTH, NowPlaying.source("com.android.bluetooth"));
|
||||||
|
assertEquals(NowPlaying.Source.CARPLAY, NowPlaying.source("com.allgo.remoteui.mediabrowserservice"));
|
||||||
|
assertEquals(NowPlaying.Source.RADIO, NowPlaying.source("com.saicmotor.radio"));
|
||||||
|
assertEquals(NowPlaying.Source.OTHER, NowPlaying.source("com.android.music"));
|
||||||
|
assertEquals(NowPlaying.Source.OTHER, NowPlaying.source(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.ConnectException;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLHandshakeException;
|
||||||
|
|
||||||
|
public class UpdateDiagnosisTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void errorsAreToldApart() {
|
||||||
|
assertTrue(UpdateDiagnosis.failure(new UnknownHostException()).contains("no data connection"));
|
||||||
|
assertTrue(UpdateDiagnosis.failure(new SocketTimeoutException()).contains("in time"));
|
||||||
|
assertTrue(UpdateDiagnosis.failure(new ConnectException()).contains("no connection"));
|
||||||
|
assertTrue(UpdateDiagnosis.failure(new SSLHandshakeException("x")).contains("in between"));
|
||||||
|
assertTrue(UpdateDiagnosis.failure(new IOException("Release API returned 401")).contains("token"));
|
||||||
|
assertTrue(UpdateDiagnosis.failure(new IOException("Invalid release response")).contains("not a list"));
|
||||||
|
assertEquals("unknown failure", UpdateDiagnosis.failure(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anErrorsOwnTextNeverReachesTheLog() {
|
||||||
|
String secret = "https://car.example.com/v1/app/releases?token=abc";
|
||||||
|
for (Throwable t : Arrays.asList(new UnknownHostException(secret), new SocketTimeoutException(secret),
|
||||||
|
new ConnectException(secret), new SSLHandshakeException(secret), new IOException(secret),
|
||||||
|
new RuntimeException(secret))) {
|
||||||
|
String line = UpdateDiagnosis.failure(t);
|
||||||
|
assertFalse(line, line.contains("example"));
|
||||||
|
assertFalse(line, line.contains("abc"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void statusesSayWhoIsAtFault() {
|
||||||
|
assertTrue(UpdateDiagnosis.status(302).contains("SIM without data"));
|
||||||
|
assertTrue(UpdateDiagnosis.status(401).contains("token"));
|
||||||
|
assertTrue(UpdateDiagnosis.status(403).contains("token"));
|
||||||
|
assertTrue(UpdateDiagnosis.status(404).contains("does not know"));
|
||||||
|
assertTrue(UpdateDiagnosis.status(502).contains("forge"));
|
||||||
|
assertTrue(UpdateDiagnosis.status(418).contains("HTTP 418"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onlyA204IsTheInternetWorking() {
|
||||||
|
assertTrue(UpdateDiagnosis.internet(204).startsWith("the internet works"));
|
||||||
|
assertTrue(UpdateDiagnosis.internet(200).contains("in between"));
|
||||||
|
assertTrue(UpdateDiagnosis.internet(302).contains("SIM without data"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aNetworkInOneLine() {
|
||||||
|
assertEquals("cellular, in use, offers internet, not confirmed by Android, roaming",
|
||||||
|
UpdateDiagnosis.network(Collections.singletonList("cellular"), true, false, true, true));
|
||||||
|
assertEquals("unknown type, no internet, confirmed by Android",
|
||||||
|
UpdateDiagnosis.network(Collections.emptyList(), false, true, false, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
android:name="android.permission.MEDIA_CONTENT_CONTROL"
|
android:name="android.permission.MEDIA_CONTENT_CONTROL"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|
||||||
|
<!-- An update check writes down which networks Android sees (UpdateNetworkCheck). -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<!-- The Logs tab's sound check (SoundProbe): Android's audio analyser asks for both. The
|
<!-- The Logs tab's sound check (SoundProbe): Android's audio analyser asks for both. The
|
||||||
system user holds them without a prompt; the analyser reports levels only, no sound. -->
|
system user holds them without a prompt; the analyser reports levels only, no sound. -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ final class OtaUpdater {
|
|||||||
private static final String TAG = "OtaUpdater";
|
private static final String TAG = "OtaUpdater";
|
||||||
private static final String CACHE_PREFIX = "NilsMG4-ota-";
|
private static final String CACHE_PREFIX = "NilsMG4-ota-";
|
||||||
|
|
||||||
|
/** Why the last download failed, for the update log. */
|
||||||
|
static volatile String downloadProblem = "unknown";
|
||||||
|
|
||||||
/** The release list on the car server, for the server configured in the app. */
|
/** The release list on the car server, for the server configured in the app. */
|
||||||
static String releasesUrl(ServerConfig config) {
|
static String releasesUrl(ServerConfig config) {
|
||||||
return config.url + "/v1/app/releases";
|
return config.url + "/v1/app/releases";
|
||||||
@@ -366,6 +369,7 @@ final class OtaUpdater {
|
|||||||
connection.setRequestProperty("User-Agent", "NilsMG4-Android");
|
connection.setRequestProperty("User-Agent", "NilsMG4-Android");
|
||||||
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
|
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
|
||||||
Log.w(TAG, "Update download returned " + connection.getResponseCode());
|
Log.w(TAG, "Update download returned " + connection.getResponseCode());
|
||||||
|
downloadProblem = UpdateDiagnosis.status(connection.getResponseCode());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
long total = connection.getContentLengthLong();
|
long total = connection.getContentLengthLong();
|
||||||
@@ -390,10 +394,14 @@ final class OtaUpdater {
|
|||||||
out.getFD().sync();
|
out.getFD().sync();
|
||||||
}
|
}
|
||||||
ok = hex(digest.digest()).equalsIgnoreCase(expectedSha256);
|
ok = hex(digest.digest()).equalsIgnoreCase(expectedSha256);
|
||||||
if (!ok) Log.w(TAG, "Update APK does not match its SHA-256");
|
if (!ok) {
|
||||||
|
Log.w(TAG, "Update APK does not match its SHA-256");
|
||||||
|
downloadProblem = "it does not match its SHA-256";
|
||||||
|
}
|
||||||
return ok ? target : null;
|
return ok ? target : null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "Update download failed: " + e.getClass().getSimpleName());
|
Log.w(TAG, "Update download failed: " + e.getClass().getSimpleName());
|
||||||
|
downloadProblem = UpdateDiagnosis.failure(e);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (connection != null) connection.disconnect();
|
if (connection != null) connection.disconnect();
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import android.util.Log;
|
|||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +63,8 @@ final class UpdateHook {
|
|||||||
"Update available: " + update.versionName,
|
"Update available: " + update.versionName,
|
||||||
android.widget.Toast.LENGTH_LONG).show());
|
android.widget.Toast.LENGTH_LONG).show());
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.w(TAG, "Startup update check failed", t);
|
Log.w(TAG, "Startup update check failed: " + t.getClass().getSimpleName());
|
||||||
|
UpdateLog.record(app, "Check at start: " + UpdateDiagnosis.failure(t));
|
||||||
}
|
}
|
||||||
}, "ota-startup-check").start();
|
}, "ota-startup-check").start();
|
||||||
}
|
}
|
||||||
@@ -78,24 +84,46 @@ final class UpdateHook {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
|
long startedMs = System.currentTimeMillis();
|
||||||
|
List<String> steps = new ArrayList<>();
|
||||||
|
step(app, steps, "Check started, app " + BuildConfig.VERSION_NAME);
|
||||||
String message;
|
String message;
|
||||||
try {
|
try {
|
||||||
|
for (String network : UpdateNetworkCheck.networks(app)) step(app, steps, network);
|
||||||
|
ServerConfig config = ServerConfig.get(app);
|
||||||
|
String host = config.isConfigured() ? OtaUpdater.allowedHost(config) : null;
|
||||||
|
if (host != null) step(app, steps, UpdateNetworkCheck.lookup(host));
|
||||||
message = runCheck(app, listener, main);
|
message = runCheck(app, listener, main);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.w(TAG, "Update check threw", t);
|
Log.w(TAG, "Update check threw: " + t.getClass().getSimpleName());
|
||||||
// The class only: a network error's text can carry the server's address.
|
message = "Update check failed: " + UpdateDiagnosis.failure(t);
|
||||||
message = "Update check failed (" + t.getClass().getSimpleName() + ")";
|
// Is it the server or the connection? The answer to "is my SIM out of data".
|
||||||
|
step(app, steps, UpdateNetworkCheck.internet());
|
||||||
} finally {
|
} finally {
|
||||||
checking.set(false);
|
checking.set(false);
|
||||||
}
|
}
|
||||||
UpdateLog.record(app, "Check: " + message);
|
step(app, steps, "Check: " + message);
|
||||||
|
String waiting = LogOutbox.status(app);
|
||||||
|
if (waiting != null) step(app, steps, "Reports: " + waiting);
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
final String result = message;
|
final String result = message;
|
||||||
main.post(() -> listener.onResult(result));
|
main.post(() -> listener.onResult(result));
|
||||||
}
|
}
|
||||||
|
// After the answer: without a connection this waits for the send to fail.
|
||||||
|
String name = "update-check-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||||
|
.format(new Date(startedMs)) + ".txt";
|
||||||
|
// Only the newest waits: repeated checks offline must not push out a recording.
|
||||||
|
LogOutbox.dropWaiting(app, "update-check-");
|
||||||
|
SignalRecorder.saveCopies(app, "update_check.txt", name, steps);
|
||||||
}, "ota-check").start();
|
}, "ota-check").start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One step of a check: on the Logs tab now, and in the report saved when it ends. */
|
||||||
|
private static void step(Context app, List<String> steps, String what) {
|
||||||
|
UpdateLog.record(app, what);
|
||||||
|
steps.add(what);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs the newest car build from the top folder of a USB stick (UsbUpdate), with the
|
* Installs the newest car build from the top folder of a USB stick (UsbUpdate), with the
|
||||||
* same checks as the over-the-air update except the server. Background thread; [listener]
|
* same checks as the over-the-air update except the server. Background thread; [listener]
|
||||||
@@ -195,14 +223,14 @@ final class UpdateHook {
|
|||||||
try {
|
try {
|
||||||
expectedHash = OtaUpdater.fetchExpectedSha256(update, config);
|
expectedHash = OtaUpdater.fetchExpectedSha256(update, config);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, "OTA hash unavailable: " + e.getMessage());
|
Log.w(TAG, "OTA hash unavailable: " + e.getClass().getSimpleName());
|
||||||
return "Update refused: no SHA-256 verification";
|
return "Update refused, no SHA-256 verification: " + UpdateDiagnosis.failure(e);
|
||||||
}
|
}
|
||||||
File apk = OtaUpdater.download(update, expectedHash, config, percent -> {
|
File apk = OtaUpdater.download(update, expectedHash, config, percent -> {
|
||||||
if (listener != null) main.post(() -> listener.onProgress(percent));
|
if (listener != null) main.post(() -> listener.onProgress(percent));
|
||||||
});
|
});
|
||||||
if (apk == null) return "Download or SHA-256 verification failed ("
|
if (apk == null) return "Download or SHA-256 verification failed ("
|
||||||
+ update.versionName + ")";
|
+ update.versionName + "): " + OtaUpdater.downloadProblem;
|
||||||
|
|
||||||
switch (OtaUpdater.install(app, apk)) {
|
switch (OtaUpdater.install(app, apk)) {
|
||||||
case SESSION_STARTED:
|
case SESSION_STARTED:
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.net.ConnectivityManager;
|
||||||
|
import android.net.Network;
|
||||||
|
import android.net.NetworkCapabilities;
|
||||||
|
import android.net.NetworkInfo;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an update check writes down about the connection before and after it fails: the networks
|
||||||
|
* Android sees, whether the server's name can be looked up, and whether the internet answers at
|
||||||
|
* all, which tells a SIM without data from a server that is down. Blocking: background thread.
|
||||||
|
* Never logs the server's address or anything about the car. — UNSTABLE BUILDS ONLY.
|
||||||
|
*/
|
||||||
|
final class UpdateNetworkCheck {
|
||||||
|
|
||||||
|
/** Android's own connectivity test, as Android itself uses it; no data about the car is sent. */
|
||||||
|
private static final String INTERNET_TEST = "https://www.gstatic.com/generate_204";
|
||||||
|
private static final int TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
private UpdateNetworkCheck() {}
|
||||||
|
|
||||||
|
/** One line per network, or one saying there is none. */
|
||||||
|
static List<String> networks(Context app) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
ConnectivityManager cm = (ConnectivityManager) app.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
|
if (cm == null) {
|
||||||
|
out.add("Network: no connectivity service");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
Network active = cm.getActiveNetwork();
|
||||||
|
if (active == null) out.add("Network: none in use");
|
||||||
|
for (Network network : cm.getAllNetworks()) {
|
||||||
|
NetworkCapabilities caps = cm.getNetworkCapabilities(network);
|
||||||
|
if (caps == null) continue;
|
||||||
|
NetworkInfo info = cm.getNetworkInfo(network);
|
||||||
|
out.add("Network: " + UpdateDiagnosis.network(transports(caps),
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
|
||||||
|
network.equals(active), info != null && info.isRoaming()));
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
out.add("Network: unreadable (" + e.getClass().getSimpleName() + ")");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> transports(NetworkCapabilities caps) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) out.add("cellular");
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) out.add("Wi-Fi");
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) out.add("ethernet");
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) out.add("Bluetooth");
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) out.add("VPN");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the host's name resolves, without saying the name. */
|
||||||
|
static String lookup(String host) {
|
||||||
|
long started = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
InetAddress.getByName(host);
|
||||||
|
return "Server's name: found in " + (System.currentTimeMillis() - started) + " ms";
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
return "Server's name: " + UpdateDiagnosis.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the world outside the car server answers. */
|
||||||
|
static String internet() {
|
||||||
|
HttpURLConnection conn = null;
|
||||||
|
try {
|
||||||
|
conn = (HttpURLConnection) new URL(INTERNET_TEST).openConnection();
|
||||||
|
conn.setInstanceFollowRedirects(false);
|
||||||
|
conn.setConnectTimeout(TIMEOUT_MS);
|
||||||
|
conn.setReadTimeout(TIMEOUT_MS);
|
||||||
|
return "Internet test: " + UpdateDiagnosis.internet(conn.getResponseCode());
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
return "Internet test: " + UpdateDiagnosis.failure(e);
|
||||||
|
} finally {
|
||||||
|
if (conn != null) conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user