0. 导读相关文章:
Android A/B System 分析系列
Android A/B System OTA 分析(一)概览Android A/B System OTA 分析(二)系统image的生成Android A/B System OTA 分析(三)主系统和bootloader的通信Android A/B System OTA 分析(四)系统的启动和升级Android A/B System OTA 分析(五)客户端参数
Android Update Engine 分析系列
Android Update Engine 分析(一)MakefileAndroid Update Engine 分析(二)Protobuf和AIDL文件Android Update Engine 分析(三)客户端进程Android Update Engine 分析(四)服务端进程Android Update Engine 分析(五)服务端核心之Action机制Android Update Engine 分析(六)服务端核心之Action详解Android Update Engine 分析(七)DownloadAction之FileWriterAndroid Update Engine 分析(八)升级包制作脚本分析Android Update Engine 分析(九)delta_generator 工具的 6 种操作Android Update Engine 分析(十)生成 payload 和 metadata 的哈希
针对群里特别多人问 update_engine_client 的 offset 和 size 参数问题,特地开辟两篇文章全面讲述 offset 和 size 参数,主要包括传入的 offset 和 size 参数在代码中是如何流转的,offset 和 size 参数最终做什么用途?以及如何计算要传入的 offset 和 size 参数?
本篇从命令行开始,一步一步向下跟踪 offset 和 size 参数的传递,由于涉及具体的代码分析,所以整个文章比较繁琐。主要流程包括,命令行参数到客户端参数解析,客户端如何将参数通过 binder 服务传递给服务端,服务端再将参数设置到 HttpFetcher,然后在具体的实现中根据是本地文件还是远程文件进行不同的处理。
不论是本地文件还是远程文件,offset 和 size 都用于指定 payload 数据读取时的偏移量,以及读取数据的大小。
如果你想了解 offset 和 size 被传递和解析过程,那么本篇文章适合你。如果你只关心大概结论,请跳转到 4.3 offset 和 size 总结 以及 5. 总结 查看相关结论。
1. update_engine_client 客户端支持的参数本文涉及的Android代码版本:android‐7.1.1_r23 (NMF27D)
在上一篇《Android A/B System OTA 分析(四)系统的启动和升级》中,提供了一个使用 update_engine_client 升级的例子:
bcm7252ssffdr4:/ # update_engine_client --payload=http://stbszx-bld-5/public/android/full-ota/payload.bin --update --headers=" FILE_HASH=ozGgyQEcnkI5ZaX+Wbjo5I/PCR7PEZka9fGd0nWa+oY= FILE_SIZE=282164983 metaDATA_HASH=GLIKfE6KRwylWMHsNadG/Q8iy5f7ENWTatvMdBlpoPg= metaDATA_SIZE=21023 "
这里传入了 --payload, --update 和 --headers 三个参数。
update_engine_client 支持的全部参数如下:
update_engine_client --help Android Update Engine Client --cancel (Cancel the ongoing update and exit.) type: bool default: false --follow (Follow status update changes until a final state is reached. Exit status is 0 if the update succeeded, and 1 otherwise.) type: bool default: false --headers (A list of key-value pairs, one element of the list per line. Used when --update is passed.) type: string default: "" --help (Show this help message) type: bool default: false --offset (The offset in the payload where the CrAU update starts. Used when --update is passed.) type: int64 default: 0 --payload (The URI to the update payload to use.) type: string default: "http://127.0.0.1:8080/payload" --reset_status (Reset an already applied update and exit.) type: bool default: false --resume (Resume a suspended update.) type: bool default: false --size (The size of the CrAU part of the payload. If 0 is passed, it will be autodetected. Used when --update is passed.) type: int64 default: 0 --suspend (Suspend an ongoing update and exit.) type: bool default: false --update (Start a new update, if no update in progress.) type: bool default: false2. update_engine_client 的参数是如何解析的?
在终端调用 update_engine_client 进行升级的流程跟踪分析请参考《Android Update Engine 分析(三)客户端进程》,下面将命令行参数的流转流程总结如下:
2.1 客户端命令行传入参数进行升级# 终端传入参数调用 update_engine_client 升级 update_engine_client ----payload=http://192.168.1.200/ota_package/payload.bin --update --headers=" FILE_HASH=R7FKJ+WstITiZpW2/nMIl6Duln+bwgLnTWy0W1d79cg= FILE_SIZE=2607014024 metaDATA_HASH=eW3P9td/sH6dV0es1gf5t0N1U3iE/CMzdXZpmi1gKUA= metaDATA_SIZE=180056 "2.2 客户端解析并使用参数
客户端实际是在 UpdateEngineClientAndroid::onInit() 函数中解析并使用参数的,如下:
// 文件: system/update_engine/update_engine_client_android.cc
int UpdateEngineClientAndroid::onInit() {
...
// 以下定义了 FLAGS_payload, FLAGS_offset, FLAGS_size, FLAGS_headers 等接收命令行的参数
DEFINE_string(payload,
"http://127.0.0.1:8080/payload",
"The URI to the update payload to use.");
DEFINE_int64(offset, 0,
"The offset in the payload where the CrAU update starts. "
"Used when --update is passed.");
DEFINE_int64(size, 0,
"The size of the CrAU part of the payload. If 0 is passed, it "
"will be autodetected. Used when --update is passed.");
DEFINE_string(headers,
"",
"A list of key-value pairs, one element of the list per line. "
"Used when --update is passed.");
...
// 在指定了 "--update" 进行升级时,将命令行参数传递给 binder 服务的 applyPayload 函数
if (FLAGS_update) {
...
Status status = service_->applyPayload(
android::String16{FLAGS_payload.data(), FLAGS_payload.size()},
FLAGS_offset,
FLAGS_size,
and_headers);
...
}
命令行参数为什么是在这里解析,具体分析请参考:请参考《Android Update Engine 分析(三)客户端进程》
客户端将参数解析后将 payload, offset, size 和 headers 参数传递给 binder 服务。
2.3 binder 服务将参数传递给服务端// 文件: system/update_engine/binder_service_android.cc
Status BinderUpdateEngineAndroidService::applyPayload(
const android::String16& url,
int64_t payload_offset,
int64_t payload_size,
const std::vector& header_kv_pairs) {
...
// Binder 服务将从客户端拿到的参数传递给服务端的 ApplyPayload 接口
if (!service_delegate_->ApplyPayload(
payload_url, payload_offset, payload_size, str_headers, &error)) {
return ErrorPtrToStatus(error);
}
return Status::ok();
}
在这里 binder 将参数转发给服务端进程。
2.4 服务端处理升级参数服务端的 UpdateAttempterAndroid::ApplyPayload() 函数最终处理传入的 payload, offset, size 和 headers 参数:
// 文件: system/update_engine/update_attempter_android.cc
bool UpdateAttempterAndroid::ApplyPayload(
const string& payload_url,
int64_t payload_offset,
int64_t payload_size,
const vector& key_value_pair_headers,
brillo::ErrorPtr* error) {
...
install_plan_.download_url = payload_url;
...
base_offset_ = payload_offset;
install_plan_.payload_size = payload_size;
if (!install_plan_.payload_size) {
if (!base::StringToUint64(headers[kPayloadPropertyFileSize],
&install_plan_.payload_size)) {
install_plan_.payload_size = 0;
}
}
...
BuildUpdateActions(payload_url);
...
}
从上面的代码里,我们最终看到:
命令行参数 --payload 在这里用于设置 install_plan_.download_url,也传递给 BuildUpdateActions(payload_url) 设置 DownloadAction。命令行参数 --offset 在这里用于设置 base_offset_命令行参数 --size 在这里用于设置 install_plan_.payload_size
如果没有提供 --size 参数,则会从属性值中提取 FILE_SIZE 作为 payload_size
对于上面这些参数,问得最多的有以下两个:
- 除了使用 http 获取远程文件进行升级外,可以使用本地文件进行升级吗?如果使用 update.zip 文件,如何设置 offset 和 size 参数?
// 文件: system/update_engine/update_attempter_android.cc
void UpdateAttempterAndroid::BuildUpdateActions(const string& url) {
...
// 如果是 "file:///" 这样的格式,使用 FileFetcher
if (FileFetcher::SupportedUrl(url)) {
DLOG(INFO) << "Using FileFetcher for file URL.";
download_fetcher = new FileFetcher();
} else {
// 其余的使用 LibcurlHttpFetcher,获取网络数据
LibcurlHttpFetcher* libcurl_fetcher =
new LibcurlHttpFetcher(&proxy_resolver_, hardware_);
libcurl_fetcher->set_server_to_check(ServerToCheck::kDownload);
download_fetcher = libcurl_fetcher;
}
...
}
仔细查看这个函数传入的 url 参数并没有用来传递给某些变量,而仅仅专递给函数调用 FileFetcher::SupportedUrl(url) 用于判断是是不是 file:/// 开头的文件协议。真正被用来获取数据的,还是 install_plan_.download_url。
因此,升级既可以使用本地文件("file:///" 协议);也可以使用远程文件("http://" 或 “https://” 协议)进行升级。
3.2 远程文件升级示例代码中支持 “file:///”, “http://”, “https://”, “file://”, 第一个走本地文件读取路径, 后三个走 curl 读取路径。
但实际上我没有使用 “https://” 和 “file://” 验证过。至于 curl 是否支持其它协议,我还没有试过,如果你有试过,欢迎群里一起讨论。
从远程 http://192.168.1.200/full_0814.zip 获取升级数据:
update_engine_client --payload=http://192.168.1.200/update.zip --update --offset=7985 --size=1096237091 --headers=" FILE_HASH=fyDltdH3RkMxjJMLKWMU8SAkeWlnp+Dxb42jQpo30zc= FILE_SIZE=1096237091 metaDATA_HASH=72+DLYstrkKDp41oTV0xMCJtAIH5YAIs4Mw/4VSUXbY= metaDATA_SIZE=125561 "3.3 本地文件升级示例
从本地 file:///data/ota_package/payload.bin 获取升级数据:
update_engine_client --payload=file:///data/ota_package/payload.bin --update --headers=" FILE_HASH=R7FKJ+WstITiZpW2/nMIl6Duln+bwgLnTWy0W1d79cg= FILE_SIZE=2607014024 metaDATA_HASH=eW3P9td/sH6dV0es1gf5t0N1U3iE/CMzdXZpmi1gKUA= metaDATA_SIZE=180056 "4. 如何设置 offset 和 size 参数?
从第 3 节的两个例子可见,升级时既可以使用 payload.bin 文件,也可以用脚本生成的 update.zip 文件。
使用不同格式的文件,就涉及到另外两个参数 offset 和 size 的设置。
使用 payload.bin 该如何设置 offset 和 size?使用 update.zip 又该如何设置 offset 和 size?
想要知道如何设置 offset 和 size 参数,就需要知道这两个参数是用来做什么的,包括两个问题:在哪里使用,以及如何使用?
4.1 offset 和 size 参数的使用在前面 2.4 节我们分析过传入的 payload, offset 和 size 参数在服务端代码 UpdateAttempterAndroid::ApplyPayload() 中被使用。
我们具体看下 offset 和 size 是如何被使用的:
// 文件: system/update_engine/update_attempter_android.cc
bool UpdateAttempterAndroid::ApplyPayload(
const string& payload_url,
int64_t payload_offset,
int64_t payload_size,
const vector& key_value_pair_headers,
brillo::ErrorPtr* error) {
...
install_plan_.download_url = payload_url;
...
base_offset_ = payload_offset;
install_plan_.payload_size = payload_size;
if (!install_plan_.payload_size) {
if (!base::StringToUint64(headers[kPayloadPropertyFileSize],
&install_plan_.payload_size)) {
install_plan_.payload_size = 0;
}
}
...
BuildUpdateActions(payload_url);
...
}
这里 payload_offset 和 payload_size 分别被设置给 base_offset_ 和 install_plan_.payload_size 变量。
代码中搜索 base_offset_,发现只在 UpdateAttempterAndroid::SetupDownload() 函数中被使用,被用来传递给 fetcher->AddRange 函数:
system/update_engine$ grep -Rnw base_offset_ .
./update_attempter_android.h:158: int64_t base_offset_{0};
./update_attempter_android.cc:146: base_offset_ = payload_offset;
./update_attempter_android.cc:495: fetcher->AddRange(base_offset_,
./update_attempter_android.cc:505: fetcher->AddRange(base_offset_ + resume_offset);
./update_attempter_android.cc:507: fetcher->AddRange(base_offset_ + resume_offset,
./update_attempter_android.cc:512: fetcher->AddRange(base_offset_, install_plan_.payload_size);
./update_attempter_android.cc:516: fetcher->AddRange(base_offset_);
grep: ./.clang-format: No such file or directory
函数 UpdateAttempterAndroid::SetupDownload() 的实现如下:
void UpdateAttempterAndroid::SetupDownload() {
MultiRangeHttpFetcher* fetcher =
static_cast(download_action_->http_fetcher());
fetcher->ClearRanges();
// 如果 install_plan_.is_resume 为 true, 即 resume 的情况
if (install_plan_.is_resume) {
// Resuming an update so fetch the update manifest metadata first.
int64_t manifest_metadata_size = 0;
int64_t manifest_signature_size = 0;
prefs_->GetInt64(kPrefsManifestmetadataSize, &manifest_metadata_size);
prefs_->GetInt64(kPrefsManifestSignatureSize, &manifest_signature_size);
fetcher->AddRange(base_offset_,
manifest_metadata_size + manifest_signature_size);
// If there're remaining unprocessed data blobs, fetch them. Be careful not
// to request data beyond the end of the payload to avoid 416 HTTP response
// error codes.
int64_t next_data_offset = 0;
prefs_->GetInt64(kPrefsUpdateStateNextDataOffset, &next_data_offset);
uint64_t resume_offset =
manifest_metadata_size + manifest_signature_size + next_data_offset;
if (!install_plan_.payload_size) {
fetcher->AddRange(base_offset_ + resume_offset);
} else if (resume_offset < install_plan_.payload_size) {
fetcher->AddRange(base_offset_ + resume_offset,
install_plan_.payload_size - resume_offset);
}
} else {
// 这里 install_plan_.is_resume 为 false,表示非 resume 情况,比如刚开始下载
if (install_plan_.payload_size) {
fetcher->AddRange(base_offset_, install_plan_.payload_size);
} else {
// If no payload size is passed we assume we read until the end of the
// stream.
fetcher->AddRange(base_offset_);
}
}
}
简单来说,上面这个函数针对第一次 download,还是暂停后恢复 download 设置提取数据的范围,包括 offset 和 size。
1. 设置第一次下载的 offset 和 size对于第一次下载,直接用 base_offset_ 和 install_plan_.payload_size 进行设置,如果没有 payload_size,则只传递 base_offset_ 给 fetcher,意思就是告诉 fetcher 从 base_offset_ 开会,一直下载到数据结束。
if (install_plan_.payload_size) {
fetcher->AddRange(base_offset_, install_plan_.payload_size);
} else {
// If no payload size is passed we assume we read until the end of the
// stream.
fetcher->AddRange(base_offset_);
}
2. 调整中断后恢复下载的 offset 和 size
对于中断后恢复下载的情况,无非就是对 base_offset_ 和 payload_size 进行调整。
// 中断后恢复下载,计算恢复后的 resume_offset,然后使用 resume_offset 对 base_offset_ 和 install_plan_.payload_size 进行调整
uint64_t resume_offset =
manifest_metadata_size + manifest_signature_size + next_data_offset;
if (!install_plan_.payload_size) {
fetcher->AddRange(base_offset_ + resume_offset);
} else if (resume_offset < install_plan_.payload_size) {
fetcher->AddRange(base_offset_ + resume_offset,
install_plan_.payload_size - resume_offset);
}
3. fetch 是什么?AddRange又做了什么?
前面提到 offset 和 size 被传递给 fetcher->AddRange() 调用。
这里的 fetcher 是 MultiRangeHttpFetcher,从定义看,其包装了一个 HttpFetcher。
调用 fetcher->AddRange(offset, size) 时,将 offset 和 size 参数存放到私有成员变量 ranges_ 中。
void AddRange(off_t offset, size_t size) {
CHECK_GT(size, static_cast(0));
// 这里参数 (offset, size) 用于设置 range 的 (offset, length)
ranges_.push_back(Range(offset, size));
}
void AddRange(off_t offset) {
ranges_.push_back(Range(offset));
}
然后在传输开始时,调用 MultiRangeHttpFetcher::StartTransfer() 将上面提到的 ranges_, 将其传递给子类 base_fetcher_ 去设置实际传输的 offset 和 length。
// State change: Stopped or Downloading -> Downloading
void MultiRangeHttpFetcher::StartTransfer() {
if (current_index_ >= ranges_.size()) {
return;
}
Range range = ranges_[current_index_];
LOG(INFO) << "starting transfer of range " << range.ToString();
// 将 offset 传递给具体使用的 base_fetcher
bytes_received_this_range_ = 0;
base_fetcher_->SetOffset(range.offset());
// 将 length 传递给具体使用的 base_fetcher
if (range.HasLength())
base_fetcher_->SetLength(range.length());
else
base_fetcher_->UnsetLength();
if (delegate_)
delegate_->SeekToOffset(range.offset());
base_fetcher_active_ = true;
base_fetcher_->BeginTransfer(url_);
}
这里的 base_fetcher_ 对象又是什么呢?
base_fetcher 就是在 UpdateAttempterAndroid::BuildUpdateActions(url) 调用时根据 url 构造的 Fetcher:
如果是 file:/// 协议,则是 FileFetcher如果是其他协议(如: http://), 则是 LibcurlHttpFetcher 4.2 FileFetcher 和 LibcurlHttpFetcher
前面分析到,offset 和 size 参数最终会传递给底层的 FileFetcher 或 LibcurlHttpFetcher 的 SetOffset() 和 SetLength() 函数。
这两个底层的 Fetcher 类又是如何处理的呢?
1. FileFetcher// 文件: system/update_engine/common/file_fetcher.h
void SetOffset(off_t offset) override { offset_ = offset; }
void SetLength(size_t length) override { data_length_ = length; }
void UnsetLength() override { SetLength(0); }
FileFetcher 的 SetOffset(offset) 和 SetLength(length) 接口会设置内部的 offset_ 和 data_length_ 成员,这些成员在 BeginTransfer() 和 ScheduleRead() 中被使用:
// Begins the transfer, which must not have already been started.
void FileFetcher::BeginTransfer(const string& url) {
...
// 根据传入的 url 打开相应的文件
string file_path = url.substr(strlen("file://"));
stream_ =
brillo::FileStream::Open(base::FilePath(file_path),
brillo::Stream::AccessMode::READ,
brillo::FileStream::Disposition::OPEN_EXISTING,
nullptr);
...
// 用 offset_ 设置文件读取开始的位置
if (offset_)
stream_->SetPosition(offset_, nullptr);
...
}
void FileFetcher::ScheduleRead() {
...
// 根据 data_length_ 设置需要读取的数据长度
if (data_length_ >= 0) {
bytes_to_read = std::min(static_cast(bytes_to_read),
data_length_ - bytes_copied_);
}
...
}
这里:
- 传输开始时,FileFetcher::BeginTransfer() 设置文件数据读取的起始位置 offset传输中,FileFetcher::ScheduleRead() 设置文件数据读取的长度 length
// 文件: system/update_engine/libcurl_http_fetcher.h
void SetOffset(off_t offset) override { bytes_downloaded_ = offset; }
void SetLength(size_t length) override { download_length_ = length; }
void UnsetLength() override { SetLength(0); }
LibcurlHttpFetcher 的 SetOffset(offset) 和 SetLength(length) 接口会设置内部的 bytes_downloaded_ 和 download_length_ 成员。这些成员在 ResumeTransfer() 和 LibcurlWrite() 中被使用。
// 文件: system/update_engine/libcurl_http_fetcher.cc
void LibcurlHttpFetcher::ResumeTransfer(const string& url) {
...
// 根据 bytes_downloaded_ 和 download_length_ 设置 curl 操作读取数据的 range 范围
if (bytes_downloaded_ > 0 || download_length_) {
// Resume from where we left off.
resume_offset_ = bytes_downloaded_;
CHECK_GE(resume_offset_, 0);
// Compute end offset, if one is specified. As per HTTP specification, this
// is an inclusive boundary. Make sure it doesn't overflow.
size_t end_offset = 0;
if (download_length_) {
end_offset = static_cast(resume_offset_) + download_length_ - 1;
CHECK_LE((size_t) resume_offset_, end_offset);
}
// Create a string representation of the desired range.
string range_str = base::StringPrintf(
"%" PRIu64 "-", static_cast(resume_offset_));
if (end_offset)
range_str += std::to_string(end_offset);
CHECK_EQ(curl_easy_setopt(curl_handle_, CURLOPT_RANGE, range_str.c_str()),
CURLE_OK);
}
...
}
size_t LibcurlHttpFetcher::LibcurlWrite(void *ptr, size_t size, size_t nmemb) {
...
// curl 每次接收到数据后写入本地缓冲,调整 bytes_downloaded_,即下次获取数据的偏移
bytes_downloaded_ += payload_size;
...
return payload_size;
这里:
- 传输中,LibcurlHttpFetcher::ResumeTransfer() 根据 bytes_downloaded_(offset) 和 download_length_(size) 设置 curl 获取远程数据的范围将接收数据写入缓冲区后,LibcurlHttpFetcher::LibcurlWrite() 调整 bytes_downloaded_(offset),设置 curl 随后获取数据的 offset
整个 offset 和 size 参数跟踪的路径比我想象的要长不少,前面分析得比较啰嗦,这里捡重点说一下。
命令行传入升级文件的地址和 offset 以及 size 参数后,代码根据升级文件地址是本地(“file:///”)还是远程,使用不同的方式读取文件。
本地文件则直接打开文件进行读取,远程则使用 curl 工具进行获取。
不论是读取本地文件还是获取远程文件,目标都是要获取有效的 payload 数据,因此有三个必须的参数,那就是:
读哪个文件?由命令行的 payload 参数指定从哪里开始读取?由命令行的 offset 参数指定读取多少内容?由命令行的 size 参数指定
对于 payload.bin 文件,因为是原始的 payload 文件,所以 offset 为 0, size 为整个 payload.bin 文件的大小,如果没有提供 size, 默认就读取文件直到文件结束。
对于 update.zip 文件,里面包含了压缩的 payload.bin 文件,因为是压缩过的 payload,所以为了读取完整的 payload,必须提供 payload 在 zip 包的偏移位置,以及 payload 在 zip 包压缩后的数据大小。
再强调一次,对于 update.zip,需要提供 payload 在 zip 文件中的偏移(offset),以及压缩后的大小(size)。如果 offset 不正确,则解析不到正确的数据。如果 size 太小,则获取的数据不完整,size 太大没有问题,只不过会多获取一些没用的数据,浪费带宽。
至于 update.zip 包中,payload 文件的 offset 和 size 要如何计算,另外单独开一篇文章详细说明。
5 总结Android 升级客户端例子 update_engine_client 支持本地文件和远程文件升级,同时也支持使用原始的 payload.bin 文件或压缩包 update.zip 文件。
对于使用 payload.bin 文件升级,可以不用传递 offset 和 size 参数 (offset 默认为 0, size 参数从 headers 的 “FILE_SIZE” 中提取)。对于使用 update.zip 文件升级,必须需要传递 payload 数据在 update.zip 中的起始位置 (offset) 和大小 (size)。
如果没有传递 size 参数,代码会从 headers 的 “FILE_SIZE” 提取,因为 update.zip 中的 payload 一般是经过压缩的,所以 update.zip 包中 payload 数据一般会小于 “FILE_SIZE” 值。
对于使用本地文件,远程文件,原始的 payload.bin 和升级包 update.zip 进行升级,参考下面两个例子:
使用本地的 payload.bin 文件升级:
update_engine_client --payload=file:///data/ota_package/payload.bin --update --headers=" FILE_HASH=R7FKJ+WstITiZpW2/nMIl6Duln+bwgLnTWy0W1d79cg= FILE_SIZE=2607014024 metaDATA_HASH=eW3P9td/sH6dV0es1gf5t0N1U3iE/CMzdXZpmi1gKUA= metaDATA_SIZE=180056 "
使用远程的 update.zip 包升级:
update_engine_client --payload=http://192.168.1.200/update.zip --update --offset=7985 --size=1096237091 --headers=" FILE_HASH=fyDltdH3RkMxjJMLKWMU8SAkeWlnp+Dxb42jQpo30zc= FILE_SIZE=1096237091 metaDATA_HASH=72+DLYstrkKDp41oTV0xMCJtAIH5YAIs4Mw/4VSUXbY= metaDATA_SIZE=125561 "
对于使用 update.zip 包升级时的 offset 和 size 参数如何获取,请参考下一篇文章。
6. 其它洛奇工作中常常会遇到自己不熟悉的问题,这些问题可能并不难,但因为不了解,找不到人帮忙而瞎折腾,往往导致浪费几天甚至更久的时间。
所以我组建了几个微信讨论群(记得微信我说加哪个群,如何加微信见后面),欢迎一起讨论:
一个密码编码学讨论组,主要讨论各种加解密,签名校验等算法,请说明加密码学讨论群。一个Android OTA的讨论组,请说明加Android OTA群。一个git和repo的讨论组,请说明加git和repo群。
在工作之余,洛奇尽量写一些对大家有用的东西,如果洛奇的这篇文章让您有所收获,解决了您一直以来未能解决的问题,不妨赞赏一下洛奇,这也是对洛奇付出的最大鼓励。扫下面的二维码赞赏洛奇,金额随意:
洛奇自己维护了一个公众号“洛奇看世界”,一个很佛系的公众号,不定期瞎逼逼。公号也提供个人联系方式,一些资源,说不定会有意外的收获,详细内容见公号提示。扫下方二维码关注公众号:



