Пишем своё приложение для установки PIN на другие приложения

от автора

Предыстория

Ещё с детства мой отец приучил меня пользоваться антивирусами. Соблюдая традиции, я купил себе подписку на антивирус для Андроида. Оказалось, в приложении есть крайне интересная фича — установка ПИН-кода для других приложений на устройстве. Интересной она была для меня тем, что я, как мобильный разработчик, не имел ни малейшего понятия, как подобное можно сделать. И вот теперь, после непродолжительных раскопок и проделанной работы, я делюсь своим опытом.

План

Приложение должно уметь:

  1. Распознавать, когда показывать экран с ПИН-кодом;

  2. Показывать ПИН-код при открытии самого приложения (в рамках «самозащиты»);

  3. Выбирать приложений для блока;

  4. Создавать/менять ПИН-код;

  5. Защищать себя от удаления.

Определение текущего запущенного приложения

Первым делом необходимо было решить проблему — «как определить, что открылось приложение, которое нужно блокировать?».

Сначала мой взгляд пал на вариант с отловом системных сообщений через 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    } } 

Далее, мы пытаемся определить, что за экран перед нами и, в зависимости от этого, блокируем или не блокируем пользователя.

Логика следующая:

  1. Если показывается Launcher — пользователь был на основном экране, а значит при запуске защищенного приложения можно показывать экран с вводом ПИН-кода.

  2. Если открывается приложение, которое нужно блокировать, и ПИН не был введен — показываем экран с вводом ПИН-кода. Нужное нам приложение определяем по его packageName.

  3. Если поставить защиту на наше приложение, то мы будем попадать в цикл и бесконечно открывать экран ввода ПИНа, т.к. мы ориентируемся на 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)            }        }    } 

Основой экран

У основного экрана есть несколько задач:

  1. Запросить разрешения (на отправку уведомлений и использование нашего сервиса);

  2. Дать возможность поставить и убрать блок с любого приложения на устройстве;

  3. Дать возможность сменить ПИН-код.

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)

А теперь вишенка на торте безопасности нашего приложения.

«Но мы же поставили ПИН-код на наше приложение, что ещё надо?» — спросит неопытный читать.
А я задам ответный вопрос: «А если человек захочет удалить наше предложение?»
Сделает он это очень быстро и просто.

Полное удаление

Полное удаление

Решать данную проблему мы будем через получение прав админа на приложение.

Но этого будет недостаточно, так как пользователь сможет в любой момент убрать приложение из админов.
Потому мы:

  1. Принудительно поставим ПИН-код на настройки (в нашем случае, приложение так и называется — «Настройки»);

  2. В случае, если у нашего приложения будут забраны права админа, то мы просто заблокируем устройство (аналогично нажатию кнопки выключения).

После таких манипуляций, пользователю, чтобы удалить наше приложение, придется вводить ПИН-коды и приложения, и устройства, что обеспечивает неплохую (хоть и не идеальную) безопасность.

Для получения прав админа необходимо написать свой 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *