Oct 25, 2026 · @Andrei
Я научил телефон ставить свежую сборку на часы TicWatch Pro 5 Enduro по Bluetooth, без adb на самих часах. Одна команда на ПК собирает оба приложения и доставляет APK на часы примерно за 2,5 минуты. Ниже — архитектуру моста и пять граблей Android 12–14, каждая из которых стоила мне вечера.
Откуда задача. Wear OS-часы отлаживаются через «Wireless debugging» по Wi-Fi. TicWatch выключает Wi-Fi вместе с adb, как только его снимают с руки. Типичная картина: adb devices показывает часы, а adb shell висит вечно, или mDNS видит устройство, но connect отказывает. Помогает только надеть часы или поставить их на зарядку.
При этом телефон и часы постоянно держат связь по Bluetooth — по ней и так ходят данные пульса. Телефон же надёжно сидит на adb по USB. Отсюда идея: команда идёт на телефон, а телефон пересылает её на часы по тому же каналу, что и в продакшене. Заодно отладочный путь проверяет реальную связку, а не параллельную.
Проект целиком
Цель проекта — связать часы с родным для Android Google Health (Health Connect), чтобы нормально следить за своим состоянием: пульсом, сном, SpO2, температурой, активностью. Фирменное приложение Mobvoi передаёт туда далеко не всё, поэтому я пишу свою связку. Часы собирают данные датчиков, а телефон записывает их в Health Connect и в собственную историю. Механизм обновления, о котором эта статья, — побочный продукт: без него разрабатывать часть для часов было бы мучением. Это монорепозиторий из четырёх независимых приложений:
|
Модуль |
Стек |
Что делает |
|---|---|---|
|
|
Kotlin, Wear OS |
Снимает пульс, SpO2, температуру, сон через Health Services и вендорские сенсоры; ставит присланные APK через |
|
|
Kotlin, Android |
Принимает данные, пишет в Health Connect, держит локальную историю, раздаёт обновления на часы |
|
|
Spring Boot, Kotlin |
REST API для синхронизации |
|
|
React, TypeScript |
Веб-дашборд |
Общего Gradle-модуля у телефона и часов нет: контракт путей сообщений (DataLayerPaths.kt) дублируется и синхронизируется вручную. Тестовое железо: Pixel 6 Pro и TicWatch Pro 5 Enduro на Android 13.
Архитектура моста
На adb нужен только телефон; до часов команда и APK идут по тому же Bluetooth-каналу, что и данные датчиков.
[embed: node/93247ebb-7de5]Результат установки возвращается по тому же мосту и оказывается в logcat телефона. Рядом с установкой живёт DebugBridgeReceiver для коротких команд: снимок датчиков и список возможностей часов.
Грабля 1. Data Layer врёт, что часов нет
Штатный путь связи телефона и часов — Wearable Data Layer из Play Services (MessageClient, ChannelClient). У меня NodeClient.connectedNodes честно возвращал часы с isNearby=true, а sendMessage падал с TARGET_NODE_NOT_CONNECTED (код 4000). При этом dumpsys bluetooth_manager показывал живое Bluetooth-соединение.
Первая гипотеза — на часах старая сборка без обработчика — оказалась неверной: ошибка возникала ещё на стороне телефона. Настоящая причина: часы были спарены через приложение Mobvoi, а не через гугловский Wear OS. Bluetooth-бонд есть, а узла Data Layer, который принимает сообщения, нет. Перепарить через Wear OS тоже не вышло: компаньон com.google.android.wearable.app падал в цикле.
Решение — свой транспорт поверх классического Bluetooth RFCOMM. В обоих приложениях появился интерфейс DeviceLinkTransport с двумя реализациями и декоратором FallbackDeviceLinkTransport. На каждой стороне работает foreground-сервис BluetoothLinkServerService с циклом accept на фиксированном UUID. Фрейм простой: путь, длина, пейлоад, а в ответ один байт ack:
watch.createRfcommSocketToServiceRecord(TransportProtocol.SERVICE_UUID).use { socket -> socket.connect() val out = DataOutputStream(socket.outputStream) out.writeUTF(path) out.writeLong(length) source.open().use { it.copyTo(out) } out.flush() // Wait for the receiver's one-byte ack before closing - otherwise // the socket can tear down before the payload is fully delivered. socket.inputStream.read()}
Тут сразу два неочевидных момента:
-
Без ack данные теряются. Первая версия закрывала сокет сразу после
flush(), и получатель ловилbt socket closed, read return: -1.flush()не означает, что байты улетели в эфир. -
Порядок фолбэка важен. Сначала основным был Data Layer, но
MessageClient.sendMessage()считается успешным, когда Play Services принял сообщение, а не когда его получили. Сообщения молча пропадали, и до фолбэка дело не доходило. Теперь Bluetooth основной: его успех подтверждён ack от получателя.
RFCOMM на этой связке ещё и нестабилен: connect() иногда сталкивается с переходом радио в sniff mode. Поэтому пейлоад передаётся как переоткрываемый PayloadSource, и неудачная попытка повторяется до трёх раз без буферизации 26 МБ APK в памяти.
Грабля 2. Broadcast от adb до приложения не доходит
Входная точка на телефоне — экспортированный DebugBridgeReceiver. Сразу после запуска приложения am broadcast -a <action> работал, а через несколько минут начинал молча пропадать. В logcat — Background execution not allowed.
Ограничения фонового исполнения режут неявные broadcast’ы даже для ресивера из манифеста, если приложение в фоне. Явный компонент под них не попадает:
adb shell am broadcast -n org.sand.ticwatch.phone/.datalayer.DebugBridgeReceiver \ -a org.sand.ticwatch.phone.action.DEBUG_PING_WATCH_SENSORS
Сразу после установки добавляйте --include-stopped-packages: новое приложение находится в состоянии stopped и broadcast’ов не получает. Пинг датчиков и запрос возможностей часов так и работают: ответ приходит по тому же мосту и пишется в logcat телефона. Часам adb не нужен ни для запроса, ни для ответа.
Грабля 3. Передачу молча замораживает freezer
Первая версия установки была ещё одним action в том же DebugBridgeReceiver: goAsync(), корутина, передача APK. am broadcast блокируется до pendingResult.finish(), а 26 МБ по классическому Bluetooth идут 60–120 секунд и дольше. Дольше, чем короткое окно, которое система даёт ресиверу.
Попытка вынести передачу в foreground-сервис из ресивера упирается в Android 12+: startForegroundService() из фонового BroadcastReceiver запрещён (BFGS denied). А если оставить голый goAsync(), ещё хуже: как только ресивер вернул управление, у процесса не остаётся активных компонентов. Cached apps freezer замораживает его посреди передачи — без краша, без лога, просто тишина.
Решение: отдельный экспортированный WatchAppInstallService и запуск его напрямую из adb. На UID shell эти ограничения не распространяются:
adb shell am start-foreground-service \ -n org.sand.ticwatch.phone/.datalayer.WatchAppInstallService \ --es apkPath /data/data/org.sand.ticwatch.phone/files/watch-app-debug.apk
Сервис вызывает тот же WatchAppManager, что и кнопка «Обновить приложение на часах» в настройках, так что отладочный путь совпадает с пользовательским. Два запуска подряд перемешивали пакеты в сокете, поэтому установки теперь сериализованы Mutex‘ом.
Грабля 4. Приложение не может прочитать собственную папку
Интуитивно APK хочется положить в /sdcard/Android/data/<пакет>/files/: папка приложения, права не нужны. Но если этого каталога ещё не было, adb push создаёт его сам, с владельцем UID 2000 (shell). Дальше FUSE scoped storage отдаёт приложению EACCES, а пересоздать каталог через run-as нельзя.
Надёжный путь для debug-сборки — внутреннее хранилище через /data/local/tmp и run-as:
adb push watch-app-debug.apk /data/local/tmp/w.apkadb shell "chmod 644 /data/local/tmp/w.apk; run-as org.sand.ticwatch.phone \ cp /data/local/tmp/w.apk /data/data/org.sand.ticwatch.phone/files/watch-app-debug.apk"
Два полезных мелочи. Не пайпите файл через adb exec-in ... cat >: у меня это однажды молча обрезало APK, так что размеры стоит сравнивать. В Git Bash на Windows выставляйте MSYS_NO_PATHCONV=1, иначе MSYS превратит /data/local/tmp в путь внутри C:\Program Files\Git.
Грабля 5. Диалог установки на часах не появляется
APK доходил до часов, но на экране ничего не появлялось. Телефон минутами ждал и в итоге получал голый FAILED. Причин оказалось три.
Background Activity Launch. PackageInstaller для несистемного приложения присылает STATUS_PENDING_USER_ACTION с интентом подтверждения. Мой InstallResultReceiver делал startActivity() из фона, и Android 13 его молча отбрасывал. В logcat часов оставалась строка Background activity start ... allowBackgroundActivityStart: false, а установка ждала вечно. Теперь InstallConfirmationPrompt показывает уведомление с высокой важностью, и по тапу открывается диалог. С appops set org.sand.ticwatch.watch SYSTEM_ALERT_WINDOW allow диалог всплывает сам (разовая настройка).
Ack после установки, а не после приёма. Часы подтверждали сокет только когда установка заканчивалась. Телефон уходил в таймаут, и логика повторов из грабли 1 заново гнала все 26 МБ. Теперь WatchMessageRouter отвечает ack, как только файл сохранён, а устанавливает в отдельной корутине. Итог приходит отдельным сообщением на /app/install/result: OK или FAILED: <причина>.
Успех раньше времени. PackageInstallerApkInstaller завершал continuation сразу после session.commit(), а не по реальному результату из InstallResultReceiver. Быстрый FAILED означал лишь то, что упал сам commit(). Мораль: асинхронный API установки нужно доводить до финального статуса, иначе вся диагностика врёт.
И ещё одно ограничение подхода: часы со старой сборкой, в которой нет этих исправлений, сами себя через мост не обновят. Первую установку придётся сделать через adb.
Одна команда на весь цикл
Всё описанное собрано в deploy.ps1 (и обёртку deploy.bat) в корне репозитория. Последовательность:
-
assembleDebugдля обоих приложений. -
SHA-256 обоих APK сравнивается с
build/deploy-state.json. Если ничего не изменилось, скрипт заканчивает работу. -
adb install -rphone-app на то устройство, которое не часы.installDebugот Gradle ставит на все подключённые устройства сразу, а это здесь мешает. -
APK часов кладётся во внутреннее хранилище телефона через
run-as(грабля 4). -
Запускается
WatchAppInstallService(грабля 3). -
Скрипт ждёт в logcat телефона
Sent watch-app install requestилиFailed to install watch-app, по умолчанию до 600 секунд. Хэш часов сохраняется только при успехе.
Флаги: -Force (игнорировать хэши), -NoWait, -WaitTimeoutSec. Скрипт совместим с Windows PowerShell 5.1, поэтому в нём нет ??, тернарников и &&. На практике цикл занимает около 2,5 минут. Всё это время идёт передача по Bluetooth без индикации прогресса в логе, так что долгая тишина — норма, а не зависание.
Ограничения и выводы
Полностью тихой установки не будет: для несистемного приложения Android всегда показывает диалог подтверждения и на установку, и на удаление. Обойти его можно только статусом системного/привилегированного приложения или MDM. Один тап по часам остаётся.
Ещё что стоит знать:
-
Скорость — уровень классического Bluetooth: 26 МБ идут от полутора до нескольких минут.
-
На часах должен работать
BluetoothLinkServerService. Если процесс убит, транспорт падает в фолбэк на нерабочий Data Layer. -
Переустановка phone-app посреди передачи молча её убивает — скрипт просто нужно перезапустить.
-
Залипшее радио (
EXIT_SNIFF_MODE: COMMAND_DISALLOWEDприconnect()) лечится выключением и включением Bluetooth на часах.
Главный вывод: почти каждая из пяти проблем проявлялась как тишина — ни краша, ни исключения, ни строки в логе приложения. Ограничения фонового исполнения в Android 12–14 отказывают вежливо и тихо. Поэтому каждому шагу нужен явный ответ: ack на сокете, финальный статус установки, строка результата в logcat. Иначе отладка превращается в гадание.
ссылка на оригинал статьи https://habr.com/ru/articles/1100140/