В МойОфис мы разрабатываем 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

«Эта платформа позволяет взаимодействовать с системой выбора поставщика документов и выбирать конкретные документы и другие файлы для вашего приложения для создания, открытия или изменения» — небольшой отрывок из официальной документации.

Вот варианты использования платформы:

Примечание для ACTION_OPEN_DOCUMENT_TREE

[ACTION_OPEN_DOCUMENT_TREE] доступен на Android 5.0 (уровень API 21) и выше. Этот вариант позволяет выбирать определенный каталог, предоставляя вашему приложению доступ ко всем файлам и подкаталогам в этом каталоге. Посмотреть всю документацию подробно можно здесь.

Например, подобный интент используется в react-native-document-picker.

Для этого вида интента не обязательно иметь разрешения для доступа к файловой системе, так как пользователь может безопасно просматривать файлы в нативном окошке системы. Приложение, открывшее интент, может считывать действия пользователя в этом окне.

API MediaStore

Вновь для начала обратимся к официальной документации: «Платформа предоставляет оптимизированную проиндексированную коллекцию мультимедиа, называемую media store. Он позволяет пользователям легко извлекать и обновлять медиафайлы. Даже после удаления вашего приложения эти файлы остаются на устройстве пользователя».

Ниже — выжимка по теме (^~^).

Коллекции MediaStore

Система автоматически сканирует внешнее хранилище устройства и добавляет медиафайлы в следующие чётко определенные коллекции:

Тип

Папка

MediaStore

Изображение

DCIM/, Pictures/ + скриншоты

MediaStore.Images

Видео

DCIM/, Movies/, Pictures/

MediaStore.Video

Аудио

Alarms/, Audiobooks/, Music/, Notifications/, Podcasts/, Ringtones/

MediaStore.Audio

Скачанные файлы

Download/

MediaStore.Downloads

Все файлы

-

MediaStore.Files

Примечание для папки 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)


  1. malinichevvv
    25.10.2024 12:23

    Ошибки в коде. resonse и подобное


    1. SalaarFiend Автор
      25.10.2024 12:23

      Вот он, внимательный читатель)

      Спасибо, поправлю)


  1. HuziMuzi
    25.10.2024 12:23

    Отличная статья, спасибо за разъяснение изменений в управлении разрешениями на Android 13


    1. SalaarFiend Автор
      25.10.2024 12:23

      Спасибо за то что прочитали)


  1. 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 еще поискать надо.


    1. SalaarFiend Автор
      25.10.2024 12:23

      Согласен, что API ещё далеко от идеала. Но думаю первые шаги в этом направлении сделаны, как минимум уже отделили работу с медиа файлами, от не медийных.

      Ждем дальнейший улучшений)