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

44
app/CMakeLists.txt Normal file
View File

@@ -0,0 +1,44 @@
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html
# Sets the minimum version of CMake required to build the native library.
cmake_minimum_required(VERSION 3.4.1)
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
add_library( # Sets the name of the library.
hnos
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
src/main/jni/hnos.cpp)
# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.
find_library( # Sets the name of the path variable.
log-lib
# Specifies the name of the NDK library that
# you want CMake to locate.
log)
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
target_link_libraries( # Specifies the target library.
hnos
# Links the target library to the log library
# included in the NDK.
${log-lib})

View File

@@ -18,8 +18,8 @@ android {
//There are no CERT files because If the mini sdk version is 23+, the AGP will ignore the V1 scheme signature.
minSdkVersion 23
targetSdkVersion 29
versionCode 7
versionName "1.0.6"
versionCode 8
versionName "1.0.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -30,12 +30,17 @@ android {
/*, "x86_64", "mips", "mips64"*/
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
lintOptions {
checkReleaseBuilds false
abortOnError false
}
viewBinding{
viewBinding {
enabled = true
}
@@ -44,9 +49,10 @@ android {
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
externalNativeBuild {
cmake {
path file('CMakeLists.txt')
}
}
//签名
@@ -144,6 +150,11 @@ dependencies {
implementation 'androidx.cardview:cardview:1.0.0'
implementation "androidx.multidex:multidex:2.0.1"
implementation 'com.google.android.exoplayer:exoplayer:2.14.1'
// implementation 'androidx.media3:media3-exoplayer:1.3.1'
// implementation 'androidx.media3:media3-exoplayer-dash:1.3.1'
// implementation 'androidx.media3:media3-ui:1.3.1'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'

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>

View File

@@ -27,11 +27,11 @@ class VideoEncryptorGUI:
# Key输入
self.label_key = tk.Label(master, text="AES密钥 (Base64编码或16/24/32字节字符串):")
self.label_key.pack(pady=(10,0))
self.entry_key = tk.Entry(master, width=50, show="*")
self.entry_key = tk.Entry(master, width=50)
self.entry_key.pack(pady=5)
# IV输入
self.label_iv = tk.Label(master, text="初始化向量IV (Base64编码或16字节字符串):")
self.label_iv = tk.Label(master, text="初始化向量IV (Base64编码或8字节字符串):")
self.label_iv.pack(pady=(10,0))
self.entry_iv = tk.Entry(master, width=50)
self.entry_iv.pack(pady=5)
@@ -64,21 +64,21 @@ class VideoEncryptorGUI:
self.status_label.config(text="文件已选择,请设置密钥")
def generate_key_iv(self):
"""生成随机的密钥和IV"""
# 生成随机密钥32字节AES-256IV16字节
"""生成随机的密钥和nonce"""
# 生成随机密钥32字节AES-256nonce8字节用于CTR模式
key = get_random_bytes(32)
iv = get_random_bytes(16)
nonce = get_random_bytes(8) # AES CTR模式使用的nonce应该是8字节
# 转换为Base64编码字符串显示在输入框中
key_b64 = base64.b64encode(key).decode('utf-8')
iv_b64 = base64.b64encode(iv).decode('utf-8')
nonce_b64 = base64.b64encode(nonce).decode('utf-8')
self.entry_key.delete(0, tk.END)
self.entry_key.insert(0, key_b64)
self.entry_iv.delete(0, tk.END)
self.entry_iv.insert(0, iv_b64)
self.entry_iv.insert(0, nonce_b64)
self.status_label.config(text="已生成随机密钥和IV,请妥善保存!")
self.status_label.config(text="已生成随机密钥和nonce,请妥善保存!")
def encrypt_video(self):
"""加密视频文件"""
@@ -94,35 +94,38 @@ class VideoEncryptorGUI:
return
try:
# 处理密钥尝试Base64解码否则使用字符串编码
try:
key = base64.b64decode(key_str)
except:
key = key_str.encode('utf-8')
# try:
# key = base64.b64decode(key_str)
# except:
key = key_str.encode('utf-8')
# 处理IV尝试Base64解码否则使用字符串编码
try:
iv = base64.b64decode(iv_str)
except:
iv = iv_str.encode('utf-8')
# try:
# iv = base64.b64decode(iv_str)
# except:
iv = iv_str.encode('utf-8')
# 确保密钥长度为16、24或32字节
if len(key) not in [16, 24, 32]:
if len(key) < 16:
key = key.ljust(16, b'\0')
elif len(key) < 24:
key = key.ljust(24, b'\0')
elif len(key) < 32:
key = key.ljust(32, b'\0')
else:
key = key[:32]
# if len(key) not in [16, 24, 32]:
# if len(key) < 16:
# key = key.ljust(16, b'\0')
# elif len(key) < 24:
# key = key.ljust(24, b'\0')
# elif len(key) < 32:
# key = key.ljust(32, b'\0')
# else:
# key = key[:32]
# 确保IV长度为16字节
if len(iv) != 16:
if len(iv) < 16:
iv = iv.ljust(16, b'\0')
else:
iv = iv[:16]
# if len(iv) != 16:
# if len(iv) < 16:
# iv = iv.ljust(16, b'\0')
# else:
# iv = iv[:16]
# 生成输出文件路径
file_dir = os.path.dirname(self.input_file_path)
@@ -140,24 +143,30 @@ class VideoEncryptorGUI:
messagebox.showerror("加密错误", f"加密过程中发生错误: {str(e)}")
def do_encryption(self, input_path, output_path, key, iv):
print(f"iv len: {len(iv)}")
"""
执行加密操作
使用AES/CBC/PKCS5Padding模式与Java代码保持一致
使用AES/CTR/NoPadding模式
"""
# 读取输入文件
with open(input_path, 'rb') as f_in:
plaintext = f_in.read()
# 创建密码器
cipher = AES.new(key, AES.MODE_CBC, iv)
# 应用PKCS5填充并加密
padded_data = pad(plaintext, AES.block_size)
ciphertext = cipher.encrypt(padded_data)
# 写入输出文件
with open(output_path, 'wb') as f_out:
f_out.write(ciphertext)
try:
# 创建CTR模式加密器
cipher = AES.new(key, AES.MODE_CTR, nonce=iv)
with open(input_path, 'rb') as fin:
with open(output_path, 'wb') as fout:
# 将IV写入文件开头解密时需要
fout.write(iv)
# 逐块加密文件
while True:
chunk = fin.read(4096) # 每次读取4KB
if len(chunk) == 0:
break
encrypted_chunk = cipher.encrypt(chunk)
fout.write(encrypted_chunk)
except Exception as e:
raise Exception(f"加密失败: {str(e)}")
def main():
# 检查所需库是否已安装

View File

@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['encryption_video.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='encryption_video',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)