Сегодня в выпуске: очередное напоминание, что диалоги запросов полномочий в Android можно подменить, инструкция, как оптимизировать потребление памяти приложением, рассказ об отличиях ArrayMap и SparseArray от HashMap, ультракороткая инструкция по созданию аналога RecyclerView с помощью Jetpack Compose, инструкции по ограничению видимости API библиотек. А также: очередная подборка библиотек для программистов и инструментов для пентеста.
В целом в статье нет ничего нового, и она просто описывает известный баг, а точнее, очередной design flaw Android, связанный с оверлеями. Если кратко, суть истории в том, что в Android есть специальное разрешение SYSTEM_ALERT_WINDOW, позволяющее «рисовать» поверх любых других окон. Это же разрешение можно использовать в корыстных целях, чтобы нарисовать поверх системного окна запроса полномочий, используемого приложениями для запроса прав на то или иное действие, свое собственное окно, которое предлагает пользователю дать разрешение на другое, более безобидное действие.
Google в курсе этой проблемы и даже «исправила» ее сразу при появлении в Android системы запроса разрешений (Android 6.0), но уже в версии Android 7.0 отказалась от исправления из‑за многочисленных жалоб пользователей софта с функцией SYSTEM_ALERT_WINDOW (экранные фильтры, системы жестовой навигации, различные всплывающие меню и так далее). Система просто блокировала возможность дать разрешение, если на экране находился оверлей.
Напомним, что SYSTEM_ALERT_WINDOW — одна из самых серьезных проблем Android. На ней построена опасная атака Cloak & Dagger, ее используют многие блокировщики экрана и банковские трояны. Однако исправить эту проблему, не сломав совместимость с существующим софтом, невозможно, и Google приходится искать пути минимизации риска. Для этого уже было сделано несколько шагов.
1. Устрани утечки памяти. Это можно сделать, используя инструмент LeakCanary, который будет показывать уведомление каждый раз, когда есть подозрение на утекшую активность, диалог или фрагмент.
2. Проанализируй использование памяти графическими элементами. Обычные Bitmap’ы, используемые в приложении, могут привести к ошибке OutOfMemoryError, когда приложение завершается из‑за нехватки памяти. Чтобы этого избежать, масштабируй изображения до меньшего размера, применяй кеширование и своевременно удаляй закешированную графику. Вот несколько советов, как сделать это с помощью популярной библиотеки загрузки изображений Glide.
Другие советы:
В отличие от HashMap, который для хранения каждого объекта создает новый объект и сохраняет его в массиве, ArrayMap не создает дополнительный объект, но использует два массива: mHashes для последовательного хранения хешей ключей и mArray для хранения ключей и их значений (друг за другом). Начальный размер первого — четыре, второго — восемь.
Анатомия ArrayMap
При добавлении элемента ArrayMap сначала добавляет его хеш в первый массив, а затем ключ и значение во второй массив, где индекс ключа высчитывается как индекс хеша в массиве mHashes, умноженный на два, а индекс значения как индекс ключа плюс один. В случае коллизии (когда два разных ключа имеют одинаковый хеш) ArrayMap производит линейный поиск ключа в mArray и, если он не найден, добавляет новый хеш в mHashes и новые ключ:значение в mArray. При достижении предельного размера массивов ArrayMap копирует их в новый массив, размер которого высчитывается так: oldSize+(oldSize>>1) (4 → 8 → 12 → 18 → 27 → ...).
SparseArray представляет собой тот же ArrayMap, но предназначенный для работы с типами данных, где ключ — это int, а значение может быть либо объектом, либо простым типом данных: int, long, boolean (SparseIntArray, SparseLongArray, SparseBooleanArray). В итоге SparseArray нет необходимости хранить обертки над простыми типами данных.
Благодаря избавлению от необходимости хранить дополнительный объект для каждого элемента, ArrayMap оказывается примерно на 25% экономнее HashMap, а SparseArray почти в два раза экономнее.
HashMap vs ArrayMap vs SparseArray: использование памяти для 1000 объектов
В то же время ArrayMap и SparseArray в целом в два раза медленнее HashMap.
Выводы:
RecyclerView — известный и очень популярный элемент интерфейса Android, позволяющий создать динамически формируемый (бесконечный) список элементов с ленивой загрузкой и переиспользуемыми элементами UI. Говоря простыми словами: RecyclerView — это быстрый список из произвольного количества элементов, который будет расходовать память только на те элементы, которые в данный момент находятся на экране.
RecyclerView — очень мощный и сложный инструмент. Чтобы создать список с его помощью, необходимо создать сам RecyclerView, подключить к нему адаптер, который будет наполнять его элементами, подключить менеджер лейаутов и создать один или несколько viewHolder’ов, которые будут хранить графическое представление элементов списка.
А теперь посмотрим, как создать аналог RecyclerView с использованием фреймворка Jetpack Compose:
Это действительно все.
Допустим, у нас есть форма с тремя полями: First Name, Password и User Id. Наша задача — сделать так, чтобы кнопка Submit активировалась лишь в том случае, если поле First Name содержит только символы латинского алфавита, поле Password содержит как минимум восемь символов, а поле User Id содержит хотя бы один символ подчеркивания.
Для хранения текущего значения поля будем использовать StateFlow:
Дополнительно создадим три метода, чтобы записывать значения в эти StateFlow:
Теперь объединим все три StateFlow в один Flow, который будет отдавать только значения true или false:
Этот код будет запускаться каждый раз, когда состояние любого из трех StateFlow изменится.
Теперь осталось только привязать три первых StateFlow к полям ввода:
А состояние кнопки Submit привязать к полученному в результате преобразования Flow:
Что делает весь этот код? При изменении любого из полей ввода будет автоматически изменено значение одного из трех StateFlow. Это, в свою очередь, повлечет за собой запуск функции combine, которая в итоге выпустит новое значение в поток isSubmitEnabled. На это действие среагирует код внутри функции collectFlow(). В итоге он изменит состояние кнопки.
Почитать
Подмена диалогов запроса полномочий
Подменяем Runtime permissions в Android — статья об уязвимости Android, позволяющей подменить системный диалог запроса разрешений своим, чтобы обманом заставить пользователя выдать приложению опасные полномочия.В целом в статье нет ничего нового, и она просто описывает известный баг, а точнее, очередной design flaw Android, связанный с оверлеями. Если кратко, суть истории в том, что в Android есть специальное разрешение SYSTEM_ALERT_WINDOW, позволяющее «рисовать» поверх любых других окон. Это же разрешение можно использовать в корыстных целях, чтобы нарисовать поверх системного окна запроса полномочий, используемого приложениями для запроса прав на то или иное действие, свое собственное окно, которое предлагает пользователю дать разрешение на другое, более безобидное действие.
Google в курсе этой проблемы и даже «исправила» ее сразу при появлении в Android системы запроса разрешений (Android 6.0), но уже в версии Android 7.0 отказалась от исправления из‑за многочисленных жалоб пользователей софта с функцией SYSTEM_ALERT_WINDOW (экранные фильтры, системы жестовой навигации, различные всплывающие меню и так далее). Система просто блокировала возможность дать разрешение, если на экране находился оверлей.
Напомним, что SYSTEM_ALERT_WINDOW — одна из самых серьезных проблем Android. На ней построена опасная атака Cloak & Dagger, ее используют многие блокировщики экрана и банковские трояны. Однако исправить эту проблему, не сломав совместимость с существующим софтом, невозможно, и Google приходится искать пути минимизации риска. Для этого уже было сделано несколько шагов.
- В Google Play теперь есть белый список приложений, которые могут получить разрешение SYSTEM_ALERT_WINDOW без необходимости его запрашивать (раньше все приложения из Google Play получали его автоматически).
- Начиная с Android 8 оверлеи не могут перекрывать строку состояния, а сам оверлей можно быстро отключить в панели уведомлений. Это сделано для борьбы с ransomware, показывающими поверх экрана оверлей, который нельзя никаким образом отключить.
- Начиная с Android 10 приложения, установленные не из Google Play, лишаются разрешения на показ оверлеев через 30 секунд после того, как приложение будет завершено или перезапущено. Приложения из Google Play лишатся этого разрешения после перезагрузки.
- В настройках Android теперь есть опция, полностью запрещающая использовать оверлеи поверх окна настроек (по умолчанию отключена).
Разработчику
Как сократить расход памяти приложением
Decrease memory usage of your Android app in half — очередная статья о способах сократить использование оперативной памяти приложением.1. Устрани утечки памяти. Это можно сделать, используя инструмент LeakCanary, который будет показывать уведомление каждый раз, когда есть подозрение на утекшую активность, диалог или фрагмент.
2. Проанализируй использование памяти графическими элементами. Обычные Bitmap’ы, используемые в приложении, могут привести к ошибке OutOfMemoryError, когда приложение завершается из‑за нехватки памяти. Чтобы этого избежать, масштабируй изображения до меньшего размера, применяй кеширование и своевременно удаляй закешированную графику. Вот несколько советов, как сделать это с помощью популярной библиотеки загрузки изображений Glide.
- По умолчанию библиотека использует формат ARGB_8888 для хранения изображений. Изменив его на RGB_565, можно вдвое сократить использование памяти, не сильно потеряв в качестве (можно использовать только на low-end-устройствах):
-
Код:
@GlideModule class CustomGlideModuleV4 : AppGlideModule() { override fun applyOptions(context: Context, builder: GlideBuilder) { builder.setDefaultRequestOptions( RequestOptions().format(DecodeFormat.PREFER_RGB_565) ) } } GlideApp.with(view.context) .load("$imgUrl$IMAGE_URL_SIZE_SPEC") .into(view) - Избежать проблемы нехватки памяти можно, очищая кеш изображений при ее нехватке. Для этого добавь в Application-класс приложения следующие строки:
Код:override fun onTrimMemory(level: Int) { GlideApp.with(applicationContext).onTrimMemory(TRIM_MEMORY_MODERATE) super.onTrimMemory(level) } - Чтобы уменьшить размер изображения, можно использовать такой код:
Код:Glide .with(context) .load(url) .apply(new RequestOptions().override(600, 200)) .into(imageView);
Другие советы:
- при использовании RecyclerView по возможности используй notifyItemChanged() вместо notifyDataSetChanged();
- не создавай дополнительных объектов‑оберток там, где этого можно избежать;
- уменьши размер APK, это приведет к уменьшению памяти, занимаемой приложением;
- не храни объекты «на всякий случай»;
- запускай бенчмарки на релизных билдах;
- избавься от избыточных анимаций.
Kotlin и видимость API
Mastering API Visibility in Kotlin — статья о том, как сделать интерфейсы библиотек как можно более закрытыми, сохранив гибкость, возможности тестирования и возможность взаимодействовать с кодом на Java.- Internal — твой друг. Этот модификатор видимости чем‑то похож на package private в Java, но покрывает не пакет, а целый модуль. Все классы, поля и методы, помеченные этим ключевым словом, будут видны только внутри текущего модуля.
- Модификатор internal можно использовать совместно с аннотацией @VisibleForTesting, чтобы тесты могли достучаться до нужных методов и полей:
Код:@VisibleForTesting(otherwise = PRIVATE) internal var state: State - В Java нет модификатора internal, поэтому в байт‑коде все, что помечено этим ключевым словом, станет public, но с одним важным отличием: к его имени прибавится название модуля. Например, метод createEntity со стороны Java будет выглядеть как createEntity$имяМодуля. Этого можно избежать с помощью аннотации @JvmName, позволяющей указать другое имя для использования из Java:
Если же метод не должен быть виден вообще, можно использовать аннотацию @JvmSynthetic:Код:class Repository { @JvmName("pleaseDoNotCallThisMethod") internal fun createEntity() { ... } }
Код:class Repository { @JvmSynthetic internal fun createEntity() { ... } } - Explicit API mode — твой второй друг. В Kotlin все объявления по умолчанию получают модификатор public. А это значит, что шанс забыть сделать метод internal или private высок. Специально для борьбы с этой проблемой в Kotlin 1.4 появился Explicit API mode, который заставляет добавлять модификатор видимости к любым объявлениям. Чтобы его включить, достаточно добавить три строки в конфиг Gradle:
Код:kotlin { explicitApi() } - Одно из неожиданных следствий использования internal — инлайновые функции не смогут использовать методы, помеченные этим ключевым словом. Так происходит потому, что код инлайновой функции полностью встраивается в вызывающий код, а он не имеет доступа к методам, помеченным как internal. Решить эту проблему можно с помощью аннотации @PublishedApi. Она сделает метод доступным для инлайновых функций, но оставит закрытым для всех остальных:
Код:@PublishedApi internal fun secretFunction() { println("through the mountains") } public inline fun song() { secretFunction() } fun clientCode() { song() // ok secretFunction() // Нет доступа }
Что такое ArrayMap и SparseArray
All you need to know about ArrayMap & SparseArray — статья об ArrayMap и SparseArray, двух фирменных, но не так хорошо известных коллекциях Android. Обе коллекции по сути аналоги HashMap из Java с тем исключением, что они созданы специально, чтобы минимизировать потребление оперативной памяти.В отличие от HashMap, который для хранения каждого объекта создает новый объект и сохраняет его в массиве, ArrayMap не создает дополнительный объект, но использует два массива: mHashes для последовательного хранения хешей ключей и mArray для хранения ключей и их значений (друг за другом). Начальный размер первого — четыре, второго — восемь.
Анатомия ArrayMap
При добавлении элемента ArrayMap сначала добавляет его хеш в первый массив, а затем ключ и значение во второй массив, где индекс ключа высчитывается как индекс хеша в массиве mHashes, умноженный на два, а индекс значения как индекс ключа плюс один. В случае коллизии (когда два разных ключа имеют одинаковый хеш) ArrayMap производит линейный поиск ключа в mArray и, если он не найден, добавляет новый хеш в mHashes и новые ключ:значение в mArray. При достижении предельного размера массивов ArrayMap копирует их в новый массив, размер которого высчитывается так: oldSize+(oldSize>>1) (4 → 8 → 12 → 18 → 27 → ...).
SparseArray представляет собой тот же ArrayMap, но предназначенный для работы с типами данных, где ключ — это int, а значение может быть либо объектом, либо простым типом данных: int, long, boolean (SparseIntArray, SparseLongArray, SparseBooleanArray). В итоге SparseArray нет необходимости хранить обертки над простыми типами данных.
Благодаря избавлению от необходимости хранить дополнительный объект для каждого элемента, ArrayMap оказывается примерно на 25% экономнее HashMap, а SparseArray почти в два раза экономнее.
HashMap vs ArrayMap vs SparseArray: использование памяти для 1000 объектов
В то же время ArrayMap и SparseArray в целом в два раза медленнее HashMap.
HashMap vs ArrayMap vs SparseArray: рандомные операции чтенияВыводы:
- по возможности используй ArrayMap;
- используй SparseArray, если ключи имеют тип int;
- если размер коллекции известен — указывай его в конструкторе.
RecyclerView с помощью Jetpack Compose
How to make a RecyclerView in Jetpack Compose — краткая заметка о том, как создать собственный RecyclerView, используя библиотеку Jetpack Compose.RecyclerView — известный и очень популярный элемент интерфейса Android, позволяющий создать динамически формируемый (бесконечный) список элементов с ленивой загрузкой и переиспользуемыми элементами UI. Говоря простыми словами: RecyclerView — это быстрый список из произвольного количества элементов, который будет расходовать память только на те элементы, которые в данный момент находятся на экране.
RecyclerView — очень мощный и сложный инструмент. Чтобы создать список с его помощью, необходимо создать сам RecyclerView, подключить к нему адаптер, который будет наполнять его элементами, подключить менеджер лейаутов и создать один или несколько viewHolder’ов, которые будут хранить графическое представление элементов списка.
А теперь посмотрим, как создать аналог RecyclerView с использованием фреймворка Jetpack Compose:
Код:
data class ItemViewState(
val text: String
)
@Composable
fun MyComposeList(
modifier: Modifier = Modifier,
itemViewStates: List<ItemViewState>
) {
LazyColumnFor(modifier = modifier, items = itemViewStates) { viewState ->
MyListItem(itemViewState = viewState)
}
}
@Composable
fun MyListItem(itemViewState: ItemViewState) {
Text(text = itemViewState.text)
}
Это действительно все.
Валидация форм с помощью Kotlin Flow
Using Flows for Form Validation in Android — короткая заметка о том, как реализовать валидацию форм с помощью Kotlin Flow. Интересна в первую очередь в качестве простой и наглядной демонстрации работы недавно появившегося StateFlow.Допустим, у нас есть форма с тремя полями: First Name, Password и User Id. Наша задача — сделать так, чтобы кнопка Submit активировалась лишь в том случае, если поле First Name содержит только символы латинского алфавита, поле Password содержит как минимум восемь символов, а поле User Id содержит хотя бы один символ подчеркивания.
Для хранения текущего значения поля будем использовать StateFlow:
Код:
private val _firstName = MutableStateFlow("")
private val _password = MutableStateFlow("")
private val _userID = MutableStateFlow("")
Дополнительно создадим три метода, чтобы записывать значения в эти StateFlow:
Код:
fun setFirstName(name: String) {
_firstName.value = name
}
fun setPassword(password: String) {
_password.value = password
}
fun setUserId(id: String) {
_userID.value = id
}
Код:
val isSubmitEnabled: Flow<Boolean> = combine(_firstName, _password, _userID) { firstName, password, userId ->
val regexString = "[a-zA-Z]+"
val isNameCorrect = firstName.matches(regexString.toRegex())
val isPasswordCorrect = password.length > 8
val isUserIdCorrect = userId.contains("_")
return@combine isNameCorrect and isPasswordCorrect and isUserIdCorrect
}
Теперь осталось только привязать три первых StateFlow к полям ввода:
Код:
private fun initListeners() {
editText_name.addTextChangedListener {
viewModel.setFirstName(it.toString())
}
editText_password.addTextChangedListener {
viewModel.setPassword(it.toString())
}
editText_user.addTextChangedListener {
viewModel.setUserId(it.toString())
}
}
Код:
private fun collectFlow() {
lifecycleScope.launch {
viewModel.isSubmitEnabled.collect { value ->
submit_button.isEnabled = value
}
}
}
Что делает весь этот код? При изменении любого из полей ввода будет автоматически изменено значение одного из трех StateFlow. Это, в свою очередь, повлечет за собой запуск функции combine, которая в итоге выпустит новое значение в поток isSubmitEnabled. На это действие среагирует код внутри функции collectFlow(). В итоге он изменит состояние кнопки.
Инструменты
- Apk-medit — утилита для поиска и изменения данных в памяти (аналог ArtMoney);
- APKLab — плагин VS Code для реверса и пересборки APK;
- RASEv1 — скрипт для рутинга стандартного эмулятора Android.
Библиотеки
- Speedometer — полукруглый прогресс‑бар;
- NoNameBottomBar — очередная панель управления в нижней части экрана;
- Bottom-sheets — коллекция диалогов в нижней части экрана: время, календарь, цвет и прочее;
- libCFSurface — библиотека, позволяющая выводить информацию на экран напрямую, используя права root;
- Strong-frida — патчи для Frida, позволяющие избежать обнаружения фреймворка;
- Circle-menu — круговое меню;
- onboardingflow — подсветка элемента интерфейса для обучающих экранов;
- fingerprint-android — библиотека для фингепринтинга устройств;
- Flower — библиотека на базе Kotlin Flow для организации получения и кеширования данных из сети;
- Kable — библиотека для асинхронной работы с BLE-устройствами;
- Accompanist — функции для разработки приложения на Jetpack Compose;
- Simple Settings — библиотека для создания экранов настроек;
- Belay — библиотека для обработки ошибок.