Pull to refresh
1900.87
Timeweb Cloud
То самое облако

Сам себе экосистема. Часть 4: как я реализовал клиент Telegram на Android-смартфоне 14-летней давности?

Level of difficultyMedium
Reading time19 min
Views9.1K
image

С момента выхода первой части статьи из рубрики «сам себе экосистема» прошёл уже практически год! За это время, мы успели с вами реализовать клиенты VK и YouTube, которые работают на Android 2.2+, а также на Windows Phone 8, написать небольшую 2D-игру с нуля весом менее 1Мб, которая работает практически везде и довести существующее приложение до ума, дабы оно работало даже на смартфоне с дисплеем 240x320! Но на дворе 2024 год, люди стремительно переходят из соц. сетей в продвинутые мессенджеры и уже сложно себе представить современного человека, который не пользовался бы «телегой» или даже «вайбером» в качестве основного средства общения. Поэтому я решил реализовать клиент Telegram на смартфоне 14-летней давности на базе официальной реализации MTProto от команды Telegram — TDLib. Сегодня мы с вами: узнаем новые причины мотивации вернуть в строй смартфоны прошлых лет, напишем на C# реле-сервер, который обрабатывает пакеты MTProto и кодирует их в простой текстовый формат датасетов, который можно моментально обработать даже при нестабильном GPRS-соединении на 21-летнем Siemens C60, а также узнаем о разработке миниатюрных Android-приложений на базе «голого» API-системы, которые не тянут за собой никаких зависимостей, в том числе и AppCompat/androidx. Интересно? Тогда жду вас под катом!


Содержание


  1. Но зачем всё это?
  2. Архитектура приложения
  3. Реализация сервера и работа с TDLib
  4. Android-приложение
  5. Как запустить клиент?
  6. Заключение

Но зачем всё это?


На дворе уже стукнул 2024 год, современные смартфоны предлагают какие-то немыслимые мощности относительно тех, которые когда-то были в первых Android-девайсах. Сейчас за сотню баксов можно купить смартфон с хорошей 1080p IPS-матрицей, 4Гб ОЗУ и 8-ядерным шустрым чипсетом, который вполне способен плавно тянуть даже стремительно «жиреющие» на ресурсы клиенты социальных сетей, банков и прочие необходимые в повседневной жизни приложения.
И казалось бы: всё хорошо, покупай себе редмик раз в год или айфон раз в несколько лет и наслаждайся всеми прелестями работы современных приложений…

image


Для многих людей смартфон — это лишь инструмент, повседневный компаньон, который помогает облегчить выполнение каких-то задач. Им совершенно не важно, как он выглядит, как ощущается в руках, какой у него дисплей и железо «под капотом», лишь бы работал да и нормально. Но есть и другая категория людей, для которых телефоны, смартфоны и любые портативные гаджеты — это не просто утилитарный девайс, а настоящее инженерное произведение искусства, с которого буквально сдувают пылинки и стараются до последнего пользоваться ими как повседневными устройствами. Хотите пример? Смотрите ниже:

image

Фактически, среди современных смартфонов по сути и нет представителей такого нынче вымершего форм-фактора, как сайдслайдеры с физической QWERTY-клавиатурой, боковые раскладушки с двумя дисплеями и даже из QWERTY-моноблоков есть только смартфоны от Unihertz. Даже среди моноблоков с тачскринами нет никакого разнообразия, лишь без-рамочные одинаковые девайсы за исключением устройств от Sony.

image
Galaxy S Plus

Раньше меня часто спрашивали, мол, да как ты вообще можешь пользоваться смартфоном 10-летней давности, на котором давно нет официальных клиентов популярных сервисов и только недавно, с развитием блога, мне перестали задавать этот вопрос, поняв, что это бесполезно — ведь это дело принципа и порыва энтузиазма! Смотрите сами: у нас уже есть простенькие, но вполне рабочие клиенты ВК, YouTube, сейчас я допиливаю клиент «Сбера» на СМСках, реализую карты OpenStreetMap (правда пока без адекватной навигации), а в будущем планирую написать приложение для мониторинга погоды и трекинга посылок. Кроме того, в рамках этой статьи мы реализуем с вами клиент Telegram: так чем же это не функционал современного смартфона?

image

image

Но хорошо, с функционалом разобрались, однако для многих читателей слова «старый смартфон» это прямые синонимы «тормозной смартфон», мол «фуу, да как можно пользоваться этим тормозным кирпичом, он же лагает в последней версии моей ВКшечки!». Но давайте поставим вопрос ребром: может, это не столько девайсы немощные, сколько сами приложения, с кодовой базой, которая тянется более 10 лет, откровенно жиреют, обрастают костылями и хаками после далеко не одного поколения программистов, которые над ними работали? :) Один, вот, предпочитал пользоваться чистым AppCompat'ом, другой решил притащить зависимость, которая, например, оптимизирует виртуализацию ListView, третий решил заменить всю сериализацию Json со встроенных классов в Android на что-то стороннее и реализовал это костылями и вот так, по чуть-чуть изначально оптимальный и шустрый код превращается в неповоротливое УГ, которое не рефакторили кучу лет.


На видео Galaxy Pocket Neo — очень дешёвый Android-смартфон из 2011 года с 1-ядерным чипсетом на ~800МГц и 256Мб ОЗУ. При этом всём, Android софтварно рисует все анимации на процессоре, без участия GPU.

А значит у стареньких девайсов всё равно есть шанс быть полезными и стать полноценными повседневными смартфонами даже спустя более чем десять лет после выхода! И в сегодняшнем материале, я вам расскажу об особенностях разработки самопального клиента Telegram с собственным прокси-сервером, которое концептуально допускает реализацию даже на кнопочном Siemens C60 2003 года. Как? Читаем ниже!

Принцип работы


В отличии от ВК (который разрабатывали те же самые люди, что и Telegram), API которого построено на базе REST-запросов и концепции Longpolling'а для моментального получения событий с сервера, Telegram построен на базе собственного протокола под названием MTProto, который может работать поверх любого «транспорта» (протокола нижнего уровня) — TCP, HTTP, WebSocket и т.п. Сам по себе MTProto в современном виде, разработка прожженного математика Николая Дурова и его команды — протокол относительно сложный для реализации «на коленке» и в первую очередь требует довольно серьезного понимания принципов работы современной криптографии, да и документирован он всё ещё не особо хорошо. Кроме того, у MTProto весьма интересный бинарный формат пакетов, эдакий велосипед Protobuf. В долгосрочной перспективе поддерживать свой велосипед MTProto может быть весьма проблематично, учитывая не самую лучшую документацию.

image

Старое лого Telegram

Но городить велосипед и не нужно, поскольку у команды Telegram есть официальная реализация MTProto — библиотека TDLib, которая инкапсулирует в себе не только детали реализации протокола, но и сетевой ввод/вывод и выбор транспорта, хранение базы данных сообщений и авторизации, автоматическую загрузку фото и видео, конвертация объектов из бинарного формата MTProto в JSON и полная многопоточность и частичная потоко-безопасность. С одной стороны это плюс — уже готовое решение для реализации клиента на новой поддерживаемой платформе, где есть OpenSSL (можно статически слинковать), zlib (линкуется статически), сокеты и файловый ввод/вывод, а также довольно неплохой механизм JSON-based API, которое позволяет использовать библиотеку в любом языке, который поддерживает вызов C-функций, а с другой и минус — библиотека довольно много весит, в одиночку прибавляя ~20Мб веса приложения для каждой архитектуры, у неё течёт память и у нее странный механизм получения данных с сервера (например, нельзя ответить на сообщение, зная его ID, если сообщение предварительно не загружено, при том что на сервере весь ответ — это просто ID, на какое сообщение прилетел ответ).

Понятное дело, что на стареньком смартфоне использовать оригинальный TDLib будет проблематичным — даже если собрать либы современным NDK и запилить JNI-интерфейс, библиотека «жрёт» много ОЗУ (20-100Мб «вхолостую», в зависимости от числа диалогов и частоты прилетающих событий, плюс со временем течет до 1-2Гб, если не использовать базу данных сообщений. Скорее всего, это косяк в реализации пулов, объекты из которых выгружаются при сбросе в базу, но не выгружаются при высоком потреблении ОЗУ) и уж тем-более TDLib не запустить на любимых кнопочных Java-сонериках! Поэтому я решил написать прокси-сервер, который отправляет команды, слушает ивенты TDLib и предоставляет REST-like API для клиентских программ, которые просто вызывают какой-либо метод, а в ответ получают простой и короткий строковой датасет только с необходимыми полями, весом до 10Кб (что позволяет его быстро загрузить даже с GPRS-интернетом), который можно быстро распарсить даже на пресловутом Siemens C60!

image

К сожалению, поскольку TDLib прожорлив, я не смогу захостить на своём сервере инстансы для читателей, которые хотят поюзать приложение, поэтому вам придется ставить и запускать сервер на своём VDS/компьютере с белым IP/роутере, если под него есть .NET Core :)

Клиентом же будет выступать Android-смартфон, где приложение будет фронтэндом данных с сервера. Ничего сложного на первое время нет: первое окно — это список диалогов, второе окно — список сообщений в диалоге + поле для написания сообщения, третье окно — информация о пользователе. Всё это я реализовал за три дня не-напряжной работы «на коленке».

Давайте же перейдем к реализации сервера!

Прокси-сервер


Сервер я решил писать на C#, поскольку у .NET Core сейчас всё очень хорошо с кроссплатформенностью и производительностью. Его можно даже на Raspberry Pi запустить :)

Итак, какая-же архитектура такого сервера может быть? Программа инициализирует TDLib, начинает слушать её события в отдельном потоке, пока в основном потоке крутится HTTP-сервер, который обрабатывает каждый отдельный запрос с клиентского приложения. Почему синхронно? Потому что TDLib фактически не возвращает никаких идентификаторов для возвращаемых датасетов, дабы их можно было отличить друг от друга. Приведу пример: у нас есть метод getChatHistory, который возвращает n-сообщений. При этом TDLib сам определяет, сколько хочет сообщений вернуть (и в первый вызов возвращает одно сообщение вне зависимости от настрое и отправляем пакет message n-раз. При этом в пакете message нет какого-либо ID, который позволял бы ассоциировать текущий объект с какой-либо операцией. Увы!

Начинаем с коммуникации с TDLib. Для работы с библиотекой, мы будем использовать json-интерфейс. Для .NET есть биндинги через C++/CLI, но в таком случае, сервер не будет работать на Linux. Для работы с библиотекой хватит лишь три функции: CreateClientID, которая аллокейтит новый инстанс клиента, Send, которая асинхронно отправляет JSON-объект с командой, которую затем обработает TDLib и Receive, которая ждёт N-секунд и возвращает в виде ASCII-строки (!) JSON-объект с описанием события или данными после одного из запросов. За это у нас отвечает класс TDLibInterface, который объявляет PInvoke-методы для вызова нативных методов из библиотеки. .NET Core сам подгрузит библиотеку tdjson (причём на Linux он добавит ей префикс а-ля libtdjson.so, а на Windows загрузит tdjson.dll) и сам разберется с маршаллингом аргументов функций: например, string автоматически преобразует в const char*. Тем не менее, с const char* возвратами нужно быть аккуратнее — у меня был SIGSEGV, пока я ручками не конвертировал их в обычную строку.

public sealed class TDLibInterface
    {
        public const string Library = "tdjson";

        [DllImport(Library, EntryPoint = "td_create_client_id", CallingConvention = CallingConvention.Cdecl)]
        public static extern int CreateClientID();

        [DllImport(Library, EntryPoint = "td_send", CallingConvention = CallingConvention.Cdecl)]
        public static extern void Send(int id, string request);

        [DllImport(Library, EntryPoint = "td_receive", CallingConvention = CallingConvention.Cdecl)]
        private static extern IntPtr RawReceive(double timeOut);

        [DllImport(Library, EntryPoint = "td_execute")]
        public static extern StringBuilder Execute(string request);

        public static unsafe string Receive(double timeOut)
        {
            IntPtr str = RawReceive(timeOut);

            return str != IntPtr.Zero ? new string((sbyte*)str.ToPointer()) : null;
        }
    }

Позволю себе чуточку критики в сторону TDLib. Во первых, почему нет s-версии функции с возможностью указать длину входной строки, а tdjson полагается исключительно на \0 в конце строки? Во вторых, почему const char*, а не wchar_t*? Сейчас юникод во входной строке приходится escape'ами превращать в \u-последовательности.
После этого, нам нужно написать обёртку над TDLib, которая будет вызывать для зарегистрированных событий специальные функции, называемые коллбэками. При этом закомментированный WriteLine снизу — это «дебаг» для того, чтобы узнать названия неизвестных мне ивентов :)

В каждом объекте, полученном с помощью receive, есть поле "@type", которое содержит в себе имя класса возвращаемого объекта. Первый же вопрос от читателей — почему я использую JObject с ручным дерганьем нужных полей и вручную пишу JSON в виде строковых литералов вместо нормальной сериализации/десериализации? Ответ прост: во-первых, для актуализации Data-классов придется писать кодогенератор из TL-схемы, а во-вторых иногда TDLib может возвращать немного разные объекты в JSON, из-за чего приходится мудрить с атрибутами на этих самых Data-классах, иначе десериализатор выбросит исключение. Это решается нормальными юнит-тестами на всех вариантах данных, но зачем себе в колени стрелять, если нужен конкретный фиксированный функционал и лишь малое число от всех полей, возвращаемых TDLib?

                    string recv = NativeInterface.Receive(10.0d);

                    if (recv != null)
                    {
                        JObject json = JObject.Parse(recv);

                        string type = json["@type"].ToString();

                        if (!handlers.ContainsKey(type))
                        {
                            //Console.WriteLine("Unknown event type: {0}", type);
                            continue;
                        }

                        handlers[type](recv, json);
                  }
    }

Теперь переходим к самому интересному — обработке событий и реализации синхронного клиента, который позволяет без async/await просто запросить список сообщений и сразу же его получить (такой подход может быть полезен и юзерботам, которые не хотят размазывать стейты по всей программе). Почему без асинков? Честно сказать, мне они просто не нравятся: как привык к концепции wait/notify и коллбэков из Java, так их и юзаю всю жизнь :)

Сначала TDLib запрашивает параметры инициализации (стейт authorizationStateWaitTdlibParameters), затем если пользователь не авторизован — запрашивает номер телефона и код подтверждения (плюс дополнительные шаги для авторизации если они есть). В конце, TDLib возвращает стейт Ready, что означает готовность библиотеки к работе:

        private void OnAuthState(string raw, JObject obj)
        {
            JObject authState = (JObject)obj["authorization_state"];
            string type = authState["@type"].ToString();

            if (type == "authorizationStateWaitTdlibParameters")
            {
                Console.WriteLine("Preparing TDLib parameters...");
                NativeInterface.Send(InstanceID,
                    Utils.Format("{" +
                        "\"@type\": \"setTdlibParameters\", " +
                        "\"database_directory\": \"tdlib\", " +
                        "\"api_id\": {0}, " +
                        "\"api_hash\": \"{1}\", " +
                        "\"use_chat_info_database\": true," +
                        "\"use_file_database\": true," +
                        "\"use_message_database\": true," +
                        "\"system_language_code\": \"en\", " +
                        "\"device_model\": \"Phone\", " +
                        "\"application_version\": \"1.0\" " +
                   "}", APIId, APIHash));
            }

            if (type == "authorizationStateWaitPhoneNumber")
            {
                Console.WriteLine("Sending phone number");
                NativeInterface.Send(InstanceID, Utils.Format("{\"@type\": \"setAuthenticationPhoneNumber\", \"phone_number\": \"{0}\" }", PhoneNumber));
            }
            
            if(type == "authorizationStateWaitCode")
            {
                NativeInterface.Send(InstanceID, Utils.Format("{\"@type\": \"checkAuthenticationCode\", \"code\": \"{0}\" }", WaitCode));
            }

            if(type == "authorizationStateReady")
            {
                Console.WriteLine("Authorized");

                waitHandle.Set();
            }
        }
   
       ...
   
      Client.AttachEventHandler("updateAuthorizationState", OnAuthState);

После этого, можно начать работу с данными. Обратите внимание, мой подход потоко-небезопасен, его нельзя дергать из нескольких потоков одновременно! В коде ниже, я вызываю метод для фетча сообщений, а затем в соответствующем коллбэке от TDLib обрабатываю данные (дабы статья не разрасталась на 20+ минут, я чуть урезал все листинги).

        public List<Message> QueryMessagesInChat(long chatId, long lastMessage, int count)
        {
            messages.Clear();

            requestMessageCount = count;
            string json = Utils.Format("{\"@type\": \"getChatHistory\", \"chat_id\": \"{0}\", \"from_message_id\": {1}, \"limit\": {2} }", chatId, lastMessage, count);
            NativeInterface.Send(InstanceID, json);

            waitHandle.WaitOne();
            return messages;
        }

        public User QueryUser(long userId)
        {
            string json = Utils.Format("{\"@type\": \"getUser\", \"user_id\": \"{0}\" }", userId);
            NativeInterface.Send(InstanceID, json);

            waitHandle.WaitOne();
            return user;
        }

Переходим к реализации самого сервера, для наших целей хватит обычного HttpListener. Сначала мы зарегистрируем все поддерживаемые методы и занесем их в ассоциативный список ключ-значение. Сами методы реализованы в виде делегатов, которые принимают лишь один аргумент — список параметров из строки запроса, а возвращают строку — все ответы, за исключением особых (связанных с загрузкой вложений) — текстовые.

        private HttpListener listener;
        private List<HttpMethodHandler> methods;
        private ScheduledRestart restartManager;

        private void AddMethod(HttpMethodHandler info)
        {
            if(info != null)
            {
                methods.Add(info);
                Console.WriteLine("Registered method: {0}", info.Method.Name);
            }
        }

        private void PrepareMethods()
        {
            AddMethod(Chats.QueryChats);
            AddMethod(Chats.QueryMessages);
            AddMethod(Chats.SendMessage);
            AddMethod(Users.QueryUserInfo);
        }

        private void PrepareState()
        {
            // We should fetch dialog list due to TDLib nature of preloading-everything
            Client.QueryChats(15);
        }

        public HttpServer()
        {
            listener = new HttpListener();
            listener.Prefixes.Add("http://+:13377/");

            Client = new SyncClient("test");
            Client.Start();
            Client.WaitUntilReady();

            //restartManager = new ScheduledRestart(5);
            //restartManager.Start();

            methods = new List<HttpMethodHandler>();
            PrepareMethods();
            PrepareState();
        }


     ...

   public void Start()
   {
       listener.Start();

      while(listener.IsListening)
      {
           HandleRequest(listener.GetContext());
      }
  }

Переходим к обработке запроса. Метод ищет, зарегистрирован ли запрошенный метод и если да, то парсит строку запроса, которая начинается с "?", которую затем передаёт в виде коллекции ключ->значения обработчику метода:

        private void HandleRequest(HttpListenerContext ctx)
        {
            string method = ctx.Request.Url.LocalPath.Substring(1).ToLower();
            
            if (method.Length < 0)
            {
                SendResponse(HttpGenericResponse.MethodRequired.ToString(), ctx);
                return;
            }

            foreach(HttpMethodHandler handler in methods)
            {
                if(method == handler.Method.Name.ToLower())
                {
                    string result = "";

                    if (ctx.Request.Url.Query.Length > 0)
                    {
                        string[] args = ctx.Request.Url.Query.Substring(1).Split('&', StringSplitOptions.RemoveEmptyEntries);
                        Dictionary<string, string> keyValuePairs = new Dictionary<string, string>();

                        foreach (string arg in args)
                        {
                            if (arg.IndexOf('=') >= 0)
                                keyValuePairs.Add(arg.Substring(0, arg.IndexOf('=')), arg.Substring(arg.IndexOf('=') + 1));
                            else
                                keyValuePairs.Add(arg.Substring(0, arg.IndexOf('=')), "");
                        }

                        result = handler(keyValuePairs);

                        if (result == null || result.Length < 1)
                        {
                            Console.WriteLine("Suspicious result from {0}", handler.Method.Name);
                        }
                    }

                    SendResponse(result, ctx);
                    return;
                }
            }

            SendResponse(HttpGenericResponse.UnknownMethod.ToString(), ctx);
        }

А сами методы, в свою очередь, дергают соответствующие функции из клиента и формируют на их основе датасет в примитивном формате:

public static string QueryChats(Dictionary<string, string> args)
        {
            if(args.ContainsKey("count"))
            {
                int count = int.Parse(args["count"]);
                StringBuilder ret = new StringBuilder();

                List<Chat> chats = HttpServer.Instance.Client.QueryChats(count);
                ret.AppendLine(string.Format("Count={0}", chats.Count));

                foreach(Chat chat in chats)
                {
                    ret.AppendLine("Begin");
                    ret.AppendLine("ID=" + chat.ID);
                    ret.AppendLine("Date=" + chat.LastMessageDate);
                    ret.AppendLine("Name=" + chat.Name);
                    ret.AppendLine("Text=" + Uri.EscapeDataString(chat.LastMessageText));
                    ret.AppendLine("MsgId=" + chat.LastMessageID);
                    ret.AppendLine("End");
                }

                return ret.ToString();
            }

            return HttpGenericResponse.InternalException.ToString();
        }

В результате получаем вот такой простой датасет, который, как я и говорил, легко распарсить и на Siemens C60, и на Atmega328 — да где угодно! В целом, такой сервер можно использовать для реализации бота в телеграме, который будет передавать показания каких-то датчиков, сигнализацию и прочие клевые штуки!

image


Переходим к реализации клиента, т.е. приложения на Android. Здесь будет не менее интересно!

Пилим для Android


В геймдеве есть своеобразный мем — некоторые инди-разработчики сначала начинают делать меню, вместо основного геймплея, что становится предметом насмешек среди других разработчиков. Но в разработке приложений для смартфонов всё по другому — здесь как-раз таки хорошо заранее продумывать макет будущего приложения!

Поскольку у нас с вами мессенджер, то главный экран должен представлять из себя список чатов (ListView) и верхнюю панельку, где в будущем могут разместиться настройки и свайп-менюшка:

image

Такой вот простой макет.

Каждый пункт меню — это тоже отдельный layout, в котором мы по шаблону строим внешний вид будущего элемента списка. На немолодых устройствах есть смысл использовать как можно меньше контейнеров в layout'е, поскольку пересчет позиций и размеров элементов — одна из самых «тяжелых» операций в UI-фреймворке вообще. Кроме того, не стоит использовать кучу картинок и drawable — в Android 2.x всё 2D рисуется софтварно, аппаратное ускорение появилось только в 3.0 (частично).

image

Но дабы в списке диалогов что-то появилось, нужно сначала реализовать фетчинг (получение) этих самых диалогов с сервера! Сам объект, который занимается обработкой запросов называется ClientManager и является синглтоном — он в единственном экземпляре на все время работы программы. Помимо менеджмента «ноды» (т.е. прокси-сервера), токена для авторизации и обработчика ошибок, ClientManager реализует метод для асинхронного запроса информации с сервера и, собственно, формирует строки запросов с помощью соответствующих методов:

    public void queryChats(int count, Response resp) {
        sendRequest(String.format("%s/QueryChats?count=%d&auth_key=%s", nodeAddress, count, token), resp);
    }

Подгрузка чатов и сообщений реализована через Adapter — концепция «виртуальных» списков, которая предполагает что система создаст не 50 элементов интерфейса на каждую кнопку чата, а только 5 и будет их виртуально «мотать по кругу», обновляя только данные в уже существующих элементах. Это позволяет значительно ускорить отрисовку, учитывая то, что Android 2.x Canvas рисуется программно.

image

    private void updateDialogList() {
        ClientManager.getCurrent().queryChats(50, new ClientManager.Response() {
            @Override
            public void onReady(String str) {
                try {
                    List<Packets.Chat> chats = Packets.parseChatListFromQueryResponse(str);

                    DialogAdapter adapter = new DialogAdapter();
                    adapter.setChats(chats);

                    ((ListView) findViewById(R.id.messages_view)).setAdapter(adapter);
                } catch (Exception e) {
                    Toast.makeText(MainActivity.this, "Упс!", Toast.LENGTH_SHORT);
                }
            }
        });
    }

Ну вы уже явно замучились видеть простыни кода, давайте посмотрим что у нас вышло!



Шустренько, да? А ведь это ультрабюджетник Alcatel OT-916D, один из последних массовых дешевых QWERTY-смартфонов за 5 000 рублей из 2012 года. Кстати, смартфон подарил мне читатель chuvakoff.

Переходим к окну чата. Основной макет почти такой-же, как и у основного окна: только добавилась панелька для ввода сообщения снизу.

image


Концептуально, всё тоже самое — запрашиваем данные с сервера, парсим их и загружаем в адаптер, благодаря чему мы сможем листать наш диалог. Однако в сообщения я добавил контекстное меню с стандартными фишками типа копирования, ответа и прочих подобных действий. Поскольку у нас нет ни пушей, ни еще каких-либо средств для поулчения данных о новых сообщениях, я раз в определенный интервал просто получаю сообщения — и если новый датасет отличается от старого — обновляю окошко чата.

    view.setOnCreateContextMenuListener(new View.OnCreateContextMenuListener() {
                @Override
                public void onCreateContextMenu(ContextMenu contextMenu, View view, ContextMenu.ContextMenuInfo contextMenuInfo) {
                    // Reply to...
                    contextMenu.add(getString(R.string.reply)).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
                        @Override
                        public boolean onMenuItemClick(MenuItem menuItem) {
                            setReplyContext((Packets.Message) view.getTag());

                            return true;
                        }
                    });

                    // Copy
                    contextMenu.add(getString(R.string.copy)).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
                        @Override
                        public boolean onMenuItemClick(MenuItem menuItem) {
                            ViewGroup vg = (ViewGroup)view;

                            android.text.ClipboardManager manager = (android.text.ClipboardManager) view.getContext().getSystemService(CLIPBOARD_SERVICE);
                            manager.setText(((TextView)vg.findViewById(R.id.message_content)).getText());

                            return true;
                        }
                    });

                    // Send to...
                    contextMenu.add(getString(R.string.resend)).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
                        @Override
                        public boolean onMenuItemClick(MenuItem menuItem) {
                            ViewGroup vg = (ViewGroup)view;
                            String text = ((TextView)vg.findViewById(R.id.message_content)).getText().toString();

                            Intent intent = new Intent();
                            intent.setAction(Intent.ACTION_SEND);
                            intent.putExtra(Intent.EXTRA_TEXT, text);
                            intent.setType("text/plain");
                            startActivity(Intent.createChooser(intent, null));

                            return true;
                        }
                    });
                }
            });

Переходим к реализации поля для ввода сообщения. Здесь всё просто — на серверсайде за это отвечает метод SendMessage. Однако для того, чтобы с нашего клиента можно было ответить на другие сообщения, я ввёл также «контекст ответа», в котором запоминается сообщение, на которое мы хотим ответить. Telegram также поддерживает Markdown, однако его полная поддержка пока не реализована.

        EditText editText = ((EditText)findViewById(R.id.message_text));

        if(editText.getText().length() > 0) {
            long replyTo = replyContext != null ? replyContext.ID : 0;

            ClientManager.getCurrent().sendTextMessage(chat.ID, editText.getText().toString(), replyTo, new ClientManager.Response() {
                @Override
                public void onReady(String str) {

                }
            });

            editText.setText("");
            setReplyContext(null);
        }

В остальном же, функционал конечно пока совсем базовый, однако клиент работает очень шустро даже бюджетной X10 Mini Pro и позволяет чатится с моими читателями в Telegram. В будущем хотелось бы допилить:

  • Поддержка картинок: Сейчас уже есть кривоватый механизм кэширования изображений на стороне сервера, который позволяет загружать аватарки чатов. В будущем, я добавлю поддержку «галерей» с картинками!
  • Поддержка голосовых сообщений: Не все их любят, но они порой удобны и выручают. Реализую как прослушивание, так и запись!
  • Подробный просмотр профилей и менеджмент чатов: Удаление сообщений, чатов и прочие фишечки из официальных клиентов.

Казалось бы — до официальных клиентов ещё очень далеко. Но сам факт, чтобы всё это работало достаточно шустро на девайсах, которым уже более 10 лет!

Звучит интересно! Как заюзать твой клиент?


Тут всё очень и очень просто! В первую очередь, нам понадобится ПК с белым IP, роутер (если под него есть сборка dotnet), либо VDS. Виртуальные сервера сейчас стоят копейки, у ТаймВеба есть тариф за 188 рублей в месяц, которого с головой хватит для нашего сервера.

image

Такая вот рекламная интеграция (к слову, прокси для всех приложений уже более года крутятся именно на мощностях TimeWeb Cloud)!

Берём уже собранный TDLib и сервер под Windows, или собираем TDLib под Linux, накатываем .NET Core. Пример для Debian/Ubuntu:

sudo apt-get install dotnet

Затем запускаем сервер:

dotnet tdsrv.dll

Программа сначала запросит номер телефона, а затем код подтверждения Telegram. После этого будет создана папка tdlib/, где будут хранится данные вашей сессии, а также файл authkey.txt, где хранится случайный ключ для сессии (md5 phone_number + response code + псевдослучайное число). Не оставляйте его в /var/www/!

image

Если всё нормально, программа начнёт слушать порт 13377 на всех сетевых интерфейсах, в т. ч. и в локальной сети. После этого, ставим уже предварительно собранный, либо собираем сами в Android Studio APK и в окне авторизации пишем адрес ноды и ключ авторизации. Если всё настроено верно — программа запомнит сервер и будет работать без проблем! Вот так всё легко :) Как видите — всё очень и очень просто!

image

Кроме того, буквально за пару дней до публикации статьи я сел вечерком из интереса что-нить под Java-телефоны попилить… и, как и обещал, реализовал Proof of Concept возможности работы Telegram даже на сонериках, которым скоро 20 лет стукнет! А ведь если ещё чуть заморочится, можно запустить приложение даже на пресловутых монохромных сименсах!

image

Заключение


Вот такой у нас получился проект с реализацией лёгкого, примитивного, но тем не менее рабочего клиента Telegram, который на клиентской части вообще не использует никаких зависимостей. Вес собранного APK в release-версии — всего 54 килобайта! Понятное дело что с ростом функционала, вес программы будет увеличиваться, но я обещаю — больше 1Мб он не вырастет :)

image

Ну а вам, моим читателям, надеюсь было интересно прочитать такой «двойной материал» не только о разработке сетевой части без использования Apache/nginx/IIS, но и UI-фронтэнда для Android-смартфонов, которым уже более 10 лет!
Исходный код проекта можно найти на моём GitHub: как приложения, так и сервера, а также убедиться в отсутствии каких либо закладок и, если совсем не доверяете, собрать бинарники сами! Для сборки понадобится VS2017 или свежее, а также Android Studio 2.3.2 (если собираете для Android 2.1 и ниже).

image

Друзья! Сейчас на Хабре опросы сломаны, поэтому если у вас есть желание, вы можете проголосовать в комментариях: какой стиль статей вам больше нравится — где больше конкретики и кода с пояснением как конкретно работает та или иная часть программы, или наоборот стиль ближе к научпопу, где фрагментов кода нет, или их значительно меньше? Пишите своё мнение о проекте в комментариях!

Кроме того, у меня есть канал в Telegram, куда я публикую бэкстейдж статей, ссылки на новый материал, свои наработки, а также посты о ремонте девайсов и различные мысли.

Кстати, я тут недавно написал в Рутек с предложением написать подробную статью об Р-Фоне (Роса Мобайл) с точки зрения энтузиаста и разработчика. Мне ответили, что ребята пока не рассылают девайсы, но будем надеяться что в будущем я смогу заполучить Р-Фон и написать подробный технический материал о нём и на Хабре. Чуть позже написал в Ф+ (смартфоны на базе «Авроры») — там, кажись, только за, но пока обсуждения «заглохли». Думаю, многим моим читателям было бы интересно посмотреть, что же у Авроры и Роса-Мобайл под капотом с точки зрения разработчика!

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 87: ↑86 and ↓1+85
Comments32

Articles

Information

Website
timeweb.cloud
Registered
Founded
Employees
201–500 employees
Location
Россия
Representative
Timeweb Cloud