Рано или поздно любой разработчик упирается в задачу “передать файл наружу”: отправить PDF в мессенджер, открыть скачанный APK, показать фотку в галерее, передать лог в почту.
Кажется, что задача на пять минут — берём путь к файлу, кладём в интент, готово. Но так это не работает и всплывет ошибка (но вы же обернули вызов в try/catch? обернули же?):
android.os.FileUriExposedException: file:///data/user/0/com.example.app/files/report.pdf
exposed beyond app through Intent.getData()
Ниже я постараюсь разобрать — а что здесь произошло? почему так сделали? и как правильно?
Почему file:// больше не работает
До Android 7.0 (API 24) файлы передавали напрямую: Uri.fromFile(file). Работало, но плохо:
- Права не передавались. Получатель мог прочитать файл только если у него самого есть доступ к этой точке файловой системы. Для внутреннего хранилища приложения (
/data/data/<пакет>/) это невозможно в принципе — там всё закрыто на уровне Linux-прав по UID. Поэтому файлы для «расшаривания» приходилось складывать на внешнюю карту и проситьWRITE_EXTERNAL_STORAGE— то есть выдавать себе доступ ко всему хранилищу целиком ради одного файла. - Утекала структура приложения. Абсолютный путь — это уже информация: где что лежит, как называется. А давать доступ другим приложениям к своей внутренней структуре bad.
- Гонки и висячие ссылки. Файл могли удалить или подменить между отправкой и открытием.
Начиная с API 24 включённый по умолчанию StrictMode.VmPolicy детектит утечку file://-URI за границу приложения и бросает FileUriExposedException. Замена — схема content:// и стандартный механизм Android для доступа к чужим данным: ContentProvider.
Что такое FileProvider
FileProvider — это готовая реализация ContentProvider из AndroidX (androidx.core.content.FileProvider), которая умеет ровно одно: отдавать файлы из заранее описанных папок вашего приложения по content://-ссылкам.
Идея простая. Вы объявляете провайдер и список разрешённых директорий. Дальше вместо пути
/data/user/0/com.example.app/files/docs/report.pdf
вы отдаёте наружу ссылку
content://com.example.app.fileprovider/internal/docs/report.pdf
Что это даёт:
- Точечные права. Вместе с интентом вы выдаёте временное разрешение на конкретный URI. Не на папку, не на хранилище — на один файл, и только тому приложению, которое запустили. Разрешение живёт, пока жива задача (task) получателя, и снимается системой автоматически.
- Изоляция. Наружу видно только имя authority и относительный путь. Реальное расположение файла — деталь реализации.
- Единый интерфейс. Получатель не работает с файлом, он работает с потоком через
ContentResolver. Ему всё равно, откуда байты — из вашего кеша, из БД или из сети. - Работает с внутренним хранилищем. Больше не нужно выкладывать файл в общедоступную папку, чтобы им поделиться.
Технически при обращении по такому URI система вызывает у вашего провайдера openFile(), тот отдаёт ParcelFileDescriptor, и дальше файловый дескриптор передаётся в чужой процесс через Binder. Получатель читает ваш файл вашими же правами, но только этот файл и только пока действует grant.
FileProvider и ContentProvider — это не альтернативы
Частый вопрос: «что лучше использовать — FileProvider или ContentProvider?». Вопрос поставлен неверно, потому что выбирать не из чего: FileProvider — это и есть ContentProvider, просто уже написанный за вас.
ContentProvider — абстрактный класс и один из четырёх компонентов приложения (наравне с activity, service и broadcast receiver). Он задаёт контракт, по которому одно приложение отдаёт данные другому: query, insert, update, delete, getType, openFile. Что стоит за этими методами — целиком ваше дело: таблица SQLite, структура в памяти, ответы сети, файлы на диске.
FileProvider наследуется от ContentProvider и реализует этот контракт под ровно один сценарий — «файл на диске внутри разрешённой папки»:
| Метод | Что делает FileProvider |
|---|---|
openFile() |
открывает файл по пути, разрешённому в file_paths.xml |
query() |
отдаёт две колонки: DISPLAY_NAME и SIZE |
getType() |
определяет MIME по расширению файла |
delete() |
удаляет файл |
insert(), update() |
бросают UnsupportedOperationException |
Вот и вся разница: ContentProvider — контракт, FileProvider — его готовая реализация для файлов.
Как выглядит свой провайдер
Чтобы было видно, что именно достаётся бесплатно, — скелет провайдера, который отдаёт не файлы, а строки из базы:
class NotesProvider : ContentProvider() {
private lateinit var db: SQLiteDatabase
override fun onCreate(): Boolean {
db = NotesDbHelper(context!!).writableDatabase
return true
}
override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?,
): Cursor = db.query("notes", projection, selection, selectionArgs, null, null, sortOrder)
override fun getType(uri: Uri): String = "vnd.android.cursor.dir/vnd.com.example.note"
override fun insert(uri: Uri, values: ContentValues?): Uri? = TODO()
override fun delete(uri: Uri, selection: String?, args: Array<String>?): Int = TODO()
override fun update(uri: Uri, values: ContentValues?, selection: String?, args: Array<String>?): Int = TODO()
}
Объявляется он в манифесте так же — со своим authorities и обязательным exported="false". И это только скелет: дальше вам самим разбирать URI через UriMatcher, валидировать пришедшие projection и selection (иначе получите SQL-инъекцию из чужого процесса), звать notifyChange() при изменениях и держать в голове потокобезопасность — методы провайдера вызываются из пула Binder-потоков.
Что выбрать
- Отдаёте файл, который лежит у вас на диске —
FileProvider. Своё писать не надо. - Нужны колонки, фильтры, курсоры, подписка на изменения через
ContentObserver— свойContentProvider. - Нужно отдать поток, которого нет на диске (собираете отчёт на лету, расшифровываете на ходу) — свой провайдер с
openPipeHelper(). - Нужно чуть подправить поведение файловой отдачи (свой MIME, свои имена) — наследник
FileProvider, о нём ниже.
И аргумент, который перевешивает остальные: провайдер — это ваш код, который вызывает чужой процесс. Каждый метод, написанный руками, — новая поверхность атаки. В FileProvider разбор путей, нормализация и отказ в доступе к неразрешённым файлам уже написаны и обкатаны. Пишите своё, только когда сценарий действительно выходит за рамки «файл на диске».
Шаг 1. Объявляем провайдер в манифесте
<manifest>
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
Разберём атрибуты, потому что каждый здесь важен:
android:authorities— уникальное имя провайдера в системе. Должно быть уникальным на всём устройстве: если два установленных приложения объявят одинаковый authority, второе просто не установится с ошибкойINSTALL_FAILED_CONFLICTING_PROVIDER. Поэтому берём${applicationId}— плейсхолдер, который Gradle подставит из вашегоapplicationId. Это же автоматически решает проблему сapplicationIdSuffix ".debug": debug и release-сборки смогут стоять рядом.android:exported="false"— обязательно. Провайдер не должен быть доступен произвольным приложениям. Доступ раздаётся не черезexported, а через временные права на URI.android:grantUriPermissions="true"— а вот это как раз включает механизм временных прав. Без негоFLAG_GRANT_READ_URI_PERMISSIONне сработает, и получатель поймаетSecurityException.meta-dataс ключомandroid.support.FILE_PROVIDER_PATHS— ссылка на XML со списком разрешённых папок. Имя ключа историческое, из времён Support Library; менять его нельзя.
Шаг 2. Описываем разрешённые пути
Создаём res/xml/file_paths.xml:
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="internal" path="docs/" />
<cache-path name="shared" path="share/" />
<external-files-path name="exports" path="exports/" />
</paths>
Каждый тег — это одна «точка монтирования»:
| Тег | Куда указывает |
|---|---|
<files-path> |
context.filesDir |
<cache-path> |
context.cacheDir |
<external-files-path> |
context.getExternalFilesDir(null) |
<external-cache-path> |
context.externalCacheDir |
<external-media-path> |
context.externalMediaDirs[0] |
<external-path> |
Environment.getExternalStorageDirectory() |
<root-path> |
корень файловой системы, / |
Атрибуты:
path— подпапка внутри базовой директории. Отдаваться наружу будет только то, что лежит внутри неё.path="."илиpath=""открывает всю базовую директорию — так лучше не делать.name— сегмент, который подставится в URI вместо реального пути. ФайлfilesDir/docs/report.pdfпри конфиге выше превратится вcontent://.../internal/report.pdf. Именно поэтому реальная структура папок не утекает.
Про
root-path: он даёт доступ ко всей файловой системе и в официальной документации не описан. Появляется он обычно в ответах со Stack Overflow как «лекарство» от ошибкиFailed to find configured root. Не копируйте это: вы получите провайдер, который отдаёт наружу что угодно по любому пути, включая ваши же приватные базы данных и токены. Правильное лечение — описать конкретную папку.
Шаг 3. Получаем URI
Сначала заведём одно-единственное место, где собирается authority, — дальше во всех примерах будет использоваться только оно:
object FileSharing {
// context.packageName — это applicationId текущей сборки, поэтому строка
// совпадёт с ${applicationId}.fileprovider из манифеста при любом
// applicationIdSuffix: debug и release не разойдутся.
fun authority(context: Context): String = "${context.packageName}.fileprovider"
}
Теперь сам URI:
val file = File(context.filesDir, "docs/report.pdf")
val uri: Uri = FileProvider.getUriForFile(
context,
FileSharing.authority(context),
file,
)
Если файл не попадает ни под одну запись из file_paths.xml, метод бросит IllegalArgumentException: Failed to find configured root that contains /.... Это самая частая ошибка, и она означает ровно то, что написано: путь не разрешён конфигом.
Есть перегрузка с четвёртым параметром — отображаемым именем:
val uri = FileProvider.getUriForFile(
context,
FileSharing.authority(context),
file,
"Отчёт за август.pdf",
)
Полезно, когда файл на диске называется tmp_8f3a91.pdf, а в мессенджере получателя должно быть человеческое имя. Провайдер вернёт это имя в колонке OpenableColumns.DISPLAY_NAME.
Почему authority стоит собирать, а не писать строкой: достаточно однажды добавить applicationIdSuffix ".debug" — и захардкоженная константа разойдётся с тем, что Gradle подставил в манифест. Failed to find configured root вы получите ровно на той сборке, которую запускаете с отладчиком.
Шаг 4. Отдаём файл другому приложению
Дальше идут два очень похожих куска кода, и путают их постоянно. Разница не в API, а в том, что хочет сделать пользователь:
ACTION_SEND— «отдай эти данные вон тому приложению». Получатель что-то делает с копией файла: отправляет в чат, прикладывает к письму, заливает в облако. Пользователь выбирает, куда отдать.ACTION_VIEW— «покажи мне этот файл». Получатель открывает содержимое на экране. Пользователь выбирает, чем открыть.
ACTION_SEND |
ACTION_VIEW |
|
|---|---|---|
| Вопрос пользователя | «куда отправить?» | «чем открыть?» |
| Куда кладётся URI | EXTRA_STREAM |
intent.data |
| Кто окажется в списке | мессенджеры, почта, облака, заметки | просмотрщики PDF, галереи, редакторы |
| Что происходит с файлом | получатель забирает копию себе | получатель показывает и обычно забывает |
Одно и то же приложение вполне может быть в обоих списках: почта умеет и принять вложение, и открыть присланный PDF. Кого показать, решает система по intent-filter получателей — поэтому неверно выбранный action даёт либо пустой список («нет подходящих приложений»), либо список совсем не тех программ.
Отдать файл другому приложению — ACTION_SEND
fun shareReport(context: Context, file: File) {
val uri = FileProvider.getUriForFile(context, FileSharing.authority(context), file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TITLE, "Отчёт за август")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(intent, "Отправить отчёт")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
)
}
Ключевой момент — FLAG_GRANT_READ_URI_PERMISSION. Без него получатель увидит URI, но при попытке открыть поток получит SecurityException: Permission Denial. Флаг — это и есть механизм выдачи прав: система при старте активности запоминает, что вот этому приложению разрешено читать вот этот конкретный URI.
Флаг на самом чузере тоже нужен: чузер — это отдельная активность системного процесса, и ей права требуются, чтобы построить превью.
Тот же код на ShareCompat из AndroidX — короче и сам расставляет флаги:
ShareCompat.IntentBuilder(context)
.setType("application/pdf")
.setStream(uri)
.setChooserTitle("Отправить отчёт")
.startChooser()
Открыть файл на просмотр — ACTION_VIEW
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/pdf")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Если подходящего приложения нет — вылетит ActivityNotFoundException
runCatching { context.startActivity(intent) }
.onFailure { toast("Нет приложения для просмотра PDF") }
MIME-тип указывайте максимально точный. С */* формально работает, но список приложений станет мусорным, а часть получателей откажется работать с файлом.
Отдать файл на запись — камера
Классический сценарий: просим системную камеру записать фото в наш файл.
val photo = File(context.cacheDir, "share/photo_${System.currentTimeMillis()}.jpg")
photo.parentFile?.mkdirs()
val photoUri = FileProvider.getUriForFile(context, FileSharing.authority(context), photo)
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
Здесь нужен FLAG_GRANT_WRITE_URI_PERMISSION: камера будет писать в наш файл, а не читать его. Флаги можно комбинировать, если нужен доступ в обе стороны.
Несколько файлов сразу
val authority = FileSharing.authority(context)
val uris = ArrayList(files.map { FileProvider.getUriForFile(context, authority, it) })
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "image/*"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
Когда флага недостаточно
Автоматическая выдача прав по флагу работает для URI, лежащих в intent.data, в intent.clipData и в EXTRA_STREAM — последний система сама переносит в ClipData при запуске активности. А вот если вы:
- кладёте URI в произвольный extra со своим ключом,
- или отправляете интент не в активность, а в сервис или broadcast,
то грант не выдастся. Варианты решения — либо положить URI ещё и в ClipData:
intent.clipData = ClipData.newRawUri(null, uri)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
либо выдать право вручную конкретному пакету:
context.grantUriPermission("com.example.receiver", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
// ...когда доступ больше не нужен:
context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
Ручной грант живёт до перезагрузки устройства или до явного отзыва, поэтому его нужно снимать самостоятельно. Автоматический — снимается системой сам. По умолчанию предпочитайте автоматический.
Долгоживущий доступ
Обычный грант умирает вместе с задачей получателя. Если получателю нужно вернуться к файлу позже (например, он сохраняет ссылку в свою базу), добавьте FLAG_GRANT_PERSISTABLE_URI_PERMISSION на стороне отправителя — тогда получатель сможет вызвать contentResolver.takePersistableUriPermission(uri, flags) и удержать доступ через перезапуски.
Шаг 5. Принимающая сторона
Симметричная задача: к вам пришёл чужой content://. Работать с ним как с файлом нельзя — File(uri.path) не существует, и получить настоящий путь вы не должны даже пытаться. Читайте поток:
fun importFile(context: Context, uri: Uri) {
val resolver = context.contentResolver
// Имя и размер — из метаданных провайдера, а не из URI
resolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val name = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
}
}
val target = File(context.filesDir, "imported/${System.currentTimeMillis()}.bin")
target.parentFile?.mkdirs()
resolver.openInputStream(uri)?.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
} ?: error("Не удалось открыть поток по $uri")
}
Три правила приёмника:
- Копируйте сразу. Грант временный, и отправитель может удалить файл в любой момент. Если файл нужен дольше одного экрана — скопируйте к себе.
- Не доверяйте имени. Об этом ниже отдельно — это самая недооценённая дыра на принимающей стороне.
- Не доверяйте MIME-типу. Тип объявляет отправитель; проверяйте содержимое, если от него зависит логика.
Почему DISPLAY_NAME — это недоверенный ввод
DISPLAY_NAME — не свойство файла. Это просто строка, которую чужой провайдер вернул в курсоре, и вернуть он может что угодно: имя задаётся кодом отправителя, а не берётся из файловой системы. Помните перегрузку getUriForFile(..., displayName) из третьего шага? Ровно она и позволяет отправителю назвать файл как захочется — а чужой провайдер вообще не обязан быть FileProvider.
Поэтому так делать нельзя:
val name = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
val target = File(context.filesDir, name) // ⚠️ path traversal
resolver.openInputStream(uri)?.use { input ->
target.outputStream().use { input.copyTo(it) }
}
Пусть отправитель вернёт имя ../databases/app.db. Тогда
File("/data/user/0/com.example.app/files", "../databases/app.db")
схлопнется в /data/user/0/com.example.app/databases/app.db, и «сохранение входящего файла» перезапишет вашу собственную базу. Тем же приёмом дотягиваются до shared_prefs/ с токенами. Атакующему нужно ровно одно: чтобы пользователь один раз выбрал ваше приложение в чужом Sharesheet.
Безопасный вариант — обрезать имя до последнего сегмента, вычистить остаток и обязательно проверить итоговый путь:
val importDir = File(context.filesDir, "imported").apply { mkdirs() }
val safeName = File(rawName).name // "../../app.db" -> "app.db"
.replace(Regex("""[^A-Za-z0-9._-]"""), "_") // режем всё экзотическое
.take(100) // имя может прийти на 4000 символов
.ifBlank { "file" } // ...а может прийти пустым
val target = File(importDir, safeName)
require(target.canonicalPath.startsWith(importDir.canonicalPath + File.separator)) {
"Имя пытается выйти за пределы папки импорта"
}
Почему одной чистки мало: File("..").name возвращает "..", а точки регулярка не трогает — и File(importDir, "..") укажет на родительскую папку. Ловит это только последняя проверка по canonicalPath, поэтому выбрасывать её нельзя.
Самый надёжный подход — вообще не строить путь из чужой строки: генерируйте имя сами (UUID, таймстамп, идентификатор записи), а DISPLAY_NAME сохраняйте отдельным полем, как метаданные для показа пользователю. Заодно исчезает тихая перезапись, когда два импорта подряд принесли файлы с одинаковым именем.
Свой наследник FileProvider
Иногда нужно вмешаться в поведение. Например, отдавать всем файлам один MIME-тип или скрывать реальные имена:
class ReportProvider : FileProvider(R.xml.file_paths) {
override fun getType(uri: Uri): String = "application/pdf"
}
Конструктор с ресурсом (FileProvider(@XmlRes int)) появился в свежих версиях androidx.core и позволяет не писать meta-data в манифесте. Если вы на старой версии — оставьте meta-data и наследуйтесь от FileProvider() без аргументов.
Помните: getType() вызывается до проверки прав, то есть его может дёрнуть любое приложение. Не возвращайте оттуда ничего чувствительного и не делайте в нём тяжёлой работы.
Когда FileProvider не нужен
Он не единственный инструмент, и часто — не лучший:
- Сохранить файл «в загрузки» или в общую галерею. Это
MediaStore: пользователь потом найдёт файл в системном менеджере, и жить он будет независимо от вашего приложения. - Дать пользователю выбрать, куда сохранить. Storage Access Framework:
ACTION_CREATE_DOCUMENT(илиActivityResultContracts.CreateDocument). Пользователь сам указывает папку, разрешений не нужно вообще. - Прочитать чужой файл.
ACTION_OPEN_DOCUMENT/ActivityResultContracts.OpenDocument— вы получите тот жеcontent://, только выданный системным провайдером.
Случай «пусть пользователь сам выберет, куда сохранить» стоит показать кодом — его чаще всего пытаются решать через FileProvider, и зря:
class ExportActivity : ComponentActivity() {
private val createDocument = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/pdf")
) { uri: Uri? ->
if (uri == null) return@registerForActivityResult // пользователь отменил
contentResolver.openOutputStream(uri)?.use { output ->
File(filesDir, "docs/report.pdf").inputStream().use { input ->
input.copyTo(output)
}
}
}
private fun exportReport() {
createDocument.launch("Отчёт за август.pdf") // имя по умолчанию в диалоге
}
}
Роли здесь развёрнуты на 180°: content:// выдаёте не вы, а системный провайдер документов, и вы — та сторона, которая пишет в чужой URI. Никакого file_paths.xml, никакого провайдера в манифесте, никаких разрешений: папку и имя выбирает пользователь в системном диалоге, а файл переживёт удаление вашего приложения. Чтение устроено симметрично — ActivityResultContracts.OpenDocument вернёт URI, который вы откроете через openInputStream().
FileProvider нужен именно тогда, когда вы отдаёте свой приватный файл конкретному приложению по инициативе пользователя.
Чеклист и типичные грабли
Failed to find configured root— файл лежит вне путей изfile_paths.xml, либо authority в коде не совпадает с манифестом (привет,applicationIdSuffix).SecurityException: Permission Denial— забылиFLAG_GRANT_READ_URI_PERMISSION, илиgrantUriPermissions="true", или URI уехал в кастомный extra безClipData.INSTALL_FAILED_CONFLICTING_PROVIDER— authority не уникален. Используйте${applicationId}.ActivityNotFoundException— на устройстве нет приложения под ваш MIME-тип. ОборачивайтеstartActivityвrunCatching.- Файл «пустой» у получателя — вы не сделали
flush()/close()перед отправкой, либо отдали URI до того, как запись закончилась. exported="true"у провайдера — дыра. Так делать нельзя никогда.<root-path>в конфиге — дыра. Опишите конкретную папку.- Входящий
DISPLAY_NAMEподставили в путь — path traversal, чужое приложение перезапишет ваши файлы. Санитизируйте имя и проверяйтеcanonicalPath. - Временные файлы копятся в
cacheDir— чистите папкуshare/при старте приложения; система сама трогает кеш только под давлением на диск.
Резюме на одну фразу: FileProvider превращает приватный файл во временную, отзываемую, адресную ссылку — и именно этим отличается от file://, который отдавал всё и навсегда.