We write our own application for setting PIN on other applications

Background

Since childhood, my father taught me to use antiviruses. Following the tradition, I bought myself a subscription to an antivirus for Android. It turned out that the application has an extremely interesting feature – setting a PIN code for other applications on the device. It was interesting for me because I, as a mobile developer, had no idea how to do something like this. And now, after some digging and work, I am sharing my experience.

Plan

The application must be able to:

  1. Recognize when to show the PIN screen;

  2. Show PIN code when opening the application itself (as part of “self-defense”);

  3. Select applications for the block;

  4. Create/change PIN code;

  5. Protect yourself from deletion.

Determining the currently running application

The first thing to do was to solve the problem of “how to determine that an application that needs to be blocked has opened?”

At first my attention fell on the option of catching system messages through BroadcastReceiver.
Unfortunately, it turned out that it is impossible to catch the application launch intent.

The second possible solution was to regularly (every few seconds) check running applications, through ActivityManager.runningAppProcesses().
But, as far as I understand, this method now returns only the processes of the current application, which is not suitable for us.

Well, all that remains is…good old AccessibilityService

AccessibilityService — a very powerful and at the same time dangerous tool. Its “useful functions” (defining and speaking text on the screen, pressing buttons, swiping, etc.) for people with disabilities can be used for the most terrible purposes, for example, for ours. That's what we'll do.

Initial solution

First, we'll add our service and the necessary permissions to the manifest:

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>

Metadata for 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"/>

What specific tasks should our service solve:

  • Determine that we have an application in front of us that needs to be protected;

  • Determine that we have our application in front of us so that it protects itself;

  • Show the PIN entry screen.

Let's start writing the very thing AccessibilityService.

When we launch a service, we pin a notification to keep track of whether our service is alive (AccessibilityService can work in the background without launching as 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
   }
}

Next, we try to determine what kind of screen is in front of us and, depending on this, we block or do not block the user.

The logic is as follows:

  1. If it shows Launcher — the user was on the main screen, which means that when launching a protected application, you can show the screen with the PIN code entry.

  2. If an application that needs to be blocked opens and the PIN has not been entered, we show the screen with the PIN code entry. We determine the application we need by its packageName.

  3. If we put protection on our application, we will get into a loop and endlessly open the PIN entry screen, because we are focusing on packageName applications, and packageName the block screen and our application are the same. To solve this problem, we will tie ourselves to the unique identifier of the main screen.

Launchers on different OS are different, so we get a list of all of them in advance Launchers on the device.

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"
   }

We will save the list of applications in SharedPreferencesfor which we will write a repository (we will use Koin to inject dependencies).

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 with notifications

While testing our service on the Messages app, I discovered that when messages arrive (and at that moment a notification appears), the lock screen is unexpectedly displayed.

To solve this problem we will simply ignore 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)
   }

Let's improve the UX a little bit

Or rather, we’ll completely rewrite some things.

Every time the application is opened, the user may get tired of entering the PIN: minimized the application, went to the main screen and back, etc.
We will give the user a sigh of relief: now, if he entered a PIN for a specific application, he will not have to enter a PIN until he opens another protected application.

So we get rid of the tie on Launchers.

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)
           }
       }
   }

Main screen

The main screen has several tasks:

  1. Request permissions (to send notifications and use our service);

  2. Provide the ability to add and remove a block from any application on the device;

  3. Provide the ability to change the PIN code.

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()
   }
}

The information about blocking applications will not update itself, so once every 4 seconds our service will check and update the list like this.

PinAccessibilityService.kt

   init {
       scope.launch {
           while (isActive) {
               delay(4000)
               packageForPinList = spRepository.packageIdList
           }
       }
***
   }

As a result, our main screen looks like this:

Main screen

Main screen

And this is the permission request dialog on the main screen:

Main screen

Main screen

PIN entry screen

This is the screen where we will block the user. It is not particularly tricky: the user enters 4 digits and either passes the check or tries further. This same screen, depending on the situation, will wait for both the current PIN code and a new one.

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
   }
}

This is the lock screen we got:

Lock screen

Lock screen

The best defense is a good offense (on admin rights, not to be confused with root)

And now the cherry on the cake of our application security.

“But we set a PIN code for our application, what else do we need?” an inexperienced reader will ask.
And I will ask a counter question: “What if a person wants to delete our offer?”
He will do it very quickly and easily.

Complete removal

Complete removal

We will solve this problem by obtaining admin rights to the application.

But this will not be enough, since the user can remove the application from the admins at any time.
That's why we:

  1. We will force a PIN code on the settings (in our case, the application is called “Settings”);

  2. If our application's admin rights are taken away, we will simply block the device (similar to pressing the power button).

After such manipulations, the user will have to enter the PIN codes of both the application and the device in order to delete our application, which provides good (though not ideal) security.

To obtain admin rights, you need to write your own DeviceAdminReceiver – subclass BroadcastReceiver-awhich allows intercepting system messages and is also capable of performing a number of privileged actions, such as changing a password or wiping data on a device.

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>

Metadata for AdminReceiver; only the screen lock force function will be used (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()
           }
       }
   }
}

Request admin rights from the user:
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 }
    }

We also need to add functionality PinAccessibilityService. In it, we will check every 15 seconds whether there are admin rights. If there are, then we will set a block on “Settings”:
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
           }
       }
       ***
   }

As a result, we have achieved that the user will need to make some effort to delete the application:

Complete removal

Complete removal

Conclusion

The purpose of this article was to show that in order to implement a software wish, you need a desire to understand the topic that interests you… and the ability to implement this wish in a given system.

Can it be done better? Of course.
In the article I did not take into account the processing of specific widgets (for example, com.google.android.googlequicksearchbox), all sorts of “loops with opening the lock screen”, optimizations, the ability to use a graphic password, resetting the PIN code if the user has forgotten the password, etc. – this was not part of my plans, but I hope that this article will be useful to you.
All the code for this article can be found here (link to code).

And these are useful links to documentation:
Device administration overview | Android Developers
Create your own accessibility service | Android Developers

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *