开发自制浏览器的过程中,学习使用了Service服务和Notification通知,下载m3u8格式视频文件,记录分享给小伙伴们。
一、依赖配置
开发用到的Android Sdk版本为32,额外用到的依赖主要是一个解密包 bcprov-jdk16-139.jar,本地引用的,放置于Android项目app目录下新建的libs文件夹中,jar包下载地址传送,补充百度网盘下载,build.gradle(app目录下)中引用方式如下
dependencies {
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation 'org.apache.commons:commons-lang3:3.7'
implementation files('libs\\bcprov-jdk16-139.jar')
}
二、清单文件配置,设置权限,配置Service
需注意,高版本网络访问和文件读写得在application标签属性中配置这俩usesCleartextTraffic="true" 、 requestLegacyExternalStorage="true"
<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" /> <!-- 下载时 安卓6.0以下的直接将读写权限写入AndroidManifest.xml之中 -->
<uses-permission
android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!--通知权限、-->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!--存储权限-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/NoTitle"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<service
android:name=".service.DownloadService"
android:enabled="true"
android:exported="true"></service>
<activity
android:name=".activity.MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity
android:name=".activity.BrowserActivity"
android:configChanges="orientation|screenSize"
android:exported="false"
android:hardwareAccelerated="true"
android:launchMode="singleTop">
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
</application>
</manifest>
三、导航栏通知,用到的视图相关xml文件
progress_bar.xml,一般放置于drawable目录下
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" >
<!-- 进度条背景色 -->
<item android:id="@android:id/background">
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#D8D8D8"
android:centerColor="#D8D8D8"
android:centerY="0.75"
android:endColor="#D8D8D8"
android:angle="270"
/>
</shape>
</item>
<!-- 第二进度条 -->
<item android:id="@android:id/secondaryProgress">
<clip>
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#b9a4ff"
android:centerColor="#c6b7ff"
android:centerY="0.75"
android:endColor="#c3b2ff"
android:angle="270"
/>
</shape>
</clip>
</item>
<!-- 进度条 -->
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="5dip" />
<gradient
android:startColor="#AEAEAE"
android:centerColor="#AEAEAE"
android:centerY="0.75"
android:endColor="#AEAEAE"
android:angle="270"
/>
</shape>
</clip>
</item>
</layer-list>
notification_download.xml,一般放置于layout目录下
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:background="@color/colorLightGray1">
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<ImageView
android:id="@+id/ivLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@color/colorLightGray1"
android:layout_margin="10dip" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_weight="1"
android:layout_marginRight="14dip">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/gray2"
android:text="自制浏览器下载任务"
android:textSize="14dip"
android:textStyle="bold"/>
<ProgressBar
android:id="@+id/pbDownload"
android:layout_width="fill_parent"
android:layout_height="6dip"
android:layout_marginTop="6dip"
android:layout_marginBottom="3dip"
android:progress="0"
android:max="100"
android:progressDrawable="@drawable/progress_bar"
style="?android:attr/progressBarStyleHorizontal" />
<TextView
android:id="@+id/tvProcess"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/gray2"
android:text="已下载0%"
android:textSize="12dip"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
四、Service服务、Notification消息通知的使用
package cn.cheng.simpleBrower.service;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.service.notification.StatusBarNotification;
import android.widget.RemoteViews;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import java.io.File;
import java.util.regex.Matcher;
import cn.cheng.simpleBrower.R;
import cn.cheng.simpleBrower.activity.BrowserActivity;
import cn.cheng.simpleBrower.activity.MainActivity;
import cn.cheng.simpleBrower.custom.M3u8DownLoader;
/**
* 下载Service (目前仅用于下载m3u8)
*/
public class DownloadService extends Service {
// 通知管理器
private NotificationManager nm;
// 消息通知
private Notification notification;
// 线程处理工具
private MyHandler myHandler;
// 下载进度
private int download_precent = 0;
// 通知提示视图
private RemoteViews views;
// 通知id 每次下载通知要不一样
private int notificationId = 1234;
// 频道id 每次下载通知要不一样
private String CHANNEL_ID = "一口一个大胖子,喜喜";
public DownloadService() {
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onStart(Intent intent, int startId) {
super.onStart(intent, startId);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
String title = intent.getStringExtra("title");
if (title != null && !"".equals(title)) {
CHANNEL_ID = title;
}
// 高版本通知Notification 必须先定义NotificationChannel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(CHANNEL_ID
, "name", NotificationManager.IMPORTANCE_DEFAULT);
if (nm.getActiveNotifications().length > 0) {
StatusBarNotification activeNotification = nm.getActiveNotifications()[nm.getActiveNotifications().length-1];
if (activeNotification.getNotification() != null) {
String channelId = activeNotification.getNotification().getChannelId();
// System.out.println("5555555555555555555555555" + channelId);
// System.out.println("5555555555555555555555555" + CHANNEL_ID);
if (CHANNEL_ID.equals(channelId)) {
return super.onStartCommand(intent, flags, startId);
} else {
notificationId ++;
}
// System.out.println("**************************" + notificationId);
}
}
nm.createNotificationChannel(channel);
}
// 创建一个Notification对象
NotificationCompat.Builder nBuilder = new NotificationCompat.Builder(this, CHANNEL_ID);
// 设置打开该通知,该通知自动消失
nBuilder.setAutoCancel(false);
// 设置通知的图标
nBuilder.setSmallIcon(R.drawable.btn_like);
// 设置通知内容的标题
// nBuilder.setContentTitle("下载中");
// 设置通知内容
// nBuilder.setContentText("打开APP查看详情!");
// 设置使用系统默认的声音、默认震动
nBuilder.setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_VIBRATE);
// 设置发送时间
nBuilder.setWhen(System.currentTimeMillis());
// 创建一个启动其他Activity的Intent
Intent i = new Intent(this, BrowserActivity.class);
String that = intent.getStringExtra("this");
if ("MainActivity".equals(that)) {
i = new Intent(this, MainActivity.class);
}
PendingIntent pendingIntent;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
pendingIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE);
} else {
pendingIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_ONE_SHOT);
}
// 设置通知栏点击跳转
nBuilder.setContentIntent(pendingIntent);
// 创建通知
notification = nBuilder.build();
//设置任务栏中下载进程显示的views
views = new RemoteViews(getPackageName(), R.layout.notification_download);
notification.contentView = views;
//将下载任务添加到任务栏中
nm.notify(notificationId, notification);
// 线程消息传递处理
myHandler = new MyHandler(Looper.myLooper(), this);
//初始化下载任务内容views
String[] arr = new String[]{"0", notificationId+""};
Message message = myHandler.obtainMessage(3, arr);
myHandler.sendMessage(message);
//启动线程开始执行下载任务
if (Build.VERSION.SDK_INT >= 29) { // android 12的sd卡读写
//启动线程开始执行下载任务
String url = intent.getStringExtra("url");
String dirName = intent.getStringExtra("dirName");
if (dirName == null || "".equals(dirName)) {
dirName = System.currentTimeMillis() + "";
}
if (title == null || "".equals(title)) {
title = "test";
}
// M3u8DownLoader.test(url, myHandler);
M3u8DownLoader m3u8Download = new M3u8DownLoader(url, notificationId);
//设置生成目录
String dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath();
m3u8Download.setDir(dir + "/SimpleBrower/" + dirName);
//设置视频名称
m3u8Download.setFileName(title);
//设置线程数
m3u8Download.setThreadCount(100);
//设置重试次数
m3u8Download.setRetryCount(8);
//设置连接超时时间(单位:毫秒)
m3u8Download.setTimeoutMillisecond(10000L);
m3u8Download.start(myHandler);
}
return super.onStartCommand(intent, flags, startId);
}
// 线程消息传递处理
private class MyHandler extends Handler {
private Context context;
public MyHandler(Looper looper, Context context) {
super(looper);
this.context = context;
}
@Override
public void handleMessage(@NonNull Message msg) {
super.handleMessage(msg);
if (!(msg.obj instanceof String[])) {
return;
}
String[] arr = (String[]) msg.obj;
if (arr.length == 2) {
switch (msg.what) {
case 0:
Toast.makeText(context, arr[0], Toast.LENGTH_SHORT).show();
case 1:
break;
case 2:
//下载完成后清除所有下载信息,执行安装提示
download_precent = 0;
nm.cancel(Integer.parseInt(arr[1]));
Toast.makeText(context, arr[0], Toast.LENGTH_SHORT).show();
//停止掉当前的服务
stopSelf();
break;
case 3:
String str = arr[0];
if (str.matches("^(([1-9]\\d*)(\\.\\d+)?)$|^((0)(\\.\\d+)?)$")) { // 判断数字
download_precent = (int) Float.parseFloat(str);
str = "已下载" + str + "%";
}
//更新状态栏上的下载进度信息
views.setTextViewText(R.id.tvProcess, str);
views.setProgressBar(R.id.pbDownload, 100, download_precent, false);
notification.contentView = views;
nm.notify(Integer.parseInt(arr[1]), notification);
break;
case 4:
nm.cancel(Integer.parseInt(arr[1]));
break;
}
}
}
}
}
五、重头戏当然是m3u8格式的文件下载
其中ts解密主要针对AES,其他加密方式可按相应情况进行解密扩展;对于png等图片式m3u8文件,也就是伪ts文件的处理,查看其他文章学习到,可以观察其文件流对应16进制字符串,发现伪ts文件虽然有一些干扰的文件头,但实际的ts文件也隐藏其中,开始的字节片段和正常的ts文件有相同之处,转成16进制,其标志为FF4740,这样就能计算出伪ts文件实际有效字节开始之处,通过RandomAccessFile,seek设置这个字节数位置即可。
package cn.cheng.simpleBrower.custom;
import android.os.Environment;
import android.os.Handler;
import android.os.Message;
import org.apache.commons.lang3.StringUtils;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.security.NoSuchAlgorithmException;
import java.security.Security;
import java.security.spec.AlgorithmParameterSpec;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* m3u8格式视频下载器
*/
public class M3u8DownLoader {
/**
*
* 解决java不支持AES/CBC/PKCS7Padding模式解密
*
*/
static {
Security.addProvider(new BouncyCastleProvider());
}
//要下载的m3u8链接
private final String DOWNLOADURL;
// 通知id
private final int id;
// 获取伪png这种ts文件实际字节开始下标
static int num = 0;
//线程数
private int threadCount = 1;
//重试次数
private int retryCount = 30;
//链接连接超时时间(单位:毫秒)
private long timeoutMillisecond = 1000L;
//合并后的文件存储目录
private String dir;
//合并后的视频文件名称
private String fileName;
//已完成ts片段个数
private int finishedCount = 0;
//解密算法名称
private String method;
//密钥
private String key = "";
//所有ts片段下载链接
private Set<String> tsSet = new LinkedHashSet<>();
//解密后的片段
private Set<File> finishedFiles = new ConcurrentSkipListSet<>(Comparator.comparingInt(o -> Integer.parseInt(o.getName().replace(".xyz", ""))));
//已经下载的文件大小
private BigDecimal downloadBytes = new BigDecimal(0);
public M3u8DownLoader(String m3U8URL, int notificationId) {
DOWNLOADURL = m3U8URL;
id = notificationId;
}
public void setThreadCount(int threadCount) {
this.threadCount = threadCount;
}
public void setRetryCount(int retryCount) {
this.retryCount = retryCount;
}
public void setTimeoutMillisecond(long timeoutMillisecond) {
this.timeoutMillisecond = timeoutMillisecond;
}
public void setDir(String dir) {
this.dir = dir;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
/**
* 模拟http请求获取内容
*
* @param urls http链接
* @return 内容
*/
private StringBuilder getUrlContent(String urls) throws M3u8Exception {
int count = 1;
HttpURLConnection httpURLConnection = null;
StringBuilder content = new StringBuilder();
while (count <= retryCount) {
try {
URL url = new URL(urls);
httpURLConnection = (HttpURLConnection) url.openConnection();
httpURLConnection.setConnectTimeout((int) timeoutMillisecond);
httpURLConnection.setReadTimeout((int) timeoutMillisecond);
httpURLConnection.setUseCaches(false);
httpURLConnection.setDoInput(true);
// 模拟电脑请求
httpURLConnection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36");
// 跨域设置相关
httpURLConnection.setRequestProperty("Access-Control-Allow-Origin", "*");
//* 代办允许所有方法
httpURLConnection.setRequestProperty("Access-Control-Allow-Methods", "*");
// Access-Control-Max-Age 用于 CORS 相关配置的缓存
httpURLConnection.setRequestProperty("Access-Control-Max-Age", "3600");
// 提示OPTIONS预检时,后端需要设置的两个常用自定义头
httpURLConnection.setRequestProperty("Access-Control-Allow-Headers", "*");
// 允许前端带认证cookie:启用此项后,上面的域名不能为'*',必须指定具体的域名,否则浏览器会提示
httpURLConnection.setRequestProperty("Access-Control-Allow-Credentials", "true");
String line;
InputStream inputStream = httpURLConnection.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
while ((line = bufferedReader.readLine()) != null)
content.append(line).append("\n");
bufferedReader.close();
inputStream.close();
// System.out.println(content);
break;
} catch (Exception e) {
System.out.println("第" + count + "获取链接重试!\t" + urls);
count++;
e.printStackTrace();
} finally {
if (httpURLConnection != null) {
httpURLConnection.disconnect();
}
}
}
if (count > retryCount)
throw new M3u8Exception("连接超时!");
return content;
}
/**
* 获取所有的ts片段下载链接
* @return 链接是否被加密,null为非加密
*/
private String getTsUrl() throws M3u8Exception, URISyntaxException {
StringBuilder content = getUrlContent(DOWNLOADURL);
//判断是否是m3u8链接
if (!content.toString().contains("#EXTM3U"))
throw new M3u8Exception(DOWNLOADURL + "不是m3u8链接!");
String[] split = content.toString().split("\\n");
String keyUrl = "";
boolean isKey = false;
for (String s : split) {
//如果含有此字段,则说明只有一层m3u8链接
if (s.contains("#EXT-X-KEY") || s.contains("#EXTINF")) {
isKey = true;
keyUrl = DOWNLOADURL;
break;
}
//如果含有此字段,则说明ts片段链接需要从第二个m3u8链接获取
if (s.contains(".m3u8")) {
if (s.startsWith("http://") || s.startsWith("https://")) {
keyUrl = s;
} else {
String relativeUrl = DOWNLOADURL.substring(0, DOWNLOADURL.lastIndexOf("/") + 1);
String relativeUrl2 = DOWNLOADURL.split("//")[0] + "//" +new URI(DOWNLOADURL).getHost();
if (s.startsWith("/")) {
keyUrl = relativeUrl2 + s;
} else {
keyUrl = relativeUrl + s;
}
}
break;
}
}
if (StringUtils.isEmpty(keyUrl))
throw new M3u8Exception("未发现有效链接!");
//获取密钥
String key1 = isKey ? getKey(keyUrl, content) : getKey(keyUrl, null);
if (StringUtils.isNotEmpty(key1))
key = key1;
else key = null;
return key;
}
/**
* 获取ts解密的密钥,并把ts片段加入set集合
*
* @param url 密钥链接,如果无密钥的m3u8,则此字段可为空
* @param content 内容,如果有密钥,则此字段可以为空
* @return ts是否需要解密,null为不解密
*/
private String getKey(String url, StringBuilder content) throws M3u8Exception, URISyntaxException {
StringBuilder urlContent;
if (content == null || StringUtils.isEmpty(content.toString()))
urlContent = getUrlContent(url);
else urlContent = content;
if (!urlContent.toString().contains("#EXTM3U"))
throw new M3u8Exception(DOWNLOADURL + "不是m3u8链接!");
String[] split = urlContent.toString().split("\\n");
for (String s : split) {
//如果含有此字段,则获取加密算法以及获取密钥的链接
if (s.contains("EXT-X-KEY")) {
String[] split1 = s.split(",", 2);
// System.out.println("EXT-X-KEY---length" + split1.length);
if (split1.length >= 2) {
if (split1[0].contains("METHOD"))
method = split1[0].split("=", 2)[1];
if (split1[1].contains("URI"))
key = split1[1].split("=", 2)[1];
}
}
}
String relativeUrl = url.substring(0, url.lastIndexOf("/") + 1);
String relativeUrl2 = DOWNLOADURL.split("//")[0] + "//" +new URI(DOWNLOADURL).getHost();
//将ts片段链接加入set集合
for (int i = 0; i < split.length; i++) {
String s = split[i];
if (s.contains("#EXTINF")) {
String ts = split[++i];
if (ts.startsWith("http://") || ts.startsWith("https://")) {
tsSet.add(ts);
} else {
if (ts.startsWith("/")) {
tsSet.add(relativeUrl2 + ts);
} else {
tsSet.add(relativeUrl + ts);
}
}
}
}
if (!StringUtils.isEmpty(key)) {
key = key.replace("\"", "");
if (key.startsWith("/")) {
return getUrlContent(relativeUrl2 + key).toString().replaceAll("\\s+", "");
} else {
return getUrlContent(relativeUrl + key).toString().replaceAll("\\s+", "");
}
}
return null;
}
/**
* 解密ts
*
* @param sSrc ts文件字节数组
* @param sKey 密钥
* @return 解密后的字节数组
*/
private byte[] decrypt(byte[] sSrc, String sKey, String method) throws Exception {
if (StringUtils.isNotEmpty(method) && !method.contains("AES"))
throw new M3u8Exception("未知的算法!");
// 判断Key是否正确
if (StringUtils.isEmpty(sKey)) {
return sSrc;
}
// 判断Key是否为16位
if (sKey.length() != 16) {
System.out.print("Key长度不是16位");
return null;
}
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
SecretKeySpec keySpec = new SecretKeySpec(sKey.getBytes("utf-8"), "AES");
//如果m3u8有IV标签,那么IvParameterSpec构造函数就把IV标签后的内容转成字节数组传进去
AlgorithmParameterSpec paramSpec = new IvParameterSpec(new byte[16]);
cipher.init(Cipher.DECRYPT_MODE, keySpec, paramSpec);
return cipher.doFinal(sSrc);
}
/**
* 开启下载线程
*
* @param urls ts片段链接
* @param i ts片段序号
* @return 线程
*/
private Thread getThread(String urls, int i) {
return new Thread(() -> {
int count = 1;
HttpURLConnection httpURLConnection = null;
//xy为未解密的ts片段,如果存在,则删除
File file2 = new File(dir + "/" + i + ".xy");
if (file2.exists())
file2.delete();
OutputStream outputStream = null;
InputStream inputStream1 = null;
FileOutputStream outputStream1 = null;
//重试次数判断
while (count <= retryCount) {
try {
//模拟http请求获取ts片段文件
URL url = new URL(urls);
httpURLConnection = (HttpURLConnection) url.openConnection();
httpURLConnection.setConnectTimeout((int) timeoutMillisecond);
httpURLConnection.setUseCaches(false);
httpURLConnection.setReadTimeout((int) timeoutMillisecond);
httpURLConnection.setDoInput(true);
InputStream inputStream = httpURLConnection.getInputStream();
try {
outputStream = new FileOutputStream(file2);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
int len;
byte[] bytes = new byte[1024 * 4];
//将未解密的ts片段写入文件
while ((len = inputStream.read(bytes)) != -1) {
outputStream.write(bytes, 0, len);
synchronized (this) {
downloadBytes = downloadBytes.add(new BigDecimal(len));
}
}
outputStream.flush();
inputStream.close();
inputStream1 = new FileInputStream(file2);
byte[] bytes1 = new byte[inputStream1.available()];
inputStream1.read(bytes1);
File file = new File(dir + "/" + i + ".xyz");
outputStream1 = new FileOutputStream(file);
//开始解密ts片段
outputStream1.write(decrypt(bytes1, key, method));
finishedFiles.add(file);
file2.delete();
break;
} catch (Exception e) {
System.out.println("第" + count + "获取链接重试!\t" + urls);
count++;
e.printStackTrace();
} finally {
try {
if (inputStream1 != null)
inputStream1.close();
if (outputStream1 != null)
outputStream1.close();
if (outputStream != null)
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
if (httpURLConnection != null) {
httpURLConnection.disconnect();
}
}
}
if (count > retryCount) {
//自定义异常
System.out.println("----------连接超时!-------");
return;
}
finishedCount++;
System.out.println(urls + "下载完毕!\t已完成" + finishedCount + "个,还剩" + (tsSet.size() - finishedCount) + "个!");
});
}
/**
* 合并下载好的ts片段
*/
private void mergeTs(BufferedOutputStream bos, BufferedInputStream bis, RandomAccessFile raFile) throws Exception {
File file = new File(dir + "/" + fileName + ".mp4");
if (file.exists())
file.delete();
else file.createNewFile();
bos = new BufferedOutputStream(new FileOutputStream(file));
byte[] b = new byte[4096];
for (File f : finishedFiles) {
int len;
if (!tsSet.iterator().next().endsWith(".ts")) {
// 破解伪装的非ts文件 向后读取获取真正的ts视频文件
raFile = new RandomAccessFile(f, "rw");
raFile.seek(getTsNum(f));
while ((len = raFile.read(b)) != -1) {
bos.write(b, 0, len);
}
raFile.close();
} else {
bis = new BufferedInputStream(new FileInputStream(f));
while ((len = bis.read(b)) > 0) {
bos.write(b, 0, len);
}
bis.close();
}
bos.flush();
f.delete();
}
bos.close();
}
/**
* 获取伪png这种ts文件实际字节开始下标
*/
private int getTsNum(File file) {
if (num > 0) {
return num;
}
int value = 0;
List<String> sbHexList = new ArrayList<>();
InputStream is = null;
try {
is = new FileInputStream(file);
while ((value = is.read()) != -1) {
// 转换16进制字符
sbHexList.add(String.format("%02X ", value));
if (num >= 2) {
String flag = sbHexList.get(num-2) + sbHexList.get(num-1) + sbHexList.get(num);
flag = flag.toUpperCase();
if (flag.equals("FF 47 40 ")) { // ts文件16进制关键标志
System.out.println((num + 1) + "----------获取伪png这种ts文件实际字节开始下标--------" + flag);
return num + 1;
}
}
num++;
// 兜底 防止一直循环
if (num >= 2000) {
break;
}
}
is.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (is != null) {
is.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return 0;
}
/**
* 下载视频
*/
private void startDownload(Handler handler) {
// 线程池
final ExecutorService fixedThreadPool = Executors.newFixedThreadPool(threadCount);
int i = 0;
// 如果生成目录不存在,则创建
File file1 = new File(dir);
// System.out.println("生成目录==========" + file1.getAbsolutePath());
if (!file1.exists())
file1.mkdirs();
// 执行多线程下载
for (String s : tsSet) {
i++;
fixedThreadPool.execute(getThread(s, i));
}
// 关闭线程池
fixedThreadPool.shutdown();
// 下载过程监视
new Thread(() -> {
BufferedOutputStream bos = null;
BufferedInputStream bis = null;
RandomAccessFile raFile = null;
try {
int consume = 0;
//轮询是否下载成功
while (!fixedThreadPool.isTerminated()) {
try {
consume++;
BigDecimal bigDecimal = new BigDecimal(downloadBytes.toString());
Thread.sleep(1000L);
if (tsSet.size() != 0) {
String[] arr = new String[]{new BigDecimal(finishedCount).divide(new BigDecimal(tsSet.size()), 4, BigDecimal.ROUND_HALF_UP).multiply(new BigDecimal(100)).setScale(2, BigDecimal.ROUND_HALF_UP) + "", id+""};
Message msg = handler.obtainMessage(3, arr);
handler.sendMessage(msg);
}
// System.out.print("已用时" + consume + "秒!\t下载速度:" /*+ StringUtils.convertToDownloadSpeed(new BigDecimal(downloadBytes.toString()).subtract(bigDecimal), 3) + "/s"*/);
// System.out.print("\t已完成" + finishedCount + "个,还剩" + (tsSet.size() - finishedCount) + "个!");
// System.out.println(new BigDecimal(finishedCount).divide(new BigDecimal(tsSet.size()), 4, BigDecimal.ROUND_HALF_UP).multiply(new BigDecimal(100)).setScale(2, BigDecimal.ROUND_HALF_UP) + "%");
} catch (InterruptedException e) {
e.printStackTrace();
String[] arr = new String[]{e.getMessage(), id+""};
Message msg= handler.obtainMessage(4, arr);
handler.sendMessage(msg);
}
}
String str = finishedFiles.size() + "个ts文件";
if (tsSet.size() == finishedFiles.size()) {
str = "下载完成,正在合并文件!共" + str;
} else {
str = "部分下载完成,正在合并文件!共" + str + ",实际" + tsSet.size() + "个ts文件";
}
String[] arr = new String[]{str, id+""};
Message msg0 = handler.obtainMessage(3, arr);
handler.sendMessage(msg0);
System.out.println(str /*+ StringUtils.convertToDownloadSpeed(downloadBytes, 3)*/);
// 开始合并视频
mergeTs(bos, bis, raFile);
// 下载成功提示
String[] arr2 = new String[]{"下载文件成功", id+""};
Message msg = handler.obtainMessage(2, arr2);
handler.sendMessage(msg);
System.out.println("视频合并完成,欢迎使用!");
} catch (Exception e) {
e.printStackTrace();
String[] arr = new String[]{e.getMessage(), id+""};
Message msg= handler.obtainMessage(4, arr);
handler.sendMessage(msg);
} finally {
try {
if (bis != null) {
bis.close();
}
if (raFile != null) {
raFile.close();
}
if (bos != null) {
bos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
/**
* 开始下载视频
*/
public void start(Handler handler) {
// checkField();
new Thread(()->{
try {
System.out.println(DOWNLOADURL + " =============");
String tsUrl = getTsUrl();
System.out.println("----" + tsUrl);
if(StringUtils.isEmpty(tsUrl)) {
System.out.println("不需要解密");
}
startDownload(handler);
} catch (Exception e) {
e.printStackTrace();
String[] arr = new String[]{e.getMessage(), id+""};
Message msg= handler.obtainMessage(4, arr);
handler.sendMessage(msg);
}
}).start();
}
public static void test(String str, Handler handler) {
new Thread(()-> {
long thatTime = System.currentTimeMillis();
while (System.currentTimeMillis() -1000*15 < thatTime) {
System.out.println("======" + System.currentTimeMillis());
}
String[] arr = new String[]{"下载文件成功", 1+""};
Message msg= handler.obtainMessage(2, arr);
handler.sendMessage(msg);
System.out.println("==================== " + str);
}).start();
}
class M3u8Exception extends Exception{
public M3u8Exception(String message) {
super(message);
}
}
}
六、在Activity中调用
// 获取SD卡的读取权限(Android6.0以上需获取相应权限)
if (ContextCompat.checkSelfPermission(BrowserActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
// 没有写入外部存储权限 时 申请该权限
ActivityCompat.requestPermissions(BrowserActivity.this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);
}
Intent intent = new Intent(BrowserActivity.this, DownloadService.class);
intent.putExtra("url", url);
if (title.length() > 20) {
title.substring(0, 20);
}
intent.putExtra("title", title.length() > 20 ? title.substring(0, 20) + "..." : title);
BrowserActivity.this.startService(intent);
feetDialog.dismiss();
参考说明
感谢大佬们的文章!