В апреле я писал про неделю, где звонки то оживали, то убивали клиент, а Xcode спорил с iCloud так, будто это отдельный вид спорта. С тех пор прошёл месяц с хвостом. GChat уже не ощущается как «сборка для четырёх карманов»: друзья сидят в чатах, звонят iPhone ↔ Android, присылают баги, и эти баги стали взрослыми.
Раньше жалоба звучала как «что-то не так со звуком». Теперь чаще так: «на iPhone я слышу гудки, Android принял вызов, но голоса нет», «после принятия системный рингтон не замолчал», «без интернета приложение ведёт себя так, будто я не залогинен». Это уже не туман. Это материал для расследования.
Ниже — что изменилось с 27 апреля до текущей сборки 0.9.106 в канале pre-release. Не пресс-релиз, а инженерный дневник: что починили, где пришлось переписать слой целиком, и что честно осталось болеть.
От беты к pre-release
Снаружи изменение маленькое, но оно задаёт ожидания. Вкладка «Защита» стала «Настройки», щит заменили на шестерёнку, в футере вместо pre-beta теперь pre-release, а рядом появился support@gchat.tech — тап открывает почтовый клиент.
Это всё ещё не релиз в стор, но уже и не «мы просто играемся». Продукт живёт у людей, а значит у людей должен быть понятный способ сказать: «вот здесь сломалось».
Mac перестал быть телефоном на большом экране
Самый крупный сдвиг за май — настоящая мульти-девайсность.
Раньше логика была ближе к «телефон главный, всё остальное рядом». Теперь каждое устройство — отдельная криптографическая личность: свои ключи, свои сессии шифрования, свой device_id. Когда я отправляю сообщение человеку с iPhone и Mac, сервер не получает один общий текст и не «раздаёт копии». Клиент заранее шифрует отдельный вариант для каждого устройства получателя.
Если совсем просто: Mac больше не зеркало телефона. Он самостоятельный участник переписки.
Для входа на Mac появились два нормальных пути:
- Recovery Phrase — восстановление через фразу, из которой расшифровывается identity material.
- Phone Export — QR-паринг с телефона, после которого Mac живёт сам.
Телефон и Mac могут работать одновременно. Это ближе к Signal, чем к «открой веб-версию и сканируй QR каждый вечер».
Цена была ожидаемой: пришлось переписать отправку и приём на мобилке, добить дедупликацию, починить расшифровку, когда у собеседника два активных устройства. Один раз мы честно словили регрессию: между телефонами перестали расшифровываться сообщения. Приоритет сразу ушёл туда, потому что E2E без расшифровки — это не мессенджер, а дорогая форма молчания.
Отдельно: iOS-приложение на Mac через «iPhone apps on Mac» теперь закрыто полноэкранной заглушкой. macOS-версия будет отдельным клиентом, а не костылём поверх iPad-билда.
Профили и «Люди» — уже не просто список контактов
Долгое время профиль в GChat был почти служебной карточкой: аватар и @handle. За май это стало похоже на нормальный социальный слой.
Появились:
- bio в настройках и карточке профиля;
- история аватаров, где старые фото не исчезают после замены главного;
- вкладка «Люди» с фото, @тегом, временем последней активности, галочками верификации и сторис;
- переход в профиль по тапу на аватар в шапке 1:1 чата;
- быстрые действия «написать» и «позвонить» из профиля.
Backend под это получил миграции под bio и avatar history. На бумаге звучит как UI-слой, но по ощущению это важнее. GChat перестаёт быть «чатами с криптографией» и становится приложением, где у людей есть присутствие. Шифрование остаётся под капотом, но пользователь не должен каждую минуту помнить, что он сидит в инженерном эксперименте.
Чат: меньше «работает», больше «можно жить»
Параллельно с профилями шла пачка вещей, которые заметны только когда пользуешься приложением каждый день:
- кнопка прокрутки вниз;
- плавающая дата при скролле;
- микро-анимации и haptic-отклик;
- мультивыбор сообщений;
- локальный поиск по чату;
- превью в ответе;
- редактирование сообщений без ломания E2E-протокола;
- оптимизация фото-превью и задел под thumbnail-cache.
Часть изменений скучная и поэтому важная: SQLite-архив для старых сообщений, локальный индекс, медиа-кэш, transit-only backend. Сервер не хранит историю чатов, он занимается доставкой. Для приватности это хорошо, но ответственность переезжает в клиент: поиск, кэш, архив и аккуратное восстановление состояния должны работать на устройстве.
Вот здесь мессенджер становится взрослым не из-за одной красивой фичи, а из-за суммы мелочей. Когда дата не прыгает, фото не съедают память, поиск находит старую фразу, а ответ показывает превью, мозг перестаёт замечать интерфейс. Это и есть цель.
Звонки: от «кажется, сеть плохая» к диагностике
В апрельском посте основная боль была грубая: звонки могли крашить клиент из-за flutter_webrtc. В мае проблема стала тоньше. Приложение уже не падало, но качество и маршрутизация аудио всё ещё могли вести себя странно:
- iPhone звонит Android, гудки идут, но собеседника не слышно;
- на видеозвонке громкая связь показывает «включена», а звук остаётся в разговорном динамике;
- CallKit продолжает играть системный рингтон, хотя пользователь уже принял вызов внутри приложения.
Разбор упёрся в связку AVAudioSession, ringback-звука и flutter_webrtc. Если объяснять без Apple-магии: iOS очень ревниво управляет аудио. Пока приложение проигрывает гудки, система может держать один режим звука; когда WebRTC пытается забрать микрофон и динамик, ему нужно заново подтвердить, что это уже VoIP-звонок, а не обычный плеер.
Решение: нативный MethodChannel на iOS, который реактивирует VoIP-сессию и жёстко переключает output. Плюс повторное подтверждение аудиомаршрута сразу после остановки гудков, не дожидаясь состояния connected.
Параллельно на клиенте появился более взрослый WebRTC-слой:
- live-сбор
getStats()— RTT, bitrate, candidate path, fps, loss; - debug-overlay на экране звонка по long-press;
- prewarm TURN/ICE при открытии 1:1 чата;
- лимиты на video bitrate и framerate;
- защита от конфликтов renegotiation и ICE restart;
- умный кэш ICE, чтобы плохой NAT не «запекался» на десять минут.
Пассивная телеметрия — главный выигрыш
Самое важное: мы перестали гадать, почему звонок развалился, и начали собирать доказательства.
Клиент отправляет на backend не звук и не содержимое разговора, а технические следы качества:
- samples примерно каждые 15 секунд: RTT, kbps in/out, relay/srflx path, fps, loss, jitter;
- summary при завершении: время подключения, секунды без аудио, relay seconds, ICE restarts, причина падения;
- lifecycle events:
offer_sent,call_accepted,answer_received,ice_connected,connected,ice_restart_*, watchdog timeouts.
Backend сохраняет это в таблицы call_quality_samples, call_quality_summary и call_quality_events. Для расследований есть admin endpoint GET /v1/admin/calls/{call_id}/quality: обе стороны звонка, timeline, samples, events и предварительный diagnosis.
Теперь жалоба «меня не слышно» раскладывается по фактам:
- у caller
audio_out = 0— вероятнее всего, микрофон или аудиосессия на его устройстве; - у caller
audio_out > 0, а у calleeaudio_in = 0— смотрим сеть, ICE или TURN; - у обоих
audio_in > 0, но человек всё равно жалуется — вероятен output route или audio focus.
Это огромная разница. Без телеметрии ты споришь с ощущениями. С телеметрией ты смотришь на пару устройств и понимаешь, где именно умер сигнал.
Следующий шаг — admin dashboard с фильтрами и графиками. Пока хватает JSON endpoint и SQL, но долго так жить не хочется.
Сеть, офлайн и «Подключение…»
Ещё одна боль беты: открыл приложение без сети — и тебя выкинуло на регистрацию, хотя ты уже залогинен. Сейчас сессия восстанавливается из secure storage, показывается «Подключение…», а кэшированные чаты остаются доступны. Тот же индикатор появляется в шапке открытого чата, когда сокет отвалился.
Это не самая громкая фича, зато одна из самых человеческих. Пользователь не обязан понимать, что backend недоступен, WebSocket переподключается, а токены живут отдельно от UI. Он должен открыть приложение и увидеть: «я на месте, данные не пропали, сеть сейчас вернётся».
CallKit тоже стал спокойнее: системный UI звонка гасится, когда пользователь принял вызов внутри приложения. Иначе получаются два рингтона и ощущение, что телефон спорит сам с собой.
Push-уведомления ушли в Variant A: в уведомлении есть hint типа сообщения, но нет текста и приватного содержимого. Так iOS и Android могут показать что-то осмысленное, не превращая push в утечку.
Медиа: mini-player на всём shell’е
Появился нижний mini-player на всём shell’е: чаты, люди, звонки, настройки. У него есть обложка, marquee-название, play/pause, прогресс-линия и компактные контролы. Он работает и для музыки из плейлистов, и для голосовых сообщений в чате.
На уровне тикета это звучит почти декоративно. На уровне ежедневного использования — нет. Когда голосовое продолжает играть при переходе между экранами, а музыка не теряется из-за любого тапа, приложение начинает ощущаться цельным. Не «открыл, отправил, закрыл», а место, где можно жить несколько минут подряд и не упираться в швы.
Что ещё открыто
Нормально говорить не только о том, что получилось.
- Видеокружки всё ещё упираются в лимит блоба и отсутствие нормального transcode под «кружок». Длинный ролик в высоком битрейте может не уйти.
- Mac-клиент активно разрабатывается. Мобильный fan-out и recovery paths уже есть, но desktop ещё не в сторе.
- Call-quality dashboard пока отсутствует. JSON есть, красивого UI для расследований нет.
- Retention и агрегаты по звонкам — daily failed %, p95 connect time и похожие метрики — в планах.
- Thumbnail-cache и media-index для галереи чата остаются следующим большим профитом по памяти и скорости.
Месяц после апрельской беты был не про идеальность. Он был про переход из «у меня на телефоне вроде работает» в состояние, где у GChat есть независимые устройства, нормальные профили, честный офлайн и звонки, которые можно расследовать.
Мне нравится эта стадия. Она уже не романтичная, потому что баги становятся конкретными и иногда неприятными. Но именно здесь продукт перестаёт быть демкой и начинает вести себя как вещь, которой не стыдно пользоваться каждый день.