Предыстория
Ещё с детства мой отец приучил меня пользоваться антивирусами. Соблюдая традиции, я купил себе подписку на антивирус для Андроида. Оказалось, в приложении есть крайне интересная фича — установка ПИН-кода для других приложений на устройстве. Интересной она была для меня тем, что я, как мобильный разработчик, не имел ни малейшего понятия, как подобное можно сделать. И вот теперь, после непродолжительных раскопок и проделанной работы, я делюсь своим опытом.
План
Приложение должно уметь:
-
Распознавать, когда показывать экран с ПИН-кодом;
-
Показывать ПИН-код при открытии самого приложения (в рамках «самозащиты»);
-
Выбирать приложений для блока;
-
Создавать/менять ПИН-код;
-
Защищать себя от удаления.
Определение текущего запущенного приложения
Первым делом необходимо было решить проблему — «как определить, что открылось приложение, которое нужно блокировать?».
Сначала мой взгляд пал на вариант с отловом системных сообщений через 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
ссылка на оригинал статьи https://habr.com/ru/articles/834768/
Добавить комментарий