Skip to content

Framework services

Add a subservice to Car Service

An OEM service inside com.android.car, with its own AIDL, its own manager class in car-lib, and the threading rules that keep the platform booting.

Advanced7 min readCar Service · AIDL · Framework

What you will build

A CarComfortService running inside Car Service, reachable from an app as CarComfortManager, visible in dumpsys car_service --services.

Estimated time
3–4 hours
Steps
6 steps

A vendor property gives apps raw vehicle state. A subservice gives them a feature — with business logic, state and a proper API — running in the process that already owns the vehicle connection.

Files you will create

packages/services/Car/ ├── car-lib/src/android/car/comfort/ │ ├── ICarComfort.aidl │ ├── ICarComfortListener.aidl │ └── CarComfortManager.java └── service/src/com/android/car/comfort/ └── CarComfortService.java

You are modifying AOSP here

Unlike everything else in these tutorials, a Car subservice lives inside packages/services/Car — an AOSP repository. Track your changes as commits on a branch you can rebase, or they will be lost on the next repo sync. On a real programme this is a managed fork with a re-merge cost at every platform upgrade, and that cost should be a conscious decision.

Step 1 — Define the AIDL#

car-lib/src/android/car/comfort/ICarComfort.aidl
package android.car.comfort;
 
import android.car.comfort.ICarComfortListener;
 
/** @hide */
interface ICarComfort {
    int getMassageIntensity(int seatAreaId);
    void setMassageIntensity(int seatAreaId, int level);
    int[] getSupportedSeats();
 
    void registerListener(in ICarComfortListener listener);
    void unregisterListener(in ICarComfortListener listener);
}
car-lib/src/android/car/comfort/ICarComfortListener.aidl
package android.car.comfort;
 
/** @hide */
oneway interface ICarComfortListener {
    void onIntensityChanged(int seatAreaId, int level);
}

Method order is frozen the day a vehicle ships

AIDL assigns transaction IDs by declaration order. Reordering or inserting a method in the middle breaks every app compiled against the old interface — apps that will be running on that vehicle for a decade. Append only.

Step 2 — Implement the service#

service/src/com/android/car/comfort/CarComfortService.java
package com.android.car.comfort;
 
import android.car.Car;
import android.car.comfort.ICarComfort;
import android.car.comfort.ICarComfortListener;
import android.content.Context;
import android.os.RemoteCallbackList;
import android.util.SparseIntArray;
 
import com.android.car.CarLog;
import com.android.car.CarServiceBase;
import com.android.car.internal.util.IndentingPrintWriter;
import com.android.internal.annotations.GuardedBy;
 
public final class CarComfortService extends ICarComfort.Stub implements CarServiceBase {
 
    private static final String TAG = CarLog.tagFor(CarComfortService.class);
    private static final String PERMISSION = "com.oem.vega.permission.CONTROL_SEAT_MASSAGE";
    private static final int MAX_LEVEL = 5;
 
    private final Context mContext;
    private final Object mLock = new Object();
 
    @GuardedBy("mLock")
    private final SparseIntArray mIntensityByArea = new SparseIntArray();
 
    private final RemoteCallbackList<ICarComfortListener> mListeners = new RemoteCallbackList<>();
 
    public CarComfortService(Context context) {
        mContext = context;
    }
 
    // ---- CarServiceBase ----------------------------------------------------
 
    @Override
    public void init() {
        // Runs on the Car Service startup path. Register and return.
        // Anything slow goes on your own handler — never block here.
        synchronized (mLock) {
            mIntensityByArea.put(SEAT_ROW_1_LEFT, 0);
            mIntensityByArea.put(SEAT_ROW_1_RIGHT, 0);
        }
    }
 
    @Override
    public void release() {
        mListeners.kill();
        synchronized (mLock) {
            mIntensityByArea.clear();
        }
    }
 
    // ---- ICarComfort -------------------------------------------------------
 
    @Override
    public int getMassageIntensity(int seatAreaId) {
        // Every Binder entry point checks permission. It is reachable by any
        // process that can find the service.
        mContext.enforceCallingOrSelfPermission(PERMISSION, "getMassageIntensity");
        synchronized (mLock) {
            return mIntensityByArea.get(seatAreaId, 0);
        }
    }
 
    @Override
    public void setMassageIntensity(int seatAreaId, int level) {
        mContext.enforceCallingOrSelfPermission(PERMISSION, "setMassageIntensity");
        if (level < 0 || level > MAX_LEVEL) {
            throw new IllegalArgumentException("level out of range: " + level);
        }
        synchronized (mLock) {
            if (mIntensityByArea.indexOfKey(seatAreaId) < 0) {
                throw new IllegalArgumentException("unsupported seat: " + seatAreaId);
            }
            mIntensityByArea.put(seatAreaId, level);
        }
        // Notify OUTSIDE the lock — listeners may call back into us.
        notifyListeners(seatAreaId, level);
    }
 
    @Override
    public int[] getSupportedSeats() {
        mContext.enforceCallingOrSelfPermission(PERMISSION, "getSupportedSeats");
        synchronized (mLock) {
            int[] seats = new int[mIntensityByArea.size()];
            for (int i = 0; i < mIntensityByArea.size(); i++) {
                seats[i] = mIntensityByArea.keyAt(i);
            }
            return seats;
        }
    }
 
    @Override
    public void registerListener(ICarComfortListener listener) {
        mContext.enforceCallingOrSelfPermission(PERMISSION, "registerListener");
        // RemoteCallbackList handles clients dying, which they will.
        mListeners.register(listener);
    }
 
    @Override
    public void unregisterListener(ICarComfortListener listener) {
        mListeners.unregister(listener);
    }
 
    private void notifyListeners(int seatAreaId, int level) {
        int n = mListeners.beginBroadcast();
        for (int i = 0; i < n; i++) {
            try {
                mListeners.getBroadcastItem(i).onIntensityChanged(seatAreaId, level);
            } catch (Exception ignored) {
                // Client died; RemoteCallbackList removes it for us.
            }
        }
        mListeners.finishBroadcast();
    }
 
    // ---- Diagnostics -------------------------------------------------------
 
    @Override
    public void dump(IndentingPrintWriter writer) {
        writer.println("*CarComfortService*");
        writer.increaseIndent();
        synchronized (mLock) {
            for (int i = 0; i < mIntensityByArea.size(); i++) {
                writer.printf("area=0x%x intensity=%d\n",
                        mIntensityByArea.keyAt(i), mIntensityByArea.valueAt(i));
            }
        }
        writer.printf("listeners=%d\n", mListeners.getRegisteredCallbackCount());
        writer.decreaseIndent();
    }
}

Never call a listener while holding your lock

notifyListeners runs outside mLock deliberately. A listener that calls back into getMassageIntensity from inside onIntensityChanged would deadlock — and it will only happen under load, on a vehicle.

Step 3 — Register it in ICarImpl#

service/src/com/android/car/ICarImpl.java
// Field
private final CarComfortService mCarComfortService;
 
// In the constructor, AFTER anything it depends on
mCarComfortService = constructWithTrace(
        t, CarComfortService.class,
        () -> new CarComfortService(serviceContext), allServices);
…and in the getCarService switch
case Car.CAR_COMFORT_SERVICE:
    return mCarComfortService;
car-lib/src/android/car/Car.java
/** @hide */
public static final String CAR_COMFORT_SERVICE = "comfort";

Construction order is dependency order

ICarImpl builds subservices in sequence. One constructed before a dependency receives null and fails in a way that surfaces as an unrelated boot problem — often "Car Service did not start" with no useful stack.

Step 4 — Write the manager#

Apps must never see your Binder stub.

car-lib/src/android/car/comfort/CarComfortManager.java
package android.car.comfort;
 
import android.annotation.RequiresPermission;
import android.car.Car;
import android.car.CarManagerBase;
import android.os.IBinder;
import android.os.RemoteException;
 
public final class CarComfortManager extends CarManagerBase {
 
    private final ICarComfort mService;
 
    /** @hide */
    public CarComfortManager(Car car, IBinder service) {
        super(car);
        mService = ICarComfort.Stub.asInterface(service);
    }
 
    @RequiresPermission("com.oem.vega.permission.CONTROL_SEAT_MASSAGE")
    public int getMassageIntensity(int seatAreaId) {
        try {
            return mService.getMassageIntensity(seatAreaId);
        } catch (RemoteException e) {
            // Car Service died. Return a sane default rather than crashing the
            // app; the lifecycle listener will hand back a fresh manager.
            return handleRemoteExceptionFromCarService(e, 0);
        }
    }
 
    @RequiresPermission("com.oem.vega.permission.CONTROL_SEAT_MASSAGE")
    public void setMassageIntensity(int seatAreaId, int level) {
        try {
            mService.setMassageIntensity(seatAreaId, level);
        } catch (RemoteException e) {
            handleRemoteExceptionFromCarService(e);
        }
    }
 
    @Override
    protected void onCarDisconnected() {
        // This manager instance is now dead. Drop any cached state.
    }
}
car-lib — wire the manager into Car.getCarManager()
case Car.CAR_COMFORT_SERVICE:
    manager = new CarComfortManager(this, binder);
    break;

Step 5 — Build and verify#

m -j
adb install-multiple -r $ANDROID_PRODUCT_OUT/system/priv-app/CarService/*.apk 2>/dev/null \
  || { m -j && emulator -wipe-data -no-snapshot & }
adb wait-for-device && sleep 40

Verify the subservice is registered and dumping

# 1. Car Service came up at all — if not, your init() blocked or threw
adb shell dumpsys car_service --help
 
# 2. Your service is in the list
adb shell dumpsys car_service --list | grep -i comfort
 
# 3. Your dump() output appears
adb shell dumpsys car_service --services CarComfortService
# expect: *CarComfortService*  area=0x1 intensity=0 ...
 
# 4. No crashes on the Car Service startup path
adb logcat -b all | grep -iE 'CarService.*(crash|fatal)|CarComfortService'

Item 1 first, always. If Car Service does not start, nothing else matters — and the usual cause is something slow or throwing inside your init().

Step 6 — Use it from an app#

Consuming your manager
val car = Car.createCar(context, null, Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT) { c, ready ->
    if (!ready) return@createCar
    val comfort = c.getCarManager(Car.CAR_COMFORT_SERVICE) as CarComfortManager
    comfort.setMassageIntensity(VehicleAreaSeat.SEAT_ROW_1_LEFT, 3)
}

Should this be a subservice at all?#

Be honest before taking on a fork of packages/services/Car:

SituationBetter answer
Just exposing vehicle stateA vendor VHAL property — no fork needed
Request/response with errorsA vendor AIDL HAL — no fork needed
Logic that must run with no user logged inA subservice, or a user-0 system app
Coordination across several Car subservicesA subservice
Anything an app could do itselfA system app

A subservice is the right answer less often than teams assume, and it is the most expensive of these options to carry across platform upgrades.

Troubleshooting#

SymptomCause
Car Service will not startBlocking or throwing init()
getCarManager returns nullMissing case in ICarImpl or in Car.getCarManager
SecurityException from an appPermission not declared or not granted
Deadlock under loadListener notified while holding the lock
Listener list growsNot using RemoteCallbackList
Changes lost after repo syncUnmanaged edits to an AOSP repository

Next#

The same idea one layer out — a service in system_server.

References & further reading

Code links target the main branch on cs.android.com. AOSP moves — if a path 404s, search the symbol instead.