В МойОфис мы разрабатываем iOS- и Android-приложения цифрового рабочего пространства Squadus с помощью кроссплатформенного фреймворка React Native. В функциональность нашего приложения входит загрузка и отправка различных вложений другим пользователям.
В какой-то момент мы получили фидбэк, что пользователи с Android не могут отправить медиафайлы и посмотреть превью. Наши доблестные QA-инженеры выяснили, что проблема напрямую связана с тем, какая именно версия Android стоит у пользователей. Сложности начинались с версии 13 — оказалось, что Android добавил новые разрешения для повышения безопасности приложений.
Под катом рассказываю, как работают новые Permissions, как Android рекомендует реализовывать запрос к пользователю, и каким образом наша команда решила проблемы при реализации новых разрешений. Думаю, наш опыт будет интересен специалистам, погруженным в тему Permissions на Android, да и вообще всем React Native и Android-разработчикам, так как особенности версионирования разрешений будут жить с нами ещё долго ?.
Привет, Хабр! Меня зовут Вячеслав Чащухин, я разработчик в МойОфис. Занимаюсь мобильной версией Squadus — цифрового рабочего пространства для совместной работы и деловых коммуникаций. Ниже я рассказываю, как изменился пользовательский опыт с новыми разрешениями Android, а также, что нужно менять со стороны разработки.
Изменения в работе разрешений
Обычно для работы с файловой системой в React Native мы просто используем rn-fetch-blob
, react-native-fs
и другие подобные библиотеки. Как известно, для работы с файлами на Android нужно запросить определенные разрешения, и, начиная с Android 13, этот список был обновлен.
Давайте рассмотрим эти разрешения. Вот они, слева направо:
READ_MEDIA_IMAGES
READ_MEDIA_VIDEO
READ_MEDIA_AUDIO
Как видно из названий, разрешения помогают определить, есть ли у вас доступ к файлам соответствующего типа.
В React Native мы можем запрашивать разрешения у пользователей по соответствующим именам. Для этого используется PermissionsAndroid, который инициирует опрос разрешений у системы, а она в свою очередь у пользователя. Ниже представлен пример, как можно разделить запрос новых и старых разрешений:
if (Platform.OS === 'android' && androidVersion >= 33) {
// Запрашиваем разрешения для Android 13 и выше (API level 33+)
// Будет показан нативный алерт
const requestedPermissions = [
PermissionsAndroid.PERMISSIONS.READ_MEDIA_IMAGES,
PermissionsAndroid.PERMISSIONS.READ_MEDIA_VIDEO,
PermissionsAndroid.PERMISSIONS.READ_MEDIA_AUDIO,
];
const results = await PermissionsAndroid.requestMultiple(
requestedPermissions,
);
const needsPermissions = Object.values(results).every(
(result) =>
result === PermissionsAndroid.RESULTS.DENIED ||
result === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN,
);
if(needsPermissions){
// Если прав нет, то в интерфейсе приложения запрашиваем у пользователя разрешение на получение прав
}
}
После получения разрешений у нас появляется доступ к некоторым файлам в системе. Исходя из имен запрашиваемых разрешений – доступ к изображениям, аудио и видео. Важно учитывать, что новые разрешения запрашиваются при установке приложения, поэтому их нужно запрашивать у пользователя непосредственно перед действиями, требующими доступ к файловой системе.
Как мы видим, система даёт доступ к группе файлов, распределяя их по расширениям, но как насчет чего-то вроде docx, apk и так далее? Исходя из методов, которые мы применили ранее, можно подумать, что нам требуется очередное разрешение на чтение нужного вида файлов. Но так ли всё просто?
Окей, обратимся к официальной документации Android.
Вот несколько интересных моментов, которые я хотел бы выделить:
Все файлы с другими расширениями (не изображения, видео, аудио) следует называть non-media
Система может предоставить доступ ко всем файлам, если мы запросим разрешение
MANAGE_EXTERNAL_STORAGEТакже мы можем получить доступ к этим дополнительным файлам и каталогам, используя [MediaStoreAPI] или прямые пути к файлам
Давайте по порядку — почему бы нам не использовать MANAGE_EXTERNAL_STORAGE? Вроде бы всё отлично, мы получаем доступ ко всем файлам, и нам не придётся запариваться с остальными разрешениями. Как раньше: запросил один раз – и всё. Но у этого метода есть некоторые ограничения.
Если в вашем AndroidManifest.xml
есть MANAGE EXTERNAL_STORAGE, то Google однозначно будет требовать разъяснений: почему и зачем вам нужно это разрешение.
У Google есть примеры, какие приложения могут использовать это разрешение:
Файловые менеджеры
Резервное копирование и восстановление приложений
Антивирусы
Приложения для управления документами
Поиск файлов на устройстве
Шифрование дисков и файлов
Если ваше приложении подходит под одно из описаний выше, то будьте готовы к «допросу» при выкатке приложения в Google Play (^~^).
Я думаю, что многие приложения не подпадают под описанные выше, и тогда следует использовать так называемые privacy-friendly APIs, такие как [Storage Access Framework] или [MediaStore API].
Давайте разберёмся с этими фреймворками. Так нам будет легче понимать, что находится в библиотеках, и исправлять ошибки будет гораздо проще.
Storage Access Framework
«Эта платформа позволяет взаимодействовать с системой выбора поставщика документов и выбирать конкретные документы и другие файлы для вашего приложения для создания, открытия или изменения» — небольшой отрывок из официальной документации.
Вот варианты использования платформы:
Действие |
Intent соответствующий действию |
Примечание для ACTION_OPEN_DOCUMENT_TREE
[ACTION_OPEN_DOCUMENT_TREE] доступен на Android 5.0 (уровень API 21) и выше. Этот вариант позволяет выбирать определенный каталог, предоставляя вашему приложению доступ ко всем файлам и подкаталогам в этом каталоге. Посмотреть всю документацию подробно можно здесь.
Например, подобный интент используется в react-native-document-picker.
Для этого вида интента не обязательно иметь разрешения для доступа к файловой системе, так как пользователь может безопасно просматривать файлы в нативном окошке системы. Приложение, открывшее интент, может считывать действия пользователя в этом окне.
API MediaStore
Вновь для начала обратимся к официальной документации: «Платформа предоставляет оптимизированную проиндексированную коллекцию мультимедиа, называемую media store. Он позволяет пользователям легко извлекать и обновлять медиафайлы. Даже после удаления вашего приложения эти файлы остаются на устройстве пользователя».
Ниже — выжимка по теме (^~^).
Коллекции MediaStore
Система автоматически сканирует внешнее хранилище устройства и добавляет медиафайлы в следующие чётко определенные коллекции:
Тип |
Папка |
MediaStore |
Изображение |
|
|
Видео |
|
|
Аудио |
|
|
Скачанные файлы |
|
|
Все файлы |
- |
|
Примечание для папки Downloads
Если ваше приложение хочет получить доступ к файлам в MediaStore.Downloads
, которые приложение не создавало, нужно использовать платформу доступа к хранилищу (Storage Access Framework).
Примечание для MeadiStore.Files
Содержимое фреймворка зависит от того, использует ли ваше приложение хранилище с ограниченной областью действия. Доступно в приложениях для Android 10 или выше.
Если это хранилище включено, в коллекции отображаются только изображения, видео и аудиофайлы, созданные вашим приложением. Большинству разработчиков не нужно использовать MediaStore.Files
для просмотра медиафайлов из других приложений. Но если у вас есть особые требования для этого, можете объявить разрешение READ_EXTERNAL_STORAGE.
Однако официальная документация рекомендует нам использовать API MediaStore
для открытия файлов, которые ваше приложение не создавало. Если хранилище с ограниченной областью недоступно или не используется, в коллекции отображаются все типы медиафайлов.
Разрешения для доступа к файловой системе
Вот описание известных разрешений для доступа к файлам:
<!-- Разрешения для Android 13 и выше (уровень API 33+) -->
<!-- Требуется только в том случае, если вашему приложению нужен доступ к изображениям, созданным другими приложениями -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Требуется, только если вашему приложению нужен доступ к видео, созданным другими приложениями -->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Требуется, только если вашему приложению нужен доступ к аудиофайлам, созданным другими приложениями -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Это разрешение должно быть для чтения файлов/папок на Android 9 или более поздней версии, или приложение временно отказалось от ограниченного хранилища -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- Это разрешение устарело с API 19 или выше -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
Sharing
Некоторые приложения позволяют пользователям обмениваться медиафайлами друг с другом. Например, с помощью приложений для соцсетей можно делиться фотографиями и видео с друзьями.
Чтобы поделиться медиафайлами, используйте URI content: //
, как рекомендовано в руководстве по созданию поставщика контента.
Пример:
Share Extension (Activity) в React Native проекте.
Ниже приведён пример модуля, который возвращает нам путь к файлу.
@ReactMethod
public void data(Promise promise) {
WritableMap map = Arguments.createMap();
WritableArray items = Arguments.createArray();
String text = "";
String type = "";
String action = "";
Activity currentActivity = getCurrentActivity();
if (Intent.ACTION_SEND.equals(intent.getAction())) {
Intent intent = currentActivity.getIntent();
Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri != null) {
try {
text = "file://" + RealPathUtil.getRealPathFromURI(currentActivity, uri);
} catch (Exception e) {
e.printStackTrace();
}
map.putString("filePath", text);
map.putString("mediaUri", uri.toString());
items.pushMap(map);
}
return items;
}
Пройдёмся построчно.
Эта строка возвращает uri из MediaStore, который можно использовать для получения доступа к файлу (например, для копирования):
// Получаем uri из MediaStore в формате content://
Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
Здесь мы помещаем наш uri в воображаемый класс RealPathUtil, который может возвращать абсолютный путь к файлу:
// RealPathUtil
text = "file://" + RealPathUtil.getRealPathFromURI(currentActivity, uri);
А теперь мы переносим наши данные в результирующий объект:
// Далее это отправим на сторону React Native
map.putString("filePath", text);
map.putString("mediaUri", uri.toString());
После этого, мы вызываем data()
на стороне React Native. И получаем наш файл:
const data : FileType[] = await ShareExt.data();
Вот пример данных, которые мы получаем:
{
filePath : "file:///sdcard/media/audio/ringtones/GetupGetOut.mp3"
mediaUri : "content://media/external/audio/media/710"
}
Теперь мы можем использовать эти данные, например, для загрузки на сервер, воспроизведения или показа медиафайлов и так далее.
В моём случае используется rn-fectch-blob
для загрузки файлов на сервер. В примере ниже создаём данные для multipart/formData
и отправляем их в RNFetchBlob
.
const formData = {
filename: fileInfo.name,
name: 'file',
type: mime.lookup(file.path),
uri: RNFetchBlob.wrap(decodeURI(file.path));,
};
await RNFetchBlob.fetch(method, url, headers, formData);
Это отлично работает для версии Android SDK api < 33. Но с 33 и выше RNFetchBlob
возвращает ошибку EACCESS и не может ничего сделать с файлами, переданными для отправки.
Чтобы это пофиксить мы можем использовать mediaUri
. Для соответствия новому флоу, если файл не мультимедийный, мы не можем получить прямой доступ. Поэтому нам нужно переместить файл во внутреннюю папку, и только после этого мы можем его использовать.
Ниже пример того, как копируется файл с помощью uri из MediaStore во внутреннюю папку:
import * as FileSystem from 'react-native-fs'
const configFileInternalFromMediaStore = async (filePath, mediaStoreUri) => {
return FileSystem.copyFile(mediaStoreUri, filePath);
};
// ... some code
let formData = {
filename: fileInfo.name,
name: 'file',
type: mime.lookup(file.path),
uri: RNFetchBlob.wrap(decodeURI(file.path));,
};
const despath = RNFS.dirs.CachesDirectoryPath + '/temp/' + fileInfo.name,
if (fileInfo?.mediaStoreUri && isAndroid) {
await configFileInternalFromMediaStore(
despath,
fileInfo.mediaStoreUri,
);
formData.uri = despath
}
const response = await RNFetchBlob.fetch(method, url, headers, formData);
Удаляем копию из кэша:
if(resonse.success){
FileSystem.unlink(despath)
}
И после этого наш файл будет успешно отправлен на сервер. Отлично!
MIUI XIAOMI SecurityShare
Это интересный случай с работой ShareExtension на устройствах с MIUI. Если вы предоставляете общий доступ к некоторым файлам из приложений, изначально созданных для MIUI (вроде файлового менеджера или галереи), то путь к файлу выглядит так: /com.miui.gallery/cache/SecurityShare/1657350098978.jpg
.
Это происходит из-за реализации некоторыми приложениями так называемого SecurityShare хранилища. По сути, это просто внешняя папка, созданная внутри директории галереи. Таким образом MIUI пытается защититься от лишних действий с папкой, откуда изначально в данном случае было взято изображение, так как галерея собирает изображения со всего устройства.
Мы не можем получить доступ напрямую, как, например, Cursor cursor = getContentResolver().query (uri, columns, ...)
. Допустим, мы пытаемся получить доступ к файловым данным из MediaStore и делаем что-то вроде этого:
final int index = cursor.getColumnIndex(MediaStore.MediaColumns.DATA);
String path = index > -1 ? cursor.getString(index) : null;
// Получаем EACCESS error. Permission denied
Так как MediaStore видит, что мы стучимся не в разрешенные папки, о которых мы говорили выше, то получаем отказ от системы.
В ходе исследования стало понятно, что стоит использовать InputStream
для копирования в каталог кэша приложения и уже после этого использовать файл:
context.getContentResolver().openInputStream(uri);
Затем используем FileOutputStream
для записи файла в нужный каталог. В моём случае это был каталог кэша.
Пример:
public static Uri getFilePathFromUri(Uri uri) throws IOException {
String fileName = getFileName(uri);
File file = new File(myContext.getExternalCacheDir(), fileName);
file.createNewFile();
try (OutputStream outputStream = new FileOutputStream(file);
InputStream inputStream = myContext.getContentResolver().openInputStream(uri)) {
FileUtil.copyStream(inputStream, outputStream);
outputStream.flush();
}
return Uri.fromFile(file);
}
Я описал всё, что смог отыскать в документации и на просторах stackoverflow, чтобы разобраться в теме. В целом радует, что современные ОС стараются создавать безопасную среду для пользователя. Приятно осознавать, что каждое такое улучшение уменьшает риск сливов коллекции запретных мемчиков ?.
Список полезных ссылок:
Комментарии (6)
HuziMuzi
25.10.2024 12:23Отличная статья, спасибо за разъяснение изменений в управлении разрешениями на Android 13
hapcode
25.10.2024 12:23В целом радует, что современные ОС стараются создавать безопасную среду для пользователя.
Ага, если бы они еще API нормальный делали. В google похоже считают, что кроме видосиков, фоток и музыки других файлов нет. Например, через Storage Access Framework нельзя открыть файл sqlite. Точнее на чтение еще как-то вроде можно, но на запись уже не выходит.
Как самому сформировать путь к файлам внутри директории открытой с помощью ACTION_OPEN_DOCUMENT_TREE тоже неясно. Приходиться делать что-то вроде Uri.parse(pickedDir.uri + "/nested_dir/filename".replace("/", "%2F")). И не факт что-это будет везде раотать. Можно что-то c findFile сделать, но это же так здорово и быстро, перебирать все файлы в поисках нужного /сарказм.В общем, я очень далеко не эксперт в android разработке, но на мой взгляд, такие кривые API еще поискать надо.
SalaarFiend Автор
25.10.2024 12:23Согласен, что API ещё далеко от идеала. Но думаю первые шаги в этом направлении сделаны, как минимум уже отделили работу с медиа файлами, от не медийных.
Ждем дальнейший улучшений)
malinichevvv
Ошибки в коде. resonse и подобное
SalaarFiend Автор
Вот он, внимательный читатель)
Спасибо, поправлю)