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 / 普通文件路径)进行适配: + * * * @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