Периодически читая Хабр, я еще не находил статей, описывающих внутренний мир штатных головных устройств (далее — ГУ) на базе Android, хотя я уверен, что не только мне было бы интересно, как там всё устроено и работает. Речь пойдет про одни из самых популярных авто на нашем рынке: Geely Coolray и частично Geely Tugella.
Эта статья обещает быть длинной с вырезками кода из JADX и не только, добро пожаловать под кат.
Пациент: Belgee X50 2024 года, он же Geely Coolray дорестайлинг, но с новым головным устройством на относительно красивом бело/синем UI.
Аппаратная и программная платформа
Железки и софт для Geely изготавливает компания ECARX.
Железо легко гуглится со скринами из AIDA64: платформа ECARX E02, включающая Mediatek Helio P60 (MT6771), 4 Гб RAM, 64 Gb Flash, двухдиапазонный WiFi модуль, всё это внутри железного блока где-то в торпеде и экран 1920х720.
Допиленный Android 9 API 28, собственный лаунчер, приложения, HAL для связи с авто.
Загрузчик по умолчанию заблокирован.
Не густо и не сильно современно, но на все авто-хотелки вполне хватит и проблем с установкой новых версий чего-либо ближайшее время возникнуть не должно.
А как пользоваться этим?
Из коробки конечно же нельзя установить стороннее ПО никаким образом.
Чтобы исправить это специально обученные люди, имеющие эмулятор CAN-шины, запускают блок ГУ и дисплей отдельно от авто "на столе", патчат boot.img
для установки Magisk, правят PackageManager
отключая проверки на установку сторонних приложений и проводят другие мероприятия для возможности этим пользоваться: ставят usbgps, устанавливая его в fusedlocation, и т.д.
Специально обученные люди в данном контексте это профессионалы своего дела с оборудованием и железками, зарабатывающие на этом деньги.
В том числе, как правило, они же занимаются русификацией привезённых авто китайского рынка по параллельному импорту.
Я не буду описывать данный процесс, потому что до такого вида Belgee X50 можете относительно легко довести без потребности в сложных манипуляциях, благодаря людям, которые бесплатно выкладывают модифицированные дампы разделов, но после установки и начала использования, вы обнаружите ряд нюансов:
-
У вас по-прежнему не работают 2 кнопки на руле и имеются новые проблемы с мультимедиа:
Одна пустая кнопка, предназначенная изначально для голосового помощника
Кнопка джойстика управлении мультимедиа, где должна быть плей/пауза
Переключение треков останавливает воспроизведение в сторонних плеерах, если открыто штатное приложение мультимедиа, или начинает играть музыка из штатного, что не планировалось
Интерфейс всех сторонних приложений достаточно мелкий: 160 DPI при 9.5" 1920х720
Меню недавних приложений перегружено лишними Активити: там даже лаунчер =)
Некоторые приложения (например, PowerAMP) вовсе не устанавливаются: система перезагружается в момент установки
Навигаторы закрываются в случайные моменты времени (Яндекс.Навигатор и 2ГИС)
Попробуем исправить ситуацию
Первым делом нужно как-то подключиться по ADB, но и в этом ожидается подвох: менюшка "для разработчиков" недоступна. Идем другим способом, у нас же есть ROOT-права: ставим com.kva.adboverwifi
, единственная задача которого установить переменную service.adb.tcp.port
в 5555
и перезапустить ADBd.
Теперь можно делать adb connect <ip>
, но adb devices
сообщает что устройство unauthorized
. Диалог принятия публичного ключа отладки также отсутствует, кладем ключик вручную в /data/misc/adb/adb_keys
, и на данном этапе у нас появляется полноценный рабочий ADB.
Заряжаем BatchAPKTool, выкачиваем по ADB /system
раздел или разархивируем его из дампов, деодексируем, осматриваемся, и можно приступать к попыткам исправления.
Попытки будут отсортированы по степени погружения в процесс, начиная с легких и заканчивая погружением в ассемблер О_о.
Меню недавних приложений перегружено лишними Активити
Начинаем с легкого.
Декомпилируем лаунчер NSLauncher.apk
, приложение климат-контроля XCHvac.apk
, активити анимации смены темы темная/белая (зачем оно вообще нужно?) ECarXPowerManagerService.apk
.
Смотрим манифест:
<activity android:configChanges="uiMode" android:exported="true" android:fitsSystemWindows="true" android:launchMode="singleTask" android:name="ecarx.hvac.app.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<action android:name="android.intent.action.HVAC_WIDGET"/>
<action android:name="android.intent.action.PSD_HVAC_WIDGET"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
Быстрым гуглением (или из предыдущих проектов) достаем нужный атрибут android:excludeFromRecents="true"
, добавляем, запаковываем обратно.
Идем к авто, закидываем измененный APK туда, откуда брали оригинальный с заменой, перезагружаемся, готово.
Повторяем примерно те же мероприятия для других приложений.
Установка PowerAMP перезагружает систему
Цепляем ADB, делаем adb logcat
, пробуем установить PowerAMP.
Система действительно перезагружается, а в logcat завещание от system_server
:
07-01 01:49:36.041 15732 15749 D avm_service_app: ActivityWatcher.java(113):appCrashed: appCrashed proc:null pid:11655 m:java.util.NoSuchElementException m2:java.util.NoSuchElementException time:1751305776039 st:java.util.NoSuchElementException
07-01 01:49:36.041 15732 15749 D avm_service_app: at android.util.MapCollections$ArrayIterator.next(MapCollections.java:55)
07-01 01:49:36.041 15732 15749 D avm_service_app: at com.android.server.pm.PackageManagerService.adjustCpuAbisForSharedUserLPw(PackageManagerService.java:12267)
07-01 01:49:36.041 15732 15749 D avm_service_app: at com.android.server.pm.PackageManagerService.scanPackageOnlyLI(PackageManagerService.java:11005)
07-01 01:49:36.041 15732 15749 D avm_service_app: at com.android.server.pm.PackageManagerService.scanPackageNewLI(PackageManagerService.java:10448)
07-01 01:49:36.041 15732 15749 D avm_service_app: at com.android.server.pm.PackageManagerService.scanPackageTracedLI(PackageManagerService.java:10229)
07-01 01:49:36.041 15732 15749 D avm_service_app: at com.android.server.pm.PackageManagerService.installNewPackageLIF(PackageManagerService.java:16792)
// укоротил чтоб меньше буков было
07-01 01:49:36.041 11655 11688 I Process : Sending signal. PID: 11655 SIG: 9
Натравливаем JADX на services.jar
, ищем метод adjustCpuAbisForSharedUserLPw
:
private static List<String> adjustCpuAbisForSharedUserLPw(Set<PackageSetting> packagesForUser, PackageParser.Package scannedPackage)
private static List<String> adjustCpuAbisForSharedUserLPw(Set<PackageSetting> packagesForUser, PackageParser.Package scannedPackage) {
String adjustedAbi;
List<String> changedAbiCodePath = null;
String requiredInstructionSet = null;
if (scannedPackage != null && scannedPackage.applicationInfo.primaryCpuAbi != null) {
requiredInstructionSet = VMRuntime.getInstructionSet(scannedPackage.applicationInfo.primaryCpuAbi);
}
PackageSetting requirer = null;
// тут был for, точно такой же как в сорцах AOSP
if (requiredInstructionSet != null) {
if (requirer != null) {
adjustedAbi = requirer.primaryCpuAbiString;
if (scannedPackage != null) {
scannedPackage.applicationInfo.primaryCpuAbi = adjustedAbi;
}
} else {
adjustedAbi = scannedPackage.applicationInfo.primaryCpuAbi;
}
if (packagesForUser.iterator().next().getSharedUserId() == 1000) {
adjustedAbi = "arm64-v8a";
}
// тут был for, точно такой же как в сорцах AOSP
}
return changedAbiCodePath;
}
В этот момент я немного не понимаю, зачем было сделано условие на 21 строке: форсировать архитектуру при установке системных приложений? Системные приложения предполагается устанавливать через PackageManager? Причем не предусмотрено, что packagesForUser
может оказаться пустым Set'ом...
зачем оно вообще нужно?
Лезем в smali и комментируем это условие целиком.
Сомневаясь в правильности решения, полез поискать в исходники AOSP Android 9 API 28, есть ли там что-то похожее. Не нашел - видимо ненужный код.
Этот кейс хоть и относительно легкий, но цена ошибки в данном случае час времени на повторную прошивку system раздела через FlashTool - напоминаю, у нас ГУ авто без TWRP, да и до "кнопок" перевода в режим прошивки лезть за бардачок, чего делать было лень.
В итоге нашелся человек (спасибо ему), который согласился проверить модифицированный services.jar
в момент первоначальной прошивки, где цена ошибки уже 10 минут - прокатило с первого раза: с PowerAMP более проблем нет.
Интерфейс всех сторонних приложений мелкий
Если честно, меня это в целом не парило, но по просьбам я решил это попробовать исправить.

wm density 240
Потому что стандартные приложения, включая климат, лаунчер и SystemUI были разработаны с привязкой разметки (layout) к штатному DPI: при изменении у вас больше не будет ни статус бара, ни боковой панели с кнопками и придется вернуть штатное значение.
Пойдем другой дорогой: у нас есть Magisk, а значит потенциально есть LSPosed (он же Xposed на базе Zygisk), а там потенциально есть App Settings - модуль, позволяющий изменять настройки для каждого приложения отдельно.
Другими словами, задача теперь стоит в установке этого всего и указания DPI 240 для Яндекс.Навигатора / любого другого приложения.
С установкой проблем никаких: скачать, импортировать, перезагрузиться; чего не сказать о настройке.
После установки LSPosed необходимо открыть LSPosed Manager, чтобы выдать права модулю App Settings на внедрение, а чтобы открыть его нужно нажать по уведомлению.
А уведомлений нет: ни всплывающих, ни панельки как на обычных телефонах.
Точнее говоря, сама панелька-то есть, но она никакого отношения к уведомлениям не имеет, она является частью CarSettings.apk
- другого системного приложения.
В настройках, похожих на AOSP-овские история уведомлений также не найдена.
Погуглил минут 5 есть ли еще варианты запустить LSPosed Manager, безрезультатно, значит придется самим.
Дампим все текущие уведомления, ищем то, что от LSPosed, достаем ID PendingIntent:
IHU730P:/ # dumpsys notification --noredact | grep -B 12 "android.title=String (LSPosed " | grep contentIntent
contentIntent=PendingIntent{9519f01: PendingIntentRecord{f2fa65e android broadcastIntent (whitelist: 6aeaa3f:+30s0ms)}}
IHU730P:/ # dumpsys notification --noredact | grep -B 12 "android.title=String (LSPosed " | grep contentIntent | cut -d"{" -f3 | cut -d" " -f1
# вернет f2fa65e - то, что надо
Дампим все Intent из приложений, ищем по ID наш:
IHU730P:/ # dumpsys activity intents | grep -A 4 f2fa65e
#27: PendingIntentRecord{f2fa65e android broadcastIntent (whitelist: 6aeaa3f:+30s0ms)}
uid=1000 packageName=android type=broadcastIntent flags=0x4000000
requestCode=1 requestResolvedType=null
requestIntent=act=07780ba6-7489-40d0-b74a-013d98477047 pkg=android
whitelistDuration=6aeaa3f:+30s0ms
IHU730P:/ # dumpsys activity intents | grep -A 4 f2fa65e | grep requestIntent | cut -d'=' -f3 | cut -d' ' -f1
# 07780ba6-7489-40d0-b74a-013d98477047 - то, что надо
Вот и нашли Intent Action, который в виде UUID, он меняется каждый ребут, то есть "хардкодить" не получится.
Ну и осталось дело за малым, за отправкой Intent
am broadcast -a 07780ba6-7489-40d0-b74a-013d98477047 -p android --receiver-include-background
Итоговый скрипт выглядит следующем образом:
#!/bin/sh
intent_id=$(dumpsys notification --noredact | grep -B 12 "android.title=String (LSPosed " | grep contentIntent | cut -d"{" -f3 | cut -d" " -f1)
act_uuid=$(dumpsys activity intents | grep -A 4 $intent_id | grep requestIntent | cut -d'=' -f3 | cut -d' ' -f1)
am broadcast -a $act_uuid -p android --receiver-include-background
LSPosed Manager открылся, дальше кликер продолжается без приключений и вполне успешно достигая результата =)
Кнопки на руле
Фото руля, для понимания

Переключения треков
Что не так с кнопками переключения треков, спросите? Не так то, что все штатные приложения и системные компоненты, несмотря на Android 9, отсылают и ожидают старый интент android.intent.action.MEDIA_BUTTON
, который не поддерживается современными плеерами, которые minSdk=21 - то есть всеми.
Достаточно понятно алгоритм описан здесь, я кратко перескажу: до Android 5 API 21 для управления воспроизведением системные компоненты отсылали приложениям intent act=android.intent.action.MEDIA_BUTTON
и клали туда информацию о кнопке, которая нажата. Приложения в свою очередь делали то, что сказано.
Начиная с API 21 это стало deprecated, потому что ввели MediaSession
- компонент, через который плееры могут выводить информацию о треке унифицированно (картинка, название, и т.д.), а также забирать ивенты о действиях, ну и еще много всякого не сильно важного.
Это привело к тому, что кнопки на руле не управляют сторонними плеерами, а это обязательно нужно было исправить и специально обученные люди исправили: небольшое системное приложение ловило android.intent.action.MEDIA_BUTTON
и делало new Instrumentation().sendKeyDownUpSync(keycode);
, который в конечном итоге вызывает событие в MediaSession, что приводит к гонке за аудиофокус при попытке воспроизведения.
Как же исправить ситуацию? В момент времени нужно отправлять только один из вариантов, либо MediaSession.getTransportControls().<action>()
либо интент.
Текущая моя реализация так и делает: явно описаны 2 режима, явно можно между ними переключаться, можно также забиндить любую другую кнопку на переключение режимов, ну или лонг-тап по кнопке.

Есть мысли, что можно впилить в штатные приложения минимальную поддержку MediaSession - теоретически возможно, и отказаться полностью от старых интентов, но требует время и желания копаться в smali.
Незадействованные кнопки
Первым делом я полез в logcat, искать откуда начинать поиски по коду, где какие события обрабатываются при нажатии кнопок. И как ни странно, обе кнопки "чекинятся" в приложении-сервисе Apple CarPlay.
Как минимум потому что, как я уже потом выяснил, в CarPlay сессии кнопка голосового ассистента всё же вызывает Siri. Кнопка плей/паузы по прежнему не работает.
Начнем с кнопки джойстика, которая нигде ничего не делает. Лезем в logcat.
// нажали
1752509568.098 1000 1119 1119 D carplayapp-CarPlayServiceHelper: onChangeEvent keyID:"[45]" keyEvent[1]
1752509568.098 1000 1119 1119 D carplayapp-CarPlayServiceHelper: onChangeEvent current source is not CarPlay gear=>P currentSourceIsCarPlay=>true siriStatus=>0 carplaySession=>true
// отпустили
1752509569.179 1000 1119 1119 D carplayapp-CarPlayServiceHelper: onChangeEvent keyID:"[45]" keyEvent[0]
1752509569.179 1000 1119 1119 D carplayapp-CarPlayServiceHelper: onChangeEvent current source is not CarPlay gear=>P currentSourceIsCarPlay=>true siriStatus=>0 carplaySession=>true
Запись в логкате current source is not CarPlay
+ currentSourceIsCarPlay=>true
вводит в замешательство, но сейчас мы всё поймем.
private void resetNextAndPreKeyListener()
private void resetNextAndPreKeyListener() {
this.mVehicleSignalManager = new VehicleSignalManager(CarPlayApplication.getApplication());
this.mVehicleSignalManager.connect();
this.mVehicleSignalManager.registerConnectWatcher(new IConnectable.IConnectWatcher() { // from class: com.neusoft.accessory.connectservice.CarPlayServiceHelper.5
@Override // com.ecarx.xui.adaptapi.binder.IConnectable.IConnectWatcher
public void onConnected() {
LogUtil.m43D(CarPlayServiceHelper.TAG, "VehicleSignalManager onConnected key:" + Integer.toHexString(VehicleProperty.INFO_ID_CARPLAY_KEY_VALUE));
CarPlayServiceHelper.this.mVehicleSignalManager.getCarPropertyManager();
boolean result = CarPlayServiceHelper.this.mVehicleSignalManager.registerListener(new CarPropertyManager.CarPropertyEventListener() { // from class: com.neusoft.accessory.connectservice.CarPlayServiceHelper.5.1
@Override // android.car.hardware.property.CarPropertyManager.CarPropertyEventListener
public void onChangeEvent(CarPropertyValue carPropertyValue) {
byte[] keyEvent = (byte[]) carPropertyValue.getValue();
byte[] keyId = CarPlayServiceHelper.this.mVehicleSignalManager.getBytesByPropID(VehicleProperty.INFO_ID_CARPLAY_KEY_ID);
LogUtil.m43D(CarPlayServiceHelper.TAG, "onChangeEvent keyID:\"" + Arrays.toString(keyId) + "\" keyEvent" + Arrays.toString(keyEvent));
boolean currentSourceIsCarPlay = GlobalStatus.currentSourceIsCarPlay.get().booleanValue();
String gear = CarPlayVehicleStatus.getInstance().getGear();
boolean carplaySession = GlobalStatus.session.get().booleanValue();
int siriStatussss = GlobalStatus.siRiStatus.get().intValue();
LogUtil.m43D(CarPlayServiceHelper.TAG, "onChangeEvent current source is not CarPlay gear=>" + gear + " currentSourceIsCarPlay=>" + currentSourceIsCarPlay + " siriStatus=>" + siriStatussss + " carplaySession=>" + carplaySession);
if (!currentSourceIsCarPlay) {
LogUtil.m43D(CarPlayServiceHelper.TAG, "onChangeEvent current source is not CarPlay, return.");
} else if ("R".equals(gear)) {
LogUtil.m43D(CarPlayServiceHelper.TAG, "onChangeEvent gear is R, return.");
} else if (1 == siriStatussss) {
LogUtil.m43D(CarPlayServiceHelper.TAG, "onChangeEvent Siri activity, return.");
} else if (!carplaySession) {
LogUtil.m43D(CarPlayServiceHelper.TAG, "onChangeEvent session not start, return.");
} else if (50 == keyId[0]) {
Message msg = CarPlayServiceHelper.this.myHandler.obtainMessage(101);
if (keyEvent[0] == 1) {
msg.obj = 0;
} else {
msg.obj = 2;
}
CarPlayServiceHelper.this.myHandler.sendMessage(msg);
} else if (49 == keyId[0]) {
Message msg2 = CarPlayServiceHelper.this.myHandler.obtainMessage(102);
if (keyEvent[0] == 1) {
msg2.obj = 0;
} else {
msg2.obj = 2;
}
CarPlayServiceHelper.this.myHandler.sendMessage(msg2);
}
}
@Override // android.car.hardware.property.CarPropertyManager.CarPropertyEventListener
public void onErrorEvent(int i, int i1) {
LogUtil.m43D(CarPlayServiceHelper.TAG, "0x028B onErrorEvent i:" + i + " i1:" + i1);
}
}, VehicleProperty.INFO_ID_CARPLAY_KEY_VALUE);
LogUtil.m43D(CarPlayServiceHelper.TAG, "resetNextAndPreKeyListener result is " + result);
}
@Override // com.ecarx.xui.adaptapi.binder.IConnectable.IConnectWatcher
public void onDisConnected() {
}
});
}
Нашли обработчик кнопок, который регистрируется по ID VehicleProperty.INFO_ID_CARPLAY_KEY_VALUE
и достает ID кнопки по VehicleProperty.INFO_ID_CARPLAY_KEY_ID
.
Еще нашли, что current source is not CarPlay
будет в логе вне зависимости подключен ли реально CarPlay или нет.
Итак, вспомним ID кнопок: 45
кнопка - кнопка джойстика, 49
кнопка - кнопка переключения на предыдущий трек, 50
кнопка - переключение на следующий. В чем может быть проблема?
Видим условия на 49
и 50
кнопки, а где условие на 45
? Его просто нет!
Ладно, сходим посмотрим куда они передаются, в myHandler
private static class MyHandler extends Handler
private static class MyHandler extends Handler {
public MyHandler(Looper looper) {
super(looper);
}
@Override // android.p010os.Handler
public void handleMessage(Message msg) {
switch (msg.what) {
case 101:
sendKeyEvent(101, ((Integer) msg.obj).intValue());
break;
case 102:
sendKeyEvent(102, ((Integer) msg.obj).intValue());
break;
case 103:
sendKeyEvent(103, ((Integer) msg.obj).intValue());
break;
case 104:
sendKeyEvent(104, ((Integer) msg.obj).intValue());
break;
case 105:
sendKeyEvent(105, ((Integer) msg.obj).intValue());
break;
case 106:
sendKeyEvent(106, ((Integer) msg.obj).intValue());
break;
}
super.handleMessage(msg);
}
private void sendKeyEvent(int keyCode, int action) {
CarPlayHardKeyManager.getInstance().onKeyEvent(keyCode, action);
}
}
Здесь уже кнопок побольше, заглянем в CarPlayHardKeyManager.getInstance().onKeyEvent(keyCode, action);
и его код я не буду полностью копипастить, потому что его разбирать неудобно (видимо декомпилятор JADX его неудобно декомпилировал), но там мы находим вызовы кнопок, которые далее уходят в CarPlay HAL:
// на нажатие кнопки (keyaction == 0)
UserInput.getInstance().mediaPlay();
UserInput.getInstance().mediaPause();
UserInput.getInstance().mediaPrevious();
UserInput.getInstance().mediaNext();
// на отпускание кнопки (keyaction == 1)
UserInput.getInstance().mediaUp();
Остается одна проблема: определить играет ли сейчас музыка, чтобы вызывать отдельно.
Отойдем немного назад, и посмотрим через android-media-controller создает ли CarPlay MediaSession сессию. И да, он ее создает, но все контролы заблокированы.

Странно, но может там есть код для обработки кнопок, несмотря на то что контролы заблокированы: и да, он тоже есть. Только почему-то if-ами внутри onMediaButtonEvent
и тоже не обрабатывает паузу. Да что-ж такое то.
HandleMedia.class
@Override // android.media.session.MediaSession.Callback
public boolean onMediaButtonEvent(Intent mediaButtonIntent) {
LogUtil.m43D(TAG, "onMediaButtonEvent");
if (GlobalStatus.session.get().booleanValue()) {
KeyEvent event = (KeyEvent) mediaButtonIntent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
if (event.getKeyCode() == 87) {
if (event.getAction() == 0) {
HandleMediaButton.sendButton(1);
}
if (event.getAction() == 1) {
HandleMediaButton.sendButton(6);
}
AudioFocusUtil.getInstance().setMasterMute(false);
}
if (event.getKeyCode() == 88) {
if (event.getAction() == 0) {
HandleMediaButton.sendButton(2);
}
if (event.getAction() == 1) {
HandleMediaButton.sendButton(6);
}
AudioFocusUtil.getInstance().setMasterMute(false);
}
return super.onMediaButtonEvent(mediaButtonIntent);
}
return false;
}
@Override // com.neusoft.carplaynew.base.BaseHandler
public void onHandleMessage(Message msg) {
switch (msg.what) {
case 1:
UserInput.getInstance().mediaNext();
return;
case 2:
UserInput.getInstance().mediaPrevious();
return;
case 3:
UserInput.getInstance().mediaPlay();
return;
case 4:
UserInput.getInstance().mediaPause();
return;
case 5:
UserInput.getInstance().mediaPlayOrPause();
return;
case 6:
UserInput.getInstance().mediaUp();
return;
default:
return;
}
}
@Override // com.neusoft.carplaynew.service.mediaservice.MediaInterFac
public void sendMediaState(long state, long curTime) {
LogUtil.m43D(TAG, "sendMediaState state ==" + state + "----curTime---" + curTime);
int playState = 0;
if (state == 1) {
playState = 3;
} else if (state == 2) {
playState = 2;
} else if (state == 0) {
playState = 1;
}
if (this.mSession == null) {
return;
}
PlaybackState states = new PlaybackState.Builder().setState(playState, curTime, 1.0f).build();
this.mSession.setPlaybackState(states);
LogUtil.m43D(TAG, "sendMediaState " + state + " curTime: " + curTime);
}
Дописываем сюда обработку KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
(85), отправляя HandleMediaButton.sendButton(5);
, добавляем флаги FLAG_HANDLES_MEDIA_BUTTONS | FLAG_HANDLES_TRANSPORT_CONTROLS
к MediaSession, добавляем в PlaybackState ACTION_PLAY_PAUSE | ACTION_SKIP_TO_PREVIOUS | ACTION_SKIP_TO_NEXT
собираем APK, проверяем: в CarPlay сессии MediaSession контролы разблокировались, кнопки заработали.
А как сделать эту кнопку рабочей в самой системе, в штатной мультимедиа и сторонних плеерах?
В системе есть компонент, который разруливает нажатия клавиш руля и называется NSInputService
. Проблема в том, что клавиши он получает через другой интерфейс AdapterAPI
- API от ECARX, и в этот интерфейс по какой-то причине не попадает обработка этой кнопки.
Исходя из этого всего, я сделал вывод, что проще будет реализовывать обработку кнопки сразу в своем приложении таким же обработчиком событий, как это делает CarPlay, согласуя с режимом кнопок из предыдущего раздела с переключением треков.
Это привело к тому, что NSInputService
больше не нужен вообще и подлежит удалению.
По поводу кнопки голосового помощника - она также обрабатывается через собственное приложение. Все же разрабатывать на Java новое приложение проще, чем копать smali в чужом, даже если его логика понятна и относительно проста.
Само приложение пока еще в процессе разработки и тестируется, актуально для владельцев Belgee X50 (других "прошитых" авто нет под рукой для проверки), вероятно через некоторое время станет доступно публично, возможно и opensource.
Навигаторы закрываются в случайные моменты времени
Поиски причин такого поведения начались с предположения связи закрытия с дорожной обстановкой: сложные развороты? USBGPS передал не те данные? Навигатор что то недополучил от кого-либо? Не хватает системных API?
Спустя буквально неделю и десяток случаев стало понятно, дело вообще не в дорожной обстановке: он мог закрыться реально в случайный момент, я мог просто стоять на месте.
В навигаторе ничего не происходит на момент закрытия.
Затем, как и в предыдущих попытках был запущен logcat и пойман момент закрытия:
# ... ничего не предвещает беды ...
06-13 17:07:16.417 813 885 W InputDispatcher: channel '9563238 PopupWindow:fc814b0 (server)' ~ Consumer closed input channel or an error occurred. events=0x9
06-13 17:07:16.417 813 885 E InputDispatcher: channel '9563238 PopupWindow:fc814b0 (server)' ~ Channel is unrecoverably broken and will be disposed!
06-13 17:07:16.450 813 885 W InputDispatcher: channel '22bb731 ru.dublgis.dgismobile/ru.dublgis.dgismobile.GrymMobileActivity (server)' ~ Consumer closed input channel or an error occurred. events=0x9
06-13 17:07:16.450 813 885 E InputDispatcher: channel '22bb731 ru.dublgis.dgismobile/ru.dublgis.dgismobile.GrymMobileActivity (server)' ~ Channel is unrecoverably broken and will be disposed!
06-13 17:07:16.454 813 8092 D DisplayManagerService: Display listener for pid 7776 died.
06-13 17:07:16.453 413 413 I Zygote : Process 7776 exited due to signal (9)
06-13 17:07:16.454 813 2207 I ActivityManager: Process ru.dublgis.dgismobile (pid 7776) has died: fore TOP
06-13 17:07:16.457 813 831 W libprocessgroup: kill(-7776, 9) failed: No such process
06-13 17:07:16.457 813 831 I libprocessgroup: Successfully killed process cgroup uid 10050 pid 7776 in 2ms
06-13 17:07:16.459 1125 1171 D carplayapp-CarPlayApplication: onProcessDied uid 10050 pid 7776
06-13 17:07:16.457 813 5159 I WindowManager: WIN DEATH: Window{9563238 u0 PopupWindow:fc814b0}
06-13 17:07:16.458 813 5159 W InputDispatcher: Attempted to unregister already unregistered input channel '9563238 PopupWindow:fc814b0 (server)'
06-13 17:07:16.458 813 2207 W ActivityManager: Scheduling restart of crashed service ru.dublgis.dgismobile/.KeepaliveService in 1000ms
06-13 17:07:16.464 813 2207 W ActivityManager: Force removing ActivityRecord{831400 u0 ru.dublgis.dgismobile/.GrymMobileActivity t1324}: app died, no saved state
06-13 17:07:16.469 813 6018 I WindowManager: WIN DEATH: Window{22bb731 u0 ru.dublgis.dgismobile/ru.dublgis.dgismobile.GrymMobileActivity}
06-13 17:07:16.469 813 6018 W InputDispatcher: Attempted to unregister already unregistered input channel '22bb731 ru.dublgis.dgismobile/ru.dublgis.dgismobile.GrymMobileActivity (server)'
06-13 17:07:16.479 451 1583 W SurfaceFlinger: Attempting to destroy on removed layer: AppWindowToken{ba74a7e token=Token{fe7b439 ActivityRecord{831400 u0 ru.dublgis.dgismobile/.GrymMobileActivity t1324}}}#0
# дальше запуск лаунчера от ActivityManager и пр.

Как будто его специально кто-то закрывает по kill -9
. И что делать с этой информацией?
Было выдвинуто ряд теорий, которые не подтвердились: например, мой взгляд привлек файл services.ecarx.jar
, в котором был некий "менеджер ресурсов" с подозрительными методами содержащими слова cleanApp
:
private void m37a(int i, int i2)
private void m37a(int i, int i2) {
if (this.f9s == null) {
this.f9s = IEcarxAppManager.Stub.asInterface(ServiceManager.checkService("ecarxappservice"));
}
if (this.f9s == null) {
return;
}
try {
for (EcarxProcessInfo ecarxProcessInfo : m31a(this.f9s.getAllAppProcessInfo(), i, i2)) {
if (ecarxProcessInfo != null) {
this.f9s.cleanApp(ecarxProcessInfo);
}
}
} catch (RemoteException e) {
}
m29b();
}
А в services.jar
сам ecarxappservice
с методом cleanApp.
public void cleanApp(EcarxProcessInfo processInfo)
public void cleanApp(EcarxProcessInfo processInfo) {
ProcessRecord proc;
if (processInfo == null || !checkCaller() || !this.mSystemReady || !this.mPolicyManager.isFeatureOn()) {
return;
}
synchronized (this.mAms) {
try {
ActivityManagerService.boostPriorityForLockedSection();
synchronized (this.mAms.mPidsSelfLocked) {
proc = this.mAms.mPidsSelfLocked.get(processInfo.pid);
}
if (proc != null && processInfo.processName.equals(proc.processName) && proc.setProcState > 2) {
this.mAms.removeProcessLocked(proc, false, false, "ecarx clean");
this.mPolicyManager.addCleanApp(processInfo);
}
} catch (Throwable th) {
ActivityManagerService.resetPriorityAfterLockedSection();
throw th;
}
}
ActivityManagerService.resetPriorityAfterLockedSection();
}
Быстро отыскав конфигурационный файл, который лежал неподалеку в /system/etc/app_ecarx_policy.xml
добавил в секцию whitelist
новые айтемы, состоящие имён пакетов навигаторов.
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<app-policys version="1">
... убрал Х строк за ненадобностью
<whitelist>
<item package="ecarx.launcher3" />
<item package="ecarx.xsf.mediacenter" />
</whitelist>
... убрал Х строк за ненадобностью
<prop>
<item name="ecarx.appservice.on" value="0" />
<item name="ecarx.heaplimit.num" value="1" />
<item name="ecarx.cachelimit.num" value="2" />
<item name="ecarx.freelimit.num" value="1" />
</prop>
... убрал еще Х строк за ненадобностью
</app-policys>
Проблема никуда не ушла, а позже я присмотрелся к коду: выполнение this.mAms.removeProcessLocked(proc, false, false, "ecarx clean");
и других методов менеджера вызвало бы сообщения в logcat, которые отсутствовали.
Затем я нашел ecarx.appservice.on
со значением 0 и !this.mPolicyManager.isFeatureOn()
, то есть этот менеджер изначально никогда не работал. Я проверил стоковый дамп, который до модификации специально обученными людьми, там тоже 0, видимо он реально никогда не работал.
В этот момент я взял паузу на пару недель на раздумья, а потом решил вернуться к вопросу. Оставалось сомнение в том, что кто-то его закрывает. Может он сам вылетает?
Обратился к разработчикам 2ГИС за помощью, скидывал логи откуда скажут, но они подтвердили худшие опасения - проблема не в навигаторе, в логах чисто.
Для надежности и чтобы поставить точку в этом вопросе скачал с сайта 2ГИС последний APK, пересобрал его, отключив в smali все вхождения Thread.setDefaultUncaughtExceptionHandler
, которые кушали бы stacktrace в себя, однако это не поменяло абсолютно ничего.

Может быть он закрывается по OOM/LMKd, как предположили разработчики 2ГИС?
Да маловероятно, памяти много, вряд ли она течет в системных компонентах (хотя и такие предположения были) и в 2ГИС, да и OOM явно спамил бы в logcat.
Закрывающийся Яндекс.Навигатор с теми же симптомами еще больше смуты вносит в эту теорию: они обязаны работать на каждом тапке у таксистов, а у нас здесь целых 4 Гб памяти.
В Android закрыть Foreground приложение, которое на переднем плане, с которым пользователь взаимодействует на данный момент - это крайняя мера нехватки памяти, там бы пострадали бы все, а не только навигатор.
Запилил на коленке свое приложение с логированием памяти в logcat каждые 10 секунд, простейший фрагмент с грепалкой ps -A
, activityManager.getMemoryInfo(memoryInfo)
и выводом с сортировкой по RSS, запустил запись logcat и поехал кататься.
Результаты неутешительные: памяти на момент закрытия свободной еще 1.1 Гб, флаг ActivityManager.MemoryInfo.lowMemory
всегда в отрицательном положении, то есть это точно не ООМ в классическом его понимании.
Опять промах.
Спустя еще несколько часов (ночью) я вспомнил, что существует еще Linux-мир, в котором есть dmesg
, они же логи ядра. Озабоченный вопросом, пошел в субботу с утра кататься и снимать логи. Причина там нашлась.
# dmesg | grep kill
[ 960.562231] -(3)[217:ecarx_mem_check]lowmemorykiller: process lgis.dgismobile , oom_adj:-15, rss memory:241377, memory exceed ecarx_mem_threshold:204800, kill it !!!!!!!
С хорошим настроением пошел по PID 217 узнавать кто это такой и что вообще себе позволяет, но это продлилось недолго.
# ps -A | grep 217
root 217 2 0 0 msleep 0 D [ecarx_mem_check]
// наелся и спит он значит, да?
# cat /proc/217/stack
[<0000000000000000>] __switch_to+0xc0/0xcc
[<0000000000000000>] msleep+0x28/0x38
[<0000000000000000>] ecarx_memory_check+0x60/0x230
[<0000000000000000>] kthread+0x118/0x138
[<0000000000000000>] ret_from_fork+0x10/0x30
[<0000000000000000>] 0xffffffffffffffff
Погрепал по /system
разделу кодовые слова "ecarx_memory_check"/"ecarx_mem_check"/"ecarx_mem_threshold", последнее по /sys
и /proc
разделам в поисках где ее поменять, но безрезультатно.
С осознанием, что скобочки []
в имени процесса не просто так и kthread
в стеке означает только одно: поток моей цели внутри ядра ОС, хорошее настроение быстро ушло. Его уже просто так не закрыть, нужно думать.
В первую очередь я попробовал поиграться с oom_adj
. Пробовал выставлять его в -17, когда обычно у приложений foreground он -15. Результата не принесло, навигатор был с тем же успехом закрыт с oom_adj -17.
Даже в случае, если это бы помогло, то oom_adj в Android динамически изменяется системой в зависимости от того, находится ли приложение на переднем плане или нет. Грубо говоря, свернув приложение на несколько десятков секунд его можно было бы потом найти закрытым, что тоже не устраивало. Делать гонку с системой за установку oom_adj выглядело суровым костылем, поэтому даже и не пробовал ставить более низкие значения.
Я как человек, который не особо дружит с низкоуровневым реверс-инжинирингом, привыкший по предыдущему опыту открывать в два клика код в JADX с безнадежностью распаковываю boot.img из дампа, грепаю там, не нахожу: взгляд затуманивается и я забываю о существовании команды file
, т.е. что сам kernel файл это LZ4 архив.
Скачиваю где-то и открываю IDA Pro, добавляю распакованный бинарник, отвечаю что попадется на вопросы с ответами в шестнадцатеричной сс. Нахожу блоки asm кода, где используется строки из Strings, перехожу, нажимаю F5 в надежде, что Hex-Rays мне всё расскажет, а в выводе другая одинокая функция которая делает совсем не то.
Думаю как так, ведь вот он код, вот условия в asm, как мне это посмотреть в Hex-Rays? Спустя еще пару часов до меня дошло, что Hex-Rays видимо не нашел прямых переходов к этой функции и поэтому декомпилировать ее код не считает нужным. Вероятно, там есть заклинание как это исправить, но я и так на пределе возможностей.
Пошел внимательнее разбирать asm код так, как есть.
Переменную ecarx_mem_threshold
точно искать дальше не имеет смысла, она там прибита на 0x32000
, что совпадает с записями в логе.

Я думал можно ли выпилить из ядра в целом запуск этого потока. Узнал, что существует опция конфигурации ядра CONFIG_MODULE_SIG
, и что она была отключена при компиляции. Таким образом, теоретически можно было бы заменить запуск потока на nop
-инструкции, решив проблему на корню.
Тут мое внимание привлекла ветка, которая заканчивается концом выполнения функции, что равнозначно завершению выполнения потока, то есть именно то, что я хотел.

Не поверив своим глазам, что task->comm
, он же proc_name
сравнивается со статически заданной строкой recovery
, я перепроверил еще раз инструкции.
Задача стала превращаться из найти что-нибудь в абсолютно конкретную цель: создать процесс с именем recovery
, который будет делать ничего и смотреть что будет.
Не долго думая, я попросил ИИ набросать код на Си, который будет делать ничего и закрываться, и дать команды по компиляции этого в статический бинарник под arm64.
#include <unistd.h>
#include <stdlib.h>
int main() {
sleep(100);
return 0;
}
aarch64-linux-gnu-gcc -static -o wait_and_exit wait_and_exit.c
Затем я переименовал его в recovery
, закинул в /dev
, выставил chmod +x
, запустил через шелл... и через непродолжительное время в dmesg объявилось.
# dmesg | grep -e lowmemory -e killer -e ecarx_mem
# dmesg | grep -e lowmemory -e killer -e ecarx_mem
[ 240.509386] (0)[216:ecarx_mem_check]lowmemorykiller: process recovery, is recovery
[ 240.509393] lowmemorykiller: recovery mode, exit
[ 240.509404] (0)[216:ecarx_mem_check]lowmemorykiller: ecarx_memory_check end
Мы постояли в пробке (в этот раз был за рулем не я), потратив на дорогу суммарно час, навигатор перестал закрываться. Это победа.
Осталось дело за малым: запилить приложение в виде АПК, которое будет запускать этот бинарник, впилить в приложение или бинарник проверки на то что процесс есть, и когда его нет или он перестает быть закрываться.
В это же время я нашел, что существует prctl(PR_SET_NAME, "recovery")
, который меняет динамически task->comm
, что мне нужен.
Имя бинарника теперь может быть любое.
Полный треш)
Как сказал один из обладателей Coolray Рестайлинг, раскопавший "волшебный" бинарник, который фиксит давнишнюю проблему.
Это приложение уже существует и владельцы Belgee X50 / Coolray Rest / Tugella Rest, мучающиеся от этой проблемы могут его установить и радоваться жизни.
Заключение
Поздравляю тех, кто дошел до конца, надеюсь вам было также интересно как и мне, когда я в этом разбирался.
Вопрос в заголовке остается актуальным: зачем производители усложняют пользование своей техникой? Понятное дело, что пользователь должен не смочь сломать стоковый вариант, но когда пользователь разлочил загрузчик и получил ROOT-права, то с какой целью эти дальнейшие "усложнения жизни" в виде, например, лаунчера в недавно запущенных приложениях?
Android-разработчики все читали гайд лайны, как нужно делать правильно: Context
не следует хранить в статических переменных класса, корректно обрабатывать onDestroy
, корректно обрабатывать согласно TargetAPI функционал.
В этом устройстве всё ровно наоборот: все стандартные приложения сервисы первым делом после запуска делают себя startForegroundService
с пустым уведомлениям (ну, а что, кто это когда увидит, раз уведомлений нет, верно?), сохраняют свой Context
или сам инстанс Service
в public static
поле, откуда его забирают все компоненты приложения.
А всё почему? Потому что срок работы устройства недолгий: после каждой поездки ГУ обесточивается до следующей поездки, то есть нет потребности в написать чистый код, нужно просто чтоб он работал здесь и сейчас. Наверное это не плохо, оно просто так есть и мы будем жить с этим.
Javian
В первый момент подумал как "срок эксплуатации", что вызвало ассоциацию с недавно прочитанной статьей, что китайские автопроизводители делают автомобили для местной банковской кредитной системы - автомобиль берется в кредит на 7 лет, а затем сдается как 50% взнос на кредит за новый авто. Поэтому автопроизводители применяют недолговечные материалы, вместо винтовых соединений делают заклепки, сварку и т.п. - никто автомобиль разбирать и ремонтировать не будет.