diff --git a/README.md b/README.md index 0b641c6..54d03d6 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,18 @@ xposed安卓虚拟摄像头 ## 感谢https://github.com/wangwei1237/CameraHook 提供的HOOK思路!! -已加入Camera2支持,抖音测试通过,需要**不静音**的可以在no-silent的分支/app/release/app-release.apk下载(no-silent更新很不及时)。(链接全部放下面了) +已加入Camera2支持,抖音测试通过,需要**不静音**的可以在no-silent的分支/app/release/app-release.apk下载(no-silent更新很不及时(也可能是不更新了))。(链接全部放下面了) ### github release里全是静音的。 -### 软件对TextureView预览信息替换的视频是 /sdcard/DCIM/Camera/virtual.mp4 -### 软件对onPreviewFrame预览信息替换的**照片**是 /sdcard/DCIM/Camera/bmp/****.bmp - **命名规则**:"****.bmp" 是bmp图片,文件命名的规则为:从1000.bmp开始,按帧排序依次为1000.bmp,1001.bmp,1002.bmp……,最少有1张图片,最大不超过1000张(超过了的话文件名会多一位),可以使用Premiere将视频转化为BMP。 -## 具体的使用方法: -1、安装xposed框架(传统xposed,edxp,lsp等均可,不确定虚拟框架能否使用,已经确定VMOS可用,应用转生不可用) +## 具体的使用方法: +1、安装xposed框架(传统xposed,edxp,lsp等均可,不确定虚拟框架能否使用,已经确定VMOS可用,应用转生不可用) 2、安装模块,启用模块,lsp等包含定义域的框架需要选勾目标app,但无需选勾系统框架。 -3、对于大多数应用,只需要将替换的视频命名为virtual.mp4,放在/sdcard/DCIM/Camera/目录下。 -4、多余少部分应用(如腾讯会议,和其他应用大部分的二维码扫描),需要使用premiere或其它剪辑软件将视频拆分成BMP格式图片(命名格式见上,premiere视频总帧数超过1000帧,导出时名字设置为1.bmp,会自动按以上的命名格式命名),要注意的是图片分辨率需要与目标分辨率匹配(获取分辨率方法见下),将这些图片放在/sdcard/DCIM/Camera/bmp/目录下(没有的话自己创建)。 +3、将需要替换的视频命名为virtual.mp4,放在/sdcard/DCIM/Camera/目录下。(前置摄像头需要水平翻转后右旋90°保存,onPreviewFrame需要匹配分辨率) +4、若需要拦截拍照事件,请在/sdcard/DCIM/Camera/目录下放置 1000.bmp 用于替换,(前置摄像头需要水平翻转后右旋90°保存,需要匹配分辨率) 5、强制结束目标应用/重启手机。 -## > 如何获得分辨率??(仅onPreviewFrame需要,其它系统自动处理) -在目标应用中打开摄像头,若创建回调则即可在Xposed log得到分辨率(可查看到字样为:帧预览回调初始化:宽……高…………)。 +## > 如何获得分辨率??(仅onPreviewFrame和拍照需要,其它系统自动处理) +在目标应用中打开摄像头,可在toast消息里看见。 ## Camera2接口有问题?? 是的,目前Camera2接口的HOOK不是所有应用程序都能生效,部分app报错打开相机失败,如果想停用Camera2接口的HOOK,可在/sdcard/DCIM下创建disable.jpg,以停用此项HOOK diff --git a/app/build.gradle b/app/build.gradle index 10c682f..874ad39 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,10 +7,10 @@ android { defaultConfig { applicationId "com.example.vcam" - minSdk 16 + minSdk 21 targetSdk 28 - versionCode 2 - versionName "1.1" + versionCode 3 + versionName "2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/example/vcam/HookMain.java b/app/src/main/java/com/example/vcam/HookMain.java index 778ea11..6936307 100644 --- a/app/src/main/java/com/example/vcam/HookMain.java +++ b/app/src/main/java/com/example/vcam/HookMain.java @@ -2,6 +2,8 @@ import android.annotation.SuppressLint; +import android.app.Application; +import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.ImageFormat; @@ -17,7 +19,26 @@ import android.os.Handler; import android.view.Surface; +import android.graphics.ImageFormat; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.media.Image; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.util.Log; +import android.widget.Toast; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.LinkedBlockingQueue; + +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AlertDialog; import java.io.ByteArrayOutputStream; import java.io.File; @@ -41,17 +62,21 @@ public class HookMain implements IXposedHookLoadPackage { public static Camera data_camera; public static volatile byte[] data_buffer; - public static byte[] pic_buff_1; - public static byte[] pic_buff_2; + //public static byte[] pic_buff_1; + //public static byte[] pic_buff_2; public static byte[] input; public static int mhight; public static int mwidth; - public static Thread file_thred; - public static Thread prepare_thred; - public static int last_buffer_index; + //public static Thread file_thred; + //public static Thread prepare_thred; + public static VideoToFrames hw_decode_obj; + public static VideoToFrames.Callback hw_decode_cb; + //public static VideoToFrames c2_hw_decode_obj; + //public static VideoToFrames.Callback c2_hw_decode_cb; public static int onemhight; public static int onemwidth; + public static Class camera_callback_calss; public static Surface c2_ori_Surf; public static MediaPlayer c2_player; @@ -59,6 +84,7 @@ public class HookMain implements IXposedHookLoadPackage { public static ImageReader c2_image_reader; public static Class c2_state_callback; + public static Context toast_content; public static int repeat_count; @@ -188,10 +214,53 @@ protected void beforeHookedMethod(MethodHookParam param) { HookMain.c2_player = new MediaPlayer(); } + /*if (c2_hw_decode_cb != null){ + c2_hw_decode_cb =null; + } + c2_hw_decode_cb = new VideoToFrames.Callback() { + @Override + public void onFinishDecode() { + try { + c2_hw_decode_obj.stopDecode(); + c2_hw_decode_obj = null; + c2_hw_decode_obj = new VideoToFrames(); + c2_hw_decode_obj.set_surfcae(HookMain.c2_ori_Surf); + c2_hw_decode_obj.setCallback(c2_hw_decode_cb); + c2_hw_decode_obj.setSaveFrames("/sdcard/DCIM/Camera2/",OutputImageFormat.NV21); + c2_hw_decode_obj.decode("/sdcard/DCIM/Camera/virtual.mp4"); + }catch (Exception eee){ + XposedBridge.log(eee.toString()); + } catch (Throwable throwable) { + throwable.printStackTrace(); + } + } + + @Override + public void onDecodeFrame(int index) { + + } + }; + if (c2_hw_decode_obj != null){ + c2_hw_decode_obj.stopDecode(); + c2_hw_decode_obj =null; + } + + c2_hw_decode_obj = new VideoToFrames(); + c2_hw_decode_obj.setCallback(c2_hw_decode_cb); + try { + c2_hw_decode_obj.setSaveFrames("/sdcard/DCIM/Camera2/", OutputImageFormat.NV21); + c2_hw_decode_obj.set_surfcae(HookMain.c2_ori_Surf); + c2_hw_decode_obj.decode("/sdcard/DCIM/Camera/virtual.mp4"); + }catch (Throwable throwable){ + throwable.printStackTrace(); + }*/ + HookMain.c2_player.setSurface(HookMain.c2_ori_Surf); HookMain.c2_player.setVolume(0, 0); HookMain.c2_player.setLooping(true); + + HookMain.c2_player.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { public void onPrepared(MediaPlayer mp) { HookMain.c2_player.start(); @@ -280,9 +349,21 @@ protected void afterHookedMethod(MethodHookParam param) { protected void beforeHookedMethod(MethodHookParam param) throws Throwable { super.beforeHookedMethod(param); XposedBridge.log("在录像,已打断"); + if (toast_content!=null){ + Toast.makeText(toast_content, "已打断录像", Toast.LENGTH_LONG).show(); + } param.args[0] = null; } }); + + XposedHelpers.findAndHookMethod("android.app.Instrumentation",lpparam.classLoader, "callApplicationOnCreate", Application.class, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + super.afterHookedMethod(param); + if(param.args[0] instanceof Application){ + HookMain.toast_content = ((Application) param.args[0]).getApplicationContext(); + } } + }); } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) @@ -331,6 +412,9 @@ protected void beforeHookedMethod(MethodHookParam paramd) throws Throwable { onemwidth = loaclcam.getParameters().getPreviewSize().width; onemhight = loaclcam.getParameters().getPreviewSize().height; XposedBridge.log("JPEG拍照回调初始化:宽:" + onemwidth + "高:" + onemhight + "对应的类:" + loaclcam.toString()); + if (toast_content!=null){ + Toast.makeText(toast_content, "宽:" + onemwidth + "\n高:" + onemhight , Toast.LENGTH_LONG).show(); + } Bitmap pict = getBMP("/sdcard/DCIM/Camera/bmp/1000.bmp"); ByteArrayOutputStream temp_array = new ByteArrayOutputStream(); pict.compress(Bitmap.CompressFormat.JPEG, 100, temp_array); @@ -361,6 +445,9 @@ protected void beforeHookedMethod(MethodHookParam paramd) throws Throwable { onemwidth = loaclcam.getParameters().getPreviewSize().width; onemhight = loaclcam.getParameters().getPreviewSize().height; XposedBridge.log("YUV拍照回调初始化:宽:" + onemwidth + "高:" + onemhight + "对应的类:" + loaclcam.toString()); + if (toast_content!=null){ + Toast.makeText(toast_content, "宽:" + onemwidth + "\n高:" + onemhight , Toast.LENGTH_LONG).show(); + } input = getYUVByBitmap(getBMP("/sdcard/DCIM/Camera/bmp/1000.bmp")); paramd.args[0] = input; } catch (Exception ee) { @@ -372,12 +459,16 @@ protected void beforeHookedMethod(MethodHookParam paramd) throws Throwable { @SuppressLint("SdCardPath") public void process_callback(XC_MethodHook.MethodHookParam param) { + File file = new File("/sdcard/DCIM/Camera/virtual.mp4"); + if (!file.exists()) { + return; + } Class nmb = param.args[0].getClass(); - XposedHelpers.findAndHookMethod(nmb, "onPreviewFrame", byte[].class, android.hardware.Camera.class, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam paramd) throws Throwable { - Camera localcam = (android.hardware.Camera) paramd.args[1]; - if (localcam.equals(data_camera)) { + XposedHelpers.findAndHookMethod(nmb, "onPreviewFrame", byte[].class, android.hardware.Camera.class, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam paramd) throws Throwable { + Camera localcam = (android.hardware.Camera) paramd.args[1]; + if (localcam.equals(data_camera)) { /*repeat_count += 1; File test_file = new File("/sdcard/DCIM/Camera/bmp/" + String.valueOf(repeat_count) + ".bmp"); if (!test_file.exists()) { @@ -385,27 +476,42 @@ protected void beforeHookedMethod(MethodHookParam paramd) throws Throwable { } input = getYUVByBitmap(getBMP("/sdcard/DCIM/Camera/bmp/" + String.valueOf(repeat_count) + ".bmp")); */ - while (HookMain.data_buffer==null){ + while (data_buffer == null) { + } + System.arraycopy(HookMain.data_buffer, 0, (byte[]) paramd.args[0], 0, Math.min(HookMain.data_buffer.length, ((byte[]) paramd.args[0]).length)); + HookMain.data_buffer = null; + } else { + camera_callback_calss = nmb; + repeat_count = 1000; + HookMain.data_camera = (android.hardware.Camera) paramd.args[1]; + mwidth = data_camera.getParameters().getPreviewSize().width; + mhight = data_camera.getParameters().getPreviewSize().height; + int frame_Rate = data_camera.getParameters().getPreviewFrameRate(); + XposedBridge.log("帧预览回调初始化:宽:" + mwidth + " 高:" + mhight + " 帧率:" + frame_Rate); + if (toast_content != null) { + Toast.makeText(toast_content, "宽:" + mwidth + "\n高:" + mhight + "\n" + "帧率:" + frame_Rate, Toast.LENGTH_LONG).show(); + } + //input = getYUVByBitmap(getBMP("/sdcard/DCIM/Camera/bmp/" + repeat_count + ".bmp")); + //System.arraycopy(input, 0, (byte[]) paramd.args[0], 0, Math.min(input.length, ((byte[]) paramd.args[0]).length)); + if (hw_decode_obj != null) { + hw_decode_obj.stopDecode(); + } - } - System.arraycopy(HookMain.data_buffer, 0, (byte[]) paramd.args[0], 0, Math.min(HookMain.data_buffer.length, ((byte[]) paramd.args[0]).length)); - HookMain.data_buffer = null; - } else { - repeat_count = 1000; - HookMain.data_camera = (android.hardware.Camera) paramd.args[1]; - mwidth = data_camera.getParameters().getPreviewSize().width; - mhight = data_camera.getParameters().getPreviewSize().height; - XposedBridge.log("帧预览回调初始化:宽:" + mwidth + "高:" + mhight + "对应的类:" + data_camera.toString()); - input = getYUVByBitmap(getBMP("/sdcard/DCIM/Camera/bmp/" + repeat_count + ".bmp")); - System.arraycopy(input, 0, (byte[]) paramd.args[0], 0, Math.min(input.length, ((byte[]) paramd.args[0]).length)); - if (prepare_thred!=null){ + hw_decode_obj = new VideoToFrames(); + hw_decode_obj.setSaveFrames("", OutputImageFormat.NV21); + hw_decode_obj.decode("/sdcard/DCIM/Camera/virtual.mp4"); + while (data_buffer == null) { + } + System.arraycopy(HookMain.data_buffer, 0, (byte[]) paramd.args[0], 0, Math.min(HookMain.data_buffer.length, ((byte[]) paramd.args[0]).length)); + ; + /*if (prepare_thred!=null){ prepare_thred.interrupt(); prepare_thred = null; } - /*if (file_thred!=null){ + if (file_thred!=null){ file_thred.interrupt(); file_thred = null; - }*/ + } //HookMain.last_buffer_index = 2; prepare_thred = new Thread(new Runnable() { public void run() { @@ -422,7 +528,7 @@ public void run() { XposedBridge.log("线程出错" + throwable.toString()); } } - /*if (HookMain.pic_buff_2 == null) { + if (HookMain.pic_buff_2 == null) { HookMain.repeat_count += 1; File test_file = new File("/sdcard/DCIM/Camera/bmp/" + HookMain.repeat_count + ".bmp"); if (!test_file.exists()) { @@ -433,13 +539,13 @@ public void run() { } catch (Throwable throwable) { XposedBridge.log("线程出错" + throwable.toString()); } - }*/ + } } } }); - /* file_thred = new Thread(new Runnable() { + file_thred = new Thread(new Runnable() { public void run() { while (true) { if (data_buffer == null) { @@ -457,12 +563,12 @@ public void run() { } });*/ - prepare_thred.start(); - //file_thred.start(); - } + //prepare_thred.start(); + //file_thred.start(); + } - } - }); + } + }); } //以下代码来源:https://blog.csdn.net/jacke121/article/details/73888732 @@ -510,4 +616,321 @@ private static byte[] getYUVByBitmap(Bitmap bitmap) { } } +//以下代码修改自 https://github.com/zhantong/Android-VideoToImages +class VideoToFrames implements Runnable { + private static final String TAG = "VideoToFrames"; + private static final boolean VERBOSE = false; + private static final long DEFAULT_TIMEOUT_US = 10000; + + private static final int COLOR_FormatI420 = 1; + private static final int COLOR_FormatNV21 = 2; + + + private final int decodeColorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible; + + private LinkedBlockingQueue mQueue; + private OutputImageFormat outputImageFormat; + private boolean stopDecode = false; + + private String videoFilePath; + private Throwable throwable; + private Thread childThread; + private Surface play_surf ; + + private Callback callback; + + public interface Callback { + void onFinishDecode(); + + void onDecodeFrame(int index); + } + + public void setCallback(Callback callback) { + this.callback = callback; + } + + public void setEnqueue(LinkedBlockingQueue queue) { + mQueue = queue; + } + + //设置输出位置,没啥用 + public void setSaveFrames(String dir, OutputImageFormat imageFormat) throws IOException { + outputImageFormat = imageFormat; + + } + + public void set_surfcae(Surface player_surface){ + if (player_surface != null){ + play_surf=player_surface; + } + } + + public void stopDecode() { + stopDecode = true; + } + + public void decode(String videoFilePath) throws Throwable { + this.videoFilePath = videoFilePath; + if (childThread == null) { + childThread = new Thread(this, "decode"); + childThread.start(); + if (throwable != null) { + throw throwable; + } + } + } + + public void run() { + try { + videoDecode(videoFilePath); + } catch (Throwable t) { + throwable = t; + } + } + + @SuppressLint("WrongConstant") + public void videoDecode(String videoFilePath) throws IOException { + MediaExtractor extractor = null; + MediaCodec decoder = null; + try { + File videoFile = new File(videoFilePath); + extractor = new MediaExtractor(); + extractor.setDataSource(videoFile.toString()); + int trackIndex = selectTrack(extractor); + if (trackIndex < 0) { + throw new RuntimeException("No video track found in " + videoFilePath); + } + extractor.selectTrack(trackIndex); + MediaFormat mediaFormat = extractor.getTrackFormat(trackIndex); + String mime = mediaFormat.getString(MediaFormat.KEY_MIME); + decoder = MediaCodec.createDecoderByType(mime); + showSupportedColorFormat(decoder.getCodecInfo().getCapabilitiesForType(mime)); + if (isColorFormatSupported(decodeColorFormat, decoder.getCodecInfo().getCapabilitiesForType(mime))) { + mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, decodeColorFormat); + Log.i(TAG, "set decode color format to type " + decodeColorFormat); + } else { + Log.i(TAG, "unable to set decode color format, color format type " + decodeColorFormat + " not supported"); + } + decodeFramesToImage(decoder, extractor, mediaFormat); + decoder.stop(); + while (true && (!stopDecode)){ + extractor.seekTo(0,0); + decodeFramesToImage(decoder, extractor, mediaFormat); + decoder.stop(); + } + } finally { + if (decoder != null) { + decoder.stop(); + decoder.release(); + decoder = null; + } + if (extractor != null) { + extractor.release(); + extractor = null; + } + } + } + + private void showSupportedColorFormat(MediaCodecInfo.CodecCapabilities caps) { + System.out.print("supported color format: "); + for (int c : caps.colorFormats) { + System.out.print(c + "\t"); + } + System.out.println(); + } + + private boolean isColorFormatSupported(int colorFormat, MediaCodecInfo.CodecCapabilities caps) { + for (int c : caps.colorFormats) { + if (c == colorFormat) { + return true; + } + } + return false; + } + + private void decodeFramesToImage(MediaCodec decoder, MediaExtractor extractor, MediaFormat mediaFormat) { + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + boolean sawInputEOS = false; + boolean sawOutputEOS = false; + decoder.configure(mediaFormat, play_surf, null, 0); + decoder.start(); + final int width = mediaFormat.getInteger(MediaFormat.KEY_WIDTH); + final int height = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT); + int outputFrameCount = 0; + while (!sawOutputEOS && !stopDecode) { + if (!sawInputEOS) { + int inputBufferId = decoder.dequeueInputBuffer(DEFAULT_TIMEOUT_US); + if (inputBufferId >= 0) { + ByteBuffer inputBuffer = decoder.getInputBuffer(inputBufferId); + int sampleSize = extractor.readSampleData(inputBuffer, 0); + if (sampleSize < 0) { + decoder.queueInputBuffer(inputBufferId, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + sawInputEOS = true; + } else { + long presentationTimeUs = extractor.getSampleTime(); + decoder.queueInputBuffer(inputBufferId, 0, sampleSize, presentationTimeUs, 0); + extractor.advance(); + } + } + } + int outputBufferId = decoder.dequeueOutputBuffer(info, DEFAULT_TIMEOUT_US); + if (outputBufferId >= 0) { + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + sawOutputEOS = true; + } + boolean doRender = (info.size != 0); + if (doRender) { + outputFrameCount++; + if (callback != null) { + callback.onDecodeFrame(outputFrameCount); + } + Image image = decoder.getOutputImage(outputBufferId); + ByteBuffer buffer = image.getPlanes()[0].getBuffer(); + byte[] arr = new byte[buffer.remaining()]; + buffer.get(arr); + if (mQueue != null) { + try { + mQueue.put(arr); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + if (outputImageFormat != null) { + while (HookMain.data_buffer !=null){}; + HookMain.data_buffer=getDataFromImage(image, COLOR_FormatNV21); + } + image.close(); + decoder.releaseOutputBuffer(outputBufferId, true); + } + } + } + if (callback != null) { + callback.onFinishDecode(); + } + } + + private static int selectTrack(MediaExtractor extractor) { + int numTracks = extractor.getTrackCount(); + for (int i = 0; i < numTracks; i++) { + MediaFormat format = extractor.getTrackFormat(i); + String mime = format.getString(MediaFormat.KEY_MIME); + if (mime.startsWith("video/")) { + if (VERBOSE) { + Log.d(TAG, "Extractor selected track " + i + " (" + mime + "): " + format); + } + return i; + } + } + return -1; + } + + private static boolean isImageFormatSupported(Image image) { + int format = image.getFormat(); + switch (format) { + case ImageFormat.YUV_420_888: + case ImageFormat.NV21: + case ImageFormat.YV12: + return true; + } + return false; + } + + private static byte[] getDataFromImage(Image image, int colorFormat) { + if (colorFormat != COLOR_FormatI420 && colorFormat != COLOR_FormatNV21) { + throw new IllegalArgumentException("only support COLOR_FormatI420 " + "and COLOR_FormatNV21"); + } + if (!isImageFormatSupported(image)) { + throw new RuntimeException("can't convert Image to byte array, format " + image.getFormat()); + } + Rect crop = image.getCropRect(); + int format = image.getFormat(); + int width = crop.width(); + int height = crop.height(); + Image.Plane[] planes = image.getPlanes(); + byte[] data = new byte[width * height * ImageFormat.getBitsPerPixel(format) / 8]; + byte[] rowData = new byte[planes[0].getRowStride()]; + if (VERBOSE) Log.v(TAG, "get data from " + planes.length + " planes"); + int channelOffset = 0; + int outputStride = 1; + for (int i = 0; i < planes.length; i++) { + switch (i) { + case 0: + channelOffset = 0; + outputStride = 1; + break; + case 1: + if (colorFormat == COLOR_FormatI420) { + channelOffset = width * height; + outputStride = 1; + } else if (colorFormat == COLOR_FormatNV21) { + channelOffset = width * height + 1; + outputStride = 2; + } + break; + case 2: + if (colorFormat == COLOR_FormatI420) { + channelOffset = (int) (width * height * 1.25); + outputStride = 1; + } else if (colorFormat == COLOR_FormatNV21) { + channelOffset = width * height; + outputStride = 2; + } + break; + } + ByteBuffer buffer = planes[i].getBuffer(); + int rowStride = planes[i].getRowStride(); + int pixelStride = planes[i].getPixelStride(); + if (VERBOSE) { + Log.v(TAG, "pixelStride " + pixelStride); + Log.v(TAG, "rowStride " + rowStride); + Log.v(TAG, "width " + width); + Log.v(TAG, "height " + height); + Log.v(TAG, "buffer size " + buffer.remaining()); + } + int shift = (i == 0) ? 0 : 1; + int w = width >> shift; + int h = height >> shift; + buffer.position(rowStride * (crop.top >> shift) + pixelStride * (crop.left >> shift)); + for (int row = 0; row < h; row++) { + int length; + if (pixelStride == 1 && outputStride == 1) { + length = w; + buffer.get(data, channelOffset, length); + channelOffset += length; + } else { + length = (w - 1) * pixelStride + 1; + buffer.get(rowData, 0, length); + for (int col = 0; col < w; col++) { + data[channelOffset] = rowData[col * pixelStride]; + channelOffset += outputStride; + } + } + if (row < h - 1) { + buffer.position(buffer.position() + rowStride - length); + } + } + if (VERBOSE) Log.v(TAG, "Finished reading data from plane " + i); + } + return data; + } + + +} + +enum OutputImageFormat { + I420("I420"), + NV21("NV21"), + JPEG("JPEG"); + private String friendlyName; + + private OutputImageFormat(String friendlyName) { + this.friendlyName = friendlyName; + } + + public String toString() { + return friendlyName; + } +} + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0809c8d..a10e01f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,5 +3,13 @@ 替换预览资源,空为停用 回调资源,仅支持本地YUV420,空为停用 注:预览资源指TextureView的替换,支持视频(支持在线资源,如M3U8)\n回调资源指onPreviewFrame的替换,仅支持本地YUV420的图片,添加图片请确认图片尺寸与摄像头预览尺寸匹配(可在xposed的日志中看到摄像头预览尺寸) - ## 具体的使用方法:\n\n 1、安装xposed框架(传统xposed,edxp,lsp等均可,不确定虚拟框架能否使用,已经确定VMOS可用,应用转生不可用) \n\n2、安装模块,启用模块,lsp等包含定义域的框架需要选勾目标app,但无需选勾系统框架。 \n\n3、对于大多数应用,只需要将替换的视频命名为virtual.mp4,放在/sdcard/DCIM/Camera/目录下。 \n\n4、对于少部分应用(如腾讯会议,和其他应用大部分的二维码扫描),需要使用premiere或其它剪辑软件将视频拆分成BMP格式图片(命名格式:1000.bmp,1001.bmp,1002.bmp…………,最少1张图片,最大不超过1000张。premiere中视频总帧数超过1000帧,导出时名字设置为1.bmp,会自动按以上的命名格式命名),要注意的是图片分辨率需要与目标分辨率匹配(可在xposed日志中看到),将这些图片放在/sdcard/DCIM/Camera/bmp/目录下(没有的话自己创建)。 \n\n5、强制结束目标应用/重启手机。\n\n 开源地址:https://github.com/w2016561536/android_virtual_cam + ## 具体的使用方法: \n\n +1、安装xposed框架(传统xposed,edxp,lsp等均可,不确定虚拟框架能否使用,已经确定VMOS可用,应用转生不可用) \n\n +2、安装模块,启用模块,lsp等包含定义域的框架需要选勾目标app,但无需选勾系统框架。 \n\n +3、将需要替换的视频命名为virtual.mp4,放在/sdcard/DCIM/Camera/目录下。(前置摄像头需要水平翻转后右旋90°保存,onPreviewFrame需要匹配分辨率) \n\n +4、若需要拦截拍照事件,请在/sdcard/DCIM/Camera/目录下放置 1000.bmp 用于替换,(前置摄像头需要水平翻转后右旋90°保存,需要匹配分辨率) \n\n +5、强制结束目标应用/重启手机。 \n\n +\n\n +## 如何获得分辨率??(仅onPreviewFrame和拍照需要,其它系统自动处理) \n\n +在目标应用中打开摄像头,可在toast消息里看见。 \n\n 开源地址:https://github.com/w2016561536/android_virtual_cam \ No newline at end of file