Введение
Базовые вопросы про BroadcastReceiver кандидат закрывает, а на следующем уровне сыпется: «почему ресивер в манифесте обязан иметь exported, чем опасен мутабельный PendingIntent и что изменилось в Android 14?» Это и есть разница между middle и junior-ответом.
В прошлом разборе мы закрыли основы: два способа регистрации, ограничение Android 8+ на неявные broadcast-сообщения в манифесте и правила onReceive. Здесь — то, что спрашивают дальше: экспорт и видимость ресивера, PendingIntent как поверхность атаки, безопасность отправки и ограничения свежих версий Android.
Экспорт ресивера: невидимая дверь
Начиная с Android 12 (API 31) компоненты с intent-filter обязаны явно указывать android:exported — иначе приложение даже не соберётся: «Apps targeting Android 12 and higher are required to specify an explicit value for android:exported».
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>Смысл атрибута: exported="true" — ресивер могут дёргать другие приложения, exported="false" — только ваше. Системные broadcast-сообщения вроде BOOT_COMPLETED доставляются и при false, так что внутренним ресиверам место в false.
Для динамических ресиверов экспорт тоже пришёл. На Android 14 (API 34)+ приложения, которые таргетят эту версию, обязаны указывать флаг при регистрации — иначе SecurityException. Делается это через ContextCompat.registerReceiver() (AndroidX core 1.9.0+):
val receiver = BatteryReceiver()
val flags = if (listenToBroadcastsFromOtherApps) {
ContextCompat.RECEIVER_EXPORTED
} else {
ContextCompat.RECEIVER_NOT_EXPORTED
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(Intent.ACTION_BATTERY_LOW),
flags
)RECEIVER_NOT_EXPORTED — безопасное значение по умолчанию. Если ресивер слушает только broadcast-сообщения вашего приложения, экспортировать его не нужно. Есть нюанс: некоторые системные broadcast-сообщения приходят от высокопривилегированных приложений (Bluetooth, телефония) — если нужно принимать и их, флаг должен быть RECEIVER_EXPORTED.
PendingIntent: почему это токен, а не Intent
BroadcastReceiver и PendingIntent — неразлучная пара: ресиверы получают события, PendingIntent их откладывает. Вопрос про PendingIntent звучит так часто, что его стоит разобрать отдельно.
PendingIntent — это токен, который хранит система: приложение A передаёт его приложению B, и B выполняет действие от имени A, даже если A уже не живо. Ключевое следствие: PendingIntent сравнивается системой по содержимому (intent + requestCode + компонент), поэтому два одинаково созданных PendingIntent — это один и тот же объект.
С Android 12 (API 31) приложения обязаны указывать мутабельность каждого PendingIntent. Если флаг не указан — система выбрасывает IllegalArgumentException:
val pendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)FLAG_IMMUTABLE— получатель не может менять внутренний Intent черезfillIn(). Это безопасный выбор по умолчанию.FLAG_MUTABLE— нужен только когда получатель обязан дополнить Intent данными (например, для действий на уведомлении или отображения на Wear OS). Мутабельный PendingIntent — известная поверхность атаки: другое приложение может дописать в незаполненные поля Intent и получить доступ к неэкспортированным компонентам.FLAG_ONE_SHOT— PendingIntent сработает один раз. Без него токен можно «переиграть», а у AlarmManager такой PendingIntent вообще нельзя отменить.FLAG_UPDATE_CURRENT— при повторном создании обновляет данные существующего токена.
Как запомнить
FLAG_IMMUTABLE — «получатель не может менять мой Intent», FLAG_MUTABLE — «может дополнять, и я понимаю зачем», FLAG_ONE_SHOT — «сработает один раз». Для ресиверов и уведомлений почти всегда FLAG_IMMUTABLE.
Ограничения новых версий: от Android 7 до Android 16
Вопрос «какие ограничения вы знаете» — любимый способ проверить, что кандидат не застрял в 2018 году. По версиям:
- Android 7 (API 24): перестали отправляться
ACTION_NEW_PICTUREиACTION_NEW_VIDEO, аCONNECTIVITY_ACTIONс этой версии принимается только через контекстную регистрацию — в манифесте она не работает. - Android 8 (API 26): неявные broadcast-сообщения нельзя объявлять в манифесте, кроме списка исключений:
BOOT_COMPLETED,TIME_SET,TIMEZONE_CHANGED,LOCALE_CHANGED, USB- и Bluetooth-события и другие. Для всего остального — динамическая регистрация. - Android 9 (API 28): из Wi-Fi broadcast-сообщений убрали SSID и BSSID —
NETWORK_STATE_CHANGEDбольше не содержит данных о точке доступа. - Android 14 (API 34): для кэшированных процессов система оптимизирует доставку — второстепенные системные broadcast-сообщения вроде
ACTION_SCREEN_ONоткладываются, пока приложение не выйдет из кэшированного состояния. - Android 16 (API 36): приоритет broadcast-сообщений через
android:priorityперестал гарантироваться между процессами — только внутри одного процесса приложения.
Слабое место многих кандидатов — знать только Android 8 и всё. А ведь есть свежее: экспортные флаги (12/14), откладывание доставки для кэшированных процессов (14) и смерть межпроцессного приоритета (16). Свежие ограничения — то, что отличает подготовленного кандидата.
Безопасность: где ресиверы становятся дырой
Экспорт — это не формальность, а вопрос безопасности. Экспортированный ресивер, слушающий произвольное неявное действие, может получать сообщения от любого приложения на устройстве: фишинговые события, поддельные данные, мусор.
Способы защиты, о которых стоит рассказать:
- Не экспортировать то, что не должно быть публичным.
RECEIVER_NOT_EXPORTEDдля внутренних событий — первая линия. - Ограничивать отправителей разрешением.
sendBroadcast(intent, permission)доставит сообщение только приложениям с нужным разрешением. Можно использовать системные разрешения или объявить своё через<permission>. - Сужать адресатов.
intent.setPackage(packageName)— broadcast-сообщение уйдёт только приложениям указанного пакета; удобно для общения между своими приложениями. - Проверять sender.
BroadcastReceiverполучаетcontextс указанием на отправителя — можно проверятьcontext.getApplicationInfo().packageName.
class ProtectedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val sender = context.getApplicationInfo().packageName
if (sender != EXPECTED_SENDER) return
// обработка события от проверенного отправителя
}
}Отдельно стоит запомнить: broadcast-сообщение не может «перехватить» Intent, которым запускается Activity — это два независимых механизма. Кандидаты часто путают, что «через broadcast можно запустить чужую Activity».
Ещё один уровень — отправитель. Если вы шлёте broadcast-сообщение сами, помните: обычный sendBroadcast() доставляет его всем приложениям с подходящим фильтром. Для внутренних событий это расточительство и риск утечки данных — экспортированные ресиверы других приложений могут их прочитать. Здесь тоже работают разрешение и setPackage(), а для коммуникации внутри одного процесса есть более современные механизмы: SharedFlow, Callback и другие, — они не проходят через систему вовсе.
- Внутренние события —
RECEIVER_NOT_EXPORTED/exported="false". - Экспорт — только если событие действительно нужно другим приложениям.
- Для чувствительных данных — разрешение на отправку или
setPackage(). - PendingIntent —
FLAG_IMMUTABLEвезде, где не нужен мутабельный. onReceive— короткая операция; долгая работа через WorkManager, а не поток.
Ошибки кандидатов
Ошибка №1: «Зачем exported, если intent-filter и так указывает действие?» Затем, что без явного значения приложение не соберётся на Android 12+, а с true без нужды ресивер открыт всем. Это теперь базовый вопрос, а не «на подумать».
Ошибка №2: «PendingIntent — это просто Intent с флагами». Нет: это токен системы, который позволяет другому компоненту выполнить действие от вашего имени. Отсюда и требования мутабельности, и риски переигрывания.
И ещё пара типичных промахов:
- Путать ограничения версий. «Ну, в манифесте нельзя с Android 8 — это всё, что я знаю» — недостаточно. Экспортные флаги, Android 14 и 16 — обязательная часть ответа для middle-позиций.
- Игнорировать sender. Проверка того, кто отправил broadcast-сообщение, особенно для экспортированных ресиверов, — признак понимания модели угроз, а не просто заученного API.
- Забывать про порядок доставки.
sendOrderedBroadcast()доставляет сообщение по очереди с возможностью прервать цепочку, обычныйsendBroadcast()— всем сразу в неопределённом порядке. А на Android 16+ межпроцессный приоритет и вовсе не гарантируется. - Ожидать гарантий времени. Система оптимизирует доставку broadcast-сообщений ради здоровья системы: точного времени доставки нет. Если приложению нужен низколатентный обмен между процессами — это bound service, а не broadcast.
- «Ресивер — точка входа: по умолчанию закрытая (
exported="false"), динамическая регистрация с флагами экспорта на Android 14+». - «PendingIntent — токен системы; мутабельность обязательна с Android 12, по умолчанию
FLAG_IMMUTABLE». - «В манифесте — только исключения из списка Android 8; свежие ограничения: Android 14 откладывает доставку, Android 16 убрал межпроцессный приоритет».
Итоги
BroadcastReceiver, выученный по старым статьям, не проходит собеседование в сильной компании — слишком многое изменилось за последние годы.
- Экспорт: с Android 12 обязателен в манифесте, с Android 14 — флаги
RECEIVER_EXPORTED/RECEIVER_NOT_EXPORTEDдля динамических ресиверов. - PendingIntent: токен системы; с Android 12 мутабельность обязательна, по умолчанию —
FLAG_IMMUTABLE. - Свежие ограничения: отложенная доставка на Android 14, конец межпроцессного приоритета на Android 16, безопасность через разрешения и
setPackage().
Главное, что нужно запомнить
Ответ уровня middle звучит так: «Ресивер — это точка входа; по умолчанию она закрыта (exported="false"), PendingIntent — токен с обязательным FLAG_IMMUTABLE, а ограничения доставки менялись в каждой версии от Android 7 до Android 16». Этого достаточно, чтобы выделиться.
Если на тренировочном собеседовании вы чувствуете, что отвечаете «по накатанной», а не по пониманию — это нормальный этап роста. Напишите в Telegram, разберём вашу ситуацию и составим план подготовки.