Android Java应用扫描外部存储目录时无法检出xml等文本文件
问题场景
正在开发一款用于测试OpenCV haarcascades的Android Java应用,遇到无法识别外部存储下.xml文件的问题。
已声明权限
Manifest中已添加如下权限:
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
核心遍历代码
用于获取外部存储指定目录下文件名与路径的代码实现如下:
public ArrayList<String []> getLongAndShortFileHandles(String root_dir) { ArrayList<String[]> ret = new ArrayList<>(); String path = Environment.getExternalStorageDirectory().toString()+"/"+root_dir; File directory = new File(path); File[] files = directory.listFiles(); Log.e(TAG, "getLongAndShortFileHandles: Has dir "+path+", with "+((files==null) ? "null" : files.length) + " number of files"); for (int i = 0; i < Objects.requireNonNull(files).length; i++) { Log.e(TAG, "getLongAndShortFileHandles: HAS FILE "+files[i].getName()); String [] temp = new String [] {files[i].getAbsolutePath(),files[i].getName()}; ret.add(temp); } return ret; }
调用逻辑
通过上述方法填充自定义RecyclerView子类的调用代码如下:
this.cascade_selector = (RecyclerView) findViewById(R.id.cascade_view); this.cascade_selector.setAdapter(new FileAdaptah(getLongAndShortFileHandles("cascade_app/cascades"),this, CASCADE)); this.xml_ano_selector = (RecyclerView) findViewById(R.id.annotation_view); this.xml_ano_selector.setAdapter(new FileAdaptah(getLongAndShortFileHandles("cascade_app/annotations"),this, ANNOTATION_XML));
故障现象
运行应用后仅能显示第二次调用(对应cascade_app/annotations目录)的结果,第一次调用(对应cascade_app/cascades目录)无内容输出。将第一次调用的目录替换为DCIM、Music等其他系统目录,可正常显示文件,但只要任意目录下添加.xml文件,该文件就不会被显示。
排查记录
- 补充1:测试不同目录与文件后发现,问题不止出在.xml文件上,所有可测试的ASCII编码文本格式文件都无法被检出;而同目录下的.jpg、.wav、.mp4、.bin等二进制文件都能正常被识别。
- 补充2:验证发现如果将任意文本文件的扩展名改为二进制类扩展名(例如将
50.xml重命名为50.jpg),文件就能被遍历逻辑检测到。尝试了不同的文件名过滤器,未解决问题;也尝试改用Directory Stream实现文件遍历,同样无效,对应代码如下:@RequiresApi(api = Build.VERSION_CODES.O) public ArrayList<String[]> getFiles(String suffix, String root_dir){ ArrayList<String[]> ret = new ArrayList<String[]>(); try { root_dir = Environment.getExternalStorageDirectory().toString() + "/"+ root_dir; Path dirName = Paths.get(root_dir); DirectoryStream<Path> paths = Files.newDirectoryStream(dirName, "*.pdf"); paths.forEach(path -> { Log.e(TAG, "getFiles: HAS FILE : "+path.toString()); if (path.endsWith(suffix)) { String sPath = path.toString(); String[] splitted = sPath.split(File.separator); ret.add(new String[]{sPath, splitted[splitted.length - 1]}); } }); } catch (IOException exception) { exception.printStackTrace(); } return ret; } - 补充3:注意到上述
getFiles方法的代码中遗留了之前复制时的*.pdfglob表达式,尝试修改或移除该表达式,问题仍然存在。 - 补充4:编写测试代码直接访问目标文件,对应代码如下:
测试发现直接通过this.test = (Button) findViewById(R.id.select_to_test); this.test.setOnClickListener(v -> { String path = Environment.getExternalStorageDirectory().toString()+"/cascade_app/cascades/50.xml"; Intent intent = new Intent(v.getContext(),CheckCascade.class); intent.putExtra("fh",path); File debugFile = new File(path); Toast.makeText(this, "File "+path+" does "+((debugFile.exists())? "exist":"not exist"), Toast.LENGTH_SHORT).show(); startActivity(intent); });File.exists()判断可以正常检测到该.xml文件的存在。
开发环境
- Android Studio版本:4.1.2
- target SDK:30
- minSdk:19
- 测试设备:运行Android 11的三星Galaxy A40
问题原因
该现象并非系统框架Bug,是Android 10+分区存储(Scoped Storage)机制在三星定制OneUI系统上的特殊表现,对应行为逻辑如下:
- 当应用targetSdk >=29(项目targetSdk为30)时,默认启用分区存储规则,无法再通过传统的
Environment.getExternalStorageDirectory()路径下的File.listFiles()方法,直接枚举公共目录下所有类型的文件 - 三星OneUI对该规则的实现做了定制:枚举目录时,仅返回已被系统MediaStore索引的二进制媒体类型文件(图片、音频、视频、通用二进制),所有被归类为文档/文本类的文件(.xml、.txt、.json等)会被从返回的文件列表中过滤掉
- 直接通过完整全路径构造
File对象调用exists()/canRead()时,系统会直接走底层文件系统校验,不会触发枚举过滤逻辑,因此可以正常检测到目标文件存在,和补充4的测试结果完全吻合 - 改用Directory Stream遍历无效,是因为该API同样走应用层目录枚举逻辑,会被系统的过滤规则拦截
解决方案
按照适配成本从低到高可选择以下方案:
1. 临时调试方案
仅用于开发阶段快速验证逻辑,不适合正式发布:
在Manifest的<application>节点下添加属性,强制应用请求旧版存储权限,绕过部分分区存储限制:
<application android:requestLegacyExternalStorage="true" android:preserveLegacyExternalStorage="true"> ... </application>
注意:该方案在Android 11及以上的三星设备上无法完全生效,系统依然会对文本类文件做枚举过滤,仅能作为临时调试手段
2. 全版本兼容的官方适配方案
放弃直接通过文件系统路径枚举外部存储目录的逻辑,改用系统提供的存储访问框架(SAF)让用户手动选择目标级联文件目录,通过持久化Uri权限遍历目录下所有类型的文件,不会被系统过滤。
核心逻辑参考:
- 启动目录选择器,让用户选中指定的
cascade_app/cascades和cascade_app/annotations目录Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); startActivityForResult(intent, REQUEST_CODE_SELECT_DIR); - 拿到用户授予的目录Uri后,通过ContentResolver查询目录下所有子文件,无需区分文件类型
Uri treeUri = resultData.getData(); Uri docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)); // 查询目录下所有子文件 try (Cursor cursor = getContentResolver().query( DocumentsContract.buildChildDocumentsUriUsingTree(docUri, DocumentsContract.getDocumentId(docUri)), new String[]{ DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_DOCUMENT_ID }, null, null, null)) { // 遍历cursor即可拿到所有文件的名称和访问Uri,包含文本类文件 } - 调用
takePersistableUriPermission保存用户授予的目录权限,应用重启后无需用户重复选择。
3. OpenCV场景最优适配方案
完全不需要申请外部存储读取权限:将需要测试的haarcascade xml文件直接放到应用的assets目录下,OpenCV的CascadeClassifier支持直接加载本地文件流,不存在系统存储限制问题。
示例加载逻辑:
// 将assets下的级联文件拷贝到应用私有存储目录 File cascadeFile = new File(getExternalFilesDir(null), "haarcascade_frontalface.xml"); InputStream is = getAssets().open("haarcascades/haarcascade_frontalface.xml"); FileOutputStream fos = new FileOutputStream(cascadeFile); byte[] buffer = new byte[1024]; int len; while ((len = is.read(buffer)) != -1) { fos.write(buffer, 0, len); } fos.close(); is.close(); // 加载级联分类器 CascadeClassifier classifier = new CascadeClassifier(cascadeFile.getAbsolutePath());
内容的提问来源于stack exchange,提问作者Nikolai Savulkin

