Intent и intent-фильтры — как приложения зовут друг друга и как на этом ловят

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

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

Три типовые истории. Приложение падает с ActivityNotFoundException на попытке открыть ссылку. Фильтр в манифесте объявлен, а в системном «Поделиться» вашего приложения нет. После подъёма targetSdk сборка перестала собираться из-за PendingIntent.

Причина у всех трёх одна: интент выглядит как «вызов чужого экрана», а на самом деле это сообщение, которое вы отдаёте системе, а она решает, кому и отдавать ли вообще. Пока держишь в голове первую модель, поведение кажется случайным.

Разберём весь путь: из чего собирается интент, как система ищет получателя, что должно быть на принимающей стороне, что поменялось в Android 12–15 и почему intent-фильтр — не замок на двери.

Разбор написан по официальной странице Intents and intent filters; схема доставки неявного интента и схема с вложенным PendingIntent — оттуда же (лицензия CC BY 2.5). Страница местами отстала от системы: в примерах живут startActivityForResult() и проверка через resolveActivity() без оглядки на package visibility. Такие места я заменил на актуальные и отметил отдельно, а ограничения новых версий сверил со страницами behavior changes для Android 13, 14 и 15.

Почему нельзя просто позвать чужой экран

Каждое приложение живёт в своём процессе под своим UID: общей памяти нет, ссылки на объект Activity соседнего приложения у вас нет и быть не может. Единственный канал наружу — Binder, а на том конце сидит системный сервис (ActivityTaskManagerService и компания), который умеет запускать компоненты.

Intent — это Parcelable-объект, то есть буквально сообщение, которое сериализуется и уезжает через Binder в системный процесс, а оттуда — получателю. Отсюда три следствия, из которых дальше выводится почти всё остальное:

Что вообще запускают интентом

Интент адресуется одному из трёх типов компонентов, и правила у них разные:

Из чего состоит Intent

У интента семь значимых полей. Первые четыре участвуют в поиске получателя, остальные просто едут вместе с ним.

Поле Как задаётся Зачем
component Intent(ctx, Foo::class.java), setClassName() точный адрес: пакет и класс
action Intent.ACTION_VIEW и другие что сделать
data (Uri) setData() над чем сделать
type (MIME) setType() какого типа данные
category addCategory() в каком качестве выступает получатель
extras putExtra() параметры
flags addFlags() как запускать: задачи, стек, права на URI
val intent = Intent().apply {
    action = Intent.ACTION_VIEW                       // что сделать
    data = "https://example.com/page".toUri()         // над чем
    putExtra("com.example.EXTRA_SOURCE", "push")      // подробности
    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)           // как запускать
}

Три вещи, на которых спотыкаются регулярно.

setData() и setType() обнуляют друг друга. Это не побочный эффект, а документированное поведение: каждый из методов затирает второе поле. Если нужны и URI, и MIME-тип — только setDataAndType():

// неверно: type затрёт data
intent.setData(uri)
intent.setType("image/png")

// верно
intent.setDataAndType(uri, "image/png")

Свои action и extra называйте с префиксом пакета. Пространство имён здесь общесистемное: "OPEN" или "user_id" рано или поздно совпадут с чужими. Правильно — com.example.action.OPEN_ORDER, com.example.extra.ORDER_ID.

Не кладите свои Parcelable/Serializable в интенты, уходящие наружу. В чужом процессе нет вашего класса, распаковка упадёт с BadParcelableException или ClassNotFoundException — причём внутри чужого приложения, где вы это даже не увидите. Наружу отдавайте примитивы, строки, Uri.

И общий принцип: интент — не место для секретов. Он проходит через системный сервис, виден в дампах системы и вполне может уехать не тому получателю, если вы ошиблись с фильтром. Токены, пароли и ключи в extras — плохая идея.

Явный интент: адресат назван

startActivity(
    Intent(this, ProfileActivity::class.java).apply {
        putExtra(EXTRA_USER_ID, userId)
    }
)

Здесь у интента заполнено поле component, и никакого поиска не происходит: система просто доставляет сообщение по адресу. Так вы запускаете свои собственные компоненты, и так же — только так — запускаются сервисы.

Явный интент адресован конкретному компоненту, неявный система разводит по фильтрам

Неявный интент: адресата ищет система

Вы описываете задачу, а не исполнителя:

val share = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_TEXT, "Ссылка на статью: https://example.com")
}
startActivity(Intent.createChooser(share, "Поделиться ссылкой"))

Дальше система идёт по манифестам всех установленных приложений, ищет подходящие intent-фильтры и запускает победителя, передав ему тот же самый объект интента:

Activity A вызывает startActivity, система ищет подходящий фильтр и запускает Activity B, передавая интент в onCreate

Схема с developer.android.com, CC BY 2.5.

Обратите внимание на шаг 3: получатель достаёт ваш интент прямо из onCreate() — тот же объект, с теми же extras.

Intent.createChooser() здесь не косметика. Он даёт два эффекта: диалог выбора показывается всегда, даже если пользователь раньше выбрал приложение «по умолчанию», и запуск самого чузера не может упасть — активити чузера есть в системе всегда, а при пустом списке пользователь просто увидит сообщение, что открывать нечем.

Если чузер не нужен и вы запускаете интент напрямую — оборачивайте вызов, потому что подходящего приложения может не быть вовсе:

runCatching { startActivity(intent) }
    .onFailure { showMessage(R.string.no_app_to_handle) }

Почему resolveActivity() вдруг возвращает null

В официальном примере проверка «есть ли кому отдать» сделана так:

// так делали до Android 11 — сейчас работает не всегда
if (sendIntent.resolveActivity(packageManager) != null) {
    startActivity(chooser)
}

С Android 11 (API 30) действует фильтрация видимости пакетов: приложение по умолчанию не видит, что установлено на устройстве. resolveActivity() вернёт null, а queryIntentActivities() — пустой список, хотя нужное приложение стоит и интент прекрасно бы открылся. В результате проверка не защищает от падения, а наоборот, тихо выключает вашу функциональность.

Есть два выхода. Первый — честно объявить, что вы собираетесь искать, в <queries>:

<manifest ...>
    <queries>
        <intent>
            <action android:name="android.intent.action.SEND" />
            <data android:mimeType="text/plain" />
        </intent>
    </queries>
    ...
</manifest>

Второй, обычно более удобный, — вообще не спрашивать заранее, а пробовать запустить и ловить ActivityNotFoundException, как в примере выше. Видимость пакетов на сам запуск не влияет: она ограничивает только опрос системы.

Разрешение QUERY_ALL_PACKAGES существует, но это крайняя мера: Google Play пропускает его только для узкого списка сценариев вроде антивирусов и файловых менеджеров.

Приём: intent-фильтр в манифесте

Чтобы получать неявные интенты, компонент объявляет фильтр:

<activity
    android:name=".ShareActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.SEND" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:mimeType="text/plain" />
    </intent-filter>
</activity>

Три обязательных условия, каждое из которых регулярно забывают:

На принимающей стороне интент читается из свойства intent:

class ShareActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handle(intent)
    }

    // Для singleTop / singleTask активити повторный интент приходит сюда,
    // а onCreate больше не вызывается — про это забывают чаще всего.
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)        // иначе свойство intent останется старым
        handle(intent)
    }

    private fun handle(intent: Intent) {
        when (intent.action) {
            Intent.ACTION_SEND -> showText(intent.getStringExtra(Intent.EXTRA_TEXT))
            Intent.ACTION_VIEW -> showUri(intent.data)
        }
    }
}

И главное: всё, что пришло в интенте, — это ввод из недоверенного источника. Любое приложение на устройстве может собрать интент с вашим action и любыми extras. Проверяйте типы, диапазоны и права, как проверяли бы данные из сети.

Резолвинг: три теста

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

Три теста резолвинга: action, category, data — и что происходит с кандидатами

Тест action. Интент проходит, если фильтр объявляет ровно такой action. Фильтр без единого <action> не пропускает ничего вообще. Интент без action проходит любой фильтр, у которого action хотя бы один есть.

Тест category. Каждая категория из интента должна найтись в фильтре. Обратное не требуется: лишние категории в фильтре ничему не мешают. Интент без категорий проходит всегда — но неявных интентов без категорий на практике не бывает, потому что startActivity() подставляет CATEGORY_DEFAULT сам.

Тест data. Самый запутанный. Сравнивается и URI, и MIME-тип, причём URI разбирается по частям:

<scheme>://<host>:<port>/<path>

Части проверяются сверху вниз и только те, что объявлены в фильтре: не указан scheme — не проверяется host; не указан host — не проверяется port; не указаны ни scheme, ни host — не проверяется path.

Дальше работают четыре правила:

Что в интенте Когда тест пройден
ни URI, ни MIME фильтр тоже не объявляет ни URI, ни MIME-тип
только URI URI подходит под формат фильтра, и MIME-тип в фильтре не объявлен
только MIME MIME-тип совпал, и URI в фильтре не объявлен
URI и MIME MIME-тип совпал и URI подходит под фильтр — либо URI имеет схему content: или file:, а фильтр объявил только MIME-тип

Последняя строчка — то самое неочевидное правило, благодаря которому работает половина шаринга в системе: считается, что компонент, объявивший только MIME-тип, умеет читать данные из content: и file:. Поэтому фильтр

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:mimeType="image/*" />
</intent-filter>

примет любую картинку из content://-провайдера, хотя про схемы в нём не сказано ни слова. А вот такой фильтр:

<data android:scheme="http" android:mimeType="video/*" />

примет только видео, лежащее по http://, — и не примет ни content://-видео, ни http://-страницу.

Отдельная грабля: <data> внутри фильтра склеиваются

Несколько <data> в одном <intent-filter> — это не «несколько вариантов», а один общий набор атрибутов. Документация прямым текстом говорит, что вот это:

<intent-filter>
    <data android:scheme="https" android:host="docs.example.com" />
    <data android:scheme="myapp" android:host="orders.example.com" />
</intent-filter>

эквивалентно перечислению всех схем и всех хостов по отдельности. То есть фильтр примет и myapp://docs.example.com, и https://orders.example.com — комбинации, которых вы не задумывали. Если нужны именно пары «схема + хост», делайте несколько отдельных <intent-filter>.

Что ужесточили в Android 13, 14 и 15

Официальная страница описывает это разрозненно, поэтому соберу в одном месте — при подъёме targetSdk вы упрётесь во всё это подряд.

Android 13 (API 33), сторона приёма. Интент из чужого приложения доставляется вашему экспортированному компоненту, только если он совпадает с action и category какого-нибудь из ваших фильтров. Раньше можно было отправить в экспортированный компонент что угодно, лишь бы знать его имя. Проверка не действует на компоненты вообще без фильтров, на интенты внутри одного приложения, на системные интенты и на root.

Android 14 (API 34), сторона отправки. Неявные интенты доставляются только экспортированным компонентам:

// targetSdk 34+: ActivityNotFoundException, если получатель exported="false"
context.startActivity(Intent("com.example.action.APP_ACTION"))

// правильно — сделать интент явным
context.startActivity(
    Intent("com.example.action.APP_ACTION").apply { setPackage(context.packageName) }
)

Там же: изменяемый PendingIntent, у интента которого не заданы ни компонент, ни пакет, приводит к исключению. И ресиверы, зарегистрированные в рантайме, обязаны указывать RECEIVER_EXPORTED или RECEIVER_NOT_EXPORTED (флаг не нужен, только если вы подписываетесь исключительно на системные броадкасты).

Android 15 (API 35). Создатель PendingIntent больше не делится своей привилегией запускать активити из фона — если это действительно нужно, отправителю её выдают явным опт-ином.

Общая линия понятна: система последовательно закрывает возможность дотянуться до чужого компонента «по имени», в обход того, что он сам о себе объявил.

Ответ от чужой активити

На официальной странице для этого всё ещё используются startActivityForResult() и onActivityResult() — оба устарели. Сейчас это Activity Result API:

class EditorActivity : ComponentActivity() {

    // Регистрируем на этапе создания класса — не внутри обработчика клика
    private val pickImage = registerForActivityResult(
        ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        if (uri != null) showImage(uri)
    }

    private val openSettings = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == RESULT_OK) reload(result.data)
    }

    private fun onPickClick() = pickImage.launch("image/*")
}

Готовые контракты (GetContent, OpenDocument, TakePicture, RequestPermission) закрывают большинство сценариев и сами собирают правильный интент. StartActivityForResult остаётся для произвольных интентов. Регистрировать лаунчер нужно до того, как активити перейдёт в STARTED — то есть в поле класса или в onCreate(), иначе получите исключение.

PendingIntent: интент, который выполнит кто-то другой

PendingIntent — это обёртка вокруг интента плюс токен, который система хранит у себя. Отдав такой объект наружу, вы даёте другому приложению право выполнить ваш интент от вашего имени: с вашим UID, вашими правами и вашим пакетом. Даже если ваш процесс к тому моменту уже убит.

Отсюда все места, где он встречается: уведомления (по тапу интент запускает система), виджеты на домашнем экране (запускает лаунчер), отложенные задачи в AlarmManager, геозоны.

val intent = Intent(this, OrderDetailsActivity::class.java).apply {
    putExtra(EXTRA_ORDER_ID, orderId)
}

val pending = PendingIntent.getActivity(
    this,
    orderId,                                 // requestCode: различает интенты между собой
    intent,
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)

Метод выбирается по типу получателя: getActivity(), getService(), getBroadcast() — и должен совпадать с тем, что вы кладёте внутрь.

Мутабельность обязательна с API 31. Не указали ни FLAG_IMMUTABLE, ни FLAG_MUTABLE — получите IllegalArgumentException на создании. По умолчанию берите FLAG_IMMUTABLE: тогда получатель не сможет дописать в ваш интент свои поля. FLAG_MUTABLE нужен в считаных случаях — прямой ответ из уведомления (RemoteInput), Android Auto, «пузыри» переписок, обновления местоположения, повторяющиеся будильники.

Два PendingIntent считаются одинаковыми, если совпали requestCode, action, data, type, класс и категории. Extras в сравнении не участвуют. Классическое следствие: пять уведомлений с разными orderId, но одинаковым requestCode — и все пять открывают первый заказ. Лечится разными requestCode либо FLAG_UPDATE_CURRENT.

Внутри PendingIntent должен быть явный интент. Неявный означает, что получателя будут искать в момент срабатывания — то есть непонятно когда и непонятно кого, но с вашими правами.

Отдельный сюжет — как безопасно дать чужому приложению запустить ваш непубличный компонент. Прямо это невозможно и не нужно: вместо вложенного интента отдаётся PendingIntent, а чужое приложение только вызывает у него send().

Клиентское приложение кладёт PendingIntent в extra, сервис достаёт его и вызывает send, система выполняет интент в контексте клиента

Схема с developer.android.com, CC BY 2.5.

Ключевой момент здесь на шаге 4: интент выполняется в контексте приложения-клиента, которое его создало. Сервис не получает никаких новых прав — он лишь дёргает готовый, заранее подписанный конверт.

Intent-фильтр — не защита

Фильтр не проверяет, кто звонит. Он отвечает только на вопрос «подходит ли это сообщение мне». Компонент с фильтром и exported="true" доступен любому приложению на устройстве — включая то, которое соберёт интент вручную с любыми extras.

Опаснее другое: приложение можно использовать как посредника. Классика — intent redirection.

// ProxyActivity, exported="true"
class ProxyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val next = intent.getParcelableExtra<Intent>("next")
        startActivity(next)      // ← дыра
    }
}

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

Приложение атакующего кладёт вложенный интент в extra, ProxyActivity запускает его и открывает непубличный SecretActivity

Атакующее приложение не может открыть ваш SecretActivity напрямую — тот объявлен exported="false", система такой запуск отклонит. Но оно может попросить об этом ваш собственный ProxyActivity. Запуск пойдёт изнутри вашего процесса, и для системы это внутренний вызов, проверять который не нужно. Тем же приёмом утекают content://-ссылки: вложенный интент несёт флаги FLAG_GRANT_READ_URI_PERMISSION, и ваше приложение само выдаёт права на чужие данные, к которым у атакующего доступа не было.

Что с этим делать:

StrictMode.setVmPolicy(
    StrictMode.VmPolicy.Builder()
        .detectUnsafeIntentLaunch()
        .penaltyLog()
        .build()
)

detectUnsafeIntentLaunch() (Android 12 и новее) ловит ровно этот паттерн: распаковали интент из extras и тут же кем-то его запустили.

Отдельно про сервисы. Неявный интент к сервису — это запуск неизвестно чьего кода с вашими данными, поэтому система такое просто запрещает: bindService() с неявным интентом бросает IllegalArgumentException начиная с Android 5.0. Сервис адресуется классом или, если он в чужом приложении, парой «пакет + класс».

Чеклист граблей

Резюме одной фразой: интент — это не вызов метода, а письмо системе, а intent-фильтр — не замок на двери, а строчка в адресной книге.

Источники

Официальная документация: Intents and intent filters, элемент <data>, Activity Result API, package visibility. Ограничения по версиям сверены со страницами behavior changes для Android 13, Android 14 и Android 15. Схема доставки неявного интента и схема с вложенным PendingIntent взяты из документации Android и распространяются по лицензии CC BY 2.5; остальные диаграммы — мои.