Это история про два бага, которые притворялись одним, про десять теорий,
которые были правдоподобны и неправильны, и про один байт 0x0D,
которого в кадре не оказалось.
Проект в итоге называется LegacyAir. Внутри — два независимых модуля: keep-alive в SpringBoard и патч строки в BTServer.
Что уже было до нас
Amy (asentientbot/ios-6-pods-hack) решила задачу, которую на iPhone 4S с AirPods 2 обычно и видят: iOS 6 закрывает A2DP, когда ничего не играет, AirPods считают линк idle и уходят.
Фикс — крутить тишину, пока подключено любое Bluetooth-устройство. Девяносто строк, MP3 внутри бинарника, новый плеер каждые 50 секунд, до трёх штук с перекрытием:
-(void)refresh
{
if(!self.active)
{
self.players.removeAllObjects;
return;
}
AVAudioPlayer* player=[AVAudioPlayer.alloc initWithData:self.data error:nil].autorelease;
player.play;
[self.players addObject:player];
if(self.players.count>2)
[self.players removeObjectAtIndex:0];
}
На телефоне этого часто хватает. На iPod без телефонного стека и с Pro 3 — нет. Отвал идёт во время трека. Это уже не idle после паузы.
Первая мысль была инженерно правильная и фактически ложная: «улучшим keep-alive, и отвалы пропадут».
Подозрение №1: тишина недостаточно непрерывна
Оригинал казался грубым. MP3, таймер на 50 секунд, три плеера. Мы переписали «как надо»:
| Было | Стало (и это сломалось) |
|---|---|
MP3 из silence.h | LPCM WAV из нулей в памяти |
| Новый плеер каждые 50 с | Один плеер, numberOfLoops = -1 |
Нет setActive: | setActive:YES/NO из SpringBoard |
| BOOL «наушники есть» | Set адресов устройств |
LPCM звучал разумно: нет кодека, нет нагрузки на A4, loop по сэмплам.
Собрать это на современном Xcode — отдельный квест. Текущий SDK не отдаёт armv7
и тащит AVAudioPlayer в AVFAudio, которого на iOS 6 нет.
Пришлось писать .tbd-стабы с install-name как в 2012-м и линковать
-nostdlib.
Твик встал. Примерно через 35 секунд наушники отключились и подключились снова. Трек ещё играл.
Keep-alive тут ни при чём. Он не вызывал этот цикл и не лечил его.
Как вообще смотреть, что происходит
На джейлбрейкнутом iPod touch 4 почти нет userland. Нет grep,
head, нормального syslog. SSH — dropbear, которому нужны
HostKeyAlgorithms +ssh-rsa.
Полезное, что удалось включить:
idevicesyslogс Mac по USB. Иногда умирает посреди сессии.- Недокументированный verbose BTServer:
DiagnosticMode = true
DefaultLevel = "Debug" ← именно строка
StackDebugEnabled = "on"
Файл: /var/mobile/Library/Preferences/com.apple.MobileBluetooth.debug.plist.
Если положить в DefaultLevel integer 5, демон молча игнорирует:
он проверяет CFGetTypeID == CFStringGetTypeID. Эта ошибка стоила круга
«почему логов нет».
3. HCI packet log. Ключ HCITraces в plist не работает —
это бит внутреннего флага. Чтобы писался
/var/mobile/Library/Logs/BTServer_hci.pklg, пришлось из dylib вызвать
штатную процедуру демона (0x00077acc) секунд через 15 после старта стека.
Если вызвать только «открыть файл» — получится валидный пустой pklg.
Ещё одна шишка.
Текстовый лог говорит «disconnect 10719 / 10927». Это внутренние коды. Они не говорят, кто повесил трубку.
Галерея тупиков
Каждая теория ниже была логичной. Каждая проверялась на устройстве. Ни одна не была причиной отвалов.
Вычеркнуть Hands-Free из plist
com.apple.MobileBluetooth.devices.plist, убрать ServiceHandsfree.
Файл правился. После реконнекта демон перезаписывал его сервисами с эфира.
Имя AirPods Pro 3 … - Find My тоже казалось подозрительным.
Это стандартное имя для не-Apple хоста. На Switch 2 то же самое.
Хуки в SpringBoard
ObjC-swizzle на connect / «поддерживает ли сервис». Алерт «filter on» появлялся.
На HFP селекторы не стреляли: строки BTM: в логе SpringBoard — зеркало
событий демона, не решение. Профили выбирает BTServer.
Заглушить HFP в демоне (nohfp.c)
Патч HandsfreeGateway::getRFCOMMChannel → -1. По пути:
- sandbox BTServer не пускает в
/var/mobile; MSHookFunctionв демоне нет: MobileLoader есть,libsubstrateне mapped;memcpyкомпилируется в__memcpy_chk, которого iOS 6 может не экспортировать — копировать байты руками;- dylib без filter-plist грузится во все процессы. Удалить только plist — не отключить твик, а расширить его. Выглядит как «патч ничего не дал».
Отвалы те же. Наушники сами открывают HFP. Резать канал бессмысленно: надо на него отвечать.
«iPod рвёт, потому что нет телефонии»
По таймингу (~10 с) похоже на правду. По механизму — нет. HCI потом показал:
Disconnect шлют наушники (0x13 / 0x14).
iPod в этих циклах HCI Disconnect не отправляет.
Siri на устройстве, где Siri нет (siri.c)
В текстовом логе: SLC → что-то про +XAPL → ровно 10.03 с → разрыв.
Гипотеза: AT+APLSIRI?, getSiriStatus на iPod возвращает мусор,
протокол ждёт 1 или 2.
Патч шесть байт: movs r0, #1; bx lr. Встал. Отвалы остались.
Уточнение: getSiriStatus зовётся внутри обработки
+XAPL, не как отдельная команда. Закрыли не тот вопрос.
Кодек AAC сломан, давайте SBC
В логе правда странно:
SetAacDataRate AAC frame len: 1 bytes, num frames: 882,
bitrate: 256 kb/s, rtp intr: 27
Планировщик AAC в iOS 6 путает байты и кадры. RTP timestamp ползёт ~879 единиц в секунду вместо 44100. Выглядит как причина, почему наушники бесятся.
sbc.c заставил chooser взять SBC. Лог: SBC выбран. Звука нет.
Отвалы есть.
noaac.c отверг AAC на разборе endpoint. Снова тишина. SBC из того же трейса
ffmpeg -f sbc декодирует в нормальную музыку. Наушники с этого источника SBC
просто не играют. AAC оставлять.
Quirk noAac в pincode_defaults.db для OUI AirPods не матчился.
Ещё проверяли с выключенным Wi‑Fi, с надетыми наушниками, с полностью снятым твиком.
Цикл тот же. Ошибки mediaserverd (sample rate is 0) следуют
за disconnect, не предшествуют ему.
Кто повесил трубку
Когда pklg наконец стал непустым, картина перевернулась.
Каждый teardown — HCI Disconnection Complete:
0x13— Remote User Terminated Connection0x14— Remote Device Terminated Due To Low Resources
Инициатор — наушники. Медиа-пакеты идут почти до конца (последний audio за 13–18 мс
до разрыва). Отказ L2CAP PSM 0x001F (ATT) они игнорируют.
«Pending» на L2CAP — норма, не отказ.
Вопрос сменился с «почему iPod отключает» на «чем наушники недовольны».
Подозрение №2: iPod молчит на Apple AT
HFP после открытия RFCOMM — обычный SLC. AT+BRSF, AT+CIND,
AT+CMER — iPod отвечает OK сразу.
Потом Apple-расширение:
AT+XAPL=<id>,<features>\r
Сначала парсер трейса терял исходящие кадры (фрагменты RFCOMM,
заголовок перед текстом). Гипотеза была: iPod не отвечает на XAPL.
Поиск байт XAPL=iPhone в pklg: отвечает пять раз из пяти.
+XAPL=iPhone,15
OK
Сразу после этого наушники шлют уровень батареи:
AT+IPHONEACCEV=1,1,76
На это iPod не отвечает ничем. Ни OK, ни ERROR.
В текстовом логе демона этой команды нет вообще. В сыром HCI она есть.
Это уже не «забыли обработчик». Это «строка не дошла до парсера».
Байт, которого нет
Спецификация AT: команда заканчивается CR, 0x0D.
iOS 6 копит байты и ищет CR (memchr на 0x0d). Без CR строки нет.
Рядом в том же трейсе:
AT+XAPL=… 0D … ← есть CR, iPod отвечает
AT+IPHONEACCEV=1,1,76 … ← кадр обрывается на «76»
Все остальные AT-линии с 0x0D. Эта — нет. Не политика протокола,
а сломанная сборка одного пакета: забыли \r, обрезали буфер,
упёрлись в границу RFCOMM.
Дальше по часам, три цикла подряд:
- Наушники ждут ответ 10.035 / 10.041 / 10.030 с — классический AT timeout.
- Закрывают HFP, открывают снова, снова
XAPL→OK, сноваIPHONEACCEVбез CR. - После второго круга рвут весь ACL.
Снаружи: отвал каждые ~28–55 секунд (два таймаута плюс A2DP/AVRCP retry). Похоже на «таймер прошивки на 45 секунд», если не смотреть HCI.
t ≈ 0.0 HFP, SLC, все OK
t ≈ 0.3 AT+XAPL → +XAPL=iPhone,15 OK
t ≈ 0.4 AT+IPHONEACCEV=1,1,76 без CR, iPod молчит
t ≈ 10.4 pods закрывают HFP
t ≈ 10.5 reconnect, то же самое
t ≈ 20.5 второй timeout → ACL down (remote)
Почему они шлют IPHONEACCEV вообще: хост в +XAPL=iPhone,15
объявил feature bitmask. 15 = 0b1111, среди бит —
battery reporting. Аксессуар репортит заряд, потому что его попросили.
Учить парсер iOS 6 глотать строку без CR — хрупкий патч thumb-2. Проще не просить заряд.
Фикс отвалов: две цифры в строке
+XAPL=iPhone,15 → +XAPL=iPhone,00
Маска ноль при любой нумерации бит выключает battery reporting. Команду не шлют. Ждать нечего. HFP-цикл исчезает.
Первая версия патчила фиксированный адрес 0x00148e87 в конкретной сборке
демона. Работает только на 10B500 / MobileBluetooth 85.10.1.
Финальная ищет префикс в __cstring загруженного BTServer и переписывает
две цифры после запятой. Адрес не нужен. Нет строки — демон не трогаем.
static const char replyPrefix[]="+XAPL=iPhone,";
static const uint8_t maskPatch[]={'0','0'};
if(memcmp(candidate, replyPrefix, PREFIX_LENGTH)==0
&& isDigit(mask[0]) && isDigit(mask[1]))
{
writeData(mask, maskPatch, 2);
}
Писать через vm_protect(... VM_PROT_COPY), байты копировать циклом.
Inject только в BTServer, линк только libSystem. Sandbox не пускает в
/var/mobile — лог в syslog.
Цена: iPod не знает процент батареи. Показывать его всё равно некуда. На iPhone обнулять всю маску уже грубо — модуль для хостов без нормального ответа на follow-up.
После этого: минуты без teardown. Раньше — каждые полминуты.
Отвалы закрыты. История на этом не кончилась.
Второй баг: тишина после скипа
Линк живой. После конца трека, скипа, иногда seek:
- наушники подключены;
- звука нет;
- play/pause не помогает;
- лечит только физический реконнект (кейс / тумблер Bluetooth).
Выключили keep-alive, чтобы не путать. Симптом остался. Значит это не «твик мешает», а отдельная дыра в A2DP.
HCI без keep-alive: iPod продолжает слать музыку (пакеты даже крупнее,
чем когда звук был слышен). AVRCP SetAbsoluteVolume 0x4C каждый раз
ACCEPTED. На скипе — дыра 0.3–0.9 с в media packets без AVDTP
Suspend/Start. После дыры декодер наушников залипает до reconnect.
Именно это изначально лечил Amy: не давать микшеру замолчать на стыке треков. Наш keep-alive в этот момент keep-alive’ом не был.
Подозрение №3: мы сломали то, что у оригинала работало
Сравнили с main_orig.m. Три расхождения, все значимые.
Цифровые нули — это не звук
Плеер в нашем логе:
tick players 1 [on 4.50/10.00 vol 1.00]
BTServer в ту же миллисекунду:
StartStreaming
… 2.93 с …
StopStreaming
… ровно 8.000 с …
StartStreaming
Плеер играет. mediaserverd считает, что аудио нет, и гасит вывод
в Bluetooth. Цикл ~11 с навечно. Keep-alive хуже, чем его отсутствие:
он долбит A2DP рестартами.
Те самые «2.93 с», которые мы валили на AAC, часто были нашим нулевым буфером.
Проверка причинности — слышимый тон (level 900, 1 кГц): поток непрерывный, писк без обрывов. Значит дело в содержимом буфера, не в плеере.
Рабочий буфер: 44.1 кГц стерео (как Music), четверть частоты дискретизации, амплитуда 2 из 32767 (~−84 дБ):
#define TONE_HZ (SILENCE_RATE/4) /* 0, +2, 0, -2, … */
#define TONE_LEVEL 2
Формально не тишина. Услышать нельзя. StopStreaming пропадает.
Скип переживается.
numberOfLoops = -1 на iOS 6 здесь не работает
setActive: оригинал не вызывал. Явная активация сессии из SpringBoard
может вырвать route у Music. Хватает play.
Громкий тестовый писк уходил в динамик iPod, если выключить Bluetooth и не подождать
grace. Keep-alive живёт до ~31 с после последнего устройства (16 с сверка + 15 с пауза).
Если connectedDevices вернул nil при выключенном BT, старый
код оставлял прежний список — тон в динамик навсегда. Теперь
enabled / powered == NO значит «устройств нет».
После respring наушники часто уже connected, connect-notification не приходит.
Keep-alive поднимается через adopt, но декодер уже мог залипнуть
на дыре A2DP — тогда помогает тумблер Bluetooth. Это та же механика, что mute после скипа.
Соседний твик и почему мы его не скопировали
AirPodsReconnectFix
чинит ту же связку с другой стороны. README обещает сброс «таймера 45 с» через
-connectWithServices: каждые 18 секунд. В коде честнее:
drop still happens, основной deliverable — burst reconnect и resume музыки.
Это рабочий костыль после падения, не устранение причины.
Периодический connectWithServices на живом устройстве — лишний renegotiate.
Их silent MP3 + overlap — тот же keep-alive, что у Amy, и он не
останавливает ACL-drop. Согласуется с нами.
Мы патчим стимул сломанной AT-команды. Они быстро поднимают линк, если он всё же упал. Разный слой.
Что в итоге в пакете
Два dylib в одном .deb:
legacyair.dylib → SpringBoard keep-alive, тон −84 дБ, overlap
legacyair-xapl.dylib → BTServer +XAPL=iPhone,00
hcitrace.c собирается, в пакет не кладётся: пишет мегабайты в минуту.
Не чинит: ear detection, iCloud auto-switch, сосуществование Wi‑Fi/BT на одной антенне.
Иконка гарнитуры в статусбаре может мигать — это кэш/supportsBatteryLevel,
не возврат IPHONEACCEV.
Сборка: ./build.tool на текущем Xcode, без старого SDK и без Theos.
Чему научили шишки
- Текстовый лог демона лжёт молчанием. Команда без CR не появляется в логе. Без HCI её как будто нет.
- «Кто disconnect» — reason code в HCI, не 10719/10927.
- Несколько настоящих багов (кривой AAC, отсутствие Siri, HFP advertise) могут жить рядом с одной причиной drop. Чинить соседний баг безопасно и бесполезно.
- Keep-alive можно сломать «улучшениями» сильнее, чем оригинал на MP3: нули, loop flag,
setActive:. - Два лога рядом (наш tick и
StopStreaming) — единственный чистый proof, что плеер жив, а система считает иначе. - Патч строки переносимее патча кода по VA. Фиксированный адрес оставить диагностике.
- Удалить Substrate plist ≠ отключить dylib.
DefaultLevelв debug-plist — строка. Integer проглатывается.
Оба симптома на iPod touch 4 + iOS 6.1.6 + AirPods Pro 3 закрыты. Отвалы — отсутствующий CR в одной AT-команде. Тишина после скипа — A2DP, которому нельзя давать дыру, и буфер, который нельзя делать из нулей.
Сложность надо заслужить измерением, а не самой красивой теорией из лога.