diff --git a/Aria/src/main/java/com/arialyy/aria/core/common/controller/FeatureController.java b/Aria/src/main/java/com/arialyy/aria/core/common/controller/FeatureController.java
index 92322853..e647d57e 100644
--- a/Aria/src/main/java/com/arialyy/aria/core/common/controller/FeatureController.java
+++ b/Aria/src/main/java/com/arialyy/aria/core/common/controller/FeatureController.java
@@ -17,8 +17,10 @@ package com.arialyy.aria.core.common.controller;
import android.Manifest;
import android.content.pm.PackageManager;
+import android.os.Build;
import android.os.Handler;
import android.os.Looper;
+
import com.arialyy.aria.core.AriaConfig;
import com.arialyy.aria.core.common.AbsEntity;
import com.arialyy.aria.core.download.CheckDEntityUtil;
@@ -36,6 +38,7 @@ import com.arialyy.aria.core.wrapper.AbsTaskWrapper;
import com.arialyy.aria.core.wrapper.ITaskWrapper;
import com.arialyy.aria.util.ALog;
import com.arialyy.aria.util.CommonUtil;
+
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
@@ -160,37 +163,79 @@ public abstract class FeatureController {
/**
* 检查权限,需要的权限有
- * {@link Manifest.permission#WRITE_EXTERNAL_STORAGE}
- * {@link Manifest.permission#READ_EXTERNAL_STORAGE}
* {@link Manifest.permission#INTERNET}
+ * 存储权限会根据 Android 版本与下载模式(分区存储 Uri / 普通文件路径)进行适配:
+ *
+ * - 分区存储 Uri 模式(MediaStore / SAF):跨 Android 版本均无需申请存储权限
+ * - Android 9 及以下:{@link Manifest.permission#WRITE_EXTERNAL_STORAGE} +
+ * {@link Manifest.permission#READ_EXTERNAL_STORAGE}
+ * - Android 10(Q):同上,仍可请求 legacy 存储
+ * - Android 11/12(R/S):{@link Manifest.permission#WRITE_EXTERNAL_STORAGE} 已废弃,
+ * 仅需 {@link Manifest.permission#READ_EXTERNAL_STORAGE}
+ * - Android 13+(TIRAMISU):READ/WRITE_EXTERNAL_STORAGE 不再强制,使用细分媒体权限
+ * {@link Manifest.permission#READ_MEDIA_IMAGES} / {@link Manifest.permission#READ_MEDIA_VIDEO} /
+ * {@link Manifest.permission#READ_MEDIA_AUDIO},或兼容旧 target 的 READ_EXTERNAL_STORAGE
+ *
*
* @return {@code false} 缺少权限
*/
private boolean checkPermission() {
-
- if (AriaConfig.getInstance()
- .getAPP()
- .checkCallingOrSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
- != PackageManager.PERMISSION_GRANTED) {
- ALog.e(TAG, "启动失败,缺少权限:Manifest.permission.WRITE_EXTERNAL_STORAGE");
- return false;
- }
- if (AriaConfig.getInstance()
- .getAPP()
- .checkCallingOrSelfPermission(Manifest.permission.INTERNET)
- != PackageManager.PERMISSION_GRANTED) {
+ if (!hasPermission(Manifest.permission.INTERNET)) {
ALog.e(TAG, "启动失败,缺少权限:Manifest.permission.INTERNET");
return false;
}
- if (AriaConfig.getInstance()
- .getAPP()
- .checkCallingOrSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
- != PackageManager.PERMISSION_GRANTED) {
- ALog.e(TAG, "启动失败,缺少权限:Manifest.permission.READ_EXTERNAL_STORAGE");
- return false;
+
+ // 分区存储 Uri 模式(MediaStore / SAF)写入 Uri,跨 Android 版本均无需存储权限
+ if (isUriTask()) {
+ return true;
}
- return true;
+ int sdk = Build.VERSION.SDK_INT;
+ if (sdk >= Build.VERSION_CODES.TIRAMISU) {
+ // Android 13+:READ/WRITE_EXTERNAL_STORAGE 已不再强制,使用细分媒体权限;
+ // 同时声明 READ_EXTERNAL_STORAGE 在 13+ 仍会被授予(兼容旧 target),二者满足其一即可
+ if (!hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
+ && !hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
+ && !hasPermission(Manifest.permission.READ_MEDIA_VIDEO)
+ && !hasPermission(Manifest.permission.READ_MEDIA_AUDIO)) {
+ ALog.e(TAG, "启动失败,缺少权限:Android 13+ 需要 Manifest.permission.READ_MEDIA_* "
+ + "或 Manifest.permission.READ_EXTERNAL_STORAGE");
+ return false;
+ }
+ return true;
+ } else if (sdk >= Build.VERSION_CODES.R) {
+ // Android 11/12:WRITE_EXTERNAL_STORAGE 已废弃且不再授予共享存储写权限,读仍可通过
+ // READ_EXTERNAL_STORAGE 兼容获取
+ if (!hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) {
+ ALog.e(TAG, "启动失败,缺少权限:Manifest.permission.READ_EXTERNAL_STORAGE");
+ return false;
+ }
+ return true;
+ } else {
+ // Android 10 及以下:需要读写外部存储权限
+ if (!hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ ALog.e(TAG, "启动失败,缺少权限:Manifest.permission.WRITE_EXTERNAL_STORAGE");
+ return false;
+ }
+ if (!hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) {
+ ALog.e(TAG, "启动失败,缺少权限:Manifest.permission.READ_EXTERNAL_STORAGE");
+ return false;
+ }
+ return true;
+ }
+ }
+
+ /**
+ * 是否为分区存储 Uri 任务,Uri 模式下通过 MediaStore / SAF 写入,无需存储权限
+ */
+ private boolean isUriTask() {
+ return mTaskWrapper instanceof DTaskWrapper && ((DTaskWrapper) mTaskWrapper).isUriMode();
+ }
+
+ private boolean hasPermission(String permission) {
+ return AriaConfig.getInstance()
+ .getAPP()
+ .checkCallingOrSelfPermission(permission) == PackageManager.PERMISSION_GRANTED;
}
private boolean checkEntity() {
diff --git a/Aria/src/main/java/com/arialyy/aria/core/download/CheckDEntityUtil.java b/Aria/src/main/java/com/arialyy/aria/core/download/CheckDEntityUtil.java
index e5cd7bdb..10bb5cb5 100644
--- a/Aria/src/main/java/com/arialyy/aria/core/download/CheckDEntityUtil.java
+++ b/Aria/src/main/java/com/arialyy/aria/core/download/CheckDEntityUtil.java
@@ -15,7 +15,11 @@
*/
package com.arialyy.aria.core.download;
+import android.content.Context;
+import android.net.Uri;
import android.text.TextUtils;
+
+import com.arialyy.aria.core.AriaConfig;
import com.arialyy.aria.core.common.controller.FeatureController;
import com.arialyy.aria.core.inf.ICheckEntityUtil;
import com.arialyy.aria.core.inf.IOptionConstant;
@@ -27,6 +31,8 @@ import com.arialyy.aria.util.CheckUtil;
import com.arialyy.aria.util.CommonUtil;
import com.arialyy.aria.util.FileUtil;
import com.arialyy.aria.util.RecordUtil;
+import com.arialyy.aria.util.UriFileUtil;
+
import java.io.File;
/**
@@ -106,6 +112,10 @@ public class CheckDEntityUtil implements ICheckEntityUtil {
}
private boolean checkFilePath() {
+ if (mWrapper instanceof DTaskWrapper
+ && !TextUtils.isEmpty(((DTaskWrapper) mWrapper).getFileUri())) {
+ return checkUriPath();
+ }
String filePath = mWrapper.getTempFilePath();
if (TextUtils.isEmpty(filePath)) {
ALog.e(TAG, "下载失败,文件保存路径为null");
@@ -139,6 +149,51 @@ public class CheckDEntityUtil implements ICheckEntityUtil {
return checkPathConflicts(filePath);
}
+ /**
+ * 校验通过 Uri 下载的路径合法性
+ */
+ private boolean checkUriPath() {
+ String uriStr = ((DTaskWrapper) mWrapper).getFileUri();
+ Uri uri = Uri.parse(uriStr);
+ Context context = AriaConfig.getInstance().getAPP();
+ int requestType = mWrapper.getRequestType();
+ if (requestType != ITaskWrapper.D_HTTP && requestType != ITaskWrapper.D_FTP) {
+ ALog.e(TAG, "当前任务类型不支持传入 Uri 下载,请使用 setFilePath 指定文件路径");
+ return false;
+ }
+ if (!UriFileUtil.canWrite(context, uri)) {
+ ALog.e(TAG, String.format("Uri【%s】不可写,请确认已通过 SAF 授予写入权限", uriStr));
+ return false;
+ }
+ UriFileUtil.tryTakePersistablePermission(context, uri);
+ if (TextUtils.isEmpty(mEntity.getFileName())) {
+ mEntity.setFileName(UriFileUtil.getFileName(context, uri));
+ }
+ mEntity.setFileUri(uriStr);
+ return checkUriConflicts(uriStr);
+ }
+
+ /**
+ * 检查 Uri 冲突
+ */
+ private boolean checkUriConflicts(String uriStr) {
+ DownloadEntity de = DbEntity.findFirst(DownloadEntity.class, "fileUri=?", uriStr);
+ if (de != null) {
+ if (de.getUrl().equals(mEntity.getUrl())) {
+ mEntity.rowID = de.rowID;
+ mEntity.setFileUri(uriStr);
+ if (!TextUtils.isEmpty(de.getFileName())) {
+ mEntity.setFileName(de.getFileName());
+ }
+ return true;
+ } else if (!mWrapper.isIgnoreFilePathOccupy()) {
+ ALog.e(TAG, String.format("Uri【%s】已被其他任务占用", uriStr));
+ return false;
+ }
+ }
+ return true;
+ }
+
/**
* 检查路径冲突
* @return true 路径没有冲突
diff --git a/Aria/src/main/java/com/arialyy/aria/core/download/target/DNormalConfigHandler.java b/Aria/src/main/java/com/arialyy/aria/core/download/target/DNormalConfigHandler.java
index fc610f31..a756a7fc 100644
--- a/Aria/src/main/java/com/arialyy/aria/core/download/target/DNormalConfigHandler.java
+++ b/Aria/src/main/java/com/arialyy/aria/core/download/target/DNormalConfigHandler.java
@@ -15,7 +15,9 @@
*/
package com.arialyy.aria.core.download.target;
+import android.net.Uri;
import android.text.TextUtils;
+
import com.arialyy.aria.core.common.AbsNormalTarget;
import com.arialyy.aria.core.download.DTaskWrapper;
import com.arialyy.aria.core.download.DownloadEntity;
@@ -60,7 +62,11 @@ class DNormalConfigHandler implements IConfigHandler {
mEntity = mWrapper.getEntity();
mTarget.setTaskWrapper(mWrapper);
if (mEntity != null) {
- getWrapper().setTempFilePath(mEntity.getFilePath());
+ if (!TextUtils.isEmpty(mEntity.getFileUri())) {
+ getWrapper().setFileUri(Uri.parse(mEntity.getFileUri()));
+ } else if (!TextUtils.isEmpty(mEntity.getFilePath())) {
+ getWrapper().setTempFilePath(mEntity.getFilePath());
+ }
}
}
@@ -109,6 +115,14 @@ class DNormalConfigHandler implements IConfigHandler {
getWrapper().setTempFilePath(tempFilePath);
}
+ /**
+ * 设置分区存储场景下的下载目标 Uri(如 SAF 文档、MediaStore 条目)。
+ * 与 {@link #setTempFilePath(String)} 互斥,设置 Uri 后文件路径将被清空。
+ */
+ void setFileUri(Uri uri) {
+ getWrapper().setFileUri(uri);
+ }
+
private DTaskWrapper getWrapper() {
return mWrapper;
}
diff --git a/Aria/src/main/java/com/arialyy/aria/core/download/target/FtpBuilderTarget.java b/Aria/src/main/java/com/arialyy/aria/core/download/target/FtpBuilderTarget.java
index 476c5db1..a2bd38e1 100644
--- a/Aria/src/main/java/com/arialyy/aria/core/download/target/FtpBuilderTarget.java
+++ b/Aria/src/main/java/com/arialyy/aria/core/download/target/FtpBuilderTarget.java
@@ -15,6 +15,8 @@
*/
package com.arialyy.aria.core.download.target;
+import android.net.Uri;
+
import com.arialyy.aria.core.common.AbsBuilderTarget;
import com.arialyy.aria.core.common.FtpOption;
import com.arialyy.aria.core.common.SFtpOption;
@@ -75,6 +77,18 @@ public class FtpBuilderTarget extends AbsBuilderTarget {
return this;
}
+ /**
+ * 设置分区存储(Scoped Storage)场景下的下载目标 Uri。
+ * 与 {@link #setFilePath(String)} 互斥,设置 Uri 后文件路径将被清空。
+ * 注意:当前仅支持 FTP 普通下载任务。
+ *
+ * @param uri 可写的文档 Uri
+ */
+ public FtpBuilderTarget setFileUri(Uri uri) {
+ mConfigHandler.setFileUri(uri);
+ return this;
+ }
+
/**
* 设置文件存储路径,如果需要修改新的文件名,修改路径便可。
* 如:原文件路径 /mnt/sdcard/test.zip
diff --git a/Aria/src/main/java/com/arialyy/aria/core/download/target/HttpBuilderTarget.java b/Aria/src/main/java/com/arialyy/aria/core/download/target/HttpBuilderTarget.java
index 10d07f42..b168df18 100644
--- a/Aria/src/main/java/com/arialyy/aria/core/download/target/HttpBuilderTarget.java
+++ b/Aria/src/main/java/com/arialyy/aria/core/download/target/HttpBuilderTarget.java
@@ -15,6 +15,8 @@
*/
package com.arialyy.aria.core.download.target;
+import android.net.Uri;
+
import com.arialyy.aria.core.common.AbsBuilderTarget;
import com.arialyy.aria.core.common.HttpOption;
import com.arialyy.aria.core.download.DTaskWrapper;
@@ -78,6 +80,24 @@ public class HttpBuilderTarget extends AbsBuilderTarget {
return this;
}
+ /**
+ * 设置分区存储(Scoped Storage)场景下的下载目标 Uri。
+ * 适用于 Android 10+ 无法直接访问外部存储路径的场景,例如:
+ *
+ * - 通过 {@link android.content.Intent#ACTION_CREATE_DOCUMENT} 或
+ * {@link android.content.Intent#ACTION_OPEN_DOCUMENT_TREE} 获取的用户授权 Uri
+ * - 通过 {@link android.provider.MediaStore} 插入后得到的 Uri
+ *
+ * 与 {@link #setFilePath(String)} 互斥,设置 Uri 后文件路径将被清空。
+ * 注意:当前仅支持 HTTP 普通下载任务,不支持 M3U8 任务。
+ *
+ * @param uri 可写的文档 Uri
+ */
+ public HttpBuilderTarget setFileUri(Uri uri) {
+ mConfigHandler.setFileUri(uri);
+ return this;
+ }
+
/**
* 设置文件存储路径,如果需要修改新的文件名,修改路径便可。
* 如:原文件路径 /mnt/sdcard/test.zip
diff --git a/Aria/src/main/java/com/arialyy/aria/core/manager/DTaskWrapperFactory.java b/Aria/src/main/java/com/arialyy/aria/core/manager/DTaskWrapperFactory.java
index 6f08c620..10addf28 100644
--- a/Aria/src/main/java/com/arialyy/aria/core/manager/DTaskWrapperFactory.java
+++ b/Aria/src/main/java/com/arialyy/aria/core/manager/DTaskWrapperFactory.java
@@ -15,12 +15,15 @@
*/
package com.arialyy.aria.core.manager;
+import android.text.TextUtils;
+
import com.arialyy.aria.core.TaskRecord;
import com.arialyy.aria.core.download.DTaskWrapper;
import com.arialyy.aria.core.download.DownloadEntity;
import com.arialyy.aria.core.inf.IEntity;
import com.arialyy.aria.core.loader.IRecordHandler;
import com.arialyy.aria.core.wrapper.ITaskWrapper;
+
import java.io.File;
/**
@@ -68,11 +71,17 @@ class DTaskWrapperFactory implements INormalTEFactory 0
+ && getRangeProgress() + len >= getThreadRecord().endLocation) {
+ len = (int) (getThreadRecord().endLocation - getRangeProgress());
+ file.write(buffer, 0, len);
+ progress(len);
+ break;
+ }
+ file.write(buffer, 0, len);
+ progress(len);
+ }
+ }
}
diff --git a/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDFileInfoTask.java b/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDFileInfoTask.java
index 91423587..0a66bbc1 100644
--- a/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDFileInfoTask.java
+++ b/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDFileInfoTask.java
@@ -19,7 +19,7 @@ import android.net.TrafficStats;
import android.net.Uri;
import android.os.Process;
import android.text.TextUtils;
-import android.util.Log;
+
import com.arialyy.aria.core.AriaConfig;
import com.arialyy.aria.core.common.CompleteInfo;
import com.arialyy.aria.core.common.RequestEnum;
@@ -36,6 +36,7 @@ import com.arialyy.aria.util.CheckUtil;
import com.arialyy.aria.util.CommonUtil;
import com.arialyy.aria.util.FileUtil;
import com.arialyy.aria.util.RecordUtil;
+
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
@@ -146,7 +147,9 @@ final class HttpDFileInfoTask implements IInfoTask, Runnable {
}
long len = lenAdapter.handleFileLen(conn.getHeaderFields());
- if (!FileUtil.checkMemorySpace(mEntity.getFilePath(), len)) {
+ // 分区存储 Uri 任务写入的是 Uri(SAF / MediaStore),不校验本地存储空间
+ if (TextUtils.isEmpty(mEntity.getFileUri())
+ && !FileUtil.checkMemorySpace(mEntity.getFilePath(), len)) {
failDownload(new AriaHTTPException(
String.format("下载失败,内存空间不足;filePath: %s, url: %s", mEntity.getFilePath(),
mEntity.getUrl())), false);
@@ -307,6 +310,12 @@ final class HttpDFileInfoTask implements IInfoTask, Runnable {
ALog.w(TAG, "重命名失败【服务器返回的文件名为空】");
return;
}
+ // 分区存储 Uri 任务写入的是 Uri,不支持重命名本地文件,仅使用服务器文件名作为展示名
+ if (!TextUtils.isEmpty(mEntity.getFileUri())) {
+ ALog.d(TAG, String.format("Uri 任务直接使用服务器文件名:%s", newName));
+ mEntity.setFileName(newName);
+ return;
+ }
ALog.d(TAG, String.format("文件重命名为:%s", newName));
File oldFile = new File(mEntity.getFilePath());
String newPath = oldFile.getParent() + "/" + newName;
diff --git a/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDTTBuilderAdapter.java b/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDTTBuilderAdapter.java
index e4e3186e..c54f939d 100644
--- a/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDTTBuilderAdapter.java
+++ b/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDTTBuilderAdapter.java
@@ -8,6 +8,7 @@ import com.arialyy.aria.core.task.IThreadTaskAdapter;
import com.arialyy.aria.util.ALog;
import com.arialyy.aria.util.BufferedRandomAccessFile;
import com.arialyy.aria.util.FileUtil;
+
import java.io.File;
import java.io.IOException;
@@ -18,6 +19,10 @@ final class HttpDTTBuilderAdapter extends AbsNormalTTBuilderAdapter {
}
@Override public boolean handleNewTask(TaskRecord record, int totalThreadNum) {
+ if (isUriMode()) {
+ // 分区存储(Uri)模式没有本地临时文件,目标内容由ContentProvider管理,无需清理
+ return true;
+ }
if (!record.isBlock) {
if (getTempFile().exists()) {
FileUtil.deleteFile(getTempFile());
diff --git a/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDThreadTaskAdapter.java b/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDThreadTaskAdapter.java
index 676c38c3..cd197750 100644
--- a/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDThreadTaskAdapter.java
+++ b/HttpComponent/src/main/java/com/arialyy/aria/http/download/HttpDThreadTaskAdapter.java
@@ -15,6 +15,7 @@
*/
package com.arialyy.aria.http.download;
+import com.arialyy.aria.core.AriaConfig;
import com.arialyy.aria.core.common.RequestEnum;
import com.arialyy.aria.core.common.SubThreadConfig;
import com.arialyy.aria.core.download.DTaskWrapper;
@@ -23,6 +24,8 @@ import com.arialyy.aria.http.BaseHttpThreadTaskAdapter;
import com.arialyy.aria.http.ConnectionHelp;
import com.arialyy.aria.util.ALog;
import com.arialyy.aria.util.BufferedRandomAccessFile;
+import com.arialyy.aria.util.UriRandomAccessFile;
+
import java.io.BufferedInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
@@ -100,6 +103,29 @@ final class HttpDThreadTaskAdapter extends BaseHttpThreadTaskAdapter {
}
is = new BufferedInputStream(ConnectionHelp.convertInputStream(conn));
+ if (getThreadConfig().tempUri != null) {
+ // 分区存储:通过 Uri 写入,支持断点续传
+ UriRandomAccessFile uriFile = null;
+ try {
+ uriFile = new UriRandomAccessFile(AriaConfig.getInstance().getAPP(),
+ getThreadConfig().tempUri, getThreadRecord().startLocation);
+ readNormal(is, uriFile);
+ handleComplete();
+ return;
+ } catch (IOException e) {
+ fail(new AriaHTTPException(String.format("任务【%s】通过 Uri 下载失败,uri: %s, url: %s",
+ getFileName(), getThreadConfig().tempUri, getThreadConfig().url), e), true);
+ return;
+ } finally {
+ if (uriFile != null) {
+ try {
+ uriFile.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
if (mTaskOption.isChunked()) {
readChunked(is);
} else if (getThreadConfig().isBlock) {
@@ -259,6 +285,31 @@ final class HttpDThreadTaskAdapter extends BaseHttpThreadTaskAdapter {
}
}
+ /**
+ * 分区存储:通过 Uri(FileChannel)写入数据,支持断点续传
+ */
+ private void readNormal(InputStream is, UriRandomAccessFile file) throws IOException {
+ byte[] buffer = new byte[getTaskConfig().getBuffSize()];
+ int len;
+ while (getThreadTask().isLive() && (len = is.read(buffer)) != -1) {
+ if (getThreadTask().isBreak()) {
+ break;
+ }
+ if (mSpeedBandUtil != null) {
+ mSpeedBandUtil.limitNextBytes(len);
+ }
+ if (getThreadRecord().endLocation > 0
+ && getRangeProgress() + len >= getThreadRecord().endLocation) {
+ len = (int) (getThreadRecord().endLocation - getRangeProgress());
+ file.write(buffer, 0, len);
+ progress(len);
+ break;
+ }
+ file.write(buffer, 0, len);
+ progress(len);
+ }
+ }
+
/**
* 处理完成配置文件的更新或事件回调
*/
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/TaskRecord.java b/PublicComponent/src/main/java/com/arialyy/aria/core/TaskRecord.java
index 75d3d92e..184d2ca0 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/TaskRecord.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/TaskRecord.java
@@ -19,7 +19,7 @@ import com.arialyy.aria.core.wrapper.ITaskWrapper;
import com.arialyy.aria.orm.DbEntity;
import com.arialyy.aria.orm.annotation.Ignore;
import com.arialyy.aria.orm.annotation.NoNull;
-import com.arialyy.aria.orm.annotation.Unique;
+
import java.util.List;
/**
@@ -78,6 +78,11 @@ public class TaskRecord extends DbEntity {
*/
public boolean isBlock = false;
+ /**
+ * 是否是通过 Uri(分区存储场景)下载的任务 {@code true} 是
+ */
+ public boolean isUriTask = false;
+
/**
* 任务类型
* {@link ITaskWrapper}
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/common/RecordHandler.java b/PublicComponent/src/main/java/com/arialyy/aria/core/common/RecordHandler.java
index 8e5f0c1e..8c92b1a1 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/common/RecordHandler.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/common/RecordHandler.java
@@ -15,6 +15,8 @@
*/
package com.arialyy.aria.core.common;
+import android.text.TextUtils;
+
import com.arialyy.aria.core.TaskRecord;
import com.arialyy.aria.core.ThreadRecord;
import com.arialyy.aria.core.download.DownloadEntity;
@@ -29,6 +31,7 @@ import com.arialyy.aria.util.ALog;
import com.arialyy.aria.util.CommonUtil;
import com.arialyy.aria.util.DbDataHelper;
import com.arialyy.aria.util.FileUtil;
+
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
@@ -80,6 +83,7 @@ public abstract class RecordHandler implements IRecordHandler {
mConfigFile = new File(CommonUtil.getFileConfigPath(false, mEntity.getFileName()));
if (mConfigFile.exists()) {
convertDb();
+ applyUriMode();
} else {
onPre();
mTaskRecord = DbDataHelper.getTaskRecord(getFilePath(), mEntity.getTaskType());
@@ -91,12 +95,32 @@ public abstract class RecordHandler implements IRecordHandler {
}
initRecord(false);
}
+ // 必须在handlerTaskRecord之前处理Uri任务信息,否则RecordHelper中File操作会因filePath为null而崩溃
+ applyUriMode();
handlerTaskRecord(mTaskRecord);
}
saveRecord();
return mTaskRecord;
}
+ /**
+ * 处理分区存储(Uri)任务的记录信息
+ */
+ private void applyUriMode() {
+ if (mTaskRecord == null) {
+ return;
+ }
+ mTaskRecord.isUriTask = isUriMode();
+ if (mTaskRecord.isUriTask) {
+ // 分区存储 Uri 任务使用单文件随机写,不使用分块
+ mTaskRecord.isBlock = false;
+ if (TextUtils.isEmpty(mTaskRecord.filePath)) {
+ // 分区存储场景下实体没有文件路径,使用 Uri 字符串作为记录路径,避免后续File操作出现NPE
+ mTaskRecord.filePath = getFilePath();
+ }
+ }
+ }
+
/**
* convertDb 是兼容性代码 从3.4.1开始,线程配置信息将存储在数据库中。 将配置文件的内容复制到数据库中,并将配置文件删除
*/
@@ -198,12 +222,25 @@ public abstract class RecordHandler implements IRecordHandler {
*/
private String getFilePath() {
if (mEntity instanceof DownloadEntity) {
- return ((DownloadEntity) mTaskWrapper.getEntity()).getFilePath();
+ DownloadEntity de = (DownloadEntity) mTaskWrapper.getEntity();
+ if (!TextUtils.isEmpty(de.getFileUri())) {
+ // 分区存储场景下,使用 Uri 字符串作为路径标识
+ return de.getFileUri();
+ }
+ return de.getFilePath();
} else {
return ((UploadEntity) mTaskWrapper.getEntity()).getFilePath();
}
}
+ /**
+ * 是否为通过 Uri 下载的分区存储任务
+ */
+ private boolean isUriMode() {
+ return mEntity instanceof DownloadEntity
+ && !TextUtils.isEmpty(((DownloadEntity) mEntity).getFileUri());
+ }
+
@Override public void accept(ILoaderVisitor visitor) {
visitor.addComponent(this);
}
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/common/RecordHelper.java b/PublicComponent/src/main/java/com/arialyy/aria/core/common/RecordHelper.java
index 365663a4..709dfa26 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/common/RecordHelper.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/common/RecordHelper.java
@@ -15,14 +15,21 @@
*/
package com.arialyy.aria.core.common;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.arialyy.aria.core.AriaConfig;
import com.arialyy.aria.core.TaskRecord;
import com.arialyy.aria.core.ThreadRecord;
+import com.arialyy.aria.core.download.DownloadEntity;
import com.arialyy.aria.core.loader.IRecordHandler;
import com.arialyy.aria.core.wrapper.AbsTaskWrapper;
import com.arialyy.aria.util.ALog;
import com.arialyy.aria.util.BufferedRandomAccessFile;
import com.arialyy.aria.util.CommonUtil;
import com.arialyy.aria.util.FileUtil;
+import com.arialyy.aria.util.UriFileUtil;
+
import java.io.File;
import java.io.IOException;
@@ -47,6 +54,10 @@ public class RecordHelper {
* 处理非分块的,多线程任务
*/
public void handleMultiRecord() {
+ if (mTaskRecord.isUriTask) {
+ handleUriMultiRecord();
+ return;
+ }
// 默认线程分块长度
long blockSize = mWrapper.getEntity().getFileSize() / mTaskRecord.threadRecords.size();
File temp = new File(mTaskRecord.filePath);
@@ -150,10 +161,81 @@ public class RecordHelper {
//mWrapper.setNewTask(false);
}
+ /**
+ * 获取分区存储任务的目标 Uri 字符串
+ */
+ private String getUriString() {
+ if (mWrapper.getEntity() instanceof DownloadEntity) {
+ String uri = ((DownloadEntity) mWrapper.getEntity()).getFileUri();
+ if (!TextUtils.isEmpty(uri)) {
+ return uri;
+ }
+ }
+ return mTaskRecord.filePath;
+ }
+
+ /**
+ * 处理分区存储(Uri)多线程任务的记录,Uri 任务不能使用{@link File}操作
+ */
+ private void handleUriMultiRecord() {
+ long fileSize = mWrapper.getEntity().getFileSize();
+ long blockSize = fileSize / mTaskRecord.threadRecords.size();
+ long len = UriFileUtil.getFileLength(AriaConfig.getInstance().getAPP(),
+ Uri.parse(getUriString()));
+ // 目标内容被清空或不存在,重新分配线程区间
+ if (len <= 0) {
+ ALog.w(TAG, String.format("Uri目标【%s】不存在或内容为空,重新分配线程区间", getUriString()));
+ for (int i = 0; i < mTaskRecord.threadNum; i++) {
+ long startL = i * blockSize, endL = (i + 1) * blockSize;
+ ThreadRecord tr = mTaskRecord.threadRecords.get(i);
+ tr.startLocation = startL;
+ tr.isComplete = false;
+ if (tr.threadId == (mTaskRecord.threadNum - 1)) {
+ endL = fileSize;
+ }
+ tr.endLocation = endL;
+ }
+ }
+ }
+
+ /**
+ * 处理分区存储(Uri)单线程任务的记录,Uri 任务不能使用{@link File}操作
+ */
+ private void handleUriSingleThreadRecord() {
+ ThreadRecord tr = mTaskRecord.threadRecords.get(0);
+ long fileSize = mWrapper.getEntity().getFileSize();
+ String uriStr = getUriString();
+ long len = UriFileUtil.getFileLength(AriaConfig.getInstance().getAPP(), Uri.parse(uriStr));
+ if (len < 0) {
+ // 无法获取Uri文件长度(如无权限、流式内容),信任数据库记录继续下载
+ if (!tr.isComplete && tr.endLocation <= 0) {
+ tr.endLocation = fileSize;
+ }
+ return;
+ }
+ if (len > fileSize) {
+ ALog.i(TAG, String.format("Uri文件【%s】长度错误,任务重新开始", uriStr));
+ tr.startLocation = 0;
+ tr.isComplete = false;
+ tr.endLocation = fileSize;
+ } else if (len != 0 && len == fileSize) {
+ ALog.d(TAG, "文件长度一致,线程完成");
+ tr.isComplete = true;
+ } else if (len != tr.startLocation) {
+ ALog.i(TAG, String.format("修正Uri任务【%s】的进度记录为:%s", uriStr, len));
+ tr.startLocation = len;
+ tr.isComplete = false;
+ }
+ }
+
/**
* 处理单线程的任务的记录
*/
public void handleSingleThreadRecord() {
+ if (mTaskRecord.isUriTask) {
+ handleUriSingleThreadRecord();
+ return;
+ }
// mTaskRecord.isBlock是为了兼容以前的文件格式
File file = new File(
mTaskRecord.isBlock ? String.format(IRecordHandler.SUB_PATH, mTaskRecord.filePath, 0)
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/common/SubThreadConfig.java b/PublicComponent/src/main/java/com/arialyy/aria/core/common/SubThreadConfig.java
index 3c49c334..78b2a8f9 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/common/SubThreadConfig.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/common/SubThreadConfig.java
@@ -15,11 +15,14 @@
*/
package com.arialyy.aria.core.common;
+import android.net.Uri;
import android.os.Handler;
+
import com.arialyy.aria.core.AriaConfig;
import com.arialyy.aria.core.ThreadRecord;
import com.arialyy.aria.core.wrapper.AbsTaskWrapper;
import com.arialyy.aria.core.wrapper.ITaskWrapper;
+
import java.io.File;
/**
@@ -39,6 +42,8 @@ public class SubThreadConfig {
// 真正的下载地址,如果是30x,则是30x后的地址
public String url;
public File tempFile;
+ // 分区存储场景下的下载目标 Uri,与 tempFile 互斥(二选一)
+ public Uri tempUri;
// 线程记录
public ThreadRecord record;
// 状态处理器
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/download/DTaskWrapper.java b/PublicComponent/src/main/java/com/arialyy/aria/core/download/DTaskWrapper.java
index 041d8f64..8932de87 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/download/DTaskWrapper.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/download/DTaskWrapper.java
@@ -15,6 +15,9 @@
*/
package com.arialyy.aria.core.download;
+import android.net.Uri;
+import android.text.TextUtils;
+
import com.arialyy.aria.core.TaskOptionParams;
import com.arialyy.aria.core.config.Configuration;
import com.arialyy.aria.core.config.DownloadConfig;
@@ -54,6 +57,11 @@ public class DTaskWrapper extends AbsTaskWrapper {
*/
private String mTempFilePath;
+ /**
+ * 分区存储场景下,下载目标的 Uri(字符串形式)临时变量,与 {@link #mTempFilePath} 互斥
+ */
+ private String mTempFileUri;
+
public DTaskWrapper(DownloadEntity entity) {
super(entity);
}
@@ -118,5 +126,19 @@ public class DTaskWrapper extends AbsTaskWrapper {
public void setTempFilePath(String mTempFilePath) {
this.mTempFilePath = mTempFilePath;
+ this.mTempFileUri = null;
+ }
+
+ public String getFileUri() {
+ return mTempFileUri;
+ }
+
+ public void setFileUri(Uri uri) {
+ this.mTempFileUri = uri == null ? null : uri.toString();
+ this.mTempFilePath = null;
+ }
+
+ public boolean isUriMode() {
+ return !TextUtils.isEmpty(mTempFileUri);
}
}
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/download/DownloadEntity.java b/PublicComponent/src/main/java/com/arialyy/aria/core/download/DownloadEntity.java
index 974209f2..134d9443 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/download/DownloadEntity.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/download/DownloadEntity.java
@@ -19,6 +19,7 @@ package com.arialyy.aria.core.download;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
+
import com.arialyy.aria.core.common.AbsNormalEntity;
import com.arialyy.aria.core.wrapper.ITaskWrapper;
import com.arialyy.aria.orm.DbEntity;
@@ -34,6 +35,12 @@ import com.arialyy.aria.util.CommonUtil;
public class DownloadEntity extends AbsNormalEntity implements Parcelable, Cloneable {
@Unique private String downloadPath; //保存路径
+ /**
+ * 分区存储(Scoped Storage)场景下,下载目标为 Uri(如 SAF 文档、MediaStore 条目)时,
+ * 该字段持久化 Uri 的字符串形式,用于任务恢复时继续写入。
+ */
+ private String fileUri;
+
/**
* 所属任务组
*/
@@ -65,7 +72,10 @@ public class DownloadEntity extends AbsNormalEntity implements Parcelable, Clone
*/
public M3U8Entity getM3U8Entity() {
if (TextUtils.isEmpty(downloadPath)) {
- ALog.e("DownloadEntity", "文件保存路径为空,获取m3u8实体之前需要设置文件保存路径");
+ // 分区存储(Uri)下载任务没有文件路径,且不支持m3u8,静默返回null即可
+ if (TextUtils.isEmpty(fileUri)) {
+ ALog.e("DownloadEntity", "文件保存路径为空,获取m3u8实体之前需要设置文件保存路径");
+ }
return null;
}
if (m3U8Entity == null) {
@@ -151,6 +161,15 @@ public class DownloadEntity extends AbsNormalEntity implements Parcelable, Clone
return this;
}
+ public String getFileUri() {
+ return fileUri;
+ }
+
+ public DownloadEntity setFileUri(String fileUri) {
+ this.fileUri = fileUri;
+ return this;
+ }
+
@Override public DownloadEntity clone() throws CloneNotSupportedException {
return (DownloadEntity) super.clone();
}
@@ -185,6 +204,7 @@ public class DownloadEntity extends AbsNormalEntity implements Parcelable, Clone
@Override public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeString(this.downloadPath);
+ dest.writeString(this.fileUri);
dest.writeString(this.groupHash);
dest.writeString(this.md5Code);
dest.writeString(this.disposition);
@@ -195,6 +215,7 @@ public class DownloadEntity extends AbsNormalEntity implements Parcelable, Clone
protected DownloadEntity(Parcel in) {
super(in);
this.downloadPath = in.readString();
+ this.fileUri = in.readString();
this.groupHash = in.readString();
this.md5Code = in.readString();
this.disposition = in.readString();
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/loader/AbsNormalTTBuilderAdapter.java b/PublicComponent/src/main/java/com/arialyy/aria/core/loader/AbsNormalTTBuilderAdapter.java
index 636c748a..ffb58382 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/loader/AbsNormalTTBuilderAdapter.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/loader/AbsNormalTTBuilderAdapter.java
@@ -15,27 +15,43 @@
*/
package com.arialyy.aria.core.loader;
+import android.net.Uri;
import android.os.Handler;
+import android.text.TextUtils;
+
import com.arialyy.aria.core.TaskRecord;
import com.arialyy.aria.core.ThreadRecord;
import com.arialyy.aria.core.common.AbsNormalEntity;
import com.arialyy.aria.core.common.SubThreadConfig;
+import com.arialyy.aria.core.download.DTaskWrapper;
import com.arialyy.aria.core.task.IThreadTaskAdapter;
import com.arialyy.aria.core.wrapper.AbsTaskWrapper;
import com.arialyy.aria.util.CommonUtil;
+
import java.io.File;
public abstract class AbsNormalTTBuilderAdapter {
protected String TAG = CommonUtil.getClassName(this);
protected AbsTaskWrapper wrapper;
private File tempFile;
+ private boolean mIsUriMode = false;
+ private Uri mUri;
public AbsNormalTTBuilderAdapter() {
}
protected void setWrapper(AbsTaskWrapper wrapper) {
this.wrapper = wrapper;
- tempFile = new File(((AbsNormalEntity) wrapper.getEntity()).getFilePath());
+ String uri = null;
+ if (wrapper instanceof DTaskWrapper) {
+ uri = ((DTaskWrapper) wrapper).getFileUri();
+ }
+ if (!TextUtils.isEmpty(uri)) {
+ mIsUriMode = true;
+ mUri = Uri.parse(uri);
+ } else {
+ tempFile = new File(((AbsNormalEntity) wrapper.getEntity()).getFilePath());
+ }
}
/**
@@ -66,11 +82,18 @@ public abstract class AbsNormalTTBuilderAdapter {
boolean isBlock, int startNum) {
SubThreadConfig config = new SubThreadConfig();
config.url = getEntity().isRedirect() ? getEntity().getRedirectUrl() : getEntity().getUrl();
- config.tempFile =
- isBlock ? new File(
- String.format(IRecordHandler.SUB_PATH, tempFile.getPath(), threadRecord.threadId))
- : tempFile;
- config.isBlock = isBlock;
+ if (mIsUriMode) {
+ // 分区存储 Uri 模式:使用单文件多线程随机写,不使用分块子文件
+ config.tempUri = mUri;
+ config.tempFile = null;
+ config.isBlock = false;
+ } else {
+ config.tempFile =
+ isBlock ? new File(
+ String.format(IRecordHandler.SUB_PATH, tempFile.getPath(), threadRecord.threadId))
+ : tempFile;
+ config.isBlock = isBlock;
+ }
config.startThreadNum = startNum;
config.taskWrapper = wrapper;
config.record = threadRecord;
@@ -87,4 +110,11 @@ public abstract class AbsNormalTTBuilderAdapter {
protected File getTempFile() {
return tempFile;
}
+
+ /**
+ * 是否为分区存储(Uri)模式,该模式下{@link #getTempFile()}为null,不能进行File操作
+ */
+ protected boolean isUriMode() {
+ return mIsUriMode;
+ }
}
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/loader/NormalLoader.java b/PublicComponent/src/main/java/com/arialyy/aria/core/loader/NormalLoader.java
index a3d4db2e..017cfbf4 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/loader/NormalLoader.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/loader/NormalLoader.java
@@ -17,6 +17,8 @@ package com.arialyy.aria.core.loader;
import android.os.Handler;
import android.os.Looper;
+import android.text.TextUtils;
+
import com.arialyy.aria.core.common.AbsEntity;
import com.arialyy.aria.core.common.AbsNormalEntity;
import com.arialyy.aria.core.common.CompleteInfo;
@@ -25,14 +27,12 @@ import com.arialyy.aria.core.inf.IThreadStateManager;
import com.arialyy.aria.core.listener.IDLoadListener;
import com.arialyy.aria.core.listener.IEventListener;
import com.arialyy.aria.core.manager.ThreadTaskManager;
-import com.arialyy.aria.core.task.AbsTask;
import com.arialyy.aria.core.task.IThreadTask;
import com.arialyy.aria.core.wrapper.AbsTaskWrapper;
import com.arialyy.aria.exception.AriaException;
-import com.arialyy.aria.util.ALog;
import com.arialyy.aria.util.FileUtil;
+
import java.io.File;
-import java.util.List;
/**
* 单文件
@@ -44,7 +44,9 @@ public class NormalLoader extends AbsNormalLoader {
public NormalLoader(T wrapper, IEventListener listener) {
super(wrapper, listener);
- mTempFile = new File(getEntity().getFilePath());
+ // 分区存储 Uri 模式下,下载目标为 Uri 而非本地文件路径,getFilePath() 为空,不创建本地 File
+ String filePath = getEntity().getFilePath();
+ mTempFile = !TextUtils.isEmpty(filePath) ? new File(filePath) : null;
EventMsgUtil.getDefault().register(this);
setUpdateInterval(wrapper.getConfig().getUpdateInterval());
}
@@ -109,9 +111,13 @@ public class NormalLoader extends AbsNormalLoader {
if (getListener() instanceof IDLoadListener) {
((IDLoadListener) getListener()).onPostPre(getEntity().getFileSize());
}
- File file = new File(getEntity().getFilePath());
- if (file.getParentFile() != null && !file.getParentFile().exists()) {
- FileUtil.createDir(file.getPath());
+ String filePath = getEntity().getFilePath();
+ // 分区存储 Uri 模式下没有本地文件路径,无需创建父目录
+ if (!TextUtils.isEmpty(filePath)) {
+ File file = new File(filePath);
+ if (file.getParentFile() != null && !file.getParentFile().exists()) {
+ FileUtil.createDir(file.getPath());
+ }
}
// 处理记录、初始化状态管理器
mRecord = mRecordHandler.getRecord(getFileSize());
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/loader/NormalThreadStateManager.java b/PublicComponent/src/main/java/com/arialyy/aria/core/loader/NormalThreadStateManager.java
index 4a8a6bbd..5a8e7384 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/loader/NormalThreadStateManager.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/loader/NormalThreadStateManager.java
@@ -19,6 +19,7 @@ import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
+
import com.arialyy.aria.core.TaskRecord;
import com.arialyy.aria.core.inf.IThreadStateManager;
import com.arialyy.aria.core.listener.IEventListener;
@@ -26,6 +27,7 @@ import com.arialyy.aria.exception.AriaException;
import com.arialyy.aria.util.ALog;
import com.arialyy.aria.util.CommonUtil;
import com.arialyy.aria.util.FileUtil;
+
import java.io.File;
import java.util.ArrayList;
import java.util.List;
@@ -247,6 +249,10 @@ public class NormalThreadStateManager implements IThreadStateManager {
* @return {@code true} 合并成功,{@code false}合并失败
*/
private boolean mergeFile() {
+ if (mTaskRecord.isUriTask) {
+ // 分区存储 Uri 任务数据已直接写入目标 Uri,无需合并
+ return true;
+ }
if (mTaskRecord.threadNum == 1) {
File targetFile = new File(mTaskRecord.filePath);
if (targetFile.exists()){
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/core/task/ThreadTask.java b/PublicComponent/src/main/java/com/arialyy/aria/core/task/ThreadTask.java
index 8a67263b..72888995 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/core/task/ThreadTask.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/core/task/ThreadTask.java
@@ -20,6 +20,7 @@ import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.os.Process;
+
import com.arialyy.aria.core.AriaConfig;
import com.arialyy.aria.core.ThreadRecord;
import com.arialyy.aria.core.common.AbsEntity;
@@ -36,6 +37,8 @@ import com.arialyy.aria.util.CommonUtil;
import com.arialyy.aria.util.ErrorHelp;
import com.arialyy.aria.util.FileUtil;
import com.arialyy.aria.util.NetUtils;
+import com.arialyy.aria.util.UriFileUtil;
+
import java.io.File;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
@@ -94,6 +97,10 @@ public class ThreadTask implements IThreadTask, IThreadTaskObserver {
}
private void checkFileExist() {
+ if (getConfig().tempUri != null) {
+ // 分区存储 Uri 模式:目标由 ContentResolver 管理,无需预创建文件
+ return;
+ }
if (!getConfig().tempFile.exists()) {
FileUtil.createFile(getConfig().tempFile);
}
@@ -111,6 +118,9 @@ public class ThreadTask implements IThreadTask, IThreadTaskObserver {
* 当前线程处理的文件名
*/
protected String getFileName() {
+ if (getConfig().tempUri != null) {
+ return UriFileUtil.getFileName(AriaConfig.getInstance().getAPP(), getConfig().tempUri);
+ }
return mConfig.tempFile.getName();
}
@@ -311,7 +321,8 @@ public class ThreadTask implements IThreadTask, IThreadTaskObserver {
@Override public synchronized void updateCompleteState() {
ALog.i(TAG, String.format("任务【%s】线程__%s__完成, blockSize = %s", getTaskWrapper().getKey(),
- mRecord.threadId, mConfig.tempFile.length()));
+ mRecord.threadId,
+ getConfig().tempUri != null ? "uri" : mConfig.tempFile.length()));
writeConfig(true, mRecord.endLocation);
// 进度发送不是实时的,发送完成任务前,需要更新一次进度
sendRunningState();
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/orm/DBConfig.java b/PublicComponent/src/main/java/com/arialyy/aria/orm/DBConfig.java
index e7e3ea28..221de83e 100644
--- a/PublicComponent/src/main/java/com/arialyy/aria/orm/DBConfig.java
+++ b/PublicComponent/src/main/java/com/arialyy/aria/orm/DBConfig.java
@@ -16,12 +16,14 @@
package com.arialyy.aria.orm;
import android.text.TextUtils;
+
import com.arialyy.aria.core.TaskRecord;
import com.arialyy.aria.core.ThreadRecord;
import com.arialyy.aria.core.download.DownloadEntity;
import com.arialyy.aria.core.download.DownloadGroupEntity;
import com.arialyy.aria.core.download.M3U8Entity;
import com.arialyy.aria.core.upload.UploadEntity;
+
import java.util.LinkedHashMap;
import java.util.Map;
@@ -34,7 +36,7 @@ class DBConfig {
static boolean DEBUG = false;
static Map> mapping = new LinkedHashMap<>();
static String DB_NAME;
- static int VERSION = 58;
+ static int VERSION = 59;
/**
* 是否将数据库保存在Sd卡,{@code true} 是
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/util/UriFileUtil.java b/PublicComponent/src/main/java/com/arialyy/aria/util/UriFileUtil.java
new file mode 100644
index 00000000..6b6ab23f
--- /dev/null
+++ b/PublicComponent/src/main/java/com/arialyy/aria/util/UriFileUtil.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2016 AriaLyy(https://github.com/AriaLyy/Aria)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arialyy.aria.util;
+
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.provider.OpenableColumns;
+import android.text.TextUtils;
+
+/**
+ * 分区存储(Scoped Storage)相关 Uri 工具类,提供文件名解析、可写性探测和权限持久化能力。
+ */
+public class UriFileUtil {
+ private static final String TAG = "UriFileUtil";
+
+ /**
+ * 从 Uri 中获取文件名
+ *
+ * @param context 上下文,可为 null(仅用 lastPathSegment 兜底)
+ * @param uri 文档 Uri
+ * @return 文件名,无法获取时返回空串
+ */
+ public static String getFileName(Context context, Uri uri) {
+ if (uri == null) {
+ return "";
+ }
+ String name = queryDisplayName(context, uri);
+ if (!TextUtils.isEmpty(name)) {
+ return name;
+ }
+ String last = uri.getLastPathSegment();
+ if (!TextUtils.isEmpty(last)) {
+ int idx = last.lastIndexOf('/');
+ return idx >= 0 ? last.substring(idx + 1) : last;
+ }
+ return "";
+ }
+
+ private static String queryDisplayName(Context context, Uri uri) {
+ if (!"content".equalsIgnoreCase(uri.getScheme()) || context == null) {
+ return null;
+ }
+ Cursor cursor = null;
+ try {
+ cursor = context.getContentResolver()
+ .query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
+ if (cursor != null && cursor.moveToFirst()) {
+ int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+ if (idx >= 0) {
+ return cursor.getString(idx);
+ }
+ }
+ } catch (Exception e) {
+ ALog.w(TAG, "query display name error: " + e.getMessage());
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 测试 Uri 是否可写(以读写方式打开)
+ */
+ public static boolean canWrite(Context context, Uri uri) {
+ if (context == null || uri == null) {
+ return false;
+ }
+ ParcelFileDescriptor pfd = null;
+ try {
+ pfd = context.getContentResolver().openFileDescriptor(uri, "rw");
+ return pfd != null;
+ } catch (Exception e) {
+ ALog.w(TAG, "uri canWrite test failed: " + e.getMessage());
+ return false;
+ } finally {
+ if (pfd != null) {
+ try {
+ pfd.close();
+ } catch (Exception ignored) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ /**
+ * 获取 Uri 对应文件的长度
+ *
+ * @return 文件长度;无法获取(Uri 不存在、无权限、流式内容等)时返回 -1
+ */
+ public static long getFileLength(Context context, Uri uri) {
+ if (context == null || uri == null) {
+ return -1;
+ }
+ ParcelFileDescriptor pfd = null;
+ try {
+ pfd = context.getContentResolver().openFileDescriptor(uri, "r");
+ return pfd == null ? -1 : pfd.getStatSize();
+ } catch (Exception e) {
+ ALog.w(TAG, "get uri file length failed: " + e.getMessage());
+ return -1;
+ } finally {
+ if (pfd != null) {
+ try {
+ pfd.close();
+ } catch (Exception ignored) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ /**
+ * 尝试持久化 Uri 权限,以便应用重启后仍能访问(SAF 文档树/文件)。
+ * 若 Uri 不支持持久化权限则静默忽略。
+ */
+ public static void tryTakePersistablePermission(Context context, Uri uri) {
+ if (context == null || uri == null) {
+ return;
+ }
+ try {
+ context.getContentResolver()
+ .takePersistableUriPermission(uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ } catch (SecurityException | IllegalArgumentException ignored) {
+ // 部分 Uri(如 MediaStore 插入项)不支持持久化权限,忽略即可
+ }
+ }
+}
diff --git a/PublicComponent/src/main/java/com/arialyy/aria/util/UriRandomAccessFile.java b/PublicComponent/src/main/java/com/arialyy/aria/util/UriRandomAccessFile.java
new file mode 100644
index 00000000..f1b90b80
--- /dev/null
+++ b/PublicComponent/src/main/java/com/arialyy/aria/util/UriRandomAccessFile.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2016 AriaLyy(https://github.com/AriaLyy/Aria)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.arialyy.aria.util;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+
+import com.arialyy.aria.core.AriaConfig;
+
+import java.io.Closeable;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+
+/**
+ * 基于 {@link android.content.ContentResolver} 的随机访问文件输出流。
+ *
+ * 用于分区存储(Scoped Storage)场景下通过 Uri(如 SAF 文档、MediaStore 条目)写入下载数据,
+ * 支持断点续传(seek)。底层通过 {@link android.content.ContentResolver#openFileDescriptor(Uri, String)}
+ * 以 "rw" 模式打开 {@link ParcelFileDescriptor},再使用 {@link FileChannel} 进行随机写。
+ *
+ * 每个下载子线程各自打开一个独立的 {@link FileChannel},并通过 {@link #seek(long)} 定位到自己的
+ * 区间写入,因此天然支持多线程断点续传。
+ */
+public class UriRandomAccessFile implements Closeable {
+
+ private final Uri mUri;
+ private ParcelFileDescriptor mPfd;
+ private FileOutputStream mFos;
+ private FileChannel mChannel;
+ private long mCurrPos;
+
+ public UriRandomAccessFile(Uri uri, long startLocation) throws IOException {
+ this(AriaConfig.getInstance().getAPP(), uri, startLocation);
+ }
+
+ public UriRandomAccessFile(Context context, Uri uri, long startLocation) throws IOException {
+ if (context == null) {
+ throw new IOException("Context is null, cannot open uri: " + uri);
+ }
+ mUri = uri;
+ mPfd = context.getContentResolver().openFileDescriptor(uri, "rw");
+ if (mPfd == null) {
+ throw new IOException("openFileDescriptor return null for uri: " + uri);
+ }
+ mFos = new FileOutputStream(mPfd.getFileDescriptor());
+ mChannel = mFos.getChannel();
+ if (startLocation > 0) {
+ mChannel.position(startLocation);
+ }
+ mCurrPos = startLocation;
+ }
+
+ public void seek(long pos) throws IOException {
+ mChannel.position(pos);
+ mCurrPos = pos;
+ }
+
+ public long getFilePointer() {
+ return mCurrPos;
+ }
+
+ public void write(int b) throws IOException {
+ ByteBuffer bb = ByteBuffer.wrap(new byte[]{(byte) b});
+ while (bb.hasRemaining()) {
+ mChannel.write(bb);
+ }
+ mCurrPos++;
+ }
+
+ public void write(byte[] b, int off, int len) throws IOException {
+ ByteBuffer bb = ByteBuffer.wrap(b, off, len);
+ while (bb.hasRemaining()) {
+ mChannel.write(bb);
+ }
+ mCurrPos += len;
+ }
+
+ public long length() throws IOException {
+ return mChannel.size();
+ }
+
+ public void flush() throws IOException {
+ mFos.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ if (mChannel != null) {
+ mChannel.close();
+ }
+ } finally {
+ try {
+ if (mFos != null) {
+ mFos.close();
+ }
+ } finally {
+ if (mPfd != null) {
+ mPfd.close();
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/arialyy/simple/core/download/SingleTaskActivity.java b/app/src/main/java/com/arialyy/simple/core/download/SingleTaskActivity.java
index 158d8f66..a1b97665 100644
--- a/app/src/main/java/com/arialyy/simple/core/download/SingleTaskActivity.java
+++ b/app/src/main/java/com/arialyy/simple/core/download/SingleTaskActivity.java
@@ -17,17 +17,26 @@
package com.arialyy.simple.core.download;
import android.content.BroadcastReceiver;
+import android.content.ContentValues;
import android.content.Context;
+import android.content.DialogInterface;
import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
import android.os.Bundle;
+import android.os.Environment;
+import android.provider.MediaStore;
+import android.text.TextUtils;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
+import android.webkit.MimeTypeMap;
import android.widget.Toast;
import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProviders;
@@ -48,6 +57,8 @@ import com.arialyy.simple.databinding.ActivitySingleBinding;
import com.arialyy.simple.widget.ProgressLayout;
import java.io.File;
+import java.io.InputStream;
+import java.security.MessageDigest;
import java.util.List;
import java.util.Map;
@@ -265,7 +276,15 @@ public class SingleTaskActivity extends BaseActivity {
Toast.makeText(SingleTaskActivity.this, getString(R.string.download_success),
Toast.LENGTH_SHORT).show();
ALog.d(TAG, "文件md5: e088677570afe2e9f847cc8159b932dd");
- ALog.d(TAG, "下载完成的文件md5: " + CommonUtil.getFileMD5(new File(task.getFilePath())));
+ String fileUri = task.getDownloadEntity().getFileUri();
+ String md5;
+ if (!TextUtils.isEmpty(fileUri)) {
+ // Uri 下载场景(分区存储):通过 ContentResolver 读流计算 MD5
+ md5 = getUriFileMD5(Uri.parse(fileUri));
+ } else {
+ md5 = CommonUtil.getFileMD5(new File(task.getFilePath()));
+ }
+ ALog.d(TAG, "下载完成的文件md5: " + md5);
getBinding().pl.setInfo(task.getEntity());
getBinding().pl.setProgress(100);
}
@@ -276,19 +295,212 @@ public class SingleTaskActivity extends BaseActivity {
return R.layout.activity_single;
}
+ private static final int REQUEST_CODE_CREATE_FILE = 0x1001;
+
private void startD() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Android 10+ 分区存储:让用户选择保存方式
+ new AlertDialog.Builder(this)
+ .setTitle("选择保存方式")
+ .setItems(new String[]{
+ "自动保存到 Download 目录(MediaStore)",
+ "自选保存位置(SAF 文件选择器)"
+ }, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (which == 0) {
+ startDownloadByMediaStore();
+ } else {
+ openSafFilePicker();
+ }
+ }
+ })
+ .show();
+ } else {
+ // Android 9 及以下:直接使用文件路径(需 WRITE_EXTERNAL_STORAGE 权限)
+ startDownloadByPath();
+ }
+ }
+
+ /**
+ * Android 10+ 通过 MediaStore 在公共 Download 目录创建条目并下载(无需用户交互)
+ */
+ private void startDownloadByMediaStore() {
+ Uri uri = createDownloadUri(new File(mFilePath).getName());
+ if (uri == null) {
+ T.showShort(this, "创建下载文件失败");
+ return;
+ }
+ startDownloadByUri(uri);
+ }
+
+ /**
+ * Android 10+ 通过 SAF(ACTION_CREATE_DOCUMENT)让用户自选保存位置
+ */
+ private void openSafFilePicker() {
+ String fileName = new File(mFilePath).getName();
+ Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ String mimeType = getMimeType(fileName);
+ intent.setType(TextUtils.isEmpty(mimeType) ? "application/octet-stream" : mimeType);
+ intent.putExtra(Intent.EXTRA_TITLE, fileName);
+ try {
+ startActivityForResult(intent, REQUEST_CODE_CREATE_FILE);
+ } catch (Exception e) {
+ ALog.e(TAG, "打开系统文件选择器失败:" + e.getMessage());
+ T.showShort(this, "无法打开系统文件选择器");
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == REQUEST_CODE_CREATE_FILE && resultCode == RESULT_OK
+ && data != null && data.getData() != null) {
+ Uri uri = data.getData();
+ // 持久化 Uri 权限,保证 App 重启后仍能续传/写入
+ try {
+ getContentResolver().takePersistableUriPermission(uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ } catch (Exception e) {
+ ALog.w(TAG, "持久化 Uri 权限失败:" + e.getMessage());
+ }
+ startDownloadByUri(uri);
+ }
+ }
+
+ /**
+ * 使用 Uri 创建下载任务(分区存储场景)
+ */
+ private void startDownloadByUri(Uri uri) {
+ mTaskId = Aria.download(SingleTaskActivity.this)
+ .load(mUrl)
+ .setFileUri(uri)
+ .option(createHttpOption())
+ .ignoreCheckPermissions()
+ .create();
+ }
+
+ /**
+ * 使用文件路径创建下载任务(Android 9 及以下)
+ */
+ private void startDownloadByPath() {
+ mTaskId = Aria.download(SingleTaskActivity.this)
+ .load(mUrl)
+ .setFilePath(mFilePath, true)
+ .option(createHttpOption())
+ .ignoreCheckPermissions()
+ .create();
+ }
+
+ private HttpOption createHttpOption() {
HttpOption option = new HttpOption();
option.useServerFileName(true);
option.addHeader("1", "@")
.useServerFileName(true)
.setFileLenAdapter(new FileLenAdapter());
//option.setRequestType(RequestEnum.POST);
- mTaskId = Aria.download(SingleTaskActivity.this)
- .load(mUrl)
- .setFilePath(mFilePath, true)
- .option(option)
- .ignoreCheckPermissions()
- .create();
+ return option;
+ }
+
+ /**
+ * Android 10+ 通过 MediaStore 在公共 Download 目录创建下载条目
+ *
+ * @param fileName 文件名
+ * @return 可写的 Uri,失败返回 null
+ */
+ private Uri createDownloadUri(String fileName) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ return null;
+ }
+ try {
+ ContentValues values = new ContentValues();
+ values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
+ values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
+ String mimeType = getMimeType(fileName);
+ if (!TextUtils.isEmpty(mimeType)) {
+ values.put(MediaStore.Downloads.MIME_TYPE, mimeType);
+ }
+ // 先删除同名旧条目,避免系统自动重命名为 xxx(1).zip
+ Uri old = queryDownloadUri(fileName);
+ if (old != null) {
+ getContentResolver().delete(old, null, null);
+ }
+ return getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
+ } catch (Exception e) {
+ ALog.e(TAG, "创建 MediaStore 下载条目失败:" + e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * 查询公共 Download 目录下是否已存在同名条目(仅限本应用创建的条目)
+ */
+ private Uri queryDownloadUri(String fileName) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ return null;
+ }
+ android.database.Cursor cursor = getContentResolver().query(
+ MediaStore.Downloads.EXTERNAL_CONTENT_URI,
+ new String[]{MediaStore.Downloads._ID},
+ MediaStore.Downloads.DISPLAY_NAME + "=?",
+ new String[]{fileName}, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ long id = cursor.getLong(0);
+ return Uri.withAppendedPath(MediaStore.Downloads.EXTERNAL_CONTENT_URI,
+ String.valueOf(id));
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 通过 ContentResolver 读流计算 Uri 对应文件的 MD5(分区存储场景)
+ *
+ * @return MD5 字符串,失败返回 null
+ */
+ private String getUriFileMD5(Uri uri) {
+ InputStream is = null;
+ try {
+ is = getContentResolver().openInputStream(uri);
+ if (is == null) {
+ return null;
+ }
+ MessageDigest digest = MessageDigest.getInstance("MD5");
+ byte[] buffer = new byte[8192];
+ int len;
+ while ((len = is.read(buffer)) != -1) {
+ digest.update(buffer, 0, len);
+ }
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digest.digest()) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ ALog.e(TAG, "计算 Uri 文件 MD5 失败:" + e.getMessage());
+ return null;
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (Exception ignored) {
+ }
+ }
+ }
+ }
+
+ private String getMimeType(String fileName) {
+ String ext = MimeTypeMap.getFileExtensionFromUrl(fileName);
+ if (TextUtils.isEmpty(ext)) {
+ return null;
+ }
+ return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.toLowerCase());
}
@Override