Периодически читая Хабр, я еще не находил статей, описывающих внутренний мир штатных головных устройств (далее — ГУ) на базе 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-backgroundLSPosed 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 поле, откуда его забирают все компоненты приложения.
А всё почему? Потому что срок работы устройства недолгий: после каждой поездки ГУ обесточивается до следующей поездки, то есть нет потребности в написать чистый код, нужно просто чтоб он работал здесь и сейчас. Наверное это не плохо, оно просто так есть и мы будем жить с этим.