Creating Device Owner App With Background Installation Feature

I had a task to support background installation of Android apps on recent Android OS.

My Googling/GPT skills identified that one way to do it on non-rooted device is to use PackageManager API along with marking the app as ‘device-owner’.

Code

AppDeviceAdminReceiver.kt

package com.example.myinstallapplication

import android.app.admin.DeviceAdminReceiver
import android.content.Context
import android.content.Intent
import android.widget.Toast

class AppDeviceAdminReceiver : DeviceAdminReceiver() {
    override fun onEnabled(context: Context, intent: Intent) {
        Toast.makeText(context, "Device Admin: enabled", Toast.LENGTH_SHORT).show()
    }

    override fun onDisabled(context: Context, intent: Intent) {
        Toast.makeText(context, "Device Admin: disabled", Toast.LENGTH_SHORT).show()
    }
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
    <uses-permission android:name="android.intent.action.PACKAGE_ADDED" />
    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
    <uses-permission android:name="android.permission.INTERNET" />

    <queries>
        <package android:name="com.example.myinstallapplication"/>
    </queries>

    <application
        android:requestLegacyExternalStorage="true"
        android:requestRawExternalStorageAccess="true"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyInstallApplication"
        tools:targetApi="31">
        <provider
            android:name=".MyProvider"
            android:authorities="${applicationId}.provider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.MyInstallApplication">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <receiver
            android:exported="true"
            android:name=".AppDeviceAdminReceiver"
            android:permission="android.permission.BIND_DEVICE_ADMIN" >
            <meta-data
                android:name="android.app.device_admin"
                android:resource="@xml/device_admin" />

            <intent-filter>
                <action android:name="android.app.action.DEVICE_ADMIN_ENABLED"/>
                <action android:name="android.app.action.PROFILE_PROVISIONING_COMPLETE"/>
                <action android:name="android.intent.action.BOOT_COMPLETED"/>
                <action android:name="android.app.action.PROFILE_OWNER_CHANGED"/>
                <action android:name="android.app.action.DEVICE_OWNER_CHANGED"/>
            </intent-filter>
        </receiver>
        <receiver android:name="com.example.myinstallapplication.InstallReceiver" />

    </application>
</manifest>

MainActivity.kt

package com.example.myinstallapplication

import android.Manifest
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.example.myinstallapplication.ui.theme.MyInstallApplicationTheme
import java.io.File


class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyInstallApplicationTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
        installWithDeviceOwnersAPI();

    }

    private fun installWithDeviceOwnersAPI() {

        if (ContextCompat.checkSelfPermission(
                this,
                Manifest.permission.READ_EXTERNAL_STORAGE
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            ActivityCompat.requestPermissions(
                this,
                arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
                111
            )
        } else {
            installDeviceOwnerAPI()
        }


    }

    private fun installDeviceOwnerAPI() {

        val params = PackageInstaller.SessionParams(
            PackageInstaller.SessionParams.MODE_FULL_INSTALL
        )
        val sessionId = packageManager.packageInstaller.createSession(params)
        val session = packageManager.packageInstaller.openSession(sessionId)

        val file =
            File("${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)}/magisk.apk")

        session.openWrite("mostly-unused", 0, file.length()).use { out ->
            file.inputStream()
                .use {
                    it.copyTo(out)
                }
        }

        val intent = Intent(application, InstallReceiver::class.java)
        val pi = PendingIntent.getBroadcast(
            application,
            100,
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT
        )
        session.commit(pi.intentSender)
        session.close()

    }

    @Deprecated("Deprecated in Java")
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        installDeviceOwnerAPI()
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    }

    private fun installWithPopup() {
        val apkUri = FileProvider.getUriForFile(
            applicationContext,
            "com.example.myinstallapplication.provider",
            File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path + "/supersu.apk")
        )

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
            intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
            intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
            intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true);
            startActivity(intent);
        } else {
            val install = Intent(Intent.ACTION_VIEW)
            install.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
            install.setDataAndType(apkUri, "application/vnd.android.package-archive")
            applicationContext.startActivity(install)
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name, it works!",
        modifier = modifier
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    MyInstallApplicationTheme {
        Greeting("Android")
    }
}

Installing app and making it as Device Owner

adb shell dpm  set-active-admin com.example.myinstallapplication/.AppDeviceAdminReceiver
dpm  set-device-owner com.example.myinstallapplication/.AppDeviceAdminReceiver

Things to be aware of

Once device-owner is set on device it’s no longer removable without doing factory reset.

If it’s build using flag,`

<application
    android:testOnly="true"`

it may be updated and then potentially removed using the following command:

adb shell dpm remove-active-admin com.example.myinstallapplication/.AppDeviceAdminReceiver
adb shell pm uninstall com.example.myinstallapplication

Debugging with Android Studio

Once app is promoted to device-owner, android studio will no longer be able to deploy new releases.
To make it work again, enter the -t flag in run configuration deployment menu.

Related Posts

Leave a Reply

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