本文主要是对 Telegram 7.9.3 的随笔记载,在目前的版本上改动配置以正常编译,简单讲解主界面UI流程、初始化、推送、加解密、前后端交互等核心功能,可能不够准确,只是我目前所了解到的,欢迎指正错误和交流。

cbf982fe420cf9082721e4b6c533d44e.jpeg    

    对于比较流行的IM开源项目,后续我会再针对性地重新整理一下Signalwire项目(曾经在这两项目上二次开发过,这里是wire旧版本环境搭建+项目运行),针对最新版本再总结一下。
        在打开项目之前,先看一下 gradle.properties文件,把 org.gradle.jvmargs 改到足够大,建议6G以上,否则你会发现有些类在滑动代码时一卡一卡的,下面先针对不同的2个AS版本修改配置以确保项目编译成功。

在安装了最新版本AS(Android Studio Arctic Fox | 2020.3.1 Patch)的情况下,你可能需要改动的地方如下:
  1. gradle/wrapper/gradle-wrapper.properties 文件:

distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
gradle 插件版本跟gradle版本对照

由于我本地安装的SDK31的版本无法使用,于是将版本改为30,后续可能不需要做跟SDK版本相关的操作(下面所有操作):

TMessagesProj/build.gradle 文件:

compileSdkVersion 30
buildToolsVersion '30.0.0'

Dockerfile 文件:

ENV ANDROID_API_LEVEL android-30
ENV ANDROID_BUILD_TOOLS_VERSION 30.0.0
ENV ANDROID_VERSION 30
RUN cp $ANDROID_HOME/build-tools/30.0.3/dx $ANDROID_HOME/build-tools/30.0.0/dx
RUN cp $ANDROID_HOME/build-tools/30.0.3/lib/dx.jar $ANDROID_HOME/build-tools/30.0.0/lib/dx.jar

删除目录 values-v31/

chats_widget_info.xml 文件:

删掉 targetCellWidthtargetCellHeightpreviewLayoutwidgetFeatures

contacts_widget_info.xml 文件:

删掉 targetCellWidthtargetCellHeightpreviewLayoutwidgetFeaturesmaxResizeHeight

启动项目,可正常编译通过。

在安装较低版本的AS(以4.0.1为例)情况下,需要改动的地方如下:

build.gradle 文件:

classpath 'com.android.tools.build:gradle:4.0.1'

TMessagesProj/build.gradle 文件:

ndk.debugSymbolLevel = 'FULL'
4.1 之后 release 版本携带未精化的本地库配置。全部屏蔽掉

关于SDK31的地方参照上面方法,另外需要额外修改 Dockerfile 文件:

FROM gradle:6.5-jdk11

至此项目可正常运行,如果res或xml有异常请按照提示来屏蔽或删除。

        第一次编译可能比较耗时,慢慢等待。我个人推荐用最新版本的AS,用项目自带的gradle插件版本(4.2.1)和gradle版本(6.7.1),因为不同gradle插件版本对应的语法可能有变动,最好跟着原项目来。

说一下个人对这个项目的感受

        首先项目代码量确实比较大,以前未见过一个类里面写了5w+行代码(这是个model类,我们一般不这么搞,这点得吐槽一下,把AS搞卡了),一个聊天界面可以写2w+行代码。估算了一下org.telegram包下代码量大概50w行,这个包下没有第三方代码估算比较切合实际。做过一些项目之后,会发现一个功能齐全、稳定迭代的IM项目可能只需要15w行java代码就能搞定(估算了一下Signal的代码大概19w行,如果把Signal代码改成Kotlin可能只需要8w行左右)。之所以Telegram 的上层代码量如此多,主要是因为其中几乎所有的UI控件都是手动写出来的,剩下的是因为他们手动写不了(通知栏和widget)。Talegram debug版本给我的体验已经非常好了,动效很炫酷、流畅,内存整体上不超过400M,单账号登录不再操作UI时一段时间后会降到170M以下,值得我们研究学习。接下来由浅入深,讲一下具体功能。

UI 层次

        简而言之,在一个Activity中通过ActionBarLayout添加、移除不同视图(BaseFragment——一个普通类)来实现UI切换,ActionBarLayout通过管理BaseFragment来让其具有类似系统Fragment生命周期的功能。
        当Activity需要展示某个BaseFragment时,流程如下:

ActionBarLayout#presentFragment-->BaseFragment#createView-->BaseFragment#onResume

        当Activity需要移除某个BaseFragment时,流程如下:

ActionBarLayout#removeFragmentFromStackInternal、ActionBarLayout#closeLastFragment 
--> BaseFragment#onPause --> BaseFragment#onFragmentDestroy

下面是主要UI 图层结构(手机版):


Telegram_UI.png

透过UI看本质,程序初始化做了些什么呢?先看上层GcmPushListenerService

        为了避免误解,我们先设置UserConfig.MAX_ACCOUNT_COUNT = 1 ,因为不论有多少账号,推送入口只有一个,gcmPushToken 也只有一个。找到 ApplicationLoader#initPlayServices,在接收到Firebase返回的 gcmPushToken 之后,客户端会将其上传至IM服务器,这个过程结束之后,部分属性会被赋值:

SharedConfig.pushAuthKey = random byte[256];SharedConfig.pushString = gcmPushToken;registeredForPush = true;// 第一次收到推送消息时,下面数据会被赋值SharedConfig.pushAuthKeyId = new byte[8];byte[] authKeyHash = Utilities.computeSHA1(SharedConfig.pushAuthKey);System.arraycopy(authKeyHash, authKeyHash.length - 8, SharedConfig.pushAuthKeyId, 0, 8);

有了这些条件,就可以解密后续推送过来的数据。
Gcm推送的整体流程如下:


Telegram_GcmPushListenerService.png

数据库初始化

ApplicationLoader#getFilesDirFixed定义了项目中的缓存文件(下载的缓存文件,本地配置文件.dat,主题资源映射*.attheme,数据库.db、文本资源等)。ApplicationLoader#onCreate --> MessagesController#getInstance --> BaseController#getMessagesStorage -->
MessagesStorage#getInstance --> MessagesStorage#openDatabase  即开始建库建表。
一共 UserConfig.MAX_ACCOUNT_COUNT 个库,路径分别为 data/data/{pkg}/files、data/data//{pkg}/files/account1、data/data//{pkg}/files/account2  ……
至此,打开项目时就会创建UserConfig.MAX_ACCOUNT_COUNT个数据库。

socket 初始化

        由于 Telegram 的网络层全部在 C++ 里面(主要涉及到 ConnectionsManager.cpp、Connection.cpp、ConnectionSocket、TgNetWrapper.cpp、Datacenter.cpp、Handshake.app、EventObject.cpp等文件),这里就从头开始梳理一下底层初始化过程。在上层调用System.loadLibrary(xx_so)时,会走到对应 so 的 JNI_OnLoad 函数,本项目则是 jni::JNI_OnLoad,紧接着里面会执行 registerNativeTgNetFunctions函数,我们找到它的实现:

extern "C" int registerNativeTgNetFunctions(JavaVM *vm, JNIEnv *env) {// 检查底层所需要的上层方法是否声明(包括 ConnectionsManager#native_init),// 主要是通过反射找方法(static 和 native 方法),找不到则报错}

socket的初始化分2步
1.检查所需要的上层方法是否声明:

ApplicationLoader#onCreate --> NativeLoader#initNativeLibs 
--> jni::JNI_OnLoad --> TgNetWrapper::registerNativeTgNetFunctions

至此底层函数跟上层方法映射完毕
2.初始化:

ApplicationLoader#onCreate --> ConnectionsManager#getInstance 
--> ConnectionsManager#init --> ConnectionsManager#native_init 
--> TgNetWrapper::init --> ConnectionsManager::init 
--> ConnectionsManager::ThreadProc --> ConnectionsManager::select

至此底层网络模块初始化完毕,一共会创建 UserConfig.MAX_ACCOUNT_COUNTConnectionsManager.cpp 对象 和ConnectionsManager.java 对象。ConnectionsManager::select 函数会给每个账号创建一个死循环监听 socket fd 的状态,并分发数据。

Handshake过程——交换 nonce、 p、q ,前后端生成 aesKey、aesIv,为后续在线socket内容加解密。

        首先是Client C 向 Server S发送随机数  nonce ,然后S接收到 nonce后给C 发送随机数 server_nonce 和大质数 pq,C分解 pq 得到 p、q,然后用RSA 公钥加密 nonce 、server_nonce 、p、q并发送 ,S通过RSA 私钥解密数据并返回 encrypted_answer 给C(虽然S 代码未开源,但是可以根据C代码推理出主要实功能),C 解密 encrypted_answer,本地计算 authKey 和临时的 aesKey 、aesIv,用临时 key 和 iv 加密数据发送给S,S 接收解密之后再次回复C后表示握手完毕。此时 C 的authKey 即为后续请求(消息)的AES 加密秘钥的变量之一(另一个变量为请求数据的SHA256),所有上层数据(一般请求、消息发送和私密私聊)都会在底层加密一次。可以推理出 S 的 aesKey 和 aesIv 跟 C 的生成方式一样。参考下图:


Talegram_Handshake.png

SecretChatHelper

        普通私聊的升级版,会话id为普通私聊的id左移32位,需要彼此确认才能建立,类似握手过程,创建上层数据加密的 authKey。

#sendRequestKeyMessage
#sendAcceptKeyMessage 
#updateEncryptedChat

加解密

        关于底层加解密实现可以参考上层生成aesKey、aesIv过程MessageKeyData#generateMessageKeyData。底层代码我暂时不方便解释,避免误导。下面是Socket发收消息的加解密相关代码:

Telegram_Datacenter.png


         联想到一般的请求发给服务器之后,服务器接收需要解密,同时结合上图解密过程和推送消息的解密过程,可以看出服务端转发给客户端的消息中,在线和离线的消息加密的秘钥不一致,所以服务端解密了客户端的socket消息后,会根据对方是否在线用握手时生成的秘钥或者推送的pushAuthKey重新加密,然后发送(推送)给接收者。这次总结先写到这,具体细节后续有空再慢慢研究。

Tips:
大家可以把这个宏——#define DEBUG_VERSION打开查看底层日志。



作者:雷小坏
链接:https://www.jianshu.com/p/ea1bbe1fc8bd
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。