43 Commits
Author SHA1 Message Date
nilsandClaude Sonnet 5.5 957638208d SPECS: app 2.51.0 published
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 11:01:21 +01:00
nilsandClaude Sonnet 5.5 142f016174 App 2.51.0: the Caterpillar after the book's newer pictures
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 11:01:09 +01:00
nilsandClaude Sonnet 5.5 3cad9499fb SPECS: app 2.50.0 published
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 10:22:51 +01:00
nilsandClaude Sonnet 5.5 60a31574a3 App 2.50.0: the Caterpillar crawls while you drive
A hump runs from the tail to the head, lifted feet swing forward while
the others stay planted, and the branch it walks on slides by with the
speed. Parked, it settles into its high arch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 10:22:43 +01:00
nilsandClaude Sonnet 5.5 3a9b6e17c8 SPECS: app 2.49.1 published
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 10:18:21 +01:00
nilsandClaude Sonnet 5.5 16b11405c8 App 2.49.1: the Caterpillar looks more like the one in the book
It arches over the middle like the cover's: overlapping chunks of
mottled green paper, a yellow fringe, olive feet, and a red-orange head
with a yellow face and purple antennae. Still an original drawing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 10:18:15 +01:00
nilsandClaude Sonnet 5.5 3bb0d5eb71 SPECS: app 2.49.0 published
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 10:07:09 +01:00
nilsandClaude Sonnet 5.5 bf6038fd37 App 2.49.0: the Caterpillar style
A cut-paper collage after a picture book about a hungry caterpillar:
the caterpillar is the speed and power, the sun the battery, the story's
fruit the range. The music light bounces the sun and the segments. A
butterfly or a ladybird tows the song on tissue paper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 10:07:02 +01:00
nilsandClaude Sonnet 5.5 5cbd6699aa SPECS: app 2.48.0 published
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 09:13:22 +01:00
nilsandClaude Sonnet 5.5 ff204caa52 App 2.48.0: the Christmas style
A tree with a bauble for the speed, fairy lights for the power, a mug of
cocoa for the battery, a wreath clock and candy canes for the range. Its
lights flare on the beat with the music light, now "Light follows the
music" for Halloween and Christmas. A sleigh or an angel tows the song.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-07 09:13:08 +01:00
nilsandClaude Sonnet 5.5 69ccf0d5fd SPECS: app 2.47.0 published
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 22:20:17 +01:00
nilsandClaude Sonnet 5.5 d80221db15 App 2.47.0: now playing in Quest, Empire, Terminal and Sci-fi
Quest: a dragon tows a scroll. Empire: an eagle flies one. Terminal: a
PLAYING row. Sci-fi: the song in black on the top bar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 22:20:11 +01:00
nilsandClaude Sonnet 5.5 83dc8b231d SPECS: app 2.46.0 published
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 17:48:49 +01:00
nilsandClaude Sonnet 5.5 cdf78c6d32 App 2.46.0: Día de Muertos shows what plays
A strip of papel picado with the song waves across the sky, towed by a
monarch butterfly and, on the next pass, an alebrije. One flag colour per
song. The banner moved into the shared SongBanner; Halloween is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 17:48:35 +01:00
nilsandClaude Opus 5.5 a445fef407 SPECS: app 2.45.2 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 14:45:41 +01:00
nilsandClaude Opus 5.5 f9813c2afd App 2.45.2: Halloween's song banner waves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 14:45:35 +01:00
nilsandClaude Opus 5.5 3a532cd498 SPECS: app 2.45.1 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 14:33:20 +01:00
nilsandClaude Opus 5.5 06a61282a6 App 2.45.1: Halloween's song flies across the sky on a bat's banner
Instead of a line at the foot of the hill, a bat tows a parchment
banner with the title and artist through the sky above the sign.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 14:33:14 +01:00
nilsandClaude Opus 5.5 ed32fbbc1f SPECS: app 2.45.0 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 14:03:44 +01:00
nilsandClaude Opus 5.5 91283093f5 App 2.45.0: now playing on the Overview
NowPlayingWatcher follows the head unit's media sessions (listening
only, never connecting to a player); NowPlaying picks what plays and
cleans the text. Tiles shows source, title, artist, cover and progress
under the battery; Halloween carves title and artist into the hill.
Settings: "Show what's playing", on by default. Nothing is logged or
sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 14:03:26 +01:00
nilsandClaude Opus 5.5 bdb3f5e765 SPECS: app 2.44.0 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 13:15:41 +01:00
nilsandClaude Opus 5.5 9d84dc6d15 App 2.44.0: the pumpkin light follows the kick drum, behind a switch
MusicPulse reads 40-120 Hz and lights only on jumps of the bass, so
held notes and bass lines stay dark. Settings gets "Pumpkin light
follows the music" (Halloween only, off by default); off, the audio
analyser never starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-02 13:15:25 +01:00
nilsandClaude Opus 5.5 418883beb0 SPECS: app 2.43.5 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-01 10:21:26 +01:00
nilsandClaude Opus 5.5 e52d7f01fc App 2.43.5: update checks log what happens
Each check writes down the networks Android sees, the server's name lookup,
how a failure happened and how many reports wait, and on a failure whether
the internet answers at all, to tell a SIM without data from a server that is
down. The report goes to a USB stick and, once online, the server. No address
or token is written. Tried on the emulator. Not tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-01 10:21:15 +01:00
nilsandClaude Opus 5.5 6b55bec419 SPECS: app 2.43.4 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-01 10:08:03 +01:00
nilsandClaude Opus 5.5 fd38c37ebe App 2.43.4: the Halloween pumpkin's light follows the music
The bass from Android's audio analyser drives the glow and brightens the
carving on the beat, only while the style is on screen. Yellow and green and
the embers keep their meaning; without a signal, as with the radio, the light
stays as it was. Levels only. Tried on the emulator. Not tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-01 10:07:01 +01:00
nilsandClaude Opus 5.5 ea9763e1de SPECS: the sound check does not hear the radio
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 23:55:56 +01:00
nilsandClaude Opus 5.5 97e37c8a3c SPECS: app 2.43.3 published; the radio finding put right
The owner only meant today's check had no radio, so the 29 October one
stands: the radio's session played with music not active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 17:10:27 +01:00
nilsandClaude Opus 5.5 744d52f322 SPECS: the radio check does not show the analyser cannot hear it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 16:57:49 +01:00
nilsandClaude Opus 5.5 0fe7897340 SPECS: what the car's music checks say about radio and CarPlay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 16:56:02 +01:00
nilsandClaude Opus 5.5 f84e18ef0e App 2.43.3: sound check on the Logs tab
One tap listens for 10 seconds with Android's audio analyser on the output
mix and reports the levels, to learn whether it hears Bluetooth, CarPlay and
radio before the Halloween pumpkin's light follows the music. Levels only, no
sound kept, no microphone. Heard a test tone on the emulator. Not tried on
the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 13:17:41 +01:00
nilsandClaude Opus 5.5 024e40cd01 SPECS: app 2.43.2 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 13:10:51 +01:00
nilsandClaude Opus 5.5 ee7cf85255 App 2.43.2: Halloween's teeth are triangles in the right colour
They hang from the grin's upper lip, each in the colour of the rib behind
it: the right one was the darker orange on the lighter rib. Not tried on
the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 13:06:35 +01:00
nilsandClaude Opus 5.5 1686b791bc SPECS: app 2.43.1 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 12:49:17 +01:00
nilsandClaude Opus 5.5 12752c41e2 App 2.43.1: Halloween's bats can be seen
They were near-black and small on the darkest part of the sky. Now a
moonlit purple-grey with a light edge, a third larger. Not tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 12:43:10 +01:00
nilsandClaude Opus 5.5 293464e297 SPECS: app 2.43.0 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 09:27:42 +01:00
nilsandClaude Opus 5.5 adc2c86303 App 2.43.0: Overview styles Día de Muertos and Halloween
Día de Muertos is an ofrenda: papel picado for the battery, a calavera
with the speed in a ring of marigolds for the power, candles for the
range, a path of petals for the last two minutes of power. Halloween is
a graveyard hill: a jack-o'-lantern with the speed in its grin, embers
for the power, a potion flask for the battery, a tombstone and bats for
the range. Each has its own palette; Tally counts what a value lights.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-31 09:24:56 +01:00
nilsandClaude Opus 5.5 1f49cdc410 SPECS: app 2.42.1 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-28 22:15:39 +01:00
nilsandClaude Opus 5.5 9de9387460 App 2.42.1: the music check connects to no other app
In the car, 2.42.0's check stopped the Bluetooth music and raised a
media-access prompt: binding every media browser service started the
car radio and the Local Media Player. The services are now only listed;
the active media sessions already carry the song of Bluetooth and
CarPlay without any connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-28 15:18:58 +01:00
nilsandClaude Opus 5.5 f9154c051f SPECS: app 2.42.0 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-28 09:45:53 +01:00
nilsandClaude Opus 5.5 6563b2c4e8 App 2.42.0: music-sources check on the Logs tab
Before a now-playing display, find where the head unit tells what is
playing, read only: the players Android reports, the active media
sessions, and every media browser service with its song, CarPlay's
(com.allgo.remoteui.mediabrowserservice) among them. The report is saved
and sent like a signal recording (shared SignalRecorder.saveCopies).
Also the findings of the first two recordings from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-28 09:31:45 +01:00
nilsandClaude Opus 5.5 8596951cef Skill car-logs: fetch and read the car's recordings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 10:36:31 +01:00
nilsandClaude Opus 5.5 33e8fe6e05 Server 2.9.0 pinned; SPECS: app 2.41.0 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 10:29:01 +01:00
60 changed files with 5507 additions and 30 deletions
+95
View File
@@ -0,0 +1,95 @@
---
name: car-logs
description: Download the signal recordings the MG4 sent to the car server (fetch-logs) and read them. Use when the owner says a recording is on the server, asks to get, download or look at the car's logs or recordings, or after a test drive with the signal recorder.
---
# Fetch and read the car's recordings
The app's signal recorder (Logs tab, `SignalRecorder`) sends each finished recording to the
car server (`LogOutbox`, `PUT /v1/logs/{name}`); the server keeps the newest 200
(`server/logs.go`). `fetch-logs` in the dev shell downloads the ones not yet in `logs/`.
## Fetch
```sh
nix develop -c fetch-logs
```
It prints each new file (`logs/signal-recording-YYYYMMDD-HHMMSS.txt`) and `fetch-logs: N new`.
The token comes from `~/.config/nilsmg4/logs-token` (the server's `LOGS_TOKEN`) and goes only in
a header.
- **Never print, cat or echo the token**, and never pass it on a command line you show. Do not
read the token file.
- `no token`: ask the owner to put the server's `LOGS_TOKEN` in `~/.config/nilsmg4/logs-token`
(and in `k8s/secrets/secrets.yaml`, then apply it). Do not create or fill it yourself.
- HTTP 401: the file and the server's Secret differ. HTTP 404: reading is off (no
`LOGS_TOKEN` on the server) or the server is older than 2.9.0. The owner applies manifests and
Secrets; never run `kubectl` against the cluster, not even read-only.
- `0 new`: nothing arrived. In the car, the Logs tab says "Sent to the server." or how many wait
and why (no server set up, server not reached, an HTTP status); the car retries every 2
minutes while the service runs.
## Read
The files are plain text but `file` may call them data; search with `rg -a`. Newest first:
`ls -t logs/`. If the owner said what they did and when (e.g. "ACC on at 08:50, set speed
100→90 at 08:51"), line those times up with the timeline.
A recording has four parts, in this order:
1. Header: captures, start time, app version.
2. **Steering-wheel keys and notes.** The first note says whether listening worked:
`listening to N assistance properties (M refused)`, or `not possible (<reason>)`.
3. **Assistance properties heard** (`EVHardware.listenToProperties` on `AssistBlock`, the
`0x21x0A1xx` block): change events, properties whose value changed first. This is where a
value shows up that the head unit fills only while someone listens.
4. **Changed values (polled)**, rarest first, then **Everything, in order**: every capture
(about every 5 s) with what changed since the last.
Useful cuts:
```sh
rg -a 'listening to' logs/<file> # did listening work
awk '/^Assistance properties heard/,/^$/' logs/<file> # what listening heard
rg -a 'A15B|A15A' logs/<file> # one property's history
rg -a 'getLatestSpeed' logs/<file> | head # the car's speed over time
```
Known leads (SPECS.md, 2026-10-27): `0x2160A15B` is `AAD_EPTCCTRGTSPD`, by its name the cruise
control's target speed (0.0 when only polled); `0x2140A15A` moved between 3 and 7;
`0x2160A1A3`/`0x2160A1A4` counted up by 0.1. A value that tracks what the owner did at the
times they name is a lead; say how sure it is, and that it is not confirmed until a second
drive shows the same.
## Music-sources checks
`fetch-logs` also brings `media-probe-YYYYMMDD-HHMMSS.txt` (Logs tab, "Check music sources",
`MediaProbe`). Three parts: audio players (use, app, state), active media sessions with state
and song (TITLE, ARTIST, ALBUM, ALBUM_ART as a size or ALBUM_ART_URI), and the media browser
services by name (2.42.1 on; 2.42.0 also connected to them, which stopped the music). CarPlay's
session is `com.allgo.remoteui.mediabrowserservice`, Bluetooth's `com.android.bluetooth`. Ask what was playing
when the check ran.
## Sound checks
`fetch-logs` also brings `sound-probe-YYYYMMDD-HHMMSS.txt` (Logs tab, "Check sound",
`SoundProbe`, 2.43.3 on): permissions, which session plays, then one line a second of the audio
analyser's peak, bass, mid, treble and rms, and a result line. Levels only, no sound. The peak has
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.
## 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
- Recordings and checks are the car's data: `logs/`, `/signal-recording-*.txt` and
`/media-probe-*.txt`, `/sound-probe-*.txt`, `/update-check-*.txt` are gitignored. Never
commit, publish or upload them anywhere.
- Findings that change what the project knows go into a `SPECS.md` done-log entry, as usual.
+3
View File
@@ -45,3 +45,6 @@ mise.local.toml
# The car's own recordings (fetch-logs, or copied off a USB stick): its data, not the code's. # The car's own recordings (fetch-logs, or copied off a USB stick): its data, not the code's.
/logs/ /logs/
/signal-recording-*.txt /signal-recording-*.txt
/media-probe-*.txt
/sound-probe-*.txt
/update-check-*.txt
+3 -3
View File
@@ -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`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; 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, Terminal, Sci-fi, Quest, Empire, Día de Muertos, Halloween, Christmas and Caterpillar, 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), Christmas (`ChristmasView`, a tree with a bauble, fairy lights, a mug of cocoa, a wreath clock and candy canes in the snow), Caterpillar (`CaterpillarView`, cut-paper collage after a picture book: the caterpillar is speed and power, the sun the battery, fruit the range), all counting with `Tally`, the pumpkin's light, Christmas's tree lights and the Caterpillar's sun flaring on the kick drum while on screen, when "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, Christmas, Caterpillar; 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), 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
@@ -111,7 +111,7 @@ nix develop # JDK 21, Android SDK, Go
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug ./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
build-car # build/car/NilsMG4-unstable-<version>.apk + .sha256 build-car # build/car/NilsMG4-unstable-<version>.apk + .sha256
publish-car # pre-release car-<version> on the forge (needs a token) publish-car # pre-release car-<version> on the forge (needs a token)
fetch-logs # the car's recordings from the server into logs/ (needs LOGS_TOKEN) fetch-logs # the car's recordings from the server into logs/ (needs LOGS_TOKEN; skill car-logs)
cd server && go vet ./... && go test ./... && govulncheck ./... cd server && go vet ./... && go test ./... && govulncheck ./...
``` ```
+5 -1
View File
@@ -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`
+3 -3
View File
@@ -39,7 +39,7 @@ APIs.
## Screenshots ## Screenshots
**Overview styles:** the Overview comes in eighteen looks, from an 80s fluorescent display to a **Overview styles:** the Overview comes in twenty looks, from an 80s fluorescent display to a
starship console, a watch face, a neon grid (also over a map of the roads round the car), a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the starship console, a watch face, a neon grid (also over a map of the roads round the car), a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the
navigation, and what inspired it. navigation, and what inspired it.
@@ -89,13 +89,13 @@ 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 |
| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import | | **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
| **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About | | **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About |
| **Logs** | Signal recorder (records every value the head unit offers for 3 minutes, listens to the driver-assistance values for every change they report, and lists what changed, to find where the car publishes something; sent to your server, and saved to a USB stick too), update log (every install step), upload log | | **Logs** | Signal recorder (records every value the head unit offers for 3 minutes, listens to the driver-assistance values for every change they report, and lists what changed, to find where the car publishes something; sent to your server, and saved to a USB stick too), music-sources check (where the head unit tells what is playing, CarPlay included; read only), update log (every install step), upload log |
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the - **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
voice key** on the steering wheel and the app comes to the front (switch on the Settings voice key** on the steering wheel and the app comes to the front (switch on the Settings
+97
View File
@@ -140,6 +140,103 @@ 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-07 — App 2.51.0 published (`car-2.51.0`).
- 2026-11-07 — App 2.51.0: **the Caterpillar after the book's newer pictures** (owner sent a 3D picture of it). Plump round segments shaded like balls of wound paper (radial light from the upper left) with brush strokes in light green and teal, bigger towards the head, a fringe of yellow and orange bristles along the back, stubby brown feet; a big round red head with teal eyes rimmed in yellow and two lilac ears, the speed under the eyes. Crawl, readings and colours (green lit, blue regenerating, dull dark) unchanged. Original drawing, not the artwork. Tested: compiles, emulator screenshots with sample values (`screenshots/themes/caterpillar*.png`). Not tried on the car.
- 2026-11-07 — App 2.50.0 published (`car-2.50.0`).
- 2026-11-07 — App 2.50.0: **the Caterpillar crawls** (owner: "make it so the caterpillar appears to be going when we drive? Using normal caterpillar movement?"). While the car moves it crawls as a caterpillar does: a hump runs from the tail to the head, the body bunches and stretches a little, feet that are lifted swing forward and the others stay planted on a branch whose knots and leaves slide backwards with the speed; the crawl is quicker the faster the car goes (at most 1.2 crawls a second). It does not change its place on screen, so the readings stay put. Parked, it eases back into its high arch (it keeps drawing for that moment only). Two screenshots redone. Tested: unit tests, lint, emulator with sample values (frames 0.25 s apart show the hump travelling and the branch sliding). Not tried on the car.
- 2026-11-07 — App 2.49.1 published (`car-2.49.1`).
- 2026-11-07 — App 2.49.1: **the Caterpillar looks more like the one in the book** (owner: "Make it more like the original caterpillar", with the German edition's cover as the reference). It arches over the middle like the cover's: overlapping chunks of mottled green and teal paper, a yellow fringe along the back, olive feet, and a red-orange head with a yellow face, green eyes and purple antennae; the speed is on the face. Power still lights the segments (blue when regenerating) and the back humps while driving. Still my own drawing of it, not the book's artwork. Two screenshots redone. Tested: unit tests, lint, emulator with sample values. Not tried on the car.
- 2026-11-07 — App 2.49.0 published (`car-2.49.0`).
- 2026-11-07 — App 2.49.0: **the Caterpillar style** (owner: "a theme based on The Very Hungry Caterpillar"; design agreed first, defaults taken: dark night page, named "Caterpillar" not the book's title, fruit as range). `CaterpillarView`, an original cut-paper collage, not the book's illustrations: the caterpillar's red head is the speed and its ten segments the power (blue while regenerating, wiggling while driving); the sun is the battery (a grey moon below 20 %); a leaf holds trip, usage, climate and odometer; a clock in a paper circle; fruit in the story's order (1 apple, 2 pears, 3 plums, 4 strawberries, 5 oranges) is the range, one per 50 km, used ones with a bite out; P R N D are an egg, the caterpillar, a cocoon and a butterfly; hills are the last two minutes of power. The music light (now also for this style) bounces the sun's rays and the segments on the kick drum. What plays: a butterfly and, on the next pass, a ladybird tow a strip of tissue paper (colour fixed by the song); both in `SongCarriers`. Own palette (green). Two screenshots in THEMES.md. Tested: unit tests, lint, emulator with sample values and with the throwaway fake song. Not tried on the car.
- 2026-11-07 — App 2.48.0 published (`car-2.48.0`).
- 2026-11-07 — App 2.48.0: **the Christmas style** (owner: "let's also do a christmas based theme", with music and something that pulsates; design agreed first). `ChristmasView`: a tree with a big red bauble (speed), fairy lights arching over it (power: gold left to right while drawing, ice blue and right to left while regenerating), a glass mug of cocoa filling with the charge (cold blue and "BATTERY LOW" below 20 %), gift tags (trip, usage, climate, odometer), a wreath clock, a signboard (range) with a candy cane per 50 km, P R N D as baubles, and a snow drift of the last two minutes of power; snow falls and the lights twinkle while driving. Own palette (red); always in the drop-down; English only. The music light is now **"Light follows the music"** for Halloween and Christmas (same `PREF_MUSIC_LIGHT`, off by default): the tree's lights and the arch flare on the kick drum. What plays: Santa's sleigh with a reindeer and, on the next pass, an angel with a trumpet tow a strip of wrapping paper (colour fixed by the song); both in `SongCarriers`. Two screenshots in THEMES.md. Tested: unit tests, lint, emulator with sample values (panel and full screen, and with no data), with the throwaway fake song (both carriers), and the music-tap thread runs only with the switch on. Not tried on the emulator: the pulse itself with real sound (the code path is Halloween's `MusicTap`). Not tried on the car.
- 2026-11-02 — App 2.47.0 published (`car-2.47.0`).
- 2026-11-02 — App 2.47.0: **now playing in four more styles** (owner: "let's implement Now playing in more styles"; Spanish labels declined). Quest: a red dragon tows a waving scroll across the top of the sky, behind the frames. Empire: an eagle flies a scroll across the top of the world, behind the speed. Terminal: a PLAYING row above the prompt. Sci-fi: the song in black on the top bar. The creatures are in the new `SongCarriers` (dragon, eagle) beside `SongBanner`. Same switch as before (Settings → "Show what's playing"). Not yet in the rest: Gauges, Cockpit, Retro, Futuristic, MG, Minimal, Nixie, Chronograph, Grid, Grid with map, Time circuits, Visor, Cartoon. Tested: unit tests, lint, emulator with the throwaway fake session. Not tried on the car.
- 2026-11-02 — App 2.46.0 published (`car-2.46.0`).
- 2026-11-02 — App 2.46.0: **Día de Muertos shows what plays** (owner: "Can we also put that into the dia de muertos theme?", then "switch between monarch and alebrije", "one color per song"). A strip of papel picado (zigzag-cut edges) with "♪ title — artist" waves across the sky between the flags and the ring, behind the ring and the clock; a monarch butterfly and an alebrije (a painted horned creature with feathered wings and a curled tail) tow it on alternate passes. The paper's colour is one of the flags', fixed by the song (hash of title and artist, never logged). The banner moved out of `HalloweenView` into the shared `SongBanner`; Halloween looks as before. Same switch as Tiles and Halloween (Settings → "Show what's playing"). Tested: unit tests, lint, emulator with the throwaway fake session. Not tried on the car.
- 2026-11-02 — App 2.45.2 published (`car-2.45.2`).
- 2026-11-02 — App 2.45.2: Halloween's song banner **waves** (owner: "Can it also be a bit wavy?"): waves run from the bat to the tail, still where the bat holds it and up to 7 units at the tail, and the text follows them (`drawTextOnPath`). Tested: emulator with the throwaway fake session, unit tests, lint. Not tried on the car.
- 2026-11-02 — App 2.45.1 published (`car-2.45.1`).
- 2026-11-02 — App 2.45.1: **Halloween's song flies through the sky** (owner: "Instead of taking up screen space on the bottom, can we somehow include the title in the theme itself? ... have text fly through the air"). A bat tows a parchment banner with "♪ title — artist" from left to right across the strip of sky above the sign, behind the moon, one pass about every half minute, again and again while something plays (the title is cut first so the artist stays); the line at the foot of the hill is gone. While a banner flies the view draws about 30 frames a second, as it does while driving. Tested: emulator with the throwaway fake session (banner clear of the sign along the whole pass), unit tests, lint. Not tried on the car.
- 2026-11-02 — App 2.45.0 published (`car-2.45.0`).
- 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-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 — Music checks from the car: the radio publishes a session while it plays (`com.saicmotor.radio`, 29 October, DAB: TITLE the station, ARTIST the programme text, AUTHOR the band), with `isMusicActive` false, so its sound may not pass through Android's mix and the audio analyser may not hear it. The sound check with the radio playing will tell. CarPlay playing (31 October, 2.43.2) has `isMusicActive` true, like Bluetooth on 28 October. The sound check itself has not run on the car yet: 2.43.3 was not published and those reports came from 2.43.2's music-sources check.
- 2026-10-31 — App 2.43.3: **sound check** on the Logs tab (`SoundProbe`, `SoundLevels`), asked for before the Halloween pumpkin's light follows the music (owner: "make the light in the pumpkin react to the equalizer/sound of the music"). One tap: 10 seconds of levels from `Visualizer` on session 0 (peak, bass, mid, treble, rms), saved and sent like a recording; no sound kept, no microphone. New in the car build's manifest: RECORD_AUDIO and MODIFY_AUDIO_SETTINGS. Grounds for trying: Bluetooth played with `isMusicActive` true (28 October check), `libCarPlayPlatformAudio.so` uses AudioTrack and no direct PCM. Whether the analyser hears anything on the car is what this is to find out. Tested: unit tests, lint, emulator (silence says nothing was playing; a pulsing 110 Hz + 1 kHz test tone from another app is heard, peak 0.27–0.91, bass strongest, the tone kept playing, no prompt, no crash). Not tried on the car.
- 2026-10-31 — App 2.43.2 published (`car-2.43.2`).
- 2026-10-31 — App 2.43.2: Halloween's teeth are triangles hanging from the grin's upper lip, each in the colour of the rib behind it (the right one was the darker orange on the lighter rib; owner: "the color on the right side is off"). Two screenshots redone. Tested: unit tests, lint, emulator with sample values (drawing in panel and full screen, regenerating in full screen) and with no data. Not tried on the car.
- 2026-10-31 — App 2.43.1 published (`car-2.43.1`).
- 2026-10-31 — App 2.43.1: Halloween's bats can be seen. They were near-black and small on the darkest part of the sky, and the owner did not spot them; now a moonlit purple-grey with a light edge, a third larger. Two screenshots redone. Tested: unit tests, lint, emulator with sample values (panel and full screen) and with no data (no bats). Not tried on the car.
- 2026-10-31 — App 2.43.0 published (`car-2.43.0`).
- 2026-10-31 — App 2.43.0: Overview styles **Día de Muertos** (`MuertosView`) and **Halloween**
(`HalloweenView`; owner: "let's design a 'dia de muertos' and a 'halloween' style"), each with
its own palette (the papel picado's pink, the pumpkin's orange). Día de Muertos, an ofrenda and
kept a celebration: papel picado = battery (one flag per 10 %), calavera with the speed on its
forehead, a ring of marigolds = power (turquoise and anticlockwise regenerating), candles =
range (one per 50 km), a path of petals = the last two minutes of power. Halloween: a
jack-o'-lantern with the speed in its grin, burning yellow or, regenerating, green; an arch of
embers = power; a potion flask = battery; a tombstone and bats = range; the hill = the last two
minutes of power. What a value lights is `Tally` (pure, unit-tested: an unread value lights
nothing). The static scene is drawn once per size into a bitmap (about 4.5 MB while the view is
attached), so a frame only draws the readings; flags, flames, bats, bubbles and the ghost move
only while the car moves. Tests and lint pass. Emulator with sample values, panel and full
screen: normal, 12 % with regeneration, 40 % (the flask's words over the potion's surface); real
build with no data: "—" and nothing lit. No crash of the app. Not tried on the car, so the frame
rate on the head unit is unmeasured.
- 2026-10-28 — App 2.42.1 published (`car-2.42.1`).
- 2026-10-28 — App 2.42.1: **the music-sources check no longer connects to other apps' media
services.** In the car, 2.42.0's check (14:26, Bluetooth music playing) made the head unit ask
whether to allow access to media (the owner agreed) and **stopped the Bluetooth music**: it
connected to every media browser service, and among them the AOSP car radio's
(`com.android.car.radio/.RadioService`, which answered "connecting", most likely taking the
audio) and the Local Media Player (`com.android.car.media.localmediaplayer`, whose permission
request is most likely the question; it may now read media files). My claim that the check only
read was wrong: binding another app's service starts it. Now the services are only listed.
Nothing lost: the active sessions alone carried everything — **Bluetooth**
(`com.android.bluetooth`: title, artist, album, duration, track, cover as a file path, playing)
and **CarPlay** (`com.allgo.remoteui.mediabrowserservice`: title, artist, duration, paused) —
readable with MEDIA_CONTENT_CONTROL, no connection needed. Services on the car: CarPlay's,
Bluetooth A2DP, Local Media Player, the AOSP car radio, SAIC's radio (DAB, "Brillux Radio"),
SAIC's USB 1/2 and online media, and SAIC's video browser. Tests and lint pass; not run on the
emulator (stopped for low memory; the change only removes code); not tried on the car.
- 2026-10-28 — App 2.42.0 published (`car-2.42.0`).
- 2026-10-28 — App 2.42.0: **music-sources check** on the Logs tab (`MediaProbe`,
`MediaProbeText`; owner: "is it possible to show playing music in our dashboard as well?", then
"yes start the probe"). Before a now-playing display, it finds where the head unit tells what is
playing, read only: the players Android's audio service reports (use, app, state), the active
media sessions (`MediaSessionManager.getActiveSessions`, MEDIA_CONTENT_CONTROL, which the system
user has) with their song, and every media browser service, each connected to, its session read
and disconnected. CarPlay's is tried even when not listed:
`com.allgo.remoteui.mediabrowserservice/.RemoteUIMediaBrowserService`, which the car's own media
library (`com.saicmotor.sdk.media…MediaPlayControlManager` in Allgo's APK) reads CarPlay's title,
artist, album and cover from through a plain `MediaBrowser`. Nothing of Allgo's is changed and
nothing is played, paused or skipped. The report (`media-probe-YYYYMMDD-HHMMSS.txt`) goes where
a recording goes, through the shared `SignalRecorder.saveCopies`: the app, a USB stick, the
server (`fetch-logs`). Unit tests for the text; tests and lint pass. Emulator (Android 9): the
players are read with their app and state (the system APIs answer the system user), the
sessions are readable (none there, no SecurityException), the AOSP music app's browser service
connects and is read, CarPlay's (not installed there) is "refused or failed", and the report
lands in the outbox. **Not tried on the car.** Unknown until then: whether CarPlay's service
lets the app connect, and whether the radio, Bluetooth and USB sources publish a session.
- 2026-10-28 — First recordings over the server (14:19 and 14:23, app 2.41.0, ACC on, the set
speed raised a few times): the server path works, and listening works in the car (249
assistance properties, 0 refused, 76 and 81 change events). `0x2160A15B` (`AAD_EPTCCTRGTSPD`)
stays 0.0 even while listened to: the car does not fill it. `0x2140A15A` (3/7, a few 0s) and
`0x2160A1A3`/`A1A4` (always equal, 1.4–5.3, now and then 0.1–0.2 down) do not move with the
key presses. New lead: steering-wheel keys **297** (2× at 14:20:09, 2× at 14:20:12) and **300**
(14:20:05, 2× at 14:20:40), the car speeding up 27 → 49 km/h after them; key 3 is Home. The
14:23 recording has no key at all. Not confirmed: which buttons these are. Also seen:
`0x21401553`–`1556` ≈ 256 are tyre pressures and `0x2140155B`–`155E` (18–24) tyre
temperatures, not the set speed.
- 2026-10-27 — Skill `car-logs` (`.claude/skills/car-logs/SKILL.md`; owner: "can we write a skill for downloading the logs?"): fetch with `fetch-logs` without ever showing the token, what its errors mean, how a recording is laid out, the cuts that find a lead, the leads so far, and that recordings are never committed. Its commands tried on the 08:52 recording.
- 2026-10-27 — Server 2.9.0 pushed (`sha256:c68837f0e53cfca150644987be535e04d5e999bd4c91064d40973db67235df48`, pinned in `k8s/server.yaml`); app 2.41.0 published (`car-2.41.0`).
- 2026-10-27 — App 2.41.0, server 2.9.0: **recordings reach the server** (owner: "can we make - 2026-10-27 — App 2.41.0, server 2.9.0: **recordings reach the server** (owner: "can we make
it so that we can upload the logs to an endpoint in the server, and you can get the logs from it so that we can upload the logs to an endpoint in the server, and you can get the logs from
there? that way i don't have to carry a usb stick"). A finished signal recording goes to there? that way i don't have to carry a usb stick"). A finished signal recording goes to
+99 -3
View File
@@ -1,6 +1,6 @@
# Overview styles # Overview styles
The Overview can look eighteen ways: pick one under **Settings → Overview style**. Every style shows The Overview can look twenty ways: pick one under **Settings → Overview style**. Every style shows
the same readings (battery, range, power, speed, gear, usage, trip, climate, odometer, time) and the same readings (battery, range, power, speed, gear, usage, trip, climate, odometer, time) and
shows "—" for anything the car did not answer, never a made-up zero. shows "—" for anything the car did not answer, never a made-up zero.
@@ -11,7 +11,7 @@ emulator has no car; on the car the values are live.
**Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings → **Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings →
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid (with or without the map) and Visor can also show their original Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid (with or without the map) and Visor can also show their original
colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire always do; while one of those is shown, the whole app colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, Día de Muertos, Halloween, Christmas and Caterpillar always do; while one of those is shown, the whole app
(navigation, buttons, switches) takes on its colours too. (navigation, buttons, switches) takes on its colours too.
| Style | Look | Colours | | Style | Look | Colours |
@@ -34,13 +34,19 @@ colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire alway
| [Quest](#quest) | a fantasy game's interface | own | | [Quest](#quest) | a fantasy game's interface | own |
| [Visor](#visor) | a soldier's helmet display | original or accent | | [Visor](#visor) | a soldier's helmet display | original or accent |
| [Empire](#empire) | a strategy game's interface | own | | [Empire](#empire) | a strategy game's interface | own |
| [Día de Muertos](#día-de-muertos) | an ofrenda with papel picado and a calavera | own |
| [Halloween](#halloween) | a jack-o'-lantern on a graveyard hill | own |
| [Christmas](#christmas) | a Christmas tree in a snowy village | own |
| [Caterpillar](#caterpillar) | a hungry caterpillar in cut-paper collage | own |
## Tiles ## Tiles
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).
![Tiles with the navigation](screenshots/themes/tiles-panel.png) ![Tiles with the navigation](screenshots/themes/tiles-panel.png)
![Tiles, full screen](screenshots/themes/tiles.png) ![Tiles, full screen](screenshots/themes/tiles.png)
@@ -90,6 +96,7 @@ A starship console, after the look of the computer screens in 90s science-fictio
"elbow" frames in orange and lilac, pill-shaped labels, condensed capitals and blocky bar graphs "elbow" frames in orange and lilac, pill-shaped labels, condensed capitals and blocky bar graphs
on black. Below 20 % charge the frames turn red: red alert. The app takes on the lilac of the on black. Below 20 % charge the frames turn red: red alert. The app takes on the lilac of the
lower frame. lower frame.
What plays (Bluetooth, CarPlay or the radio) is written in black on the top bar, cut to its length.
![Sci-fi with the navigation](screenshots/themes/scifi-panel.png) ![Sci-fi with the navigation](screenshots/themes/scifi-panel.png)
![Sci-fi, full screen](screenshots/themes/scifi.png) ![Sci-fi, full screen](screenshots/themes/scifi.png)
@@ -127,6 +134,7 @@ driving.
As if a shell printed the car's state: `watch -n 0.2 vehicle-status` on a green-phosphor screen As if a shell printed the car's state: `watch -n 0.2 vehicle-status` on a green-phosphor screen
with faint scan lines, each value on a row with an ASCII bar, the gear as `[D]`, and a prompt with with faint scan lines, each value on a row with an ASCII bar, the gear as `[D]`, and a prompt with
a blinking cursor. A low battery turns its row amber. In green (shown) or in the accent colour. a blinking cursor. A low battery turns its row amber. In green (shown) or in the accent colour.
What plays (Bluetooth, CarPlay or the radio) shows as a PLAYING row above the prompt, cut to the width.
![Terminal with the navigation](screenshots/themes/terminal-panel.png) ![Terminal with the navigation](screenshots/themes/terminal-panel.png)
![Terminal, full screen](screenshots/themes/terminal.png) ![Terminal, full screen](screenshots/themes/terminal.png)
@@ -202,6 +210,7 @@ energy bar; a minimap with the clock; the speed in large gold numbers over a cas
are the trip, the usage and the climate; a chat window with system lines; P R N D as action are the trip, the usage and the climate; a chat window with system lines; P R N D as action
buttons, and the odometer as the experience bar, a level per 1000 km. Below 20 % a raid warning buttons, and the odometer as the experience bar, a level per 1000 km. Below 20 % a raid warning
flashes "Low battery!". The app turns gold with it. flashes "Low battery!". The app turns gold with it.
What plays (Bluetooth, CarPlay or the radio) crosses the top of the sky on a waving scroll towed by a red dragon, behind the frames, again and again while it plays.
![Quest with the navigation](screenshots/themes/quest-panel.png) ![Quest with the navigation](screenshots/themes/quest-panel.png)
![Quest, full screen](screenshots/themes/quest.png) ![Quest, full screen](screenshots/themes/quest.png)
@@ -230,6 +239,93 @@ while the ground and trees move past as fast as you drive and the speed stands l
land. The carved panel at the bottom has P R N D as command buttons, the unit's portrait and land. The carved panel at the bottom has P R N D as command buttons, the unit's portrait and
stats, and a diamond minimap with the odometer and the way to the next age. Below 20 % it warns stats, and a diamond minimap with the odometer and the way to the next age. Below 20 % it warns
"Your battery is running low!". The app turns the player's blue with it. "Your battery is running low!". The app turns the player's blue with it.
What plays (Bluetooth, CarPlay or the radio) crosses the top of the world on a waving scroll flown by an eagle, behind the speed, again and again while it plays.
![Empire with the navigation](screenshots/themes/empire-panel.png) ![Empire with the navigation](screenshots/themes/empire-panel.png)
![Empire, full screen](screenshots/themes/empire.png) ![Empire, full screen](screenshots/themes/empire.png)
## Día de Muertos
After the ofrenda, the altar families in Mexico build to welcome their dead back for a visit: a
celebration, so bright and warm rather than frightening. The papel picado across the top is the
battery, one cut-paper flag per 10 %, those not lit hanging dark, turning red with "BATTERY LOW"
below 20 %. The calavera in the middle, a decorated sugar skull with marigolds (cempasúchil) for
eyes, has the speed on its forehead; the ring of marigolds round it is the power, clockwise while
drawing, turquoise and anticlockwise while regenerating. The altar's candles are the range, one
per 50 km. The path of marigold petals along the bottom, which in the tradition leads the visitors
home, is the last two minutes of power. On the right the clock in a tin frame, P R N D as small
marigolds, and trip, usage, climate and odometer on the altar cloth. While you drive the flags
sway and the flames flicker. What plays (Bluetooth, CarPlay or the radio) crosses the sky between
the flags and the ring on a waving strip of cut paper, towed by a monarch butterfly and, on the next
pass, an alebrije; the paper takes one of the flags' colours, fixed by the song. The app turns the
papel picado's pink with it.
![Día de Muertos with the navigation](screenshots/themes/muertos-panel.png)
![Día de Muertos, full screen](screenshots/themes/muertos.png)
## Halloween
A graveyard hill under a full moon. The jack-o'-lantern has the speed cut into its grin, and its
carving burns candle yellow while drawing and witch-fire green while regenerating; the arch of
embers over it is the power, from the left while drawing and from the right while regenerating.
The potion flask is the battery, red with "BATTERY LOW" blinking below 20 %. The tombstone reads
the range ("here lies … km"), and the bats are the range too, one per 50 km; P R N D are small
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
you drive the bats fly, the potion bubbles and the ghost floats (where there is room for it, as on
this screen). With "Pumpkin light follows the music" on (Settings, shown with this style; off by
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) flies across the top of the sky on a waving parchment banner that a
bat tows, behind the moon, again and again while it plays (`SongBanner` draws the banner for both
this style and Día de Muertos). The app turns the
pumpkin's orange with it.
![Halloween with the navigation](screenshots/themes/halloween-panel.png)
![Halloween, full screen](screenshots/themes/halloween.png)
## Christmas
A snowy village at night, warm and festive. In the middle a Christmas tree with a big red bauble
in front of it: the speed. Fairy lights arch over the tree: the power, left to right in warm gold
while drawing, ice blue and right to left while regenerating. On the left a glass mug of hot cocoa
fills with the charge, with marshmallows floating on it and steam rising; it turns cold blue with
"BATTERY LOW" blinking below 20 %. Above it, gift tags hang from a garland with trip, usage,
climate and odometer. On the right a wreath holds the clock, and a snowy signboard reads the range
with a candy cane for every 50 km in the snow below; P R N D are four baubles, the one engaged lit.
The snow drift along the bottom is the last two minutes of power, rising with it. While you drive the
snow falls and the tree's lights twinkle. With "Light follows the music" on (Settings, shown with
this style and Halloween; off by default) and Bluetooth or CarPlay music playing, the lights on the
tree and the fairy lights flare on each kick drum; the radio cannot be heard, so they twinkle as
usual then. What plays (Bluetooth, CarPlay or the radio) crosses the sky above the tree on a waving
strip of wrapping paper towed by Santa's sleigh and, on the next pass, a small angel with a
trumpet; the paper's colour is fixed by the song. The app turns red with it.
![Christmas with the navigation](screenshots/themes/christmas-panel.png)
![Christmas, full screen](screenshots/themes/christmas.png)
## Caterpillar
After a picture book about a very hungry caterpillar, drawn anew in cut-paper shapes and bright
tissue colours on the book's night page. The caterpillar arches over the middle, after the one on
the newer pictures of the book: plump, round segments like balls of wound paper with brush strokes
in light green and teal, a fringe of yellow and orange bristles along the back and stubby brown
feet, and a big round red head with teal eyes rimmed in yellow and two lilac ears. The face
carries the speed, and its ten segments are the power, lit from the head backwards while drawing and
blue while regenerating. While you drive it crawls the way a caterpillar does: a hump runs from its
tail to its head, the feet it lifts swing forward and the others stay planted, and the branch it
walks on slides by, faster the faster the car goes. Parked, it settles into its high arch. On the left the sun is the battery,
filling with the charge; below 20 % it turns into a grey moon and "BATTERY LOW" blinks. A big leaf
above it holds trip, usage, climate and odometer on paper labels. On the right a clock in a paper
circle, and the story's fruit, one apple, two pears, three plums, four strawberries and five
oranges: a fruit per 50 km of range, those used up with a bite taken out. P R N D are the stages of
its life, an egg, the caterpillar, a cocoon and a butterfly, the one engaged lit. The hills along
the bottom are the last two minutes of power, rising with it. With "Light follows the music" on
(Settings, shown with this style, Halloween and Christmas; off by default) and Bluetooth or CarPlay
music playing, the sun's rays and the caterpillar's segments bounce on each kick drum; the radio
cannot be heard. What plays (Bluetooth, CarPlay or the radio) crosses the sky on a waving strip of
tissue paper towed by a butterfly and, on the next pass, a ladybird; the paper's colour is fixed by
the song. The app turns green with it.
![Caterpillar with the navigation](screenshots/themes/caterpillar-panel.png)
![Caterpillar, full screen](screenshots/themes/caterpillar.png)
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 150 versionCode 169
versionName "2.41.0" versionName "2.51.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.
@@ -0,0 +1,664 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RadialGradient;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Caterpillar Overview style, after the picture book about a very hungry caterpillar (drawn
* anew, not copied from it): cut-paper shapes in bright tissue colours on the book's night page.
*
* In the middle the caterpillar, arched like the one on the book's cover (overlapping chunks of
* mottled green tissue paper, a yellow fringe along the back, a red-orange head with a yellow face):
* the face carries the speed, and its ten segments are the power, lit from the head backwards while
* drawing, blue while regenerating; while the car moves it crawls the way a caterpillar does: a hump runs from tail to head, its feet
* lifting and planting, on a branch that slides by. On the left the sun is the battery, filling with the charge, and turning into a
* grey moon with "BATTERY LOW" blinking below 20 %; above it a big leaf holds trip, usage, climate
* and odometer on paper labels. On the right the clock in a paper circle, and the
* story's fruit: one apple, two pears, three plums, four strawberries and five oranges, a fruit
* per 50 km of range, those used up with a bite taken out. P R N D are the four stages of its life,
* an egg, the caterpillar, a cocoon and a butterfly, the one engaged lit. The hills along the bottom
* are the last two minutes of power, rising with it. Unread values show "—" and light nothing.
* With music playing, the sun's rays and the caterpillar's segments bounce on the beat
* ({@link MusicTap}). What plays crosses the sky on a banner of tissue paper towed by a butterfly
* and, on the next pass, a ladybird ({@link NowPlaying}).
*
* The night, the stars and the far hills are drawn once into a bitmap per size. The caterpillar
* wiggles (about 30 frames a second) only while the car moves and the view is on screen; the
* pulse and the song keep it drawing while there are any.
*/
final class CaterpillarView extends ClusterCanvasView {
private static final int SEGMENTS = 10, RAYS = 14, FRUIT = 15;
private static final int NIGHT_TOP = 0xFF050B22, NIGHT_LOW = 0xFF12305A, NIGHT_END = 0xFF1D4A5E;
private static final int GREEN = 0xFF7ED957, GREEN_DARK = 0xFF2E8B3A, LEAF = 0xFF3FA34D, HEAD = 0xFFE53935;
private static final int SUN = 0xFFFFA726, SUN_LIGHT = 0xFFFFD54F, MOON = 0xFFB8BCC8, ICE = 0xFF5CC8FF;
private static final int PAPER = 0xFFFBF1DA, INK = 0xFF1B1530, BONE = 0xFFF6F0E4, YELLOW = 0xFFFFE066;
private static final int[] TISSUE = { 0xFFE53935, 0xFF2B7BE0, 0xFF2EA84F, 0xFFFFC928, 0xFF8E4DD6, 0xFFFF8A1F };
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint image = new Paint(Paint.FILTER_BITMAP_FLAG);
private final Path path = new Path();
private final Path bite = new Path();
private final SongBanner banner = new SongBanner();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
private Bitmap sky;
private int skyW, skyH;
private float time;
/** Where the crawl is (whole numbers are one full crawl), how far the ground has gone by, and how much it crawls (0 to 1). */
private float phase, travel, crawl;
private long lastFrame;
private MusicTap music;
CaterpillarView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.ROUND);
}
/** Where the sun and the caterpillar hear the music; without it, they do not follow any. */
void setMusic(MusicTap tap) {
music = tap;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
sky = null;
skyW = 0;
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
int w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = moving || crawl > 0.01f ? now : 0;
if (moving) {
float kmh = Math.min(v.speedKmh, 130f);
time += dt;
// One crawl takes a second or two; faster the faster the car goes, and the ground slides by.
phase = (phase + dt * Math.min(1.2f, 0.4f + kmh / 90f)) % 1000f;
travel = (travel + dt * kmh * 1.1f * u) % 100000f;
}
// It settles into its resting arch when the car stops, and sets off again when it moves.
crawl += ((moving ? 1f : 0f) - crawl) * Math.min(1f, dt * 4f);
if (sky == null || skyW != w || skyH != h) buildSky(w, h, u);
c.drawBitmap(sky, 0, 0, image);
boolean song = drawSong(c, w, u);
drawHills(c, w, h, u);
float pulse = music == null ? 0f : music.pulse();
drawSun(c, u, pulse);
drawLeaf(c, u);
drawClock(c, w, u);
drawFruit(c, w, u);
drawGears(c, w, u);
drawBranch(c, w, u);
drawCaterpillar(c, w, u, pulse);
keepAnimating(moving || crawl > 0.01f || pulse > 0f || song, 33);
}
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
text.setTypeface(tf);
text.setTextSize(size);
text.setTextAlign(a);
text.setColor(colour);
c.drawText(s, x, y, text);
}
/** Text with a dark outline, to read over whatever is behind it. */
private void outlined(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a, float u) {
text.setStyle(Paint.Style.STROKE);
text.setStrokeWidth(Math.max(3f * u, size * 0.12f));
text.setStrokeJoin(Paint.Join.ROUND);
label(c, s, x, y, size, 0xDD0A0608, tf, a);
text.setStyle(Paint.Style.FILL);
label(c, s, x, y, size, colour, tf, a);
}
private boolean regen() {
return v.powerKw != null && v.powerKw < -0.05f;
}
// ---------- The night page ----------
private void buildSky(int w, int h, float u) {
sky = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
skyW = w;
skyH = h;
Canvas c = new Canvas(sky);
Paint p = new Paint();
p.setShader(new LinearGradient(0, 0, 0, h, new int[] { NIGHT_TOP, NIGHT_LOW, NIGHT_END },
new float[] { 0f, 0.7f, 1f }, Shader.TileMode.CLAMP));
c.drawRect(0, 0, w, h, p);
Paint star = new Paint(Paint.ANTI_ALIAS_FLAG);
star.setColor(0xB0FFF6C8);
for (int i = 0; i < 80; i++) {
float x = ((i * 191 + 17) % 1009) / 1009f * w, y = ((i * 83 + 5) % 421) * u;
c.drawCircle(x, y, (i % 4 == 0 ? 2.2f : 1.2f) * u, star);
}
// A crescent moon, as on the book's first night.
Path moon = new Path(), cut = new Path();
float mx = w * 0.3f, my = 150f * u;
moon.addCircle(mx, my, 30f * u, Path.Direction.CW);
cut.addCircle(mx + 13f * u, my - 6f * u, 27f * u, Path.Direction.CW);
moon.op(cut, Path.Op.DIFFERENCE);
Paint paper = new Paint(Paint.ANTI_ALIAS_FLAG);
paper.setColor(0xFFFFF3B0);
c.drawPath(moon, paper);
hills(c, w, h, h - 150f * u, 0xFF124A3A, 0.8f, u);
hills(c, w, h, h - 100f * u, 0xFF1A5E45, 1.5f, u);
}
private void hills(Canvas c, int w, int h, float base, int colour, float wave, float u) {
Path hill = new Path();
hill.moveTo(0, h);
for (int i = 0; i <= 24; i++) {
float x = w * i / 24f;
hill.lineTo(x, base - (float) (Math.sin(i * wave) * 0.5 + Math.sin(i * wave * 0.37 + 1) * 0.5) * 22f * u);
}
hill.lineTo(w, h);
hill.close();
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
p.setColor(colour);
c.drawPath(hill, p);
}
// ---------- The hills in front: the last two minutes of power ----------
private void drawHills(Canvas c, int w, int h, float u) {
float base = h - 44f * u, amp = 36f * u;
float[] trail = v.powerTrail;
path.reset();
path.moveTo(0, h);
int n = trail == null ? 0 : trail.length;
if (n == 0) {
path.lineTo(0, base);
path.lineTo(w, base);
} else {
for (int i = 0; i < n; i++) {
float p = trail[i];
float height = Float.isNaN(p) ? 0f : Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f));
path.lineTo(i / (n - 1f) * w, base - height * amp);
}
}
path.lineTo(w, h);
path.close();
fill.setColor(0xFF2E9A4E);
c.drawPath(path, fill);
if (n > 1) {
line.setStrokeWidth(3f * u);
for (int i = 1; i < n; i++) {
float a = trail[i - 1], b = trail[i];
if (Float.isNaN(a) || Float.isNaN(b)) continue;
float ha = Math.min(1f, Math.abs(a) / (a < 0 ? 40f : 60f));
float hb = Math.min(1f, Math.abs(b) / (b < 0 ? 40f : 60f));
line.setColor(b < 0 ? ICE : YELLOW);
c.drawLine((i - 1f) / (n - 1f) * w, base - ha * amp, i / (n - 1f) * w, base - hb * amp, line);
}
}
}
// ---------- The sun (or the moon): the battery ----------
private void drawSun(Canvas c, float u, float pulse) {
float cx = 178f * u, cy = 452f * u, rad = 100f * u;
boolean low = v.socPercent != null && v.socPercent < 20f;
boolean blinkOn = (SystemClock.uptimeMillis() / 500) % 2 == 0;
float share = v.socPercent == null ? 0f : Math.max(0f, Math.min(1f, v.socPercent / 100f));
// The rays, longer and stronger with the music; none for the moon.
if (!low) {
for (int i = 0; i < RAYS; i++) {
double a = Math.PI * 2 * i / RAYS;
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
float tip = rad + (22f + pulse * 16f + (i % 2) * 8f) * u;
float nx = -sin * 8f * u, ny = cos * 8f * u;
path.reset();
path.moveTo(cx + cos * (rad - 2f * u) + nx, cy + sin * (rad - 2f * u) + ny);
path.lineTo(cx + cos * tip, cy + sin * tip);
path.lineTo(cx + cos * (rad - 2f * u) - nx, cy + sin * (rad - 2f * u) - ny);
path.close();
fill.setColor(AccentColor.withAlpha(SUN_LIGHT, 0x60 + (int) (share * 0x9F)));
c.drawPath(path, fill);
}
}
// The disc: dark where the charge is gone, the sun's colour as high as it is full.
fill.setColor(0xFF1E2748);
c.drawCircle(cx, cy, rad, fill);
if (v.socPercent != null) {
float level = cy + rad - share * 2f * rad;
c.save();
c.clipRect(cx - rad, level, cx + rad, cy + rad + 1f);
fill.setColor(low ? MOON : SUN);
c.drawCircle(cx, cy, rad, fill);
c.restore();
}
if (low) {
fill.setColor(0x33000000);
c.drawCircle(cx - 36f * u, cy - 30f * u, 13f * u, fill);
c.drawCircle(cx + 34f * u, cy + 40f * u, 17f * u, fill);
c.drawCircle(cx + 40f * u, cy - 36f * u, 8f * u, fill);
}
line.setColor(low ? MOON : SUN_LIGHT);
line.setStrokeWidth(4f * u);
c.drawCircle(cx, cy, rad, line);
String number = fmt(v.socPercent, "%.0f %%", "—");
outlined(c, number, cx, cy + 20f * u, 58f * u, BONE, serif, Paint.Align.CENTER, u);
if (low && blinkOn) outlined(c, "BATTERY LOW", cx, cy + 56f * u, 21f * u, BONE, face, Paint.Align.CENTER, u);
else if (!low) outlined(c, "BATTERY", cx, cy + 56f * u, 19f * u, YELLOW, face, Paint.Align.CENTER, u);
}
// ---------- The leaf: trip, usage, climate, odometer ----------
private void drawLeaf(Canvas c, float u) {
float l = 20f * u, rr = 336f * u, t = 34f * u, b = 246f * u;
// A stem, then the leaf: a rounded sheet with a pointed tip and veins.
line.setColor(GREEN_DARK);
line.setStrokeWidth(7f * u);
c.drawLine(l + 6f * u, b - 4f * u, l - 12f * u, b + 20f * u, line);
path.reset();
path.moveTo(l + 30f * u, t);
path.lineTo(rr - 50f * u, t);
path.quadTo(rr, t + 4f * u, rr + 10f * u, (t + b) / 2f);
path.quadTo(rr, b - 4f * u, rr - 50f * u, b);
path.lineTo(l + 30f * u, b);
path.quadTo(l, b, l, b - 30f * u);
path.lineTo(l, t + 30f * u);
path.quadTo(l, t, l + 30f * u, t);
path.close();
fill.setColor(LEAF);
c.drawPath(path, fill);
line.setColor(0xFF2E8B3A);
line.setStrokeWidth(3f * u);
c.drawPath(path, line);
line.setColor(0x55B6F0A8);
line.setStrokeWidth(2.5f * u);
c.drawLine(l + 8f * u, (t + b) / 2f, rr + 6f * u, (t + b) / 2f, line);
for (int i = 1; i < 6; i++) {
float x = l + i * 50f * u;
c.drawLine(x, (t + b) / 2f, x + 26f * u, t + 8f * u, line);
c.drawLine(x, (t + b) / 2f, x + 26f * u, b - 8f * u, line);
}
String[][] rows = {
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
};
for (int i = 0; i < rows.length; i++) {
float top = t + 12f * u + i * 38f * u;
c.save();
c.rotate((i % 2 == 0 ? -0.6f : 0.6f), (l + rr) / 2f, top + 16f * u);
r.set(l + 14f * u, top, rr - 14f * u, top + 32f * u);
fill.setColor(PAPER);
c.drawRect(r, fill);
label(c, rows[i][0], l + 24f * u, top + 22f * u, 16f * u, 0xFF6B4A8A, thin, Paint.Align.LEFT);
label(c, rows[i][1], rr - 24f * u, top + 24f * u, 21f * u, INK, face, Paint.Align.RIGHT);
c.restore();
}
}
// ---------- The clock ----------
private void drawClock(Canvas c, int w, float u) {
float cx = w - 115f * u, cy = 96f * u, rad = 62f * u;
// Two leaves on top.
fill.setColor(LEAF);
for (int side = -1; side <= 1; side += 2) {
c.save();
c.rotate(side * 40f, cx, cy);
r.set(cx - 12f * u, cy - rad - 34f * u, cx + 12f * u, cy - rad + 8f * u);
c.drawOval(r, fill);
c.restore();
}
fill.setColor(PAPER);
c.drawCircle(cx, cy, rad, fill);
line.setColor(GREEN_DARK);
line.setStrokeWidth(6f * u);
c.drawCircle(cx, cy, rad, line);
label(c, v.clock.isEmpty() ? "—" : v.clock, cx, cy + 14f * u, 40f * u, INK, serif, Paint.Align.CENTER);
}
// ---------- The fruit: the range ----------
private void drawFruit(Canvas c, int w, float u) {
float rr = w - 40f * u, l = rr - 250f * u;
label(c, fmt(v.rangeKm, "%.0f km", "— km"), (l + rr) / 2f, 220f * u, 36f * u, BONE, serif, Paint.Align.CENTER);
int whole = Tally.lit(v.rangeKm, 50f, FRUIT);
int index = 0;
for (int row = 0; row < 5; row++) {
for (int k = 0; k <= row; k++, index++) {
fruit(c, row, l + 24f * u + k * 46f * u, 256f * u + row * 50f * u, 19f * u, index < whole, u);
}
}
}
/** One fruit of kind {@code kind} (apple, pear, plum, strawberry, orange); used up ones have a bite out. */
private void fruit(Canvas c, int kind, float x, float y, float s, boolean whole, float u) {
final int[] colours = { 0xFFE53935, 0xFFA8D83F, 0xFF7E3FBF, 0xFFE8263E, 0xFFFF8F1F };
path.reset();
switch (kind) {
case 1: r.set(x - s * 0.8f, y - s, x + s * 0.8f, y + s); path.addOval(r, Path.Direction.CW); break;
case 3:
path.moveTo(x - s, y - s * 0.6f);
path.lineTo(x + s, y - s * 0.6f);
path.lineTo(x, y + s);
path.close();
break;
default: path.addCircle(x, y, s, Path.Direction.CW);
}
if (!whole) {
bite.reset();
bite.addCircle(x + s * 0.85f, y - s * 0.55f, s * 0.5f, Path.Direction.CW);
path.op(bite, Path.Op.DIFFERENCE);
}
fill.setColor(whole ? colours[kind] : AccentColor.withAlpha(colours[kind], 0x78));
c.drawPath(path, fill);
if (!whole) return;
// A stem and a leaf, seeds on the strawberry.
line.setColor(kind == 3 ? GREEN_DARK : 0xFF6B4226);
line.setStrokeWidth(2.5f * u);
c.drawLine(x, y - s * (kind == 3 ? 0.6f : 0.95f), x + 3f * u, y - s * 1.3f, line);
fill.setColor(GREEN);
r.set(x + 1f * u, y - s * 1.35f, x + 11f * u, y - s * 1.05f);
c.drawOval(r, fill);
if (kind == 3) {
fill.setColor(YELLOW);
for (int i = 0; i < 3; i++) c.drawCircle(x + (i - 1) * 7f * u, y + (i % 2) * 7f * u - 2f * u, 1.6f * u, fill);
}
}
// ---------- The stages of its life: the gears ----------
private void drawGears(Canvas c, int w, float u) {
float rr = w - 40f * u, l = rr - 250f * u, cy = 520f * u, rad = 26f * u;
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float step = (rr - l) / 4f;
for (int i = 0; i < gears.length; i++) {
float mid = l + (i + 0.5f) * step;
boolean on = gears[i].equals(now);
if (on) {
fill.setColor(0x55FFE066);
c.drawCircle(mid, cy, 40f * u, fill);
}
fill.setColor(0xFF1B2A4A);
c.drawCircle(mid, cy, rad, fill);
line.setColor(on ? YELLOW : 0xFF3A5080);
line.setStrokeWidth(2.5f * u);
c.drawCircle(mid, cy, rad, line);
stage(c, i, mid, cy, u, on);
label(c, gears[i], mid, cy + rad + 22f * u, 22f * u, on ? YELLOW : 0xB0FFFFFF, serif, Paint.Align.CENTER);
}
}
/** The life stage for gear {@code i}: an egg, the caterpillar, a cocoon, a butterfly. */
private void stage(Canvas c, int i, float x, float y, float u, boolean on) {
int alpha = on ? 0xFF : 0x90;
switch (i) {
case 0:
r.set(x - 9f * u, y - 13f * u, x + 9f * u, y + 13f * u);
fill.setColor(AccentColor.withAlpha(0xFFF6F0E4, alpha));
c.drawOval(r, fill);
break;
case 1:
for (int k = 0; k < 3; k++) {
fill.setColor(AccentColor.withAlpha(k == 2 ? HEAD : GREEN, alpha));
c.drawCircle(x + (k - 1) * 10f * u, y + 2f * u, 7f * u, fill);
}
break;
case 2:
r.set(x - 8f * u, y - 16f * u, x + 8f * u, y + 16f * u);
fill.setColor(AccentColor.withAlpha(0xFFB08A5A, alpha));
c.drawOval(r, fill);
line.setColor(AccentColor.withAlpha(0xFF6B4226, alpha));
line.setStrokeWidth(1.5f * u);
c.drawLine(x - 7f * u, y - 4f * u, x + 7f * u, y - 4f * u, line);
c.drawLine(x - 7f * u, y + 5f * u, x + 7f * u, y + 5f * u, line);
break;
default:
fill.setColor(AccentColor.withAlpha(0xFF7E57E0, alpha));
r.set(x - 16f * u, y - 14f * u, x, y + 2f * u);
c.drawOval(r, fill);
r.set(x, y - 14f * u, x + 16f * u, y + 2f * u);
c.drawOval(r, fill);
fill.setColor(AccentColor.withAlpha(0xFFFF5FA2, alpha));
r.set(x - 14f * u, y - 2f * u, x, y + 12f * u);
c.drawOval(r, fill);
r.set(x, y - 2f * u, x + 14f * u, y + 12f * u);
c.drawOval(r, fill);
}
}
// ---------- The branch it crawls on ----------
/** A branch under the caterpillar; its knots and leaves slide backwards as the car goes. */
private void drawBranch(Canvas c, int w, float u) {
float hx = w / 2f + 250f * u, y = 410f * u + 87f * u;
float l = hx - 640f * u, rr = hx + 110f * u, span = rr - l;
line.setColor(0xFF6B4A2B);
line.setStrokeWidth(20f * u);
c.drawLine(l, y, rr, y, line);
line.setColor(0x44FFD59A);
line.setStrokeWidth(4f * u);
c.drawLine(l + 6f * u, y - 6f * u, rr - 6f * u, y - 6f * u, line);
for (int k = 0; k < 8; k++) {
float x = l + (((k * span / 8f) - travel) % span + span) % span;
fill.setColor(0xFF4A3018);
r.set(x - 9f * u, y - 5f * u, x + 9f * u, y + 6f * u);
c.drawOval(r, fill);
if (k % 2 == 0) {
fill.setColor(0xFF3FA34D);
r.set(x - 4f * u, y + 6f * u, x + 20f * u, y + 18f * u);
c.drawOval(r, fill);
}
}
}
// ---------- The caterpillar: speed and power ----------
/** Paper for a segment: three base colours, a light and a teal for the strokes; by state. */
private static final int[][] DRAWING = { { 0xFF5FBF52, 0xFF2E9E6A, 0xFF8ACD4A }, { 0xFFC9F06A, 0xFF1F8A7A } };
private static final int[][] REGEN = { { 0xFF2A8FC9, 0xFF1F78B4, 0xFF4AA8DA }, { 0xFF9BDCFF, 0xFF3AC8D0 } };
private static final int[][] OFF = { { 0xFF2B7A40, 0xFF256B38, 0xFF328C4B }, { 0xFF3A9060, 0xFF2A8078 } };
private static final float SEG_R = 47f;
/** [state][base colour]: a round, lit-from-the-upper-left shading for one segment, in its own units. */
private final Paint[][] shades = new Paint[3][3];
private Paint headShade;
private float shadesFor;
private static int mix(int a, int b, float t) {
int ar = (a >> 16) & 0xFF, ag = (a >> 8) & 0xFF, ab = a & 0xFF;
int br = (b >> 16) & 0xFF, bg = (b >> 8) & 0xFF, bb = b & 0xFF;
return 0xFF000000 | (ar + (int) ((br - ar) * t)) << 16 | (ag + (int) ((bg - ag) * t)) << 8 | (ab + (int) ((bb - ab) * t));
}
private void buildShades(float u) {
int[][][] states = { DRAWING, REGEN, OFF };
float r = SEG_R * u;
for (int st = 0; st < 3; st++) {
for (int k = 0; k < 3; k++) {
int base = states[st][0][k];
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
p.setShader(new RadialGradient(-0.3f * r, -0.35f * r, 1.5f * r,
new int[] { mix(base, 0xFFFFFFFF, 0.3f), base, mix(base, 0xFF000000, 0.18f) },
new float[] { 0f, 0.55f, 1f }, Shader.TileMode.CLAMP));
shades[st][k] = p;
}
}
headShade = new Paint(Paint.ANTI_ALIAS_FLAG);
headShade.setShader(new RadialGradient(-25f * u, -30f * u, 130f * u,
new int[] { 0xFFFF6A45, 0xFFE8452B, 0xFFA82414 }, new float[] { 0f, 0.55f, 1f }, Shader.TileMode.CLAMP));
shadesFor = u;
}
/**
* The caterpillar, after the picture book's newer pictures: plump round segments like balls of
* wound paper, each with brush strokes in light green and teal, bigger towards the head, a fringe
* of yellow and orange bristles along the back, stubby brown feet, and a red apple-shaped head with
* big teal eyes and two lilac ears. At rest it arches high in the middle. Power lights the segments
* (bright green, or blue when regenerating, dull when dark); the speed is on the head under the
* eyes. Crawling, a real caterpillar pulls its rear forward and humps the body up, and the hump
* runs from the tail to the head while the feet it lifts swing forward and the others stay planted;
* the body also bunches and stretches as it goes.
*/
private void drawCaterpillar(Canvas c, int w, float u, float pulse) {
if (shadesFor != u) buildShades(u);
float hx = w / 2f + 250f * u, hy = 410f * u;
int lit = Tally.ofPower(v.powerKw, 150f, 50f, SEGMENTS);
int state = lit == 0 ? 2 : regen() ? 1 : 0;
float arch = (80f * (1f - crawl) + 26f * crawl) * u;
float cycle = phase % 1f;
float stretch = 1f + 0.08f * crawl * (float) Math.sin(cycle * 2f * Math.PI);
float wave = 1.25f - cycle * 1.6f;
float[] xs = new float[SEGMENTS], ys = new float[SEGMENTS], lift = new float[SEGMENTS];
for (int i = 0; i < SEGMENTS; i++) {
float along = (SEGMENTS - 1 - i) / (SEGMENTS - 1f);
float d = Math.max(0f, 1f - Math.abs(along - wave) / 0.4f);
lift[i] = crawl * d * d * (3f - 2f * d);
xs[i] = hx - 104f * u - i * 46f * u * stretch;
ys[i] = hy + 22f * u - arch * (float) Math.sin(Math.PI * (i + 0.7) / (SEGMENTS + 0.4)) - lift[i] * 46f * u;
}
float r = SEG_R * u;
// From the tail towards the head, each overlapping the one before.
for (int i = SEGMENTS - 1; i >= 0; i--) {
boolean on = i < lit;
int[][] pal = on ? (regen() ? REGEN : DRAWING) : OFF;
Paint shade = shades[on ? (regen() ? 1 : 0) : 2][i % 3];
int prev = Math.max(0, i - 1), next = Math.min(SEGMENTS - 1, i + 1);
float angle = (float) Math.toDegrees(Math.atan2(ys[prev] - ys[next], xs[prev] - xs[next]));
// Bigger towards the head, and swelling a little with the music.
float grow = (0.82f + 0.2f * (1f - i / (SEGMENTS - 1f))) * (1f + (on ? pulse * (i % 2 == 0 ? 0.14f : 0.09f) : 0f));
c.save();
c.translate(xs[i], ys[i]);
c.rotate(angle);
c.scale(grow, grow);
// A stubby brown foot under every other one: a lifted foot swings forward and up.
if (i % 2 == 0) {
float up = lift[i] * 12f * u, fwd = lift[i] * 12f * u;
this.r.set(-0.2f * r + fwd, 0.85f * r - up, 0.4f * r + fwd, 1.4f * r - up);
fill.setColor(0xFF9A6A3A);
c.drawOval(this.r, fill);
}
this.r.set(-r, -1.05f * r, r, 1.05f * r);
c.drawOval(this.r, shade);
// Brush strokes down the ball, light and teal in turn.
line.setStrokeWidth(3f * u);
for (int k = -2; k <= 2; k++) {
float x0 = k * 0.32f * r;
line.setColor(AccentColor.withAlpha(pal[1][(k + 2) % 2], 0x70));
path.reset();
path.moveTo(x0 * 0.7f, -0.8f * r);
path.quadTo(x0 * 1.5f, 0f, x0 * 0.7f, 0.8f * r);
c.drawPath(path, line);
}
// The fringe along the back, in yellow and orange.
line.setStrokeWidth(3f * u);
for (int k = -3; k <= 3; k++) {
line.setColor(on ? (k % 2 == 0 ? 0xFFFFC21F : 0xFFFF8A1F) : 0x80FFC21F);
c.drawLine(k * 0.28f * r, (-0.98f + Math.abs(k) * 0.05f) * r, k * 0.34f * r, (-1.22f + Math.abs(k) * 0.06f) * r, line);
}
c.restore();
}
drawHead(c, hx, hy, u, pulse);
boolean regen = regen();
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
Math.abs(v.powerKw));
outlined(c, power, hx - 104f * u - 4.5f * 46f * u, 556f * u, 34f * u, regen ? ICE : BONE, face, Paint.Align.CENTER, u);
}
/** A red apple-shaped head, big teal eyes with yellow rims and two lilac ears, the speed under the eyes. */
private void drawHead(Canvas c, float hx, float hy, float u, float pulse) {
float hs = 1f + pulse * 0.04f;
c.save();
c.translate(hx, hy);
c.scale(hs, hs);
// The ears, stubby and rounded, leaning out.
for (int side = -1; side <= 1; side += 2) {
c.save();
c.rotate(side * 12f, side * 36f * u, -64f * u);
r.set(side * 36f * u - 15f * u, -122f * u, side * 36f * u + 15f * u, -58f * u);
fill.setColor(0xFFB59AE0);
c.drawOval(r, fill);
r.set(side * 36f * u - 15f * u, -122f * u, side * 36f * u - 2f * u, -58f * u);
fill.setColor(0x40FFFFFF);
c.drawOval(r, fill);
c.restore();
}
r.set(-88f * u, -76f * u, 88f * u, 82f * u);
c.drawOval(r, headShade);
fill.setColor(0x55000000);
r.set(-12f * u, -78f * u, 12f * u, -68f * u);
c.drawOval(r, fill);
for (int side = -1; side <= 1; side += 2) {
float ex = side * 34f * u;
r.set(ex - 19f * u, -50f * u, ex + 19f * u, 6f * u);
fill.setColor(0xFF2FA89A);
c.drawOval(r, fill);
line.setColor(0xFFFFD84A);
line.setStrokeWidth(4f * u);
c.drawOval(r, line);
fill.setColor(0x99FFFFFF);
c.drawCircle(ex - 6f * u, -34f * u, 4.5f * u, fill);
}
outlined(c, fmt(v.speedKmh, "%.0f", "—"), 0f, 50f * u, 40f * u, 0xFFFFF3D0, serif, Paint.Align.CENTER, u);
label(c, "KM/H", 0f, 70f * u, 12f * u, 0xFFFFE0B0, face, Paint.Align.CENTER);
c.restore();
}
// ---------- What plays: a butterfly or a ladybird tows it on tissue paper ----------
/**
* A butterfly and a ladybird take turns towing a waving strip of tissue paper with the song across
* the sky, behind the clock and the leaf, again and again while something plays. The paper's
* colour is fixed by the song. Returns whether it is drawn, so the view keeps animating for it.
*/
private boolean drawSong(Canvas c, int w, float u) {
if (v.songTitle == null) return false;
text.setTypeface(face);
text.setTextSize(19f * u);
text.setStyle(Paint.Style.FILL);
String s = SongBanner.line(text, v.songTitle, v.songArtist, Math.min(560f * u, w * 0.45f));
float t = SystemClock.uptimeMillis() / 1000f;
float bw = text.measureText(s) + 40f * u, bh = 28f * u, tow = 76f * u;
float bx = SongBanner.left(t, w, bw, tow, u);
float yc = 66f * u + (float) Math.sin(t * 1.3f) * 3f * u;
int paper = TISSUE[Math.floorMod((v.songTitle + "\n" + v.songArtist).hashCode(), TISSUE.length)];
boolean darkInk = paper == TISSUE[3] || paper == TISSUE[5];
banner.draw(c, fill, line, text, s, bx, yc, bw, bh, t, u, 9f * u, paper, 0x66FFFFFF, darkInk ? INK : 0xFFFFFFFF);
boolean butterfly = SongBanner.pass(t, w, bw, tow, u) % 2 == 0;
float right = bx + bw, cx = right + tow, cy = yc - 4f * u, size = (butterfly ? 60f : 44f) * u;
float[] tail = butterfly ? SongCarriers.butterflyTail : SongCarriers.ladybirdTail;
float ax = cx + tail[0] * size, ay = cy + tail[1] * size;
line.setColor(0xCCFFFFFF);
line.setStrokeWidth(1.5f * u);
c.drawLine(right, yc - bh / 2f + 3f * u, ax, ay, line);
c.drawLine(right, yc + bh / 2f - 3f * u, ax, ay, line);
float flap = (float) Math.sin(t * (butterfly ? 7f : 18f));
if (butterfly) SongCarriers.butterfly(c, fill, line, path, r, cx, cy, size, flap, u);
else SongCarriers.ladybird(c, fill, line, path, r, cx, cy, size, flap, u);
return true;
}
}
@@ -0,0 +1,616 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.DashPathEffect;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RadialGradient;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Christmas Overview style: a snowy village night, warm and festive.
*
* In the middle a Christmas tree with a big red bauble in front of it: the speed. Fairy lights
* arch over the tree: the power, left to right in warm gold while drawing, ice blue and right to
* left while regenerating. On the left a glass mug of hot cocoa filling with the charge, cold blue
* with "BATTERY LOW" blinking below 20 %, under gift tags with trip, usage, climate and odometer.
* On the right a wreath holds the clock, and a snowy signboard reads the range, with a candy cane
* per 50 km; P R N D are four baubles, the one engaged lit. The snow drift along the bottom is the
* last two minutes of power, rising with it, in gold or, where it regenerated, ice blue. Unread
* values show "—" and light nothing. With music playing, the tree's lights and the fairy lights
* flare on the beat ({@link MusicTap}); with no music they twinkle as before. What plays crosses
* the sky on a banner of wrapping paper, towed by Santa's sleigh and, on the next pass, an angel
* ({@link NowPlaying}).
*
* The night, the hills and the stars are drawn once into a bitmap per size; only the rest goes over
* it. Snow falls and the lights twinkle (about 30 frames a second) only while the car moves and the
* view is on screen; the lights' pulse and the song keep it drawing while there are any.
*/
final class ChristmasView extends ClusterCanvasView {
private static final int BULBS = 25, FLAKES = 70, CANES = 8;
private static final int SKY_TOP = 0xFF06142B, SKY_LOW = 0xFF173A5E, SKY_END = 0xFF2A5A7A;
private static final int SNOW = 0xFFE9F2FF, PINE = 0xFF1F7A3C, PINE_DARK = 0xFF0F4A24;
private static final int RED = 0xFFE53946, GOLD = 0xFFFFD04A, BONE = 0xFFF6F0E4, INK = 0xFF1B0F12;
private static final int COCOA = 0xFF7B3F22, COLD = 0xFF4A8AD0, ICE = 0xFF8FD8FF, WOOD = 0xFF4A2E1A;
private static final int[] LIGHTS = { RED, GOLD, ICE, 0xFFFFFFFF, 0xFF5CE08A };
private static final int[] PAPER = { 0xFFC62828, 0xFF1E7B3C, 0xFF2B5FA8, 0xFFD9A62E, 0xFFF1F1F1 };
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint image = new Paint(Paint.FILTER_BITMAP_FLAG);
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path = new Path();
private final SongBanner banner = new SongBanner();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
private Bitmap sky;
private int skyW, skyH;
private float time;
private long lastFrame;
private MusicTap music;
ChristmasView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.ROUND);
}
/** Where the lights hear the music; without it, they do not follow any. */
void setMusic(MusicTap tap) {
music = tap;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
sky = null;
skyW = 0;
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
int w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
if (moving) {
long now = SystemClock.uptimeMillis();
time += lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
} else {
lastFrame = 0;
}
if (sky == null || skyW != w || skyH != h) buildSky(w, h, u);
c.drawBitmap(sky, 0, 0, image);
boolean song = drawSong(c, w, u);
drawSnow(c, w, h, u);
drawDrift(c, w, h, u);
float pulse = music == null ? 0f : music.pulse();
drawTree(c, w, u, pulse);
drawArch(c, w, u, pulse);
drawBauble(c, w, u, pulse);
drawWreath(c, w, u);
drawTags(c, u);
drawMug(c, u);
drawBoard(c, w, u);
drawGears(c, w, u);
keepAnimating(moving || pulse > 0f || song, 33);
}
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
text.setTypeface(tf);
text.setTextSize(size);
text.setTextAlign(a);
text.setColor(colour);
c.drawText(s, x, y, text);
}
/** Text with a dark outline, to read over whatever is behind it. */
private void outlined(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a, float u) {
text.setStyle(Paint.Style.STROKE);
text.setStrokeWidth(Math.max(3f * u, size * 0.12f));
text.setStrokeJoin(Paint.Join.ROUND);
label(c, s, x, y, size, 0xDD0A0608, tf, a);
text.setStyle(Paint.Style.FILL);
label(c, s, x, y, size, colour, tf, a);
}
private boolean regen() {
return v.powerKw != null && v.powerKw < -0.05f;
}
// ---------- Night sky and hills ----------
private void buildSky(int w, int h, float u) {
sky = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
skyW = w;
skyH = h;
Canvas c = new Canvas(sky);
Paint p = new Paint();
p.setShader(new LinearGradient(0, 0, 0, h, new int[] { SKY_TOP, SKY_LOW, SKY_END },
new float[] { 0f, 0.7f, 1f }, Shader.TileMode.CLAMP));
c.drawRect(0, 0, w, h, p);
Paint star = new Paint(Paint.ANTI_ALIAS_FLAG);
star.setColor(0x99FFFFFF);
for (int i = 0; i < 90; i++) {
float x = ((i * 191 + 17) % 1009) / 1009f * w, y = ((i * 83 + 5) % 421) * u;
c.drawCircle(x, y, (i % 4 == 0 ? 1.7f : 0.9f) * u, star);
}
// Two ranges of snowy hills, with pines on the far one.
hills(c, w, h, h - 150f * u, 0xFF28496B, 0.8f, u);
Paint pine = new Paint(Paint.ANTI_ALIAS_FLAG);
pine.setColor(0xFF0F3A2A);
for (int i = 0; i < 14; i++) {
float x = ((i * 233 + 41) % 997) / 997f * w, tall = (36f + (i * 17) % 30) * u;
float base = h - 150f * u + 8f * u;
Path tri = new Path();
tri.moveTo(x, base - tall);
tri.lineTo(x + tall * 0.32f, base);
tri.lineTo(x - tall * 0.32f, base);
tri.close();
c.drawPath(tri, pine);
}
hills(c, w, h, h - 100f * u, 0xFF3C6286, 1.5f, u);
}
private void hills(Canvas c, int w, int h, float base, int colour, float wave, float u) {
Path hill = new Path();
hill.moveTo(0, h);
for (int i = 0; i <= 24; i++) {
float x = w * i / 24f;
hill.lineTo(x, base - (float) (Math.sin(i * wave) * 0.5 + Math.sin(i * wave * 0.37 + 1) * 0.5) * 22f * u);
}
hill.lineTo(w, h);
hill.close();
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
p.setColor(colour);
c.drawPath(hill, p);
}
private void drawSnow(Canvas c, int w, int h, float u) {
for (int i = 0; i < FLAKES; i++) {
float fall = (30f + (i % 5) * 12f) * u;
float y = (((i * 53) % 600) * u + time * fall) % h;
float x = ((i * 97 + 13) % 1000) / 1000f * w + (float) Math.sin(time * 0.8f + i) * 12f * u;
fill.setColor(i % 3 == 0 ? 0xE0FFFFFF : 0x90FFFFFF);
c.drawCircle(x, y, (1f + (i % 3) * 0.8f) * u, fill);
}
}
// ---------- The drift: the last two minutes of power ----------
private void drawDrift(Canvas c, int w, int h, float u) {
float base = h - 44f * u, amp = 36f * u;
float[] trail = v.powerTrail;
path.reset();
path.moveTo(0, h);
int n = trail == null ? 0 : trail.length;
if (n == 0) {
path.lineTo(0, base);
path.lineTo(w, base);
} else {
for (int i = 0; i < n; i++) {
float p = trail[i];
float height = Float.isNaN(p) ? 0f : Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f));
path.lineTo(i / (n - 1f) * w, base - height * amp);
}
}
path.lineTo(w, h);
path.close();
fill.setColor(SNOW);
c.drawPath(path, fill);
if (n > 1) {
line.setStrokeWidth(3f * u);
for (int i = 1; i < n; i++) {
float a = trail[i - 1], b = trail[i];
if (Float.isNaN(a) || Float.isNaN(b)) continue;
float ha = Math.min(1f, Math.abs(a) / (a < 0 ? 40f : 60f));
float hb = Math.min(1f, Math.abs(b) / (b < 0 ? 40f : 60f));
line.setColor(b < 0 ? ICE : GOLD);
c.drawLine((i - 1f) / (n - 1f) * w, base - ha * amp, i / (n - 1f) * w, base - hb * amp, line);
}
}
}
// ---------- The tree ----------
private void drawTree(Canvas c, int w, float u, float pulse) {
float cx = w / 2f;
// The trunk, then three tiers, each with a darker underside.
r.set(cx - 22f * u, 480f * u, cx + 22f * u, 526f * u);
fill.setColor(WOOD);
c.drawRect(r, fill);
tier(c, cx, 118f * u, 238f * u, 96f * u, u);
tier(c, cx, 190f * u, 358f * u, 136f * u, u);
tier(c, cx, 270f * u, 492f * u, 182f * u, u);
// The star on top, flaring with the music.
float sy = 104f * u, flare = 1f + pulse * 0.5f;
glow.setShader(new RadialGradient(cx, sy, 60f * u * flare,
new int[] { AccentColor.withAlpha(GOLD, 0x55 + (int) (pulse * 0x80)), AccentColor.withAlpha(GOLD, 0) }, null,
Shader.TileMode.CLAMP));
c.drawCircle(cx, sy, 60f * u * flare, glow);
path.reset();
for (int i = 0; i < 10; i++) {
double a = -Math.PI / 2 + Math.PI * i / 5;
float rad = (i % 2 == 0 ? 24f : 10f) * u;
float x = cx + (float) Math.cos(a) * rad, y = sy + (float) Math.sin(a) * rad;
if (i == 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
path.close();
fill.setColor(GOLD);
c.drawPath(path, fill);
// The lights: strung along each tier's lower edge, steady, twinkling a little while driving,
// flaring together on the beat.
float[][] strings = { { 238f, 96f, 5 }, { 358f, 136f, 7 }, { 492f, 182f, 10 } };
int i = 0;
for (float[] st : strings) {
int n = (int) st[2];
for (int k = 0; k < n; k++, i++) {
float x = cx - st[1] * 0.85f * u + k * (1.7f * st[1] * u) / (n - 1);
float y = (st[0] - 16f + (k % 2) * 9f) * u;
int colour = LIGHTS[i % LIGHTS.length];
float twinkle = 0.5f + 0.5f * (float) Math.sin(time * 3f + i * 1.7f);
int alpha = Math.min(0xFF, 0x58 + (int) (twinkle * 0x38) + (int) (pulse * 0x90));
fill.setColor(AccentColor.withAlpha(colour, alpha));
c.drawCircle(x, y, (11f + pulse * 9f) * u, fill);
fill.setColor(brighter(colour, pulse * 0.4f));
c.drawCircle(x, y, 4.8f * u * (1f + pulse * 0.35f), fill);
}
}
}
private void tier(Canvas c, float cx, float apex, float base, float half, float u) {
for (int pass = 0; pass < 2; pass++) {
float dy = pass == 0 ? 7f * u : 0f;
path.reset();
path.moveTo(cx, apex + dy);
path.lineTo(cx + half, base - 6f * u + dy);
int scallops = 5;
float sw = half * 2f / scallops;
for (int i = scallops; i > 0; i--) {
float x = cx - half + i * sw;
path.quadTo(x - sw / 2f, base + 12f * u + dy, x - sw, base - 6f * u + dy);
}
path.close();
fill.setColor(pass == 0 ? PINE_DARK : PINE);
c.drawPath(path, fill);
}
}
/** The colour moved {@code share} of the way to white. */
private static int brighter(int colour, float share) {
int r = (colour >> 16) & 0xFF, g = (colour >> 8) & 0xFF, b = colour & 0xFF;
return 0xFF000000 | (r + (int) ((255 - r) * share)) << 16 | (g + (int) ((255 - g) * share)) << 8
| (b + (int) ((255 - b) * share));
}
// ---------- The fairy lights over the tree: the power ----------
private void drawArch(Canvas c, int w, float u, float pulse) {
float cx = w / 2f, cy = 348f * u, radX = 272f * u, radY = 244f * u;
int lit = Tally.ofPower(v.powerKw, 150f, 50f, BULBS);
boolean regen = regen();
// The wire.
line.setColor(PINE_DARK);
line.setStrokeWidth(3f * u);
path.reset();
for (int i = 0; i <= 48; i++) {
double a = Math.PI + Math.PI * i / 48.0;
float x = cx + (float) Math.cos(a) * radX, y = cy + (float) Math.sin(a) * radY;
if (i == 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
c.drawPath(path, line);
for (int i = 0; i < BULBS; i++) {
// Left to right over the top; regeneration comes back from the right.
int k = regen ? BULBS - 1 - i : i;
double a = Math.PI + Math.PI * k / (BULBS - 1);
float x = cx + (float) Math.cos(a) * radX, y = cy + (float) Math.sin(a) * radY;
boolean on = i < lit;
if (on) {
fill.setColor(AccentColor.withAlpha(regen ? ICE : GOLD, 0x44 + (int) (pulse * 0x50)));
c.drawCircle(x, y, (15f + pulse * 6f) * u, fill);
}
fill.setColor(on ? brighter(regen ? ICE : GOLD, pulse * 0.4f) : 0x38FFFFFF);
c.drawCircle(x, y, 7.5f * u, fill);
}
}
// ---------- The big bauble: the speed ----------
private void drawBauble(Canvas c, int w, float u, float pulse) {
float cx = w / 2f, cy = 372f * u, rad = 88f * u;
glow.setShader(new RadialGradient(cx, cy, rad * (1.6f + pulse * 0.2f),
new int[] { AccentColor.withAlpha(RED, 0x40 + (int) (pulse * 0x50)), AccentColor.withAlpha(RED, 0) }, null,
Shader.TileMode.CLAMP));
c.drawCircle(cx, cy, rad * (1.6f + pulse * 0.2f), glow);
// The cap and its loop.
line.setColor(GOLD);
line.setStrokeWidth(3f * u);
r.set(cx - 12f * u, cy - rad - 30f * u, cx + 12f * u, cy - rad - 6f * u);
c.drawOval(r, line);
glow.setShader(new RadialGradient(cx - rad * 0.3f, cy - rad * 0.35f, rad * 1.3f,
new int[] { 0xFFFF7A82, RED, 0xFF8E1623 }, new float[] { 0f, 0.5f, 1f }, Shader.TileMode.CLAMP));
c.drawCircle(cx, cy, rad, glow);
r.set(cx - 16f * u, cy - rad - 12f * u, cx + 16f * u, cy - rad + 8f * u);
fill.setColor(GOLD);
c.drawRect(r, fill);
// A gold band round the middle's lower half, and a highlight.
line.setColor(0x88FFD04A);
line.setStrokeWidth(3f * u);
r.set(cx - rad + 6f * u, cy + 34f * u, cx + rad - 6f * u, cy + rad - 4f * u);
c.drawArc(r, 20f, 140f, false, line);
fill.setColor(0x55FFFFFF);
r.set(cx - rad * 0.62f, cy - rad * 0.72f, cx - rad * 0.2f, cy - rad * 0.42f);
c.drawOval(r, fill);
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, cy + 20f * u, 84f * u, BONE, serif, Paint.Align.CENTER);
label(c, "KM/H", cx, cy + 50f * u, 16f * u, 0xCCFFE9EA, face, Paint.Align.CENTER);
// The power under the tree, above the drift.
boolean regen = regen();
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
Math.abs(v.powerKw));
outlined(c, power, cx, 556f * u, 34f * u, regen ? ICE : BONE, face, Paint.Align.CENTER, u);
}
// ---------- The wreath: the clock ----------
private void drawWreath(Canvas c, int w, float u) {
float rad = 74f * u, cx = w - 100f * u - rad * 0.2f, cy = 96f * u;
line.setColor(PINE);
line.setStrokeWidth(24f * u);
c.drawCircle(cx, cy, rad, line);
line.setColor(PINE_DARK);
line.setStrokeWidth(5f * u);
c.drawCircle(cx, cy, rad - 8f * u, line);
fill.setColor(0xFF2E9C52);
for (int i = 0; i < 16; i++) {
double a = Math.PI * 2 * i / 16;
c.drawCircle(cx + (float) Math.cos(a) * rad, cy + (float) Math.sin(a) * rad, 8f * u, fill);
}
fill.setColor(RED);
for (int i = 0; i < 9; i++) {
double a = Math.PI * 2 * i / 9 + 0.3;
c.drawCircle(cx + (float) Math.cos(a) * rad, cy + (float) Math.sin(a) * rad, 4.5f * u, fill);
}
// The bow at the foot.
path.reset();
float bx = cx, by = cy + rad;
path.moveTo(bx, by);
path.lineTo(bx - 28f * u, by - 14f * u);
path.lineTo(bx - 28f * u, by + 14f * u);
path.close();
path.moveTo(bx, by);
path.lineTo(bx + 28f * u, by - 14f * u);
path.lineTo(bx + 28f * u, by + 14f * u);
path.close();
fill.setColor(RED);
c.drawPath(path, fill);
c.drawCircle(bx, by, 7f * u, fill);
// The clock face inside.
fill.setColor(0xFFFFF6E0);
c.drawCircle(cx, cy, rad - 18f * u, fill);
line.setColor(GOLD);
line.setStrokeWidth(3f * u);
c.drawCircle(cx, cy, rad - 18f * u, line);
label(c, v.clock.isEmpty() ? "—" : v.clock, cx, cy + 14f * u, 40f * u, INK, serif, Paint.Align.CENTER);
}
// ---------- Gift tags: trip, usage, climate, odometer ----------
private void drawTags(Canvas c, float u) {
float l = 28f * u, rr = l + 300f * u, t = 44f * u, tag = 34f * u, step = 38f * u;
// The garland they hang from.
line.setColor(PINE);
line.setStrokeWidth(8f * u);
c.drawLine(l + 10f * u, 20f * u, rr - 10f * u, 20f * u, line);
fill.setColor(RED);
for (int i = 0; i < 7; i++) c.drawCircle(l + 24f * u + i * 44f * u, 20f * u, 4f * u, fill);
String[][] rows = {
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
};
for (int i = 0; i < rows.length; i++) {
float top = t + i * step;
// A tag with a clipped corner and a hole, hung on a thread from the one above.
path.reset();
path.moveTo(l + 18f * u, top);
path.lineTo(rr, top);
path.lineTo(rr, top + tag);
path.lineTo(l + 18f * u, top + tag);
path.lineTo(l, top + tag / 2f);
path.close();
fill.setColor(i % 2 == 0 ? 0xFFF6EBD0 : 0xFFEAD9B6);
c.drawPath(path, fill);
line.setColor(RED);
line.setStrokeWidth(2f * u);
c.drawPath(path, line);
line.setColor(0xCCFFFFFF);
line.setStrokeWidth(1.5f * u);
c.drawLine(l + 12f * u, top - (i == 0 ? 20f * u - 0f : step - tag), l + 12f * u, top + tag / 2f, line);
fill.setColor(0xFF2A1A10);
c.drawCircle(l + 12f * u, top + tag / 2f, 3f * u, fill);
label(c, rows[i][0], l + 28f * u, top + 23f * u, 17f * u, 0xFF8E1623, thin, Paint.Align.LEFT);
label(c, rows[i][1], rr - 12f * u, top + 25f * u, 22f * u, INK, face, Paint.Align.RIGHT);
}
}
// ---------- The mug of cocoa: the battery ----------
private void drawMug(Canvas c, float u) {
float cx = 178f * u, l = cx - 92f * u, rr = cx + 92f * u, top = 330f * u, bottom = 560f * u;
boolean low = v.socPercent != null && v.socPercent < 20f;
boolean blinkOn = (SystemClock.uptimeMillis() / 500) % 2 == 0;
float corner = 22f * u, inner = 6f * u;
// The handle.
line.setColor(0xFFCFE6F0);
line.setStrokeWidth(14f * u);
r.set(rr - 26f * u, top + 50f * u, rr + 40f * u, top + 170f * u);
c.drawArc(r, -90f, 180f, false, line);
// The glass.
r.set(l, top, rr, bottom);
fill.setColor(0x30CFE6F0);
c.drawRoundRect(r, corner, corner, fill);
if (v.socPercent != null) {
float share = Math.max(0f, Math.min(1f, v.socPercent / 100f));
float level = bottom - inner - share * (bottom - top - 2f * inner);
c.save();
r.set(l + inner, top + inner, rr - inner, bottom - inner);
path.reset();
path.addRoundRect(r, corner - inner, corner - inner, Path.Direction.CW);
c.clipPath(path);
fill.setColor(low ? COLD : COCOA);
c.drawRect(l, level, rr, bottom, fill);
fill.setColor(0x33FFFFFF);
c.drawRect(l, level, rr, level + 4f * u, fill);
c.restore();
// Marshmallows float on top, bobbing while driving.
for (int i = 0; i < 3; i++) {
float mx = cx + (i - 1) * 50f * u, my = level - 8f * u + (float) Math.sin(time * 2f + i * 2f) * 2f * u;
r.set(mx - 14f * u, my - 12f * u, mx + 14f * u, my + 8f * u);
fill.setColor(BONE);
c.drawRoundRect(r, 6f * u, 6f * u, fill);
}
// Steam.
line.setColor(0x66FFFFFF);
line.setStrokeWidth(4f * u);
for (int i = 0; i < 3; i++) {
float sx = cx + (i - 1) * 44f * u;
path.reset();
path.moveTo(sx, level - 24f * u);
for (int k = 1; k <= 6; k++) {
path.lineTo(sx + (float) Math.sin(time * 2.5f + i + k * 0.9f) * 9f * u, level - 24f * u - k * 11f * u);
}
c.drawPath(path, line);
}
}
line.setColor(0xFFCFE6F0);
line.setStrokeWidth(4f * u);
r.set(l, top, rr, bottom);
c.drawRoundRect(r, corner, corner, line);
String number = fmt(v.socPercent, "%.0f %%", "—");
outlined(c, number, cx, 470f * u, 58f * u, low ? 0xFFFF9AA3 : BONE, serif, Paint.Align.CENTER, u);
if (low && blinkOn) outlined(c, "BATTERY LOW", cx, 508f * u, 21f * u, BONE, face, Paint.Align.CENTER, u);
else if (!low) outlined(c, "BATTERY", cx, 508f * u, 19f * u, GOLD, face, Paint.Align.CENTER, u);
}
// ---------- The signboard and candy canes: the range; baubles: the gears ----------
private void drawBoard(Canvas c, int w, float u) {
float rr = w - 40f * u, l = rr - 250f * u, t = 214f * u, b = 394f * u, mid = (l + rr) / 2f;
r.set(l, t, rr, b);
fill.setColor(WOOD);
c.drawRoundRect(r, 10f * u, 10f * u, fill);
line.setColor(0xFF8A5A33);
line.setStrokeWidth(3f * u);
c.drawRoundRect(r, 10f * u, 10f * u, line);
// Snow on top.
path.reset();
path.moveTo(l - 4f * u, t + 4f * u);
path.quadTo(l + 40f * u, t - 24f * u, l + 90f * u, t - 4f * u);
path.quadTo(l + 140f * u, t - 28f * u, l + 190f * u, t - 6f * u);
path.quadTo(l + 230f * u, t - 16f * u, rr + 4f * u, t + 4f * u);
path.lineTo(rr + 4f * u, t + 12f * u);
path.lineTo(l - 4f * u, t + 12f * u);
path.close();
fill.setColor(SNOW);
c.drawPath(path, fill);
label(c, "RANGE", mid, t + 52f * u, 20f * u, GOLD, face, Paint.Align.CENTER);
label(c, fmt(v.rangeKm, "%.0f", "—"), mid, t + 128f * u, 80f * u, BONE, serif, Paint.Align.CENTER);
label(c, "KM", mid, t + 160f * u, 20f * u, GOLD, face, Paint.Align.CENTER);
// A candy cane per 50 km in the snow below.
int lit = Tally.lit(v.rangeKm, 50f, CANES);
for (int i = 0; i < CANES; i++) {
cane(c, l + 18f * u + i * 30f * u, 488f * u, u, i < lit);
}
}
private void cane(Canvas c, float x, float foot, float u, boolean on) {
path.reset();
path.moveTo(x, foot);
path.lineTo(x, foot - 58f * u);
path.cubicTo(x, foot - 78f * u, x + 18f * u, foot - 78f * u, x + 18f * u, foot - 62f * u);
line.setStrokeWidth(9f * u);
line.setColor(on ? 0xFFFFFFFF : 0x40FFFFFF);
c.drawPath(path, line);
line.setColor(on ? RED : 0x40E53946);
line.setPathEffect(new DashPathEffect(new float[] { 7f * u, 7f * u }, 0f));
c.drawPath(path, line);
line.setPathEffect(null);
}
private void drawGears(Canvas c, int w, float u) {
float rr = w - 40f * u, l = rr - 250f * u, cy = 540f * u, rad = 25f * u;
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float step = (rr - l) / 4f;
for (int i = 0; i < gears.length; i++) {
float mid = l + (i + 0.5f) * step;
boolean on = gears[i].equals(now);
if (on) {
fill.setColor(0x55FFD04A);
c.drawCircle(mid, cy, 42f * u, fill);
}
fill.setColor(GOLD);
r.set(mid - 7f * u, cy - rad - 9f * u, mid + 7f * u, cy - rad + 3f * u);
c.drawRect(r, fill);
fill.setColor(on ? GOLD : 0xFF7A1F2A);
c.drawCircle(mid, cy, rad, fill);
line.setColor(on ? 0xFFFFF0B0 : 0xFFB03A48);
line.setStrokeWidth(2f * u);
c.drawCircle(mid, cy, rad, line);
label(c, gears[i], mid, cy + 11f * u, 30f * u, on ? INK : 0xB0FFFFFF, serif, Paint.Align.CENTER);
}
}
// ---------- What plays: a sleigh or an angel tows it on wrapping paper ----------
/**
* Santa's sleigh and an angel take turns towing a waving strip of wrapping paper with the song
* across the sky above the tree, behind the wreath and the tags, again and again while
* something plays. The paper's colour is fixed by the song. Returns whether it is drawn, so the
* view keeps animating for it.
*/
private boolean drawSong(Canvas c, int w, float u) {
if (v.songTitle == null) return false;
text.setTypeface(face);
text.setTextSize(19f * u);
text.setStyle(Paint.Style.FILL);
String s = SongBanner.line(text, v.songTitle, v.songArtist, Math.min(560f * u, w * 0.45f));
float t = SystemClock.uptimeMillis() / 1000f;
float bw = text.measureText(s) + 40f * u, bh = 28f * u, tow = 80f * u;
float bx = SongBanner.left(t, w, bw, tow, u);
float yc = 66f * u + (float) Math.sin(t * 1.3f) * 3f * u;
int paper = PAPER[Math.floorMod((v.songTitle + "\n" + v.songArtist).hashCode(), PAPER.length)];
boolean darkInk = paper == PAPER[3] || paper == PAPER[4];
banner.draw(c, fill, line, text, s, bx, yc, bw, bh, t, u, 0f, paper, 0xCCFFFFFF, darkInk ? INK : 0xFFFFFFFF);
boolean sleigh = SongBanner.pass(t, w, bw, tow, u) % 2 == 0;
float right = bx + bw, cx = right + tow, cy = yc - 6f * u, size = (sleigh ? 70f : 56f) * u;
float[] tail = sleigh ? SongCarriers.sleighTail : SongCarriers.angelTail;
float ax = cx + tail[0] * size, ay = cy + tail[1] * size;
line.setColor(0xCCFFFFFF);
line.setStrokeWidth(1.5f * u);
c.drawLine(right, yc - bh / 2f + 3f * u, ax, ay, line);
c.drawLine(right, yc + bh / 2f - 3f * u, ax, ay, line);
if (sleigh) SongCarriers.sleigh(c, fill, line, path, r, cx, cy, size, t, u);
else SongCarriers.angel(c, fill, line, path, r, cx, cy, size, (float) Math.sin(t * 7f), u);
return true;
}
}
@@ -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;
} }
@@ -38,6 +38,7 @@ final class EmpireView extends ClusterCanvasView {
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path = new Path(); private final Path path = new Path();
private final SongBanner banner = new SongBanner();
private final RectF r = new RectF(); private final RectF r = new RectF();
private final Typeface serif = Typeface.create(Typeface.SERIF, Typeface.BOLD); private final Typeface serif = Typeface.create(Typeface.SERIF, Typeface.BOLD);
private final Typeface sans = Typeface.create("sans-serif-condensed", Typeface.BOLD); private final Typeface sans = Typeface.create("sans-serif-condensed", Typeface.BOLD);
@@ -61,10 +62,38 @@ final class EmpireView extends ClusterCanvasView {
advance(moving); advance(moving);
float top = 46f * u, bottom = h - 186f * u; float top = 46f * u, bottom = h - 186f * u;
drawWorld(c, w, top, bottom, u); drawWorld(c, w, top, bottom, u);
boolean song = drawSong(c, w, top, u);
drawSpeed(c, w, top, u); drawSpeed(c, w, top, u);
drawResources(c, w, top, u); drawResources(c, w, top, u);
drawPanel(c, w, h, bottom, u); drawPanel(c, w, h, bottom, u);
keepAnimating(moving, 33); keepAnimating(moving || song, 33);
}
// ---------- What plays: an eagle brings it on a scroll ----------
/**
* An eagle flies a waving scroll with the song across the top of the world, behind the speed,
* again and again while something plays. Returns whether it is drawn, so the view keeps animating.
*/
private boolean drawSong(Canvas c, float w, float top, float u) {
if (v.songTitle == null) return false;
text.setTypeface(serif);
text.setTextSize(19f * u);
text.setStyle(Paint.Style.FILL);
String s = SongBanner.line(text, v.songTitle, v.songArtist, Math.min(560f * u, w * 0.45f));
float t = SystemClock.uptimeMillis() / 1000f;
float bw = text.measureText(s) + 40f * u, bh = 28f * u, size = 58f * u, tow = size * 1.0f;
float bx = SongBanner.left(t, Math.round(w), bw, tow, u);
float yc = top + 30f * u + (float) Math.sin(t * 1.3f) * 3f * u;
banner.draw(c, fill, line, text, s, bx, yc, bw, bh, t, u, 0f, 0xFFEFE0B8, WOOD_DARK, 0xFF2A1A08);
float right = bx + bw, cx = right + tow, cy = yc - 10f * u;
line.setColor(0xCCFFFFFF);
line.setStrokeWidth(1.5f * u);
float ax = cx + SongCarriers.eagleTail[0] * size, ay = cy + SongCarriers.eagleTail[1] * size;
c.drawLine(right, yc - bh / 2f + 3f * u, ax, ay, line);
c.drawLine(right, yc + bh / 2f - 3f * u, ax, ay, line);
SongCarriers.eagle(c, fill, line, path, r, cx, cy, size, (float) Math.sin(t * 7f), t, u);
return true;
} }
private void advance(boolean moving) { private void advance(boolean moving) {
@@ -0,0 +1,598 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RadialGradient;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Halloween Overview style: a graveyard hill under a full moon.
*
* In the middle a jack-o'-lantern with the speed cut into its grin; its carving burns candle yellow
* while drawing and witch-fire green while regenerating, and the arch of embers over it is the
* power, left to right while drawing, green and right to left while regenerating. On the left a
* potion flask: the battery, red with "BATTERY LOW" blinking below 20 %, under a sign hanging from
* a cobweb with trip, usage, climate and odometer. On the right the moon holds the clock, and a
* tombstone reads the range ("here lies … km"); P R N D are four small gravestones, the one engaged
* lit. The bats are the range too, one per 50 km. The hill along the bottom is the last two minutes
* 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
* beat ({@link MusicTap}); colours and the embers keep their meaning, and with no music it burns as
* before. What plays flies across the sky on a banner a bat tows ({@link NowPlaying}).
*
* 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 light's pulse keeps it drawing while there is one.
*/
final class HalloweenView extends ClusterCanvasView {
private static final int EMBERS = 25, BATS = 8;
private static final int SKY_TOP = 0xFF0B0620, SKY_LOW = 0xFF2A1140, HILL = 0xFF0A0612;
private static final int PUMPKIN = 0xFFF2760F, PUMPKIN_DARK = 0xFFB84A06, PUMPKIN_LIT = 0xFFFF9A2E;
private static final int CANDLE = 0xFFFFD84A, FIRE = 0xFF6BFF5A, RED = 0xFFFF3B30, STONE = 0xFF6C6A78;
private static final int STONE_DARK = 0xFF3E3C4A, MOON = 0xFFF4E9B8, MOON_SHADE = 0xFFD8C98C;
private static final int POTION = 0xFF7BF03A, GLASS = 0xFFB9D8D0, BONE = 0xFFF2EAD8, ORANGE = 0xFFFF8C1A;
private static final int STEM = 0xFF4E7A2B, WOOD = 0xFF2E1B10, INK = 0xFF140A08;
private static final int BAT = 0xFF6F6390, BAT_EDGE = 0xFFD6CCEA;
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint image = new Paint(Paint.FILTER_BITMAP_FLAG);
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path = new Path();
private final SongBanner banner = new SongBanner();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
private Bitmap sky;
private int skyW, skyH;
private float time;
private long lastFrame;
private MusicTap music;
HalloweenView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.ROUND);
}
/** Where the pumpkin's light hears the music; without it, the light does not follow any. */
void setMusic(MusicTap tap) {
music = tap;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
sky = null;
skyW = 0;
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
int w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
if (moving) {
long now = SystemClock.uptimeMillis();
time += lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
} else {
lastFrame = 0;
}
if (sky == null || skyW != w || skyH != h) buildSky(w, h, u);
c.drawBitmap(sky, 0, 0, image);
drawBats(c, w, u);
boolean song = drawSong(c, w, u);
drawMoon(c, w, u);
drawGhost(c, w, u);
drawHill(c, w, h, u);
drawEmbers(c, w, u);
float pulse = music == null ? 0f : music.pulse();
drawPumpkin(c, w, u, pulse);
drawSign(c, u);
drawFlask(c, u);
drawTombstone(c, w, u);
drawGears(c, w, u);
keepAnimating(moving || pulse > 0f || song, 33);
}
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
text.setTypeface(tf);
text.setTextSize(size);
text.setTextAlign(a);
text.setColor(colour);
c.drawText(s, x, y, text);
}
private boolean regen() {
return v.powerKw != null && v.powerKw < -0.05f;
}
// ---------- Sky ----------
private void buildSky(int w, int h, float u) {
sky = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
skyW = w;
skyH = h;
Canvas c = new Canvas(sky);
Paint p = new Paint();
p.setShader(new LinearGradient(0, 0, 0, h, new int[] { SKY_TOP, SKY_LOW, 0xFF4A1A3A },
new float[] { 0f, 0.7f, 1f }, Shader.TileMode.CLAMP));
c.drawRect(0, 0, w, h, p);
Paint star = new Paint(Paint.ANTI_ALIAS_FLAG);
star.setColor(0x80FFFFFF);
for (int i = 0; i < 90; i++) {
float x = ((i * 191 + 17) % 1009) / 1009f * w, y = ((i * 83 + 5) % 421) * u;
c.drawCircle(x, y, (i % 4 == 0 ? 1.7f : 0.9f) * u, star);
}
// Bare trees in the distance.
tree(c, w * 0.235f, 590f * u, 150f * u, u);
tree(c, w * 0.745f, 590f * u, 118f * u, u);
}
/** A bare tree: a trunk and branches that fork twice. */
private void tree(Canvas c, float x, float y, float tall, float u) {
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
p.setStyle(Paint.Style.STROKE);
p.setStrokeCap(Paint.Cap.ROUND);
p.setColor(0xFF160B20);
branch(c, p, x, y, -90f, tall * 0.5f, 7f * u, 4);
}
private void branch(Canvas c, Paint p, float x, float y, float deg, float len, float width, int depth) {
double a = Math.toRadians(deg);
float nx = x + (float) Math.cos(a) * len, ny = y + (float) Math.sin(a) * len;
p.setStrokeWidth(width);
c.drawLine(x, y, nx, ny, p);
if (depth == 0) return;
branch(c, p, nx, ny, deg - 24f - depth * 3f, len * 0.68f, width * 0.65f, depth - 1);
branch(c, p, nx, ny, deg + 28f - depth * 2f, len * 0.62f, width * 0.65f, depth - 1);
}
// ---------- Moon and clock ----------
private void drawMoon(Canvas c, int w, float u) {
float rad = 74f * u, cx = w - 100f * u - rad * 0.2f, cy = 96f * u;
glow.setShader(new RadialGradient(cx, cy, rad * 2.2f, new int[] { 0x55F4E9B8, 0x00F4E9B8 }, null,
Shader.TileMode.CLAMP));
c.drawCircle(cx, cy, rad * 2.2f, glow);
fill.setColor(MOON);
c.drawCircle(cx, cy, rad, fill);
fill.setColor(MOON_SHADE);
c.drawCircle(cx - 42f * u, cy - 34f * u, 12f * u, fill);
c.drawCircle(cx + 40f * u, cy + 38f * u, 16f * u, fill);
c.drawCircle(cx + 46f * u, cy - 26f * u, 7f * u, fill);
c.drawCircle(cx - 30f * u, cy + 44f * u, 8f * u, fill);
label(c, v.clock.isEmpty() ? "—" : v.clock, cx, cy + 17f * u, 50f * u, INK, serif, Paint.Align.CENTER);
}
// ---------- Bats: the range ----------
private void drawBats(Canvas c, int w, float u) {
int lit = Tally.lit(v.rangeKm, 50f, BATS);
float cx = w / 2f;
for (int i = 0; i < lit; i++) {
// Each its own lane high in the sky, clear of the pumpkin's middle.
float lane = (40f + (i * 37) % 70) * u;
float span = w * 0.92f, speed = (26f + i * 5f) * u;
float x = ((i * 0.13f * span + time * speed) % span) + w * 0.04f;
float y = lane + (float) Math.sin(time * 2f + i) * 8f * u;
if (Math.abs(x - cx) < 60f * u) y -= 10f * u;
bat(c, x, y, (i % 2 == 0 ? 20f : 16f) * u, (float) Math.sin(time * 14f + i * 2f), u);
}
}
private void bat(Canvas c, float x, float y, float s, float flap, float u) {
float tip = -s * 0.5f * flap;
path.reset();
path.moveTo(x, y - s * 0.3f);
path.quadTo(x + s * 0.6f, y - s * 0.7f + tip, x + s * 1.5f, y - s * 0.3f + tip);
path.quadTo(x + s * 1.1f, y + s * 0.05f, x + s * 0.9f, y + s * 0.35f);
path.quadTo(x + s * 0.6f, y + s * 0.05f, x + s * 0.35f, y + s * 0.4f);
path.quadTo(x + s * 0.15f, y + s * 0.15f, x, y + s * 0.5f);
path.quadTo(x - s * 0.15f, y + s * 0.15f, x - s * 0.35f, y + s * 0.4f);
path.quadTo(x - s * 0.6f, y + s * 0.05f, x - s * 0.9f, y + s * 0.35f);
path.quadTo(x - s * 1.1f, y + s * 0.05f, x - s * 1.5f, y - s * 0.3f + tip);
path.quadTo(x - s * 0.6f, y - s * 0.7f + tip, x, y - s * 0.3f);
path.close();
// Lit by the moon: black would vanish in the night sky.
fill.setColor(BAT);
c.drawPath(path, fill);
line.setColor(BAT_EDGE);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
}
// ---------- Ghost: only where there is room ----------
private void drawGhost(Canvas c, int w, float u) {
if (w / u < 1400f) return;
float x = (w / 2f + 272f * u + w - 290f * u) / 2f, y = 330f * u + (float) Math.sin(time * 1.4f) * 12f * u, s = 34f * u;
path.reset();
path.moveTo(x - s, y + s * 1.5f);
path.lineTo(x - s, y);
path.cubicTo(x - s, y - s * 1.4f, x + s, y - s * 1.4f, x + s, y);
path.lineTo(x + s, y + s * 1.5f);
float wob = (float) Math.sin(time * 3f) * s * 0.12f;
path.quadTo(x + s * 0.66f, y + s * 1.1f + wob, x + s * 0.33f, y + s * 1.5f);
path.quadTo(x, y + s * 1.1f - wob, x - s * 0.33f, y + s * 1.5f);
path.quadTo(x - s * 0.66f, y + s * 1.1f + wob, x - s, y + s * 1.5f);
path.close();
fill.setColor(0xB8F0F4FF);
c.drawPath(path, fill);
fill.setColor(0xFF1A1030);
c.drawCircle(x - s * 0.34f, y - s * 0.2f, s * 0.14f, fill);
c.drawCircle(x + s * 0.34f, y - s * 0.2f, s * 0.14f, fill);
r.set(x - s * 0.14f, y + s * 0.08f, x + s * 0.14f, y + s * 0.44f);
c.drawOval(r, fill);
}
// ---------- The hill: the last two minutes of power ----------
private void drawHill(Canvas c, int w, int h, float u) {
float base = h - 44f * u, amp = 36f * u;
float[] trail = v.powerTrail;
path.reset();
path.moveTo(0, h);
int n = trail == null ? 0 : trail.length;
if (n == 0) {
path.lineTo(0, base);
path.lineTo(w, base);
} else {
for (int i = 0; i < n; i++) {
float p = trail[i];
float height = Float.isNaN(p) ? 0f : Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f));
path.lineTo(i / (n - 1f) * w, base - height * amp);
}
}
path.lineTo(w, h);
path.close();
fill.setColor(HILL);
c.drawPath(path, fill);
// The ridge line, each stretch in the colour of what the car was doing then.
if (n > 1) {
line.setStrokeWidth(3f * u);
for (int i = 1; i < n; i++) {
float a = trail[i - 1], b = trail[i];
if (Float.isNaN(a) || Float.isNaN(b)) continue;
float ha = Math.min(1f, Math.abs(a) / (a < 0 ? 40f : 60f));
float hb = Math.min(1f, Math.abs(b) / (b < 0 ? 40f : 60f));
line.setColor(b < 0 ? FIRE : ORANGE);
c.drawLine((i - 1f) / (n - 1f) * w, base - ha * amp, i / (n - 1f) * w, base - hb * amp, line);
}
}
}
// ---------- What plays: a bat tows it across the sky on a banner ----------
/**
* A bat pulls a waving parchment banner with the song from left to right, high in the sky, behind the
* sign and the moon, again and again while something plays. Returns whether it is drawn, so
* the view keeps animating for it.
*/
private boolean drawSong(Canvas c, int w, float u) {
if (v.songTitle == null) return false;
text.setTypeface(face);
text.setTextSize(19f * u);
String s = SongBanner.line(text, v.songTitle, v.songArtist, Math.min(560f * u, w * 0.45f));
float t = SystemClock.uptimeMillis() / 1000f;
float bw = text.measureText(s) + 40f * u, bh = 30f * u, tow = 56f * u;
float bx = SongBanner.left(t, w, bw, tow, u);
// In the strip of sky above the sign, clear of it.
float yc = 34f * u + (float) Math.sin(t * 1.3f) * 4f * u;
banner.draw(c, fill, line, text, s, bx, yc, bw, bh, t, u, 0f, BONE, 0xFF8A6A3A, INK);
// The tow lines, and the bat at their end.
float right = bx + bw, batX = right + tow, batY = yc - 10f * u;
line.setColor(BAT_EDGE);
line.setStrokeWidth(1.5f * u);
c.drawLine(right, yc - bh / 2f + 3f * u, batX, batY + 6f * u, line);
c.drawLine(right, yc + bh / 2f - 3f * u, batX, batY + 6f * u, line);
bat(c, batX, batY, 26f * u, (float) Math.sin(t * 12f), u);
return true;
}
// ---------- The arch of embers: the power ----------
private void drawEmbers(Canvas c, int w, float u) {
float cx = w / 2f, cy = 348f * u, radX = 272f * u, radY = 244f * u;
int lit = Tally.ofPower(v.powerKw, 150f, 50f, EMBERS);
boolean regen = v.powerKw != null && v.powerKw < 0f;
for (int i = 0; i < EMBERS; i++) {
// Left to right over the top; regeneration comes back from the right.
int k = regen ? EMBERS - 1 - i : i;
double a = Math.PI + Math.PI * k / (EMBERS - 1);
float x = cx + (float) Math.cos(a) * radX, y = cy + (float) Math.sin(a) * radY;
boolean on = i < lit;
if (on) {
fill.setColor(AccentColor.withAlpha(regen ? FIRE : ORANGE, 0x44));
c.drawCircle(x, y, 15f * u, fill);
}
fill.setColor(on ? (regen ? FIRE : CANDLE) : 0x38FFFFFF);
c.drawCircle(x, y, 7.5f * u, fill);
}
}
// ---------- The jack-o'-lantern: the speed ----------
private void drawPumpkin(Canvas c, int w, float u, float pulse) {
float cx = w / 2f, cy = 348f * u, rx = 200f * u, ry = 158f * u;
boolean regen = regen();
// The carving brightens on the beat, gently, and stays yellow or green.
int burn = brighter(regen ? FIRE : CANDLE, pulse * 0.35f);
// The light it throws, stronger the more power, swelling with the music.
float share = v.powerKw == null ? 0f : Math.min(1f, Math.abs(v.powerKw) / 80f);
float reach = rx * (1.6f + pulse * 0.2f);
glow.setShader(new RadialGradient(cx, cy, reach,
new int[] { AccentColor.withAlpha(regen ? FIRE : ORANGE, 0x30 + (int) (share * 0x50) + (int) (pulse * 0x60)),
AccentColor.withAlpha(regen ? FIRE : ORANGE, 0) }, null, Shader.TileMode.CLAMP));
c.drawCircle(cx, cy, reach, glow);
// The stem.
path.reset();
path.moveTo(cx - 22f * u, cy - ry + 12f * u);
path.quadTo(cx - 14f * u, cy - ry - 26f * u, cx + 14f * u, cy - ry - 44f * u);
path.lineTo(cx + 34f * u, cy - ry - 30f * u);
path.quadTo(cx + 14f * u, cy - ry - 12f * u, cx + 22f * u, cy - ry + 12f * u);
path.close();
fill.setColor(STEM);
c.drawPath(path, fill);
// The ribs, outermost first, overlapping.
float[][] ribs = { { -0.70f, 0.42f }, { 0.70f, 0.42f }, { -0.40f, 0.48f }, { 0.40f, 0.48f }, { 0f, 0.50f } };
for (int i = 0; i < ribs.length; i++) {
float rcx = cx + ribs[i][0] * rx, half = ribs[i][1] * rx;
r.set(rcx - half, cy - ry * (i < 2 ? 0.86f : 0.98f), rcx + half, cy + ry * (i < 2 ? 0.86f : 0.98f));
fill.setColor(i % 2 == 0 ? PUMPKIN : PUMPKIN_LIT);
if (i == ribs.length - 1) fill.setColor(PUMPKIN);
c.drawOval(r, fill);
line.setColor(PUMPKIN_DARK);
line.setStrokeWidth(2.5f * u);
c.drawOval(r, line);
}
// Eyes and nose: triangles, burning.
triangle(c, cx - 88f * u, cy - 92f * u, cx - 34f * u, cy - 28f * u, cx - 128f * u, cy - 28f * u, burn, u);
triangle(c, cx + 88f * u, cy - 92f * u, cx + 128f * u, cy - 28f * u, cx + 34f * u, cy - 28f * u, burn, u);
triangle(c, cx, cy - 28f * u, cx + 19f * u, cy + 4f * u, cx - 19f * u, cy + 4f * u, burn, u);
// The grin, with the speed cut dark into its light.
float gt = cy + 8f * u, gb = cy + 122f * u, gw = 156f * u;
path.reset();
path.moveTo(cx - gw, gt);
path.quadTo(cx, gt + 26f * u, cx + gw, gt);
path.quadTo(cx + gw * 0.82f, gb, cx, gb);
path.quadTo(cx - gw * 0.82f, gb, cx - gw, gt);
path.close();
fill.setColor(0xFF4A1F04);
c.drawPath(path, fill);
c.save();
c.scale(0.965f, 0.93f, cx, (gt + gb) / 2f);
fill.setColor(burn);
c.drawPath(path, fill);
c.restore();
// Two teeth at the grin's corners, clear of the digits, each the colour of the rib it hangs from.
tooth(c, cx, -120f * u, gw, gt, 26f * u, 17f * u, gt + 40f * u, PUMPKIN, u);
tooth(c, cx, 120f * u, gw, gt, 26f * u, 17f * u, gt + 40f * u, PUMPKIN_LIT, u);
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, gb - 18f * u, 92f * u, INK, serif, Paint.Align.CENTER);
label(c, "KM/H", cx + 80f * u, cy + 64f * u, 16f * u, INK, face, Paint.Align.LEFT);
// The power under the pumpkin, above the ridge.
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
Math.abs(v.powerKw));
label(c, power, cx, cy + ry + 44f * u, 34f * u, regen ? FIRE : BONE, face, Paint.Align.CENTER);
}
/** The colour moved {@code share} of the way to white. */
private static int brighter(int colour, float share) {
int r = (colour >> 16) & 0xFF, g = (colour >> 8) & 0xFF, b = colour & 0xFF;
return 0xFF000000 | (r + (int) ((255 - r) * share)) << 16 | (g + (int) ((255 - g) * share)) << 8
| (b + (int) ((255 - b) * share));
}
private void triangle(Canvas c, float x1, float y1, float x2, float y2, float x3, float y3, int burn, float u) {
path.reset();
path.moveTo(x1, y1);
path.lineTo(x2, y2);
path.lineTo(x3, y3);
path.close();
fill.setColor(burn);
c.drawPath(path, fill);
line.setColor(0xFF4A1F04);
line.setStrokeWidth(4f * u);
c.drawPath(path, line);
}
/** A triangle hanging from the grin's upper lip (a curve sagging by {@code sag}) to {@code tipY}. */
private void tooth(Canvas c, float cx, float dx, float gw, float gt, float sag, float halfW, float tipY, int colour, float u) {
float left = lip(dx - halfW, gw, gt, sag), right = lip(dx + halfW, gw, gt, sag);
path.reset();
// A little above the lip, so none of its dark edge shows across the tooth's root.
path.moveTo(cx + dx - halfW, left - 1.5f * u);
path.lineTo(cx + dx + halfW, right - 1.5f * u);
path.lineTo(cx + dx, tipY);
path.close();
fill.setColor(colour);
c.drawPath(path, fill);
line.setColor(0xFF4A1F04);
line.setStrokeWidth(3f * u);
c.drawLine(cx + dx - halfW, left + 2f * u, cx + dx, tipY, line);
c.drawLine(cx + dx + halfW, right + 2f * u, cx + dx, tipY, line);
}
/** The height of the grin's upper lip {@code dx} from the middle. */
private static float lip(float dx, float gw, float gt, float sag) {
float t = (gw - Math.abs(dx)) / (2f * gw);
return gt + 2f * t * (1f - t) * sag;
}
// ---------- Left: the sign under the cobweb ----------
private void drawSign(Canvas c, float u) {
// The cobweb in the corner.
float cs = 150f * u;
line.setColor(0x70E8E8F4);
line.setStrokeWidth(1.2f * u);
for (int i = 0; i <= 5; i++) {
double a = Math.PI / 2 * i / 5;
c.drawLine(0, 0, (float) Math.cos(a) * cs, (float) Math.sin(a) * cs, line);
}
for (int ring = 1; ring <= 4; ring++) {
float d = cs * ring / 4f;
path.reset();
for (int i = 0; i <= 5; i++) {
double a = Math.PI / 2 * i / 5;
float x = (float) Math.cos(a) * d, y = (float) Math.sin(a) * d;
if (i == 0) {
path.moveTo(x, y);
} else {
double mid = Math.PI / 2 * (i - 0.5) / 5;
path.quadTo((float) Math.cos(mid) * d * 0.86f, (float) Math.sin(mid) * d * 0.86f, x, y);
}
}
c.drawPath(path, line);
}
// The sign, on two threads.
float l = 28f * u, rr = l + 300f * u, t = 50f * u, b = 244f * u;
line.setColor(0xA0E8E8F4);
c.drawLine(l + 60f * u, 0, l + 60f * u, t, line);
c.drawLine(rr - 60f * u, 0, rr - 60f * u, t, line);
r.set(l, t, rr, b);
fill.setColor(WOOD);
c.drawRoundRect(r, 8f * u, 8f * u, fill);
line.setColor(0xFF6B4226);
line.setStrokeWidth(2.5f * u);
c.drawRoundRect(r, 8f * u, 8f * u, line);
String[][] rows = {
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
};
for (int i = 0; i < rows.length; i++) {
float y = t + (36f + i * 34f) * u;
label(c, rows[i][0], l + 16f * u, y, 19f * u, ORANGE, thin, Paint.Align.LEFT);
label(c, rows[i][1], rr - 16f * u, y, 23f * u, BONE, face, Paint.Align.RIGHT);
}
}
// ---------- The potion flask: the battery ----------
private void drawFlask(Canvas c, float u) {
float rad = 104f * u, cx = 28f * u + 150f * u, cy = 452f * u;
boolean low = v.socPercent != null && v.socPercent < 20f;
boolean blinkOn = (SystemClock.uptimeMillis() / 500) % 2 == 0;
int colour = low ? RED : POTION;
float neckW = 34f * u, neckTop = cy - rad - 42f * u;
// The glass shape: a round belly and a neck.
path.reset();
path.addCircle(cx, cy, rad, Path.Direction.CW);
r.set(cx - neckW, neckTop, cx + neckW, cy - rad + 16f * u);
path.addRect(r, Path.Direction.CW);
fill.setColor(0x38B9D8D0);
c.drawPath(path, fill);
// The potion: as high as the battery is full, across the belly only.
if (v.socPercent != null) {
float share = Math.max(0f, Math.min(1f, v.socPercent / 100f));
float level = cy + rad - share * 2f * rad;
c.save();
c.clipRect(cx - rad, level, cx + rad, cy + rad + 1f);
fill.setColor(colour);
c.drawCircle(cx, cy, rad - 5f * u, fill);
c.restore();
// The surface, a brighter line.
float half = (float) Math.sqrt(Math.max(0f, (rad - 5f * u) * (rad - 5f * u) - (level - cy) * (level - cy)));
line.setColor(0xCCFFFFFF);
line.setStrokeWidth(2.5f * u);
if (half > 0f) c.drawLine(cx - half, level, cx + half, level, line);
// Bubbles rise only while driving.
fill.setColor(0x99FFFFFF);
for (int i = 0; i < 6; i++) {
float span = Math.max(1f, cy + rad - level - 12f * u);
float y = cy + rad - 12f * u - ((time * (24f + i * 7f) * u + i * 31f * u) % span);
float x = cx + (float) Math.sin(i * 2.1f + time * 1.5f) * rad * 0.5f;
c.drawCircle(x, y, (3f + i % 3) * u, fill);
}
}
// The glass, and its cork.
line.setColor(GLASS);
line.setStrokeWidth(4f * u);
c.drawCircle(cx, cy, rad, line);
c.drawLine(cx - neckW, neckTop + 12f * u, cx - neckW, cy - rad + 14f * u, line);
c.drawLine(cx + neckW, neckTop + 12f * u, cx + neckW, cy - rad + 14f * u, line);
r.set(cx - neckW - 4f * u, neckTop - 8f * u, cx + neckW + 4f * u, neckTop + 16f * u);
fill.setColor(0xFF9B6B3A);
c.drawRoundRect(r, 5f * u, 5f * u, fill);
// Dark where the potion is behind the words; outlined where its surface cuts through them.
float level = v.socPercent == null ? cy + rad
: cy + rad - Math.max(0f, Math.min(1f, v.socPercent / 100f)) * 2f * rad;
boolean numberOn = level <= cy - 18f * u, wordOn = level <= cy + 38f * u;
String number = fmt(v.socPercent, "%.0f %%", "—");
if (!numberOn && level < cy + 30f * u) {
text.setStyle(Paint.Style.STROKE);
text.setStrokeWidth(7f * u);
label(c, number, cx, cy + 22f * u, 62f * u, INK, serif, Paint.Align.CENTER);
text.setStyle(Paint.Style.FILL);
}
label(c, number, cx, cy + 22f * u, 62f * u, numberOn ? INK : low ? RED : BONE, serif, Paint.Align.CENTER);
if (low && blinkOn) {
label(c, "BATTERY LOW", cx, cy + 56f * u, 21f * u, BONE, face, Paint.Align.CENTER);
} else if (!low) {
label(c, "BATTERY", cx, cy + 56f * u, 19f * u, wordOn ? INK : POTION, face, Paint.Align.CENTER);
}
}
// ---------- The tombstone: the range; small gravestones: the gears ----------
private void stone(Canvas c, float l, float t, float rr, float b, int colour, int edge, float u) {
float mid = (l + rr) / 2f, half = (rr - l) / 2f;
path.reset();
path.moveTo(l, b);
path.lineTo(l, t + half);
path.cubicTo(l, t + half * 0.1f, mid - half * 0.5f, t, mid, t);
path.cubicTo(mid + half * 0.5f, t, rr, t + half * 0.1f, rr, t + half);
path.lineTo(rr, b);
path.close();
fill.setColor(colour);
c.drawPath(path, fill);
line.setColor(edge);
line.setStrokeWidth(3f * u);
c.drawPath(path, line);
}
private void drawTombstone(Canvas c, int w, float u) {
float rr = w - 40f * u, l = rr - 250f * u, t = 196f * u, b = 484f * u, mid = (l + rr) / 2f;
stone(c, l, t, rr, b, STONE, STONE_DARK, u);
label(c, "R.I.P.", mid, t + 70f * u, 36f * u, STONE_DARK, serif, Paint.Align.CENTER);
label(c, "HERE LIES", mid, t + 106f * u, 19f * u, 0xFF242230, face, Paint.Align.CENTER);
label(c, fmt(v.rangeKm, "%.0f", "—"), mid, t + 186f * u, 86f * u, BONE, serif, Paint.Align.CENTER);
label(c, "KM OF RANGE", mid, t + 224f * u, 21f * u, 0xFF242230, face, Paint.Align.CENTER);
}
private void drawGears(Canvas c, int w, float u) {
float rr = w - 40f * u, l = rr - 250f * u, t = 496f * u, b = 566f * u;
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float step = (rr - l) / 4f, half = 24f * u;
for (int i = 0; i < gears.length; i++) {
float mid = l + (i + 0.5f) * step;
boolean on = gears[i].equals(now);
if (on) {
fill.setColor(0x55FF8C1A);
c.drawCircle(mid, (t + b) / 2f, 42f * u, fill);
}
stone(c, mid - half, t, mid + half, b, on ? ORANGE : STONE_DARK, on ? CANDLE : 0xFF242230, u);
label(c, gears[i], mid, b - 18f * u, 32f * u, on ? INK : 0xB0FFFFFF, serif, Paint.Align.CENTER);
}
}
}
@@ -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) {
@@ -77,6 +77,8 @@ public class MainActivity extends AppCompatActivity {
private boolean inFront; private boolean inFront;
/** 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 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. */
@@ -195,6 +197,8 @@ public class MainActivity extends AppCompatActivity {
@Override public void onPageSelected(int position) { @Override public void onPageSelected(int position) {
markCurrentPage(position); markCurrentPage(position);
applyOverviewFullScreen(position); applyOverviewFullScreen(position);
updateMusicTap();
updateNowPlaying();
} }
}); });
findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen()); findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen());
@@ -213,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 (the styles that have a place for it); on by default. */
static final String PREF_NOW_PLAYING = "now_playing";
/** The Halloween pumpkin's and the Christmas tree's and the Caterpillar'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;
@@ -353,9 +361,18 @@ public class MainActivity extends AppCompatActivity {
mapRoads = new MapRoads(this); mapRoads = new MapRoads(this);
gridMapView = new GridView(this, mapRoads); gridMapView = new GridView(this, mapRoads);
visorView = new VisorView(this); visorView = new VisorView(this);
HalloweenView halloweenView = new HalloweenView(this);
musicTap = new MusicTap(this);
nowPlaying = new NowPlayingWatcher(this);
ChristmasView christmasView = new ChristmasView(this);
halloweenView.setMusic(musicTap);
CaterpillarView caterpillarView = new CaterpillarView(this);
christmasView.setMusic(musicTap);
caterpillarView.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),
new QuestView(this), visorView, new EmpireView(this)); new QuestView(this), visorView, new EmpireView(this), new MuertosView(this), halloweenView,
christmasView, caterpillarView);
View[] canvasPanes = new View[canvasStyles.size()]; View[] canvasPanes = new View[canvasStyles.size()];
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i)); for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
// Grid with map: its map layer goes under it, in the same pane. // Grid with map: its map layer goes under it, in the same pane.
@@ -408,6 +425,14 @@ public class MainActivity extends AppCompatActivity {
else SignalRecorder.start(this); else SignalRecorder.start(this);
refreshSignalRecorder(); refreshSignalRecorder();
}); });
logPane.findViewById(R.id.media_probe_button).setOnClickListener(v -> {
MediaProbe.start(this);
refreshMediaProbe();
});
logPane.findViewById(R.id.sound_probe_button).setOnClickListener(v -> {
SoundProbe.start(this);
refreshSoundProbe();
});
setUpPager(); setUpPager();
// Back on the page that was open when the activity was recreated (accent colour change). // Back on the page that was open when the activity was recreated (accent colour change).
@@ -498,6 +523,8 @@ public class MainActivity extends AppCompatActivity {
uiHandler.post(overviewRefresh); uiHandler.post(overviewRefresh);
inFront = true; inFront = true;
updateMapLocation(); updateMapLocation();
updateMusicTap();
updateNowPlaying();
} }
@Override @Override
@@ -547,6 +574,8 @@ public class MainActivity extends AppCompatActivity {
uiHandler.removeCallbacks(overviewRefresh); uiHandler.removeCallbacks(overviewRefresh);
inFront = false; inFront = false;
updateMapLocation(); updateMapLocation();
updateMusicTap();
updateNowPlaying();
} }
@Override @Override
@@ -1208,6 +1237,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) -> {
@@ -1743,6 +1784,78 @@ public class MainActivity extends AppCompatActivity {
// The accent choices depend on the style ("Original colours" for Retro and Terminal). // The accent choices depend on the style ("Original colours" for Retro and Terminal).
if (settingsPane != null) bindAccentColor(); if (settingsPane != null) bindAccentColor();
updateMapLocation(); updateMapLocation();
updateMusicTap();
updateNowPlaying();
}
/** The styles that show what plays. */
private static boolean showsNowPlaying(OverviewStyle style) {
return style == OverviewStyle.TILES || style == OverviewStyle.HALLOWEEN
|| style == OverviewStyle.MUERTOS || style == OverviewStyle.QUEST || style == OverviewStyle.EMPIRE
|| style == OverviewStyle.TERMINAL || style == OverviewStyle.SCIFI
|| style == OverviewStyle.CHRISTMAS || style == OverviewStyle.CATERPILLAR;
}
/** 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 styles with a light that can follow the music: Halloween's pumpkin, Christmas's tree and the Caterpillar's sun. */
private static boolean hasMusicLight(OverviewStyle style) {
return style == OverviewStyle.HALLOWEEN || style == OverviewStyle.CHRISTMAS
|| style == OverviewStyle.CATERPILLAR;
}
/**
* The light listens only while it can be seen (the app in front, on the Overview, in
* Halloween, Christmas or Caterpillar) and only when "Light follows the music" is on.
*/
private void updateMusicTap() {
ViewPager2 pager = findViewById(R.id.content);
musicTap.setRunning(inFront && hasMusicLight(overviewStyle())
&& prefs.getBoolean(PREF_MUSIC_LIGHT, false)
&& pager != null && pager.getCurrentItem() == 0);
} }
/** /**
@@ -1805,6 +1918,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 = hasMusicLight(style) ? 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. */
@@ -1819,7 +1938,9 @@ public class MainActivity extends AppCompatActivity {
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid), getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
getString(R.string.overview_style_grid_map), getString(R.string.overview_style_grid_map),
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest), getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire) }; getString(R.string.overview_style_visor), getString(R.string.overview_style_empire),
getString(R.string.overview_style_muertos), getString(R.string.overview_style_halloween),
getString(R.string.overview_style_christmas), getString(R.string.overview_style_caterpillar) };
} }
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */ /** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
@@ -1886,6 +2007,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);
@@ -2325,8 +2450,31 @@ public class MainActivity extends AppCompatActivity {
if (!status.isEmpty()) text.setText(String.join("\n", status)); if (!status.isEmpty()) text.setText(String.join("\n", status));
} }
/** The music-sources check: its button and its last report. */
private void refreshMediaProbe() {
MaterialButton button = logPane.findViewById(R.id.media_probe_button);
button.setEnabled(!MediaProbe.running());
TextView text = logPane.findViewById(R.id.media_probe_text);
java.util.List<String> status = MediaProbe.status(this);
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
if (status.size() > 120) status = status.subList(0, 120);
if (!status.isEmpty()) text.setText(String.join("\n", status));
}
/** The sound check: its button and its last report. */
private void refreshSoundProbe() {
MaterialButton button = logPane.findViewById(R.id.sound_probe_button);
button.setEnabled(!SoundProbe.running());
TextView text = logPane.findViewById(R.id.sound_probe_text);
java.util.List<String> status = SoundProbe.status(this);
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
if (!status.isEmpty()) text.setText(String.join("\n", status));
}
private void refreshCallLog() { private void refreshCallLog() {
refreshSignalRecorder(); refreshSignalRecorder();
refreshMediaProbe();
refreshSoundProbe();
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent(); java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
// Update steps first: after a failed install, they are what the driver came here for. // Update steps first: after a failed install, they are what the driver came here for.
java.util.List<String> updates = UpdateLog.lines(this); java.util.List<String> updates = UpdateLog.lines(this);
@@ -0,0 +1,283 @@
package com.evsuite.abrp;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.media.AudioManager;
import android.media.AudioPlaybackConfiguration;
import android.media.MediaMetadata;
import android.media.session.MediaController;
import android.media.session.MediaSessionManager;
import android.media.session.PlaybackState;
import android.service.media.MediaBrowserService;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TreeSet;
/**
* The music-sources check on the Logs tab: where the head unit tells what is playing, before
* the Overview shows it. Three places, all read only:
* <ol>
* <li>the playback Android's audio service reports (which app plays, for what use);</li>
* <li>the active media sessions ({@code MediaSessionManager.getActiveSessions}, which needs
* MEDIA_CONTENT_CONTROL: the car build has it as the system user), with their song and
* state;</li>
* <li>every media browser service on the head unit, listed but not connected to (2.42.1:
* a connection is not harmless, see {@link #browsers}).</li>
* </ol>
* No other app's service is bound; nothing is played, paused or skipped. The report goes where
* a signal recording goes ({@link SignalRecorder#saveCopies}): the app, a USB stick, the server.
*/
final class MediaProbe {
static final String FILE = "media_probe.txt";
private static volatile boolean running;
private static long startedMs;
private static List<String> report = new ArrayList<>();
private static String result;
private MediaProbe() {}
static synchronized boolean running() {
return running;
}
static synchronized void start(Context context) {
if (running) return;
Context app = context.getApplicationContext();
running = true;
startedMs = System.currentTimeMillis();
result = null;
report = new ArrayList<>();
new Thread(() -> probe(app), "media-probe").start();
}
private static void probe(Context app) {
List<String> out = new ArrayList<>();
out.add("Media probe, " + SignalRecording.time(startedMs) + ", app " + BuildConfig.VERSION_NAME);
String saved;
try {
out.add("");
audio(app, out);
out.add("");
sessions(app, out);
out.add("");
browsers(app, out);
} catch (Throwable t) {
out.add("probe stopped: " + t.getClass().getSimpleName());
} finally {
String name = "media-probe-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
.format(new Date(startedMs)) + ".txt";
saved = SignalRecorder.saveCopies(app, FILE, name, out);
synchronized (MediaProbe.class) {
report = out;
result = saved;
running = false;
}
}
}
/** Who plays audio, as Android's audio service sees it. */
private static void audio(Context app, List<String> out) {
out.add("Audio playing (AudioManager):");
AudioManager am = (AudioManager) app.getSystemService(Context.AUDIO_SERVICE);
if (am == null) {
out.add(" no audio service");
return;
}
try {
out.add(" music active: " + am.isMusicActive() + ", mode " + am.getMode());
} catch (RuntimeException e) {
out.add(" music active: unreadable (" + e.getClass().getSimpleName() + ")");
}
List<AudioPlaybackConfiguration> players;
try {
players = am.getActivePlaybackConfigurations();
} catch (RuntimeException e) {
out.add(" players: unreadable (" + e.getClass().getSimpleName() + ")");
return;
}
if (players == null || players.isEmpty()) {
out.add(" players: none");
return;
}
PackageManager pm = app.getPackageManager();
for (AudioPlaybackConfiguration p : players) {
StringBuilder line = new StringBuilder(" player: ");
try {
line.append(MediaProbeText.usage(p.getAudioAttributes().getUsage()));
} catch (RuntimeException e) {
line.append("usage ?");
}
// The app and state are system APIs: read when the platform lets the system user.
Integer uid = hiddenInt(p, "getClientUid");
if (uid != null) line.append(", uid ").append(uid).append(' ').append(packages(pm, uid));
Integer state = hiddenInt(p, "getPlayerState");
if (state != null) line.append(", ").append(MediaProbeText.playerState(state));
if (uid == null && state == null) line.append(" — ").append(MediaProbeText.clean(p.toString()));
out.add(line.toString());
}
}
private static Integer hiddenInt(Object target, String method) {
try {
Object v = target.getClass().getMethod(method).invoke(target);
return v instanceof Integer ? (Integer) v : null;
} catch (Throwable t) {
return null;
}
}
private static String packages(PackageManager pm, int uid) {
try {
String[] names = pm.getPackagesForUid(uid);
return names == null ? "(no package)" : String.join(", ", names);
} catch (RuntimeException e) {
return "(package unreadable)";
}
}
/** The media sessions apps publish, newest first, with their song. */
private static void sessions(Context app, List<String> out) {
out.add("Active media sessions (MediaSessionManager):");
List<MediaController> controllers;
try {
MediaSessionManager msm = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE);
controllers = msm == null ? null : msm.getActiveSessions(null);
} catch (Throwable t) {
out.add(" not readable: " + t.getClass().getSimpleName() + " " + MediaProbeText.clean(t.getMessage()));
return;
}
if (controllers == null || controllers.isEmpty()) {
out.add(" none");
return;
}
for (MediaController c : controllers) {
out.add(" session of " + safe(c::getPackageName));
describe(c, out, " ");
}
}
/**
* Every media browser service, listed only. 2.42.0 connected to each, as the car's media
* library does; on the car that was not harmless: the Bluetooth music stopped (the AOSP car
* radio's service, `com.android.car.radio/.RadioService`, answered "connecting", most likely
* taking the audio) and the Local Media Player asked for access to media. The active sessions
* above already carry the song of whatever plays, CarPlay's and Bluetooth's included, so no
* service is bound any more.
*/
private static void browsers(Context app, List<String> out) {
out.add("Media browser services (" + MediaBrowserService.SERVICE_INTERFACE + "), listed, not connected to:");
try {
List<ResolveInfo> found = app.getPackageManager()
.queryIntentServices(new Intent(MediaBrowserService.SERVICE_INTERFACE), 0);
if (found == null || found.isEmpty()) {
out.add(" none");
return;
}
for (ResolveInfo ri : found) {
if (ri.serviceInfo == null) continue;
out.add(" " + new ComponentName(ri.serviceInfo.packageName, ri.serviceInfo.name)
.flattenToShortString());
}
} catch (RuntimeException e) {
out.add(" list not readable: " + e.getClass().getSimpleName());
}
}
/** A session's state and everything its song's metadata holds. */
private static void describe(MediaController c, List<String> out, String indent) {
try {
PlaybackState s = c.getPlaybackState();
out.add(indent + "state: " + (s == null ? "(none)"
: MediaProbeText.playbackState(s.getState()) + ", at "
+ MediaProbeText.duration(s.getPosition()) + ", actions 0x"
+ Long.toHexString(s.getActions())));
} catch (Throwable t) {
out.add(indent + "state: unreadable (" + t.getClass().getSimpleName() + ")");
}
try {
MediaController.PlaybackInfo info = c.getPlaybackInfo();
if (info != null) {
out.add(indent + "plays " + (info.getPlaybackType() == MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE
? "remotely" : "locally") + ", for "
+ MediaProbeText.usage(info.getAudioAttributes().getUsage()));
}
} catch (Throwable ignored) {
// Not every session says.
}
MediaMetadata m;
try {
m = c.getMetadata();
} catch (Throwable t) {
out.add(indent + "song: unreadable (" + t.getClass().getSimpleName() + ")");
return;
}
if (m == null) {
out.add(indent + "song: (no metadata)");
return;
}
for (String key : new TreeSet<>(m.keySet())) {
String shortKey = key.startsWith("android.media.metadata.")
? key.substring("android.media.metadata.".length()) : key;
out.add(indent + shortKey + ": " + value(m, key));
}
}
/** One metadata value: a picture by its size, text cleaned, anything else as a number. */
private static String value(MediaMetadata m, String key) {
try {
Bitmap bitmap = m.getBitmap(key);
if (bitmap != null) return "picture " + bitmap.getWidth() + "×" + bitmap.getHeight();
} catch (Throwable ignored) {
// not a picture
}
try {
CharSequence text = m.getText(key);
if (text != null) return MediaProbeText.clean(text);
} catch (Throwable ignored) {
// not text
}
try {
if (key.equals(MediaMetadata.METADATA_KEY_DURATION)) {
return MediaProbeText.duration(m.getLong(key));
}
if (m.getRating(key) != null) return "rating";
return Long.toString(m.getLong(key));
} catch (Throwable t) {
return "?";
}
}
private static String safe(java.util.concurrent.Callable<String> read) {
try {
return MediaProbeText.clean(read.call());
} catch (Throwable t) {
return "?";
}
}
/** What the Logs tab shows: the state, then the report. */
static synchronized List<String> status(Context context) {
List<String> lines = new ArrayList<>();
if (startedMs == 0) return lines;
if (running) {
lines.add(context.getString(R.string.media_probe_running));
return lines;
}
lines.add(context.getString(R.string.media_probe_done, SignalRecording.time(startedMs),
result == null ? "…" : result));
String sent = LogOutbox.status(context);
if (sent != null) lines.add(sent);
lines.addAll(report.subList(Math.min(1, report.size()), report.size()));
return lines;
}
}
@@ -0,0 +1,88 @@
package com.evsuite.abrp;
import java.util.Locale;
/**
* The words of {@link MediaProbe}'s report: Android's numbers for playback and audio states as
* names, and text from other apps made safe for one line. Android-free and unit-tested.
*/
final class MediaProbeText {
/** Longest text kept from another app (a title, an artist). */
static final int MAX_TEXT = 120;
private MediaProbeText() {}
/** Another app's text on one line: control characters out, at most {@link #MAX_TEXT}. */
static String clean(CharSequence text) {
if (text == null) return "(none)";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < text.length() && sb.length() < MAX_TEXT; i++) {
char c = text.charAt(i);
sb.append(Character.isISOControl(c) ? ' ' : c);
}
String s = sb.toString().trim();
if (text.length() > MAX_TEXT) s += "…";
return s.isEmpty() ? "(empty)" : s;
}
/** {@code android.media.session.PlaybackState} STATE_*. */
static String playbackState(int state) {
switch (state) {
case 0: return "none";
case 1: return "stopped";
case 2: return "paused";
case 3: return "playing";
case 4: return "fast forwarding";
case 5: return "rewinding";
case 6: return "buffering";
case 7: return "error";
case 8: return "connecting";
case 9: return "skipping to previous";
case 10: return "skipping to next";
case 11: return "skipping to queue item";
default: return "state " + state;
}
}
/** {@code android.media.AudioAttributes} USAGE_*. */
static String usage(int usage) {
switch (usage) {
case 0: return "unknown";
case 1: return "media";
case 2: return "voice call";
case 3: return "call signalling";
case 4: return "alarm";
case 5: return "notification";
case 6: return "ringtone";
case 7: case 8: case 9: case 10: return "notification (" + usage + ")";
case 11: return "accessibility";
case 12: return "navigation guidance";
case 13: return "sonification";
case 14: return "game";
case 15: return "virtual source";
case 16: return "assistant";
default: return "usage " + usage;
}
}
/** {@code android.media.AudioPlaybackConfiguration} PLAYER_STATE_*. */
static String playerState(int state) {
switch (state) {
case -1: return "unknown";
case 0: return "released";
case 1: return "idle";
case 2: return "started";
case 3: return "paused";
case 4: return "stopped";
default: return "state " + state;
}
}
/** A position or duration in milliseconds as m:ss; negative (unknown) as "?". */
static String duration(long ms) {
if (ms < 0) return "?";
long s = ms / 1000;
return String.format(Locale.US, "%d:%02d", s / 60, s % 60);
}
}
@@ -0,0 +1,662 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RadialGradient;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Día de Muertos Overview style: an ofrenda at night, the altar families build to welcome
* their dead back for a visit. A celebration, so bright and warm, not frightening.
*
* Across the top the papel picado, cut-paper flags on a string: the battery, one flag per 10 %,
* those not lit hanging dark. In the middle a calavera, a decorated sugar skull, with the speed
* on its forehead and marigolds (cempasúchil) for eyes; round it a ring of marigolds is the power,
* clockwise while drawing, turquoise and anticlockwise while regenerating. On the left the candles
* of the altar: the range, one candle per 50 km. Along the bottom the path of marigold petals that
* leads the visitors home: the last two minutes of power, higher the more power. On the right the
* clock in a tin frame, P R N D as four small marigolds, and trip, usage, climate and odometer on
* the altar cloth. Below 20 % the battery turns red and says so. Unread values show "—" and light
* nothing.
*
* The altar is drawn once into a bitmap per size; only the readings go over it. The flags sway and
* the flames flicker (about 30 frames a second) only while the car moves and the view is on screen.
*/
final class MuertosView extends ClusterCanvasView {
private static final int FLAGS = 10, PETALS = 24, CANDLES = 8;
private static final int NIGHT = 0xFF160A26, PLUM = 0xFF2C1242, CLOTH = 0xFF3A0F3E, BONE = 0xFFF8EEDC;
private static final int INK = 0xFF2A0E36, MARIGOLD = 0xFFFF9A1F, GOLD = 0xFFFFC94A, TURQUOISE = 0xFF2EE6D0;
private static final int PINK = 0xFFFF2E93, RED = 0xFFFF3B47, FLAME = 0xFFFFD36B, WAX = 0xFFF4E6C8;
private static final int[] PAPER = { 0xFFFF2E93, 0xFFFF9A1F, 0xFF2EE6D0, 0xFFFFD23F, 0xFF9B5CFF };
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint image = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
private final Path path = new Path();
private final Path flag = new Path();
private final SongBanner banner = new SongBanner();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
private Bitmap scene, marigold, turquoise;
private int sceneW, sceneH;
private float time;
private long lastFrame;
MuertosView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.ROUND);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
scene = null;
marigold = null;
turquoise = null;
sceneW = 0;
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
int w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
if (moving) {
long now = SystemClock.uptimeMillis();
time += lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
} else {
lastFrame = 0;
}
if (scene == null || sceneW != w || sceneH != h) buildScene(w, h, u);
c.drawBitmap(scene, 0, 0, image);
float cx = w / 2f, cy = 345f * u;
drawFlags(c, w, u);
boolean song = drawSong(c, w, u);
drawRing(c, cx, cy, u);
drawSpeed(c, cx, cy, u);
drawBattery(c, u);
drawCandles(c, u, moving);
drawPetals(c, w, h, u);
drawClock(c, w, u);
drawGears(c, w, u);
drawRows(c, w, h, u);
keepAnimating(moving || song, 33);
}
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
text.setTypeface(tf);
text.setTextSize(size);
text.setTextAlign(a);
text.setColor(colour);
c.drawText(s, x, y, text);
}
// ---------- The altar, once per size ----------
private void buildScene(int w, int h, float u) {
scene = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
sceneW = w;
sceneH = h;
marigold = flower(44f * u, MARIGOLD, GOLD, 0xFFE06A00);
turquoise = flower(44f * u, TURQUOISE, 0xFFA8FFF4, 0xFF0E9C90);
buildFlag(u);
Canvas c = new Canvas(scene);
c.drawColor(NIGHT);
Paint glow = new Paint();
glow.setShader(new RadialGradient(w / 2f, 345f * u, Math.max(w, h) * 0.6f,
new int[] { 0xFF4A1C5E, PLUM, NIGHT }, new float[] { 0f, 0.45f, 1f }, Shader.TileMode.CLAMP));
c.drawRect(0, 0, w, h, glow);
// A scatter of stars, always the same.
fill.setColor(0x66FFFFFF);
for (int i = 0; i < 70; i++) {
float x = ((i * 137 + 31) % 997) / 997f * w, y = (110f + (i * 71 % 113) * 3f) * u;
c.drawCircle(x, y, (i % 3 == 0 ? 1.8f : 1f) * u, fill);
}
// The string the flags hang on.
line.setColor(0x99FFFFFF);
line.setStrokeWidth(1.5f * u);
c.drawLine(0, 12f * u, w, 12f * u, line);
drawSkull(c, w / 2f, 345f * u, u);
drawAltarCloth(c, w, u);
drawCandleStands(c, u);
}
/** One flower on a small bitmap: outer petals, inner petals, heart. */
private Bitmap flower(float size, int outer, int inner, int shade) {
int s = (int) Math.ceil(size);
Bitmap b = Bitmap.createBitmap(s, s, Bitmap.Config.ARGB_8888);
Canvas c = new Canvas(b);
drawFlower(c, s / 2f, s / 2f, s / 2f, outer, inner, shade);
return b;
}
private void drawFlower(Canvas c, float x, float y, float rad, int outer, int inner, int shade) {
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
for (int ring = 0; ring < 3; ring++) {
int n = 12 - ring * 3;
float d = rad * (0.68f - ring * 0.24f), pr = rad * (0.32f - ring * 0.05f);
for (int i = 0; i < n; i++) {
double a = Math.PI * 2 * i / n + ring * 0.3;
p.setColor(ring == 0 ? (i % 2 == 0 ? outer : shade) : ring == 1 ? (i % 2 == 0 ? inner : outer) : inner);
c.drawCircle(x + (float) Math.cos(a) * d, y + (float) Math.sin(a) * d, pr, p);
}
}
p.setColor(shade);
c.drawCircle(x, y, rad * 0.14f, p);
}
/** A paper flag with its holes cut out: scalloped below, a flower and diamonds in it. */
private void buildFlag(float u) {
float w = 100f * u, h = 84f * u;
flag.reset();
flag.setFillType(Path.FillType.EVEN_ODD);
flag.moveTo(0, 0);
flag.lineTo(w, 0);
flag.lineTo(w, h - 8f * u);
int scallops = 6;
float sw = w / scallops;
for (int i = scallops; i > 0; i--) {
float x = i * sw;
flag.quadTo(x - sw / 2f, h + 8f * u, x - sw, h - 8f * u);
}
flag.close();
// The middle: a flower of eight petals round a round hole.
float cx = w / 2f, cy = h * 0.46f;
flag.addCircle(cx, cy, 6f * u, Path.Direction.CW);
for (int i = 0; i < 8; i++) {
double a = Math.PI / 4 * i;
float px = cx + (float) Math.cos(a) * 17f * u, py = cy + (float) Math.sin(a) * 17f * u;
diamond(flag, px, py, 7f * u, 5f * u, a);
}
// Corners and borders.
for (int i = 0; i < 6; i++) {
float x = (i + 0.5f) * w / 6f;
diamond(flag, x, 8f * u, 5f * u, 3f * u, 0);
if (i == 0 || i == 5) {
diamond(flag, x, h * 0.3f, 4f * u, 6f * u, 0);
diamond(flag, x, h * 0.62f, 4f * u, 6f * u, 0);
}
}
}
private static void diamond(Path p, float x, float y, float a, float b, double turn) {
float ca = (float) Math.cos(turn), sa = (float) Math.sin(turn);
p.moveTo(x + a * ca, y + a * sa);
p.lineTo(x - b * sa, y + b * ca);
p.lineTo(x - a * ca, y - a * sa);
p.lineTo(x + b * sa, y - b * ca);
p.close();
}
/** The calavera: the bone, the decoration, the teeth. Its speed and ring go over it. */
private void drawSkull(Canvas c, float cx, float cy, float u) {
float top = cy - 150f * u, jaw = cy + 150f * u;
// Bone.
path.reset();
path.moveTo(cx, top);
path.cubicTo(cx + 110f * u, top, cx + 148f * u, cy - 70f * u, cx + 142f * u, cy + 8f * u);
path.cubicTo(cx + 140f * u, cy + 48f * u, cx + 112f * u, cy + 62f * u, cx + 84f * u, cy + 74f * u);
path.lineTo(cx + 78f * u, jaw - 8f * u);
path.quadTo(cx, jaw + 6f * u, cx - 78f * u, jaw - 8f * u);
path.lineTo(cx - 84f * u, cy + 74f * u);
path.cubicTo(cx - 112f * u, cy + 62f * u, cx - 140f * u, cy + 48f * u, cx - 142f * u, cy + 8f * u);
path.cubicTo(cx - 148f * u, cy - 70f * u, cx - 110f * u, top, cx, top);
path.close();
fill.setColor(BONE);
c.drawPath(path, fill);
line.setColor(PINK);
line.setStrokeWidth(3f * u);
c.drawPath(path, line);
// A band of scallops under the forehead, where the speed stands above.
line.setColor(PINK);
line.setStrokeWidth(2.5f * u);
for (int i = -4; i < 4; i++) {
float x = cx + i * 26f * u;
r.set(x, cy - 54f * u, x + 26f * u, cy - 28f * u);
c.drawArc(r, 0, 180, false, line);
}
fill.setColor(TURQUOISE);
for (int i = -4; i <= 4; i++) c.drawCircle(cx + i * 26f * u, cy - 41f * u, 3f * u, fill);
// Eyes: dark sockets with a marigold each, a ring of dots round them.
for (int side = -1; side <= 1; side += 2) {
float ex = cx + side * 62f * u, ey = cy + 4f * u;
fill.setColor(INK);
c.drawCircle(ex, ey, 36f * u, fill);
drawFlower(c, ex, ey, 31f * u, MARIGOLD, GOLD, 0xFFE06A00);
fill.setColor(PINK);
for (int i = 0; i < 14; i++) {
double a = Math.PI * 2 * i / 14;
c.drawCircle(ex + (float) Math.cos(a) * 43f * u, ey + (float) Math.sin(a) * 43f * u, 2.5f * u, fill);
}
// A small flower on each cheek.
drawFlower(c, cx + side * 92f * u, cy + 56f * u, 10f * u, 0xFF9B5CFF, 0xFFD2B8FF, 0xFF6A32C8);
}
// Nose: an upside-down heart.
path.reset();
path.moveTo(cx, cy + 30f * u);
path.cubicTo(cx + 18f * u, cy + 46f * u, cx + 20f * u, cy + 66f * u, cx + 8f * u, cy + 66f * u);
path.quadTo(cx + 2f * u, cy + 66f * u, cx, cy + 58f * u);
path.quadTo(cx - 2f * u, cy + 66f * u, cx - 8f * u, cy + 66f * u);
path.cubicTo(cx - 20f * u, cy + 66f * u, cx - 18f * u, cy + 46f * u, cx, cy + 30f * u);
path.close();
fill.setColor(INK);
c.drawPath(path, fill);
// Teeth: a smile stitched across.
line.setColor(INK);
line.setStrokeWidth(3f * u);
float my = cy + 106f * u;
path.reset();
path.moveTo(cx - 70f * u, my - 6f * u);
path.quadTo(cx, my + 12f * u, cx + 70f * u, my - 6f * u);
c.drawPath(path, line);
line.setStrokeWidth(2.5f * u);
for (int i = -5; i <= 5; i++) {
float x = cx + i * 12f * u;
float y = my + 3f * u * (1f - (i * i) / 25f);
c.drawLine(x, y - 15f * u, x, y + 15f * u, line);
}
// The chin's little flower.
drawFlower(c, cx, jaw - 13f * u, 10f * u, PINK, 0xFFFFA8D4, 0xFFC4186E);
}
private void drawAltarCloth(Canvas c, int w, float u) {
float rr = w - 28f * u, l = rr - 300f * u, t = 340f * u, b = 548f * u;
r.set(l, t, rr, b);
fill.setColor(CLOTH);
c.drawRoundRect(r, 10f * u, 10f * u, fill);
// A fringe of paper colours on top.
float seg = (rr - l) / 15f;
for (int i = 0; i < 15; i++) {
fill.setColor(PAPER[i % PAPER.length]);
path.reset();
path.moveTo(l + i * seg, t);
path.lineTo(l + (i + 1) * seg, t);
path.lineTo(l + (i + 0.5f) * seg, t + 13f * u);
path.close();
c.drawPath(path, fill);
}
}
/** The tier the candles stand on. */
private void drawCandleStands(Canvas c, float u) {
float l = 28f * u, rr = l + 300f * u, b = 548f * u;
r.set(l, b - 26f * u, rr, b);
fill.setColor(CLOTH);
c.drawRoundRect(r, 6f * u, 6f * u, fill);
float seg = (rr - l) / 15f;
for (int i = 0; i < 15; i++) {
fill.setColor(PAPER[i % PAPER.length]);
path.reset();
path.moveTo(l + i * seg, b - 26f * u);
path.lineTo(l + (i + 1) * seg, b - 26f * u);
path.lineTo(l + (i + 0.5f) * seg, b - 15f * u);
path.close();
c.drawPath(path, fill);
}
}
// ---------- Papel picado: the battery ----------
private void drawFlags(Canvas c, int w, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
int lit = Tally.lit(v.socPercent, 10f, FLAGS);
float fw = 100f * u, gap = (w - FLAGS * fw) / (FLAGS + 1f);
fill.setStyle(Paint.Style.FILL);
for (int i = 0; i < FLAGS; i++) {
float x = gap + i * (fw + gap);
c.save();
c.translate(x, 12f * u);
// The breeze of driving; still when parked.
c.skew((float) Math.sin(time * 2.2f + i * 0.8f) * 0.05f, 0);
boolean on = i < lit;
fill.setColor(!on ? 0x38FFFFFF : low ? RED : PAPER[i % PAPER.length]);
c.drawPath(flag, fill);
c.restore();
}
}
// ---------- What plays: a monarch or an alebrije tows it on a strip of papel picado ----------
/**
* A monarch butterfly (the souls' messengers) and an alebrije (Oaxaca's painted spirit creature)
* take turns towing a waving strip of cut paper with the song across the sky between the flags
* and the ring, behind the ring and the clock, again and again while something plays. The paper's
* colour is one of the flags', fixed by the song. Returns whether it is drawn, so the view keeps
* animating for it.
*/
private boolean drawSong(Canvas c, int w, float u) {
if (v.songTitle == null) return false;
text.setTypeface(face);
text.setTextSize(19f * u);
String s = SongBanner.line(text, v.songTitle, v.songArtist, Math.min(560f * u, w * 0.45f));
float t = SystemClock.uptimeMillis() / 1000f;
float bw = text.measureText(s) + 40f * u, bh = 28f * u, tow = 64f * u;
float bx = SongBanner.left(t, w, bw, tow, u);
float yc = 106f * u + (float) Math.sin(t * 1.3f) * 2f * u;
int paper = PAPER[Math.floorMod((v.songTitle + "\n" + v.songArtist).hashCode(), PAPER.length)];
fill.setStyle(Paint.Style.FILL);
banner.draw(c, fill, line, text, s, bx, yc, bw, bh, t, u, 14f * u, paper, 0xCC2A0E36,
paper == PAPER[4] ? BONE : INK);
boolean butterfly = SongBanner.pass(t, w, bw, tow, u) % 2 == 0;
float right = bx + bw, cx = right + tow, cy = yc - 6f * u, size = (butterfly ? 40f : 36f) * u;
// The tow lines run to the tail.
float ax = cx - (butterfly ? 0.4f : 0.6f) * size, ay = cy - (butterfly ? 0f : 0.35f * size);
line.setColor(0x99FFFFFF);
line.setStrokeWidth(1.5f * u);
c.drawLine(right, yc - bh / 2f + 3f * u, ax, ay, line);
c.drawLine(right, yc + bh / 2f - 3f * u, ax, ay, line);
float flap = (float) Math.sin(t * (butterfly ? 9f : 7f));
if (butterfly) monarch(c, cx, cy, size, flap, u);
else alebrije(c, cx, cy, size, flap, u);
return true;
}
/** A monarch seen from above, flying right: orange wings veined and edged in black, white dots. */
private void monarch(Canvas c, float x, float y, float s, float flap, float u) {
float k = 0.3f + 0.7f * Math.abs(flap);
c.save();
c.translate(x, y);
for (int side = 0; side < 2; side++) {
c.save();
if (side == 1) c.scale(1f, -1f);
path.reset();
path.moveTo(0.3f * s, 0);
path.quadTo(0.3f * s, -0.75f * s * k, -0.02f * s, -0.95f * s * k);
path.quadTo(-0.45f * s, -0.7f * s * k, -0.1f * s, 0);
path.close();
path.moveTo(-0.05f * s, 0);
path.quadTo(-0.4f * s, -0.55f * s * k, -0.7f * s, -0.4f * s * k);
path.quadTo(-0.7f * s, -0.1f * s * k, -0.3f * s, 0);
path.close();
fill.setColor(0xFFFF8A1F);
c.drawPath(path, fill);
line.setColor(0xFF1A0A10);
line.setStrokeWidth(2.2f * u);
line.setStrokeJoin(Paint.Join.ROUND);
c.drawPath(path, line);
line.setStrokeWidth(1.2f * u);
c.drawLine(0.1f * s, 0, -0.02f * s, -0.88f * s * k, line);
c.drawLine(0.05f * s, 0, -0.3f * s, -0.62f * s * k, line);
c.drawLine(-0.1f * s, 0, -0.62f * s, -0.36f * s * k, line);
fill.setColor(0xFFFFFFFF);
for (float[] d : new float[][] { { 0.14f, -0.74f }, { -0.1f, -0.88f }, { -0.3f, -0.72f }, { -0.62f, -0.36f } }) {
c.drawCircle(d[0] * s, d[1] * s * k, 1.6f * u, fill);
}
c.restore();
}
fill.setColor(0xFF1A0A10);
r.set(-0.4f * s, -0.07f * s, 0.35f * s, 0.07f * s);
c.drawOval(r, fill);
c.drawCircle(0.38f * s, 0, 0.08f * s, fill);
line.setColor(0xFF1A0A10);
line.setStrokeWidth(1.5f * u);
c.drawLine(0.42f * s, -0.04f * s, 0.62f * s, -0.2f * s, line);
c.drawLine(0.42f * s, 0.04f * s, 0.62f * s, 0.2f * s, line);
c.restore();
}
/** An alebrije flying right: a painted horned creature with feathered wings and a curled tail. */
private void alebrije(Canvas c, float x, float y, float s, float flap, float u) {
int[] feathers = { TURQUOISE, GOLD, PINK, MARIGOLD, PAPER[4] };
c.save();
c.translate(x, y);
// The wing, behind the body: feathers fanned from the shoulder, lifted and dropped by the flap.
for (int i = 0; i < feathers.length; i++) {
c.save();
c.translate(-0.05f * s, -0.2f * s);
c.rotate(-125f + flap * 30f + i * 15f);
r.set(0, -0.07f * s, 0.8f * s, 0.07f * s);
fill.setColor(feathers[i]);
c.drawOval(r, fill);
line.setColor(INK);
line.setStrokeWidth(1.5f * u);
c.drawOval(r, line);
c.restore();
}
// The curled tail.
path.reset();
path.moveTo(-0.4f * s, 0.05f * s);
path.cubicTo(-0.8f * s, 0.1f * s, -0.85f * s, -0.3f * s, -0.6f * s, -0.35f * s);
line.setColor(PINK);
line.setStrokeWidth(0.1f * s);
c.drawPath(path, line);
fill.setColor(GOLD);
c.drawCircle(-0.6f * s, -0.35f * s, 0.07f * s, fill);
// Legs.
line.setColor(INK);
line.setStrokeWidth(3f * u);
c.drawLine(-0.25f * s, 0.15f * s, -0.3f * s, 0.42f * s, line);
c.drawLine(0.15f * s, 0.15f * s, 0.2f * s, 0.42f * s, line);
fill.setColor(GOLD);
c.drawCircle(-0.3f * s, 0.43f * s, 0.05f * s, fill);
c.drawCircle(0.2f * s, 0.43f * s, 0.05f * s, fill);
// The body, painted with stripes and dots.
r.set(-0.45f * s, -0.2f * s, 0.35f * s, 0.2f * s);
fill.setColor(PINK);
c.drawOval(r, fill);
line.setColor(INK);
line.setStrokeWidth(2f * u);
c.drawOval(r, line);
line.setColor(TURQUOISE);
line.setStrokeWidth(0.05f * s);
path.reset();
path.moveTo(-0.35f * s, 0f);
for (int i = 1; i <= 6; i++) path.lineTo((-0.35f + 0.1f * i) * s, (i % 2 == 0 ? 0f : -0.07f) * s);
c.drawPath(path, line);
fill.setColor(GOLD);
for (float[] d : new float[][] { { -0.3f, 0.1f }, { -0.1f, 0.12f }, { 0.1f, 0.1f }, { 0.25f, 0.05f } }) {
c.drawCircle(d[0] * s, d[1] * s, 0.035f * s, fill);
}
// Horns, the head, snout, eye.
line.setColor(GOLD);
line.setStrokeWidth(3.5f * u);
path.reset();
path.moveTo(0.35f * s, -0.38f * s);
path.quadTo(0.2f * s, -0.55f * s, 0.3f * s, -0.78f * s);
path.moveTo(0.5f * s, -0.38f * s);
path.quadTo(0.65f * s, -0.52f * s, 0.55f * s, -0.78f * s);
c.drawPath(path, line);
fill.setColor(MARIGOLD);
c.drawCircle(0.42f * s, -0.22f * s, 0.2f * s, fill);
line.setColor(INK);
line.setStrokeWidth(2f * u);
c.drawCircle(0.42f * s, -0.22f * s, 0.2f * s, line);
r.set(0.5f * s, -0.22f * s, 0.85f * s, -0.06f * s);
fill.setColor(GOLD);
c.drawOval(r, fill);
c.drawOval(r, line);
fill.setColor(PINK);
c.drawCircle(0.8f * s, -0.15f * s, 0.035f * s, fill);
fill.setColor(BONE);
c.drawCircle(0.45f * s, -0.28f * s, 0.07f * s, fill);
fill.setColor(INK);
c.drawCircle(0.47f * s, -0.28f * s, 0.035f * s, fill);
c.restore();
}
private void drawBattery(Canvas c, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
float x = 36f * u;
label(c, low ? "BATTERY LOW" : "BATTERY", x, 134f * u, 19f * u, low ? RED : GOLD, face, Paint.Align.LEFT);
label(c, fmt(v.socPercent, "%.0f %%", "—"), x, 200f * u, 66f * u, low ? RED : BONE, serif, Paint.Align.LEFT);
}
// ---------- The marigold ring: the power ----------
private void drawRing(Canvas c, float cx, float cy, float u) {
float rad = 188f * u, half = marigold.getWidth() / 2f;
int lit = Tally.ofPower(v.powerKw, 150f, 50f, PETALS);
boolean regen = v.powerKw != null && v.powerKw < 0f;
for (int i = 0; i < PETALS; i++) {
// Clockwise from the top; regeneration runs the other way.
int k = regen ? PETALS - i : i;
double a = Math.PI * 2 * k / PETALS - Math.PI / 2;
float x = cx + (float) Math.cos(a) * rad, y = cy + (float) Math.sin(a) * rad;
boolean on = i < lit;
image.setAlpha(on ? 255 : 46);
c.drawBitmap(on && regen ? turquoise : marigold, x - half, y - half, image);
}
image.setAlpha(255);
// The power's number on a plate at the foot of the ring.
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
Math.abs(v.powerKw));
float py = cy + rad + 2f * u;
r.set(cx - 78f * u, py - 22f * u, cx + 78f * u, py + 20f * u);
fill.setColor(0xF02C1242);
c.drawRoundRect(r, 20f * u, 20f * u, fill);
line.setColor(regen ? TURQUOISE : MARIGOLD);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 20f * u, 20f * u, line);
label(c, power, cx, py + 10f * u, 28f * u, regen ? TURQUOISE : GOLD, face, Paint.Align.CENTER);
}
private void drawSpeed(Canvas c, float cx, float cy, float u) {
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, cy - 66f * u, 74f * u, INK, serif, Paint.Align.CENTER);
label(c, "KM/H", cx, cy - 128f * u, 14f * u, 0xFFC4186E, face, Paint.Align.CENTER);
}
// ---------- Candles: the range ----------
private void drawCandles(Canvas c, float u, boolean moving) {
float x0 = 36f * u;
label(c, "RANGE", x0, 246f * u, 19f * u, GOLD, face, Paint.Align.LEFT);
label(c, fmt(v.rangeKm, "%.0f km", "—"), x0, 298f * u, 48f * u, BONE, serif, Paint.Align.LEFT);
int lit = Tally.lit(v.rangeKm, 50f, CANDLES);
float base = 522f * u, step = 36f * u;
for (int i = 0; i < CANDLES; i++) {
float x = x0 + 14f * u + i * step;
float tall = (130f - (i % 4) * 22f - (i % 3) * 8f) * u;
float top = base - tall;
r.set(x - 11f * u, top, x + 11f * u, base);
boolean on = i < lit;
fill.setColor(on ? WAX : 0x40F4E6C8);
c.drawRoundRect(r, 3f * u, 3f * u, fill);
// A band of colour.
r.set(x - 11f * u, top + tall * 0.55f, x + 11f * u, top + tall * 0.55f + 10f * u);
fill.setColor(AccentColor.withAlpha(PAPER[i % PAPER.length], on ? 0xFF : 0x40));
c.drawRect(r, fill);
// The wick.
line.setColor(on ? INK : 0x60FFFFFF);
line.setStrokeWidth(2f * u);
c.drawLine(x, top, x, top - 6f * u, line);
if (!on) continue;
// The flame, flickering only while driving, with its halo.
float flick = moving ? (float) Math.sin(time * 9f + i * 1.7f) * 2.5f * u : 0f;
fill.setColor(0x33FFB84A);
c.drawCircle(x, top - 18f * u, 19f * u, fill);
path.reset();
path.moveTo(x, top - 32f * u - Math.abs(flick));
path.quadTo(x + 10f * u + flick, top - 12f * u, x, top - 4f * u);
path.quadTo(x - 10f * u + flick, top - 12f * u, x, top - 32f * u - Math.abs(flick));
path.close();
fill.setColor(FLAME);
c.drawPath(path, fill);
}
}
// ---------- The petal path: the last two minutes of power ----------
private void drawPetals(Canvas c, int w, int h, float u) {
float base = h - 16f * u, amp = 58f * u;
float[] trail = v.powerTrail;
// The path itself, faint, whatever the readings.
fill.setColor(0x30FF9A1F);
r.set(0, base - 4f * u, w, base + 4f * u);
c.drawRect(r, fill);
if (trail == null || trail.length == 0) return;
int n = trail.length;
for (int i = 0; i < n; i++) {
float p = trail[i];
if (Float.isNaN(p)) continue;
float x = (i + 0.5f) / n * w;
float share = Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f));
// Each stack of petals rises with the power, each petal tilted its own way.
int stack = 1 + Math.round(share * 4f);
for (int s = 0; s < stack; s++) {
float y = base - s * amp / 4f;
c.save();
c.rotate(((i * 47 + s * 83) % 180) - 90f, x, y);
r.set(x - 8f * u, y - 3.5f * u, x + 8f * u, y + 3.5f * u);
fill.setColor(p < 0 ? TURQUOISE : s % 2 == 0 ? MARIGOLD : GOLD);
c.drawOval(r, fill);
c.restore();
}
}
}
// ---------- The right side ----------
/** The clock in a tin frame. */
private void drawClock(Canvas c, int w, float u) {
float rr = w - 28f * u, l = rr - 300f * u, t = 118f * u, b = 202f * u;
r.set(l, t, rr, b);
fill.setColor(0xCC160A26);
c.drawRoundRect(r, 12f * u, 12f * u, fill);
line.setColor(GOLD);
line.setStrokeWidth(2.5f * u);
c.drawRoundRect(r, 12f * u, 12f * u, line);
fill.setColor(GOLD);
for (int i = 0; i <= 12; i++) {
float x = l + 12f * u + i * (rr - l - 24f * u) / 12f;
c.drawCircle(x, t + 8f * u, 2f * u, fill);
c.drawCircle(x, b - 8f * u, 2f * u, fill);
}
label(c, v.clock.isEmpty() ? "—" : v.clock, (l + rr) / 2f, b - 22f * u, 50f * u, BONE, serif, Paint.Align.CENTER);
}
/** P R N D as four small marigolds: the one engaged in bloom. */
private void drawGears(Canvas c, int w, float u) {
float rr = w - 28f * u, l = rr - 300f * u, y = 270f * u;
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float step = (rr - l) / 4f, half = marigold.getWidth() / 2f, scale = 58f * u / marigold.getWidth();
for (int i = 0; i < gears.length; i++) {
float x = l + (i + 0.5f) * step;
boolean on = gears[i].equals(now);
if (on) {
c.save();
c.scale(scale, scale, x, y);
c.drawBitmap(marigold, x - half, y - half, image);
c.restore();
} else {
fill.setColor(0x40FFFFFF);
c.drawCircle(x, y, 22f * u, fill);
}
label(c, gears[i], x, y + 11f * u, 30f * u, on ? INK : 0xB0FFFFFF, serif, Paint.Align.CENTER);
}
}
/** Trip, usage, climate, odometer on the altar cloth. */
private void drawRows(Canvas c, int w, int h, float u) {
float rr = w - 28f * u, l = rr - 300f * u, top = 378f * u;
String[][] rows = {
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
};
for (int i = 0; i < rows.length; i++) {
float y = top + i * 35f * u;
label(c, rows[i][0], l + 18f * u, y, 19f * u, GOLD, thin, Paint.Align.LEFT);
label(c, rows[i][1], rr - 18f * u, y, 23f * u, BONE, face, Paint.Align.RIGHT);
}
}
}
@@ -0,0 +1,51 @@
package com.evsuite.abrp;
/**
* How strongly the Halloween pumpkin's light answers the music at this moment, from 0 to 1, out of
* the kick drum's band the audio analyser reports. What counts is the hit, not the loudness: only
* a jump of the bass since the last reading lights it, measured against the biggest jumps of the
* last seconds, so quiet and loud songs both flash, and a held bass note or a steady hum does not.
* Jumps that are small against the bass's own level (a swelling note, noise) are ignored. It
* 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 {
/** Below this the bass counts as silence. */
static final float SILENT = 0.005f;
/** 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 last, average, peakJump, pulse;
/** One reading, {@code seconds} after the last; null when it failed. Returns the new pulse. */
float update(Float bass, float seconds) {
float target = 0f;
if (bass == null || bass.isNaN() || bass < SILENT) {
last = 0f;
average = 0f;
peakJump = 0f;
} else {
float jump = Math.max(0f, bass - last);
last = bass;
average = average == 0f ? bass : average + (bass - average) * share(seconds, AVERAGE_S);
peakJump = Math.max(jump, peakJump - peakJump * share(seconds, PEAK_S));
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 ? target : pulse + (target - pulse) * share(seconds, FALL_S);
if (pulse < 0.005f) pulse = 0f;
return pulse;
}
private static float share(float seconds, float timeConstant) {
return Math.min(1f, Math.max(0f, seconds) / timeConstant);
}
}
@@ -0,0 +1,91 @@
package com.evsuite.abrp;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.audiofx.Visualizer;
/**
* Listens to what the head unit plays for the Halloween pumpkin's light: Android's audio analyser
* 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
* 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.
*/
final class MusicTap {
private static final int READ_MS = 40, GIVE_UP_AFTER_FAILURES = 50;
private final Context app;
private volatile Thread worker;
private volatile float pulse;
MusicTap(Context context) {
app = context.getApplicationContext();
}
/** From 0 to 1; safe to read from any thread. */
float pulse() {
return pulse;
}
void setRunning(boolean run) {
if (run == (worker != null)) return;
if (!run) {
Thread stopping = worker;
worker = null;
pulse = 0f;
stopping.interrupt();
return;
}
if (app.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) return;
Thread thread = new Thread(this::listen, "music-tap");
thread.setDaemon(true);
worker = thread;
thread.start();
}
/** All analyser calls live here, off the main thread; it is always let go. */
private void listen() {
Thread me = Thread.currentThread();
Visualizer analyser = null;
try {
analyser = new Visualizer(0);
analyser.setCaptureSize(Visualizer.getCaptureSizeRange()[1]);
analyser.setScalingMode(Visualizer.SCALING_MODE_NORMALIZED);
if (analyser.setEnabled(true) != Visualizer.SUCCESS) return;
int rateHz = analyser.getSamplingRate() / 1000;
byte[] fft = new byte[analyser.getCaptureSize()];
MusicPulse music = new MusicPulse();
long last = System.nanoTime();
int failures = 0;
while (worker == me && failures < GIVE_UP_AFTER_FAILURES) {
Thread.sleep(READ_MS);
boolean read = analyser.getFft(fft) == Visualizer.SUCCESS;
failures = read ? 0 : failures + 1;
long now = System.nanoTime();
float value = music.update(read ? SoundLevels.band(fft, rateHz, MusicPulse.FROM_HZ, MusicPulse.TO_HZ) : null, (now - last) / 1e9f);
last = now;
if (worker == me) pulse = value;
}
} catch (InterruptedException e) {
// Told to stop.
} catch (RuntimeException e) {
// No analyser here: the pumpkin burns as usual.
} finally {
if (worker == me) pulse = 0f;
if (analyser != null) {
try {
analyser.setEnabled(false);
} catch (RuntimeException ignored) {
// Released next either way.
}
try {
analyser.release();
} catch (RuntimeException ignored) {
// Nothing more can be done from here.
}
}
}
}
}
@@ -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;
}
}
}
@@ -31,7 +31,11 @@ enum OverviewStyle {
TIME_CIRCUITS(false, false), TIME_CIRCUITS(false, false),
QUEST(false, false), QUEST(false, false),
VISOR(true, true), VISOR(true, true),
EMPIRE(false, false); EMPIRE(false, false),
MUERTOS(false, false),
HALLOWEEN(false, false),
CHRISTMAS(false, false),
CATERPILLAR(false, false);
static final String PREF_KEY = "overview_style"; static final String PREF_KEY = "overview_style";
@@ -37,6 +37,7 @@ final class QuestView extends ClusterCanvasView {
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint skyPaint = new Paint(); private final Paint skyPaint = new Paint();
private final Path path = new Path(); private final Path path = new Path();
private final SongBanner banner = new SongBanner();
private final RectF r = new RectF(); private final RectF r = new RectF();
private final Typeface serif = Typeface.create(Typeface.SERIF, Typeface.BOLD); private final Typeface serif = Typeface.create(Typeface.SERIF, Typeface.BOLD);
private final Typeface sans = Typeface.create("sans-serif-condensed", Typeface.BOLD); private final Typeface sans = Typeface.create("sans-serif-condensed", Typeface.BOLD);
@@ -54,6 +55,7 @@ final class QuestView extends ClusterCanvasView {
if (w <= 0 || h <= 0) return; if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f); float u = Math.min(h / 640f, w / 1100f);
drawLandscape(c, w, h); drawLandscape(c, w, h);
boolean song = drawSong(c, w, u);
drawPlayer(c, u); drawPlayer(c, u);
drawMinimap(c, w, u); drawMinimap(c, w, u);
drawSpeed(c, w, u); drawSpeed(c, w, u);
@@ -62,6 +64,35 @@ final class QuestView extends ClusterCanvasView {
drawChat(c, u); drawChat(c, u);
drawActionBar(c, w, h, u); drawActionBar(c, w, h, u);
drawXp(c, w, h, u); drawXp(c, w, h, u);
keepAnimating(song, 33);
}
// ---------- What plays: a dragon tows it on a scroll ----------
/**
* A dragon tows a waving scroll with the song across the top of the sky, behind the frames, again
* and again while something plays. Returns whether it is drawn, so the view keeps animating.
*/
private boolean drawSong(Canvas c, float w, float u) {
if (v.songTitle == null) return false;
text.setTypeface(serif);
text.setTextSize(19f * u);
text.setStyle(Paint.Style.FILL);
String s = SongBanner.line(text, v.songTitle, v.songArtist, Math.min(560f * u, w * 0.45f));
float t = SystemClock.uptimeMillis() / 1000f;
float bw = text.measureText(s) + 40f * u, bh = 28f * u, size = 58f * u, tow = size * 1.3f;
int wi = Math.round(w);
float bx = SongBanner.left(t, wi, bw, tow, u);
float yc = 72f * u + (float) Math.sin(t * 1.3f) * 3f * u;
banner.draw(c, fill, line, text, s, bx, yc, bw, bh, t, u, 0f, 0xFFE8D5A8, GOLD_DARK, 0xFF2A1A08);
float right = bx + bw, cx = right + tow, cy = yc - 8f * u;
line.setColor(0xCCFFFFFF);
line.setStrokeWidth(1.5f * u);
float ax = cx + SongCarriers.dragonTail[0] * size, ay = cy + SongCarriers.dragonTail[1] * size;
c.drawLine(right, yc - bh / 2f + 3f * u, ax, ay, line);
c.drawLine(right, yc + bh / 2f - 3f * u, ax, ay, line);
SongCarriers.dragon(c, fill, line, path, r, cx, cy, size, (float) Math.sin(t * 6f), t, u);
return true;
} }
// ---------- World ---------- // ---------- World ----------
@@ -67,6 +67,13 @@ public final class SciFiView extends View {
label(c, redAlert ? "RED ALERT · LOW RESERVES" : "PROPULSION STATUS", w * 0.61f + 10f * d, label(c, redAlert ? "RED ALERT · LOW RESERVES" : "PROPULSION STATUS", w * 0.61f + 10f * d,
bar * 0.86f, bar * 1.05f, redAlert ? alert : orange, Paint.Align.LEFT); bar * 0.86f, bar * 1.05f, redAlert ? alert : orange, Paint.Align.LEFT);
pill(c, w * 0.94f, 0, w, bar, frame2); pill(c, w * 0.94f, 0, w, bar, frame2);
if (v.songTitle != null) {
// What plays, in black on the top bar, cut to its length.
float x = side + 16f * d, size = bar * 0.8f;
text.setTextSize(size);
String s = SongBanner.line(text, v.songTitle, v.songArtist, w * 0.60f - x - 16f * d);
label(c, s, x, bar * 0.78f, size, 0xFF000000, Paint.Align.LEFT);
}
// The bottom bar's end: odometer and time. // The bottom bar's end: odometer and time.
String odo = v.odometerKm == null ? "—" : String.format(Locale.US, "%06.0f", v.odometerKm); String odo = v.odometerKm == null ? "—" : String.format(Locale.US, "%06.0f", v.odometerKm);
label(c, "ODO " + odo + " · " + (v.clock.isEmpty() ? "—" : v.clock), label(c, "ODO " + odo + " · " + (v.clock.isEmpty() ? "—" : v.clock),
@@ -167,10 +167,19 @@ final class SignalRecorder {
text.add(""); text.add("");
text.add("Everything, in order:"); text.add("Everything, in order:");
text.addAll(rec.lines()); text.addAll(rec.lines());
List<String> places = new ArrayList<>();
if (write(new File(app.getFilesDir(), FILE), text)) places.add("app");
String name = "signal-recording-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US) String name = "signal-recording-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
.format(new Date(startedMs)) + ".txt"; .format(new Date(startedMs)) + ".txt";
return saveCopies(app, FILE, name, text);
}
/**
* Writes a report to the app's storage ({@code appFile}), to the app's folder on every USB
* stick plugged in ({@code name}) and to the car server; says where it went. Shared with
* {@link MediaProbe}.
*/
static String saveCopies(Context app, String appFile, String name, List<String> text) {
List<String> places = new ArrayList<>();
if (write(new File(app.getFilesDir(), appFile), text)) places.add("app");
File[] dirs = app.getExternalFilesDirs(null); File[] dirs = app.getExternalFilesDirs(null);
for (File dir : dirs == null ? new File[0] : dirs) { for (File dir : dirs == null ? new File[0] : dirs) {
try { try {
@@ -0,0 +1,97 @@
package com.evsuite.abrp;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PathMeasure;
/**
* The strip of paper a creature tows across a style's sky with the song on it, shared by the styles
* that show what plays (Halloween's parchment, Día de Muertos's papel picado). The ribbon waves in
* the wind: still where the creature holds it, rippling more towards its tail, which is cut as a
* swallowtail, and the text follows the waves. The style draws the creature and the tow lines. Keeps
* only the paths it reuses between frames.
*/
final class SongBanner {
private static final float SPEED = 70f;
private final Path path = new Path();
private final Path baseline = new Path();
private final PathMeasure measure = new PathMeasure();
/** One pass takes about half a minute; then the creature comes round again from the left. */
static float travel(int w, float bw, float tow, float u) {
return w + bw + tow + 80f * u;
}
/** Where the banner's left end is at {@code t} seconds. */
static float left(float t, int w, float bw, float tow, float u) {
return (t * SPEED * u) % travel(w, bw, tow, u) - bw - tow - 40f * u;
}
/** How many times it has crossed by {@code t}, to let two creatures take turns. */
static int pass(float t, int w, float bw, float tow, float u) {
return (int) (t * SPEED * u / travel(w, bw, tow, u));
}
/**
* Draws the banner from {@code bx} to {@code bx + bw}, centred on {@code yc}. {@code tooth} above
* zero cuts both long edges in zigzags of that width (cut paper). The text paint is set up by the
* caller (typeface and size); its colour and alignment are set here.
*/
void draw(Canvas c, Paint fill, Paint line, Paint text, String s, float bx, float yc, float bw, float bh,
float t, float u, float tooth, int paper, int edge, int ink) {
float top = yc - bh / 2f, bottom = yc + bh / 2f, right = bx + bw, notch = 12f * u;
float cut = tooth > 0f ? 3.5f * u : 0f;
int steps = Math.max(8, Math.round(bw / (tooth > 0f ? tooth / 2f : 12f * u)));
path.reset();
for (int i = 0; i <= steps; i++) {
float x = right - bw * i / steps;
float y = top + wave(x, right, bw, t, u) - (i % 2 == 1 ? cut : 0f);
if (i == 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
path.lineTo(bx + notch, yc + wave(bx + notch, right, bw, t, u));
for (int i = steps; i >= 0; i--) {
float x = right - bw * i / steps;
path.lineTo(x, bottom + wave(x, right, bw, t, u) + (i % 2 == 1 ? cut : 0f));
}
path.close();
fill.setColor(paper);
c.drawPath(path, fill);
line.setColor(edge);
line.setStrokeWidth(2f * u);
c.drawPath(path, line);
baseline.reset();
for (int i = 0; i <= steps; i++) {
float x = bx + notch + (bw - notch) * i / steps;
if (i == 0) baseline.moveTo(x, yc + wave(x, right, bw, t, u));
else baseline.lineTo(x, yc + wave(x, right, bw, t, u));
}
measure.setPath(baseline, false);
text.setColor(ink);
text.setTextAlign(Paint.Align.LEFT);
c.drawTextOnPath(s, baseline, Math.max(0f, (measure.getLength() - text.measureText(s)) / 2f), 7f * u, text);
}
/**
* How far the banner bends at {@code x}: nothing at the creature's end ({@code right}), up to 7
* units at the tail, in waves that run from the creature to the tail.
*/
private static float wave(float x, float right, float bw, float t, float u) {
float along = right - x;
return 7f * u * (along / bw) * (float) Math.sin(along / (170f * u) * 2f * Math.PI - t * 5f);
}
/** "♪ title — artist" in the text paint as set, cut with "…" to fit {@code room}; the title gives way first. */
static String line(Paint text, String title, String artist, float room) {
String a = artist == null ? "" : " — " + cut(text, artist, room / 2.5f);
return "♪ " + cut(text, title, room - text.measureText("♪ " + a)) + a;
}
private static String cut(Paint text, 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() + "…";
}
}
@@ -0,0 +1,338 @@
package com.evsuite.abrp;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
/**
* The creatures that tow a {@link SongBanner} in the styles with a sky for it, drawn flying right
* with their centre at (x, y) and about {@code s} wide: a dragon (Quest), an eagle (Empire), Santa's
* sleigh with a reindeer and an angel (Christmas), a butterfly and a ladybird (Caterpillar).
* {@code flap} runs from −1 to 1 with the wing beat, {@code t} is the time in seconds. The caller
* passes its own paints and scratch objects; the paints' colour and width are set here. Where the
* tow lines tie on is {@link #dragonTail}, {@link #eagleTail}, {@link #sleighTail}, {@link #angelTail}, {@link #butterflyTail} and {@link #ladybirdTail}, as offsets in units of {@code s}.
*/
final class SongCarriers {
static final float[] dragonTail = { -1.0f, -0.25f }, eagleTail = { -0.55f, 0.05f },
sleighTail = { -0.62f, 0f }, angelTail = { -0.45f, 0.1f }, butterflyTail = { -0.3f, 0f },
ladybirdTail = { -0.4f, 0f };
private SongCarriers() {}
static void dragon(Canvas c, Paint fill, Paint line, Path path, RectF r, float x, float y, float s,
float flap, float t, float u) {
final int red = 0xFFB3262E, dark = 0xFF6E1219, belly = 0xFFE8B84A;
c.save();
c.translate(x, y);
// The wing behind the body: a membrane between three fingers, lifted and dropped by the flap.
c.save();
c.translate(-0.05f * s, -0.2f * s);
c.rotate(-120f + flap * 30f);
path.reset();
path.moveTo(0, 0);
path.lineTo(0.95f * s, -0.05f * s);
path.quadTo(0.8f * s, 0.15f * s, 0.62f * s, 0.12f * s);
path.quadTo(0.5f * s, 0.3f * s, 0.3f * s, 0.22f * s);
path.quadTo(0.2f * s, 0.35f * s, 0, 0.15f * s);
path.close();
fill.setColor(dark);
c.drawPath(path, fill);
line.setColor(0xFF2A0508);
line.setStrokeWidth(2f * u);
c.drawPath(path, line);
c.drawLine(0, 0.05f * s, 0.62f * s, 0.12f * s, line);
c.restore();
// The tail, ending in a spade.
path.reset();
path.moveTo(-0.4f * s, 0f);
path.cubicTo(-0.9f * s, 0.1f * s, -0.8f * s, -0.3f * s, -1.0f * s, -0.25f * s);
line.setColor(red);
line.setStrokeWidth(0.13f * s);
line.setStrokeCap(Paint.Cap.ROUND);
c.drawPath(path, line);
line.setStrokeCap(Paint.Cap.BUTT);
// Legs.
line.setColor(dark);
line.setStrokeWidth(3f * u);
c.drawLine(-0.25f * s, 0.12f * s, -0.3f * s, 0.34f * s, line);
c.drawLine(0.1f * s, 0.12f * s, 0.18f * s, 0.34f * s, line);
// Body with a pale belly.
r.set(-0.45f * s, -0.17f * s, 0.3f * s, 0.17f * s);
fill.setColor(red);
c.drawOval(r, fill);
r.set(-0.4f * s, 0.0f, 0.25f * s, 0.15f * s);
fill.setColor(belly);
c.drawOval(r, fill);
// Neck and head, a horn, an eye, and a small flame at the mouth.
path.reset();
path.moveTo(0.2f * s, -0.05f * s);
path.quadTo(0.4f * s, -0.05f * s, 0.5f * s, -0.3f * s);
line.setColor(red);
line.setStrokeWidth(0.15f * s);
c.drawPath(path, line);
r.set(0.46f * s, -0.45f * s, 0.86f * s, -0.24f * s);
fill.setColor(red);
c.drawOval(r, fill);
line.setColor(belly);
line.setStrokeWidth(3f * u);
c.drawLine(0.5f * s, -0.44f * s, 0.38f * s, -0.62f * s, line);
fill.setColor(0xFFFFE04A);
c.drawCircle(0.62f * s, -0.37f * s, 0.035f * s, fill);
fill.setColor(0xFF2A0508);
c.drawCircle(0.63f * s, -0.37f * s, 0.016f * s, fill);
float flick = 0.8f + 0.2f * (float) Math.sin(t * 25f);
fill.setColor(0xFFFF8A1F);
c.drawCircle(0.93f * s, -0.33f * s, 0.07f * s * flick, fill);
fill.setColor(0xFFFFE04A);
c.drawCircle(0.95f * s, -0.33f * s, 0.035f * s * flick, fill);
c.restore();
}
static void eagle(Canvas c, Paint fill, Paint line, Path path, RectF r, float x, float y, float s,
float flap, float t, float u) {
final int brown = 0xFF6B4423, dark = 0xFF3E2610, white = 0xFFF4EAD2, beak = 0xFFF2B21B;
c.save();
c.translate(x, y);
// The tail, a short fan.
path.reset();
path.moveTo(-0.3f * s, 0f);
path.lineTo(-0.58f * s, -0.1f * s);
path.lineTo(-0.6f * s, 0.02f * s);
path.lineTo(-0.55f * s, 0.14f * s);
path.close();
fill.setColor(dark);
c.drawPath(path, fill);
// The far wing, then the near one: feathers fanned from the shoulder, lifted by the flap.
for (int wing = 0; wing < 2; wing++) {
for (int i = 0; i < 5; i++) {
c.save();
c.translate(0.02f * s, -0.08f * s);
c.rotate(-130f + flap * 32f + i * 13f + (wing == 0 ? 8f : 0f));
r.set(0, -0.05f * s, (0.95f - 0.08f * Math.abs(i - 2)) * s, 0.05f * s);
fill.setColor(wing == 0 ? dark : (i % 2 == 0 ? brown : 0xFF84552E));
c.drawOval(r, fill);
c.restore();
}
}
// Body, white head, hooked beak, eye, talons.
r.set(-0.35f * s, -0.14f * s, 0.3f * s, 0.14f * s);
fill.setColor(brown);
c.drawOval(r, fill);
fill.setColor(white);
c.drawCircle(0.34f * s, -0.12f * s, 0.12f * s, fill);
path.reset();
path.moveTo(0.42f * s, -0.16f * s);
path.quadTo(0.6f * s, -0.15f * s, 0.55f * s, -0.04f * s);
path.lineTo(0.43f * s, -0.08f * s);
path.close();
fill.setColor(beak);
c.drawPath(path, fill);
fill.setColor(0xFF1A1008);
c.drawCircle(0.38f * s, -0.15f * s, 0.022f * s, fill);
line.setColor(beak);
line.setStrokeWidth(2.5f * u);
c.drawLine(-0.1f * s, 0.13f * s, -0.14f * s, 0.26f * s, line);
c.drawLine(0.05f * s, 0.13f * s, 0.02f * s, 0.26f * s, line);
c.restore();
}
/** A reindeer with a glowing nose pulling Santa's sleigh, drawn about 1.7 s wide. */
static void sleigh(Canvas c, Paint fill, Paint line, Path path, RectF r, float x, float y, float s,
float t, float u) {
final int brown = 0xFF8A5A33, red = 0xFFC62828, gold = 0xFFFFD04A;
c.save();
c.translate(x, y);
// The reindeer: four legs trotting, a body, a neck, a head with antlers and a red nose.
float swing = (float) Math.sin(t * 8f) * 0.18f;
line.setColor(brown);
line.setStrokeWidth(3f * u);
c.drawLine(0.72f * s, 0.05f * s, (0.72f + swing) * s, 0.3f * s, line);
c.drawLine(0.66f * s, 0.05f * s, (0.66f - swing) * s, 0.3f * s, line);
c.drawLine(0.44f * s, 0.05f * s, (0.44f - swing) * s, 0.3f * s, line);
c.drawLine(0.38f * s, 0.05f * s, (0.38f + swing) * s, 0.3f * s, line);
r.set(0.32f * s, -0.15f * s, 0.8f * s, 0.1f * s);
fill.setColor(brown);
c.drawOval(r, fill);
line.setStrokeWidth(0.1f * s);
c.drawLine(0.76f * s, -0.08f * s, 0.86f * s, -0.2f * s, line);
c.drawCircle(0.88f * s, -0.22f * s, 0.075f * s, fill);
line.setColor(0xFFD9C08A);
line.setStrokeWidth(2f * u);
c.drawLine(0.85f * s, -0.28f * s, 0.78f * s, -0.46f * s, line);
c.drawLine(0.82f * s, -0.37f * s, 0.9f * s, -0.4f * s, line);
c.drawLine(0.9f * s, -0.28f * s, 0.97f * s, -0.46f * s, line);
c.drawLine(0.94f * s, -0.37f * s, 0.86f * s, -0.42f * s, line);
float glow = 0.75f + 0.25f * (float) Math.sin(t * 6f);
fill.setColor(0x55FF3B30);
c.drawCircle(0.97f * s, -0.2f * s, 0.1f * s * glow, fill);
fill.setColor(0xFFFF3B30);
c.drawCircle(0.96f * s, -0.2f * s, 0.04f * s, fill);
// The harness, and the sleigh: a red body with a curled front on a gold runner.
line.setColor(gold);
line.setStrokeWidth(1.5f * u);
c.drawLine(0.36f * s, -0.02f * s, 0.12f * s, -0.02f * s, line);
path.reset();
path.moveTo(-0.55f * s, -0.12f * s);
path.lineTo(0.05f * s, -0.12f * s);
path.quadTo(0.22f * s, -0.12f * s, 0.2f * s, -0.28f * s);
path.lineTo(0.12f * s, 0.1f * s);
path.lineTo(-0.5f * s, 0.1f * s);
path.quadTo(-0.64f * s, 0.08f * s, -0.6f * s, -0.12f * s);
path.close();
fill.setColor(red);
c.drawPath(path, fill);
line.setColor(gold);
line.setStrokeWidth(2f * u);
c.drawPath(path, line);
path.reset();
path.moveTo(-0.62f * s, 0.19f * s);
path.lineTo(0.18f * s, 0.19f * s);
path.quadTo(0.32f * s, 0.19f * s, 0.3f * s, 0.07f * s);
line.setStrokeWidth(3f * u);
c.drawPath(path, line);
c.drawLine(-0.4f * s, 0.1f * s, -0.4f * s, 0.19f * s, line);
c.drawLine(0.0f, 0.1f * s, 0.0f, 0.19f * s, line);
// Santa with his sack.
fill.setColor(0xFF7A4A22);
c.drawCircle(-0.5f * s, -0.18f * s, 0.11f * s, fill);
fill.setColor(red);
c.drawCircle(-0.25f * s, -0.2f * s, 0.12f * s, fill);
fill.setColor(0xFFF2CFA0);
c.drawCircle(-0.22f * s, -0.36f * s, 0.07f * s, fill);
fill.setColor(0xFFFFFFFF);
r.set(-0.28f * s, -0.37f * s, -0.15f * s, -0.28f * s);
c.drawOval(r, fill);
path.reset();
path.moveTo(-0.29f * s, -0.4f * s);
path.lineTo(-0.14f * s, -0.4f * s);
path.lineTo(-0.3f * s, -0.56f * s);
path.close();
fill.setColor(red);
c.drawPath(path, fill);
fill.setColor(0xFFFFFFFF);
c.drawCircle(-0.3f * s, -0.57f * s, 0.035f * s, fill);
c.restore();
}
/** A small angel flying right with a trumpet, wings beating. */
static void angel(Canvas c, Paint fill, Paint line, Path path, RectF r, float x, float y, float s,
float flap, float u) {
final int white = 0xFFF4F8FF, gold = 0xFFFFD04A;
c.save();
c.translate(x, y);
for (int i = 0; i < 4; i++) {
c.save();
c.translate(-0.05f * s, -0.12f * s);
c.rotate(-125f + flap * 28f + i * 14f);
r.set(0, -0.06f * s, (0.85f - 0.07f * i) * s, 0.06f * s);
fill.setColor(white);
c.drawOval(r, fill);
line.setColor(0xFFB9C6DC);
line.setStrokeWidth(1.5f * u);
c.drawOval(r, line);
c.restore();
}
// The robe trails behind, with a gold sash.
path.reset();
path.moveTo(0.2f * s, -0.14f * s);
path.quadTo(0.28f * s, 0.05f * s, 0.05f * s, 0.12f * s);
path.lineTo(-0.5f * s, 0.22f * s);
path.quadTo(-0.35f * s, -0.02f * s, -0.1f * s, -0.14f * s);
path.close();
fill.setColor(white);
c.drawPath(path, fill);
line.setColor(0xFFB9C6DC);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
line.setColor(gold);
line.setStrokeWidth(3f * u);
c.drawLine(0.02f * s, -0.12f * s, 0.12f * s, 0.06f * s, line);
// The head with a halo, and the trumpet held out in front.
fill.setColor(0xFFF2CFA0);
c.drawCircle(0.3f * s, -0.2f * s, 0.1f * s, fill);
fill.setColor(gold);
r.set(0.2f * s, -0.4f * s, 0.4f * s, -0.33f * s);
c.drawOval(r, line);
line.setStrokeWidth(2.5f * u);
c.drawLine(0.3f * s, -0.04f * s, 0.7f * s, -0.1f * s, line);
path.reset();
path.moveTo(0.66f * s, -0.1f * s);
path.lineTo(0.9f * s, -0.22f * s);
path.lineTo(0.9f * s, 0.02f * s);
path.close();
c.drawPath(path, fill);
c.restore();
}
/** A big tissue-paper butterfly seen from above, flying right, wings beating. */
static void butterfly(Canvas c, Paint fill, Paint line, Path path, RectF r, float x, float y, float s,
float flap, float u) {
final int purple = 0xFF7E57E0, pink = 0xFFFF5FA2, yellow = 0xFFFFE066, dark = 0xFF1B1530;
float k = 0.3f + 0.7f * Math.abs(flap);
c.save();
c.translate(x, y);
for (int side = 0; side < 2; side++) {
c.save();
if (side == 1) c.scale(1f, -1f);
path.reset();
path.moveTo(0.25f * s, 0);
path.quadTo(0.3f * s, -0.8f * s * k, -0.1f * s, -0.95f * s * k);
path.quadTo(-0.4f * s, -0.6f * s * k, -0.05f * s, 0);
path.close();
fill.setColor(purple);
c.drawPath(path, fill);
path.reset();
path.moveTo(-0.05f * s, 0);
path.quadTo(-0.45f * s, -0.6f * s * k, -0.7f * s, -0.35f * s * k);
path.quadTo(-0.6f * s, -0.05f * s * k, -0.2f * s, 0);
path.close();
fill.setColor(pink);
c.drawPath(path, fill);
fill.setColor(yellow);
c.drawCircle(0.02f * s, -0.58f * s * k, 0.08f * s, fill);
c.drawCircle(-0.4f * s, -0.33f * s * k, 0.06f * s, fill);
c.restore();
}
fill.setColor(dark);
r.set(-0.4f * s, -0.06f * s, 0.3f * s, 0.06f * s);
c.drawOval(r, fill);
c.drawCircle(0.34f * s, 0, 0.07f * s, fill);
line.setColor(dark);
line.setStrokeWidth(1.5f * u);
c.drawLine(0.38f * s, -0.03f * s, 0.55f * s, -0.18f * s, line);
c.drawLine(0.38f * s, 0.03f * s, 0.55f * s, 0.18f * s, line);
c.restore();
}
/** A ladybird seen from above, flying right, with its wings out. */
static void ladybird(Canvas c, Paint fill, Paint line, Path path, RectF r, float x, float y, float s,
float flap, float u) {
final int red = 0xFFE53935, dark = 0xFF1B1530;
c.save();
c.translate(x, y);
float out = (0.3f + 0.25f * Math.abs(flap)) * s;
fill.setColor(0xAAFFFFFF);
r.set(-0.45f * s, -out - 0.12f * s, 0.15f * s, -out + 0.12f * s);
c.drawOval(r, fill);
r.set(-0.45f * s, out - 0.12f * s, 0.15f * s, out + 0.12f * s);
c.drawOval(r, fill);
fill.setColor(red);
r.set(-0.4f * s, -0.26f * s, 0.26f * s, 0.26f * s);
c.drawOval(r, fill);
line.setColor(dark);
line.setStrokeWidth(2f * u);
c.drawLine(-0.4f * s, 0, 0.26f * s, 0, line);
fill.setColor(dark);
c.drawCircle(-0.2f * s, -0.12f * s, 0.045f * s, fill);
c.drawCircle(-0.2f * s, 0.12f * s, 0.045f * s, fill);
c.drawCircle(0.05f * s, -0.14f * s, 0.045f * s, fill);
c.drawCircle(0.05f * s, 0.14f * s, 0.045f * s, fill);
c.drawCircle(0.34f * s, 0, 0.12f * s, fill);
line.setStrokeWidth(1.5f * u);
c.drawLine(0.42f * s, -0.06f * s, 0.58f * s, -0.18f * s, line);
c.drawLine(0.42f * s, 0.06f * s, 0.58f * s, 0.18f * s, line);
c.restore();
}
}
@@ -0,0 +1,108 @@
package com.evsuite.abrp;
import java.util.Locale;
/**
* What {@link SoundProbe} makes of Android's audio analyser: how loud a captured waveform is, how
* strong a band of its spectrum, and whether a run of those looks like music the analyser can
* hear. A failed read is null, never a silent 0. Android-free and unit-tested.
*/
final class SoundLevels {
/** Below this the peak counts as silence (the 8-bit waveform's noise is about 1/128). */
static final float SILENT = 0.02f;
private SoundLevels() {}
/** The waveform's peak from 0 to 1. Its samples are unsigned 8-bit, with silence at 128. */
static Float peak(byte[] wave) {
if (wave == null || wave.length == 0) return null;
int max = 0;
for (byte b : wave) max = Math.max(max, Math.abs((b & 0xFF) - 128));
return Math.min(1f, max / 128f);
}
/**
* The mean strength from 0 to 1 of the bins between {@code fromHz} and {@code toHz}. The
* analyser's FFT holds the DC and Nyquist values first, then each bin's real and imaginary
* part, signed; a bin {@code k} is {@code k × rate / length} Hz.
*/
static Float band(byte[] fft, int rateHz, float fromHz, float toHz) {
if (fft == null || fft.length < 4 || rateHz <= 0) return null;
float sum = 0f;
int bins = 0;
for (int k = 1; k < fft.length / 2; k++) {
float hz = k * (float) rateHz / fft.length;
if (hz < fromHz || hz >= toHz) continue;
sum += (float) Math.hypot(fft[2 * k], fft[2 * k + 1]) / 128f;
bins++;
}
return bins == 0 ? null : Math.min(1f, sum / bins);
}
enum Verdict {
/** The analyser gave no readings at all. */
NO_READING,
/** Nothing was playing and the analyser heard nothing: says nothing about the source. */
NOTHING_PLAYING,
/** Something played and the analyser heard silence: it cannot hear this source. */
DEAF,
/** The level stays where it is: not music, or not real data. */
STEADY,
/** The level moves: the pumpkin could follow it. */
HEARS
}
/** Readings gathered over a time. */
static final class Summary {
private int samples, readings, bandReadings;
private float peakMin = Float.MAX_VALUE, peakMax, bass, mid, treble;
private Integer rms;
/** One round of reads, each null when it failed. */
void add(Float peak, Float bassBand, Float midBand, Float trebleBand, Integer rmsMilliBel) {
samples++;
if (peak != null) {
readings++;
peakMin = Math.min(peakMin, peak);
peakMax = Math.max(peakMax, peak);
}
if (bassBand != null && midBand != null && trebleBand != null) {
bandReadings++;
bass += bassBand;
mid += midBand;
treble += trebleBand;
}
if (rmsMilliBel != null) rms = rmsMilliBel;
}
Verdict verdict(boolean somethingPlays) {
if (readings == 0) return Verdict.NO_READING;
if (peakMax < SILENT) return somethingPlays ? Verdict.DEAF : Verdict.NOTHING_PLAYING;
return peakMax - peakMin < SILENT ? Verdict.STEADY : Verdict.HEARS;
}
/** One line of the report; what could not be read is "—". */
String line() {
return "peak " + (readings == 0 ? "—" : String.format(Locale.US, "%.2f–%.2f", peakMin, peakMax))
+ ", bass " + mean(bass) + ", mid " + mean(mid) + ", treble " + mean(treble)
+ ", rms " + (rms == null ? "—" : rms + " mB")
+ " (" + readings + "/" + samples + ")";
}
private String mean(float sum) {
return bandReadings == 0 ? "—" : String.format(Locale.US, "%.3f", sum / bandReadings);
}
}
/** The report's last line. */
static String words(Verdict verdict) {
switch (verdict) {
case HEARS: return "Result: the analyser hears it, and the level moves.";
case STEADY: return "Result: the level does not move, so this is not usable music.";
case DEAF: return "Result: something plays, but the analyser hears silence.";
case NOTHING_PLAYING: return "Result: nothing was playing, so this says nothing. Play something and check again.";
default: return "Result: the analyser gave no readings.";
}
}
}
@@ -0,0 +1,179 @@
package com.evsuite.abrp;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.AudioManager;
import android.media.audiofx.Visualizer;
import android.media.session.MediaController;
import android.media.session.MediaSessionManager;
import android.media.session.PlaybackState;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/**
* The sound check on the Logs tab: whether Android's audio analyser ({@link Visualizer} on the
* output mix, session 0) can hear what the head unit plays, before the Halloween pumpkin's light
* follows the music. Only one tap starts it, and it listens for {@value #SECONDS} seconds and lets
* go. It records levels only, never the sound, and uses no microphone. The report goes where a
* signal recording goes ({@link SignalRecorder#saveCopies}): the app, a USB stick, the server.
*/
final class SoundProbe {
static final String FILE = "sound_probe.txt";
private static final int SECONDS = 10, READS_PER_SECOND = 10;
private static volatile boolean running;
private static long startedMs;
private static List<String> report = new ArrayList<>();
private static String result;
private SoundProbe() {}
static synchronized boolean running() {
return running;
}
static synchronized void start(Context context) {
if (running) return;
Context app = context.getApplicationContext();
running = true;
startedMs = System.currentTimeMillis();
result = null;
report = new ArrayList<>();
new Thread(() -> probe(app), "sound-probe").start();
}
private static void probe(Context app) {
List<String> out = new ArrayList<>();
out.add("Sound probe, " + SignalRecording.time(startedMs) + ", app " + BuildConfig.VERSION_NAME);
String saved;
try {
permission(app, Manifest.permission.RECORD_AUDIO, out);
permission(app, Manifest.permission.MODIFY_AUDIO_SETTINGS, out);
boolean plays = sessions(app, out);
plays |= musicActive(app, out);
SoundLevels.Summary all = listen(out);
plays |= musicActive(app, out);
out.add("");
out.add(SoundLevels.words(all.verdict(plays)));
} catch (Throwable t) {
out.add("probe stopped: " + t.getClass().getSimpleName());
} finally {
String name = "sound-probe-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
.format(new Date(startedMs)) + ".txt";
saved = SignalRecorder.saveCopies(app, FILE, name, out);
synchronized (SoundProbe.class) {
report = out;
result = saved;
running = false;
}
}
}
private static void permission(Context app, String name, List<String> out) {
boolean granted = app.checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
out.add(name.substring(name.lastIndexOf('.') + 1) + ": " + (granted ? "granted" : "not granted"));
}
private static boolean musicActive(Context app, List<String> out) {
try {
AudioManager am = (AudioManager) app.getSystemService(Context.AUDIO_SERVICE);
boolean active = am != null && am.isMusicActive();
out.add("music active: " + active);
return active;
} catch (RuntimeException e) {
out.add("music active: unreadable (" + e.getClass().getSimpleName() + ")");
return false;
}
}
/** Which source is playing, for reading the report later; no song. True when one is. */
private static boolean sessions(Context app, List<String> out) {
boolean playing = false;
try {
MediaSessionManager msm = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE);
List<MediaController> controllers = msm == null ? null : msm.getActiveSessions(null);
if (controllers == null || controllers.isEmpty()) out.add("sessions: none");
else for (MediaController c : controllers) {
PlaybackState s = c.getPlaybackState();
out.add("session: " + MediaProbeText.clean(c.getPackageName()) + ", "
+ (s == null ? "no state" : MediaProbeText.playbackState(s.getState())));
if (s != null && s.getState() == PlaybackState.STATE_PLAYING) playing = true;
}
} catch (Throwable t) {
out.add("sessions: not readable (" + t.getClass().getSimpleName() + ")");
}
return playing;
}
/** Listens for the whole time, one report line a second; always lets the analyser go. */
private static SoundLevels.Summary listen(List<String> out) throws InterruptedException {
SoundLevels.Summary all = new SoundLevels.Summary();
Visualizer analyser = null;
try {
analyser = new Visualizer(0);
int[] sizes = Visualizer.getCaptureSizeRange();
int code = analyser.setCaptureSize(sizes[1]);
analyser.setScalingMode(Visualizer.SCALING_MODE_NORMALIZED);
analyser.setMeasurementMode(Visualizer.MEASUREMENT_MODE_PEAK_RMS);
int enabled = analyser.setEnabled(true);
int size = analyser.getCaptureSize(), rateHz = analyser.getSamplingRate() / 1000;
out.add("analyser: capture " + size + " at " + rateHz + " Hz, set size " + code + ", enable " + enabled);
if (enabled != Visualizer.SUCCESS) return all;
byte[] wave = new byte[size], fft = new byte[size];
Visualizer.MeasurementPeakRms measure = new Visualizer.MeasurementPeakRms();
for (int second = 0; second < SECONDS; second++) {
SoundLevels.Summary now = new SoundLevels.Summary();
for (int read = 0; read < READS_PER_SECOND; read++) {
Thread.sleep(1000 / READS_PER_SECOND);
Float peak = analyser.getWaveForm(wave) == Visualizer.SUCCESS ? SoundLevels.peak(wave) : null;
boolean spectrum = analyser.getFft(fft) == Visualizer.SUCCESS;
Float bass = spectrum ? SoundLevels.band(fft, rateHz, 20f, 250f) : null;
Float mid = spectrum ? SoundLevels.band(fft, rateHz, 250f, 2000f) : null;
Float treble = spectrum ? SoundLevels.band(fft, rateHz, 2000f, 16000f) : null;
Integer rms = analyser.getMeasurementPeakRms(measure) == Visualizer.SUCCESS ? measure.mRms : null;
now.add(peak, bass, mid, treble, rms);
all.add(peak, bass, mid, treble, rms);
}
out.add(MediaProbeText.duration(second * 1000L) + " " + now.line());
}
} catch (RuntimeException e) {
out.add("analyser not available: " + e.getClass().getSimpleName() + " " + MediaProbeText.clean(e.getMessage()));
} finally {
if (analyser != null) {
try {
analyser.setEnabled(false);
} catch (RuntimeException ignored) {
// Released next either way.
}
try {
analyser.release();
} catch (RuntimeException e) {
out.add("analyser not released: " + e.getClass().getSimpleName());
}
}
}
return all;
}
/** What the Logs tab shows: the state, then the report. */
static synchronized List<String> status(Context context) {
List<String> lines = new ArrayList<>();
if (startedMs == 0) return lines;
if (running) {
lines.add(context.getString(R.string.sound_probe_running));
return lines;
}
lines.add(context.getString(R.string.media_probe_done, SignalRecording.time(startedMs),
result == null ? "…" : result));
String sent = LogOutbox.status(context);
if (sent != null) lines.add(sent);
lines.addAll(report.subList(Math.min(1, report.size()), report.size()));
return lines;
}
}
@@ -24,6 +24,10 @@ final class StylePalette {
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest; case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor; case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
case EMPIRE: return R.style.ThemeOverlay_EV_Style_Empire; case EMPIRE: return R.style.ThemeOverlay_EV_Style_Empire;
case MUERTOS: return R.style.ThemeOverlay_EV_Style_Muertos;
case HALLOWEEN: return R.style.ThemeOverlay_EV_Style_Halloween;
case CHRISTMAS: return R.style.ThemeOverlay_EV_Style_Christmas;
case CATERPILLAR: return R.style.ThemeOverlay_EV_Style_Caterpillar;
default: return 0; default: return 0;
} }
} }
@@ -0,0 +1,27 @@
package com.evsuite.abrp;
/**
* How many of a row of things a value lights: flags, candles, petals, embers, bats. An unread
* value lights none, never a made-up amount. Android-free and unit-tested.
*/
final class Tally {
private Tally() {}
/** One thing per {@code step} of the value, rounded, from 0 to {@code max}; null or NaN is 0. */
static int lit(Float value, float step, int max) {
if (value == null || value.isNaN() || step <= 0f || value <= 0f) return 0;
return Math.min(max, Math.round(value / step));
}
/**
* Of {@code count} things round a gauge, how many the power lights: all of them at
* {@code fullDraw} kW drawn, or at {@code fullRegen} kW regenerated (negative power).
* Any real power lights at least one, so the gauge never looks idle while it is not.
*/
static int ofPower(Float kw, float fullDraw, float fullRegen, int count) {
if (kw == null || kw.isNaN() || Math.abs(kw) < 0.1f) return 0;
float share = Math.abs(kw) / (kw < 0f ? fullRegen : fullDraw);
return Math.max(1, Math.min(count, Math.round(share * count)));
}
}
@@ -84,6 +84,14 @@ final class TerminalView extends ClusterCanvasView {
y += line; y += line;
print(c, row("TYRES", "FL " + v.tyreFl + " FR " + v.tyreFr + " RL " + v.tyreRl + " RR " + v.tyreRr), x, y, green); print(c, row("TYRES", "FL " + v.tyreFl + " FR " + v.tyreFr + " RL " + v.tyreRl + " RR " + v.tyreRr), x, y, green);
y += line * 1.4f; y += line * 1.4f;
if (v.songTitle != null) {
// What plays, cut to the row's width.
String label = String.format(Locale.US, "%-9s", "PLAYING");
String s = v.songTitle + (v.songArtist == null ? "" : " — " + v.songArtist);
int fits = text.breakText(s, true, Math.max(0f, getWidth() - x - text.measureText(label)), null);
print(c, label + (fits < s.length() ? s.substring(0, Math.max(0, fits - 1)).trim() + "…" : s), x, y, green);
y += line;
}
boolean cursor = (SystemClock.uptimeMillis() / 500) % 2 == 0; boolean cursor = (SystemClock.uptimeMillis() / 500) % 2 == 0;
print(c, "[" + (v.clock.isEmpty() ? "--:--" : v.clock) + "] nils@mg4:~$ " + (cursor ? "█" : ""), x, y, green); print(c, "[" + (v.clock.isEmpty() ? "--:--" : v.clock) + "] nils@mg4:~$ " + (cursor ? "█" : ""), x, y, green);
@@ -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 (the styles with a place for it). -->
<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, Christmas and Caterpillar listen 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" />
+14
View File
@@ -8,6 +8,20 @@
<com.google.android.material.button.MaterialButton android:id="@+id/signal_record_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_signal_record" style="@style/Widget.EV.Button.Outlined" /> <com.google.android.material.button.MaterialButton android:id="@+id/signal_record_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_signal_record" style="@style/Widget.EV.Button.Outlined" />
<TextView android:id="@+id/signal_record_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" /> <TextView android:id="@+id/signal_record_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
</LinearLayout> </LinearLayout>
<!-- The music-sources check (MediaProbe): where the head unit tells what is playing. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:layout_marginBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_media_probe" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/media_probe_hint" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.button.MaterialButton android:id="@+id/media_probe_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_media_probe" style="@style/Widget.EV.Button.Outlined" />
<TextView android:id="@+id/media_probe_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
</LinearLayout>
<!-- The sound check (SoundProbe): whether the audio analyser can hear what plays. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:layout_marginBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_sound_probe" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/sound_probe_hint" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.button.MaterialButton android:id="@+id/sound_probe_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_sound_probe" style="@style/Widget.EV.Button.Outlined" />
<TextView android:id="@+id/sound_probe_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" /> <com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" />
@@ -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 (the styles with a place for it). -->
<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, Christmas and Caterpillar listen 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" />
+22
View File
@@ -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">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>
@@ -141,6 +150,10 @@
<string name="overview_style_quest">Quest</string> <string name="overview_style_quest">Quest</string>
<string name="overview_style_visor">Visor</string> <string name="overview_style_visor">Visor</string>
<string name="overview_style_empire">Empire</string> <string name="overview_style_empire">Empire</string>
<string name="overview_style_muertos">Día de Muertos</string>
<string name="overview_style_halloween">Halloween</string>
<string name="overview_style_christmas">Christmas</string>
<string name="overview_style_caterpillar">Caterpillar</string>
<string name="overview_style_nixie">Nixie tubes</string> <string name="overview_style_nixie">Nixie tubes</string>
<string name="overview_style_chronograph">Chronograph</string> <string name="overview_style_chronograph">Chronograph</string>
<string name="cockpit_battery">BATTERY</string> <string name="cockpit_battery">BATTERY</string>
@@ -228,6 +241,15 @@
<string name="btn_signal_stop">Stop recording</string> <string name="btn_signal_stop">Stop recording</string>
<string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures, %4$d change events heard. Keys reported, then assistance properties heard, then values changed so far, the rarest first:</string> <string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures, %4$d change events heard. Keys reported, then assistance properties heard, then values changed so far, the rarest first:</string>
<string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$d change events heard, %4$s. Keys reported, then assistance properties heard, then values changed, the rarest first:</string> <string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$d change events heard, %4$s. Keys reported, then assistance properties heard, then values changed, the rarest first:</string>
<string name="label_media_probe">Music sources</string>
<string name="media_probe_hint">Finds where the head unit tells what is playing: the playback Android reports and the media sessions apps publish (CarPlay, Bluetooth, radio, USB), each with its song, and the head unit\'s media services by name. Play something first and check once for each source. Like a recording, the result goes to your server and to a USB stick. It only reads and connects to no other app: nothing is played, paused or skipped.</string>
<string name="btn_media_probe">Check music sources</string>
<string name="label_sound_probe">Sound check</string>
<string name="sound_probe_hint">Finds out whether the app can hear the music, for a light that follows it. Park first, play something, then check once each for Bluetooth, CarPlay and radio. It listens for 10 seconds and keeps only the levels, never the sound, and uses no microphone. Like a recording, the result goes to your server and to a USB stick.</string>
<string name="btn_sound_probe">Check sound</string>
<string name="sound_probe_running">Listening… (10 seconds)</string>
<string name="media_probe_running">Checking… (up to a minute)</string>
<string name="media_probe_done">Check from %1$s, %2$s:</string>
<string name="log_outbox_sent">Sent to the server.</string> <string name="log_outbox_sent">Sent to the server.</string>
<string name="log_outbox_waiting">Not on the server yet (%1$d waiting: %2$s); it is sent again every 2 minutes.</string> <string name="log_outbox_waiting">Not on the server yet (%1$d waiting: %2$s); it is sent again every 2 minutes.</string>
<string name="call_log_empty">No upload attempted yet.</string> <string name="call_log_empty">No upload attempted yet.</string>
@@ -104,4 +104,44 @@
<item name="evAccentStroke">#80344A99</item> <item name="evAccentStroke">#80344A99</item>
<item name="evAccentTrack">#2A3F82</item> <item name="evAccentTrack">#2A3F82</item>
</style> </style>
<!-- Día de Muertos: the pink of the papel picado. -->
<style name="ThemeOverlay.EV.Style.Muertos" parent="">
<item name="evAccent">#FF2E93</item>
<item name="evOnAccent">#22041A</item>
<item name="evAccentLight">#FFA8D4</item>
<item name="evAccentDeep">#4A0E32</item>
<item name="evAccentSurface">#2C1242</item>
<item name="evAccentStroke">#80993366</item>
<item name="evAccentTrack">#7A2558</item>
</style>
<!-- Halloween: the pumpkin's orange. -->
<style name="ThemeOverlay.EV.Style.Halloween" parent="">
<item name="evAccent">#FF8C1A</item>
<item name="evOnAccent">#1F0E02</item>
<item name="evAccentLight">#FFC27A</item>
<item name="evAccentDeep">#3D2008</item>
<item name="evAccentSurface">#22102E</item>
<item name="evAccentStroke">#80804A1E</item>
<item name="evAccentTrack">#6A3F18</item>
</style>
<!-- Christmas: the red of the big bauble. -->
<style name="ThemeOverlay.EV.Style.Christmas" parent="">
<item name="evAccent">#E53946</item>
<item name="evOnAccent">#1F0306</item>
<item name="evAccentLight">#FF9AA3</item>
<item name="evAccentDeep">#3D0C12</item>
<item name="evAccentSurface">#10243A</item>
<item name="evAccentStroke">#80B08A3A</item>
<item name="evAccentTrack">#7A2630</item>
</style>
<!-- Caterpillar: the green of its body. -->
<style name="ThemeOverlay.EV.Style.Caterpillar" parent="">
<item name="evAccent">#5CC15A</item>
<item name="evOnAccent">#07210B</item>
<item name="evAccentLight">#B6F0A8</item>
<item name="evAccentDeep">#123A1A</item>
<item name="evAccentSurface">#10243A</item>
<item name="evAccentStroke">#8057A04A</item>
<item name="evAccentTrack">#2E6B35</item>
</style>
</resources> </resources>
@@ -0,0 +1,38 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class MediaProbeTextTest {
@Test
public void anotherAppsTextStaysOnOneLine() {
assertEquals("Song Title", MediaProbeText.clean("Song\nTitle"));
assertEquals("(none)", MediaProbeText.clean(null));
assertEquals("(empty)", MediaProbeText.clean(" \t"));
String longText = new String(new char[500]).replace('\0', 'a');
String cleaned = MediaProbeText.clean(longText);
assertEquals(MediaProbeText.MAX_TEXT + 1, cleaned.length());
assertTrue(cleaned.endsWith("…"));
}
@Test
public void androidsNumbersHaveNames() {
assertEquals("playing", MediaProbeText.playbackState(3));
assertEquals("paused", MediaProbeText.playbackState(2));
assertEquals("state 99", MediaProbeText.playbackState(99));
assertEquals("media", MediaProbeText.usage(1));
assertEquals("navigation guidance", MediaProbeText.usage(12));
assertEquals("assistant", MediaProbeText.usage(16));
assertEquals("started", MediaProbeText.playerState(2));
}
@Test
public void durationsAsMinutes() {
assertEquals("3:25", MediaProbeText.duration(205_400));
assertEquals("0:00", MediaProbeText.duration(0));
assertEquals("?", MediaProbeText.duration(-1));
}
}
@@ -0,0 +1,110 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class MusicPulseTest {
private static final float STEP = 0.04f;
@Test
public void noReadingAndSilenceLightNothing() {
MusicPulse p = new MusicPulse();
assertEquals(0f, p.update(null, STEP), 0f);
assertEquals(0f, p.update(Float.NaN, STEP), 0f);
assertEquals(0f, p.update(0f, STEP), 0f);
}
@Test
public void aBeatLightsAndFadesSlowerThanItRises() {
MusicPulse p = new MusicPulse();
for (int i = 0; i < 50; i++) p.update(0.05f, STEP);
float beat = p.update(0.5f, STEP);
assertTrue("a beat lights it, got " + beat, beat > 0.8f);
float after = p.update(0.05f, STEP);
assertTrue("it fades, got " + after, after < beat);
assertTrue("slowly, got " + after, after > beat * 0.7f);
}
@Test
public void aSteadyHumSettlesAtNothing() {
MusicPulse p = new MusicPulse();
float last = 1f;
for (int i = 0; i < 400; i++) last = p.update(0.3f, STEP);
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
public void quietAndLoudSongsPulseAlike() {
assertEquals(beatOf(0.5f), beatOf(0.05f), 0.02f);
}
private static float beatOf(float scale) {
MusicPulse p = new MusicPulse();
float top = 0f;
for (int i = 0; i < 500; i++) {
top = Math.max(top * 0.99f, p.update(i % 12 == 0 ? scale : scale * 0.2f, STEP));
}
return top;
}
@Test
public void whenTheMusicStopsItGoesOut() {
MusicPulse p = new MusicPulse();
for (int i = 0; i < 100; i++) p.update(i % 12 == 0 ? 0.5f : 0.1f, STEP);
float last = 1f;
for (int i = 0; i < 100; i++) last = p.update(null, STEP);
assertEquals(0f, last, 0f);
}
@Test
public void itStaysBetweenZeroAndOne() {
MusicPulse p = new MusicPulse();
for (int i = 0; i < 2000; i++) {
Float bass = i % 11 == 0 ? null : Float.valueOf(i % 7 == 0 ? 1f : 0.1f);
float v = p.update(bass, i % 5 == 0 ? 2f : STEP);
assertTrue(v >= 0f && v <= 1f);
}
}
}
@@ -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,78 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import org.junit.Test;
public class SoundLevelsTest {
private static byte[] wave(int... unsigned) {
byte[] out = new byte[unsigned.length];
for (int i = 0; i < out.length; i++) out[i] = (byte) unsigned[i];
return out;
}
@Test
public void silenceIsTheMiddleOfTheWaveform() {
assertEquals(0f, SoundLevels.peak(wave(128, 128, 128)), 0f);
assertEquals(0.5f, SoundLevels.peak(wave(128, 192, 128)), 0f);
assertEquals(0.5f, SoundLevels.peak(wave(128, 64, 128)), 0f);
assertEquals(1f, SoundLevels.peak(wave(0, 255)), 0f);
}
@Test
public void aFailedReadIsNotSilence() {
assertNull(SoundLevels.peak(null));
assertNull(SoundLevels.peak(new byte[0]));
assertNull(SoundLevels.band(null, 48000, 0f, 250f));
assertNull(SoundLevels.band(new byte[8], 0, 0f, 250f));
}
@Test
public void aBandAveragesItsOwnBins() {
// Eight values: DC and Nyquist, then bins 1 to 3, here 1000, 2000 and 3000 Hz.
byte[] fft = { 99, 99, 64, 0, 0, 32, 3, 4 };
assertEquals(0.5f, SoundLevels.band(fft, 8000, 500f, 1500f), 1e-6f);
assertEquals(0.25f, SoundLevels.band(fft, 8000, 2000f, 2500f), 1e-6f);
assertEquals(5f / 128f, SoundLevels.band(fft, 8000, 3000f, 4000f), 1e-6f);
assertEquals((0.5f + 0.25f) / 2f, SoundLevels.band(fft, 8000, 500f, 2500f), 1e-6f);
assertNull(SoundLevels.band(fft, 8000, 5000f, 6000f));
}
@Test
public void theVerdictTellsDeafFromNothingPlaying() {
SoundLevels.Summary none = new SoundLevels.Summary();
none.add(null, null, null, null, null);
assertEquals(SoundLevels.Verdict.NO_READING, none.verdict(true));
SoundLevels.Summary silent = new SoundLevels.Summary();
silent.add(0f, 0f, 0f, 0f, -9600);
silent.add(0.01f, 0f, 0f, 0f, -9600);
assertEquals(SoundLevels.Verdict.DEAF, silent.verdict(true));
assertEquals(SoundLevels.Verdict.NOTHING_PLAYING, silent.verdict(false));
}
@Test
public void onlyAMovingLevelIsHeard() {
SoundLevels.Summary steady = new SoundLevels.Summary();
steady.add(0.4f, 0.1f, 0.1f, 0.1f, -2000);
steady.add(0.41f, 0.1f, 0.1f, 0.1f, -2000);
assertEquals(SoundLevels.Verdict.STEADY, steady.verdict(true));
SoundLevels.Summary music = new SoundLevels.Summary();
music.add(0.1f, 0.2f, 0.1f, 0f, -3000);
music.add(0.6f, 0.4f, 0.3f, 0.1f, -1500);
assertEquals(SoundLevels.Verdict.HEARS, music.verdict(true));
}
@Test
public void theLineAveragesAndMarksWhatFailed() {
SoundLevels.Summary s = new SoundLevels.Summary();
s.add(0.1f, 0.2f, 0.1f, 0f, -3000);
s.add(null, null, null, null, null);
s.add(0.6f, 0.4f, 0.3f, 0.1f, -1500);
assertEquals("peak 0.10–0.60, bass 0.300, mid 0.200, treble 0.050, rms -1500 mB (2/3)", s.line());
assertEquals("peak —, bass —, mid —, treble —, rms — (0/0)", new SoundLevels.Summary().line());
}
}
@@ -0,0 +1,41 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class TallyTest {
@Test
public void anUnreadValueLightsNothing() {
assertEquals(0, Tally.lit(null, 10f, 10));
assertEquals(0, Tally.lit(Float.NaN, 10f, 10));
assertEquals(0, Tally.ofPower(null, 150f, 50f, 24));
assertEquals(0, Tally.ofPower(Float.NaN, 150f, 50f, 24));
}
@Test
public void oneThingPerStepUpToTheRow() {
assertEquals(8, Tally.lit(76f, 10f, 10));
assertEquals(10, Tally.lit(100f, 10f, 10));
assertEquals(8, Tally.lit(900f, 50f, 8));
assertEquals(6, Tally.lit(312f, 50f, 8));
assertEquals(0, Tally.lit(0f, 10f, 10));
assertEquals(0, Tally.lit(-5f, 10f, 10));
}
@Test
public void powerFillsTheGaugeEachWay() {
assertEquals(0, Tally.ofPower(0f, 150f, 50f, 24));
assertEquals(24, Tally.ofPower(150f, 150f, 50f, 24));
assertEquals(24, Tally.ofPower(400f, 150f, 50f, 24));
assertEquals(12, Tally.ofPower(-25f, 150f, 50f, 24));
assertEquals(24, Tally.ofPower(-50f, 150f, 50f, 24));
}
@Test
public void realPowerAlwaysShows() {
assertEquals(1, Tally.ofPower(0.5f, 150f, 50f, 24));
assertEquals(1, Tally.ofPower(-0.2f, 150f, 50f, 24));
}
}
@@ -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));
}
}
+13
View File
@@ -2,6 +2,7 @@
<!-- Unstable channel only. The stable build has no self-update path, so neither the install <!-- Unstable channel only. The stable build has no self-update path, so neither the install
permission nor the session result receiver belongs in the main manifest. --> permission nor the session result receiver belongs in the main manifest. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:sharedUserId="android.uid.system"> android:sharedUserId="android.uid.system">
<!-- Needed for the PackageInstaller session that replaced `pm install -r`. On this <!-- Needed for the PackageInstaller session that replaced `pm install -r`. On this
@@ -11,6 +12,18 @@
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
<!-- Lists other apps' media sessions (MediaProbe, the Logs tab's music-sources check).
A platform permission: the system user has it; elsewhere the check says it cannot. -->
<uses-permission
android:name="android.permission.MEDIA_CONTENT_CONTROL"
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
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.MODIFY_AUDIO_SETTINGS" />
<application> <application>
<provider <provider
@@ -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();
}
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ spec:
containers: containers:
- name: server - name: server
# Pinned by digest so a re-pushed tag cannot change what runs (version in SPECS.md). # Pinned by digest so a re-pushed tag cannot change what runs (version in SPECS.md).
image: git.stinnesbeck.com/nils/mg4-server@sha256:8b8d94e0562833d196162866b7fdc6910d5dd219f6eb84572d4d5c91b74f7ff1 image: git.stinnesbeck.com/nils/mg4-server@sha256:c68837f0e53cfca150644987be535e04d5e999bd4c91064d40973db67235df48
ports: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080
Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB