Анимация iPhone Duo на Galaxy Fold: от красивой демки до работающего приложения
9 сентября Apple представила складной iPhone Duo. Из показанного меня, как и многих, особенно зацепила анимация раскрытия: изображение словно остаётся на месте, пока вокруг него разворачивается экран. Стало интересно, как связать такой переход с движением шарнира и интерфейсом открытого приложения на моём Galaxy Z Fold 8.
Уже на следующий день появился ролик с похожей анимацией на Samsung. По нему можно было решить, что задачу уже закрыли: прошло около суток после презентации, а переход между экранами повторили на Android. Я тоже сначала смотрел на результат, а не на то, что именно происходит в приложении.
В комментариях автор объяснил, что заранее сделал скриншоты внутреннего и внешнего дисплеев, а затем анимировал их шейдером в зависимости от угла шарнира, оставив настоящий интерфейс системы на месте. Автор и сам называл это proof of concept, а среди ограничений отмечал зависимость эффекта от угла, под которым смотришь на телефон.
Мне хотелось пойти дальше: открыть любое приложение, сложить телефон, увидеть переход на внешнем экране и продолжить пользоваться им. Если остановить движение телефона на полпути, анимация тоже должна остановиться, а после полного раскрытия весь цикл должен работать снова, без перезапуска демонстрации. Причём это мой основной телефон, поэтому менять прошивку, жертвовать навигацией или держать внутренний дисплей включённым в закрытом устройстве ради эффекта я не собирался.
Так начался Hinge Lab, в котором анимация поверх приложений в итоге заработала, хотя системное мигание осталось, а некоторые приложения до сих пор не дают пригодный внешний кадр. Пришлось разбираться, как при очередном складывании получать свежий угол, включать нужную панель и находить изображение, которое на ней вообще можно нарисовать.
Что внутри
Сначала нужно было решить, чем я не готов пожертвовать
Я уже занимался интерфейсом для складного телефона, но этот эксперимент быстро отделился от лаунчера, и Hinge Lab можно использовать со своим привычным домашним экраном. Менять выбранные обои ради получения угла тоже не хотелось: эффект должен был сохранять обычную работу приложений, блокировки и навигации.
Стендом был Samsung Galaxy Z Fold 8 (SM-F971B) с Android 17 и One UI 8.5; аппаратные выводы ниже относятся к этому устройству и проверенной прошивке. Даже для похожего телефона их нужно проверять заново.
Условием успеха был полный цикл: приложение открыто внутри → начинается складывание → снаружи виден подходящий кадр → полное закрытие возвращает обычное приложение → раскрытие снова работает. Если после одного прохода требовался перезапуск, задача оставалась нерешённой.
Двадцать ответов в секунду, а угол один
Чтобы связать движение картинки с шарниром, сначала обратился к публичному TYPE_HINGE_ANGLE. На моём стенде встречались главным образом опорные значения 0, 90 и 180 градусов, чего для плавного движения не хватало.
Поиск привёл к FoldInteractive, штатным интерактивным обоям Samsung, через внутренний механизм которых удавалось получить более подробный угол. На этом источнике строились и некоторые чужие эксперименты, к которым ещё вернусь.
В одном из прогонов помощник присылал примерно 20 ответов в секунду, но каждый содержал 180°. Свежие ответы подтверждали связь, а следит ли источник за шарниром, оставалось непонятно. После этого в диагностике появились сравнение источников, возраст последнего ответа, диапазон и число разных значений. Проверка стала требовать движения телефона: одной свежей временной метки уже было недостаточно.
Всё работало, пока я не закрыл телефон до конца
На внутреннем экране подробные значения менялись вслед за движением шарнира. При складывании телефона угол уменьшался, но после полного закрытия мог застыть на 1-3 градусах. При следующем раскрытии новых значений уже не было, и анимации не за чем было следовать.
При разборе FoldInteractive выяснилось, что подписка зависит от видимости движка: источник мог перестать обновлять угол, хотя помощник продолжал к нему обращаться. Поэтому искать ошибку только в слушателе Activity моего приложения было недостаточно.
Пробовал прямую подписку из shell, пробуждение движка, запросы к разным экранам и предпросмотр штатных обоев. Прямая подписка на моём стенде была недоступна, а через предпросмотр источник в отдельных условиях работал, но системное окно появлялось перед пользователем и теряло нужное состояние при переходах. Оставлять его частью анимации я не хотел.
Для начала раскрытия нашлась другая зацепка: состояние устройства TENT. В одном медленном прогоне оно пришло примерно на 4,6 секунде записи, событие публичного датчика 90° на 10,4 секунде, а широкое окно на 10,8 секунде. Эти отметки относятся к одному медленному раскрытию, поэтому по ним нельзя судить о постоянной задержке датчика. В этом прогоне TENT позволял начать переход раньше переключения окна на внутренний экран, хотя точные градусы по-прежнему нужно было получать отдельно.
Возвращение к чужим демонстрациям и исходникам
Чтобы понять, как с переходами справляются другие разработчики, после просмотра демонстраций перешёл к разбору и проверке доступных открытых проектов.
В ZFoldDuo нашлась полезная основа: получение угла через Samsung и модель проекции с шейдером, которые я адаптировал для Hinge Lab. Однако даже после повторения цепочки переходов и сборки варианта с изменениями совместимости проблема повторного раскрытия на моём телефоне сохранилась.
Позднее посмотрел Folduo. В описании v0.1.21 автор указывает конкретный Fold7, настройку интерактивных обоев на внешнем экране, удержание обоих дисплеев и ограничения внутренней системной навигации. Такой набор компромиссов не подходил под мои условия с сохранением обоев и обычного управления телефоном.
Также посмотрел Duo Open. Его README указывает тестирование на OnePlus Open, а прочие складные устройства оставляет непроверенными. Там же описаны ограничения быстрого жеста и чёрный переход при включении панели. Как он поведёт себя на моём Samsung, из этих проверок было непонятно.
Я изучал, что можно перенять для моего телефона и какие ограничения придётся принять; одинакового сравнительного бенчмарка этих проектов не проводил. Следующим вопросом стало управление двумя панелями: подробного угла без него оказалось мало.
Получил угол, но вместе с ним получил мигание
Дальше пришлось разбираться с режимами дисплеев, которые в журналах моего телефона обозначались номерами 4 и 5: в первом основным оставался внутренний экран, во втором основным был внешний. На другом устройстве или прошивке номера, названия и поведение могут отличаться.
Режим 4 позволял получать подробный угол и включать внешнюю панель до полного закрытия, но система отменяла запрос при складывании. В одной из ранних версий приложение повторно запрашивало режим двух экранов после полного закрытия телефона, и источник угла снова оживал. Вместе с ним возвращалась и проблема: видимое переключение экранов.
В режиме 5 удавалось наблюдать более непрерывное поведение внешней панели, но получить рабочий источник подробного угла в этих условиях не удалось. Оставить оба дисплея включёнными постоянно тоже означало отказаться от одного из исходных требований.
Когда анимация уже рисовалась, короткое исчезновение экрана особенно мешало: переход обрывался в самом начале или мигал после завершения. Попробовал убрать задержку опроса dumpsys, заменив его callbacks. Подтверждение состояния теперь приходило за 2-7 мс, однако до появления картинки всё ещё оставалась задержка и видимое мигание сохранилось.
Затем добавил короткий системный запрос: на 700 мс удержать оба дисплея во включённом состоянии во время переключения. Запрос отправлялся для обоих логических дисплеев, поскольку при переходе их привязка к физическим панелям менялась. По моим ощущениям, чёрный промежуток стал короче, поэтому этот приём оставил. Отдельно проверил, не связано ли появление системных обоев с исчезновением окна анимации. Удержание окна меняло вспышку обоев в отдельных переходах, но чёрный промежуток оставался другой проблемой. Замена Presentation другим способом создания окна заметного улучшения тоже не дала, и без новой проверяемой гипотезы продолжать переписывание окон не стал.
Само переключение выполняет shell-помощник через системную службу device_state. Класс EventDisplayRequest получает её Binder-интерфейс IDeviceStateManager, вызывает requestState и слушает callbacks о состоянии и отмене запроса. Binder здесь служит каналом вызова системной службы из отдельного процесса. В рабочем варианте запрос режима 4 отправляется с отдельным Binder-токеном, а cancelStateRequest снимает запрос. Так можно просить систему изменить режим, но последнее слово остаётся за ней: при закрытии она способна отменить его сама.
После этих проб осталась схема, которая запрашивает два экрана на время перехода и освобождает запрос при CLOSED или свежем угле от 178°. Из сложенного состояния переход запускается по раннему TENT, запрос удержания дисплеев включёнными автоматически истекает через 700 мс. Так не приходится постоянно держать внутренний дисплей включённым в закрытом телефоне, но полностью убрать системное мигание не получилось.
Откуда взять картинку для анимации поверх приложений
С этим компромиссом можно было переходить от собственной лабораторной сцены, где заранее было известно, что показывать на каждой панели, к настоящим приложениям. Теперь нужно было получить внешний вид приложения, открытого внутри, а включение внешнего дисплея само по себе его не создавало.
Сначала ошибся и в самой проверке: одновременно запросил снимки двух дисплеев и получил ошибку слишком частых захватов. После разнесения запросов callbacks стали успешными, но полезное изображение снаружи всё равно отсутствовало. Код ошибки интервала описан в Android AccessibilityService API.
Чтобы проверить, включена ли панель физически, добавил временную цветную плашку. Её было видно, но после исчезновения плашки ожидаемого приложения под ней не оказалось. Значит, в этой проверке панель включалась, а искать нужно было причину отсутствия нужного контента.
Для внешнего экрана можно было обрезать внутренний снимок или временно перенести туда задачу приложения, дать ему подготовить настоящую узкую компоновку, снять её и вернуть задачу обратно. Для обоих способов были отдельные проверки. Обрезка мне понравилась меньше: половина широкого интерфейса выглядела хуже, чем интерфейс, действительно подготовленный для узкого экрана.
В выбранном варианте анимация сначала использует подходящий снимок внешнего экрана, если он уже есть, а если снимка нет, при складывании Hinge Lab ненадолго перемещает открытое приложение с внутреннего дисплея на внешний. Приложение получает узкие размеры экрана и перестраивает интерфейс, после чего Hinge Lab снимает получившееся изображение для внешней части анимации и возвращает приложение на внутренний дисплей. При этом перемещается то самое открытое приложение, в Android это называется задачей; второй независимый экземпляр не запускается, поэтому получить таким способом два одновременно работающих интерфейса на разных экранах нельзя.

Сохранённый кадр тоже нужно было привязать к тому, что сейчас происходит на телефоне. У кэша ключ PanelSize(width, height, rotation), а внутри записи хранятся время захвата и epoch, номер контекста приложения. Логический ID дисплея ключом не служит: при переключениях его привязка к физической панели меняется. При чтении нужны совпадающие размеры, поворот и контекст, а возраст снимка должен укладываться в 10 секунд.
При асинхронном захвате после переноса перед запросом сохраняются generation текущего сеанса и contentEpoch, а в callback они сравниваются с актуальными значениями. Если за время ожидания анимацию перезапустили или сменилось приложение, результат отбрасывается, даже когда сам захват завершился успешно: полученная картинка уже относится к прошлому состоянию.
При отсутствии пригодного внешнего кадра выполняется одна ограниченная попытка переноса; ошибка возврата останавливает режим.
Первый удачный проход ещё ничего не решал
Перенос выглядел удачным, пока я не стал несколько раз подряд раскрывать и складывать телефон, меняя приложения между циклами. При очередном складывании попытка перенести приложение на внешний экран иногда вообще не запускалась. В другом сценарии помощник отклонял запрос ещё до того, как успевал переместить приложение, но в состоянии анимации оставался запрет на новый перенос. Он должен был защищать от повторного запуска незавершённой операции, а вместо этого блокировал следующий цикл. После неудачи можно было открыть Telegram и снова сложить телефон, но получить для него внешний кадр через перенос уже не удавалось: оставшийся запрет блокировал весь механизм переноса.
Я ввёл явные результаты операции переноса: NOT_MOVED означает, что приложение не перемещалось, RESTORED подтверждает его возврат после переноса, а RESTORE_FAILED сообщает, что подтвердить возврат не удалось. Общего ответа REJECTED было недостаточно: он не позволял понять, осталась ли задача на исходном экране или ошибка возникла уже после перемещения, когда приложение ещё нужно вернуть.
Из текущего кода:
fun safeCompletion(line: String) =
line.startsWith("TRANSFER RESTORED ") ||
line.startsWith("TRANSFER NOT_MOVED ")
Следующий перенос разрешается после двух событий: безопасного завершения предыдущей операции и достижения конечного положения телефона. В коде это небольшой TransferCycleGate с тремя флагами: used отмечает, что попытка в этом жесте уже была, busy удерживает запрет до безопасного завершения, endpoint запоминает конечное положение. Начало операции сбрасывает endpoint, поэтому отметка от прошлого жеста не подходит.
Поскольку телефон может закрыться как до подтверждения возврата приложения, так и после него, проверка хранит оба события:
fun restored() { busy=false; rearm() }
fun boundary() { endpoint=true; rearm() }
private fun rearm() { if(!busy && endpoint) used=false }
В тестах отдельно проверены оба порядка, запрет второй попытки в том же жесте и отсутствие подтверждения возврата: в последнем случае новые сообщения о конечном положении не снимают запрет, чтобы быстрое складывание не запустило ещё один перенос поверх незавершённого.
Возврат задачи запрашивается в finally с ограниченным ожиданием, чтобы протокол не ждал бесконечно, хотя мгновенное восстановление экрана самой прошивкой это не гарантирует.
Google Сообщения починились, WhatsApp остался чёрным
При проверке разных приложений я замечал похожие чёрные экраны. В Google Сообщениях картинка исчезала даже после получения пригодного кадра. По журналам оказалось, что после возврата задачи приходило событие специальных возможностей от домашнего экрана: код воспринимал его как настоящую смену приложения и убирал анимацию, которая в одной записи прожила около 26 мс.
Вместо общего игнорирования событий я добавил узкую проверку фокуса после переноса. Когда система подтверждает новое приложение, обработчик принимает событие, а если активным осталось прежнее, сохраняет его кадр; при неоднозначном ответе очищает изображение. У проверки есть таймаут и привязка к текущему сеансу, чтобы поздний ответ не восстановил старую картинку.
На телефоне исправление Google Сообщений подтвердилось, а в WhatsApp внешняя анимация так и не появилась. Там пришлось отдельно проверить захват:
Задача действительно оказывалась на внешнем дисплее и возвращалась обратно.
Система отмечала основное окно как видимое и отрисованное.
Дополнительное ожидание до 500 мс не помогло в трёх попытках.
В диагностическом захвате обе сетки проверки, 16×16 и 64×64, дали только полностью прозрачные точки; RGB выше порога также не обнаружился.
Обычный фильтр проверяет сетку 16×16: хотя бы у 5% точек альфа должна быть не ниже 240, а максимальный RGB-канал выше 12 из 255. Это эвристика пригодности картинки, и действительно тёмный интерфейс тоже может быть отброшен. В диагностике WhatsApp проверку дополнительно повторили на сетке 64×64, но в проверенных точках изображения по-прежнему не было, поэтому ослаблять порог яркости было бессмысленно. До проверки каждого пикселя я не дошёл, и окончательная причина осталась неизвестной. Была гипотеза о переходном окне приложения, но отдельно захватить его не удалось, поскольку события не дали нужный идентификатор; по пустому кадру также нельзя было понять, сработала ли защита от скриншотов. На этом я остановился, сохранив работавший общий сценарий.
Когда можно было считать очередное исправление рабочим
Работа шла небольшими итерациями: гипотеза, ограниченное изменение, APK, движение на телефоне и сравнение с журналом. При этом я искал результат, который мог бы опровергнуть гипотезу. Так, ускорившиеся callbacks при сохранившемся мигании показывали, что одним сокращением опроса основной дефект не объяснить. Когда задача успешно возвращалась, но следующий жест не работал, нужно было разбираться с состоянием протокола переноса.
Одного удачного прохода было недостаточно: сбой мог обнаружиться при смене приложения или разблокировке. Поэтому в ручную проверку вошли медленное и быстрое складывание, обратное движение без полного закрытия, смена приложения, прокрутка, блокировка кнопкой, разблокировка и повторный цикл после неудачи. Для чистой логики были написаны unit-тесты: они проверяют, когда разрешён следующий перенос, как обрабатываются разные порядки событий и почему внутри одного жеста нельзя запустить перенос повторно. Физические переходы остаются ручной проверкой; совместимость с прошивками эти тесты не доказывают.
В одном дополнительном прогоне было 41 попытка переноса в 24 пакетах: 34 пригодных захвата, пять пустых и два ранних возврата без захвата. Даже в Госуслугах и нескольких банковских приложениях встречались как пригодные, так и пустые кадры. Это небольшой несбалансированный прогон, из которого нельзя выводить процент надёжности: пригодный захват ещё не гарантирует, что сама анимация выглядит правильно.
Где я решил остановиться
В какой-то момент результат стал достаточно хорошим, чтобы я захотел им пользоваться и поделиться, даже с оставшимся миганием.
Я разделил приложение на две части: в пользовательской можно пройти настройку и включить постоянный эффект, а в лаборатории остались исторические проверки для тех, кто хочет разобраться или продолжить исследование.
Подготовку хотелось собрать в самом Hinge Lab, чтобы не требовалось отдельно устанавливать Shizuku или подключать компьютер. Пользователь подключается к Wi-Fi, включает параметры разработчика и USB-отладку, затем разрешает в специальных возможностях службу “Hinge Lab - настройка помощника”. По кнопке автоматического запуска она открывает нужные настройки, включает беспроводную отладку и считывает код локального сопряжения ADB. Системные подтверждения пользователь принимает сам; если поиск остановился на строке “Отладка по Wi-Fi”, приложение просит нажать её. Затем Hinge Lab сопрягается с ADB этого же телефона и запускает отдельный процесс с правами shell. Это расширенные права отладки, без root: помощник получает угол и выполняет команды управления дисплеями и переноса приложения, недоступные обычному APK. При первом подключении также выдаётся WRITE_SECURE_SETTINGS, чтобы последующие запуски могли переключать беспроводную отладку без прохода по меню. После успешного запуска беспроводная отладка выключается, а помощник продолжает работать. USB-отладку на проверенной прошивке приходится оставлять включённой, хотя кабель не нужен: без неё отключение беспроводной отладки завершало и запущенный помощник.
Помощник запускается через app_process из кода APK и работает отдельным процессом под UID 2000, системным пользователем shell, при этом само приложение этих прав не получает. Команды и ответы идут по локальному TCP-соединению с 127.0.0.1:43987; перед началом обмена сервер проверяет секретный токен. Свернуть окно Hinge Lab можно: процесс помощника от этого не заканчивается, а эффект рисует служба специальных возможностей. Поэтому в приложении есть отдельная остановка помощника, и удаление APK не заменяет её.
Остаётся включить отдельную службу “Hinge Lab - анимация поверх приложений”, разрешить уведомление с кнопкой остановки и нажать “Включить анимацию”. Эта служба специальных возможностей следит за сменой приложений, получает снимки экрана и показывает эффект поверх интерфейса. Служба настройки для уже запущенного помощника больше не нужна, её можно отключить.
Эффект строится по снимкам, поэтому изображение приложения в анимации не обновляется. Основной кэш ограничен двумя кадрами, сроком пригодности 10 секунд и контекстом приложения/геометрии; есть временные копии для рисования. Изображения не пишутся в файлы и не отправляются на сервер. Долгая пауза на промежуточном угле может завершить эффект, чтобы неподвижная картинка не оставалась поверх интерфейса.
Основные ограничения:
Мигание при системном переключении дисплеев не устранено.
Некоторые приложения не дают пригодный внешний кадр во время перехода.
Очень быстрый жест может закончиться раньше, чем подготовится внешняя анимация.
Результат проверен на моём Samsung Galaxy Z Fold 8 (SM-F971B), Android 17, One UI 8.5, с описанными выше ограничениями. На других устройствах и сборках системы поведение может отличаться; приватные интерфейсы Samsung могут измениться при обновлении.
Перед удалением приложения стоит остановить помощник из самого Hinge Lab, поскольку удаление APK не гарантирует остановку отдельного shell-процесса. После перезагрузки помощник нужно запускать заново. Полученным эффектом можно пользоваться поверх своих приложений, как я и хотел, хотя мигание и проблемные приложения мешают считать его универсальной заменой системной анимации. Для тех, кто захочет продолжить исследование, я сохранил код и проверки.
Пока я собирался публиковать статью, появился ещё один проект
Буквально перед публикацией я наткнулся на Duo Fold Live. Первая мысль была: неужели опоздал? Посмотрел, как он устроен и что пишут пользователи. Главное преимущество для меня здесь в живом содержимом: при раскрытии движение продолжается на обоих экранах. Благодаря живому зеркалированию, которое есть в коде, условное видео на YouTube должно продолжать играть во время перехода.
С миганием, однако, получилось хуже: к погасанию экрана в конце складывания, знакомому и по Hinge Lab, добавляется ещё одно примерно на 90°. Такое двойное мигание описывает владелец той же модели SM-F971B, а чёрный переход остаётся среди известных проблем проекта. Для меня это особенно мешает именно в такой анимации: вся её задача в том, чтобы создать ощущение плавного перехода между экранами, а дополнительное погасание посередине это ощущение прерывает.
Настройка тоже сложнее: нужно отдельно установить и запустить Shizuku, а затем назначить требуемые обои Samsung на оба домашних экрана. В Hinge Lab вся подготовка собрана внутри приложения: необходимые доступы выдаются по кнопкам, помощник запускается оттуда же, переназначать обои не нужно. Поэтому живое содержимое в Duo Fold Live мне нравится, но дополнительное мигание и возня с настройкой для повседневного использования остаются минусами.
Попробовать и продолжить исследование
Исходники и описание запуска: Hinge Lab на GitHub.
APK в Telegram. В том же сообществе можно обсудить исследование, сравнить подходы Hinge Lab и Duo Fold Live (или других аналогов), предложить решение оставшихся проблем или показать свой форк. Если будете делиться наблюдениями, добавьте модель телефона и условия проверки: так другим участникам будет проще повторить опыт и сравнить результаты. Дальнейшую доработку со своей стороны не обещаю, но буду рад обсуждению и тому, что кто-то продолжит исследование на основе открытого кода.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.