- PVSM.RU - https://www.pvsm.ru -
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 для коротких команд: снимок датчиков и список возможностей часов.
Штатный путь связи телефона и часов — 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 в памяти.
Входная точка на телефоне — экспортированный 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 не нужен ни для запроса, ни для ответа.
Первая версия установки была ещё одним 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'ом.
Интуитивно 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.apk
adb 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 FilesGit.
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 -r phone-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. Иначе отладка превращается в гадание.
Автор: sandvik71
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/android/460996
Ссылки в тексте:
[1] Источник: https://habr.com/ru/articles/1100140/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1100140
Нажмите здесь для печати.