Тестирование runtime-разрешений в мобильном приложении: состояния, отзыв на лету и edge-кейсы
Разрешения (permissions) — одно из самых недооценённых мест в мобильном QA. Их часто тестируют поверхностно: «дал доступ — камера работает». А настоящие баги живут в отказах, отзывах на лету и состояниях, о которых забывают: «Don’t ask again» на Android, limited-доступ к фото на iOS, убийство процесса после смены разрешения в Настройках. Разберём модель состояний, когда и как просить, платформенные особенности и чек-лист, который ловит то, что обычно пропускают.
Модель состояний: их не два, а минимум пять
Главная ошибка — считать, что состояний два (дал / не дал). На самом деле каждое опасное разрешение живёт в нескольких состояниях, и каждое — отдельный тест-кейс:
- Not determined / не запрашивалось — система ещё ни разу не спрашивала. Это единственное состояние, в котором можно показать системный диалог.
- Granted / выдано — доступ есть.
- Denied / отказано — на iOS системный диалог повторно уже не покажется, только переход в Настройки. На Android после двух отказов система сама ставит «не спрашивать».
- Permanently denied / «Don’t ask again» (Android) — системный диалог больше не появляется, запрос молча «отказывает». Нужно вести пользователя в Настройки руками.
- Limited / частичный — iOS Photos (выбранные фото), приблизительная вместо точной геолокации, «While Using» вместо «Always».
- Provisional / ephemeral — тихие пуши без диалога (iOS), временный доступ.
Подробная модель и обработка отказа — в гайде Android по запросу разрешений.
Когда и как просить
- Не пачкой на старте. Запрос — в момент, когда польза очевидна: нажал «сделать фото» → просим камеру. Пять диалогов на первом экране — антипаттерн и путь к отказам.
- Pre-permission priming. Свой экран-объяснение ДО системного диалога. На iOS системный диалог показывается один раз: если юзер откажет «на холодную», второго шанса не будет — только Настройки. Поэтому сначала свой экран «зачем это нужно», потом системный.
- Usage description строки (iOS).
NSCameraUsageDescription,NSLocationWhenInUseUsageDescriptionи т.п. должны быть на месте и осмысленны — без них iOS крашит приложение в момент запроса.
Тест-кейсы: отказ на priming-экране не тратит системный диалог (разрешение остаётся not determined); согласие на priming → показывается системный диалог.
Отказ не должен ломать флоу
На каждый отказ — внятный graceful fallback, а не тупик, краш или пустой экран:
- Камера отклонена → объяснение + кнопка «Открыть Настройки» или выбор из галереи.
- Геолокация отклонена → ручной ввод города/адреса.
- Пуши отклонены → приложение работает, фича помечена как выключенная.
- Контакты / микрофон / фото — то же: фича деградирует, но не блокирует остальное.
Отдельно проверяем: нет бесконечного цикла запроса (нельзя долбить системный диалог); фича в состоянии «отказано» не выглядит доступной — кнопка не должна вести в никуда.
Отзыв на лету — самый забываемый кейс
На обеих платформах пользователь может отозвать разрешение в системных Настройках, пока приложение в фоне. И вот ключевой момент, который пропускают:
При изменении опасного разрешения через Настройки ОС убивает процесс приложения. При возврате приложение стартует заново (cold/warm start) — и состояние должно восстановиться без краша.
Сценарий: дать разрешение → пользоваться → свернуть → в Настройках отозвать → вернуться в приложение. Ожидание: не краш, фича корректно переключилась в «нет доступа», экран и данные восстановлены. Обратный сценарий: отозвать → выдать снова → вернуться → фича снова доступна без ручного перезапуска. Про сброс и пересоздание процесса — в разделе про обработку отказа.
Особые и «опасные» разрешения
- Background location. «Always» на iOS,
ACCESS_BACKGROUND_LOCATIONна Android 10+ — запрашивается отдельно и обычно после foreground-доступа; стор ревьюит это строго. См. гайд Android по геолокации и CoreLocation authorization. - Точная vs приблизительная гео. Android 12+ (
ACCESS_FINE/COARSE) и precise-toggle на iOS: юзер может дать только приблизительную — фича обязана работать или осознанно деградировать. См. approximate location. - POST_NOTIFICATIONS (Android 13+). Пуши теперь тоже требуют runtime-разрешения, как на iOS, — старый код этого не ждёт.
- Special permissions (Android). Overlay, all-files-access, exact alarms, notification listener идут через отдельный системный экран, а не обычный диалог. См. special permissions.
iOS-специфика
- ATT (App Tracking Transparency). Отдельный prompt для IDFA; показывается один раз; при отказе нельзя слать tracking-идентификатор. См. ATT и App Store privacy.
- Limited Photos. Юзер выбрал N фото — приложение видит только их. Должно дать «выбрать ещё» и не падать на остальных. См. enhanced privacy in Photos.
- Provisional notifications. Тихая доставка без диалога — проверить, что уведомления приходят в Центр уведомлений.
Android-специфика
- «Don’t ask again» / permanently denied. После второго отказа
shouldShowRequestPermissionRationale→ false, системный диалог больше не появится. Единственный путь — Настройки. Тест: не должно быть «мёртвой» кнопки, которая молча ничего не делает. - Auto-reset. Если приложением не пользуются месяцами, Android сбрасывает разрешения — при следующем запуске состояние снова
not determined. - Battery / hibernation. Пересекается с фоновыми ограничениями OEM — фоновая гео/пуши могут не работать даже при выданном разрешении.
Приватность по факту: отказ ≠ данные не уходят
Отказ в UI не гарантирует, что данные не отправляются. Это надо проверять по трафику: после отказа от ATT/гео/аналитики SDK реально не должен слать идентификатор или координаты.
- Перехват HTTPS (Proxyman/Charles): granted → поле в запросе есть; denied → поля нет или оно обнулено.
- Особенно критично для рекламных и трекинг-SDK и для compliance (GDPR/ATT).
- Consent-флоу (например, UMP у AdMob) должен реально влиять на то, что уходит в сеть.
Инструменты
- Android:
adb shell pm grant/revoke <pkg> <perm>,adb shell pm reset-permissions,appopsдля special-разрешений. - iOS (симулятор):
xcrun simctl privacy <device> grant|revoke|reset <service> <bundleId>. - Proxyman / Charles: подтверждение фактической отправки данных после отказа.
- Матричный прогон: каждое разрешение × каждое состояние × foreground/отзыв-на-лету.
Чек-лист тестирования разрешений
- Запрос разрешения — в осмысленный момент, а не пачкой на старте
- Есть pre-permission priming-экран; его отказ не тратит единственный системный диалог
- iOS: usage description строки на месте и осмысленны (иначе краш при запросе)
- Каждое разрешение протестировано в 5 состояниях: not determined / granted / denied / permanently denied / limited
- Отказ даёт graceful fallback, а не краш, тупик или мёртвую кнопку
- После permanently denied системный диалог не показывается повторно — ведём в Настройки
- Отзыв разрешения в Настройках на лету → процесс убит → возврат в приложение без краша, состояние восстановлено
- Повторная выдача → фича снова работает без ручного перезапуска
- Точная/приблизительная гео и While Using/Always обрабатываются корректно
- Background location и special permissions (overlay, exact alarm, POST_NOTIFICATIONS) — отдельные сценарии
- iOS: ATT, limited Photos, provisional push проверены отдельно
- По трафику (Proxyman) подтверждено: после отказа SDK реально не шлёт данные
Итог. Разрешения — это не «дал / не дал», а матрица состояний × прерываний × платформенных особенностей. Самые дорогие баги: краш при возврате после отзыва, мёртвая кнопка после permanently denied и SDK, который шлёт данные несмотря на отказ. Прогоните каждое разрешение по чек-листу — и закроете то, что обычно всплывает только в отзывах в сторе.
Источники: Android — Request runtime permissions, Android — Approximate location, Apple — App Tracking Transparency, Apple — Requesting location authorization, Apple — Enhanced privacy in Photos.