version:1.0.7

bugfixes:
update:修改默认登录方式,增加播放加密视频
This commit is contained in:
2026-02-25 10:39:28 +08:00
parent c5980c419a
commit 85ccdcce72
23 changed files with 1079 additions and 76 deletions

View File

@@ -67,7 +67,10 @@
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:theme="@style/DialogCloseOnTouchOutside" />
<activity
android:name=".activity.player.DecryptionPlayerActivity"
android:launchMode="singleTask"
android:screenOrientation="portrait" />
<activity
android:name=".activity.preview.VideoPreviewActivity"
android:launchMode="singleTask"

View File

@@ -8,6 +8,7 @@ import com.hainaos.vc.base.mvvm.BaseViewModel;
import com.hainaos.vc.bean.LocalVideoInfo;
import com.hainaos.vc.databinding.ActivityCategoryLocalBinding;
import com.hainaos.vc.utils.FileUtils;
import com.hainaos.vc.utils.VideoUtils;
import com.trello.rxlifecycle4.android.ActivityEvent;
import java.io.File;
@@ -16,6 +17,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
@@ -58,27 +60,41 @@ public class LocalCategoryViewModel extends BaseViewModel<ActivityCategoryLocalB
}
})
.subscribeOn(Schedulers.io())
.map(new io.reactivex.rxjava3.functions.Function<List<String>, ArrayList<LocalVideoInfo>>() {
@Override
public ArrayList<LocalVideoInfo> apply(List<String> stringList) throws Throwable {
ArrayList<LocalVideoInfo> localVideoInfos = stringList.stream().map(new Function<String, LocalVideoInfo>() {
@Override
public LocalVideoInfo apply(String s) {
File videoFile = new File(file.getAbsolutePath() + File.separator + s);
LocalVideoInfo localVideoInfo = new LocalVideoInfo();
localVideoInfo.setFile_name(FileUtils.getFileNameWithoutExtension(s));
localVideoInfo.setLocalPath(videoFile.getAbsolutePath());
ArrayList<LocalVideoInfo> localVideoInfos = stringList.stream()
.filter(new Predicate<String>() {
@Override
public boolean test(String s) {
return VideoUtils.isVideoFormat(s) || s.endsWith(".hnv");
}
})
.map(new Function<String, LocalVideoInfo>() {
@Override
public LocalVideoInfo apply(String s) {
File videoFile = new File(file.getAbsolutePath() + File.separator + s);
LocalVideoInfo localVideoInfo = new LocalVideoInfo();
localVideoInfo.setFile_name(FileUtils.getFileNameWithoutExtension(s));
localVideoInfo.setLocalPath(videoFile.getAbsolutePath());
long time = System.currentTimeMillis();
FFmpegMediaMetadataRetriever mmr = new FFmpegMediaMetadataRetriever();
mmr.setDataSource(videoFile.getAbsolutePath());
int duration = Integer.parseInt(mmr.extractMetadata(FFmpegMediaMetadataRetriever.METADATA_KEY_DURATION));
Log.e("AudioUtils", "getDurationInMilliseconds: " + (System.currentTimeMillis() - time));
mmr.release();//释放资源
localVideoInfo.setDuration(duration / 1000);
return localVideoInfo;
}
}).collect(Collectors.toCollection(ArrayList::new));
FFmpegMediaMetadataRetriever mmr = new FFmpegMediaMetadataRetriever();
try {
long time = System.currentTimeMillis();
mmr.setDataSource(videoFile.getAbsolutePath());
int duration = Integer.parseInt(mmr.extractMetadata(FFmpegMediaMetadataRetriever.METADATA_KEY_DURATION));
Log.e("AudioUtils", "getDurationInMilliseconds: " + (System.currentTimeMillis() - time));
localVideoInfo.setDuration(duration / 1000);
} catch (Exception e) {
Log.e(TAG, "apply: " + e.getMessage());
} finally {
mmr.release();//释放资源
}
return localVideoInfo;
}
}).collect(Collectors.toCollection(ArrayList::new));
return localVideoInfos;
}
})

View File

@@ -47,7 +47,7 @@ public class LoginActivity extends BaseMvvmActivity<LoginViewModel, ActivityLogi
@Override
protected void initView() {
mViewDataBinding.setLoginMode(mLoginMode);
}
@Override
@@ -134,7 +134,7 @@ public class LoginActivity extends BaseMvvmActivity<LoginViewModel, ActivityLogi
mViewDataBinding.cardView.setEnabled(true);
}
private int mLoginMode = 0;
private int mLoginMode = 1;
public class BtnClick {
public void changeLoginMode(View view) {

View File

@@ -0,0 +1,161 @@
package com.hainaos.vc.activity.player;
import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.util.Util;
import com.hainaos.vc.R;
import com.hainaos.vc.base.mvvm.BaseMvvmActivity;
import com.hainaos.vc.databinding.ActivityDecryptionPlayerBinding;
import com.hainaos.vc.utils.JgyUtils;
import com.hainaos.vc.video.AesDataSource;
import com.hjq.toast.Toaster;
public class DecryptionPlayerActivity extends BaseMvvmActivity<DecryptionPlayerViewModel, ActivityDecryptionPlayerBinding> {
private static final String TAG = "DecryptionPlayer";
private ExoPlayer mExoPlayer;
private String mUrl;
@Override
protected int getLayoutId() {
return R.layout.activity_decryption_player;
}
@Override
protected void initDataBinding() {
mViewModel.setCtx(this);
mViewModel.setVDBinding(mViewDataBinding);
mViewModel.setLifecycle(getLifecycleSubject());
mViewDataBinding.setClick(new BtnClick());
}
@Override
protected void initView() {
mExoPlayer = new SimpleExoPlayer.Builder(this).build();
mViewDataBinding.playerView.setPlayer(mExoPlayer);
}
@Override
protected void initData() {
Intent intent = getIntent();
mUrl = intent.getStringExtra("url");
if (!TextUtils.isEmpty(mUrl)) {
Uri videoUri = Uri.parse(mUrl);
MediaItem mediaItem = MediaItem.fromUri(videoUri);
byte[] key = JgyUtils.getSecretKey().getBytes();
byte[] iv = JgyUtils.getIvParameter().getBytes();
// SecretKeySpec keySpec = new SecretKeySpec(JgyUtils.getSecretKey().getBytes(), "AES");
// IvParameterSpec ivSpec = new IvParameterSpec(JgyUtils.getIvParameter().getBytes());
// try {
// Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
// cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
// 创建自定义 Factory
DataSource.Factory dataSourceFactory = () -> {
try {
return new AesDataSource(key, iv);
// DataSource upstream = new FileDataSource();
// return new AesDataSource2(upstream, key, iv);
} catch (Exception e) {
throw new RuntimeException(e);
}
};
MediaSource mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(mediaItem);
mExoPlayer.setMediaSource(mediaSource);
mExoPlayer.prepare();
mExoPlayer.play();
// } catch (Exception e) {
// Log.e(TAG, "initData: " + e.getMessage());
// }
} else {
Toaster.show("文件地址为空");
finish();
}
}
@Override
protected void onStart() {
super.onStart();
// 在 Android 7.0 (API 24) 及以上,在 onStart 中初始化
if (Util.SDK_INT >= 24) {
initializePlayer();
}
}
@Override
protected void onResume() {
super.onResume();
// 在 Android 7.0 以下,在 onResume 中初始化
if (Util.SDK_INT < 24) {
initializePlayer();
}
}
@Override
protected void onPause() {
super.onPause();
// 在 Android 7.0 以下,在 onPause 中释放
if (Util.SDK_INT < 24) {
releasePlayer();
}
}
@Override
protected void onStop() {
super.onStop();
// 在 Android 7.0 及以上,在 onStop 中释放
if (Util.SDK_INT >= 24) {
releasePlayer();
}
}
private void initializePlayer() {
// if (mExoPlayer == null) {
// // 1. 创建 ExoPlayer 实例
// mExoPlayer = new ExoPlayer.Builder(this).build();
// // 2. 将播放器绑定到视图
// playerView.setPlayer(mExoPlayer);
// // 3. 恢复之前的播放状态(如果有)
// mExoPlayer.setPlayWhenReady(playWhenReady);
// mExoPlayer.seekTo(currentMediaItemIndex, playbackPosition);
// }
//
// // 4. 准备媒体源
// Uri videoUri = Uri.parse("https://your-video-url.com/sample.mp4"); // 替换为你的视频URL
// MediaItem mediaItem = MediaItem.fromUri(videoUri);
// mExoPlayer.setMediaItem(mediaItem);
// mExoPlayer.prepare();
// // 如果需要自动播放,可以调用 exoPlayer.play();
}
private void releasePlayer() {
if (mExoPlayer != null) {
// 记录当前的播放状态
// playbackPosition = mExoPlayer.getCurrentPosition();
// currentMediaItemIndex = mExoPlayer.getCurrentMediaItemIndex();
// playWhenReady = mExoPlayer.getPlayWhenReady();
// 释放播放器资源
mExoPlayer.release();
mExoPlayer = null;
}
}
public class BtnClick {
}
}

View File

@@ -0,0 +1,18 @@
package com.hainaos.vc.activity.player;
import com.hainaos.vc.base.mvvm.BaseViewModel;
import com.hainaos.vc.databinding.ActivityDecryptionPlayerBinding;
import com.trello.rxlifecycle4.android.ActivityEvent;
public class DecryptionPlayerViewModel extends BaseViewModel<ActivityDecryptionPlayerBinding, ActivityEvent> {
@Override
public ActivityDecryptionPlayerBinding getVDBinding() {
return binding;
}
@Override
public void onDestroy() {
}
}

View File

@@ -23,6 +23,7 @@ import com.arialyy.annotations.Download;
import com.arialyy.aria.core.Aria;
import com.arialyy.aria.core.task.DownloadTask;
import com.hainaos.vc.R;
import com.hainaos.vc.activity.player.DecryptionPlayerActivity;
import com.hainaos.vc.activity.preview.VideoPreviewActivity;
import com.hainaos.vc.bean.CategoryVideoInfo;
import com.hainaos.vc.config.Permissions;
@@ -103,6 +104,7 @@ public class CategoryVideoAdapter extends RecyclerView.Adapter<CategoryVideoAdap
} else {
if (XXPermissions.isGranted(mContext, Permissions.STORAGE_PERMISSIONS)) {
FileUtils.ariaDownload(mContext, mDirName, url, md5);
// FileUtils.ariaDownloadCover(mContext, mDirName, cover, md5);
} else {
showPermissionsDialog(mContext);
}
@@ -113,10 +115,16 @@ public class CategoryVideoAdapter extends RecyclerView.Adapter<CategoryVideoAdap
@Override
public void onClick(View v) {
if (file.exists()) {
Intent intent = new Intent(mContext, VideoPreviewActivity.class);
intent.putExtra("cover", cover);
intent.putExtra("url", file.getAbsolutePath());
mContext.startActivity(intent);
if (file.getAbsolutePath().endsWith(".hnv")) {
Intent intent = new Intent(mContext, DecryptionPlayerActivity.class);
intent.putExtra("url", file.getAbsolutePath());
mContext.startActivity(intent);
} else {
Intent intent = new Intent(mContext, VideoPreviewActivity.class);
intent.putExtra("cover", cover);
intent.putExtra("url", file.getAbsolutePath());
mContext.startActivity(intent);
}
} else {
Toaster.show("请先下载视频");
}

View File

@@ -20,6 +20,7 @@ import com.bumptech.glide.Glide;
import com.google.gson.Gson;
import com.google.gson.JsonParser;
import com.hainaos.vc.R;
import com.hainaos.vc.activity.player.DecryptionPlayerActivity;
import com.hainaos.vc.activity.tiktok.TikTokActivity;
import com.hainaos.vc.bean.LocalVideoInfo;
import com.hainaos.vc.utils.FileUtils;
@@ -102,7 +103,13 @@ public class VideoAdapter extends RecyclerView.Adapter<VideoAdapter.VideoHolder>
holder.root.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
playVideo(position);
if (file.getAbsolutePath().endsWith(".hnv")) {
Intent intent = new Intent(mContext, DecryptionPlayerActivity.class);
intent.putExtra("url", file.getAbsolutePath());
mContext.startActivity(intent);
} else {
playVideo(position);
}
}
});
holder.root.setOnLongClickListener(view -> {

View File

@@ -89,6 +89,41 @@ public class FileUtils {
return path + File.separator;
}
public static void ariaDownloadCover(Context context, String dirName, String fileName, String url, String md5) {
String downLoadPath = getHainaVideoPath(context) + dirName + File.separator;
File dirFile = new File(downLoadPath);
if (!dirFile.exists()) {
Log.e(TAG, "ariaDownload: mkdirs = " + dirFile.mkdirs());
}
File file = new File(downLoadPath + fileName);
if (file.exists() && !file.isDirectory()) {
String fileMD5 = com.blankj.utilcode.util.FileUtils.getFileMD5ToString(file);
Log.e("ariaDownload", "fileOnlineMD5=" + md5);
Log.e("ariaDownload", "fileMD5=" + fileMD5);
if (!TextUtils.isEmpty(md5)) {
if (!md5.equals(fileMD5)) {
Aria.download(context)
.load(url) //读取下载地址
.setFilePath(file.getAbsolutePath())
// .ignoreFilePathOccupy()
.setExtendField(url)
.create(); //启动下载}
} else {
Log.e("ariaDownload", "fileName = " + fileName + " exists");
}
} else {
Log.e("ariaDownload", url + " no md5 params , skip");
}
} else {
Aria.download(context)
.load(url) //读取下载地址
.setFilePath(file.getAbsolutePath())
// .ignoreFilePathOccupy()
.setExtendField(url)
.create(); //启动下载}
}
}
public static void ariaDownload(Context context, String dirName, String url, String md5) {
String downLoadPath = getHainaVideoPath(context) + dirName + File.separator;
File dirFile = new File(downLoadPath);

View File

@@ -31,6 +31,13 @@ public class JgyUtils {
private static JgyUtils sInstance;
private Context mContext;
static {
System.loadLibrary("hnos");
}
public static native String getSecretKey();
public static native String getIvParameter();
private JgyUtils(Context context) {
if (context == null) {

View File

@@ -1,5 +1,7 @@
package com.hainaos.vc.utils;
import android.text.TextUtils;
import java.io.File;
import java.io.FileInputStream;
import java.math.BigInteger;
@@ -27,6 +29,9 @@ public class VideoUtils {
};
public static boolean isVideoFormat(String filePath) {
if (TextUtils.isEmpty(filePath)) {
return false;
}
for (String s : video_extension) {
if (filePath.endsWith(s)) {
return true;

View File

@@ -0,0 +1,166 @@
package com.hainaos.vc.video;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.CipherOutputStream;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class AesCtrFileUtil {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/CTR/NoPadding";
private static final int IV_SIZE = 16; // AES block size
private static final int BUFFER_SIZE = 8192; // 8KB buffer
/**
* 加密文件
* 逻辑生成随机IV -> 写入IV到文件头 -> 读取源文件 -> 加密 -> 写入密文
*
* @param keySecret AES密钥 (16, 24, or 32 bytes)
* @param inputFile 源文件路径
* @param outputFile 输出文件路径
*/
public static void encryptFile(byte[] keySecret, File inputFile, File outputFile)
throws GeneralSecurityException, IOException {
// 1. 准备密钥
SecretKeySpec keySpec = new SecretKeySpec(keySecret, ALGORITHM);
// 2. 生成随机 IV
// byte[] iv = new byte[IV_SIZE];
byte[] iv = "hainaos1hainaos1".getBytes();
new SecureRandom().nextBytes(iv);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
// 3. 初始化 Cipher
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
// 4. 流式读写
try (FileInputStream inputStream = new FileInputStream(inputFile);
FileOutputStream outputStream = new FileOutputStream(outputFile)) {
// IMPORTANT: 先将 IV 写入输出文件的头部,解密时需要读取它
outputStream.write(iv);
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
// update 方法处理数据块
byte[] output = cipher.update(buffer, 0, bytesRead);
if (output != null) {
outputStream.write(output);
}
}
// doFinal 处理最后的数据(虽然 NoPadding 通常不需要,但必须调用以完成操作)
byte[] outputBytes = cipher.doFinal();
if (outputBytes != null) {
outputStream.write(outputBytes);
}
}
}
/**
* 解密文件
* 逻辑读取文件头IV -> 初始化Cipher -> 读取密文 -> 解密 -> 写入原文件
*/
public static void decryptFile(byte[] keySecret, File inputFile, File outputFile)
throws GeneralSecurityException, IOException {
SecretKeySpec keySpec = new SecretKeySpec(keySecret, ALGORITHM);
try (FileInputStream inputStream = new FileInputStream(inputFile);
FileOutputStream outputStream = new FileOutputStream(outputFile)) {
// 1. 从文件头部读取 IV
byte[] iv = new byte[IV_SIZE];
int ivRead = inputStream.read(iv);
if (ivRead < IV_SIZE) {
throw new IllegalArgumentException("文件太短,无法读取 IV/文件已损坏");
}
IvParameterSpec ivSpec = new IvParameterSpec(iv);
// 2. 初始化 Cipher (解密模式)
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
// 3. 流式处理剩余内容
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
byte[] output = cipher.update(buffer, 0, bytesRead);
if (output != null) {
outputStream.write(output);
}
}
byte[] outputBytes = cipher.doFinal();
if (outputBytes != null) {
outputStream.write(outputBytes);
}
}
}
public static void encryptFile(File inputFile, File outputFile, byte[] key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
try (FileInputStream fis = new FileInputStream(inputFile);
FileOutputStream fos = new FileOutputStream(outputFile);
CipherOutputStream cos = new CipherOutputStream(fos, cipher)) {
byte[] buffer = new byte[8192];
int read;
while ((read = fis.read(buffer)) != -1) {
cos.write(buffer, 0, read);
}
}
}
// --- 测试 Main 方法 ---
public static void main(String[] args) {
try {
// 1. 生成一个测试密钥 (256位)
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256);
SecretKey secretKey = keyGen.generateKey();
// byte[] keyBytes = secretKey.getEncoded();
byte[] keyBytes = "hainaos_key_123_".getBytes();
// 2. 准备文件
File sourceFile = new File("C:\\Users\\TT\\Videos\\美妆\\test_video_1.mp4");
// File sourceFile = new File("test_original.txt");
File encryptedFile = new File("C:\\Users\\TT\\Videos\\美妆\\test_video_1.hnv");
File decryptedFile = new File("C:\\Users\\TT\\Videos\\美妆\\test_decrypted.mp4");
encryptFile(sourceFile, encryptedFile, keyBytes, "hainaos1hainaos1".getBytes());
// 创建一个简单的测试文件
// try (FileWriter writer = new FileWriter(sourceFile)) {
// writer.write("Hello, World! This is a test for AES/CTR/NoPadding
// encryption.");
// }
// System.out.println("开始加密...");
// encryptFile(keyBytes, sourceFile, encryptedFile);
// System.out.println("加密完成: " + encryptedFile.getAbsolutePath());
// System.out.println("开始解密...");
// decryptFile(keyBytes, encryptedFile, decryptedFile);
// System.out.println("解密完成: " + decryptedFile.getAbsolutePath());
} catch (Exception e) {
e.printStackTrace();
}
}
}

View File

@@ -0,0 +1,69 @@
package com.hainaos.vc.video;
import android.net.Uri;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.TransferListener;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class AesDataSource implements DataSource {
private final Cipher cipher;
private InputStream inputStream;
private Uri uri;
public AesDataSource(byte[] key, byte[] iv) throws Exception {
cipher = Cipher.getInstance("AES/CTR/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
}
@Override
public long open(DataSpec dataSpec) throws IOException {
this.uri = dataSpec.uri;
// 注意:如果是网络流,这里需要使用 HttpURLConnection
// 示例代码以本地文件为例
FileInputStream fis = new FileInputStream(uri.getPath());
inputStream = new CipherInputStream(fis, cipher);
// 如果支持 Seek拖动进度条此处需处理 skip
if (dataSpec.position > 0) {
inputStream.skip(dataSpec.position);
}
return dataSpec.length;
}
@Override
public int read(byte[] buffer, int offset, int readLength) throws IOException {
if (readLength == 0) return 0;
int read = inputStream.read(buffer, offset, readLength);
if (read == -1) return -1;
return read;
}
@Override
public Uri getUri() {
return uri;
}
@Override
public void close() throws IOException {
if (inputStream != null) {
inputStream.close();
inputStream = null;
}
}
@Override
public void addTransferListener(TransferListener transferListener) {
}
}

View File

@@ -0,0 +1,200 @@
package com.hainaos.vc.video;
import android.net.Uri;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSourceInputStream;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.TransferListener;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class AesDataSource2 implements DataSource {
private final DataSource upstream;
private final byte[] secretKey;
private final byte[] iv;
private CipherInputStream cipherInputStream;
private long bytesRemaining;
private boolean opened;
public AesDataSource2(DataSource upstream, byte[] secretKey, byte[] iv) {
this.upstream = upstream;
this.secretKey = secretKey;
this.iv = iv;
}
@Override
public void addTransferListener(TransferListener transferListener) {
upstream.addTransferListener(transferListener);
}
@Override
public long open(DataSpec dataSpec) throws IOException {
// 1. 获取请求的绝对位置
long position = dataSpec.position;
// AES 块大小通常为 16 字节
final int AES_BLOCK_SIZE = 16;
// 2. 计算块索引 (Block Index) 和 块内偏移 (Offset inside the block)
// 例如position = 100blockIndex = 6 (96字节处)offset = 4
long blockIndex = position / AES_BLOCK_SIZE;
int offsetInBlock = (int) (position % AES_BLOCK_SIZE);
// 3. 计算对齐后的起始读取位置 (必须是 16 的倍数)
long startPosition = blockIndex * AES_BLOCK_SIZE;
try {
// 4. 初始化 Cipher
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(secretKey, "AES");
// 【关键步骤】:根据 blockIndex 计算新的 IV
// CTR 模式下NewIV = OriginalIV + blockIndex
byte[] newIv = getAdjustedIv(this.iv, blockIndex);
IvParameterSpec ivSpec = new IvParameterSpec(newIv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
// 5. 让上层数据源 (FileDataSource) 从对齐的位置 (startPosition) 开始读
// 注意:我们修改了 position但保持 length 不变 (或者处理 open ended)
long length = dataSpec.length != C.LENGTH_UNSET ? dataSpec.length + offsetInBlock : C.LENGTH_UNSET;
DataSpec newSpec = dataSpec.buildUpon()
.setPosition(startPosition)
.setLength(length)
.build();
// 打开上层流
InputStream inputStream = new DataSourceInputStream(upstream, newSpec);
// 创建解密流
cipherInputStream = new CipherInputStream(inputStream, cipher);
// 6. 【重要】跳过块内的偏移量
// 因为我们要给 ExoPlayer 返回的是从 dataSpec.position 开始的数据,
// 但我们是从 startPosition (前一个16倍数) 开始解密的,所以前面多读的 offsetInBlock 个字节是无用的。
if (offsetInBlock > 0) {
forceSkip(cipherInputStream, offsetInBlock);
}
opened = true;
// 计算剩余长度
if (dataSpec.length != C.LENGTH_UNSET) {
bytesRemaining = dataSpec.length;
} else {
long upstreamLength = upstream.open(newSpec); // 这一步其实已经在 DataSourceInputStream 里做过了,这里仅作逻辑参考
// 通常 upstream.open 返回的是从 startPosition 开始的长度
// 如果 upstream 支持长度解析:
// bytesRemaining = upstreamLength != C.LENGTH_UNSET ? upstreamLength - offsetInBlock : C.LENGTH_UNSET;
bytesRemaining = C.LENGTH_UNSET;
}
return bytesRemaining;
} catch (Exception e) {
throw new IOException(e);
}
}
/**
* 根据块索引计算新的 IV。
* CTR 模式将 IV 视为一个大整数 (BigEndian),每过一个块,计数器 +1。
*/
private byte[] getAdjustedIv(byte[] originalIv, long blockIndex) {
// 使用 BigInteger 处理大数加法,防止溢出
BigInteger ivVal = new BigInteger(1, originalIv);
BigInteger offset = BigInteger.valueOf(blockIndex);
BigInteger newIvVal = ivVal.add(offset);
byte[] newIv = newIvVal.toByteArray();
// BigInteger.toByteArray() 可能会因为符号位导致长度变为 17 (如果是正数且最高位是1)
// 或者因为数值较小导致长度小于 16。必须确保返回 16 字节。
byte[] result = new byte[16];
int srcOffset = newIv.length > 16 ? newIv.length - 16 : 0;
int dstOffset = newIv.length < 16 ? 16 - newIv.length : 0;
int copyLength = newIv.length > 16 ? 16 : newIv.length;
System.arraycopy(newIv, srcOffset, result, dstOffset, copyLength);
return result;
}
/**
* 强制跳过指定字节数。CipherInputStream 的 skip 有时不可靠,建议循环 read。
*/
private void forceSkip(InputStream stream, int bytesToSkip) throws IOException {
long skipped = 0;
byte[] skipBuffer = new byte[1024];
while (skipped < bytesToSkip) {
int toRead = (int) Math.min(bytesToSkip - skipped, skipBuffer.length);
int read = stream.read(skipBuffer, 0, toRead);
if (read == -1) {
break;
}
skipped += read;
}
}
@Override
public int read(byte[] buffer, int offset, int readLength) throws IOException {
if (readLength == 0) {
return 0;
}
int bytesToRead = readLength;
if (bytesRemaining != C.LENGTH_UNSET) {
bytesToRead = (int) Math.min(readLength, bytesRemaining);
}
if (bytesToRead == 0) {
return C.RESULT_END_OF_INPUT;
}
int bytesRead = cipherInputStream.read(buffer, offset, bytesToRead);
if (bytesRead == -1) {
if (bytesRemaining != C.LENGTH_UNSET) {
// 预期还要读数据但读不到了,抛错
throw new IOException("End of stream reached prematurely");
}
return C.RESULT_END_OF_INPUT;
}
if (bytesRemaining != C.LENGTH_UNSET) {
bytesRemaining -= bytesRead;
}
return bytesRead;
}
@Override
public Uri getUri() {
return upstream.getUri();
}
@Override
public Map<String, List<String>> getResponseHeaders() {
return upstream.getResponseHeaders();
}
@Override
public void close() throws IOException {
if (opened) {
opened = false;
if (cipherInputStream != null) {
cipherInputStream.close(); // 这也会关闭 upstream
cipherInputStream = null;
}
}
}
}

View File

@@ -0,0 +1,60 @@
package com.hainaos.vc.video;
import android.net.Uri;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.TransferListener;
import java.io.IOException;
import javax.crypto.Cipher;
public class DecryptingDataSource implements DataSource {
private final DataSource upstream;
private final Cipher cipher;
public DecryptingDataSource(DataSource upstream, Cipher cipher) {
this.upstream = upstream;
this.cipher = cipher;
}
@Override
public void addTransferListener(TransferListener transferListener) {
}
@Override
public long open(DataSpec dataSpec) throws IOException {
// 初始化解密状态,比如根据 dataSpec.position 调整 Cipher 的偏移量
return upstream.open(dataSpec);
}
@Override
public int read(byte[] buffer, int offset, int readLength) throws IOException {
if (readLength == 0) return 0;
// 1. 从原始源(文件或网络)读取加密数据
int bytesRead = upstream.read(buffer, offset, readLength);
if (bytesRead == -1) return -1;
// 2. 原地解密 (In-place decryption)
// 注意:此处需要处理流式解密的块对齐问题
byte[] decryptedData = cipher.update(buffer, offset, bytesRead);
if (decryptedData != null) {
System.arraycopy(decryptedData, 0, buffer, offset, decryptedData.length);
}
return bytesRead;
}
@Override
public Uri getUri() {
return upstream.getUri();
}
@Override
public void close() throws IOException {
upstream.close();
}
}

View File

@@ -0,0 +1,25 @@
package com.hainaos.vc.video;
import android.content.Context;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSource;
import javax.crypto.Cipher;
public class DecryptingDataSourceFactory implements DataSource.Factory {
private final Context context;
private final Cipher cipher;
public DecryptingDataSourceFactory(Context context, Cipher cipher) {
this.context = context;
this.cipher = cipher;
}
@Override
public DataSource createDataSource() {
// 通常包装一个 DefaultDataSource 用于支持本地文件和网络
DataSource upstream = new DefaultDataSource(context, "User-Agent", false);
return new DecryptingDataSource(upstream, cipher);
}
}

View File

@@ -0,0 +1,40 @@
package com.hainaos.vc.video;
public class DecryptionPlay {
private static final String ALGORITHM = "AES/CTR/NoPadding";
// public void testCode(Context context) {
// SecretKeySpec keySpec = new SecretKeySpec(JgyUtils.getSecretKey().getBytes(), "AES");
// IvParameterSpec ivSpec = new IvParameterSpec(JgyUtils.getIvParameter().getBytes());
//
// // 初始化 Cipher (例如 AES/CTR/NoPadding)
// Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
// cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
// DataSource.Factory dataSourceFactory = new DecryptingDataSourceFactory(context, cipher);
// MediaSource mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory)
// .createMediaSource(MediaItem.fromUri(encryptedUri));
//
//
//
// // 1. 准备密钥和 IV (需与加密时一致)
// byte[] secretKey = "your_16_byte_key".getBytes();
// byte[] iv = "your_16_byte_iv__".getBytes();
//
// // 2. 创建用于解密的 DataSource 工厂
// DataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context) ;
// DataSource.Factory decryptingDataSourceFactory = () ->
// new AesCipherDataSource(secretKey, upstreamFactory.createDataSource());
//
// // 3. 构建播放列表项并设置 MediaSource
// ExoPlayer player = new ExoPlayer.Builder(context).build();
// MediaItem mediaItem = MediaItem.fromUri(Uri.fromFile(encryptedFile));
//
// MediaSource mediaSource = new ProgressiveMediaSource.Factory(decryptingDataSourceFactory)
// .createMediaSource(mediaItem);
//
// player.setMediaSource(mediaSource);
// player.prepare();
// player.play();
// }
}

View File

@@ -0,0 +1,36 @@
package com.hainaos.vc.video;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import javax.crypto.Cipher;
import javax.crypto.CipherOutputStream;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class VideoEncryptor {
private static final String ALGORITHM = "AES/CTR/NoPadding";
// 注意实际开发中Key 和 IV 应该安全存储,不要硬编码
public static void encryptVideo(File inputFile, File outputFile, byte[] key, byte[] iv) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
try (FileInputStream fis = new FileInputStream(inputFile);
FileOutputStream fos = new FileOutputStream(outputFile);
CipherOutputStream cos = new CipherOutputStream(fos, cipher)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
cos.write(buffer, 0, bytesRead);
}
}
}
}

22
app/src/main/jni/hnos.cpp Normal file
View File

@@ -0,0 +1,22 @@
#include <jni.h>
#include <string>
#include <sys/system_properties.h>
// 日志打印
#include <android/log.h>
#define LOG_TAG "TAG_LOG"
#define LOGI(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
extern "C"
JNIEXPORT jstring JNICALL
Java_com_hainaos_vc_utils_JgyUtils_getSecretKey(JNIEnv *env, jclass clazz) {
std::string key = "hainaos_key_123_";
return env->NewStringUTF(key.c_str());
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_hainaos_vc_utils_JgyUtils_getIvParameter(JNIEnv *env, jclass clazz) {
std::string key = "hainaos1hainaos1";
return env->NewStringUTF(key.c_str());
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".activity.player.DecryptionPlayerActivity">
<data>
<variable
name="click"
type="com.hainaos.vc.activity.player.DecryptionPlayerActivity.BtnClick" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.exoplayer2.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>