Android 自定义加解密播放音视频(m3u8独立加密)

背景

  1. 当涉及App内部视频的时候,我们不希望被别人以抓包的形式来爬取我们的视频
  2. 大视频文件以文件方式整个加密的话需要完全下载后才能进行解密
  3. 当前m3u8格式虽然支持加密,但是ts格式的小视频可以独立播放的,也就是ts文件本身没有被加密,或者加密方法过于复杂

根据以上,我通过修改ExoPlayer的源代码实现以下功能,这里不讨论其他视频流加密解密的方法

  1. 大文件分段加密后应用分段解密(m3u8)
  2. 高度自定义,你可以实现任何你需要的加密方法,甚至每一个ts都有自己的解码方式
  3. ts加密,不允许独立播放

加密流程

PS:使用ffmpeg进行音视频分割后使用Java代码进行加密

  1. 音视频分割
    代码就是通过java执行ffmpeg的命令即可,请确保环境变量中安装了ffmpeg,内部的代码可以自己通过需求来修改,其中音频与视频的分割方式差不多
 private static String encryptVideoWithFFmpeg(String videoFilePath, String outputDirPath) {
        File outputDir = new File(outputDirPath);
        if (!outputDir.exists()) {
            outputDir.mkdirs();
        }

        String outputFileName = "output"; // 输出文件名,这里可以根据需要自定义
        String tsOutputPath = outputDirPath + File.separator + outputFileName + ".ts";
        String m3u8OutputPath = outputDirPath + File.separator + outputFileName + ".m3u8";

           try {
            ProcessBuilder processBuilder = new ProcessBuilder("ffmpeg",
                    "-i", videoFilePath,
                    "-c:v", "libx264",
                    "-c:a", "aac",
                    "-f", "hls",
                    "-hls_time", "5",
                    "-hls_list_size", "0",
                    "-hls_segment_filename", outputDirPath + File.separator + "output%03d.ts",
                    m3u8OutputPath);

            // 设置工作目录,可以防止某些情况下找不到 ffmpeg 命令的问题

            Process process = processBuilder.start();

            // 获取 ffmpeg 命令执行的输出信息(可选,如果需要查看 ffmpeg 执行日志)
            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }

            int exitCode = process.waitFor();
            if (exitCode == 0) {
                System.out.println("FFmpeg command executed successfully.");
            } else {
                System.err.println("Error executing FFmpeg command. Exit code: " + exitCode);
            }

        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }

        return tsOutputPath;
    }
private static String splitAudioWithFFmpeg(String audioFilePath, String outputDirPath) {
        File outputDir = new File(outputDirPath);
        if (!outputDir.exists()) {
            outputDir.mkdirs();
        }

        String outputFileName = "output"; // 输出文件名,这里可以根据需要自定义
        String tsOutputPath = outputDirPath + File.separator + outputFileName + ".ts";
        String m3u8OutputPath = outputDirPath + File.separator + outputFileName + ".m3u8";

        try {
            ProcessBuilder processBuilder = new ProcessBuilder("ffmpeg",
                    "-i", audioFilePath,
                    "-c:a", "aac",
                    "-f", "hls",
                    "-hls_time", "10",
                    "-hls_list_size", "0",
                    "-hls_segment_filename", outputDirPath + File.separator + "output%03d.ts",
                    m3u8OutputPath);

            Process process = processBuilder.start();

            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }

            int exitCode = process.waitFor();
            if (exitCode == 0) {
                System.out.println("FFmpeg command executed successfully.");
            } else {
                System.err.println("Error executing FFmpeg command. Exit code: " + exitCode);
            }

        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }

        return tsOutputPath;
    }
  1. 音视频加密
    这里的视频加密使用的是AES加密,是将ts结尾的所有文件进行加密,后面的方法是解密,一般用不到
 private static void encryptTSSegmentsWithAES(String outputDirPath, String aesKey) {
        File outputDir = new File(outputDirPath);
        File[] tsFiles = outputDir.listFiles((dir, name) -> name.endsWith(".ts"));

        if (tsFiles != null) {
            try {
                byte[] keyBytes = aesKey.getBytes();
                Key aesKeySpec = new SecretKeySpec(keyBytes, AES_ALGORITHM);
                Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
                cipher.init(Cipher.ENCRYPT_MODE, aesKeySpec);

                for (File tsFile : tsFiles) {
                    byte[] tsData = Files.readAllBytes(Paths.get(tsFile.getPath()));
                    byte[] encryptedData = cipher.doFinal(tsData);
                    Files.write(Paths.get(tsFile.getPath()), encryptedData);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
  public static void decryptTSSegmentsWithAES(String outputDirPath, String aesKey) {
        File outputDir = new File(outputDirPath);
        File[] tsFiles = outputDir.listFiles((dir, name) -> name.endsWith(".ts"));

        if (tsFiles != null) {
            try {
                byte[] keyBytes = aesKey.getBytes();
                Key aesKeySpec = new SecretKeySpec(keyBytes, "AES");
                Cipher cipher =  Cipher.getInstance(AES_ALGORITHM);
                cipher.init(Cipher.DECRYPT_MODE, aesKeySpec);
                for (File tsFile : tsFiles) {
                    byte[] tsData = Files.readAllBytes(Paths.get(tsFile.getPath()));
                    byte[] encryptedData = cipher.doFinal(tsData);
                    Files.write(Paths.get(tsFile.getPath()), encryptedData);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

加密完成之后将m3u8放在服务器上,并且分割的文件也要在同一目录,或者切片的时候手动设置,保证切片后的视频可以正常播放即可

音视频解密

这里使用的是修改ExoPlayer的源代码来实现的,因为在Android手机上面播放视频的选择有很多,大家也可以根据我的方法修改其他播放器,本次按照ExoPlayer进行演示教学
PS:因为Google把ExoPlayer整合到MediaPlayer3里了,所以如果不使用纯源代码来修改的话,也会跟我的演示一样有删除线,但是无伤大雅

  1. 引入依赖,直接在App层的Build.gradle引入ExoPlayer2的依赖,其中我们要使用的视频流为hls格式,所以需要引入hls模块
	implementation 'com.google.android.exoplayer:exoplayer-core:2.19.0'
    implementation 'com.google.android.exoplayer:exoplayer-dash:2.19.0'
    implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.0'
    implementation 'com.google.android.exoplayer:exoplayer-hls:2.19.0'
  1. 准备修改代码,我们需要修改的类如下
  • DefaultDataSource
  • DefaultDataSourceFactory
  • DefaultHttpDataSource
  • HttpDataSource

我们只需要复制其源码然后进行修改后,使用ExoPlayer播放视频的时候,使用我们自己的类即可,如果你不想这样,那么可以直接下载ExoPlayer2的源代码进行修改,这样的话还能去除废弃的表示,没有那么多删除线,接下来我们正式开始修改
修改类“DefaultHttpDataSource
我将以注释的方式来讲解代码,注意这里只是演示一个简单的自定义加解密的切入方式,所以按照文件名末尾为ts的文件进行暴力判断,精细化的处理方式可以有很多拓展,比如仅加密视频的中间部分作为会员视频,这样只需要单一视频流就可以解决试看的问题,而且不怕应用内部修改VIP标志位(对于修改源码等暴力破解的方法无效,毕竟源码都给你扒出来了)

//定义解密流,主要使用此流来进行解密
private CipherInputStream cipherInputStream;
//修改open方法代码,最后的try代码块中增加如下内容用来解密流
@Override
public long open(DataSpec dataSpec) throws HttpDataSourceException {
....
try {
            inputStream = connection.getInputStream();
            if (isCompressed) {
                inputStream = new GZIPInputStream(inputStream);
            }
            //新增代码块,这里的解密方法可以按照自己的需求编写----------------------------------
            if (dataSpec.uri.getPath().endsWith(".ts")) {
                Cipher cipher;
                try {
                    cipher = Cipher.getInstance("AES");
                } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
                    throw new RuntimeException(e);
                }
                Key aesKeySpec = new SecretKeySpec("1234567890abcdef".getBytes(), "AES");
                try {
                    cipher.init(Cipher.DECRYPT_MODE, aesKeySpec);
                } catch (InvalidKeyException e) {
                    throw new RuntimeException(e);
                }
                cipherInputStream = new CipherInputStream(inputStream, cipher);
            }
            //新增代码块结束------------------------------
        } catch (IOException e) {
            closeConnectionQuietly();
            throw new HttpDataSourceException(
                    e,
                    dataSpec,
                    PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
                    HttpDataSourceException.TYPE_OPEN);
        }
        ....
}
	//修改read方法如下,如果判断是需要解密的文件则走cipherInputStream
   @Override
    public final int read(byte[] buffer, int offset, int length) throws IOException {
        if (dataSpec.uri.getPath().endsWith(".ts")) {
            Assertions.checkNotNull(cipherInputStream);
            int bytesRead = cipherInputStream.read(buffer, offset, length);
            if (bytesRead < 0) {
                return C.RESULT_END_OF_INPUT;
            }
            return bytesRead;
        } else {
            try {
                return readInternal(buffer, offset, length);
            } catch (IOException e) {
                throw HttpDataSourceException.createForIOException(
                        e, castNonNull(dataSpec), HttpDataSourceException.TYPE_READ);
            }
        }
    }
//最后释放资源
 @Override
    public void close() throws HttpDataSourceException {
        try {
            @Nullable InputStream inputStream = this.inputStream;
            if (inputStream != null) {
                long bytesRemaining =
                        bytesToRead == C.LENGTH_UNSET ? C.LENGTH_UNSET : bytesToRead - bytesRead;
                maybeTerminateInputStream(connection, bytesRemaining);
                try {
                    inputStream.close();
                } catch (IOException e) {
                    throw new HttpDataSourceException(
                            e,
                            castNonNull(dataSpec),
                            PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
                            HttpDataSourceException.TYPE_CLOSE);
                }

            }
            if (cipherInputStream != null) {
                cipherInputStream.close();
            }
        } catch (IOException e) {
            throw new HttpDataSourceException(
                    e,
                    castNonNull(dataSpec),
                    PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
                    HttpDataSourceException.TYPE_CLOSE);
        } finally {
            inputStream = null;
            cipherInputStream = null;
            closeConnectionQuietly();
            if (opened) {
                opened = false;
                transferEnded();
            }
        }
    }

修改类“DefaultDataSourceFactory
此类只需要修改一点,那就是将DefaultDataSource的create过程引导到我们自己写的DefaultDataSource,也就是删除原来的ExoPlayer2的依赖引入,引入刚刚讲到的DefaultHttpDataSource,不需要修改代码,只需要切换依赖即可

 public DefaultDataSourceFactory(
      Context context, @Nullable String userAgent, @Nullable TransferListener listener) {
    this(context, listener, new DefaultHttpDataSource.Factory().setUserAgent(userAgent));
  } 

音视频播放

因为ExoPlayer2同时支持音频和视频的播放,所以均可使用下列方式完成

public class PlayerActivity extends AppCompatActivity {
    private PlayerView playerView;
    private SimpleExoPlayer player;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_player);

        // Initialize PlayerView
        playerView = findViewById(R.id.player);

        // Create a DefaultTrackSelector to enable tracks
        DefaultTrackSelector trackSelector = new DefaultTrackSelector(this);

        // Create an instance of ExoPlayer
        player = new SimpleExoPlayer.Builder(this)
                .setTrackSelector(trackSelector)
                .build();

        // Attach the player to the PlayerView
        playerView.setPlayer(player);

        String userAgent = Util.getUserAgent(this, "ExoPlayerDemo");
        DefaultDataSourceFactory dataSourceFactory = new DefaultDataSourceFactory(this, userAgent);

        String videoUrl = "http://zhangzhiao.top/missit/aa/output.m3u8";

        // Create an HlsMediaSource
        HlsMediaSource mediaSource = new HlsMediaSource.Factory(dataSourceFactory)
                .createMediaSource(MediaItem.fromUri(Uri.parse(videoUrl)));

        // Prepare the player with the media source
        player.prepare(mediaSource);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // Release the player when the activity is destroyed
        player.release();
    }
}

源码下载

结语

代码里给大家提供了一个小视频,如果按照流程编写应该是可以顺利播放的,如果需要还可以把m3u8文件进行加密处理,一切处理方法都可以实现,如果对您有帮助不妨点个赞