Перейти к основному содержимому
Мобилка42ГЛАВА 42Возможности устройства: камера, уведомления, виджетРазрешить доступ к камере?НетРазрешитьвиджет · 12владелец решает

Возможности устройства: камера, уведомления, виджет

Прочитано0%
Квизы0/6
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Модуль аппаратных возможностей весит немного — пять-шесть баллов. Но три его требования повторяются в заданиях из года в год почти дословно, а проверяются они не чтением кода, а руками и секундомером. И у каждого есть свой способ сломаться без единого сообщения об ошибке: уведомление не пришло, виджет показал ноль, камера упала — и в журнале пусто.

Три требования, выписанные из конкурсных заданий:

  1. «При нажатии на кнопку „Выбрать фото" предоставить возможность выбрать фотографию из галереи или сделать фото с камеры устройства» — с предпросмотром.
  2. «Приложение отправляет локальное уведомление, если пользователь не открывал приложение более минуты. Уведомление отправляется один раз до следующего входа в приложение».
  3. «Виджет для домашнего экрана со счётчиком; если пользователь не прошёл авторизацию — отображать призыв к действию».

Все три упираются в одно: телефон — не ваш компьютер, а чужое устройство с владельцем. Владелец решает, что приложению можно, — и решает во время работы, а не при установке.

Разрешение — не строка в манифесте​

Манифест только объявляет намерение. Само разрешение с 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отказал разDENIED · trueотказал навсегдаDENIED · falseразрешилGRANTEDпервое и третье состояние для системы одинаковыбез своего флага «уже спрашивали» приложение зациклится: третье нажатие снова «просит», а диалог не появитсяс флагом — «разрешение выключено, открыть настройки» и запасной путь через галереюприложение обязано работать при любом отказе — это отдельный критерий
«Ещё не спрашивали» и «отказал навсегда» для системы неразличимы; различить их может только само приложение — своим флагом

Смотрите на первую и третью строки таблицы: DENIED / false в обоих случаях. Система не различает «ещё не спрашивали» и «пользователь отказал дважды и больше диалог не увидит». Приложение без собственного флага «уже спрашивали» на третьем нажатии снова «просит разрешение» — а диалог не появляется. Пользователь жмёт кнопку, ничего не происходит, и так до бесконечности.

Вариант Б держит флаг сам и на третьем нажатии показывает то, что нужно: объяснение, кнопку «Открыть настройки» и запасной путь — выбрать из галереи, для которой разрешение не требуется вовсе.

Как это пишется в Compose​

val cameraLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) openCamera() else onCameraDenied()
}

Button(onClick = { cameraLauncher.launch(Manifest.permission.CAMERA) }) {
Text("Сделать фото")
}
Нажми на часть выражения

Последняя заметка — про самую частую ошибку главы. launch() в теле @Composable вместо обработчика нажатия даёт бесконечную череду диалогов: тело выполняется на каждую рекомпозицию, и каждая рекомпозиция снова спрашивает.

Отвечено вопросов: 0 из 2
Почему нельзя обойтись двумя состояниями разрешения — «дали» и «не дали»?
Что случится, если вызвать launch() у лаунчера разрешения прямо в теле @Composable?
Отвечено верно: 0 из 2

Что показывать при отказе​

Три экрана вместо одного, по состояниям из модели:

  • Первый отказ — короткое объяснение, зачем нужна камера, и кнопка «Разрешить» для повторного запроса. Система в этом состоянии диалог ещё покажет.
  • Отказ навсегда — «Разрешение выключено» и кнопка «Открыть настройки»: там пользователь сам включит, если захочет.
  • В любом случае — запасной путь. Для фото это галерея, для которой разрешение не нужно совсем.
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 должен пережить поворот экрана, иначе пользователь выберет фото, повернёт телефон и увидит пустое место.

Отвечено вопросов: 0 из 2
Какое разрешение нужно, чтобы выбрать фото из галереи через системный экран?
Объявили CAMERA в манифесте «на всякий случай», фото делает системная камера. Что изменилось?
Отвечено верно: 0 из 2

Уведомление: три обязательные части​

Локальное уведомление состоит из трёх частей, и каждая появилась в своей версии 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
«ЧЕРЕЗ МИНУТУ, ОДИН РАЗ» — ТРИ РЕАЛИЗАЦИИпериодическая работа, 60 сWorkManager молча поднял период до 15 минут1 уведомление · в 15:00отложенная, без отменыодно пришло при открытом приложении15 уведомленийотложенная, отмена при возвратеровно столько, сколько уходов в фон дольше минуты2 уведомления · 02:30 и 07:50три ухода в фон, один короче минуты — правильный ответ два; первые два варианта его не дают
Периодическая работа не бывает чаще 15 минут, отложенная без отмены стреляет при открытом приложении; правильный ответ даёт только отложенная с отменой

Вариант А — периодическая работа раз в минуту. 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. Иначе переход между экранами приложения будет считаться уходом.

Отвечено вопросов: 0 из 2
Реализовали «уведомление через минуту» периодической задачей WorkManager с интервалом 60 секунд. Что произойдёт?
Отложенное уведомление поставлено при уходе в фон. Пользователь вернулся через 30 секунд. Что нужно сделать?
Отвечено верно: 0 из 2

Виджет живёт в другом процессе​

Виджет на домашнем экране рисует не ваше приложение, а лаунчер. Это другой процесс со своей памятью. Отсюда факт, который ломает первую попытку почти у всех: виджет не видит ни ViewModel, ни репозиторий в памяти, ни object-синглтон. Всё это существует только в процессе приложения.

ВИДЖЕТ ЖИВЁТ В ЧУЖОМ ПРОЦЕССЕпроцесс приложения· ViewModel· репозиторий в памяти· object-синглтонвиджету это всё недоступнограницапроцесс лаунчера· GlanceAppWidget· рисует то, что прочиталчитает только с дискаDataStore / Room — единственный мост
Единственный мост между приложением и виджетом — то, что записано на диск

Единственный мост — диск: 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 или новее — на старых версиях разрешение на уведомления не спрашивается, и ошибка «объявили, но не запросили» не проявится. А на защите устройство может оказаться новым.

Отвечено вопросов: 0 из 2
Уведомление не показывается на Android 13, на Android 12 работает. Что вероятнее всего?
Виджет показывает ноль, хотя в приложении счётчик 12. Почему?
Отвечено верно: 0 из 2

Что дальше​

Приложение умеет всё, что требует задание: входит, хранит, кэширует, снимает, напоминает и живёт на домашнем экране. Осталось то, чем работу доказывают эксперту без слов: тесты. Требование там конкретное — тесты состояний для каждого компонента UI Kit и тесты сетевого слоя на каждый запрос. Об этом — следующая глава.

Отвечено вопросов: 0 из 1
Где ловить «пользователь свернул приложение» для отложенного уведомления?
Отвечено верно: 0 из 1

Углубиться​

  • CameraX — камера внутри приложения, когда системной недостаточно: свой интерфейс съёмки, режимы, анализ кадров.
  • PickMultipleVisualMedia — выбор нескольких фото за раз.
  • Группы уведомлений и setStyle — сворачивание нескольких уведомлений в одно, большая картинка, развёрнутый текст.
  • AlarmManager с точным временем — когда «через минуту» должно быть минутой, а не «примерно через минуту, когда система решит».
  • Настраиваемый виджет: несколько размеров, конфигурация при добавлении, обновление по расписанию.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоПроверка Permission в Android (Jetpack Compose)Dev2Devs
ВидеоPhoto Picker: выбор фото без разрешенийDev2Devs
ВидеоHow to Send Local Notifications in AndroidPhilipp Lackner
ВидеоBuild app widgets with Jetpack GlanceAndroid Developers
ВидеоWorkManager: отложенная и периодическая работаSenior Javasky

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.