Как получить доступ ко всем SD-картам, используя новый Lollipop API?
фон
Начиная с Lollipop, приложения могут получить доступ к реальным SD-картам (после того, как они были недоступны в Kitkat и официально не поддерживались в предыдущих версиях), как я спросил о .
Проблема
Поскольку стало довольно редко видеть устройство Lollipop, поддерживающее SD-карту, и потому что эмулятор действительно не обладает способностью (или не так ли?), чтобы эмулировать поддержку SD-карты, мне потребовалось довольно много времени чтобы проверить его.
В любом случае кажется, что вместо обычного класса File для доступа к SD-карте (после получения разрешения для него) вам нужно использовать Uris для него, используя DocumentFile.
Это ограничивает доступ к обычным путям, поскольку я не могу найти способ конвертировать Uris в пути и наоборот (плюс это довольно раздражает). Это также означает, что я не знаю, как проверить, доступны ли текущие SD-карты, поэтому я не знаю, когда спросить у пользователя разрешение на чтение/запись на него (или на них).
Что я пробовал
В настоящее время я получаю пути ко всем SD-картам:
/**
* returns a list of all available sd cards paths, or null if not found.
*
* @param includePrimaryExternalStorage set to true if you wish to also include the path of the primary external storage
*/
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public static List<String> getExternalStoragePaths(final Context context,final boolean includePrimaryExternalStorage)
{
final File primaryExternalStorageDirectory=Environment.getExternalStorageDirectory();
final List<String> result=new ArrayList<>();
final File[] externalCacheDirs=ContextCompat.getExternalCacheDirs(context);
if(externalCacheDirs==null||externalCacheDirs.length==0)
return result;
if(externalCacheDirs.length==1)
{
if(externalCacheDirs[0]==null)
return result;
final String storageState=EnvironmentCompat.getStorageState(externalCacheDirs[0]);
if(!Environment.MEDIA_MOUNTED.equals(storageState))
return result;
if(!includePrimaryExternalStorage&&VERSION.SDK_INT>=VERSION_CODES.HONEYCOMB&&Environment.isExternalStorageEmulated())
return result;
}
if(includePrimaryExternalStorage||externalCacheDirs.length==1)
{
if(primaryExternalStorageDirectory!=null)
result.add(primaryExternalStorageDirectory.getAbsolutePath());
else
result.add(getRootOfInnerSdCardFolder(externalCacheDirs[0]));
}
for(int i=1;i<externalCacheDirs.length;++i)
{
final File file=externalCacheDirs[i];
if(file==null)
continue;
final String storageState=EnvironmentCompat.getStorageState(file);
if(Environment.MEDIA_MOUNTED.equals(storageState))
result.add(getRootOfInnerSdCardFolder(externalCacheDirs[i]));
}
return result;
}
private static String getRootOfInnerSdCardFolder(File file)
{
if(file==null)
return null;
final long totalSpace=file.getTotalSpace();
while(true)
{
final File parentFile=file.getParentFile();
if(parentFile==null||parentFile.getTotalSpace()!=totalSpace)
return file.getAbsolutePath();
file=parentFile;
}
}
Вот как я проверяю, что может сделать Uris:
final List<UriPermission> persistedUriPermissions=getContentResolver().getPersistedUriPermissions();
Вот как получить доступ к SD-картам:
startActivityForResult(new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE),42);
public void onActivityResult(int requestCode,int resultCode,Intent resultData)
{
if(resultCode!=RESULT_OK)
return;
Uri treeUri=resultData.getData();
DocumentFile pickedDir=DocumentFile.fromTreeUri(this,treeUri);
grantUriPermission(getPackageName(),treeUri,Intent.FLAG_GRANT_READ_URI_PERMISSION|Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(treeUri,Intent.FLAG_GRANT_READ_URI_PERMISSION|Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
Вопросы
-
Можно ли проверить, доступны ли текущие SD-карты, а какие нет, и каким-то образом попросить пользователя получить разрешение на них?
-
Есть ли официальный способ конвертировать между DocumentFile uris и реальными путями? Я нашел этот ответ, но он разбился в моем случае, плюс он выглядит hack-y.
-
Можно ли запросить разрешение у пользователя о конкретном пути? Может быть, даже показать только диалог "вы принимаете" да/нет? "?
-
Можно ли использовать обычный API файлов вместо API DocumentFile после предоставления разрешения?
-
Учитывая путь к файлу/файлу, можно ли просто запросить разрешение на доступ к нему (и проверить, было ли оно задано раньше) или его корневой путь?
-
Можно ли сделать эмулятор SD-картой? В настоящее время упоминается "SD-карта", но она работает как основное внешнее хранилище, и я бы хотел протестировать ее с использованием вторичного внешнего хранилища, чтобы попробовать и использовать новый API.
Я думаю, что для некоторых из этих вопросов один помогает многому ответить другому.
Ответы
Ответ 1
Можно ли проверить, доступны ли текущие SD-карты, а какие нет, и как-то попросить пользователя получить разрешение на них?
Есть три части: обнаружение, какие карты есть, проверка наличия карты и запрос доступа.
Если производители устройств являются хорошими парнями, вы можете получить список "внешнего" хранилища, вызвав getExternalFiles с аргументом null
( см. соответствующий javadoc).
Они могут быть не хорошими парнями. И не у всех есть новейшая, самая горячая, безошибочная версия ОС с регулярными обновлениями. Таким образом, могут быть некоторые каталоги, которые там не указаны (например, хранилище OTG USB и т.д.). Если у вас есть сомнения, вы можете получить полный список OS-монстров из файла /proc/self/mounts
. Этот файл является частью ядра Linux, этот формат документирован здесь. Вы можете использовать содержимое /proc/self/mounts
в качестве резервной копии: проанализировать его, найти используемые файловые системы (fat, ext3, ext4 и т.д.) И удалить дубликаты с выводом getExternalFilesDirs
. Предложите оставшиеся опции для пользователей под некоторым ненавязчивым именем, что-то вроде "разных справочников". Это даст вам все возможное внешнее, внутреннее, любое хранилище.
РЕДАКТИРОВАТЬ: после предоставления рекомендации выше я, наконец, попытался следовать за ним сам. До сих пор он работал нормально, но будьте осторожны, но вместо /proc/self/mounts
вам лучше разобрать /pros/self/mountinfo
(первый из них по-прежнему доступен в современной Linux, но позже - более эффективная и надежная замена). Также учитывайте проблемы атомарности при допущениях, основанных на содержимом списка монтирования.
Вы можете наивно проверить, если каталог доступен для чтения/записи, вызывая canRead и canWrite. Если это удастся, нет смысла делать дополнительную работу. Если это не так, у вас либо есть постоянное разрешение Uri, либо нет.
"Получение разрешения" - уродливая часть. AFAIK, нет никакого способа сделать это чисто в инфраструктуре SAF. Intent.ACTION_PICK
звучит как нечто, что может сработать (поскольку он принимает Ури, из которого следует выбирать), но это не так. Возможно, это можно считать ошибкой и должно быть сообщено в Android-трекер ошибок как таковой.
Учитывая путь к файлу/файлу, можно ли просто запросить разрешение на доступ к нему (и проверить, было ли оно задано раньше) или его корневой путь?
Это для ACTION_PICK
. Опять же, сборщик SAF не поддерживает ACTION_PICK
из коробки. Сторонние файловые менеджеры могут, но очень немногие из них фактически предоставят вам реальный доступ. Вы можете сообщить об этом как об ошибке, если вам это нравится.
EDIT: этот ответ был написан до выхода Android Nougat. Начиная с API 24, по-прежнему невозможно запросить мелкий доступ к определенному каталогу, но по крайней мере вы можете динамически запрашивать доступ ко всему тому: определить том, содержащий файл, и запрос доступа с помощью getAccessIntent с аргументом null
(для вторичных томов) или путем запроса WRITE_EXTERNAL_STORAGE
(для основного тома).
Есть ли официальный способ конвертировать между DocumentFile uris и реальными путями? Я нашел этот ответ, но он разбился в моем случае, плюс он выглядит взломанным.
Нет. Никогда. Вы навсегда останетесь подчиненными капризам разработчиков Access Access Framework! злой смех
На самом деле существует гораздо более простой способ: просто откройте Uri и проверьте расположение файловой системы созданного дескриптора (для простоты, только версия Lollipop):
public String getFilesystemPath(Context context, Uri uri) {
ContentResolver res = context.getContentResolver();
String resolved;
try (ParcelFileDescriptor fd = res.openFileDescriptor(someSafUri, "r")) {
final File procfsFdFile = new File("/proc/self/fd/" + fd.getFd());
resolved = Os.readlink(procfsFdFile.getAbsolutePath());
if (TextUtils.isEmpty(resolved)
|| resolved.charAt(0) != '/'
|| resolved.startsWith("/proc/")
|| resolved.startsWith("/fd/"))
return null;
} catch (Exception errnoe) {
return null;
}
}
Если приведенный выше метод возвращает местоположение, вам все равно нужен доступ, чтобы использовать его с помощью File
. Если он не возвращает местоположение, рассматриваемый Uri не относится к файлу (даже временному). Это может быть сетевой поток, Unix-канал, что угодно. Вы можете получить версию метода выше для более старых версий Android из этого ответа. Он работает с любым Uri, любым ContentProvider, а не только с SAF, если Uri можно открыть с помощью openFileDescriptor
(например, от Intent.CATEGORY_OPENABLE
).
Обратите внимание: этот подход выше можно считать официальным, если вы рассматриваете какую-либо часть официального Linux API как такового. Многие программы Linux используют его, и я также видел, что он используется некоторым кодом AOSP (например, в тестах Launcher3).
ИЗМЕНИТЬ: Android Nougat внедрил число изменений безопасности, в первую очередь изменив права доступа к приватным каталогам приложения. Это означает, что, поскольку API 24, вышеприведенный фрагмент будет всегда терпеть неудачу с Исключением, когда Uri ссылается на файл внутри частной папки приложения. Это так, как предполагалось: вы больше не должны знать этот путь вообще. Даже если вы каким-то образом определите путь к файловой системе другими способами, вы не сможете получить доступ к файлам, используя этот путь. Даже если другое приложение взаимодействует с вами и изменяет разрешение файла на читаемый мир, вы все равно не сможете получить к нему доступ. Это связано с тем, что Linux не разрешает доступ к файлам, если у вас нет доступа для поиска к одному из каталогов в пути. Таким образом, получение дескриптора файла из ContentProvider является единственным способом доступа к ним.
Можно ли использовать обычный API файлов вместо API DocumentFile после предоставления разрешения?
Вы не можете. Не по крайней мере на Нуге. Нормальный файл API переходит в ядро Linux для разрешений. Согласно ядру Linux, ваша внешняя SD-карта имеет ограничительные разрешения, которые не позволяют вашему приложению использовать ее. Разрешения, предоставляемые Storage Access Framework, управляются SAF (IIRC, хранящимся в некоторых файлах xml), и ядро ничего не знает о них. Чтобы получить доступ к внешнему хранилищу, вы должны использовать промежуточную сторону (Storage Access Framework). Обратите внимание, что в ядре Linux есть собственный механизм управления доступом к поддеревьям каталога (он называется bind-mounts), но создатели среды доступа к хранилищу либо не знаю об этом или не хочу его использовать.
Вы можете получить некоторую степень доступа (почти все, что можно сделать с помощью File
), используя файловый дескриптор, созданный из Uri. Я рекомендую вам прочитать этот ответ, он может иметь некоторую полезную информацию об использовании дескрипторов файлов в целом и в отношении Android.
Ответ 2
Это ограничивает доступ к обычным путям, поскольку я не могу найти способ конвертировать Uris в пути и наоборот (плюс это довольно раздражает). Это также означает, что я не знаю, как проверить, доступны ли текущие SD-карты, поэтому я не знаю, когда спросить у пользователя разрешение на чтение/запись на него (или на них).
Начиная с API 19 (KitKat) непубличный класс Android StorageVolume может использоваться для получения ответов на ваш вопрос с помощью рефлексии:
public static Map<String, String> getSecondaryMountedVolumesMap(Context context) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Object[] volumes;
StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
Method getVolumeListMethod = sm.getClass().getMethod("getVolumeList");
volumes = (Object[])getVolumeListMethod.invoke(sm);
Map<String, String> volumesMap = new HashMap<>();
for (Object volume : volumes) {
Method getStateMethod = volume.getClass().getMethod("getState");
String mState = (String) getStateMethod.invoke(volume);
Method isPrimaryMethod = volume.getClass().getMethod("isPrimary");
boolean mPrimary = (Boolean) isPrimaryMethod.invoke(volume);
if (!mPrimary && mState.equals("mounted")) {
Method getPathMethod = volume.getClass().getMethod("getPath");
String mPath = (String) getPathMethod.invoke(volume);
Method getUuidMethod = volume.getClass().getMethod("getUuid");
String mUuid = (String) getUuidMethod.invoke(volume);
if (mUuid != null && mPath != null)
volumesMap.put(mUuid, mPath);
}
}
return volumesMap;
}
Этот метод дает вам все смонтированные неосновные хранилища (включая USB OTG) и отображает их UUID на их путь, который поможет вам преобразовать DocumentUri в реальный путь (и наоборот):
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public static String convertToPath(Uri treeUri, Context context) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
String documentId = DocumentsContract.getTreeDocumentId(treeUri);
if (documentId != null){
String[] split = documentId.split(":");
String uuid = null;
if (split.length > 0)
uuid = split[0];
String pathToVolume = null;
Map<String, String> volumesMap = getSecondaryMountedVolumesMap(context);
if (volumesMap != null && uuid != null)
pathToVolume = volumesMap.get(uuid);
if (pathToVolume != null) {
String pathInsideOfVolume = split.length == 2 ? IFile.SEPARATOR + split[1] : "";
return pathToVolume + pathInsideOfVolume;
}
}
return null;
}
Кажется, Google не собирается давать нам лучший способ справиться со своим уродливым SAF.
EDIT: Кажется, этот подход бесполезен в Android 6.0...