feat(download): 支持分区存储Uri下载模式

This commit is contained in:
2026-07-27 01:38:57 +08:00
parent cb50a2dcd2
commit cef92d6a2f
25 changed files with 1037 additions and 54 deletions

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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;
// 状态处理器

View File

@@ -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<DownloadEntity> {
*/
private String mTempFilePath;
/**
* 分区存储场景下,下载目标的 Uri字符串形式临时变量与 {@link #mTempFilePath} 互斥
*/
private String mTempFileUri;
public DTaskWrapper(DownloadEntity entity) {
super(entity);
}
@@ -118,5 +126,19 @@ public class DTaskWrapper extends AbsTaskWrapper<DownloadEntity> {
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);
}
}

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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<T extends AbsTaskWrapper> extends AbsNormalLoader<T> {
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<T extends AbsTaskWrapper> extends AbsNormalLoader<T> {
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());

View File

@@ -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()){

View File

@@ -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();

View File

@@ -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<String, Class<? extends DbEntity>> mapping = new LinkedHashMap<>();
static String DB_NAME;
static int VERSION = 58;
static int VERSION = 59;
/**
* 是否将数据库保存在Sd卡{@code true} 是

View File

@@ -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 插入项)不支持持久化权限,忽略即可
}
}
}

View File

@@ -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} 的随机访问文件输出流。
*
* <p>用于分区存储Scoped Storage场景下通过 Uri如 SAF 文档、MediaStore 条目)写入下载数据,
* 支持断点续传seek。底层通过 {@link android.content.ContentResolver#openFileDescriptor(Uri, String)}
* 以 "rw" 模式打开 {@link ParcelFileDescriptor},再使用 {@link FileChannel} 进行随机写。</p>
*
* <p>每个下载子线程各自打开一个独立的 {@link FileChannel},并通过 {@link #seek(long)} 定位到自己的
* 区间写入,因此天然支持多线程断点续传。</p>
*/
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();
}
}
}
}
}