Впечатления от доработки Telegram Android

aefa163ebd977e1a17e923104526530c

Я являюсь давним и постоянным пользователем Telegram, никакие мессенджеры не заменят мне белый самолетик на фоне чистого голубого неба. Не то, чтобы я был большим поклонником Дурова, просто так исторически сложилось. И естественно, поскольку я постоянно пользуюсь Telegram на десктопе и на телефоне, мне со временем захотелось некоторых дополнительных фичей и возможностей, отсутствующих в официальных и распространенных неофициальных клиентах. Ждать милости от природы занятие бесперспективное, поэтому я решил реализовать доработки самостоятельно. Начал с десктоп версии, о чем уже писал на Хабр. Сейчас закончил первый этап доработок Android версии, и решил поделиться впечатлениями и результатом с сообществом.

Ссылка на итоговое приложение и исходный код находится в конце статьи.

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

Сейчас Kilogram (так я назвал свою модификацию официального клиента Telegram) умеет: для пользователей из списка заблокированных -

  • не подсвечивается «Никнейм печатает…» в топе окна

  • не показываются их сообщения

  • не показываются их реакции на сообщения

  • не появляются иконки непрочитанных упоминаний

  • не появляются иконки непрочитанных реакций

  • не показываются сообщения в результатах поиска (глобального и локального в чате)

Переходить в режим Kilogram и обратно в исходный режим Telegram можно через основное меню.

Чтобы добиться такого результата нужно было изменить код Telegram, а значит главная задача как у врача ‑ не навредить :).

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

Встречались классы в 30–40 тысяч строк, так что даже гитхаб отказывается показывать содержимое файла с кодом из-за того, что тот слишком велик. И если методы в десятки тысяч строк, можно через редакторы кода сворачивать/разворачивать по «плюсикам» то с ветками условных операторов длиной в тысячи строк было уже сложнее, т.к. не очень удобно скроллить постоянно вверх-вних в поисках отличий, держать в голове многочисленный контекст и считать отступы, потому что внутри внешних веток условий есть вложенные внутренние, также длиной сотни и тысячи строк. Причем, во многих случаях в коде обеих веток условий есть достаточно обширные (вплоть до 80–100%) общие части:

    if (currentType >= 0) {
        cacheByChatsController.setKeepMedia(currentType, keepMedia);
        if (callback != null) {
            callback.onKeepMediaChange(currentType, keepMedia);
        }
    } else {
        if (callback != null) {
            callback.onKeepMediaChange(currentType, keepMedia);
        }
    }
    if (topicId == 0) {
        state = getMessagesStorage().getDatabase().executeFast("REPLACE INTO reaction_mentions VALUES(?, ?, ?)");
        state.requery();
        state.bindInteger(1, messageId);
        state.bindInteger(2, hasUnreadReaction ? 1 : 0);
        state.bindLong(3, dialogId);
        state.step();
        state.dispose();
    } else {
        state = getMessagesStorage().getDatabase().executeFast("REPLACE INTO reaction_mentions_topics VALUES(?, ?, ?, ?)");
        state.requery();
        state.bindInteger(1, messageId);
        state.bindInteger(2, hasUnreadReaction ? 1 : 0);
        state.bindLong(3, dialogId);
        state.bindLong(4, topicId);
        state.step();
        state.dispose();
    }
    if (progressToFullView != 1f) {
        childImage.draw(canvas);
        fullImage.setImageCoords(childImage.getImageX(), childImage.getImageY(), childImage.getImageWidth(), childImage.getImageHeight());
        fullImage.draw(canvas);
    } else {
        fullImage.setImageCoords(childImage.getImageX(), childImage.getImageY(), childImage.getImageWidth(), childImage.getImageHeight());
        fullImage.draw(canvas);
    }
    if (animationInProgress) {
        float startY = viewPadding + (animateFromPosition * (1f - animationProgress) + animateToPosition * animationProgress) * lineH - startOffset;
        rectF.set(0, startY + linePadding, getMeasuredWidth(), startY + lineH - linePadding);
        canvas.drawRoundRect(rectF, r, r, selectedPaint);
    } else {
        float startY = viewPadding + selectedPosition * lineH - startOffset;
        rectF.set(0, startY + linePadding, getMeasuredWidth(), startY + lineH - linePadding);
        canvas.drawRoundRect(rectF, r, r, selectedPaint);
    }

Иногда можно увидеть даже такие варианты:

    if (i == 0) {
        particlePaints[i].setStrokeWidth(AndroidUtilities.dp(1.4f));
        particlePaints[i].setStyle(Paint.Style.STROKE);
        particlePaints[i].setStrokeCap(Paint.Cap.ROUND);
    } else {
        particlePaints[i].setStrokeWidth(AndroidUtilities.dp(1.2f));
        particlePaints[i].setStyle(Paint.Style.STROKE);
        particlePaints[i].setStrokeCap(Paint.Cap.ROUND);
    }

Вплоть до полного (!) дублирования кода в обеих ветках условия:

    if (lastOffset == Integer.MAX_VALUE) {
        layoutManager.scrollToPositionWithOffset(0,  0);
    } else {
        layoutManager.scrollToPositionWithOffset(0, 0);
    }
    if (drawInBackground) {
        placeholderMatrix[index].postTranslate(-offset + totalTranslation - x, 0);
    } else {
        placeholderMatrix[index].postTranslate(-offset + totalTranslation - x, 0);
    }
    if (service.isScreencast()) {
        bottomButton.setType(VoIpSwitchLayout.Type.VIDEO, false, fast);
    } else {
        bottomButton.setType(VoIpSwitchLayout.Type.VIDEO, false, fast);
    }

Или же подобные конструкции:

    if (x > inset && x < width && y > inset && y < height) {
        return 0;
    }
    
    return 0;

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

    if (d.last_message_date == 0) {
        ArrayList arrayList = new_dialogMessage.get(d.id);
        if (arrayList != null) {
            int maxDate = Integer.MIN_VALUE;
            for (int i = 0; i < arrayList.size(); ++i) {
                MessageObject msg = arrayList.get(i);
                if (msg != null && msg.messageOwner != null && maxDate < msg.messageOwner.date) {
                    maxDate = msg.messageOwner.date;
                }
            }
            if (maxDate > Integer.MIN_VALUE) {
                d.last_message_date = maxDate;
            }
        }
    }
    if (d.last_message_date == 0) {
        ArrayList messages = new_dialogMessage.get(d.id);
        if (messages != null) {
            int maxDate = Integer.MIN_VALUE;
            for (int i = 0; i < messages.size(); ++i) {
                MessageObject msg = messages.get(i);
                if (msg != null && msg.messageOwner != null && msg.messageOwner.date > maxDate) {
                    maxDate = msg.messageOwner.date;
                }
            }
            if (maxDate > Integer.MIN_VALUE) {
                d.last_message_date = maxDate;
            }
        }
    }

И такой код встречатеся в файле класса 5 раз.

Но довольно о копипасте. Вообще, анализатор кода Android Studio подсвечивает очень много подобных участков и даже дает советы как их можно изменить, но моей целью не было приводить в порядок исходный код, а только внести свои работоспособные изменения. Хотя иногда приходилось сильно рефакторить — например, последний пример 5 раз встречающегося кода надо было доработать, увеличив объем каждого участка еще раза в два, и я скрепя сердце все-таки вынес его в отдельный приватный метод.

Из забавного:

    public boolean loadingBlockedPeers = false;
    public LongSparseIntArray blockePeers = new LongSparseIntArray();

И конечно далее во всех классах используется именно blockePeers. Понятно, что это опечатка, и легко исправляется. Я даже порывался исправить, но потом подумал — если после исправления я буду искать по проекту blockedPeers, то мне будет попадаться и та булевская переменная выше, и еще много всего. А так, с опечаткой у нас есть уникальное ипя поля класса, которое при поиске не перемешается ни с чем другим :)

Впрочем, встречались не только стилистические ошибки, легко выявляемые анализатором кода, а такие, например:

    if (!missingData && !updates.entities.isEmpty()) {
        for (int a = 0; a < updates.entities.size(); a++) {
            TLRPC.MessageEntity entity = updates.entities.get(a);
            if (entity instanceof TLRPC.TL_messageEntityMentionName) {
                long uid = ((TLRPC.TL_messageEntityMentionName) entity).user_id;
                TLRPC.User entityUser = getUser(uid);
                if (entityUser == null || entityUser.min) {
                    entityUser = getMessagesStorage().getUserSync(uid);
                    if (entityUser != null && entityUser.min) {
                        entityUser = null;
                    }
                    if (entityUser == null) {
                        missingData = true;
                        break;
                    }
                    putUser(user, true);
                }
            }
        }
    }

В коде выше мы прикладываем много сил, чтобы получить непустое значение локальной переменной entityUser. Эта локальная переменная определена внутри блока условного оператора и используется исключительно только в процитированном небольшом участке кода. Но получив, мы ее игнорируем, и вызываем putUser на переменной user, которая определена выше по коду во внешнем контексте, и имеет свой вызов putUser. Очень сильно подозреваю, что должно было быть putUser (entityUser, true).

Еще пример:

    if (topicId != 0) {
        cursor = getMessagesStorage().getDatabase().queryFinalized(String.format(Locale.US, "SELECT message_id, state FROM reaction_mentions WHERE message_id IN (%s) AND dialog_id = %d", stringBuilder, dialogId));
    } else {
        cursor = getMessagesStorage().getDatabase().queryFinalized(String.format(Locale.US, "SELECT message_id, state FROM reaction_mentions_topics WHERE message_id IN (%s) AND dialog_id = %d AND topic_id = %d", stringBuilder, dialogId, topicId));
    }

Причем выше в том же файле подобная же конструкция вызывается как надо. Налицо ошибка вследствие копипасты. Хотя тут можно копнуть еще глубже. До какого-то времени в Телеграм не было форумов с топиками, а с их появлением клиентские версии надо было доработать. И вместо варианта добавить во все нужные таблицы локальной БД колонку topic_id DEFAULT 0, все таблицы просто продублировали. В итоге есть сообщения, а есть сообщения в топиках, есть реакции-упоминания, а есть то же самое, но только для топиков, и т.п. Возможно, у разработчиков были свои аргументы в пользу такого решения. Но это привело к необходимости проверок и дублирования во всех местах использования. Как следствие, множество кода с условием — если есть идентификатор топика, то используем одну таблицу, если нет — то другую. Ну и ошибки копипасты, естественно.

Встречались и менее очевидные ошибки, замеченные только при тестах изменённого кода. Вот короткий пример:

    button.setUsers(users);
    if (users != null && !users.isEmpty()) {
        button.count = 0;
        button.counterDrawable.setCount(0, false);
    }

Этот код выполняется в контексте класса, отвечающего за отрисовку реакций на сообщении. Мне нужно было внести изменения, чтобы при активированном фильтре, реакции от заблокированных пользователей не показывались на сообщении. Телеграм использует следующую логику: если общее число реакций меньше 4, то он показывает их не числом, а списком аватарок пользователей, выставивших эти реакции. И код выше как раз проверяет, что если массив пользователей не пустой, тогда стереть число реакций, показывая только аватарки пользователей.Но при этом не проверяется, что мы заполнили ровно столько пользователей, сколько и было реакций. В результате, если у нас в локальный кеш не подгружен какой-то пользователь с его аватаркой, или сервер по прошествии времени решил почистить заэкспайренные реакции из блока недавних recent, то мы можем показать на экране меньше аватарок, чем было реально реакций. Простыми словами — если на сообщении 3 реакции, то мы можем увидеть всего 2 или 1 аватарку. Исправляется это легко — достаточно заменить проверку ! users.isEmpty () на users.size () == button.count.

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

    private void updateVisibleRows(int mask) {
        if (listView == null) {
            return;
        }
        int count = listView.getChildCount();
        for (int a = 0; a < count; a++) {
            View child = listView.getChildAt(a);
            if (child instanceof ManageChatUserCell) {
                ((ManageChatUserCell) child).update(mask);
            }
        }
    }

Когда я попытался использовать этот подход для скрытия/показа сообщений от заблокированных пользователей в чате (при включении/отключении режима Kilogram), некоторые сообщения не меняли своего отображения. Дело в том, что компонент RecyclerView подготавливает несколько дополнительных View выше и ниже видимой области списка — для плавного отображения при прокрутке. Но эти элементы невидимы и не входят в перечень дочерних элементов listView.getChildAt (a), поэтому не обновляют свое представление при вызове вышеприведенного метода. И при прокрутке они появляются в области видимости в необновленном виде. Мне нужно было надежное решение для реализуемого мной функционала, поэтому я выбрал другой путь — при связывании ViewHolder помещаю его в множество (поле класса адаптера), при его ресайкле — удаляю из этого множества, и в результате в любой момент могу получить все связанные ViewHolder-ы как элементы этого множества. Подобный метод решения описанной проблемы изложен в этом посте на Stack Overflow

Также пришлось доработать код RecyclerView.LayoutManager. Вот код одного из его методов (до доработок):

    // Returns the first child that is visible in the provided index range, i.e. either partially or
    // fully visible depending on the arguments provided. Completely invisible children are not
    // acceptable by this method, but could be returned
    // using #findOnePartiallyOrCompletelyInvisibleChild
    View findOneVisibleChild(int fromIndex, int toIndex, boolean completelyVisible,
            boolean acceptPartiallyVisible) {
        ensureLayoutState();
        @ViewBoundsCheck.ViewBounds int preferredBoundsFlag = 0;
        @ViewBoundsCheck.ViewBounds int acceptableBoundsFlag = 0;
        if (completelyVisible) {
            preferredBoundsFlag = (ViewBoundsCheck.FLAG_CVS_GT_PVS | ViewBoundsCheck.FLAG_CVS_EQ_PVS
                    | ViewBoundsCheck.FLAG_CVE_LT_PVE | ViewBoundsCheck.FLAG_CVE_EQ_PVE);
        } else {
            preferredBoundsFlag = (ViewBoundsCheck.FLAG_CVS_LT_PVE
                    | ViewBoundsCheck.FLAG_CVE_GT_PVS);
        }
        if (acceptPartiallyVisible) {
            acceptableBoundsFlag = (ViewBoundsCheck.FLAG_CVS_LT_PVE
                    | ViewBoundsCheck.FLAG_CVE_GT_PVS);
        }
        return (mOrientation == HORIZONTAL) ? mHorizontalBoundCheck
                .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag,
                        acceptableBoundsFlag) : mVerticalBoundCheck
                .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag,
                        acceptableBoundsFlag);
    }

Суть метода пояснена в комментариях: он возвращает первый полностью или частично (в зависимости переданных от аргументов) видимый элемент списка. Однако, элементы нулевой высоты по логике этого метода являются полностью видимыми, но при этом не являются частично видимыми. Это приводило к забавным артефактам: при прокрутке до конца списка не исчезала кнопка PageDown, не отсылался запрос на сервер о пометке чата как полностью прочитанного и т.п. Но тут разработчики Telegram ни при чем — они не используют элементы нулевой высоты, и у них все работает нормально.

Это далеко не весь перечень обнаруженных мной ошибок или неоптимальностей в коде Telegram. Но при этом, проект работает, по всей видимости проходит все тесты на этапе выпуска релиза, и пользователи довольны -, а это главное. В конце концов мы оцениваем программные продукты по тому, как они выглядят и работают, и зачастую не смотрим в их исходный код.

Самостоятельно попробовать Kilogram и посмотреть исходный код можно по этой ссылке.

Также готов обсудить предложения и пожелания по доработке официального Телеграм клиента Андроид под кастомные требования.

© Habrahabr.ru