Три типовые истории. Приложение падает с 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 в системный процесс, а оттуда — получателю. Отсюда три следствия, из которых дальше выводится почти всё остальное:
- Всё, что вы положили в интент, покидает ваш процесс. Оно сериализуется, проходит через системный сервис и распаковывается в чужом приложении.
- Между
startActivity()иonCreate()получателя лежит IPC. Вызов не блокирующий: управление вернётся вам сразу, а получатель стартует когда стартует. - Кому доставить — решает не вы, а система. Вы только описываете, что хотите; сопоставлением занимается
PackageManager.
Что вообще запускают интентом
Интент адресуется одному из трёх типов компонентов, и правила у них разные:
- Activity —
startActivity(), либоActivityResultLauncher, если нужен ответ. Самый частый и самый свободный случай: сюда можно слать неявные интенты. - Service —
startService()/bindService(). Здесь только явные интенты, это жёсткое требование системы, а не рекомендация (подробности ниже). Для фоновой работы сегодня в большинстве случаев нужен не сервис, аWorkManager. - Broadcast —
sendBroadcast()и родня. Интент уходит всем подписчикам сразу. С Android 8.0 ресиверы, объявленные в манифесте, почти не получают неявные системные броадкасты — их нужно регистрировать в рантайме.
Из чего состоит 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-фильтры и запускает победителя, передав ему тот же самый объект интента:

Схема с 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>
Три обязательных условия, каждое из которых регулярно забывают:
android:exportedуказывается явно. С Android 12 (targetSdk 31) компонент с интент-фильтром обязан объявитьexported, иначе приложение просто не соберётся. Значениеtrueозначает «меня может запустить кто угодно» — ставьте его осознанно.CATEGORY_DEFAULTобязателен.startActivity()автоматически добавляет эту категорию ко всем неявным интентам, поэтому фильтр без неё не получит ни одного. Исключение — стартовый фильтрMAIN+LAUNCHER, емуDEFAULTне нужен.- Фильтров может быть несколько, но интент должен целиком пройти хотя бы один из них. Нельзя набрать совпадение по кусочкам из разных фильтров.
На принимающей стороне интент читается из свойства 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. Интент проходит, если фильтр объявляет ровно такой 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().

Схема с 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) // ← дыра
}
}
Выглядит как безобидный «универсальный роутер», а на деле это пульт управления вашим приложением, выданный наружу.

Атакующее приложение не может открыть ваш SecretActivity напрямую — тот объявлен exported="false", система такой запуск отклонит. Но оно может попросить об этом ваш собственный ProxyActivity. Запуск пойдёт изнутри вашего процесса, и для системы это внутренний вызов, проверять который не нужно. Тем же приёмом утекают content://-ссылки: вложенный интент несёт флаги FLAG_GRANT_READ_URI_PERMISSION, и ваше приложение само выдаёт права на чужие данные, к которым у атакующего доступа не было.
Что с этим делать:
- Не пробрасывать вложенные интенты. Нужен параметр — передавайте идентификатор или строку и собирайте интент сами. Это закрывает проблему целиком.
- Если пробросить всё-таки надо — доставать через
IntentCompat.getParcelableExtra(intent, "next", Intent::class.java), проверятьcomponentиpackageпо белому списку и снимать флаги выдачи прав на URI. - Там, где нужен именно запуск чужой стороной, — отдавать
PendingIntent, а не интент. - Всё, что не должно быть публичным, помечать
android:exported="false". - Включить детектор в дебажной сборке:
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectUnsafeIntentLaunch()
.penaltyLog()
.build()
)
detectUnsafeIntentLaunch() (Android 12 и новее) ловит ровно этот паттерн: распаковали интент из extras и тут же кем-то его запустили.
Отдельно про сервисы. Неявный интент к сервису — это запуск неизвестно чьего кода с вашими данными, поэтому система такое просто запрещает: bindService() с неявным интентом бросает IllegalArgumentException начиная с Android 5.0. Сервис адресуется классом или, если он в чужом приложении, парой «пакет + класс».
Чеклист граблей
ActivityNotFoundException— на устройстве нет приложения под ваш интент. Оборачивайте запуск вrunCatchingили используйтеcreateChooser().- Приложения нет в списке «Поделиться» — в фильтре забыт
<category android:name="android.intent.category.DEFAULT" />. - Сборка падает на манифесте — у компонента с фильтром не указан
android:exported. resolveActivity()вернулnull, хотя приложение установлено — package visibility. Объявите<queries>или не спрашивайте заранее.- MIME-тип «потерялся» —
setType()послеsetData()(или наоборот). НуженsetDataAndType(). - Фильтр ловит лишнее — несколько
<data>в одном фильтре склеиваются в общий набор атрибутов. Разносите по разным<intent-filter>. - Уведомления открывают один и тот же экран — одинаковый
requestCodeуPendingIntentи нетFLAG_UPDATE_CURRENT. - Падение на создании
PendingIntent— не указан ниFLAG_IMMUTABLE, ниFLAG_MUTABLE(обязательно с API 31). - В активити приходит старый интент — она
singleTop/singleTask, интент пришёл вonNewIntent(), аsetIntent()не вызван. BadParcelableExceptionв чужом приложении — вы положили в extras свойParcelable.- Свой броадкаст ловит кто угодно — для рантайм-ресиверов с targetSdk 34 обязателен
RECEIVER_NOT_EXPORTED, а для событий внутри приложения броадкасты вообще не нужны: естьSharedFlow. - Активити принимает интент и запускает вложенный — intent redirection, чинить до релиза.
Резюме одной фразой: интент — это не вызов метода, а письмо системе, а 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; остальные диаграммы — мои.