Предыстория

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

План

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

  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

Комментарии (5)


  1. Shaman_RSHU
    08.08.2024 18:37
    +2

    На чистом андройде 14: "Настройки -> Приложения -> Блокировки приложений" - это не то (или сейчас под смартфонами на андройд понимаются только с оболочкой Самсунга, как дистрибутивы Linux - это Ubuntu? :)


    1. DradeFire Автор
      08.08.2024 18:37

      Своё роднее :)
      Но если серьёзно - я думаю, практическая польза статьи в том, что читатель сможет воспользоваться кодом для смежных/похожих задач.


      1. Shaman_RSHU
        08.08.2024 18:37
        +1

        Да, в любом случае сделать своё не скопипастив всегда роднее :)


    1. trackline
      08.08.2024 18:37

      Какой-то у вас "чистый" андроид странный, у меня на Pixel нет такого


      1. Shaman_RSHU
        08.08.2024 18:37

        Вру. У меня не чистый, а AOSP. Посмотрел на Pixel Experience - там нет.