Тлента

Post Thumbnail

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

На этой неделе будет приложение, которое делает из Telegram ленту. Первая версия была реализована на Go, но в этом проекте всё написано на Flutter. Для Go есть отличная библиотека для работы с tdlib — доступная на GitHub библиотека gotd. Но нет ничего лучше Flutter для написания интерфейсов.

Так как я сильно ограничен по времени, то приложение будет без супердизайна и будет работать только под macOS. Но зачатки приложений для iOS и Android я тоже сделаю.

Для работы с API Telegram я нашёл библиотеку tdlib-dart. Но её всё равно пришлось форкать и править под мои нужды.

Приложение Telegram

Приложение для Telegram создаётся на страничке my.telegram.org. Мне нужны значения api_id и api_hash, которые я буду использовать в своём приложении.

Значения будем прокидывать при запуске приложения через флаг --dart-define=. Я пользуюсь VSCode, и мой конфиг для запуска свежесозданного Flutter-приложения выглядит так:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "lenta",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "toolArgs": [
                "--dart-define",
                "API_ID=api_id",
                "--dart-define",
                "API_HASH=api_hash"
            ]
        }
    ]
}

Этого достаточно для старта. Flutter-приложение я создал для всех платформ. Библиотеку tdlib соберу только для macOS, Android и iOS. А интерфейс реализую только для macOS.

Время не ждёт, пора компилировать!

Собираем

Собирать tdlib несложно, но иногда раздражают мелкие проблемы, с которыми сталкиваешься постоянно.

Начнём сборку для платформы iOS и macOS. Для этого клонируем репозиторий, переходим в папку example/ios и внимательно читаем README.md. Для сборки понадобится всего несколько команд:

Подготовка:

cd <path to TDLib sources>
mkdir native-build
cd native-build
cmake -DTD_GENERATE_SOURCE_FILES=ON ..
cmake --build .

Теперь нужно собрать OpenSSL специально для tdlib:

cd <path to TDLib sources>/example/ios
./build-openssl.sh

Для сборки будет использоваться пропатченый Python-Apple-support. К сожалению, скрипты из Python-Apple-support используют устаревший формат target triple для iOS-симулятора (добавляют лишний -simulator). Поэтому, если вы получите ошибку, в которой будет указано на проблемы сборки под таргет с названием, где фигурирует -simulator-simulator, то вам нужно вручную залезть в Python-Apple-support/Makefile и там поправить нужные таргеты. Только обратите внимание, что перед каждой сборкой в скрипте ./build-openssl.sh заново патчится Makefile, поэтому после первого запуска и исправления Makefile закомментируйте строки в файле ./build-openssl.sh:

# git clone https://github.com/beeware/Python-Apple-support
# cd Python-Apple-support
# git checkout 6f43aba0ddd5a9f52f39775d0141bd4363614020 || exit 1
# git reset --hard || exit 1
# git apply ../Python-Apple-support.patch || exit 1
# cd ..

Если всё прошло успешно, то теперь можно собирать XCFramework:

cd <path to TDLib sources>/example/ios
./build.sh

В результате сборки должен получиться набор библиотек в папке ios/tdjson и файл libtdjson.xcframework. Теперь нужно закинуть библиотеки в нужные папки для iOS и для macOS. Для macOS я использовал файл libtdjson.1.8.49.dylib из директории ios/tdjson/macOS/lib, закинул его во Flutter-проект в папку macos и добавил как фреймворк в настройках проекта через Xcode.

А для iOS использовал libtdjson.xcframework, который закинул в папку ios и тоже добавил как фреймворк в настройках проекта через Xcode.

Погнали собирать под Android. Тут у меня было значительно меньше проблем, всё собирается запуском нескольких скриптов:

./check-environment.sh
./fetch-sdk.sh
./build-openssl.sh
./build-tdlib.sh

Но важно обратить внимание на то, какой интерфейс библиотеки ожидается. Библиотека tdlib-dart работает с JSON-интерфейсом библиотеки tdlib. В документации есть важное упоминание:

You can also build TDLib with JSON interface instead of Java interface by passing "JSON" as the fifth parameter to the script ./build-tdlib.sh.

Необходимо указать правильный параметр при запуске скрипта ./build-tdlib.sh. Я просто поменял значение по умолчанию на:

TDLIB_INTERFACE=${5:-JSON}

В результате сборки у меня получился набор библиотек под архитектуры arm64-v8a, armeabi-v7a, x86_64, x86. Выглядит это вот так:

Закидываем их в правильные папки: android/app/src/main/jniLibs. Всего у вас должно получиться 4 папки под каждую архитектуру:

└── example 
    └── android 
        └── app 
            └── main 
                └── jniLibs 
                    └── arm64-v8a
                    │   └── libtdjsonandroid.so
                    └── armeabi-v7a
                    │   └── libtdjsonandroid.so
                    └── x86
                    │   └── libtdjsonandroid.so
                    └── x86_64
                        └── libtdjsonandroid.so

Для библиотеки tdlib-dart важно, чтобы название каждой библиотеки было именно таким — libtdjsonandroid. Это нужно, чтобы отличить библиотеки под Android от аналогичных библиотек под Linux.

На этом закончился первый час работы над 3-часовым проектом. Конечно, время сборки tdlib не входит в это время — она собирается ооочень долго, даже на моём не самом днищевом MacBook.

Авторизация и первые события

Всё готово к началу разработки. Как шаблон возьмём пример из библиотеки tdlib-dart.

Принцип работы tdlib довольно прост — приложение подписывается на все события, которые прилетают из библиотеки. Чтобы подключить эту библиотеку, необходимо импортировать модули:

import 'package:tdlib/td_api.dart' as td;
import 'package:tdlib/td_client.dart';

После этого мы можем инициализировать клиента и начать прослушивать поток событий от tdlib:

class _AppState extends State<MyApp> {
    Client? _client;
    StreamSubscription<td.TdObject>? _eventsSubscription;

    void _initialize() {
        if (_client != null) {
            return;
        }

        final Client cl = Client.create();
        _client = cl;

        _eventsSubscription?.cancel();
        _eventsSubscription = cl.updates.listen(_onNewEvent);

        cl.initialize();
    }
}

Делаем прилично и публикуем

Окей, у нас есть база для получения и обработки сообщений. Реализуем логику авторизации пользователя. Для этого нужно реализовать несколько экранов: ввод номера телефона, ввод проверочного кода, ввод пароля.

Начнём с использования MultiProvider, в котором нужно инициализировать клиента tdlib и сделать его доступным для всех наших сервисов:

MultiProvider(
    providers: [
        Provider(create: (context) => Client.create()..initialize()),
        // ...
    ]
)

Чтобы навигация работала как надо, будем использовать go_router. Нужно организовать все наши экраны и передать список роутов в приложение:

class App extends StatefulWidget {
    const App({super.key});

    static const title = 'Лента';

    @override
    State<App> createState() => _AppState();
}

class _AppState extends State<App> {
    @override
    Widget build(BuildContext context) {
        return MaterialApp.router(routerConfig: _router);
    }
}

final _router = GoRouter(
    routes: [
        GoRoute(
            path: '/',
            // если нет авторизации, то отправляемся на логин
            builder: (context, state) => HomeScreen(),
        ),

        // login
        GoRoute(path: '/phone', builder: (context, state) => PhoneScreen()),
        GoRoute(path: '/code', builder: (context, state) => CodeScreen()),
        GoRoute(path: '/password', builder: (context, state) => PasswordScreen()),
        GoRoute(path: '/profile', builder: (context, state) => ProfileScreen()),
        
        // messages
        GoRoute(path: '/posts', builder: (context, state) => PostsScreen()),
    ],
);

Для организации состояния в приложении будет использоваться flutter_state_notifier. Внутри StateNotifierов будет использоваться final Client client, с помощью которого будут совершаться запросы к tdlib, и через него будем подписываться на обновления, но уже внутри стейт-нотифаеров.

Дальше реализуем базовый экран HomeScreen — на этом экране у нас будет логика обработки событий UpdateAuthorizationState внутри HomeStateNotifier. Для этого подписываемся на события внутри нотифаера:

class HomeStateNotifier extends StateNotifier<HomeState> {
    final Client client;

    HomeStateNotifier({required this.client}) : super(HomeState(loading: false, state: null));

    void subscribe() {
        client.updates.listen((TdObject event) async {
            if (event is UpdateAuthorizationState) {
                await authorization(event.authorizationState);
            }
        });
    }

    Future<void> authorization(AuthorizationState val) async {
        if (val is AuthorizationStateWaitTdlibParameters) {
            await client.send(
                SetTdlibParameters(
                    systemVersion: '',
                    useTestDc: false,
                    useSecretChats: false,
                    useMessageDatabase: true,
                    useFileDatabase: true,
                    useChatInfoDatabase: true,
                    filesDirectory: await getFilesDirectory(),
                    databaseDirectory: await getDatabaseDirectory(),
                    systemLanguageCode: 'en',
                    deviceModel: 'unknown',
                    applicationVersion: '1.0.0',
                    apiId: getApiId(),
                    apiHash: getApiHash(),
                    databaseEncryptionKey: '',
                ),
            );
        } else {
            state = state.copyWith(state: val);
        }
    }
}

А в самом виджете HomeScreen будем обрабатывать изменение состояния. Это удобно, потому что для навигации используется виджет go_router, а для него нужен контекст виджета, и его неудобно использовать из нотифаера.

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

@override
Widget build(BuildContext context) {
    final state = context.watch<HomeState>().state;

    if (state is td.AuthorizationStateWaitPhoneNumber) {
        WidgetsBinding.instance.addPostFrameCallback((_) {
            context.go('/phone');
        });
    } else if (state is td.AuthorizationStateWaitCode) {
        WidgetsBinding.instance.addPostFrameCallback((_) {
            context.go('/code');
        });
    } else if (state is td.AuthorizationStateWaitPassword) {
        WidgetsBinding.instance.addPostFrameCallback((_) {
            context.go('/password');
        });
    } else if (state is td.AuthorizationStateReady) {
        WidgetsBinding.instance.addPostFrameCallback((_) {
            context.go('/posts');
        });
    } else if (state is td.AuthorizationStateLoggingOut) {
        // navigate to login screen
    } else if (state is td.AuthorizationStateClosing) {
        // navigate to login screen
    } else if (state is td.AuthorizationStateClosed) {
        // navigate to login screen
    }

    return Base(child: const Center(child: Text("Home")));
}

В зависимости от состояния, которое получили из tdlib, выполняем навигацию на нужный экран.

Первый шаг, как правило, — это ввод номера телефона. Поэтому нам нужен экран PhoneScreen, на котором мы сможем указать номер телефона и вызвать нужный метод клиента. Вместе с экраном я создам PhoneState и PhoneStateNotifier и для PhoneStateNotifier добавлю метод phone(String value):

Future<td.TdObject> phone(String value) async {
    state = state.copyWith(phone: value);

    return client.send(td.SetAuthenticationPhoneNumber(phoneNumber: value));
}

Остальные 2 экрана для указания кода и пароля создаются аналогично.

В финале авторизации мы должны получить стейт td.AuthorizationStateReady, и после этого редиректим пользователя на экран, где уже можно будет просматривать сообщения из каналов — PostsScreen. Также заводим PostsState и PostsStateNotifier. Внутри PostsStateNotifier подписываемся на обновления tdlib:

class PostsStateNotifier extends StateNotifier<PostsState> {
    final Client client;

    PostsStateNotifier({
        required this.client
    }) : super(PostsState(loading: false, message: ""));

    void subscribe() {
        client.updates.listen((TdObject event) async {
            if (event is UpdateNewMessage) {
                if (event.message.isChannelPost) {
                    state = state.copyWith(message: "${event.message.toJson()}");
                }
            }
        });
    }
}

Пара объяснений:

  • if (event is UpdateNewMessage) — тут мы получаем только обновления с новыми сообщениями.
  • if (event.message.isChannelPost) — а тут оставляем только сообщения из каналов. Личные сообщения нам не нужны.

Логика очень простая — мы выводим только последнее сообщение на экран. Сам код виджета PostsScreen:

class PostsScreen extends StatefulWidget {
    const PostsScreen({super.key});

    @override
    State<PostsScreen> createState() => _PostsScreenState();
}

class _PostsScreenState extends State<PostsScreen> {
    @override
    Widget build(BuildContext context) {
        return Base(
            child: SingleChildScrollView(
                child: SizedBox(
                    width: double.infinity,
                    child: Column(
                        crossAxisAlignment: CrossAxisAlignment.center,
                        children: [
                            Text(context.watch<PostsState>().message),    
                        ],
                    ),
                ),
            ),
        );
    }
}

Запускаем и наслаждаемся сообщениями из каналов в чистом JSON-формате.

Завершение

Фух, это было непросто. На самом деле, чтобы написать чуть-чуть Flutter-кода, пришлось неплохо так заморочиться со сборкой библиотек и ковырянии в документации. Если захотите попробовать, то весь код и собранные библиотеки доступны у меня на Gitflic:

Если вам понравился такой формат, то дайте мне знать в комментариях или в канале t.me/Код и Капуста