FileProvider на Android — как делиться файлами и не словить краш

· 12 минут чтения

androidkotlinбезопасность

Рано или поздно любой разработчик упирается в задачу “передать файл наружу”: отправить 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). Работало, но плохо:

  1. Права не передавались. Получатель мог прочитать файл только если у него самого есть доступ к этой точке файловой системы. Для внутреннего хранилища приложения (/data/data/<пакет>/) это невозможно в принципе — там всё закрыто на уровне Linux-прав по UID. Поэтому файлы для «расшаривания» приходилось складывать на внешнюю карту и просить WRITE_EXTERNAL_STORAGE — то есть выдавать себе доступ ко всему хранилищу целиком ради одного файла.
  2. Утекала структура приложения. Абсолютный путь — это уже информация: где что лежит, как называется. А давать доступ другим приложениям к своей внутренней структуре bad.
  3. Гонки и висячие ссылки. Файл могли удалить или подменить между отправкой и открытием.

Начиная с 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 система вызывает у вашего провайдера 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 разбор путей, нормализация и отказ в доступе к неразрешённым файлам уже написаны и обкатаны. Пишите своё, только когда сценарий действительно выходит за рамки «файл на диске».

Шаг 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>

Разберём атрибуты, потому что каждый здесь важен:

Шаг 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> корень файловой системы, /

Атрибуты:

Про 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
Вопрос пользователя «куда отправить?» «чем открыть?»
Куда кладётся 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 ещё и в 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")
}

Три правила приёмника:

  1. Копируйте сразу. Грант временный, и отправитель может удалить файл в любой момент. Если файл нужен дольше одного экрана — скопируйте к себе.
  2. Не доверяйте имени. Об этом ниже отдельно — это самая недооценённая дыра на принимающей стороне.
  3. Не доверяйте 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 не нужен

Он не единственный инструмент, и часто — не лучший:

Случай «пусть пользователь сам выберет, куда сохранить» стоит показать кодом — его чаще всего пытаются решать через 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 нужен именно тогда, когда вы отдаёте свой приватный файл конкретному приложению по инициативе пользователя.

Чеклист и типичные грабли

Резюме на одну фразу: FileProvider превращает приватный файл во временную, отзываемую, адресную ссылку — и именно этим отличается от file://, который отдавал всё и навсегда.