Введение

Базовые вопросы про 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».

AndroidManifest.xml
<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+):

MainActivity.kt
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:

NotificationHelper.kt
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.
SecurityReceiver.kt
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, разберём вашу ситуацию и составим план подготовки.