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.