Анатомия клиента Telegram на Compose Multiplatform: матрешки TDLib 1.8, стеганография словами и грабли первой альфы
Решил написать свой Telegram на KMP за 30 дней. Что может TDLib и с чем у меня возникли проблемы
Пока искал работу, решил ради инженерного эксперимента написать альтернативный клиент Telegram на Kotlin Multiplatform (Android + Desktop)(Мог бы ещё на Ios но у меня нету ни мак бука, ни денег на него). Уделял проекту по 2–3 часа под ночь. Думал, что TDLib возьмет на себя всю грязную работу, но вместо этого ловил бесконечные рекомпозиции, падения от нехватки памяти (OOM) и загадочные ошибки файловой системы. В итоге всё‑таки допилил клиент со встроенным ИИ, Anti‑Revoke и антиспамом. В статье разбираю, какие были трудности, баги и т.д, как я их чинил, ну и оставляю ссылку на готовый APK.
Стек
Язык и UI: Kotlin 2.4, KMP&CMP (Android + Linux Desktop).
Ядро мессенджера: TDLib 1.8 (C++ через JNI).
База данных: Room KMP (SQLite).
Архитектура: MVI + Clean Architecture, Koin.
ИИ: Google Gemini API (с поддержкой своего ключа — BYOK).
Запуск TDLib и первые проблемы
Начиналось всё оптимистично. Подключил C++ ядро TDLib. На ПК всё запустилось быстро, но как только я скомпилировал билд под Android — приложение упало с FileNotFoundException / EROFS (Read‑only file system).
Оказалось, что TDLib по привычке пытался писать логи и сессию прямо в корень рабочей папки. На Linux это прокатывало, а Android с его песочницей мгновенно падал.
Пришлось писать свой платформенный AppStorageConfig, который через Koin скармливает ядру путь к context.filesDir на телефоне и ~/.ghostgram на компе:
data class AppStorageConfig(val basePath: String)
в init koin в модуле нужно было прописать: module { single { AppStorageConfig(appStoragePath) } },
и дальше при вызове init koin нужно было прописывать appStoragePath вот пример: initKoin(appStoragePath = this@GhostGramApp.filesDir.absolutePath) { androidContext(this@GhostGramApp)}
initKoin(appStoragePath = this@GhostGramApp.filesDir.absolutePath) {
androidContext(this@GhostGramApp)
}Ад с отправкой медиа файлов
Когда я доделал до ± адекватного уровня отправку сообщений, я решил сделать отправку фотографий и видео. Написал стандартный JSON по старым туториалам из интернета: @type: inputMessagePhoto и путь к файлу.
И тут TDLib начал выдавать каждый раз ошибку: error 400: InputFile is not specified
Я потратил два дня на решение, перерыл логи, думал на права доступа и пути файлов. А разгадка крылась в недокументированном изменении TDLib 1.8 (на эту же мину наступили авторы библиотек на Node.js и Emacs в GitHub Issue #3683): Разработчики Telegram добавили спойлеры и истории, и втихую завернули все медиафайлы в обязательные объекты‑матрешки.
Фото теперь требует: inputMessagePhoto ➔ inputPhoto ➔ inputFileLocal.
Видео требует: inputMessageVideo ➔ inputVideo ➔ inputFileLocal.
Стикер требует: inputMessageSticker ➔ inputSticker ➔ inputFileId.
Стоило добавить этот скрытый уровень вложенности в JSON‑билдер — медиа улетели как они и задумывались:
val request = buildJsonObject {
put("@type", "sendMessage")
put("chat_id", chatId)
if (replyToId != 0L) {
put("reply_to", buildJsonObject {
put("@type", "inputMessageReplyToMessage")
put("message_id", replyToId)
})
}
put("input_message_content", buildJsonObject {
when {
asDocument -> {
put("@type", "inputMessageDocument")
put("document", buildJsonObject {
put("@type", "inputDocument")
put("document", buildJsonObject { put("@type", "inputFileLocal"); put("path", absolutePath) })
})
}
isVideo -> {
put("@type", "inputMessageVideo")
put("video", buildJsonObject {
put("@type", "inputVideo")
put("video", buildJsonObject { put("@type", "inputFileLocal"); put("path", absolutePath) })
})
}
else -> {
put("@type", "inputMessagePhoto")
put("photo", buildJsonObject {
put("@type", "inputPhoto")
put("photo", buildJsonObject { put("@type", "inputFileLocal"); put("path", absolutePath) })
})
}
}
if (finalCaption.isNotBlank()) {
put("caption", buildJsonObject {
put("@type", "formattedText")
put("text", finalCaption.trim())
})
}
})
}Как я сделал Anti‑Revoke (удалённые сообщения были видны)
В официальном клиенте, когда собеседник удаляет сообщение, сервер шлет updateDeleteMessages, и приложение стирает запись из памяти.
Я пошел другим путем: все сообщения падают в реактивную базу Room KMP. Когда от серверов Telegram прилетает команда стереть ID, мой MessageUpdateHandler не делает DELETE, а просто выставляет флаг.
В UI у пузыря сообщения появляется красная плашкой 🗑️ Удалено отправителем. А в настройках я разделил ручную очистку: можно удалить все локальные файлы, но оставить удаленные нетронутыми.

Что добавила gemini в пользовательском опыте
В приложение я добавил gemini, который используется для catch‑up и ответа собеседнику. Catch‑up gemini анализирует ваш диалог(зашифрованные сообщения gemini не видит), и присылает краткий ответ какие события ты пропустил в диалоге:
Ответ собеседнику пока работает как т9. И что бы у всех gemini работало нормально я сделал, что в приложении, ты можешь вести свой ключ от gemini api и пользоваться ии со своими лимитами и конечно, если тебе лень я оставил возможность пользования ии без ввода ключей, но при наплыве пользователей ии будет работать по праздникам.

Стеганография: зачем шифровать слова словами
Обычные «секретные чаты» Telegram шифруют трафик сквозным методом, но сервер видит сам факт: «эти двое открыли секретный чат и гоняют бинарный шум».
Мне хотелось сделать что‑то вроде PGP‑стеганографии:
Клиенты обмениваются ключами на эллиптических кривых (ECDH P-256).
Текст шифруется AES‑GCM-256.
Полученный шифротекст кодируется байт‑в‑слово через статический словарь из 256 обычных русских слов (WordCoder).
В итоге для серверов Telegram и любого сетевого фильтра сообщение выглядит как несвязный диалог: «утюг жизнь искра стриж вокзал». А клиент на лету выдирает зашифрованную суть и вешает зеленый замок 🔒.

Как это выглядит у собеседника:

Как это выглядит в самом тг:

В будущем скорее всего улучшу шифрование пока это можно считать MVP. Т.к у этого метода есть недостатки, но а так для демонстрации и пробы пера сойдет.
Оптимизация и работа приложения
Пока что приложение не оптимизировано на 100%. Приложение на пк работает нормально без фризов и пролагов. Но на телефоне при выгрузке так скажем стенки из стикеров, пользователь может заметить просадок фпс, который стабилизируется спустя пару секунд, скорее всего эта проблема на более мощных телефонах не будет, но на моём специальном телефоне для тестов на производительность, это есть.
Что пока НЕ сделано и ограничения
Не сделано и половины мессенджера, фокус был на то что бы работали основные функции мессенджера: а это переписка один на один, отправка стикеров, медиа, сообщений и т.д, хоть какое‑то нормальное взаимодействие в чате где больше трёх человек. И добавил свои фичи которые как мне показались полезными: это просмотр удалённых сообщений, анонимный просмотр сообщений, ии, в частности это catch‑up.
Итоги и ссылки
Уложиться в 30 дней в соло с новым стеком реально, но с некоторыми условиями. Тем не менее, KMP показал себя потрясающе: 80% кода (вся бизнес‑логика, база, крипта и верстка) работает абсолютно одинаково на телефоне и на моем Linux‑ноутбуке.
Проект и дальше будет обновляться по крайне мере пока не выйдет в релиз, дальше буду думать продолжать или забросить проект.
GitHub (GPL-3.0): https://github.com/vdggrtff/GhostGram
Готовый APK (v0.1.0-alpha): https://github.com/vdggrtff/GhostGram/releases
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.