RE paperConfirmed

Reverse engineering paper #6 — the one gate a normal app controls

BYD's background killer spares any process owning a visible activity. The cluster is a public display, so an ordinary app can put one there.

BYD LabVerified 8 min read

Run A app process 449 MB PSS, backgrounded activity cluster display 1 survives 5 min, never evaluated Run B — control app process 449 MB PSS, backgrounded task removed — nothing visible SIGKILL 30 s after evaluation mHasForegroundActivities — the one term a normal app can satisfy display 1 carries no FLAG_PRIVATE, so putting an activity there needs no permission

Our own app. Sealion 7 Pilot is developed by BYD Lab. It is held to the same standards as every other app on this site — read with that in mind.

Tested on

Vehicle
BYD Sealion 7 · Morocco
Head-unit version
51.1.4.2606220.1
Android
11
Verified

Paper #5 established what kills a backgrounded app on this unit: PerfMonitor, a per-process PSS cap and a 30-second timer. This paper is the other half — the single term in that rule an app without a platform signature can satisfy, and the experiment that proves it works.

Question#

Is there anything a normal-signed app can do to survive BYD’s background kill?

Environment#

Decompiled read-only with jadx 1.5.5 from /system/framework/services.jar and framework-res.apk pulled off the unit; raw resource ids resolved with aapt against the same framework-res.apk. Probed over adb with dumpsys (activity processes, activity activities, activity p, meminfo, display, window, car_service, activity exit-info) and logcat filtered on the PerfMonitor / PerfMonitorInfo tags. The A/B run used am start --display 1, input keyevent 3 and am stack remove. Car parked throughout; nothing here writes a vehicle property.

Observation#

The decompiled kill predicate is a conjunction of four terms, and three of them are out of an app’s hands — the per-process cap comes from a file in /vendor, the adj term is already satisfied by a backgrounded process (a foreground service sits at adj 200), and audio focus only holds while something is actually playing. The fourth is ordinary app state:

java
// com.android.server.am.PerfMonitor.checkProcessPssLeak(proc)
return !proc.mHasForegroundActivities
    && proc.curAdj >= 100
    && proc.lastPss >= cap
    && !checkAudioFocusApp(proc);

mHasForegroundActivities is not something an app sets. It is derived: OomAdjuster computes it through WindowProcessController.computeOomAdjFromActivities, and any activity of the process with mVisibleRequested — or in PAUSING, PAUSED, or STOPPING-not-finishing — makes it true, pulls the process to adj 100 or below (vis-activity) and sets procState TOP. Nothing in that path asks which display the activity is on.

This car has a second display. dumpsys display reports display 1 — the instrument cluster — as a public physical display:

Field Value
Name / address "Écran HDMI", local:3, port 3
Type EXTERNAL
Resolution 1920×720 at 60 Hz, density 213
Flags FLAG_SECURE, FLAG_SUPPORTS_PROTECTED_BUFFERS, FLAG_PRESENTATION, FLAG_TRUSTED, FLAG_OWN_CONTENT_ONLY
Not set FLAG_PRIVATE
Layer stack / state 1, ON

The missing flag is the one that matters. ActivityStackSupervisor.isCallerAllowedToLaunchOnDisplay, decompiled from the same services.jar, needs no INTERNAL_SYSTEM_WINDOW for a display that is not private: the !displayContent.isPrivate() branch logs “allow launch on public display” and lets the start through.

Evidence#

This is the Android Automotive contract rather than a trick: on AAOS the platform’s own cluster renderer launches the navigation app’s android.car.cluster.NAVIGATION activity on the cluster display. On this unit nobody performs that launch.

The A/B run#

One process, one afternoon, two runs. Setup: the head-unit process of com.ali.sealion7pilot in the foreground at 490 MB PSS by dumpsys meminfo, no cast active, persist.sys.background_mem_check=1.

Run A — with a visible activity on display 1. am start --display 1 -n com.ali.sealion7pilot/.ClusterNavigationActivity (the app’s existing android.car.cluster.NAVIGATION probe; it drew its test view on the cluster framebuffer). dumpsys activity activities then showed Display #1 … mResumedActivity: … ClusterNavigationActivity t1076, and dumpsys activity p showed foregroundActivities=true (rep=true). Then HOME (input keyevent 3):

Log
t+30 s … t+300 s   pid 10651 alive   lastPss=449MB   foregroundActivities=true   oom cur=50   state TOP
18:56:12  PerfMonitorInfo: process : com.byd.trafficmonitor be killed when in bg
18:57:36  PerfMonitorInfo: process : com.byd.trafficmonitor be killed when in bg

The two com.byd.trafficmonitor lines are the load-bearing part of run A: the monitor was awake and reaping in exactly the window in which our over-cap process was left alone.

Run B — control. Same process, same memory, same background state; the probe task removed with am stack remove 1076:

Log
19:00:36  PerfMonitor: checkProcessPssLeak proc: ProcessRecord{6ef3fc9 10651:com.ali.sealion7pilot/u0a46}
19:01:06  PerfMonitorInfo: process : com.ali.sealion7pilot be killed. Reason: Pss cost too large 459458 kb, MaxMem : 370688
ApplicationExitInfo 19:01:06  reason=3 (LOW_MEMORY)  pss=449MB  "… be killed. Pss : 459458 kb, mMemAvailable : 3221240"

Evaluation, thirty seconds, kill — exactly the decompiled behaviour, with the reason string naming 459 458 kB against the 370 688 kB cap.

Interpretation#

The gate is real, it is singular, and it is not a memory fix. Nothing about a visible activity on display 1 raises the cap or shrinks the process; PSS keeps growing exactly as before. It makes the process ineligible for the rule, which is a different thing and worth saying plainly: the app is not healthier, it is merely no longer selected.

It also says nothing about kills from any other mechanism. Only PerfMonitor’s predicate was under test here — not the kernel low-memory killer, not a crash, not a user force-stop.

The focus measurement is the trap. A focusable activity on the cluster moves mTopFocusedDisplayId to 1, which sends key events to a display the driver is not touching. Any production window used this way has to be FLAG_NOT_FOCUSABLE, and the focus check belongs in the validation protocol next to the survival check — it is the failure that would not announce itself.

What Sealion 7 Pilot applied is one line of the answer, not the finding: while a cast is wanted, the projection owns a task on the cluster display — an opaque root activity in the :cluster process, sitting under the unchanged Presentation that still does the rendering (paper #11 covers what the instrument does with that surface), and a translucent partner activity in the head-unit process on top of it, so that both processes own a visible activity. Both windows are non-focusable and input-transparent, launches are budgeted, and the task is removed on release. The launches are legal from the background because the cast has already made a window of this UID visible.

Three vendor mechanisms remain out of reach and were therefore not attempted: editing /vendor/etc/data/proc_mem_info.xml, where BYD’s own navigation packages get their raised caps; flipping the persist.sys.* toggles that arm the check; and joining the process whitelist. Nor is android.uid.system available to a sideloaded app. Holding audio focus as a keep-alive was rejected rather than tested — it only counts while playback is actually active.

Result#

Confirmed: there is exactly one gate, and it is ProcessRecord.mHasForegroundActivities. A process that owns a visible activity on any display — including the public cluster display, which needs no permission to launch on — is not selected by checkProcessPssLeak and is not killed, at any PSS.

Three consequences:

  • A foreground service does not substitute. An FGS with no visible activity sits at adj 200, which satisfies curAdj >= 100 and leaves the process fully eligible.
  • The activity must not take focus. FLAG_NOT_FOCUSABLE, and verify that mTopFocusedDisplayId stays 0.
  • This is the AAOS contract, self-served. The role is the one the platform’s cluster renderer would play; on this unit the renderer is absent, so the app performs the launch the platform does not.

Limitations#

One car, one market, one head-unit build, one afternoon. The A/B is a single pair — one run with the activity, one control without — not a repeated trial. The kill in run B matches five earlier recorded LOW_MEMORY exits of the same process, but the control itself was run once.

Run A was observed for a little over five minutes. Nothing here demonstrates the gate holding for hours, across an ignition cycle, or while the cluster display sleeps; the research record expects the platform to resume the activities on wake, and that expectation was not exercised in this experiment.

The production shape — two non-focusable, input-transparent activities, one per process, budgeted and removed on release — is what was applied, not what was measured. Its long-background validation exists in the record as a device protocol; this paper does not claim that protocol was executed, and the focus behaviour of the production windows is reasoned from the window flags rather than observed.

Several supporting facts were read from decompiled code and never exercised: the PAUSING / PAUSED / STOPPING-not-finishing branch of computeOomAdjFromActivities (only mVisibleRequested was actually observed), and the background-activity-start path that makes the launch legal while the app is minimised. Display 1’s flags are this unit’s; another build could mark the cluster private, and every conclusion here would fall with it.

Reproduction#

You need a process that is genuinely over the cap — below it there is nothing to observe. Check with dumpsys meminfo <package> first, and start the log capture before you press HOME: the buffer on this unit holds roughly two minutes of these tags.

Shell
adb shell dumpsys display
adb shell dumpsys car_service | grep -i -A4 cluster
adb shell dumpsys meminfo com.example.app

Then the pair. Start any activity of the app on display 1, confirm it resumed, background the app, and watch:

Shell
adb shell am start --display 1 -n com.example.app/.SomeActivity
adb shell dumpsys activity activities | grep -B2 -A8 'Display #1'
adb shell dumpsys activity p com.example.app | grep -i foregroundActivities
adb shell dumpsys window | grep mTopFocusedDisplayId
adb shell input keyevent 3
adb logcat -s PerfMonitor:* PerfMonitorInfo:*

Five minutes with no checkProcessPssLeak line for the pid is the positive result. Then remove the task and repeat with nothing else changed:

Shell
adb shell am stack remove <task-id>
adb logcat -s PerfMonitor:* PerfMonitorInfo:*
adb shell dumpsys activity exit-info com.example.app

The checkProcessPssLeak line appears within about a minute, and the kill 30 seconds after it. The task id comes from the Display #1 section of dumpsys activity activities.