由于项目需要,要求开发一个类似于“ES 文件浏览器“的模块,这个模块当然不需要像 ES一样那么功能强大,只需要具备ES文件浏览器的最核心功能,可以操作管理本地sdcard和外置sdcard的文件和文件夹,包括查询,删除,重命名,从网络下载文件。
这个模块的功能不复杂,一两句话,就描述清楚需求了,但是要把这个模块做好,并不简单,遇到了不少坑,很是心累。
第一个坑,如何判断手机有没有外置sdcard ?
第二个坑,如何处理外置sdcard的写权限?
从android 5.1 Lollipop, API level 22 开始,google对外置sdcard的写权限限制地更严格了,新增了DocumentFile这个类,在android 5.1 之前是没有这个限制的,可以不经过用户授权,app可以直接往外置sdcard新增/删除/重命名 文件和文件夹。 当API level >= 22, 需要调用:
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, 42);
getContentResolver().takePersistableUriPermission(treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION |
Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
https://stackoverflow.com/questions/26744842/how-to-use-the-new-sd-card-access-api-presented-for-android-5-0-lollipop#
打开系统自带的activity来让用户授权,用户授权后,对授权结果的处理,也比较麻烦,因为这个系统自带的activity可以允许用户只授权某个文件夹的写权限,而不是我们期望的整个外置sdcard的写权限。
public boolean isSdCardWritable() {
if (!hasExternalSdcard()) {
return false;
}
String tempFilePath = getExternalStoragePath() + File.separator + "test.txt";
File file = new File(tempFilePath);
try {
FileOutputStream output = new FileOutputStream(file, true);
output.close();
} catch (Exception e) {
// File not has permission
}
boolean result = file.canWrite();
if (file.exists()) {
file.delete();
}
if (!result && hasLollipop()) {
DocumentFile document = createDocumentFile(mContext, file, false);
result = document != null ? document.delete() : false;
}
return result;
}
public static boolean hasLollipop() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP;
}
public static DocumentFile createDocumentFile(@NonNull Context context, @NonNull final File file, final boolean isDirectory) {
if (file == null || context == null) {
return null;
}
boolean originalDirectory = false;
String baseFolder = null;
String fullPath = null;
String relativePath = null;
try {
fullPath = file.getCanonicalPath();
baseFolder = DeviceHelper.getInstance().getRealRoot(fullPath);
} catch (IOException e) {
return null;
} catch (Exception exception) {
originalDirectory = true;
}
if (baseFolder == null) {
return null;
}
if (!baseFolder.equals(fullPath)) {
relativePath = fullPath.substring(baseFolder.length() + 1);
} else {
originalDirectory = true;
}
Uri treeUri = SharedPreferencesHelper.getInstance().getRootSdCardUri();
if (treeUri == null) {
return null;
}
// Start with root of SD card and then parse through document tree.
try {
DocumentFile document = DocumentFile.fromTreeUri(context, treeUri);
if (document == null) {
return null;
}
if (originalDirectory) {
return document;
}
String[] parts = relativePath.split("\\/");
for (int i = 0; i < parts.length; i++) {
DocumentFile nextDocument = document.findFile(parts[i]);
if (nextDocument == null) {
if ((i < parts.length - 1) || isDirectory) {
nextDocument = document.createDirectory(parts[i]);
} else {
String fileNameWithoutExt = parts[i].replaceFirst("[.][^.]+$", "");
nextDocument = document.createFile(MIMETypeHelper.getMimeType(file), fileNameWithoutExt);
}
}
document = nextDocument;
}
return document;
} catch (SecurityException e) {
//no permission for createDocumentFile()
e.printStackTrace();
}
return null;
}
第三坑,分页处理,ES文件浏览器,File Commander, 都有分页功能,有一个用户场景,用户打开一个CAMERA文件夹,这个相册文件夹里有上万张照片,如果全部加载到内存,会有UI卡顿,这种情况需要分页。 ES文件浏览器,File Commander 这两个APP 怎么实现分页的,我们不太清楚,但是他们这种场景下,打开文件夹很快,感觉不到卡顿,一定实现了分页。
要实现分页功能,有两个比较靠谱的选择,
A:使用Java的File API,File.listFiles(), 先把文件夹下的所有子文件都加载到内存中,然后在从这块内存中借取一部分做为分页的结果,返回给调用者。
B:使用Sqlite的分页查询,查询 android.providers.media.MediaProvider 创建的file 表,这个file表包含所有外置的,内置的文件和文件夹记录。
A的好处是坑少很多,但没有B的分页速度快。文件总数越多,A和B的性能差距越大。
我们选择了B方案,首先如何区分file表的哪些记录是文件,哪些是文件夹? 这个问题在google, stackoverflow上都找不到答案,必须去看源代码,
https://android.googlesource.com/platform/packages/providers/MediaProvider/+/f5b95460c7b93029bf16634dbefe4185d461410a/src/com/android/providers/media/MediaProvider.java
从这个代码可以知道file的表结构,1320行, db.execSQL(“CREATE TABLE files (” +
从2701行, insertDirectory 这个函数可以知道,
values.put(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION);
如果是folder, 那么FileColumns.FORMAT 一定是 MtpConstants.FORMAT_ASSOCIATION
之前也尝试了根据size和mimetype列,来判断记录是否是folder,结果不行,因为虽然大多数folder的size都是0,但总有一些folder的size是大于零的, 虽然folder的mimetype列总是null,但总有一些文件,mimetype列也是null
最后还是在组长的提示下发现了这个关键的insertDirectory函数,我本来已经没招了,打算用笨办法,
1. 找到一个mimetype为null的文件
2. 把这个文件改为和一个文件夹同名
3. 写sql语句,这两个记录同时选出来,然后逐列对比,找出数值不同的列
选择方案B后,新增,删除,修改文件,文件夹最麻烦,如果用A方案,没有这些问题。
在android 7, 6, 5.1 做这些操作,需要额外的代码去更新android的file表,而且各个版本去刷新数据库的代码还不一样,更新的是内置还是外置的sdcard上的文件也有区别。处理好这些,特别不容易,ES文件浏览器做的最好,File Commander做的不如ES,
比如在android 5.1 上重命名外置sdcard一个文件夹,ES 会更新android的file表,而File Commander不会. 同时就算其他app把android的file表搞乱了,ES也能正常浏览所有sdcard上的文件,说明ES 不依赖 android的file表
public static void notifyMediaScan(File file, Context context) {
Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
Uri uri = Uri.fromFile(file);
intent.setData(uri);
context.sendBroadcast(intent);
}
static private void getChildrenRecursive(File folder, ArrayList output) {
File[] lst = folder.listFiles();
for (int i = 0; i < lst.length; i++) {
File f = lst[i];
output.add(f);
if (f.isDirectory()) {
getChildrenRecursive(f, output);
}
}
}
private static void scanByMediaConnection(Context context, String[] fileArr) {
MediaScannerConnection.scanFile(context, fileArr,
null, new MediaScannerConnection.OnScanCompletedListener() {
public void onScanCompleted(String path, Uri uri) {
Timber.d("media scan path: " + path);
}
});
}
//update "file" table created in android.providers.media.MediaProvider
public static void updateMediaFileDB(Context context, String filePath) {
File file = new File(filePath);
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1) {
notifyMediaScan(file, context);
} else {
scanByMediaConnection(context, new String[] {filePath});
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M && file.isDirectory()) {
ArrayList childrenFiles = new ArrayList<>();
if (file.list() != null) {
getChildrenRecursive(file, childrenFiles);
}
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1) {
for (File f : childrenFiles) {
notifyMediaScan(f, context);
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
String[] arr = new String[childrenFiles.size()];
for (int i = 0; i < arr.length; i++) {
arr[i] = childrenFiles.get(i).getAbsolutePath();
}
scanByMediaConnection(context, arr);
} else {
context.sendBroadcast(new Intent(Intent.ACTION_MEDIA_MOUNTED, Uri.parse("file://" + Environment.getExternalStorageDirectory())));
}
}
}
通过命令行手动更新 android 系统 media 数据库
adb -s cb46b23f shell am broadcast -a android.intent.action.MEDIA_MOUNTED -d file:///sdcard