Возможности устройства: камера, уведомления, виджет
- Зачем это нужно
- Разрешение — не строка в манифесте
- Как это пишется в Compose
- Что показывать при отказе
- Фото: галерея не требует разрешений вовсе
- Один экран, две кнопки, один
Uri - Уведомление: три обязательные части
- «Через минуту, один раз» — три реализации
- Виджет живёт в другом процессе
- Виджет на Glance
- Логирование и комментарии здесь
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Модуль аппаратных возможностей весит немного — пять-шесть баллов. Но три его требования повторяются в заданиях из года в год почти дословно, а проверяются они не чтением кода, а руками и секундомером. И у каждого есть свой способ сломаться без единого сообщения об ошибке: уведомление не пришло, виджет показал ноль, камера упала — и в журнале пусто.
Три требования, выписанные из конкурсных заданий:
- «При нажатии на кнопку „Выбрать фото" предоставить возможность выбрать фотографию из галереи или сделать фото с камеры устройства» — с предпросмотром.
- «Приложение отправляет локальное уведомление, если пользователь не открывал приложение более минуты. Уведомление отправляется один раз до следующего входа в приложение».
- «Виджет для домашнего экрана со счётчиком; если пользователь не прошёл авторизацию — отображать призыв к действию».
Все три упираются в одно: телефон — не ваш компьютер, а чужое устройство с владельцем. Владелец решает, что приложению можно, — и решает во время работы, а не при установке.
Разрешение — не строка в манифесте
Манифест только объявляет намерение. Само разрешение с Android 6 выдаёт пользователь во время работы, диалогом, и он же может его отозвать в любой момент из настроек.
Отсюда две вещи, которые надо принять сразу. Первая: разрешение проверяют перед каждым использованием, а не один раз при запуске. Вторая: приложение обязано работать при отказе — это отдельный критерий, а не пожелание.
Кажется, что состояний два: дали или не дали. На самом деле четыре, и система различает только два из них:
// Модель системы разрешений Android.
// Показывает, что «ещё не спрашивали» и «отказал навсегда» для приложения выглядят одинаково.
enum class Otvet { RAZRESHIT, ZAPRETIT }
class SistemaRazresheniy(private val scenario: List<Otvet>) {
private var vydano = false
private var otkazov = 0
private var shag = 0
var dialogovPokazano = 0
private set
fun checkSelfPermission(): String = if (vydano) "GRANTED" else "DENIED"
// Начиная с Android 11 система показывает диалог не больше двух раз.
// После второго отказа launcher возвращает отказ мгновенно — без диалога,
// без исключения, без единой строчки в логе.
fun requestPermission(): String {
if (vydano) return "GRANTED"
if (otkazov >= 2) return "DENIED (диалог не показан)"
dialogovPokazano++
val otvet = scenario.getOrElse(shag++) { Otvet.ZAPRETIT }
return if (otvet == Otvet.RAZRESHIT) { vydano = true; "GRANTED" }
else { otkazov++; "DENIED" }
}
// true — только между первым и вторым отказом
fun shouldShowRequestPermissionRationale(): Boolean = otkazov == 1
}
// Вариант А: решение принимается только по системным вызовам
fun ekranA(s: SistemaRazresheniy): String = when {
s.checkSelfPermission() == "GRANTED" -> "открываем камеру"
s.shouldShowRequestPermissionRationale() -> "объясняем, зачем нужна камера, и просим снова"
else -> "просим разрешение"
}
// Вариант Б: приложение само помнит, что уже спрашивало (флаг в DataStore)
fun ekranB(s: SistemaRazresheniy, sprashivali: Boolean): String = when {
s.checkSelfPermission() == "GRANTED" -> "открываем камеру"
!sprashivali -> "просим разрешение"
s.shouldShowRequestPermissionRationale() -> "объясняем, зачем нужна камера, и просим снова"
else -> "«Разрешение выключено. Открыть настройки» + кнопка «Выбрать из галереи»"
}
fun main() {
val s = SistemaRazresheniy(listOf(Otvet.ZAPRETIT, Otvet.ZAPRETIT))
var sprashivali = false
val stroki = mutableListOf<Array<String>>()
fun snimok(podpis: String) {
stroki += arrayOf(
podpis,
s.checkSelfPermission(),
s.shouldShowRequestPermissionRationale().toString(),
ekranA(s),
ekranB(s, sprashivali),
)
}
snimok("до первого нажатия")
println("нажали «Выбрать фото» → requestPermission() = " + s.requestPermission()); sprashivali = true
snimok("после 1-го отказа")
println("нажали ещё раз → requestPermission() = " + s.requestPermission())
snimok("после 2-го отказа")
println("нажали третий раз → requestPermission() = " + s.requestPermission())
snimok("после 3-го нажатия")
val shapka = arrayOf("состояние", "checkSelf", "rationale", "вариант А (без флага)", "вариант Б (с флагом)")
val shirina = IntArray(shapka.size) { i ->
maxOf(shapka[i].length, stroki.maxOf { it[i].length })
}
println()
println(shapka.mapIndexed { i, t -> t.padEnd(shirina[i]) }.joinToString(" │ "))
println(shirina.joinToString("─┼─") { "─".repeat(it) })
stroki.forEach { r -> println(r.mapIndexed { i, t -> t.padEnd(shirina[i]) }.joinToString(" │ ")) }
println()
println("системных диалогов показано: " + s.dialogovPokazano + " из 3 нажатий")
println("строк первого и последнего состояния совпадает у А: " +
(stroki.first()[3] == stroki.last()[3]) + ", у Б: " + (stroki.first()[4] == stroki.last()[4]))
}
Живой вывод:
нажали «Выбрать фото» → requestPermission() = DENIED
нажали ещё раз → requestPermission() = DENIED
нажали третий раз → requestPermission() = DENIED (диалог не показан)
состояние │ checkSelf │ rationale │ вариант А (без флага) │ вариант Б (с флагом)
до первого нажатия │ DENIED │ false │ просим разрешение │ просим разрешение
после 1-го отказа │ DENIED │ true │ объясняем, зачем нужна камера, и просим снова │ объясняем, зачем нужна камера, и просим снова
после 2-го отказа │ DENIED │ false │ просим разрешение │ «Разрешение выключено. Открыть настройки» + кнопка «Выбрать из галереи»
системных диалогов показано: 2 из 3 нажатий
строк первого и последнего состояния совпадает у А: true, у Б: false
Смотрите на первую и третью строки таблицы: DENIED / false в обоих случаях. Система не различает «ещё не спрашивали» и «пользователь отказал дважды и больше диалог не увидит». Приложение без собственного флага «уже спрашивали» на третьем нажатии снова «просит разрешение» — а диалог не появляется. Пользователь жмёт кнопку, ничего не происходит, и так до бесконечности.
Вариант Б держит флаг сам и на третьем нажатии показывает то, что нужно: объяснение, кнопку «Открыть настройки» и запасной путь — выбрать из галереи, для которой разрешение не требуется вовсе.
Как это пишется в Compose
val cameraLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) openCamera() else onCameraDenied()
}
Button(onClick = { cameraLauncher.launch(Manifest.permission.CAMERA) }) {
Text("Сделать фото")
}
Последняя заметка — про самую частую ошибку главы. launch() в теле @Composable вместо обработчика нажатия даёт бесконечную череду диалогов: тело выполняется на каждую рекомпозицию, и каждая рекомпозиция снова спрашивает.
Что показывать при отказе
Три экрана вместо одного, по состояниям из модели:
- Первый отказ — короткое объяснение, зачем нужна камера, и кнопка «Разрешить» для повторного запроса. Система в этом состоянии диалог ещё покажет.
- Отказ навсегда — «Разрешение выключено» и кнопка «Открыть настройки»: там пользователь сам включит, если захочет.
- В любом случае — запасной путь. Для фото это галерея, для которой разрешение не нужно совсем.
fun openAppSettings(context: Context) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", context.packageName, null))
context.startActivity(intent)
}
Фото: галерея не требует разрешений вовсе
Первое, что стоит знать о требовании «из галереи или с камеры»: половина его закрывается без единого разрешения. Модель показывает, кому что нужно:
// Модель: какое разрешение реально нужно, чтобы получить фотографию,
// и что видит разработчик, когда разрешения нет.
enum class Sposob(val podpis: String) {
PHOTO_PICKER("PickVisualMedia — системный выбор фото"),
GET_CONTENT("GetContent / OpenDocument"),
MEDIASTORE("запрос к MediaStore напрямую"),
SIS_KAMERA("TakePicture — системная камера"),
CAMERAX("CameraX внутри приложения"),
}
fun nuzhno(s: Sposob, api: Int, cameraVManifeste: Boolean): String = when (s) {
Sposob.PHOTO_PICKER, Sposob.GET_CONTENT -> "—"
Sposob.MEDIASTORE -> when {
api >= 34 -> "READ_MEDIA_IMAGES + READ_MEDIA_VISUAL_USER_SELECTED"
api >= 33 -> "READ_MEDIA_IMAGES"
else -> "READ_EXTERNAL_STORAGE"
}
Sposob.SIS_KAMERA -> if (cameraVManifeste) "CAMERA" else "—"
Sposob.CAMERAX -> "CAMERA"
}
fun chtoBudet(s: Sposob, api: Int, cameraVManifeste: Boolean): String = when {
nuzhno(s, api, cameraVManifeste) == "—" -> "работает без единого запроса разрешения"
s == Sposob.MEDIASTORE && api >= 34 -> "вернутся только выбранные пользователем фото — молча, без ошибки"
s == Sposob.MEDIASTORE -> "пустой курсор или SecurityException"
s == Sposob.SIS_KAMERA -> "SecurityException, хотя код камеры вы не писали"
else -> "камера не запускается"
}
fun main() {
val sh = Sposob.entries.maxOf { it.podpis.length }
println("разрешение при CAMERA, НЕ объявленной в манифесте:")
for (s in Sposob.entries) {
if (s == Sposob.MEDIASTORE) {
println(" " + s.podpis.padEnd(sh) + " API ≤32: " + nuzhno(s, 30, false))
println(" " + " ".repeat(sh) + " API 33: " + nuzhno(s, 33, false))
println(" " + " ".repeat(sh) + " API 34+: " + nuzhno(s, 34, false))
} else {
println(" " + s.podpis.padEnd(sh) + " " + nuzhno(s, 33, false))
}
}
println()
println("ловушка манифеста — тот же TakePicture, API 33:")
for (m in listOf(false, true)) {
val v = if (m) "объявлена " else "не объявлена"
println(" <uses-permission CAMERA> $v → требуется ${nuzhno(Sposob.SIS_KAMERA, 33, m).padEnd(7)}" +
" → без него: ${chtoBudet(Sposob.SIS_KAMERA, 33, m)}")
}
println()
println("если разрешение не выдано (API 34, CAMERA в манифесте):")
for (s in Sposob.entries) {
val r = chtoBudet(s, 34, true)
if (!r.startsWith("работает")) println(" " + s.podpis.padEnd(sh) + " " + r)
}
println()
val polnyy = 4000 to 3000
val minyatura = 160 to 120
val mpP = polnyy.first.toLong() * polnyy.second
val mpM = minyatura.first.toLong() * minyatura.second
println("две функции камеры дают разный результат:")
println(" TakePicture(uri) файл по вашему URI: ${polnyy.first}×${polnyy.second}, %.1f Мп".format(mpP / 1e6))
println(" TakePicturePreview() Bitmap-миниатюра: ${minyatura.first}×${minyatura.second}, %.2f Мп".format(mpM / 1e6))
println(" разница в пикселях: в ${mpP / mpM} раз")
}
Живой вывод:
разрешение при CAMERA, НЕ объявленной в манифесте:
PickVisualMedia — системный выбор фото —
запрос к MediaStore напрямую API 34+: READ_MEDIA_IMAGES + READ_MEDIA_VISUAL_USER_SELECTED
TakePicture — системная камера —
CameraX внутри приложения CAMERA
ловушка манифеста — тот же TakePicture, API 33:
<uses-permission CAMERA> не объявлена → работает без единого запроса разрешения
<uses-permission CAMERA> объявлена → без запроса: SecurityException, хотя код камеры вы не писали
две функции камеры дают разный результат:
TakePicture(uri) файл по вашему URI: 4000×3000, 12.0 Мп
TakePicturePreview() Bitmap-миниатюра: 160×120, 0.02 Мп
разница в пикселях: в 625 раз
Три факта из вывода.
Системный выбор фото не требует ничего. PickVisualMedia открывает системный экран, пользователь сам выбирает, приложение получает Uri ровно на выбранное. Никаких READ_MEDIA_IMAGES, никаких диалогов. Прямой запрос к MediaStore — другое дело, и с Android 14 он ещё и сложнее: пользователь может выбрать «разрешить выбранные», и тогда обычное разрешение окажется не выдано молча.
Ловушка манифеста. Системная камера через TakePicture сама по себе разрешения не требует — фото делает системное приложение. Но если объявить CAMERA в манифесте «на всякий случай», тот же самый вызов начнёт падать с SecurityException, пока разрешение не запрошено. Код камеры вы не писали, а исключение получили.
Две функции — разный результат. TakePicturePreview() возвращает миниатюру 160×120 — в 625 раз меньше пикселей, чем полный снимок. Для аватарки хватит, для фото товара — нет. TakePicture(uri) пишет полноразмерный файл по вашему адресу.
// галерея — без разрешений
val pickPhoto = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri -> uri?.let(onPhotoSelected) }
pickPhoto.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
// камера — полный снимок в свой файл
val takePhoto = rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture()
) { saved -> if (saved) onPhotoSelected(photoUri) }
takePhoto.launch(photoUri) // photoUri заранее получен через FileProvider
TakePicture(uri) требует, чтобы Uri указывал на файл, доступный системной камере, — то есть выдан через FileProvider, объявленный в манифесте. Прямой путь к файлу во внутреннем каталоге камера не примет. Это отдельная настройка, которую делают один раз и потом не трогают.
Один экран, две кнопки, один Uri
Оба пути сходятся в одном месте: приложение получает Uri и показывает предпросмотр. Требование «с предпросмотром» означает, что выбранное фото видно до отправки на сервер.
var photoUri by rememberSaveable { mutableStateOf<Uri?>(null) }
photoUri?.let { uri ->
AsyncImage(model = uri, contentDescription = "Выбранное фото", modifier = Modifier.size(120.dp))
}
Row {
Button(onClick = { pickPhoto.launch(PickVisualMediaRequest(ImageOnly)) }) { Text("Из галереи") }
Button(onClick = { cameraLauncher.launch(Manifest.permission.CAMERA) }) { Text("Сделать фото") }
}
rememberSaveable здесь не случайность: Uri должен пережить поворот экрана, иначе пользователь выберет фото, повернёт телефон и увидит пустое место.
Уведомление: три обязательные части
Локальное уведомление состоит из трёх частей, и каждая появилась в своей версии Android. Пропуск любой даёт одно и то же: уведомление молча не показывается.
Канал — с Android 8. Создаётся один раз, до первого показа. Уведомление в несуществующий канал отбрасывается без ошибки.
val channel = NotificationChannel("reminders", "Напоминания", NotificationManager.IMPORTANCE_DEFAULT)
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
Разрешение — с Android 13, POST_NOTIFICATIONS. Объявляется в манифесте и запрашивается во время работы тем же лаунчером, что и камера. Объявили, но не запросили — уведомления нет, в журнале пусто.
PendingIntent — что откроется по нажатию. С Android 12 обязателен флаг FLAG_IMMUTABLE.
val pending = PendingIntent.getActivity(
context, 0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = NotificationCompat.Builder(context, "reminders")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Вы давно не заходили")
.setContentText("Корзина ждёт")
.setContentIntent(pending)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(1, notification)
«Через минуту, один раз» — три реализации
Формулировка требования кажется простой, и первые две реализации, которые приходят в голову, её не выполняют:
// Модель требования: «уведомление, если пользователь не открывал приложение более минуты;
// отправляется один раз до следующего входа». Три реализации на одной ленте времени.
const val MIN_PERIODIC_SEC = 15 * 60L // WorkManager: минимальный период работы
class Lenta {
val vydano = mutableListOf<Pair<Long, String>>() // когда и какой веткой
val vFone = mutableListOf<Long>() // выдано, пока приложение открыто
}
// А. Периодическая работа: просили раз в минуту
class VariantA(val l: Lenta) {
private var srabotka = -1L
private var period = 0L
fun vhod(t: Long) {
if (period == 0L) {
period = maxOf(60L, MIN_PERIODIC_SEC) // WorkManager поднимает период сам
srabotka = t + period
println(" [A] просили период 60 с, WorkManager поставил $period с")
}
}
fun uhod(t: Long) {}
fun tik(t: Long, otkryto: Boolean) {
if (srabotka == t) {
l.vydano += t to "A"
if (otkryto) l.vFone += t
srabotka = t + period
}
}
}
// Б. Отложенная задача на 60 с при уходе в фон. Без отмены, без флага.
class VariantB(val l: Lenta) {
private var srabotka = -1L
fun vhod(t: Long) {}
fun uhod(t: Long) { srabotka = t + 60 }
fun tik(t: Long, otkryto: Boolean) {
if (srabotka == t) {
l.vydano += t to "B"
if (otkryto) l.vFone += t
srabotka = t + 60 // «напоминаем и дальше»
}
}
}
// В. Отложенная задача + отмена при возврате + флаг «уже отправляли»
class VariantV(val l: Lenta) {
private var srabotka = -1L
private var otpravleno = false
fun vhod(t: Long) { srabotka = -1L; otpravleno = false } // отмена + сброс флага
fun uhod(t: Long) { if (!otpravleno) srabotka = t + 60 }
fun tik(t: Long, otkryto: Boolean) {
if (srabotka == t) {
srabotka = -1L
if (otpravleno) return
otpravleno = true
l.vydano += t to "В"
if (otkryto) l.vFone += t
}
}
}
fun mmss(t: Long) = "%02d:%02d".format(t / 60, t % 60)
fun main() {
// (секунда, событие): true — открыл приложение, false — свернул
val sobytiya = listOf(0L to true, 10L to false, 40L to true, 90L to false, 400L to true, 410L to false)
val konec = 1000L
val la = Lenta(); val lb = Lenta(); val lv = Lenta()
val a = VariantA(la); val b = VariantB(lb); val v = VariantV(lv)
var otkryto = false
println("лента событий:")
for (t in 0..konec) {
sobytiya.filter { it.first == t }.forEach { (_, vhod) ->
otkryto = vhod
println(" ${mmss(t)} " + if (vhod) "пользователь открыл приложение" else "свернул приложение")
if (vhod) { a.vhod(t); b.vhod(t); v.vhod(t) } else { a.uhod(t); b.uhod(t); v.uhod(t) }
}
a.tik(t, otkryto); b.tik(t, otkryto); v.tik(t, otkryto)
}
println()
println("что получил пользователь за ${mmss(konec)}:")
for ((imya, l) in listOf("А периодическая" to la, "Б без отмены" to lb, "В по требованию" to lv)) {
val kogda = l.vydano.joinToString(", ") { mmss(it.first) }
println(" ${imya.padEnd(16)} уведомлений: ${l.vydano.size.toString().padEnd(2)} в ${if (kogda.isEmpty()) "—" else kogda}")
if (l.vFone.isNotEmpty()) println(" из них при открытом приложении: ${l.vFone.joinToString(", ") { mmss(it) }}")
}
println()
println("уходов в фон: 3 — в 00:10, 01:30, 06:50")
println(" из них возврат раньше минуты: один (00:10 → 00:40), уведомления быть не должно")
println(" правильный ответ: 2 уведомления, в 02:30 и в 07:50")
println(" вариант А опоздал на ${(MIN_PERIODIC_SEC - 60) / 60} мин: минимальный период работы 900 с вместо заказанных 60")
}
Живой вывод:
лента событий:
00:10 свернул приложение
00:40 пользователь открыл приложение
01:30 свернул приложение
06:40 пользователь открыл приложение
06:50 свернул приложение
что получил пользователь за 16:40:
А периодическая уведомлений: 1 в 15:00
Б без отмены уведомлений: 15 в 01:10, 02:30, 03:30, ...
из них при открытом приложении: 01:10
В по требованию уведомлений: 2 в 02:30, 07:50
уходов в фон: 3 — в 00:10, 01:30, 06:50
из них возврат раньше минуты: один (00:10 → 00:40), уведомления быть не должно
правильный ответ: 2 уведомления, в 02:30 и в 07:50
вариант А опоздал на 14 мин: минимальный период работы 900 с вместо заказанных 60
Вариант А — периодическая работа раз в минуту. WorkManager молча поднимает период до пятнадцати минут: это его минимум, и он не сообщает об этом ни исключением, ни предупреждением. Первое уведомление приходит на четырнадцать минут позже, чем нужно.
Вариант Б — отложенная задача при каждом уходе в фон, без отмены. Пятнадцать уведомлений за шестнадцать минут, одно из них — при открытом приложении: пользователь вернулся через полминуты, а задача, поставленная при уходе, всё равно сработала.
Вариант В — отложенная с отменой при возврате. Ровно два уведомления, ровно тогда, когда нужно. Логика: при уходе в фон ставим одноразовую задачу с задержкой в минуту; при возврате — отменяем её, если она ещё не сработала.
// при уходе в фон
val work = OneTimeWorkRequestBuilder<ReminderWorker>()
.setInitialDelay(1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniqueWork("reminder", ExistingWorkPolicy.REPLACE, work)
// при возврате
WorkManager.getInstance(context).cancelUniqueWork("reminder")
enqueueUniqueWork с именем гарантирует, что задача одна: повторный уход в фон заменит предыдущую, а не добавит вторую. Это и есть «один раз до следующего входа».
Где ловить уход в фон и возврат — не в конкретном экране, а на уровне всего процесса: ProcessLifecycleOwner с событиями ON_STOP и ON_START. Иначе переход между экранами приложения будет считаться уходом.
Виджет живёт в другом процессе
Виджет на домашнем экране рисует не ваше приложение, а лаунчер. Это другой процесс со своей памятью. Отсюда факт, который ломает первую попытку почти у всех: виджет не видит ни ViewModel, ни репозиторий в памяти, ни object-синглтон. Всё это существует только в процессе приложения.
Единственный мост — диск: DataStore или Room. Приложение пишет счётчик туда, виджет оттуда читает. Обновился счётчик — приложение просит виджет перечитать.
Виджет на Glance
Glance — способ писать виджеты в стиле Compose. Три части: сам виджет, его приёмник и объявление в манифесте.
class CounterWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val token = context.settings.data.first()[tokenKey]
val count = context.settings.data.first()[counterKey] ?: 0
provideContent {
if (token == null) {
Text("Войдите, чтобы видеть счётчик",
modifier = GlanceModifier.clickable(actionStartActivity<MainActivity>()))
} else {
Text("Рядов: $count")
}
}
}
}
class CounterWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = CounterWidget()
}
Два состояния — ровно как требует задание. Авторизованному — счётчик. Гостю — призыв к действию, и по нажатию открывается экран входа.
Обновить виджет из приложения после записи в хранилище:
CounterWidget().updateAll(context)
Glance живёт в двух линиях: стабильная 1.2.0 и альфа 1.3.0. Берите стабильную. И проверяйте виджет не только через превью в Android Studio, а добавлением на домашний экран эмулятора — превью не покажет, что виджет читает из пустого хранилища.
Логирование и комментарии здесь
Множитель из главы про чистый код действует и на этот модуль. Обязательные точки для аппаратных функций:
Log.i(TAG, "[ProfileScreen]: Нажата кнопка — Выбрать фото")
Log.i(TAG, "[ProfileScreen]: Разрешение CAMERA — запрошено")
Log.d(TAG, "[ProfileScreen]: Разрешение CAMERA — выдано")
Log.e(TAG, "[ProfileScreen]: Разрешение CAMERA — отказ, показываем галерею")
Log.i(TAG, "[ReminderWorker]: Уведомление — поставлено, задержка 60 с")
Отказ в разрешении логируют как ERROR, потому что это ветка, где приложение обязано перестроиться, — и именно её потом ищут в журнале, когда «камера не работает».
Типичные ошибки
POST_NOTIFICATIONS объявлен, но не запрошен. На Android 13+ уведомление не появляется. Исключения нет, в журнале пусто.
Канал не создан или создан после первого notify(). Уведомление отбрасывается молча.
Периодическая задача на 60 секунд. WorkManager поднимает до 15 минут без предупреждения.
Отложенное уведомление не отменяется при возврате. Приходит при открытом приложении.
Виджет читает из ViewModel или синглтона. Видит ноль: другой процесс.
CAMERA в манифесте «на всякий случай». Системная камера начинает требовать разрешение и падать с SecurityException.
Галерея через MediaStore вместо PickVisualMedia. На Android 14 «разрешить выбранные» оставляет обычное разрешение невыданным — молча.
launch() в теле @Composable. Диалог на каждой рекомпозиции.
Uri фото в remember вместо rememberSaveable. Поворот экрана — фото пропало.
Слова главы
- Разрешение времени выполнения — выдаётся пользователем диалогом, проверяется перед каждым использованием.
rememberLauncherForActivityResult— регистрация обработчика результата: разрешения, фото, камеры.PickVisualMedia— системный выбор фото; разрешений не требует.TakePicture(uri)— полный снимок в файл черезFileProvider.FileProvider— способ дать системной камере доступ к вашему файлу.- Канал уведомлений — обязателен с Android 8; создаётся до первого показа.
POST_NOTIFICATIONS— разрешение на уведомления с Android 13.PendingIntent— что откроется по нажатию; с Android 12 нуженFLAG_IMMUTABLE.OneTimeWorkRequest+setInitialDelay— отложенная задача; периодическая не бывает чаще 15 минут.ProcessLifecycleOwner— жизненный цикл всего приложения, а не экрана.- Glance — виджеты в стиле Compose; живут в процессе лаунчера, читают только с диска.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> }
На чемпионате
Проверка идёт руками, по каждому требованию отдельно:
- Фото. Эксперт нажимает «Выбрать фото» и ждёт выбора: галерея или камера. Выбирает — на экране появляется предпросмотр. Отказывает в разрешении камеры — приложение не падает и предлагает галерею.
- Уведомление. Эксперт сворачивает приложение и засекает время. Через минуту — уведомление. Возвращается, сворачивает ещё раз — ещё одно. Возвращается через полминуты — уведомления нет. Второе уведомление за один уход снимает балл так же, как его отсутствие.
- Виджет. Виджет есть в системном списке и добавляется на экран. Показывает счётчик из приложения. После выхода из аккаунта — призыв войти, по нажатию открывается экран входа.
Практический совет: проверяйте на эмуляторе с Android 13 или новее — на старых версиях разрешение на уведомления не спрашивается, и ошибка «объявили, но не запросили» не проявится. А на защите устройство может оказаться новым.
Что дальше
Приложение умеет всё, что требует задание: входит, хранит, кэширует, снимает, напоминает и живёт на домашнем экране. Осталось то, чем работу доказывают эксперту без слов: тесты. Требование там конкретное — тесты состояний для каждого компонента UI Kit и тесты сетевого слоя на каждый запрос. Об этом — следующая глава.
Углубиться
CameraX— камера внутри приложения, когда системной недостаточно: свой интерфейс съёмки, режимы, анализ кадров.PickMultipleVisualMedia— выбор нескольких фото за раз.- Группы уведомлений и
setStyle— сворачивание нескольких уведомлений в одно, большая картинка, развёрнутый текст. AlarmManagerс точным временем — когда «через минуту» должно быть минутой, а не «примерно через минуту, когда система решит».- Настраиваемый виджет: несколько размеров, конфигурация при добавлении, обновление по расписанию.
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Разрешения времени выполнения — developer.android.com — состояния, диалог, что показывать при отказе.
- Photo Picker — системный выбор фото без разрешений.
- Уведомления: разрешение с Android 13 — канал,
POST_NOTIFICATIONS, поведение по версиям. - WorkManager: отложенная работа — одноразовые и периодические задачи и их ограничения.
- Jetpack Glance — виджеты в стиле Compose.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.