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

Попытки будут отсортированы по степени погружения в процесс, начиная с легких и заканчивая погружением в ассемблер О_о.

  1. Меню недавних приложений перегружено лишними Активити

  2. Установка PowerAMP перезагружает систему

  3. Интерфейс всех сторонних приложений мелкий

  4. Кнопки на руле

  5. Навигаторы закрываются в случайные моменты времени

Меню неда��них приложений перегружено лишними Активити

Начинаем с легкого.
Декомпилируем лаунчер 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
нельзя просто так взять (с) и сделать 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 режима, явно можно между ними переключаться, можно также забиндить любую другую кнопку на переключение режимов, ну или лонг-тап по кнопке.

Там ниже свитч есть, просто в превью студии он уехал за границы экрана. На авто помещается из за маленького DPI.
Там ниже свитч есть, просто в превью студии он уехал за границы экрана. На авто помещается из за маленького DPI.

Есть мысли, что можно впилить в штатные приложения минимальную поддержку 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 сессию. И да, он ее создает, но все контролы заблокированы.

MediaSession от приложения AppleCarPlay.apk
MediaSession от приложения AppleCarPlay.apk

Странно, но может там есть код для обработки кнопок, несмотря на то что контролы заблокированы: и да, он тоже есть. Только почему-то 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 в себя, однако это не поменяло абсолютно ничего.

Диалог с комьюнити-менеджером 2ГИС в Телеграме
Диалог с комьюнити-менеджером 2ГИС в Телеграме

Может быть он закрывается по 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, что совпадает с записями в логе.

Переименовывал регистры по пути, совместно с DeepSeek, который подсказывал что находится по оффсетам структур
Переименовывал регистры по пути, совместно с DeepSeek, который подсказывал что находится по оффсетам структур

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

Тут мое внимание привлекла ветка, которая заканчивается концом выполнения функции, что равнозначно завершению выполнения потока, то есть именно то, что я хотел.

Цикл обработки процессов в ecarx_memory_check
Цикл обработки процессов в ecarx_memory_check

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