android文件管理器模块开发,细节整理

由于项目需要,要求开发一个类似于“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

你可能感兴趣的:(android)