Skip to content

About

Loop-recording dashcam for the MG4 EV (pre-facelift). Stripped fork of jamakr4/MG4-360-Camera-App.

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

MG4 Dashcam

A loop-recording dashcam for the MG4 EV (pre-facelift, AAOS 9 / API 28). It records the four factory cameras into one clip on a rolling buffer and does nothing else.

This is a stripped fork of jamakr4/MG4-360-Camera-App, whose author did the hard part: finding out how to get frames out of this vehicle's cameras at all. See Relationship to upstream.

Status: it works, and no longer only on one car. An hour of continuous recording on 21 September 2026, and a 33-minute stretch measured frame by frame: 54,706 frames per camera, 29.9 fps sustained, not one dropped by the driver. A second MG4, on a different firmware build and belonging to somebody else, has since run it and reported back.

Known limits, both measured rather than guessed. The changeover between clips costs about half a second of road every thirty seconds, and most of that is the medium rather than the app: the same build on a different stick in a different car does it in 132 ms. And the internal-storage target and the event save still have little real mileage - the event save in particular has no trigger in the interface at all, only a broadcast.

What it does

  • Records front, rear, left and right into a single 1440x1040 clip at 25 fps, in 30-second segments, with a footer showing the time, the speed and a signature of your choice.

  • Keeps the most recent N segments and deletes the rest. Default: 10 segments, about 5 minutes and 340 MB.

  • Saves an event on request: the segments around the moment of the request are copied into a folder the ring buffer never touches. Trigger it with a broadcast:

    am broadcast -a com.drivehub.kamera.action.TRIGGER_DASHCAM_EVENT -p com.tommasov.mg4dashcam
    

    If the loop is not running, the event captures the next three segments instead of the previous ones.

  • Records to internal storage, to a USB stick, or to USB with a fall back to internal.

  • Yields the cameras while the factory 360/reverse view is on screen, so the stock reversing camera keeps working. Turning this off means the OEM app gets "Device or resource busy" for as long as the dashcam is recording.

  • Starts itself on boot when the switch is on.

  • Says what it is doing from across the cabin: a green ON badge while recording, amber while it has handed the cameras over, red when something is wrong. Green is checked rather than claimed - a watchdog turns it red if no clip has been written for three segment lengths, because a badge that lies is worse than no badge.

  • Can put a dot in the car's own status bar, so the recording state is readable without opening the app. Off by default - see In the car's own status bar.

  • Reads, copies and clears what the factory 360 app leaves in its own private folder - see The factory app's hidden recorder.

  • Sends a diagnostics report, on request and after a confirmation - see Diagnostics.

  • Updates itself, so a new version does not mean another trip to this page - see Updating.

  • Speaks English and Italian, and follows the head unit between its light and dark themes.

What it deliberately does not do: tile view, turn-signal overlay, digital rearview mirror, floating banners. Those are upstream's, and upstream is where they belong.

What a recording looks like

A 2x2 grid with front and rear on top, left and right below, all four in their true proportions, and a footer with the date, time and speed

Four real camera frames, laid out by hand before the grid existed: the arrangement the app records today, from pictures the cameras actually produced.

A 2x2 grid at 1440x1040. Every cell at its true 720x480 shape, no rotation, no black. Front and rear sit side by side on top, which is the pair you want together when you are working out who came from where; left and right go below, each on the side it belongs to. The rear view is not mirrored: flipping it matches what a driver expects from a mirror, which is right when reversing and wrong in an archive, because it reverses every number plate behind you.

It records at 25 fps, the same as the factory 360 app, with the cameras delivering 29.9 each.

What it took to get there

The layout was the easy half. Four cameras open together delivered six frames a second each, and no amount of making the compositor cheaper moved it, because the cost was not where it looked. The buffers VIDIOC_REQBUFS hands back are not cached: the CPU reads them at about 85 MB/s, so every extra pass over one costs four milliseconds however few bytes it takes. A conversion that touches the mapped buffer three times is slower than one that touches it once, however much less memory it moves overall.

Reading it once, and keeping per-frame housekeeping off the capture loop, took capture to 29.9 fps per camera. How to tell a slow device from a slow reader, what else looked guilty and was not, and two ways of measuring a frame rate that give confidently wrong answers are in docs/capture-performance.md.

Smooth, eventually

Motion in the finished file was uneven for a long time, in a way nobody could name: not dropped frames - the counters said none were ever lost - and not slow, just faintly wrong, worst at walking pace where the eye can follow a kerb. It took two separate causes, and both were found by measuring inside the app rather than by studying the footage. Six different measurements on the video itself had failed to separate them.

Timestamps that told the truth about the wrong thing. Each frame was stamped with the moment it was composed, which depends on when the system gives the app its turn. But the cameras deliver on a regular beat, so the picture advances by an even step every time: even motion, carried by uneven timestamps, is exactly what a viewer calls stuttering. Measured on two clips: 3.7% and 6.8% of intervals fell more than 8 ms out of place, and each late one was followed by an early one - the signature of a thread waking late and then catching up. Frames now go in the slot they belong to, which leaves a real stall visible while erasing the jitter. Over a 46,454-frame drive, 0.086% of intervals are anything other than exact.

A compositor running on its own clock. The cameras deliver 29.9 pictures a second; the compositor was sampling at 30.3. Gaining four tenths of a frame every second, it now and then photographed the canvas before the new picture had arrived and used the old one again - and a stale cell is worse to look at than a lost frame, because that quarter of the screen holds still for one frame and then moves twice as far. With four cameras each on its own phase it happened to one sample in five.

Waiting a little for the late ones brought that to 4.9%, and it was the wrong fix: a race patched with patience holds until something slows down, and deinterlacing - which takes the work per camera from 8.8 ms to 19.5 - put it straight back to 19%. So the clock went. The last camera to complete the picture now wakes the compositor, which makes a stale cell not unlikely but impossible: a frame cannot be composed before the frame exists, and the output rate becomes the cameras' own, which is the rate the footage was always going to have. Measured on the road with deinterlacing on: 0.16%, and no frames lost in 140,844 deliveries. The waiting costs nothing - it is paid for out of time the thread used to spend asleep.

Both were found the same way. Two counters were added - did the camera hand back the same picture twice? and did the sample find a cell nobody had rewritten? - and the first report answered a question that weeks of looking at video had not: 0 of 32,706 for the camera, and 21.5% for us. The fault was ours, and once named it took an afternoon.

What it still owes

Vertical resolution was the oldest debt here, and 1.4.0 pays it. Each cell used to be the top field of the interlaced buffer, 720x240 stretched to 720x480, with the other field's lines sitting unread in the same buffer.

They are now woven back. On the road that moves the ratio of vertical to horizontal detail from 1.39 to about 1.9 - real lines where there were invented ones - and it holds the frame rate: 30 fps, nothing lost, the capture loop still landing on the camera's own 33.4 ms. Interleaving two instants combs anything that moves, so each row is checked against the two around it and redrawn from their average where it falls outside both; on real drives that is 5 to 9% of rows, depending on how much is moving, which leaves the rest genuinely woven. The worst case is therefore the old picture, applied only to the pixels that would have combed.

It costs. Reading twice as much of an uncached buffer, plus the comb test, takes the work per camera from 8.8 ms to 19.5 ms of a 33.4 ms period - from a quarter of each capture thread to rather more than half. It could not ship until the compositor stopped running on its own clock, because the cells now arrive later than the old one was willing to wait for: it traded an invisible fault for a visible one until that was fixed.

The MediaTek hardware de-interlacer the factory app uses remains the other road, and would give the same result for almost no CPU. It is a larger piece of work - V4L2 mem2mem and ION buffers - and its library belongs to the vehicle, so it could only ever be loaded from the device, never shipped.

Chroma still comes from one field. NV12 wants one chroma row per two picture rows, so a 240-row field already fits a 480-row cell with nothing to resample, and vertical colour resolution is the least visible thing in a frame. Changing one plane at a time also means that if this looks wrong there is one place it can be wrong in.

Where a field is still stretched - the preview, and any cell that arrives at half height - the stretch is bilinear rather than something sharper: a sharper filter only invents edges the encoder then pays for. Measured on real footage at equal quality, lanczos costs 61 per cent more bitrate, bicubic 57, bilinear 41.

About half a second a rotation. Clips are cut every 30 seconds and the changeover is not free, so a little road goes unfilmed each time - 0.4 s on average now, down from 0.9. Where it goes is no longer a guess: the app times the two halves of starting a clip and prints them.

opening a clip: encoder and muxer 386ms, attaching the cameras 3ms (48 times)

Attaching the cameras used to be a quarter of a second on its own, because stopping a clip let the last consumer go and a session nobody reads from tears its capture down - so all four video devices were closed and reopened, identically, every thirty seconds. They are now held across a rotation, which is what took that figure to five milliseconds.

What is left is building an encoder and a muxer, which means creating the output file on a stick that is still finishing the previous clip. It is the same shape of problem as working out where the next clip goes, which used to cost 200 ms and now costs zero because it happens a segment early. The cure is the same: prepare it while the current clip is still recording.

The clip names and the chapter titles join-clips.py writes carry the real wall-clock time, so a gap is always visible and dated rather than silently closed up.

The bitrate stays at 9 Mbit/s, about 58 MB a minute. It used to spend almost a fifth of itself on black bars; now every bit goes on picture, at the same write rate and the same load on the USB stick.

Raising it was tried and dropped. At 15 Mbit/s the same measure of vertical detail gives 1.97 against 1.80 - two ranges that overlap almost entirely, so nothing that can be called a gain - while clips grow from 34 MB to 56, opening one goes from 386 ms to 598, and a rotation was seen to take a second and a half. The encoder was not the thing holding detail back.

In the car's own status bar

The head unit's top bar in two states: an amber 360 marker while the factory around-view has the cameras, and a red REC marker while the dashcam is recording

Six states, and the ones that are not REC matter more than it does:

REC red dot recording
360 amber dot the factory around-view has the cameras and the dashcam is waiting
... amber dot starting, or waiting for a medium that is slow, missing or full
ERR amber triangle something needs looking at
OFF grey dot not recording
nothing the indicator is switched off in the settings

The dots were drawn as REC until 1.4.0, which is the worst thing this indicator could say. Starting lasts a second at an ordinary ignition, but up to two and a half minutes while the app waits for a stick that is slow to mount or has nothing left on it - and for all that time the driver was being told the recording was running. The one state this app exists to be honest about, reported wrongly. Three dots rather than a word because the app is read in English and in Italian, and this is the state where the message is "wait" in both.

Three letters rather than a bare coloured dot, because a colour has to be learned and a word does not. It does not blink: the middle of a dashboard is the one place that should not pull the eye.

OFF is drawn rather than hidden. An absent indicator cannot be told apart from a feature nobody switched on, and a dashcam that is quietly not recording is the worst failure this app can have. Showing it means the app stays alive with nothing to do while the loop is off - no cameras, no threads - which is what asking for a permanent indicator asks for.

A fault is a different shape, not just a different colour. It used to be a grey dot, because grey was what the code fell through to, so the one state a driver must not miss was drawn in the quietest colour available. A triangle survives sunlight, a glance of a quarter of a second, and whatever anybody remembers about which colour meant what.

Where it goes. Two choices, left or right of the climate strip, because the strip is in the middle of every MG4 and never moves while everything else does: the system icons grow inwards from the right and the now-playing text from the left. So each position hugs the centre rather than reaching for an edge. The two are not mirror images - deriving the left one by reflection put it on top of the temperature reading, since the strip is not symmetric about the middle - and a slider underneath covers the car where neither measurement holds.

A tap on it opens the app - and if the dashcam is recording at that moment, the app asks "Stop recording?" before stopping anything. Any other state just opens the app, because there is nothing to stop.

The confirmation is in the app and not in the bar on purpose, for two reasons. A command that cannot be taken back does not belong in the middle of a dashboard, where a hand resting on the screen would carry it out. And the state can change between the finger going down and the screen coming up - a clip rotates, the factory 360 takes the cameras - so the question is asked about how things are at that moment rather than how they were when the dot was tapped. The indicator itself carries no command at all: it reports that it was tapped, and nothing more.

Only one kind of window can do this. Android offers three that could hold the indicator, and each was tried on the car:

window type drawn above the factory bar can receive a tap
TYPE_SYSTEM_OVERLAY (2006) yes never - the platform forbids it
TYPE_APPLICATION_OVERLAY (2038) no - it sits under an opaque bar, invisible yes
TYPE_STATUS_BAR_PANEL (2017) yes yes

The middle one is the trap: the window is created without complaint, sits exactly where it is told, and cannot be seen. The log said the overlay was showing the whole time; only the driver could see that it was not.

What it costs. A window that can be tapped also keeps the gestures that land inside it, and this one is 124 x 80 pixels wherever you put it. If the swipe down that opens the factory tools drawer is started exactly on the indicator, it may not answer. Starting it a finger's width to one side does.

Turn it on in the settings. It ships off, and that is deliberate rather than timid - this is the most conspicuous thing the app does, and whether something of yours belongs inside the factory interface depends on who else looks at the car.

How it gets there, and why that took three attempts

The factory bar is not Android's. com.android.systemui.statusbar is not in this SystemUI at all: SAIC removed it and wrote their own, and what is in there is a fixed set of icons whose visibility is toggled - no notification icon area, no slots, nothing to register an icon with. The one service their SystemUI exposes to other apps turned out to speak a media-browser protocol, for the radio.

So the dot is a window of ours, drawn on top. Which a platform-signed app running as the system user is allowed to do, and which still failed twice in ways worth writing down:

  • TYPE_APPLICATION_OVERLAY is as high as an ordinary app can reach, and it sits below TYPE_STATUS_BAR. The window was created, positioned correctly, and invisible behind an opaque bar. Two window types reserved for the platform sit above it; the app tries those first and records in its log which one it got.
  • The window was sized in dp while its contents were sized from the bar's height. On this head unit a dp is exactly a pixel and the bar is eighty of them, so the text came out wider than the window holding it. Everything is now a fraction of one number - the bar height, asked of the framework rather than assumed - and the width is measured from the widest word it can show.

Position. A slider, defaulting to just right of the climate strip. That is not a preference: the factory icons fill the bar from the right and move as they come and go, while the climate strip in the middle has a fixed width. So the only stable gap is the one next to the fixed thing, and the dot should be left as close to it as looks right - every pixel further right is a pixel nearer the row that shifts.

Sized for the driver's seat

Android sizes its controls for a phone held at arm's length. This screen is a metre away, behind a steering wheel, and is read in glances of about a quarter of a second. Everything here is sized for that instead.

The row is the target, not the control. Every choice, switch and button is 88dp tall and spans the width, so the thing a thumb has to find is a band rather than a circle. The radio and check glyphs are drawn at 36dp rather than the stock size, because the stock ones were small enough to be dangerous to aim at while moving.

Two choices side by side get air between them. Without it the second one's circle begins exactly where the first one's label ends: they read as one control, and they are two touch targets with no border between them.

Dialogs are drawn at 1.4 times the system font, and multiply whatever the head unit is already set to rather than replacing it - somebody who has enlarged the car's text is asking for bigger text, not for ours. The whole dialog is scaled through a configuration rather than restyled piece by piece, because one piece cannot be restyled at all: AppCompat writes the message's appearance into its own layout instead of reading it from a theme, so a theme can reach the title and the buttons but never the paragraph in the middle - the part that most needs it.

Their buttons are the same 88dp, and not in capitals. They had been left at the stock size, flat labels laid out for a thumb that is not holding a wheel. A word in capitals is read letter by letter, which is the wrong thing to ask of somebody who is glancing; nothing else in the app shouts either.

Installing

The app declares android:sharedUserId="android.uid.system". That is what lets it open the /dev/video* nodes, and it means the APK must be signed with the head unit's platform key or it will not install. The MG4 ships with the public AOSP platform key, so you can sign it yourself:

# once: fetch the public AOSP platform key pair
curl -sL "https://android.googlesource.com/platform/build/+/refs/heads/main/target/product/security/platform.pk8?format=TEXT" | base64 -d > platform.pk8
curl -sL "https://android.googlesource.com/platform/build/+/refs/heads/main/target/product/security/platform.x509.pem?format=TEXT" | base64 -d > platform.x509.pem

# per build
./gradlew :app:assembleRelease
zipalign -p -f 4 app/build/outputs/apk/release/app-release-unsigned.apk mg4dashcam.apk
apksigner sign --key platform.pk8 --cert platform.x509.pem mg4dashcam.apk
apksigner verify --print-certs --min-sdk-version 28 mg4dashcam.apk

Then adb install -r mg4dashcam.apk, or copy it to a USB stick and install it from the vehicle's Files app. Upstream's README documents how to get ADB working on the head unit and which firmware versions still allow it; that part applies here unchanged.

This fork uses its own application id (com.tommasov.mg4dashcam), so it installs alongside upstream's app rather than replacing it. Both cannot record at the same time: they contend for the same camera devices.

Building

No NDK, CMake or OpenCV SDK needed. app/src/main/jniLibs/arm64-v8a/libcameraprobe.so is upstream's own build of the C++ in app/src/main/cpp/, which this fork does not modify — see app/src/main/jniLibs/README.md for its provenance and for how to rebuild it from source instead.

Everything else is a normal Gradle build against the Android SDK.

One optional file: apikeys.properties at the root, git-ignored, holding probe.key for the diagnostics endpoint. Without it the build succeeds and the send button is not shown, which is the intended behaviour for anyone building this who is not the author.

Sharing the cameras with the car

There are four camera devices and only one of anything can hold them, so when the factory around-view app wants them the dashcam has to let go - for reverse, for the steering-wheel button, for the indicator view at low speed. It is a race, and losing it looks like the screen dimming with no picture behind it.

The factory app gives up after about 210 milliseconds. That is measured, twice, and the two readings were 209 and 218 ms - close enough together to be a timeout in its code rather than luck. Miss it and the driver presses the button twice and blames the dashcam.

Everything below is arranged around that number. The cameras are now free 49 ms after the request, with two thirds of the budget to spare.

Three things decide it:

  • The broadcast from the factory app is answered in the receiver, which raises the flags and interrupts the recording thread directly. Going through the service first cost service creation and scheduling before anything was released, which was time spent on the wrong side of the race.
  • A second detector watches which app is actually in front, every 60 ms. It is slower than a broadcast but catches every route into the factory camera, including any this project has not mapped, and it notices when the factory app goes away without saying so. It used to look once a second, then once every 200 ms - and 200 ms of a 210 ms budget can be spent simply finding out.
  • The release does not wait for the file. Letting a camera go used to wait for the whole encoder to drain, because a combined tap answers for the shared sink, so releasing the first camera waited for the last frame of the clip. The factory app wants the video devices, not our MP4: the four are now detached together, in parallel, and the clip is finalised afterwards on a thread nobody is waiting for. That alone took the release from about 200 ms to under ten.
  • A pause never lasts more than a minute. Whatever was missed - a signal, a broadcast, a stale reading - a dashcam that stays paused is a dashcam that is not recording, and contending briefly with the factory camera is the lesser failure.

Above 20 km/h the cameras are not handed over, which is adjustable in the app. The factory view stops being offered around 15 km/h anyway, so the margin errs upwards on purpose: too high costs an occasional lost segment, too low leaves the reversing camera dark.

The clip in progress is closed when the cameras go, so a hand-off costs seconds, not a file. If the encoder cannot finish it in time the fragment is deleted rather than left behind: an unreadable clip would otherwise take a retention slot from one that can be watched.

Storage, and what it costs

At 25 fps and 9 Mbit/s a clip is about 67 MB per minute, 4 GB per hour. With the default 10-segment buffer that is only ~340 MB on disk, so space is not the problem — continuous rewriting is. An hour of driving a day writes roughly 1.5 TB a year onto the head unit's eMMC.

Do not fill more than about a quarter of the medium. The rest is what the controller uses to spread wear, and a dashcam is the worst case for wear: the same space is rewritten continuously, for ever. A card at 10 per cent used spreads the same writes over ten times the cells. Buy for endurance and empty space, not for speed - the app writes 1.1 MB/s, which nothing sold this decade struggles with.

Retention is capped by the medium, quietly. Ask for 500 clips on a stick that holds 200 and nothing breaks: the volume fills, the space check makes room by removing the oldest, and the setting becomes up to 500, or as many as fit. It self-regulates at every rotation and says nothing, which is the right behaviour and a surprising number if you go counting.

Two things worth knowing before turning the retention up:

  • The canvas does not depend on how many cameras you select. Recording only the front camera still produces a full 1440x1040 frame, with the other three quadrants black for the whole clip. Deselecting cameras saves camera bandwidth, not much storage: a still black quadrant is nearly free in the frames between keyframes, but it is re-encoded in each keyframe, so it is cheap rather than free.
  • Both numbers are constants in the code (RecordingService.recordClip and the sink constructor in camera_stream_manager.cpp). Lowering them means editing and, for the canvas, rebuilding the native library.

Recording to a USB stick

A stick takes the eMMC out of the picture, and this fork can use one. Where the clips land is not where you would expect, and the reason is worth knowing before reading either codebase.

Android has refused general write access at the root of a secondary volume since KitKat. On this vehicle the same 32 GB stick reports, to the app itself:

/storage/9EFB-89C8                          r=true  w=false
/mnt/media_rw/9EFB-89C8                     r=true  w=true
/storage/9EFB-89C8/Android/data/<pkg>/files r=true  w=true

Upstream probes only the first of those, so its USB support could not have worked on any MG4, whatever the stick or the permissions. This fork tries three locations per volume and keeps the first that survives an actual write:

  1. /mnt/media_rw/<uuid>/dashcam — the raw vold mount. Reachable because the app runs as uid 1000, and the one that puts clips at the root of the stick, where someone plugging it into a computer will look for them.
  2. <volume>/dashcam — what upstream tries. Kept in case some firmware allows it.
  3. .../Android/data/com.tommasov.mg4dashcam/files/dashcam — the app's own sandbox. Always writable, but deleted when the app is uninstalled.

Show storage details, on the main screen, prints which one was chosen and, for a volume it rejected, why it was rejected. The head unit has no adb, so that button is the only way to see any of this from inside the car.

Use Stop and eject USB before pulling the stick: it stops the loop and waits for the pending writes instead of truncating the clip in flight.

Deleting is a write too, and it took a year to notice. The clips this app writes were always fsynced; the ones it deletes were not, and on FAT deleting a file is two writes of its own - the directory entry freed, and the chain of clusters released in the allocation table. Neither is forced anywhere. They wait in the kernel's cache until it chooses to write them, which in a car is until the ignition is switched off.

What survives is a table that still says those clusters are in use and a directory that no longer mentions them. The next mount finds clusters with no owner and files them under LOST.DIR - a little more on every drive, until the stick is full of the recordings it was told to throw away. On the author's car it reached the point of filling a 32 GB stick, and the app could do nothing about it: it measures free space on the whole volume but can only delete inside its own folder.

It stayed invisible for as long as it did because it needs the ring buffer to be full. Until the stick fills up nothing is ever deleted, so there is nothing to leave half-finished. The day the retention starts deleting at every rotation is the day it begins.

Every deletion is now pushed to the medium before anything else happens - syncfs on that one filesystem, so a slow stick cannot drag the head unit's own storage into the wait. At each deletion rather than once at shutdown: the ignition is cut whenever the driver feels like it, and a flush that only runs when the car is switched off is a flush that runs after the thing it was meant to prevent.

Fixed in 1.4.0. If you are coming from an earlier version, empty LOST.DIR once by hand - it holds nothing but clips that were already discarded.

Surviving the ignition going off

FAT has no journal. A power cut while the filesystem is mid-update leaves the cluster chains and the directory disagreeing, and the volume comes back wrong: on this car it has come back mounted read-only, and it has come back reporting zero bytes free on a stick that was not full, which fails every write with ENOSPC and looks exactly like a stick that is finished. chkdsk finds real damage. The fix is not to survive that but to not be writing when it happens.

The app closes its clip when the car shuts down, and holds the shutdown - briefly, and with the system's permission - until the medium is actually idle. Getting that right needed two things that were both wrong:

  • It was listening for the wrong broadcast. The close was built on ACTION_SCREEN_OFF, on the reasonable belief that locking the car dims the display before the processor stops. It does not: on this head unit the panel is switched off by hardware, outside Android, which stays awake and unaware - reopen the car within a minute and the tablet is exactly where you left it. That broadcast never arrives. ACTION_SHUTDOWN does, within a minute of locking, and it is now what the close hangs on.
  • "Clip closed" did not mean the stick was idle. The wait watched the muxer and one of the two background executors, and neither of the flush threads. So it could report a clean close while retention was still deleting expired clips off the stick and up to 34 MB was still being fsynced onto it - and deleting files is precisely what rewrites the chains and the free-cluster count that came back broken. It now waits for all of them, names each one in its journal, and gives up out loud rather than silently if the eight seconds a shutdown receiver gets run out.

There is a standby journal on internal storage - not on the stick, which is the fragile thing being protected - that survives the power going and is printed in the diagnostics report. It is how the above was found: between one session and the next there was no screen-off line at all, only ACTION_SHUTDOWN and then nothing.

At startup, patience. A volume that has only just been mounted appears in StorageManager, has its directory, answers yes to canWrite() - and reports zero bytes free, because the FAT free-cluster count has not been worked out yet. Every write then fails with ENOSPC, which is indistinguishable from a full stick and is nothing of the sort. The app now waits up to two and a half minutes without calling any of it an error, and wakes early on the mount broadcast - the one the head unit shows as a "disk inserted" message. Nothing about the first fifteen seconds of this head unit is reliable: the factory music player misses its own scan often enough that sometimes it does not start either.

The factory app's hidden recorder

The stock around-view app ships a screen that is not on any menu: com.saicmotor.hmi.aroundview.RecordActivity. It is reachable from outside because its intent-filter carries the invented action android.intent.action.MAIN3 with category.LAUNCHER - a trick that keeps it out of the launcher while leaving it exported, since the app targets API 29 and an intent-filter makes exported default to true there.

am start -n com.saicmotor.hmi.aroundview/.RecordActivity

or point any activity-launcher app at that component. It shows the four cameras live with two buttons in Chinese: start/stop video, and capture a single frame.

It is safe to open. Its only calls are V4l2Utils.init, startRecordVideo, stopRecordVideo, saveCameraImageX4, setAngle, setSpeed and unInit. Nothing touches calibration, which on this vehicle is not stored on the head unit at all - the unit has no persist partition, the app holds no calibration file, and a factory reset does not lose it.

Turn loop recording off first. RecordActivity does not emit the AVM broadcasts that AVMActivity sends, so the automatic hand-off that yields the cameras to the factory app does not cover this screen. With the dashcam running, whichever opens the devices second gets nothing. And the video button has no time limit: it records until pressed again.

What it writes

Into /data/user/0/com.saicmotor.hmi.aroundview/files/, and it never cleans up:

file what it is
720x480_nv12_<ms>_0..3.nv12 four raw NV12 stills, one per camera, 518400 bytes each
720x480_<ms>.h264 the four cameras as one 1440x960 raw H.264 stream at 25 fps - the name is the per-camera size, not the frame size
test_720x480_0.nv12 a debug dump whose path is hardcoded inside libv4l2utils.so; one camera only, overwritten every time

Channel order is the 2x2 reading order: 0 top-left, 1 top-right, 2 bottom-left, 3 bottom-right. On this car channel 0 is the left camera. That numbering is the factory app's own and does not match this app's camera mask, where bit 0 is the front camera.

That directory is private to the factory app, so a file manager cannot reach it - but that app declares android:sharedUserId="android.uid.system" exactly as this one does, so both run as uid 1000 and the files belong to us as much as to it. Factory 360 app on the main screen lists them, copies them into the dashcam records folder, and deletes them.

To look at them on a computer:

ffmpeg -f rawvideo -pix_fmt nv12 -s 720x480 -i 720x480_nv12_1789590561951_0.nv12 cam0.png
ffmpeg -r 25 -i 720x480_1789590524395.h264 -c copy oem_360.mp4

Both are headerless streams: nothing in the file states its geometry or frame rate, so it has to be given on the command line.

Why it is worth knowing about

Those stills are 720x480 per camera, and they are what sent us looking at the capture format - which turned out to be the most useful thing in this repository.

Each camera hands over a 720x480 interlaced frame. This app records cells of 720x240, because the capture path keeps one field and discards the other. That costs vertical resolution, not field of view: a 720x240 cell stretched back to 720x480 is a complete, correctly proportioned fisheye. But the missing scan lines are not gone - they are in the same buffer, in rows 240-479, and interleaving the two halves recovers 127% more vertical detail, measured.

The driver reports field=V4L2_FIELD_NONE, a progressive frame. It is wrong. And the factory stills are not evidence to the contrary: the factory app runs the frames through the MediaTek hardware de-interlacer first - libv4l2utils.so exports v4l2_OpenMtkDI.

The evidence, the method for reproducing it, and what to do about it are in docs/camera-format.md.

Reading all four at once

The same stills are why the capture rate is what it is. Four cameras open together delivered six frames a second each until it turned out the mapped buffers are not cached - see what it took to get there and docs/capture-performance.md.

Updating

From 1.0.3 the app can fetch and install its own updates, because the alternative is telling somebody parked in a car park to come back to GitHub on a phone and sideload an APK.

What it contacts, and when. A manifest of a few hundred bytes at ws2.tommasovietina.it/mg4/MG4_Dashcam/version.json, at most once a day, and only while the main screen is open. It carries nothing about the car, the phone or the driver: it is a GET for a static file. The answer is written down and the only thing that happens is a small amber mark beside the version number in the corner of the screen - no dialog, no notification, nothing that interrupts. Check for updates under Tools asks immediately and says what it found either way.

What it downloads, and when. Nothing, until somebody presses Install. Then the APK is fetched, its SHA-256 is compared with the one in the manifest, and a file that does not match is discarded without being installed.

How it installs. Silently. This app is signed with the platform key and runs as android.uid.system, so it holds INSTALL_PACKAGES and can commit an install session without putting anybody through the system installer's two screens. Where that is refused it falls back to the ordinary installer.

The beta channel is a developer setting, deliberately not an ordinary one: it offers builds that have not been driven yet. Turning it on switches the manifest to version-beta.json and forgets what the other channel had found.

If none of this appeals, it is all inert until used: the daily check can only light a mark, and nothing installs without a press.

Diagnostics

This head unit gives a developer almost nothing to work with. No adb once the firmware is new enough, no real browser, and nothing on board that accepts a share: the firmware ships the Bluetooth stack with profile_supported_opp false, which disables the one activity handling ACTION_SEND. There is no text field to paste into either. A log that cannot leave the car can only be photographed off the screen.

So there are two ways out, and the app prefers the one that costs nothing to read:

  • Show storage details prints, on the car, what the storage probe saw, what the factory 360 app has left in its folder, and the runtime log - every hand-off, every pause, every failure, with timings.
  • Send a diagnostics report posts the same text to the author's endpoint. It asks first, states exactly what will be sent, and asks for one line about what you were doing, which is the half that makes the other half readable. Nothing leaves the car until you confirm, and the report carries no video, no images and no location.

The report endpoint can only accept: it cannot read a report back, list what is there or delete anything, with any key. That is what makes it safe to ship its write key inside an APK anyone can unzip. The key lives in apikeys.properties, which is git-ignored - a clone without one still builds, and simply does not offer the button.

Graphic resources

The switch track and thumb are taken from the vehicle's own system software, so that a toggle in this app is the toggle the driver already knows rather than a phone control dropped into a car. The palette and the type scale beside them are measured from the factory launcher, not copied from it: values, not artwork.

Those images are not licensed to this project. They remain the property of SAIC/MG and their respective owners, and are included here only so that the app can match the system on a vehicle that already contains them. No ownership is claimed over them, and their presence implies no permission, endorsement or affiliation. Anyone who redistributes this project, or builds on it, does so under their own responsibility. The same applies to trademarks and brand names, used here descriptively only.

Note that the GPL-3.0 licence covering this code says nothing about third-party assets sitting in the tree: the two coexist, and forking this repository does not place those images under the GPL.

Relationship to upstream

Upstream is GPL-3.0 and so is this, as required. The work that matters — the V4L2 device mapping for this vehicle, the doubled frame height the devices report, the encoder colour format this SoC accepts, the OEM AVM broadcast names recovered from the factory app — is jamakr4's, and it lives in app/src/main/cpp/ and in the dashcam/ package, both kept here with their history.

Changes made in this fork:

  • Removed the tile view, turn-signal overlay, digital rearview mirror, in-app updater, the settings dialog, the developer panel and the floating banner service. The banner service was replaced by toasts (DashcamNotice), the settings dialog by a single screen.
  • DashcamSettingsController became DashcamSettings, a prefs accessor with no views.
  • Re-enabled the storage target preference. Upstream's DashcamStorageManager ignores it and hardcodes internal storage, which is worth knowing if you are reading upstream's README: it documents a choice the code does not honour.
  • Writes to a volume's raw mount, or to the app sandbox, when the root of the FUSE view refuses — see Recording to a USB stick. Upstream probes only /storage/<uuid>, which no app may write to.
  • Logs what the storage probe saw at each step, and surfaces it on screen, because a head unit with no adb cannot be asked afterwards.
  • Reaches into the factory 360 app's private directory, which is possible because both declare the same system shared user, to recover and clear what its hidden recorder leaves there.
  • Hardened the recording loop: an exception can no longer kill the worker quietly, leaving the service alive and the UI claiming to record. A stalled loop turns the badge red instead.
  • Made the camera hand-off fast enough to win its race, added a second detector that watches the foreground app, and capped a pause at a minute - see Sharing the cameras with the car.
  • Ships the native library as a prebuilt instead of building it, since the sources are unchanged.
  • New application id and app name; the Java namespace stays com.drivehub.kamera because the JNI entry points are bound by name.

About

Loop-recording dashcam for the MG4 EV (pre-facelift). Stripped fork of jamakr4/MG4-360-Camera-App.

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages