Обновляем приложение на Wear OS-часах без adb по Wi-Fi: мост через телефон

—

от автора

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

Модуль

Стек

Что делает

watch-app

Kotlin, Wear OS

Снимает пульс, SpO2, температуру, сон через Health Services и вендорские сенсоры; ставит присланные APK через PackageInstaller

phone-app

Kotlin, Android

Принимает данные, пишет в Health Connect, держит локальную историю, раздаёт обновления на часы

backend

Spring Boot, Kotlin

REST API для синхронизации

webapp

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) в корне репозитория. Последовательность:

  1. assembleDebug для обоих приложений.

  2. SHA-256 обоих APK сравнивается с build/deploy-state.json. Если ничего не изменилось, скрипт заканчивает работу.

  3. adb install -r phone-app на то устройство, которое не часы. installDebug от Gradle ставит на все подключённые устройства сразу, а это здесь мешает.

  4. APK часов кладётся во внутреннее хранилище телефона через run-as (грабля 4).

  5. Запускается WatchAppInstallService (грабля 3).

  6. Скрипт ждёт в 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/