Предыстория
Ещё с детства мой отец приучил меня пользоваться антивирусами. Соблюдая традиции, я купил себе подписку на антивирус для Андроида. Оказалось, в приложении есть крайне интересная фича — установка ПИН-кода для других приложений на устройстве. Интересной она была для меня тем, что я, как мобильный разработчик, не имел ни малейшего понятия, как подобное можно сделать. И вот теперь, после непродолжительных раскопок и проделанной работы, я делюсь своим опытом.
План
Приложение должно уметь:
Распознавать, когда показывать экран с ПИН-кодом;
Показывать ПИН-код при открытии самого приложения (в рамках «самозащиты»);
Выбирать приложений для блока;
Создавать/менять ПИН-код;
Защищать себя от удаления.
Определение текущего запущенного приложения
Первым делом необходимо было решить проблему — «как определить, что открылось приложение, которое нужно блокировать?».
Сначала мой взгляд пал на вариант с отловом системных сообщений через BroadcastReceiver.
К сожалению, оказалось что невозможно отловить интент запуска приложения.
Вторым возможным решением было регулярно (раз в несколько секунд) проверять запущенные приложения, через ActivityManager.runningAppProcesses().
Но, насколько я понимаю, сейчас данный метод возвращает только процессы текущего приложения, что нам не подходит.
Что ж, остаётся только...старый добрый AccessibilityService
AccessibilityService — очень мощный и одновременно опасный инструмент. Его «полезные функции» (определение и озвучивание текста на экране, нажатие на кнопки, свайпы и т.д.) для людей с ограниченными возможностями можно использовать в самых ужасных целях, например, в наших. Так и поступим.
Начальное решение
Для начала мы добавим в манифест наш сервис и необходимые разрешения:
AndroidManifest.xml
<uses-permission
android:name="android.permission.BIND_ACCESSIBILITY_SERVICE"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
***
<service
android:name=".service.PinAccessibilityService"
android:exported="true"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="pin_code_for_another_apps" />
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibilityservice" />
</service>
Метаданные для PinAccessibilityService.
accessibilityservice.xml
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:canRequestEnhancedWebAccessibility="true"
android:notificationTimeout="100"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagIncludeNotImportantViews"
android:canRetrieveWindowContent="true"/>
Какие конкретные задачи должен решать наш сервис:
Определять, что перед нами приложение, которое нужно защитить;
Определять, что перед нами наше приложение, чтобы оно защищало само себя;
Показывать экран с вводом ПИН-кода.
Приступим к написанию самого AccessibilityService.
При запуске сервиса мы закрепляем уведомление, чтобы следить, жив ли наш сервис (AccessibilityService может работать в фоне без запуска как ForegroundService).
PinAccessibilityService.kt
class PinAccessibilityService : AccessibilityService() {
override fun onCreate() {
super.onCreate()
startForeground()
}
private fun startForeground() {
val channelId = createNotificationChannel()
val notification = NotificationCompat.Builder(this, channelId)
.setContentTitle("Pin On App")
.setContentText("Apps protected")
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.setPriority(PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
startForeground(101, notification)
}
private fun createNotificationChannel(): String {
val channelId = "pin_on_app_service"
val channelName = "PinOnApp"
val channel = NotificationChannel(
channelId,
channelName,
NotificationManager.IMPORTANCE_HIGH,
).apply {
lightColor = Color.BLUE
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
service.createNotificationChannel(channel)
return channelId
}
}
Далее, мы пытаемся определить, что за экран перед нами и, в зависимости от этого, блокируем или не блокируем пользователя.
Логика следующая:
Если показывается Launcher — пользователь был на основном экране, а значит при запуске защищенного приложения можно показывать экран с вводом ПИН-кода.
Если открывается приложение, которое нужно блокировать, и ПИН не был введен — показываем экран с вводом ПИН-кода. Нужное нам приложение определяем по его packageName.
Если поставить защиту на наше приложение, то мы будем попадать в цикл и бесконечно открывать экран ввода ПИНа, т.к. мы ориентируемся на packageName приложений, а packageName у экрана блока и нашего приложения одинаковый. Для решения этой проблемы мы завяжемся на уникальный идентификатор основного экрана.
Launcher-ы на разных ОС разные, поэтому мы заранее получаем список всех Launcher-ов на устройстве.
PinAccessibilityService.kt
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val event = MutableSharedFlow<AccessibilityEvent>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val spRepository by inject<SharedPreferencesRepository>()
private var packageForPinList = spRepository.packageIdList
private var launcherList = listOf<String>()
private val isPinCodeNotExist
get() = spRepository.pinCode.isNullOrEmpty()
private val isCorrectPin
get() = spRepository.isCorrectPin == true
init {
event
.filter { !isPinCodeNotExist && !isCorrectPin }
.onEach { _ ->
spRepository.isCorrectPin = false
val startActivityIntent = Intent(applicationContext, ConfirmActivity::class.java)
.apply {
setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
or Intent.FLAG_ACTIVITY_NO_ANIMATION
or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS,
)
}
startActivity(startActivityIntent)
}.catch {
Log.e("ERROR", it.message, it)
}.launchIn(scope)
}
override fun onCreate() {
super.onCreate()
startForeground()
setLauncherListOnDevice()
}
private fun setLauncherListOnDevice() {
val i = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
}
launcherList = packageManager.queryIntentActivities(i, 0).map {
it.activityInfo.packageName
}
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event != null) {
if (launcherList.contains(event.packageName)) {
spRepository.isCorrectPin = false
} else if (packageForPinList.contains(event.packageName) || event.isMainActivityShowed()) {
this.event.tryEmit(event)
}
}
}
private fun AccessibilityEvent.isMainActivityShowed() =
className?.contains(APP_CLASS_NAME) ?: false
override fun onInterrupt() {}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
companion object {
private const val APP_CLASS_NAME = "com.dradefire.securepinonapp.ui.main.MainActivity"
}
Список приложений мы будем сохранять в SharedPreferences, для которых напишем репозиторий (для внедрения зависимостей мы будем использовать Koin).
KoinModule.kt
val KoinModule = module {
viewModelOf(::ConfirmViewModel)
viewModelOf(::MainViewModel)
factoryOf(::SharedPreferencesRepository)
singleOf(::Gson)
}
App.kt
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@App)
modules(KoinModule)
}
}
}
SharedPreferencesRepository.kt
class SharedPreferencesRepository(
private val context: Context,
private val gson: Gson,
) {
/**
* Был ли введён правильный ПИН-код (нужно, чтобы лишний раз не показывать экран блокировки)
*/
var isCorrectPin: Boolean?
get() = context.sp.getBoolean(IS_CORRECT_PIN_KEY, false)
set(isCorrectPin) {
context.sp.edit().putBoolean(IS_CORRECT_PIN_KEY, isCorrectPin ?: false).apply()
}
/**
* ПИН-код
*/
var pinCode: String?
get() = context.sp.getString(PIN_KEY, null)
set(pinCode) {
context.sp.edit().putString(PIN_KEY, pinCode).apply()
}
/**
* Список приложений, которые нужно защитить ПИН-кодом
*/
var packageIdList: List<String>
get() = gson.fromJson(
context.sp.getString(
PACKAGE_ID_LIST_KEY,
gson.toJson(emptyList<String>()),
),
List::class.java,
) as List<String>
set(list) {
context.sp.edit().putString(PACKAGE_ID_LIST_KEY, gson.toJson(list)).apply()
}
private val Context.sp
get() = getSharedPreferences(SECURE_PIN_ON_APP_STORAGE, Context.MODE_PRIVATE)
companion object {
private const val PACKAGE_ID_LIST_KEY = "PACKAGE_ID_LIST"
private const val PIN_KEY = "PIN"
private const val IS_CORRECT_PIN_KEY = "IS_CORRECT_PIN"
private const val SECURE_PIN_ON_APP_STORAGE = "secure_pin_on_app_storage"
}
}
Corner case с уведомлениями
В процессе тестирования нашего сервиса на приложении «Сообщения» я обнаружил, что когда приходят сообщения (а в этот момент появляется уведомление), то неожиданно показывается экран блокировки.
Для решения этой проблемы мы просто будем игнорировать TYPE_NOTIFICATION_STATE_CHANGED.
PinAccessibilityService.kt
init {
event
.filter { !isPinCodeNotExist && !isCorrectPin && it.eventType != TYPE_NOTIFICATION_STATE_CHANGED }
.onEach { event ->
***
}.catch {
Log.e("ERROR", it.message, it)
}.launchIn(scope)
}
Чуть-чуть улучшим UX
А точнее — кое-что полностью перепишем.
Каждый раз при открытии приложения пользователю может надоесть вводить ПИН: свернул приложение, вышел на основной экран и обратно и т.п.
Мы дадим пользователю вздохнуть свободнее: теперь, если он ввел ПИН для конкретного приложения ему не придется вводить ПИН, пока он не откроет другое защищенное приложение.
Поэтому мы избавляемся от завязки на Launcher-ы.
PinAccessibilityService.kt
private var lastPinnedAppPackageName: String? = null
init {
event
.filter { event ->
!isPinCodeNotExist && !isCorrectPin &&
event.eventType != TYPE_NOTIFICATION_STATE_CHANGED || event.packageName != lastPinnedAppPackageName
}
.onEach { event ->
spRepository.isCorrectPin = false
lastPinnedAppPackageName = event.packageName.toString()
val startActivityIntent = Intent(this, ConfirmActivity::class.java)
.apply {
setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
or Intent.FLAG_ACTIVITY_NO_ANIMATION
or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS,
)
}
startActivity(startActivityIntent)
}.catch {
Log.e("ERROR", it.message, it)
}.launchIn(scope)
}
override fun onCreate() {
super.onCreate()
startForeground()
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event != null) {
if (packageForPinList.contains(event.packageName) || event.isMainActivityShowed()) {
this.event.tryEmit(event)
}
}
}
Основой экран
У основного экрана есть несколько задач:
Запросить разрешения (на отправку уведомлений и использование нашего сервиса);
Дать возможность поставить и убрать блок с любого приложения на устройстве;
Дать возможность сменить ПИН-код.
MainActivity.kt
class MainActivity : ComponentActivity() {
private val spRepository by inject<SharedPreferencesRepository>()
private val viewModel by viewModel<MainViewModel>()
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
checkPermission()
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
override fun onStart() {
checkPermission()
super.onStart()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Если ПИН-код не существует - даем возможность задать его
if (spRepository.pinCode == null) {
openConfirmActivityWithSettingPinCode()
}
// Достаём список всех приложений на устройстве
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_LAUNCHER)
}
val applicationList = packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_ALL,
).distinctBy { it.activityInfo.packageName }
val packageIdListInit = spRepository.packageIdList
val appInfoListInit = applicationList.mapNotNull {
val activityInfo = it.activityInfo
if (activityInfo.packageName == APP_PACKAGE_ID) { // Текущее приложение не показываем
null
} else {
ApplicationInfo(
icon = activityInfo.applicationInfo.loadIcon(packageManager)
.toBitmap(),
name = activityInfo.applicationInfo.loadLabel(packageManager)
.toString(),
packageId = activityInfo.packageName,
isSecured = packageIdListInit.contains(activityInfo.packageName),
)
}
}
setContent {
MaterialTheme {
var appInfoList = remember {
appInfoListInit.toMutableStateList()
}
val isAccessibilityGranted by viewModel.isAccessibilityGranted.collectAsState()
val isNotificationGranted by viewModel.isNotificationGranted.collectAsState()
if (!isAccessibilityGranted || !isNotificationGranted) {
Dialog(onDismissRequest = {
// block
}) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
shape = RoundedCornerShape(16.dp),
) {
Text(
text = "Необходимы следующие разрешения:",
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
textAlign = TextAlign.Center,
)
if (!isAccessibilityGranted) {
OutlinedButton(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
onClick = {
// Запрашиваем разрешение на использование Специальных возможностей
val openSettings =
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
openSettings.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_HISTORY)
startActivity(openSettings)
},
) {
Text(text = "Специальные возможности")
}
}
if (!isNotificationGranted) {
OutlinedButton(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
onClick = {
// Запрашиваем разрешение на отправление уведомлений (нужно запрашивать с 33 API)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions(
this@MainActivity,
arrayOf(POST_NOTIFICATIONS),
1,
)
}
},
) {
Text(text = "Уведомления")
}
}
}
}
}
Screen(
***
)
}
}
}
/**
* Проверка необходимых разрешений:
* 1. Специальные возможности (AccessibilityService)
* 2. Уведомления
*/
private fun checkPermission() {
val isAccessibilityGranted = isAccessibilityServiceEnabled()
viewModel.setAccessibilityPermission(isAccessibilityGranted)
val isNotificationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(
this@MainActivity,
POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
viewModel.setNotificationPermission(isNotificationGranted)
}
private fun openConfirmActivityWithSettingPinCode() {
val startActivityIntent = Intent(applicationContext, ConfirmActivity::class.java)
.apply {
setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
or Intent.FLAG_ACTIVITY_NO_ANIMATION
or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS,
)
putExtra("isSettingPinCode", true) // Задаем новый ПИН-код
}
startActivity(startActivityIntent)
}
data class ApplicationInfo(
val icon: Bitmap,
val name: String,
val packageId: String,
val isSecured: Boolean,
)
}
AccessibilityServiceUtils.kt
// Copied from https://mhrpatel12.medium.com/android-accessibility-service-the-unexplored-goldmine-d336b0f33e30
fun Context.isAccessibilityServiceEnabled(): Boolean {
var accessibilityEnabled = 0
val service: String = packageName + "/" + PinAccessibilityService::class.java.canonicalName
try {
accessibilityEnabled = Settings.Secure.getInt(
applicationContext.contentResolver,
Settings.Secure.ACCESSIBILITY_ENABLED,
)
} catch (e: SettingNotFoundException) {
Log.e(
"ACCESSIBILITY_ENABLED_LOG",
"Error finding setting, default accessibility to not found: " + e.message,
)
}
val mStringColonSplitter = SimpleStringSplitter(':')
if (accessibilityEnabled == 1) {
val settingValue: String = Settings.Secure.getString(
applicationContext.contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
)
mStringColonSplitter.setString(settingValue)
while (mStringColonSplitter.hasNext()) {
val accessibilityService = mStringColonSplitter.next()
if (accessibilityService.equals(service, ignoreCase = true)) {
return true
}
}
}
return false
}
MainViewModel.kt
class MainViewModel(
private val sharedPreferencesRepository: SharedPreferencesRepository,
) : ViewModel() {
private val _isAccessibilityGranted = MutableStateFlow(false)
val isAccessibilityGranted = _isAccessibilityGranted.asStateFlow()
private val _isNotificationGranted = MutableStateFlow(false)
val isNotificationGranted = _isNotificationGranted.asStateFlow()
fun setAccessibilityPermission(isGranted: Boolean) {
_isAccessibilityGranted.update { isGranted }
}
fun setNotificationPermission(isGranted: Boolean) {
_isNotificationGranted.update { isGranted }
}
fun onSwitchClick(packageId: String, checked: Boolean) {
val packageIdList = sharedPreferencesRepository.packageIdList.toMutableSet()
if (checked) {
packageIdList.add(packageId)
} else {
packageIdList.remove(packageId)
}
sharedPreferencesRepository.packageIdList = packageIdList.toList()
}
}
Информация о приложениях для блокировки сама себя не обновит, поэтому раз в 4 секунды наш сервис будет проверять и обновлять список вот таким образом.
PinAccessibilityService.kt
init {
scope.launch {
while (isActive) {
delay(4000)
packageForPinList = spRepository.packageIdList
}
}
***
}
В итоге наш основной экран выглядит вот так:
А это диалог запроса разрешений на основном экране:
Экран с вводом ПИН-кода
Это тот экран на котором мы будем блокировать пользователя. Он не особо хитрый: пользователь вводит 4 цифры и либо проходит проверку, либо пробует дальше. Этот же экран, в зависимости от ситуации, будет ожидать как ввод текущего ПИН-кода так и нового.
ConfirmActivity.kt
class ConfirmActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val viewModel = koinViewModel<ConfirmViewModel>()
val pinCode by viewModel.pinCode.collectAsState()
val isSettingPinCode =
intent.getBooleanExtra("isSettingPinCode", false) || viewModel.isPinCodeNotExist
LaunchedEffect(Unit) {
viewModel.closeActivityEvent.collect {
finishAndRemoveTask()
}
}
BackHandler {
// block button
}
MaterialTheme {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
BlockScreen(
onButtonClick = {
viewModel.onButtonClick(it, isSettingPinCode)
},
pinCodeLength = pinCode.length,
isPinValid = viewModel.isPinValid,
title = if (isSettingPinCode) "Set PIN" else "Enter PIN",
)
}
}
}
}
***
}
ConfirmViewModel.kt
class ConfirmViewModel(
private val sharedPreferencesRepository: SharedPreferencesRepository,
) : ViewModel() {
private val correctPinCode = sharedPreferencesRepository.pinCode
val isPinCodeNotExist = sharedPreferencesRepository.pinCode.isNullOrEmpty()
private val _closeActivityEvent = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val closeActivityEvent = _closeActivityEvent.asSharedFlow()
private val _pinCode = MutableStateFlow("")
val pinCode = _pinCode.asStateFlow()
val isPinValid
get() = _pinCode.value == correctPinCode
fun onButtonClick(event: ButtonClickEvent, isSettingPinCode: Boolean) {
when (event) {
is ButtonClickEvent.Number -> {
_pinCode.update { it + event.number }
if (_pinCode.value.length >= 4) {
handleEnteredFullPinCode(isSettingPinCode)
}
}
ButtonClickEvent.Delete -> {
if (_pinCode.value.isNotEmpty()) {
_pinCode.update { it.substring(0, it.length - 1) }
}
}
}
}
private fun handleEnteredFullPinCode(isSettingPinCode: Boolean) {
if (isSettingPinCode) {
sharedPreferencesRepository.pinCode = _pinCode.value
onSuccessPinEnter()
} else if (isPinValid) {
onSuccessPinEnter()
} else {
_pinCode.update { "" }
}
}
private fun onSuccessPinEnter() {
sharedPreferencesRepository.isCorrectPin = true
_closeActivityEvent.tryEmit(Unit)
}
sealed interface ButtonClickEvent {
data class Number(val number: Int) : ButtonClickEvent
data object Delete : ButtonClickEvent
}
}
Вот такой экран блокировки у нас получился:
Лучшая защита — это нападение (на права админа, не путать с root)
А теперь вишенка на торте безопасности нашего приложения.
«Но мы же поставили ПИН-код на наше приложение, что ещё надо?» — спросит неопытный читать.
А я задам ответный вопрос: «А если человек захочет удалить наше предложение?»
Сделает он это очень быстро и просто.
Решать данную проблему мы будем через получение прав админа на приложение.
Но этого будет недостаточно, так как пользователь сможет в любой момент убрать приложение из админов.
Потому мы:
Принудительно поставим ПИН-код на настройки (в нашем случае, приложение так и называется — «Настройки»);
В случае, если у нашего приложения будут забраны права админа, то мы просто заблокируем устройство (аналогично нажатию кнопки выключения).
После таких манипуляций, пользователю, чтобы удалить наше приложение, придется вводить ПИН-коды и приложения, и устройства, что обеспечивает неплохую (хоть и не идеальную) безопасность.
Для получения прав админа необходимо написать свой DeviceAdminReceiver — подкласс BroadcastReceiver-a, который позволяет перехватывать системные сообщения, а также способный выполнять ряд привилегированных действий, например, менять пароль или очищать данные на устройстве.
AndroidManifest.xml
<receiver
android:name=".receiver.AdminReceiver"
android:label="@string/app_name"
android:permission="android.permission.BIND_DEVICE_ADMIN"
android:exported="false">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin"/>
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED"/>
</intent-filter>
</receiver>
Метаданные для AdminReceiver; использоваться будет только функция форсирования блокировки экрана (force-lock).
device_admin.xml
<?xml version="1.0" encoding="utf-8"?>
<device-admin>
<uses-policies>
<force-lock />
</uses-policies>
</device-admin>
AdminReceiver.kt
class AdminReceiver : DeviceAdminReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
when (action) {
ACTION_DEVICE_ADMIN_DISABLED,
ACTION_DEVICE_ADMIN_DISABLE_REQUESTED -> {
val dpm = context.getSystemService(DevicePolicyManager::class.java)
dpm.lockNow()
}
}
}
}
Запрашиваем права админа у пользователя:
MainActivity.kt
private val dpm by lazy { getSystemService(DEVICE_POLICY_SERVICE) as DevicePolicyManager }
private val adminReceiver by lazy { ComponentName(applicationContext, AdminReceiver::class.java) }
***
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
***
setContent {
MaterialTheme {
***
val isAdminGranted by viewModel.isAdminGranted.collectAsState()
if (!isAccessibilityGranted || !isNotificationGranted || !isAdminGranted) {
Dialog(onDismissRequest = {
// block
}) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
shape = RoundedCornerShape(16.dp),
) {
***
if (!isAdminGranted) {
OutlinedButton(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
onClick = {
val adminAskIntent = Intent(ACTION_ADD_DEVICE_ADMIN).apply {
putExtra(EXTRA_DEVICE_ADMIN, adminReceiver)
putExtra(
EXTRA_ADD_EXPLANATION,
"Не против, если я позаимствую немного админских прав?",
)
}
startActivity(adminAskIntent)
},
) {
Text(text = "Права админа")
}
}
}
}
}
Screen(
***
)
}
}
}
***
/**
* Проверка необходимых разрешений:
* 1. Специальные возможности (AccessibilityService)
* 2. Уведомления
* 3. Права админа
*/
private fun checkPermission() {
val isAccessibilityGranted = isAccessibilityServiceEnabled()
viewModel.setAccessibilityPermission(isAccessibilityGranted)
val isAdminGranted = dpm.isAdminActive(adminReceiver)
viewModel.setAdminPermission(isAdminGranted)
val isNotificationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(
this@MainActivity,
POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
viewModel.setNotificationPermission(isNotificationGranted)
}
}
MainViewModel.kt
private val _isAdminGranted = MutableStateFlow(false)
val isAdminGranted = _isAdminGranted.asStateFlow()
fun setAdminPermission(isGranted: Boolean) {
_isAdminGranted.update { isGranted }
}
Также нам необходимо дополнить функционал PinAccessibilityService. В нем мы раз в 15 секунд будем проверять, есть ли права админа. Если есть, то будем устанавливать блокировку на «Настройки»:
PinAccessibilityService.kt
private var lastPinnedAppPackageName: String? = null
private var settingsList = listOf<String>()
private val dpm by lazy { getSystemService(DEVICE_POLICY_SERVICE) as DevicePolicyManager }
private val adminReceiver by lazy {
ComponentName(
applicationContext,
AdminReceiver::class.java
)
}
init {
scope.launch {
while (isActive) {
delay(15_000)
if (dpm.isAdminActive(adminReceiver)) {
val intent = Intent(ACTION_SETTINGS)
settingsList = packageManager
.queryIntentActivities(intent, PackageManager.MATCH_ALL)
.distinctBy { it.activityInfo.packageName }
.map { it.activityInfo.packageName }
}
}
}
scope.launch {
while (isActive) {
delay(4000)
packageForPinList = spRepository.packageIdList + settingsList
}
}
***
}
В итоге мы добились того, что пользователю, чтобы удалить приложение, понадобится приложить некоторые усилия:
Заключение
Целью данной статьи было показать, что для реализации программной хотелки нужно желание разобраться в интересующей вас теме... ну и наличие возможности в данной системе реализовать эту хотелку.
Можно ли сделать лучше? Естественно.
В статье я не учел обработку специфических виджетов (например, com.google.android.googlequicksearchbox), всевозможные «петли с открытием экрана блокировки», оптимизации, возможность использовать графический пароль, сброс ПИН-кода в случае, если пользователь забыл пароль и т.п. — это не входило в мои планы, но я надеюсь, что данная статья окажется вам полезной.
Весь код для этой статьи можно найти вот здесь (ссылка на код).
А это полезные ссылки на документацию:
— Device administration overview | Android Developers
— Create your own accessibility service | Android Developers
Shaman_RSHU
На чистом андройде 14: "Настройки -> Приложения -> Блокировки приложений" - это не то (или сейчас под смартфонами на андройд понимаются только с оболочкой Самсунга, как дистрибутивы Linux - это Ubuntu? :)
DradeFire Автор
Своё роднее :)
Но если серьёзно - я думаю, практическая польза статьи в том, что читатель сможет воспользоваться кодом для смежных/похожих задач.
Shaman_RSHU
Да, в любом случае сделать своё не скопипастив всегда роднее :)
trackline
Какой-то у вас "чистый" андроид странный, у меня на Pixel нет такого
Shaman_RSHU
Вру. У меня не чистый, а AOSP. Посмотрел на Pixel Experience - там нет.