DMR-клиент на Motorola LEX F10: что сломалось, когда мы опустили minSdk до 22
DMRConnect — наше Android-приложение для цифровой радиосвязи DMR. Оно подключается к BrandMeister и к любительским серверам по протоколу Homebrew (MMDVM), кодирует голос в AMBE+2 и превращает телефон или POC-рацию в абонента сети. До версии 2.2.0 приложение требовало Android 7.0, то есть minSdk 24, и нас это устраивало: телефонов старше почти не попадалось.
В октябре написал Алекс. У него Motorola LEX F10 — LTE-рация с сенсорным экраном 2017 года, в Россию её официально не возили. Внутри Android 5.1.1 с патчем безопасности от мая 2017-го, и обновлений уже не будет. Просьба была одна: поставить DMRConnect.
Ниже то, на что мы наступили по дороге. Почти всё это касается любого приложения, которое захочет жить на Android 5, так что, возможно, сэкономит кому-то вечер.
Что за устройство
Первое открытие было в списке пакетов. F10 продаётся под маркой Motorola Solutions, но в системе стоят com.sonim.launcher, com.sonimtech.setupwizard и com.sonimtech.spccservice: рацию делает Sonim. Позже это сыграло главную роль в истории с кнопкой PTT.
Процессор Qualcomm MSM8926, 32 бита, только armeabi-v7a. Экран 480x800 при 240 dpi, это 320x508 dp, примерно как у первых смартфонов. Сенсор есть, плюс кнопки PTT, Emergency, программируемая, громкость и аппаратные «Назад», «Домой», «Недавние».
Кнопки мы снимали не по adb, а своим мини-приложением «Проверка рации»: человек ставит его, по очереди жмёт кнопки, а отчёт с keyCode, scanCode, флагами и содержимым keylayout уходит нам на сервер. Его тоже пришлось опустить до API 22, но об этом ниже.
Отдельная сборка, а не общий minSdk
Самый простой путь — поставить minSdk 22 всему приложению. Мы так делать не стали. Основной аудитории это ничего не даёт, зато тянет за собой проверки версий по всему коду и, главное, ломает зависимости. Поэтому появился ещё один productFlavor рядом с direct и rustore:
create("legacy") {
dimension = "dist"
minSdk = 22
buildConfigField("boolean", "SELF_UPDATE", "true")
buildConfigField("String", "UPDATE_JSON", "\"legacy/version.json\"")
buildConfigField("String", "RUSTORE_PUSH_PROJECT", "\"\"")
proguardFile("proguard-legacy.pro")
externalNativeBuild { cmake { arguments += "-DNO_MD380=ON" } }
}
У legacy свой канал обновлений, legacy/version.json. Иначе старая рация, проверив обновления, скачала бы основную сборку, которая на ней не встанет.
Грабли первые: SDK RuStore
Первая же сборка упала на слиянии манифестов:
uses-sdk:minSdkVersion 22 cannot be smaller than version 23 declared in library
[ru.rustore.sdk:pushclient:7.5.0]
То же самое у ru.rustore.sdk:metrics. Все SDK RuStore, которые мы используем, требуют API 23. Можно было заглушить проверку через tools:overrideLibrary, но тогда библиотека упадёт уже на устройстве, в первом же вызове из API 23.
Сначала мы попробовали вынести код push-уведомлений в общую папку src/push и подключить её только двум основным флейворам. Kotlin 2.0.21 подхватывал лишний srcDir лишь у одного варианта из двух, Variant API тоже не помог. После третьей попытки бросили и сделали скучно: в legacy библиотека подключена как compileOnly, код компилируется, но в APK её нет.
"directImplementation"("ru.rustore.sdk:pushclient:7.5.0")
"rustoreImplementation"("ru.rustore.sdk:pushclient:7.5.0")
"legacyCompileOnly"("ru.rustore.sdk:pushclient:7.5.0")
Сервис push-уведомлений вырезается манифестом самого флейвора через tools:node="remove", R8 получает -dontwarn ru.rustore.sdk.**, а пустой RUSTORE_PUSH_PROJECT выключает инициализацию. В логе ART на 5.1 остаётся предупреждение о классе, который ссылается на отсутствующую библиотеку. Оно ни на что не влияет, этот класс никто не загружает.
Грабли вторые: вокодер не линкуется
На arm64 и x86_64 голос у нас кодирует md380_vocoder: это прошивка рации TYT MD-380, исполняемая через эмулятор ARM. Звук у неё заметно лучше открытых реализаций. Библиотека лежит готовыми .a, собранными под API 23, и под API 22 линковщик сказал:
ld.lld: error: undefined symbol: stdout
ld.lld: error: undefined symbol: stderr
Причина в bionic. До Android 6.0 stdout и stderr были макросами поверх массива __sF, а настоящими экспортируемыми символами libc они стали только в API 23. Код, собранный под новый уровень, ищет символы, которых в старой libc просто нет. Пересобрать md380 под 22 можно, но это отдельная история с эмулятором внутри, и на F10 она ничего не даёт: рация 32-битная, а md380 у нас только для 64 бит. Поэтому в CMake появилось условие:
if(EXISTS "${MD380_LIB}" AND NOT NO_MD380)
Legacy везде работает на mbelib-neo, и для этой сборки звук на приёме хуже. Мы прямо пишем это на сайте в списке отличий.
Грабли третьи: Let’s Encrypt и старый Android
Приложение собралось и запустилось на эмуляторе с образом android-22, и тут выяснилось, что оно не видит собственный сервер. Ни проверки обновлений, ни отправки отчётов, в логе Trust anchor for certification path not found. Браузер эмулятора на dmrconnect.ru показал то же самое.

Цепочка у сайта такая: сертификат домена, промежуточный YR2, ISRG Root YR, а над ними ISRG Root X1. В системное хранилище Android X1 попал только в 7.1.1. Раньше старые телефоны спасала перекрёстная подпись от DST Root CA X3, но тот корень истёк ещё в 2021 году, а Let’s Encrypt давно перестал отдавать эту цепочку.
Штатное решение, Network Security Config с собственными корнями, появилось только в Android 7.0. На 5.1 XML-конфиг просто игнорируется, поэтому корни пришлось подключать кодом. Мы положили ISRG Root X1 и X2 в assets/certs и собрали trust manager, который сначала спрашивает систему, а к своим корням обращается только если система отказала:
object LegacyTls {
fun install(ctx: Context) {
if (Build.VERSION.SDK_INT >= 24) return
runCatching {
val cf = CertificateFactory.getInstance("X.509")
val extra = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) }
val names = ctx.assets.list("certs").orEmpty()
if (names.isEmpty()) return
for (n in names) ctx.assets.open("certs/$n").use {
extra.setCertificateEntry(n, cf.generateCertificate(it))
}
val system = trustManager(null)
val own = trustManager(extra)
val both = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) =
system.checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
try { system.checkServerTrusted(chain, authType) }
catch (e: CertificateException) { own.checkServerTrusted(chain, authType) }
}
override fun getAcceptedIssuers(): Array<X509Certificate> =
system.acceptedIssuers + own.acceptedIssuers
}
val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf(both), null) }
HttpsURLConnection.setDefaultSSLSocketFactory(ssl.socketFactory)
SSLContext.setDefault(ssl)
}
}
private fun trustManager(ks: KeyStore?): X509TrustManager =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
.apply { init(ks) }.trustManagers.filterIsInstance<X509TrustManager>().first()
}
Вызывается это первой строкой в Application.onCreate. Проверку мы не ослабляем: цепочка по-прежнему должна сходиться к настоящему корню, просто корней становится на два больше. Прежде чем класть файлы в APK, мы прогнали живую цепочку сайта через openssl verify с этим X1 в качестве единственного корня, она сошлась. Тот же класс пришлось скопировать в «Проверку рации», иначе отчёт о кнопках с F10 не доходил до сервера.
Мелочи, которые нашёл lint
После этого сборка честно прогнала lint и выдала пачку NewApi. Почти все они безобидные, но на 5.1 каждая роняет приложение. getSystemService(Class) появился в API 23, меняем на вариант со строкой. AtomicInteger.updateAndGet - это API 24, переписываем на цикл с compareAndSet. getForegroundService для виджета - API 26, ниже обычный getService. Каналы уведомлений создаём только с API 26. Плитка в шторке получила @RequiresApi(24), на старых версиях её просто нет.
Один компромисс неприятный, и о нём стоит сказать отдельно. Пароли серверов мы храним зашифрованными ключом из Android Keystore, а AES-GCM там появился с API 23. На Android 5.1 пароли лежат без этого слоя, только в закрытых настройках приложения. Для рации, у которой нет root и которая не уходит из рук владельца, мы сочли это приемлемым, но молчать об этом было бы нечестно.
Ещё одна деталь касается самообновления. Установщик пакетов на Android до 7.0 не принимает content:// от FileProvider, поэтому legacy качает APK в externalCacheDir и отдаёт установщику Uri.fromFile.
Кнопка PTT, которую приложению не отдают
Отчёт «Проверки рации» с F10 сначала озадачил. В gpio-keys.kl у кнопки PTT скан-код 149, ему соответствует keyCode 228. Но приложение не видит честного нажатия и отпускания. Приходит сразу долгое нажатие с FLAG_LONG_PRESS, а за ним отпускание с FLAG_CANCELED. Кнопка Emergency (260) ведёт себя так же, причём нажатие приходит с repeat 51, то есть с большим опозданием. Держать по такой кнопке передачу нельзя.
Разгадка нашлась не в документации Motorola, которой в открытом виде почти нет, а в обсуждениях прошивок Sonim. В репозитории ndoo/sonim-xp8-gsi разбирали штатную прошивку XP8, и там видно, как это устроено. Прошивка забирает PTT себе и рассылает широковещательные сообщения com.sonim.intent.action.PTT_KEY_DOWN и PTT_KEY_UP тому приложению, которое пользователь выбрал в настройках программируемых клавиш. В этот список попадают только приложения, у которых приёмник объявлен в манифесте. У Алекса там стоял штатный POC-клиент, поэтому нам и доставались одни огрызки.
Приёмник получился в десяток строк:
class SonimPttReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val down = when (intent.action) {
"com.sonim.intent.action.PTT_KEY_DOWN" -> true
"com.sonim.intent.action.PTT_KEY_UP" -> false
else -> return
}
(context.applicationContext as? StackDmrApp)?.container?.handleRadioPtt(down, "Sonim")
}
}
В профиле устройства для LEX F10 мы специально не назначаем клавишу 228 по умолчанию, иначе на прошивке, которая отдаёт и клавишу, и сигнал, одно нажатие сработало бы дважды. Проверить всё это на эмуляторе можно через adb shell am broadcast -a com.sonim.intent.action.PTT_KEY_DOWN, но мы не были уверены, что прошивка F10 2017 года шлёт эти сигналы так же, как XP8. Подтвердить мог только Алекс.
Как проверяли без рации в руках
Эмулятор собрали под F10: образ android-22, 480x800, 240 dpi, аппаратные кнопки навигации. На этом разрешении сразу вылезли переносы, которых на телефонах не видно. Для голоса у нас есть тестовый сервер miniDMR с «попугаем» на группе 9990: он записывает передачу и проигрывает её обратно. Попугай удобен тем, что проверяет сразу оба направления. Логи в релизной сборке R8 вырезает, поэтому факт передачи мы смотрели не в logcat, а в журнале вызовов на самом сервере.

Сборку отправили Алексу вместе с обновлённой «Проверкой рации», где для F10 появился свой набор шагов под его кнопки. Алекс выбрал DMRConnect в программируемых клавишах, и PTT заработала, в том числе при заблокированном экране. На это мы особенно не рассчитывали.
Что в итоге
Legacy вышла вместе с 2.2.0, для Android с 5.1.1 по 6. От основной сборки она отличается тремя вещами. Нет вокодера md380, звук на приёме хуже. Нет push-уведомлений RuStore. Обновляется она сама с нашего сайта по своему каналу, а не через магазин. Основная сборка осталась на minSdk 24, в неё попали только проверки версий и приёмник Sonim.
Если подводить итог, Android 5 оказался не таким страшным, как мы ждали. Код почти не пришлось трогать. Основное время ушло на зависимости, которые молча требуют API 23, на корень Let’s Encrypt и на кнопку, которую производитель отдаёт не через KeyEvent. Последнее, к слову, касается не только F10: так же PTT устроена на других рациях Sonim, и DMRConnect на них должен заработать без отдельной доработки. Проверить это мы пока не можем, таких раций у нас нет.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.