安卓虚拟摄像头是怎么“骗“过 App 的?Xposed Hook 与 MediaCodec 硬解码双通道替换实战拆解

📅 2026/8/18 15:39:40
安卓虚拟摄像头是怎么“骗“过 App 的?Xposed Hook 与 MediaCodec 硬解码双通道替换实战拆解
安卓虚拟摄像头是怎么骗过 App 的Xposed Hook 与 MediaCodec 硬解码双通道替换实战拆解【免费下载链接】android_virtual_camxposed安卓虚拟摄像头 android virtual camera on xposed hook项目地址: https://gitcode.com/gh_mirrors/an/android_virtual_cam想象一个场景你正在调试一个直播类应用需要反复测试画面异常时的表现但办公室里根本没有摄像头。更麻烦的是你测试的 App 既用了老的 Camera1 API又兼容新的 Camera2 API。这时候一个基于 Xposed Hook 的安卓虚拟摄像头项目 android_virtual_cam 就能派上用场——它能把 App 看到的实时画面整体替换成一段循环播放的视频文件。这篇文章不打算只讲怎么装而是带你把它的拦截逻辑一层层剥开看看它凭什么能让 App 深信不疑以及为什么它在预览显示和帧回调上走了两条完全不同的路。一、先问一个问题App 到底从哪里拿到画面在动手看 Hook 代码之前我们得先想清楚一个基础问题App 要显示相机预览画面数据是怎么流过去的这条链路理解透了后面的所有 Hook 点都能对号入座。以 Camera1 为例数据流大概是这样的App 创建 Camera 对象 │ ├─ setPreviewDisplay(SurfaceHolder) 或 setPreviewTexture(SurfaceTexture) │ └─ 指定画面送到哪块屏幕上 │ ├─ setPreviewCallback / setPreviewCallbackWithBuffer │ └─ 每帧回调 onPreviewFrame(byte[], Camera)App 拿原始帧做分析 │ ├─ startPreview() │ └─ 通知底层开始出帧 │ └─ takePicture(...) └─ 拍照回调 onPictureTaken(byte[], Camera)App 拿到 JPEG 或 YUV你发现了什么App 拿到画面的所有入口都是一个个公开的方法调用。既然调用是公开的Xposed 就有机会在调用发生前把它们拦下来替换参数、替换返回值甚至把整条数据源换掉。这就是虚拟摄像头的立身之本——它不修改系统相机驱动只在这几个咽喉要道上做手脚。二、三层递进替换、注帧、重定向整个项目可以拆成三个层次一层比一层深入第一层替换预览显示偷梁换柱。App 说我要把预览渲染到这个 SurfaceTexture 上Hook 就回答好的但请渲染到我给你的这个假纹理上。真正的视频画面由 MediaPlayer 播进 App 原来指定的那个纹理里。App 全程没察觉——它以为自己在看相机实际上在看播放器。第二层注入预览帧数据伪造。有些 App 不用 Surface 显示预览而是通过 onPreviewFrame 拿原始 YUV 帧自己做渲染或分析。这时候光替换 Surface 没用必须把帧数据也换成假的。项目的做法是用 MediaCodec 硬解码视频把每一帧转成 NV21 格式再通过 System.arraycopy 塞进回调参数里。第三层路径重定向权限自救。视频文件放在哪默认是/storage/emulated/0/DCIM/Camera1/。但目标 App 可能没有存储权限读不到这个目录。项目在Instrumentation.callApplicationOnCreate的 Hook 里做了一次权限体检没权限就把视频路径重定向到该 App 的私有目录Android/data/[包名]/files/Camera1/并用 Toast 告诉你目录被改到了哪里。这三层合起来才构成了完整的虚拟摄像头体验。缺了哪一层都会遇到黑屏或者显示真实画面的诡异现象。三、代码精读三个关键实现为什么这样写3.1 表面替换setPreviewTexture 的调包计核心代码在app/src/main/java/com/example/vcam/HookMain.java的handleLoadPackage中XposedHelpers.findAndHookMethod(android.hardware.Camera, lpparam.classLoader, setPreviewTexture, SurfaceTexture.class, new XC_MethodHook() { Override protected void beforeHookedMethod(MethodHookParam param) { File file new File(video_path virtual.mp4); if (!file.exists()) { return; } // 没有素材就直接放行 if (param.args[0] null) { return; } // 空纹理不需要处理 origin_preview_camera (Camera) param.thisObject; mSurfacetexture (SurfaceTexture) param.args[0]; // 存下 App 真正的纹理 if (fake_SurfaceTexture null) { fake_SurfaceTexture new SurfaceTexture(10); // 造一个假纹理 } param.args[0] fake_SurfaceTexture; // 把参数调包 } });注意两个容易被忽略的细节。第一它用的是beforeHookedMethod也就是在方法真正执行前改参数改完 App 的调用就变味了。第二param.args[0]被换成假纹理之后原来的mSurfacetexture被存成了静态变量——这样在startPreview的 Hook 里就能用new Surface(mSurfacetexture)把 MediaPlayer 的视频画面喂进 App 原本想用的那块纹理。为什么这样绕一圈因为startPreview阶段才适合启动播放器而setPreviewTexture阶段我们只知道目标纹理是谁。3.2 帧回调注入onPreviewFrame 的无中生有对于走setPreviewCallback的 App项目把回调对象取出来再 Hook 它的onPreviewFrameXposedHelpers.findAndHookMethod(preview_cb_class, onPreviewFrame, byte[].class, android.hardware.Camera.class, new XC_MethodHook() { Override protected void beforeHookedMethod(MethodHookParam paramd) { Camera localcam (android.hardware.Camera) paramd.args[1]; if (localcam.equals(camera_onPreviewFrame)) { // 已经是第二次回调直接把解码好的帧拷贝进去 System.arraycopy(data_buffer, 0, paramd.args[0], 0, Math.min(data_buffer.length, ((byte[]) paramd.args[0]).length)); } else { // 第一次回调记录分辨率、启动 MediaCodec 解码器 camera_onPreviewFrame localcam; hw_decode_obj new VideoToFrames(); hw_decode_obj.setSaveFrames(, OutputImageFormat.NV21); hw_decode_obj.decode(video_path virtual.mp4); } } });这里有个很有意思的取舍第一次回调时来不及准备好假帧所以先把真帧放过去同时启动解码线程从第二次开始才用data_buffer里的解码帧覆盖真实数据。为什么不一开始就同步解码因为解码器预热需要时间强行等待只会让 App 卡死。这也是很多类似项目容易翻车的地方——把耗时操作放在了回调主路径上。顺带一提项目还 Hook 了addCallbackBuffer把 App 传入的缓冲区替换成一个等长的全新数组让真实相机数据根本没机会写进 App 的 buffer 里。3.3 硬解码循环VideoToFrames 的呼吸节奏解码逻辑在app/src/main/java/com/example/vcam/VideoToFrames.java。核心循环是标准的 MediaCodec 异步-同步混合写法while (!sawOutputEOS !stopDecode) { int inputBufferId decoder.dequeueInputBuffer(TIMEOUT_US); if (inputBufferId 0) { int sampleSize extractor.readSampleData(buffer, 0); if (sampleSize 0) { decoder.queueInputBuffer(id, 0, 0, 0L, BUFFER_FLAG_END_OF_STREAM); sawInputEOS true; } else { decoder.queueInputBuffer(id, 0, sampleSize, extractor.getSampleTime(), 0); extractor.advance(); } } // 取输出帧交给 surface 渲染或转成 NV21 字节数组 ... // 按时间戳控制播放节奏 long sleepTime info.presentationTimeUs / 1000 - (System.currentTimeMillis() - startWhen); if (sleepTime 0) Thread.sleep(sleepTime); }值得学习的是节奏控制解码器产帧的速度远快于真实播放速度如果直接输出视频会像开了倍速。项目用presentationTimeUs和系统当前时间做差算出每帧该等多久保证输出帧率和视频本身一致。文件播完一遍后还会extractor.seekTo(0, 0)重新解码实现无限循环。颜色格式转换getDataFromImage则是把解码器输出的 YUV_420_888 多平面数据按 rowStride/pixelStride 逐行打包成 App 熟悉的 NV21 单字节数组——这步处理不好画面就会花。四、落地实操十分钟跑通第一个替换克隆源码git clone https://gitcode.com/gh_mirrors/an/android_virtual_cam用 Android Studio 打开确认app/libs/下的 xposed-api 包已引入构建出 APK。安装模块在 LSPosed 等框架中启用并把目标 App 勾选进作用域无需选系统框架。给目标 App 授予存储读取权限然后强制结束它。若它没申请该权限Hook 会自动把Camera1目录重定向到它的私有目录并弹出气泡提示。在/storage/emulated/0/DCIM/Camera1/或提示的私有目录放入virtual.mp4。打开目标 App 的相机观察气泡消息里提示的宽xx 高xx这就是 App 请求的预览分辨率你的视频最好完全匹配。拍照时若出现发现拍照提示就按提示分辨率准备一张1000.bmp放进去拍照结果就会被替换。验证手段观察 Toast 的分辨率提示是否出现把视频换成有明显时间码的画面看预览和照片是否同步变化用XposedBridge.log的日志过滤【VCAM】标签能看到每一步 Hook 是否被触发——这个日志习惯也是你排障时最重要的工具。五、常见坑问答Q1预览黑屏、相机打不开怎么办A先查日志里有没有不存在替换视频的提示——多半是路径不对比如误建了DCIM/Camera1/Camera1/virtual.mp4两级目录。另外系统相机这类深度定制的 App 很难替换成功属于已知边界。Q2画面花屏或扭曲A花屏十有八九是视频分辨率与 App 请求的预览尺寸不一致解码出的帧和回调 buffer 对不上扭曲则是宽高比的问题需要用剪辑软件把视频裁成和 Toast 提示一致的分辨率。Q3前置摄像头画面方向不对A多数前置场景需要把视频水平翻转并右旋 90 度再匹配提示的分辨率。但不同 App 处理方式不同这条规则不是绝对的得按实际画面判断。Q4disable.jpg 创建了却不生效A注意版本差异——4.0 及以下版本中有存储权限的 App 读全局目录的文件没权限的 App 要在私有目录建4.1 以上则统一读全局目录。Q5App 录像时还能替换吗A不能。项目 Hook 了MediaRecorder.setCamera但也只能在检测到时弹 Toast 提示目前无法拦截这是当前实现明确承认的能力边界。六、深度进阶双通道设计的性能账与二次开发方向为什么项目要维护MediaPlayer 渲染 VideoToFrames 取帧两条解码通道因为用途不同给 Surface 显示时MediaPlayer 直接走硬件合成开销最小而给 onPreviewFrame 喂数据时必须拿到逐帧字节数组只能走 MediaCodec 取帧再转 NV21。两条通道互不干扰各司其职这是按数据消费方式分流的典型设计。性能上要注意三点一是解码器和线程在每次停止预览时要及时stopDecode()并释放否则多开几次相机会内存暴涨二是回调里用Math.min截断拷贝长度防止帧尺寸不匹配时越界崩溃三是视频帧率和分辨率越高转换开销越大素材没必要超过 App 实际请求的尺寸。二次开发可以从这几处入手给不同 App 分配不同视频项目已提供private_dir.jpg强制私有目录可以扩展成按包名分发素材把静态文件开关改成动态配置接口或者给 Camera2 的虚拟 Surface增加多路输出支持。另外Camera2 那套createCaptureSession系列方法有六七个重载变体项目几乎全部 Hook 了一遍——这提醒我们做系统级兼容时同一个语义的每个重载入口都不能漏。七、收尾与延伸回头看android_virtual_cam 的技术内核其实只有三件事在正确的调用点拦截、准备好替代数据、控制好替换的时机。它同时兼容 Camera1/Camera2 双 API用文件系统做开关用 Toast 做分辨率反馈这些设计对任何做系统级工具的开发都有参考价值。想深入的话建议按三条线学习Android 官方的 MediaCodec 与 MediaExtractor 文档、Xposed 的 XposedHelpers 与 XC_MethodHook 的 API 参考、以及 Camera2 的 CaptureSession 状态机。把这套链路吃透你不仅能熟练使用虚拟摄像头也能举一反三写出自己的系统级 Hook 模块。【免费下载链接】android_virtual_camxposed安卓虚拟摄像头 android virtual camera on xposed hook项目地址: https://gitcode.com/gh_mirrors/an/android_virtual_cam创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考