for (int i = 0; i < TARGET_MODULE_COUNT; ++i) {
const char* so_name = TARGET_MODULES[i];
__android_log_print(ANDROID_LOG_INFO, kTag, “try to hook function in %s.”, so_name);
void* soinfo = xhook_elf_open(so_name);
if (!soinfo) {
__android_log_print(ANDROID_LOG_WARN, kTag, “Failure to open %s, try next.”, so_name);
continue;
}
xhook_hook_symbol(soinfo, “open”, (void*)ProxyOpen, (void**)&original_open);
xhook_hook_symbol(soinfo, “open64”, (void*)ProxyOpen64, (void**)&original_open64);
bool is_libjavacore = (strstr(so_name, “libjavacore.so”) != nullptr);
if (is_libjavacore) {
if (xhook_hook_symbol(soinfo, “read”, (void*)ProxyRead, (void**)&original_read) != 0) {
__android_log_print(ANDROID_LOG_WARN, kTag, “doHook hook read failed, try __read_chk”);
if (xhook_hook_symbol(soinfo, “__read_chk”, (void*)ProxyReadChk, (void**)&original_read_chk) != 0) {
__android_log_print(ANDROID_LOG_WARN, kTag, “doHook hook failed: __read_chk”);
xhook_elf_close(soinfo);
return JNI_FALSE;
}
}
if (xhook_hook_symbol(soinfo, “write”, (void*)ProxyWrite, (void**)&original_write) != 0) {
__android_log_print(ANDROID_LOG_WARN, kTag, “doHook hook write failed, try __write_chk”);
if (xhook_hook_symbol(soinfo, “__write_chk”, (void*)ProxyWriteChk, (void**)&original_write_chk) != 0) {
__android_log_print(ANDROID_LOG_WARN, kTag, “doHook hook failed: __write_chk”);
xhook_elf_close(soinfo);
return JNI_FALSE;
}
}
}
xhook_hook_symbol(soinfo, “close”, (void*)ProxyClose, (void**)&original_close);
xhook_elf_close(soinfo);
}
__android_log_print(ANDROID_LOG_INFO, kTag, “doHook done.”);
return JNI_TRUE;
}
在上面的代码中,分别 hook libopenjdkjvm.so、libjavacore.so、libopenjdk.so 中的 open、open64、close函数,此外还会额外 hook libjavacore.so 的 read、__read_chk、write、__write_chk 的方法。这样打开、读写、关闭全流程都可以 hook 到了。hook 之后,调用被 hook 的函数都会先被 matrix 拦截处理。
此外,我们还可以看到 xHook 的使用是非常简单的,流程如下:
- 调用 xhook_elf_open 打开对应的 so
- 调用 xhook_hook_symbol hook 对应的方法
- 调用 xhook_elf_close close 资源,防止资源泄漏
- 如果需要还原 hook,也是调用 xhook_hook_symbol 进行 hook 点的还原
open
matrix IO 模块目前只检测主线的 IO 问题,当 open 等操作执行成功时,才会进入统计、检测流程。
在 open 操作中,会将入参与出参一起作为参数向下层传递,这里的返回值 ret 实际上是指文件描述符 fd。
int ProxyOpen64(const char *pathname, int flags, mode_t mode) {
if(!IsMainThread()) {
return original_open64(pathname, flags, mode);
}
int ret = original_open64(pathname, flags, mode);
if (ret != -1) {
DoProxyOpenLogic(pathname, flags, mode, ret);
}
return ret;
}
在捕获到 open 操作后,下面就转入了 IOCanary 的处理逻辑了。在 DoProxyOpenLogic 函数中,首先调用 Java 层的 IOCanaryJniBridge#getJavaContext 方法获取当前的上下文环境 JavaContext,然后将 Java 层的 JavaContext 转为 C 层的 java_context;最后调用了 IOCanary#OnOpen 方法。
static void DoProxyOpenLogic(const char pathname, int flags, mode_t mode, int ret) {
JNIEnv env = NULL;
kJvm->GetEnv((void**)&env, JNI_VERSION_1_6);
if (env == NULL || !kInitSuc) {
__android_log_print(ANDROID_LOG_ERROR, kTag, “ProxyOpen env null or kInitSuc:%d”, kInitSuc);
} else {
jobject java_context_obj = env->CallStaticObjectMethod(kJavaBridgeClass, kMethodIDGetJavaContext);
if (NULL == java_context_obj) {
return;
}
jstring j_stack = (jstring) env->GetObjectField(java_context_obj, kFieldIDStack);
jstring j_thread_name = (jstring) env->GetObjectField(java_context_obj, kFieldIDThreadName);
char* thread_name = jstringToChars(env, j_thread_name);
char* stack = jstringToChars(env, j_stack);
JavaContext java_context(GetCurrentThreadId(), thread_name == NULL ? “” : thread_name, stack == NULL ? “” : stack);
free(stack);
free(thread_name);
iocanary::IOCanary::Get().OnOpen(pathname, flags, mode, ret, java_context);
env->DeleteLocalRef(java_context_obj);
env->DeleteLocalRef(j_stack);
env->DeleteLocalRef(j_thread_name);
}
}
IOCanary#OnOpen 代理调用了 IOInfoCollector#OnOpen 方法。在后者的实现中,会以 fd 为 key, pathname、java_context 等值组成的对象 IOInfo 作为 value,保存到 info_map_ 这个 map 中。 IOInfo 这个对象里面的字段很多,包含了 IOCanary 对 IO 问题检测的各方面所需的字段,具体里面有什么我们下面遇到再说。 IOCanary#OnOpen 代码如下:
// matrix/matrix-android/matrix-io-canary/src/main/cpp/core/io_canary.cc
void IOCanary::OnOpen(const char *pathname, int flags, mode_t mode,
int open_ret, const JavaContext& java_context) {
collector_.OnOpen(pathname, flags, mode, open_ret, java_context);
}
// matrix/matrix-android/matrix-io-canary/src/main/cpp/core/io_info_collector.cc
void IOInfoCollector::OnOpen(const char *pathname, int flags, mode_t mode
, int open_ret, const JavaContext& java_context) {
//__android_log_print(ANDROID_LOG_DEBUG, kTag, “OnOpen fd:%d; path:%s”, open_ret, pathname);
if (open_ret == -1) {
return;
}
if (info_map_.find(open_ret) != info_map_.end()) {
//_android_log_print(ANDROID_LOG_WARN, kTag, "OnOpen fd:%d already in info_map", open_ret);
return;
}
std::shared_ptr info = std::make_shared(pathname, java_context);
info_map_.insert(std::make_pair(open_ret, info));
}
至此,open 流程相关的代码我们梳理了一下,就是以 open 操作中的 fd 为 key,对应的 IOInfo 为 value 保存到哈希表中备用。
read/write
read 操作 hook 了 read、__read_chk 两个函数,函数定义如下:
// read() attempts to read up to count bytes from file descriptor fd into the buffer starting at buf.
ssize_t read(int fd, void *buf, size_t count);
// The interface __read_chk() shall function in the same way as the interface read(), except that __read_chk() shall check for buffer overflow before computing a result. If an overflow is anticipated, the function shall abort and the program calling it shall exit.
//
// The parameter buflen specifies the size of the buffer buf. If nbytes exceeds buflen, the function shall abort, and the program calling it shall exit.
//
// The __read_chk() function is not in the source standard; it is only in the binary standard.
ssize_t __read_chk(int fd, void * buf, size_t nbytes, size_t buflen);
因此,读写操作的 buffer_size 都应该对应第三个参数才是。
接着,我们看看代理函数。在读的代理函数中,依旧是只处理主线程的调用。这里面 ret 表示的是本次操作中读取到的字节长度,同时还记录本次读的操作耗时 read_cost_us。收集到入参、出参以及耗时这五项参数后,作为入参调用 IOCanary#OnRead 函数。
/**
- Proxy for read: callback to the java layer
*/
ssize_t ProxyRead(int fd, void *buf, size_t size) {
if(!IsMainThread()) {
return original_read(fd, buf, size);
}
int64_t start = GetTickCountMicros();
size_t ret = original_read(fd, buf, size);
long read_cost_us = GetTickCountMicros() - start;
//__android_log_print(ANDROID_LOG_DEBUG, kTag, “ProxyRead fd:%d buf:%p size:%d ret:%d cost:%d”, fd, buf, size, ret, read_cost_us);
iocanary::IOCanary::Get().OnRead(fd, buf, size, ret, read_cost_us);
return ret;
}
ssize_t ProxyReadChk(int fd, void* buf, size_t count, size_t buf_size) {
if(!IsMainThread()) {
return original_read_chk(fd, buf, count, buf_size);
}
int64_t start = GetTickCountMicros();
ssize_t ret = original_read_chk(fd, buf, count, buf_size);
long read_cost_us = GetTickCountMicros() - start;
//__android_log_print(ANDROID_LOG_DEBUG, kTag, “ProxyRead fd:%d buf:%p size:%d ret:%d cost:%d”, fd, buf, size, ret, read_cost_us);
iocanary::IOCanary::Get().OnRead(fd, buf, count, ret, read_cost_us);
return ret;
}
在 IOCanary#OnRead 函数中,还是代理调用了 IOInfoCollector#OnRead 函数:
void IOCanary::OnRead(int fd, const void *buf, size_t size,
ssize_t read_ret, long read_cost) {
collector_.OnRead(fd, buf, size, read_ret, read_cost);
}
void IOInfoCollector::OnRead(int fd, const void *buf, size_t size,
ssize_t read_ret, long read_cost) {
if (read_ret == -1 || read_cost < 0) {
return;
}
if (info_map_.find(fd) == info_map_.end()) {
//_android_log_print(ANDROID_LOG_DEBUG, kTag, "OnRead fd:%d not in info_map", fd);
return;
}
CountRWInfo(fd, FileOpType::kRead, size, read_cost);
}
如果 fd 在 map 中,也就是说如果 open 时被捕获到了,那么才会进入 CountRWInfo 这个函数。CountRWInfo 会记录 IOInfo 所代表的文件的累计读写操作次数、累计buffer size、累计操作耗时、单次读写最大耗时、当前连续读写操作耗时、最大连续读写操作耗时、本次操作时间戳、最大操作buffer size、操作类型这些数据,具体看下面代码即可,一目了然。
void IOInfoCollector::CountRWInfo(int fd, const FileOpType &fileOpType, long op_size, long rw_cost) {
if (info_map_.find(fd) == info_map_.end()) {
return;
}
// 获取系统的当前时间,单位微秒(us)
const int64_t now = GetSysTimeMicros();
// 累计读写操作次数累加
info_map_[fd]->op_cnt_ ++;
// 累计buffer size
info_map_[fd]->op_size_ += op_size;
// 累计文件读写耗时
info_map_[fd]->rw_cost_us_ += rw_cost;
// 单次文件读写最大耗时
if (rw_cost > info_map_[fd]->max_once_rw_cost_time_μs_) {
info_map_[fd]->max_once_rw_cost_time_μs_ = rw_cost;
}
//android_log_print(ANDROID_LOG_DEBUG, kTag, "CountRWInfo rw_cost:%d max_once_rw_cost_time:%d current_continual_rw_time:%d;max_continual_rw_cost_time_:%d; now:%lld;last:%lld",
// rw_cost, info_map_[fd]->max_once_rw_cost_time_μs_, info_map_[fd]->current_continual_rw_time_μs_, info_map_[fd]->max_continual_rw_cost_time_μs_, now, info_map_[fd]->last_rw_time_ms_);
// 连续读写耗时,若两次操作超过阈值(8000us,约为一帧耗时16.6667ms的一半),则不累计
if (info_map_[fd]->last_rw_time_μs_ > 0 && (now - info_map_[fd]->last_rw_time_μs_) < kContinualThreshold) {
info_map_[fd]->current_continual_rw_time_μs_ += rw_cost;
} else {
info_map_[fd]->current_continual_rw_time_μs_ = rw_cost;
}
// 最大连续读写耗时
if (info_map_[fd]->current_continual_rw_time_μs_ > info_map_[fd]->max_continual_rw_cost_time_μs_) {
info_map_[fd]->max_continual_rw_cost_time_μs_ = info_map_[fd]->current_continual_rw_time_μs_;
}
// 本次读写记录的时间戳
info_map_[fd]->last_rw_time_μs_ = now;
// 最大读写操作buffer size
if (info_map_[fd]->buffer_size_ < op_size) {
info_map_[fd]->buffer_size_ = op_size;
}
// 读写操作类型
if (info_map_[fd]->op_type_ == FileOpType::kInit) {
info_map_[fd]->op_type_ = fileOpType;
}
}
我们可以看到 read 时就将对 IOInfo 里面的字段进行了赋值。实际上对 write 操作的统计也和 read 操作类似,最后也是调用的 CountRWInfo 函数对写操作进行统计,这里不做更多赘述。
/**
- Proxy for write: callback to the java layer
*/
ssize_t ProxyWrite(int fd, const void *buf, size_t size) {
if(!IsMainThread()) {
return original_write(fd, buf, size);
}
int64_t start = GetTickCountMicros();
size_t ret = original_write(fd, buf, size);
long write_cost_μs = GetTickCountMicros() - start;
//__android_log_print(ANDROID_LOG_DEBUG, kTag, “ProxyWrite fd:%d buf:%p size:%d ret:%d cost:%d”, fd, buf, size, ret, write_cost_μs);
iocanary::IOCanary::Get().OnWrite(fd, buf, size, ret, write_cost_μs);
return ret;
}
close
close 时我们会对整个文件生命周期的一些操作进行最后的统计并通知 detector 进行检测上报。
我们先看看 close 的代理方法,如下所示。可以看到,只是调用 IOCanary#OnClose 方法。
/**
- Proxy for close: callback to the java layer
*/
int ProxyClose(int fd) {
if(!IsMainThread()) {
return original_close(fd);
}
int ret = original_close(fd);
//__android_log_print(ANDROID_LOG_DEBUG, kTag, “ProxyClose fd:%d ret:%d”, fd, ret);
iocanary::IOCanary::Get().OnClose(fd, ret);
return ret;
}
接着看看 IOCanary#OnClose 方法的实现,这里先调用了 IOInfoCollector#OnClose 方法进行最后的统计操作。 具体操作为:通过当前系统时间减去 IOInfo 创建的时间得到的文件操作的生命周期的总时间,以及 stat 函数获取文件的 size。最后从 map 中移除并返回该对象。
然后通过 OfferFileIOInfo 方法将此 IOInfo 提交给检测线程中让各个 detector 进行检测。
// matrix/matrix-android/matrix-io-canary/src/main/cpp/core/io_canary.cc
void IOCanary::OnClose(int fd, int close_ret) {
std::shared_ptr info = collector_.OnClose(fd, close_ret);
if (info == nullptr) {
return;
}
OfferFileIOInfo(info);
}
// matrix/matrix-android/matrix-io-canary/src/main/cpp/core/io_info_collector.cc
std::shared_ptr IOInfoCollector::OnClose(int fd, int close_ret) {
if (info_map_.find(fd) == info_map_.end()) {
//_android_log_print(ANDROID_LOG_DEBUG, kTag, "OnClose fd:%d not in info_map", fd);
return nullptr;
}
// 系统当前时间减去IOInfo对象初始化的时间,则为整个文件的生命周期时间
info_map_[fd]->total_cost_μs_ = GetSysTimeMicros() - info_map_[fd]->start_time_μs_;
// 通过stat函数获取文件的实际尺寸
info_map_[fd]->file_size_ = GetFileSize(info_map_[fd]->path_.c_str());
std::shared_ptr info = info_map_[fd];
info_map_.erase(fd);
return info;
}
// matrix/matrix-android/matrix-io-canary/src/main/cpp/comm/io_canary_utils.cc
int GetFileSize(const char* file_path) {
struct stat stat_buf;
if (-1 == stat(file_path, &stat_buf)) {
return -1;
}
return stat_buf.st_size;
}
下面我们看看 OfferFileIOInfo 的相关实现,这是 C++ 实现的一个生产者消费者模型。具体代码如下,这里不做过多讲解。 我们看到 Detect 函数,这个函数运行在 IOCanary 初始化时就创建的工作线程中,在取到 IOInfo 之后,就挨个调用 detector 进行检测,并将检测结果添加到 published_issues 这个数组中。最后调用 issued_callback_ 这个函数指针进行上报。实际上这里的 issued_callback_ 就是 io_canary_jni.cc 中的 OnIssuePublish 函数。
// io_canary.h
class IOCanary {
…
private:
…
std::deque<std::shared_ptr> queue_;
std::mutex queue_mutex_;
std::condition_variable queue_cv_;
}
// io_canary.cc
// 生产者
void IOCanary::OfferFileIOInfo(std::shared_ptr file_io_info) {
std::unique_lockstd::mutex lock(queue_mutex_);
queue_.push_back(file_io_info);
queue_cv_.notify_one();
lock.unlock();
}
IOCanary::IOCanary() {
exit_ = false;
std::thread detect_thread(&IOCanary::Detect, this);
detect_thread.detach();
}
void IOCanary::Detect() {
std::vector published_issues;
std::shared_ptr file_io_info;
while (true) {
published_issues.clear();
// 阻塞直到获取到IOInfo
int ret = TakeFileIOInfo(file_io_info);
if (ret != 0) {
break;
}
// 将IOInfo交给各个detector进行检测
for (auto detector : detectors_) {
detector->Detect(env_, *file_io_info, published_issues);
}
// 若可以上报,则进行上报
if (issued_callback_ && !published_issues.empty()) {
issued_callback_(published_issues);
}
file_io_info = nullptr;
}
}
// 消费者
int IOCanary::TakeFileIOInfo(std::shared_ptr &file_io_info) {
std::unique_lockstd::mutex lock(queue_mutex_);
while (queue_.empty()) {
queue_cv_.wait(lock);
if (exit_) {
return -1;
}
}
file_io_info = queue_.front();
queue_.pop_front();
return 0;
}
IO检测策略
主线程IO-FileIOMainThreadDetector
void FileIOMainThreadDetector::Detect(const IOCanaryEnv &env, const IOInfo &file_io_info,
std::vector& issues) {
if (GetMainThreadId() == file_io_info.java_context_.thread_id_) {
int type = 0;
//可能引起卡顿的主线程IO,默认值13ms
if (file_io_info.max_continual_rw_cost_time_μs_ > IOCanaryEnv::kPossibleNegativeThreshold) {
type = 1;
}
//引起主线程严重性能问题的IO,默认500ms
if(file_io_info.max_continual_rw_cost_time_μs_ > env.GetMainThreadThreshold()) {
type |= 2;
}
if (type != 0) {
Issue issue(kType, file_io_info);
issue.repeat_read_cnt_ = type; //use repeat to record type
PublishIssue(issue, issues);
}
}
}
Small Buffer IO-FileIOSmallBufferDetector
void FileIOSmallBufferDetector::Detect(const IOCanaryEnv &env, const IOInfo &file_io_info,
std::vector& issues) {
//单次操作的字节数小于阈值
if (file_io_info.op_cnt_ > env.kSmallBufferOpTimesThreshold && (file_io_info.op_size_ / file_io_info.op_cnt_) < env.GetSmallBufferThreshold()
&& file_io_info.max_continual_rw_cost_time_μs_ >= env.kPossibleNegativeThreshold) {
PublishIssue(Issue(kType, file_io_info), issues);
}
}
Repeat Read IO-FileIORepeatReadDetector
void FileIORepeatReadDetector::Detect(const IOCanaryEnv &env,
const IOInfo &file_io_info,
std::vector& issues) {
const std::string& path = file_io_info.path_;
if (observing_map_.find(path) == observing_map_.end()) {
if (file_io_info.max_continual_rw_cost_time_μs_ < env.kPossibleNegativeThreshold) {
return;
}
observing_map_.insert(std::make_pair(path, std::vector()));
}
std::vector& repeat_infos = observing_map_[path];
//有write行为,清空repeat_info
if (file_io_info.op_type_ == FileOpType::kWrite) {
repeat_infos.clear();
return;
}
RepeatReadInfo repeat_read_info(file_io_info.path_, file_io_info.java_context_.stack_, file_io_info.java_context_.thread_id_,
file_io_info.op_size_, file_io_info.file_size_);
if (repeat_infos.size() == 0) {
repeat_infos.push_back(repeat_read_info);
return;
}
//read操作间隔17ms,清空repeat_info
if((GetTickCount() - repeat_infos[repeat_infos.size() - 1].op_timems) > 17) { //17ms todo astrozhou add to params
repeat_infos.clear();
}
bool found = false;
int repeatCnt;
for (auto& info : repeat_infos) {
if (info == repeat_read_info) {
found = true;
info.IncRepeatReadCount();
repeatCnt = info.GetRepeatReadCount();
break;
}
}
if (!found) {
repeat_infos.push_back(repeat_read_info);
return;
}
//重复read次数达到阈值,上报IO Issue
if (repeatCnt >= env.GetRepeatReadThreshold()) {
Issue issue(kType, file_io_info);
issue.repeat_read_cnt_ = repeatCnt;
issue.stack = repeat_read_info.GetStack();
PublishIssue(issue, issues);
}
}
PLT(GOT)Hook介绍
Native Hook大体上可以分为PLT(GOT) Hook、ART Hook(基于ART虚拟机)、Dalvik Hook(基于Dalvik虚拟机)、inline Hook这几类Hook手段。
PLT(GOT) Hook是基于so(实际是一个elf格式的文件)的GOT跳转表实现的。
对于PLT(GOT) HOOK,需要关注的是ELF文件链接视图下名为.plt和.got的Section。
plt Section说明:
got Section说明:
先来介绍一下Android PLT Hook的基本原理。Linux在执行动态链接的ELF的时候,为了优化性能使用了一个叫延时绑定的策略。相关资料有很多,这边简述一下:这个策略是为了解决原本静态编译时要把各种系统API的具体实现代码都编译进当前ELF文件里导致文件巨大臃肿的问题。所以当在动态链接的ELF程序里调用共享库的函数时,第一次调用时先去查找PLT表中相应的项目,而PLT表中再跳跃到GOT表中希望得到该函数的实际地址,但这时GOT表中指向的是PLT中那条跳跃指令下面的代码,最终会执行_dl_runtime_resolve()并执行目标函数。第二次调用时也是PLT跳转到GOT表,但是GOT中对应项目已经在第一次_dl_runtime_resolve()中被修改为函数实际地址,因此第二次及以后的调用直接就去执行目标函数,不用再去执行_dl_runtime_resolve()了。因此,PLT Hook通过直接修改GOT表,使得在调用该共享库的函数时跳转到的是用户自定义的Hook功能代码。
PLT(GOT) Hook代码实现
解析需要hook的so文件,封装一个loaded_soinfo对象。
查找GOT表中是否有对应的方法声明。
locate_symbol内部调用locate_symbol_hash。
备选方案, locate_symbol_hash失败后会走到这个方法。
实际替换对应的函数地址。
Android系统加载so的过程
源码地址位于android / platform / bionic / froyo / . / linker / linker.c
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip204888 (备注Android)
推荐学习资料
- 脑图
674)]
[外链图片转存中…(img-KTrzDmxO-1711868737674)]
[外链图片转存中…(img-n7UBVgx4-1711868737674)]
[外链图片转存中…(img-csvJPYkB-1711868737674)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip204888 (备注Android)
[外链图片转存中…(img-itxM8Gjp-1711868737675)]
推荐学习资料
- 脑图
[外链图片转存中…(img-2wk1AAl6-1711868737675)]
[外链图片转存中…(img-wZmRqQz6-1711868737675)]
[外链图片转存中…(img-aOrU4Q0i-1711868737675)]