v0.6.4
This is a test if updates work
This commit is contained in:
1
installer/.gitignore
vendored
Normal file
1
installer/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
23
installer/build.gradle.kts
Normal file
23
installer/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
alias(libs.plugins.looker.android.library)
|
||||
alias(libs.plugins.looker.hilt)
|
||||
alias(libs.plugins.looker.lint)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.looker.installer"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
modules(
|
||||
Modules.coreCommon,
|
||||
Modules.coreDatastore,
|
||||
Modules.coreDomain,
|
||||
)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.coroutines.guava)
|
||||
implementation(libs.libsu.core)
|
||||
implementation(libs.shizuku.api)
|
||||
api(libs.shizuku.provider)
|
||||
}
|
||||
117
installer/src/main/java/com/looker/installer/InstallManager.kt
Normal file
117
installer/src/main/java/com/looker/installer/InstallManager.kt
Normal file
@@ -0,0 +1,117 @@
|
||||
package com.looker.installer
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.core.common.extension.addAndCompute
|
||||
import com.looker.core.common.extension.filter
|
||||
import com.looker.core.common.extension.updateAsMutable
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.InstallerType
|
||||
import com.looker.core.domain.model.PackageName
|
||||
import com.looker.installer.installers.Installer
|
||||
import com.looker.installer.installers.LegacyInstaller
|
||||
import com.looker.installer.installers.root.RootInstaller
|
||||
import com.looker.installer.installers.session.SessionInstaller
|
||||
import com.looker.installer.installers.shizuku.ShizukuInstaller
|
||||
import com.looker.installer.model.InstallItem
|
||||
import com.looker.installer.model.InstallState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class InstallManager(
|
||||
private val context: Context,
|
||||
settingsRepository: SettingsRepository
|
||||
) {
|
||||
|
||||
private val installItems = Channel<InstallItem>()
|
||||
private val uninstallItems = Channel<PackageName>()
|
||||
|
||||
val state = MutableStateFlow<Map<PackageName, InstallState>>(emptyMap())
|
||||
|
||||
private var _installer: Installer? = null
|
||||
set(value) {
|
||||
field?.close()
|
||||
field = value
|
||||
}
|
||||
private val installer: Installer get() = _installer!!
|
||||
|
||||
private val lock = Mutex()
|
||||
private val installerPreference = settingsRepository.get { installerType }
|
||||
|
||||
suspend operator fun invoke() = coroutineScope {
|
||||
setupInstaller()
|
||||
installer()
|
||||
uninstaller()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
_installer = null
|
||||
uninstallItems.close()
|
||||
installItems.close()
|
||||
}
|
||||
|
||||
suspend infix fun install(installItem: InstallItem) {
|
||||
installItems.send(installItem)
|
||||
}
|
||||
|
||||
suspend infix fun uninstall(packageName: PackageName) {
|
||||
uninstallItems.send(packageName)
|
||||
}
|
||||
|
||||
infix fun remove(packageName: PackageName) {
|
||||
updateState { remove(packageName) }
|
||||
}
|
||||
|
||||
private fun CoroutineScope.setupInstaller() = launch {
|
||||
installerPreference.collectLatest(::setInstaller)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.installer() = launch {
|
||||
val currentQueue = mutableSetOf<String>()
|
||||
installItems.filter { item ->
|
||||
currentQueue.addAndCompute(item.packageName.name) { isAdded ->
|
||||
if (isAdded) {
|
||||
updateState { put(item.packageName, InstallState.Pending) }
|
||||
}
|
||||
}
|
||||
}.consumeEach { item ->
|
||||
if (state.value.containsKey(item.packageName)) {
|
||||
updateState { put(item.packageName, InstallState.Installing) }
|
||||
val success = installer.use {
|
||||
it.install(item)
|
||||
}
|
||||
updateState { put(item.packageName, success) }
|
||||
currentQueue.remove(item.packageName.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.uninstaller() = launch {
|
||||
uninstallItems.consumeEach {
|
||||
installer.uninstall(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setInstaller(installerType: InstallerType) {
|
||||
lock.withLock {
|
||||
_installer = when (installerType) {
|
||||
InstallerType.LEGACY -> LegacyInstaller(context)
|
||||
InstallerType.SESSION -> SessionInstaller(context)
|
||||
InstallerType.SHIZUKU -> ShizukuInstaller(context)
|
||||
InstallerType.ROOT -> RootInstaller(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun updateState(block: MutableMap<PackageName, InstallState>.() -> Unit) {
|
||||
state.update { it.updateAsMutable(block) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.looker.installer
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.installer.installers.root.RootPermissionHandler
|
||||
import com.looker.installer.installers.shizuku.ShizukuPermissionHandler
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object InstallModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun providesInstaller(
|
||||
@ApplicationContext context: Context,
|
||||
settingsRepository: SettingsRepository
|
||||
): InstallManager = InstallManager(context, settingsRepository)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideShizukuPermissionHandler(
|
||||
@ApplicationContext context: Context
|
||||
): ShizukuPermissionHandler = ShizukuPermissionHandler(context)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideRootPermissionHandler(): RootPermissionHandler = RootPermissionHandler()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.looker.installer.installers
|
||||
|
||||
import com.looker.core.domain.model.PackageName
|
||||
import com.looker.installer.model.InstallItem
|
||||
import com.looker.installer.model.InstallState
|
||||
|
||||
interface Installer : AutoCloseable {
|
||||
|
||||
suspend fun install(installItem: InstallItem): InstallState
|
||||
|
||||
suspend fun uninstall(packageName: PackageName)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.looker.installer.installers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AndroidRuntimeException
|
||||
import androidx.core.net.toUri
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.domain.model.PackageName
|
||||
import com.looker.installer.model.InstallItem
|
||||
import com.looker.installer.model.InstallState
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal class LegacyInstaller(private val context: Context) : Installer {
|
||||
|
||||
companion object {
|
||||
private const val APK_MIME = "application/vnd.android.package-archive"
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
val (uri, flags) = if (SdkCheck.isNougat) {
|
||||
Cache.getReleaseUri(
|
||||
context,
|
||||
installItem.installFileName
|
||||
) to Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
} else {
|
||||
val file = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
file.toUri() to 0
|
||||
}
|
||||
try {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_INSTALL_PACKAGE).setDataAndType(uri, APK_MIME).setFlags(flags)
|
||||
)
|
||||
cont.resume(InstallState.Installed)
|
||||
} catch (e: AndroidRuntimeException) {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_INSTALL_PACKAGE).setDataAndType(uri, APK_MIME)
|
||||
.setFlags(flags or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
cont.resume(InstallState.Installed)
|
||||
} catch (e: Exception) {
|
||||
cont.resume(InstallState.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
context.uninstallPackage(packageName)
|
||||
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
internal suspend fun Context.uninstallPackage(packageName: PackageName) =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
try {
|
||||
startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_UNINSTALL_PACKAGE,
|
||||
"package:${packageName.name}".toUri()
|
||||
).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
cont.resume(Unit)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.looker.installer.installers.root
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.domain.model.PackageName
|
||||
import com.looker.installer.installers.Installer
|
||||
import com.looker.installer.installers.uninstallPackage
|
||||
import com.looker.installer.model.InstallItem
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
internal class RootInstaller(private val context: Context) : Installer {
|
||||
|
||||
private companion object {
|
||||
const val ROOT_INSTALL_PACKAGE = "cat %s | pm install --user %s -t -r -S %s"
|
||||
const val DELETE_PACKAGE = "%s rm %s"
|
||||
|
||||
val getCurrentUserState: String
|
||||
get() = if (SdkCheck.isOreo) {
|
||||
Shell.cmd("am get-current-user").exec().out[0]
|
||||
} else {
|
||||
Shell.cmd("dumpsys activity | grep -E \"mUserLru\"")
|
||||
.exec().out[0].trim()
|
||||
.removePrefix("mUserLru: [").removeSuffix("]")
|
||||
}
|
||||
|
||||
val String.quote
|
||||
get() = "\"${this.replace(Regex("""[\\$"`]""")) { c -> "\\${c.value}" }}\""
|
||||
|
||||
val getUtilBoxPath: String
|
||||
get() {
|
||||
listOf("toybox", "busybox").forEach {
|
||||
val shellResult = Shell.cmd("which $it").exec()
|
||||
if (shellResult.out.isNotEmpty()) {
|
||||
val utilBoxPath = shellResult.out.joinToString("")
|
||||
if (utilBoxPath.isNotEmpty()) return utilBoxPath.quote
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun installCmd(file: File): String = String.format(
|
||||
ROOT_INSTALL_PACKAGE,
|
||||
file.absolutePath,
|
||||
getCurrentUserState,
|
||||
file.length()
|
||||
)
|
||||
|
||||
fun deleteCmd(file: File): String = String.format(
|
||||
DELETE_PACKAGE,
|
||||
getUtilBoxPath,
|
||||
file.absolutePath.quote
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
val releaseFile = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
Shell.cmd(installCmd(releaseFile)).submit { shellResult ->
|
||||
val result = if (shellResult.isSuccess) InstallState.Installed
|
||||
else InstallState.Failed
|
||||
cont.resume(result)
|
||||
Shell.cmd(deleteCmd(releaseFile)).submit()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
context.uninstallPackage(packageName)
|
||||
|
||||
override fun close() {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.looker.installer.installers.root
|
||||
|
||||
import com.topjohnwu.superuser.Shell
|
||||
|
||||
class RootPermissionHandler {
|
||||
|
||||
val isGranted: Boolean
|
||||
get() {
|
||||
Shell.getCachedShell() ?: Shell.getShell()
|
||||
return Shell.isAppGrantedRoot() ?: false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.looker.installer.installers.session
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.common.sdkAbove
|
||||
import com.looker.core.domain.model.PackageName
|
||||
import com.looker.installer.installers.Installer
|
||||
import com.looker.installer.model.InstallItem
|
||||
import com.looker.installer.model.InstallState
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
internal class SessionInstaller(private val context: Context) : Installer {
|
||||
|
||||
private val installer = context.packageManager.packageInstaller
|
||||
private val intent = Intent(context, SessionInstallerReceiver::class.java)
|
||||
|
||||
companion object {
|
||||
private var installerCallbacks: PackageInstaller.SessionCallback? = null
|
||||
private val flags = if (SdkCheck.isSnowCake) PendingIntent.FLAG_MUTABLE else 0
|
||||
private val sessionParams =
|
||||
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
sdkAbove(sdk = Build.VERSION_CODES.S) {
|
||||
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
sdkAbove(sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
setRequestUpdateOwnership(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
val cacheFile = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
val id = installer.createSession(sessionParams)
|
||||
val installerCallback = object : PackageInstaller.SessionCallback() {
|
||||
override fun onCreated(sessionId: Int) {}
|
||||
override fun onBadgingChanged(sessionId: Int) {}
|
||||
override fun onActiveChanged(sessionId: Int, active: Boolean) {}
|
||||
override fun onProgressChanged(sessionId: Int, progress: Float) {}
|
||||
override fun onFinished(sessionId: Int, success: Boolean) {
|
||||
if (sessionId == id) cont.resume(InstallState.Installed)
|
||||
}
|
||||
}
|
||||
installerCallbacks = installerCallback
|
||||
|
||||
installer.registerSessionCallback(
|
||||
installerCallbacks!!,
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
|
||||
val session = installer.openSession(id)
|
||||
|
||||
session.use { activeSession ->
|
||||
val sizeBytes = cacheFile.length()
|
||||
cacheFile.inputStream().use { fileStream ->
|
||||
activeSession.openWrite(cacheFile.name, 0, sizeBytes).use { outputStream ->
|
||||
if (cont.isActive) {
|
||||
fileStream.copyTo(outputStream)
|
||||
activeSession.fsync(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, id, intent, flags)
|
||||
|
||||
if (cont.isActive) activeSession.commit(pendingIntent.intentSender)
|
||||
}
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
try {
|
||||
installer.abandonSession(id)
|
||||
} catch (e: SecurityException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
intent.putExtra(SessionInstallerReceiver.ACTION_UNINSTALL, true)
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, -1, intent, flags)
|
||||
|
||||
installer.uninstall(packageName.name, pendingIntent.intentSender)
|
||||
cont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
installerCallbacks?.let {
|
||||
installer.unregisterSessionCallback(it)
|
||||
installerCallbacks = null
|
||||
}
|
||||
try {
|
||||
installer.mySessions.forEach { installer.abandonSession(it.sessionId) }
|
||||
} catch (e: SecurityException) {
|
||||
log(e.message, type = Log.ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.looker.installer.installers.session
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import com.looker.core.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.core.common.R
|
||||
import com.looker.core.common.createNotificationChannel
|
||||
import com.looker.core.common.extension.getPackageName
|
||||
import com.looker.core.common.extension.notificationManager
|
||||
import com.looker.core.domain.model.toPackageName
|
||||
import com.looker.installer.InstallManager
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.looker.installer.notification.createInstallNotification
|
||||
import com.looker.installer.notification.installNotification
|
||||
import com.looker.installer.notification.removeInstallNotification
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SessionInstallerReceiver : BroadcastReceiver() {
|
||||
|
||||
// This is a cyclic dependency injection, I know but this is the best option for now
|
||||
@Inject
|
||||
lateinit var installManager: InstallManager
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
|
||||
|
||||
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
||||
// prompts user to enable unknown source
|
||||
val promptIntent: Intent? = intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
|
||||
promptIntent?.let {
|
||||
it.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
|
||||
it.putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, "com.android.vending")
|
||||
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
context.startActivity(it)
|
||||
}
|
||||
} else {
|
||||
notifyStatus(intent, context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyStatus(intent: Intent, context: Context) {
|
||||
val packageManager = context.packageManager
|
||||
val notificationManager = context.notificationManager
|
||||
|
||||
context.createNotificationChannel(
|
||||
id = NOTIFICATION_CHANNEL_INSTALL,
|
||||
name = context.getString(R.string.install)
|
||||
)
|
||||
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
|
||||
val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
val isUninstall = intent.getBooleanExtra(ACTION_UNINSTALL, false)
|
||||
|
||||
val appName = packageManager.getPackageName(packageName)
|
||||
|
||||
if (packageName != null) {
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
notificationManager?.removeInstallNotification(packageName)
|
||||
val notification = context.createInstallNotification(
|
||||
appName = (appName ?: packageName.substringAfterLast('.')).toString(),
|
||||
state = InstallState.Installed,
|
||||
isUninstall = isUninstall,
|
||||
) {
|
||||
setTimeoutAfter(SUCCESS_TIMEOUT)
|
||||
}
|
||||
notificationManager?.installNotification(
|
||||
packageName = packageName.toString(),
|
||||
notification = notification,
|
||||
)
|
||||
}
|
||||
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
installManager.remove(packageName.toPackageName())
|
||||
}
|
||||
|
||||
else -> {
|
||||
installManager.remove(packageName.toPackageName())
|
||||
val notification = context.createInstallNotification(
|
||||
appName = appName.toString(),
|
||||
state = InstallState.Failed,
|
||||
) {
|
||||
setContentText(message)
|
||||
}
|
||||
notificationManager?.installNotification(
|
||||
packageName = packageName,
|
||||
notification = notification
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_UNINSTALL = "action_uninstall"
|
||||
|
||||
private const val SUCCESS_TIMEOUT = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.looker.installer.installers.shizuku
|
||||
|
||||
import android.content.Context
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.extension.size
|
||||
import com.looker.core.domain.model.PackageName
|
||||
import com.looker.installer.installers.Installer
|
||||
import com.looker.installer.installers.uninstallPackage
|
||||
import com.looker.installer.model.InstallItem
|
||||
import com.looker.installer.model.InstallState
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import rikka.shizuku.Shizuku
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStream
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
internal class ShizukuInstaller(private val context: Context) : Installer {
|
||||
|
||||
companion object {
|
||||
private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])")
|
||||
}
|
||||
|
||||
override suspend fun install(
|
||||
installItem: InstallItem
|
||||
): InstallState = suspendCancellableCoroutine { cont ->
|
||||
var sessionId: String? = null
|
||||
val file = Cache.getReleaseFile(context, installItem.installFileName)
|
||||
val packageName = installItem.packageName.name
|
||||
try {
|
||||
val fileSize = file.size ?: run {
|
||||
cont.cancel()
|
||||
error("File is not valid: Size ${file.size}")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
file.inputStream().use {
|
||||
val createCommand =
|
||||
if (SdkCheck.isNougat) {
|
||||
"pm install-create --user current -i $packageName -S $fileSize"
|
||||
} else {
|
||||
"pm install-create -i $packageName -S $fileSize"
|
||||
}
|
||||
val createResult = exec(createCommand)
|
||||
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value
|
||||
?: run {
|
||||
cont.cancel()
|
||||
throw RuntimeException("Failed to create install session")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
|
||||
val writeResult = exec("pm install-write -S $fileSize $sessionId base -", it)
|
||||
if (writeResult.resultCode != 0) {
|
||||
cont.cancel()
|
||||
throw RuntimeException("Failed to write APK to session $sessionId")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
|
||||
val commitResult = exec("pm install-commit $sessionId")
|
||||
if (commitResult.resultCode != 0) {
|
||||
cont.cancel()
|
||||
throw RuntimeException("Failed to commit install session $sessionId")
|
||||
}
|
||||
if (cont.isCompleted) return@suspendCancellableCoroutine
|
||||
cont.resume(InstallState.Installed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (sessionId != null) exec("pm install-abandon $sessionId")
|
||||
cont.resume(InstallState.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uninstall(packageName: PackageName) =
|
||||
context.uninstallPackage(packageName)
|
||||
|
||||
override fun close() {}
|
||||
|
||||
private data class ShellResult(val resultCode: Int, val out: String)
|
||||
|
||||
private fun exec(command: String, stdin: InputStream? = null): ShellResult {
|
||||
val process = Shizuku.newProcess(arrayOf("sh", "-c", command), null, null)
|
||||
if (stdin != null) {
|
||||
process.outputStream.use { stdin.copyTo(it) }
|
||||
}
|
||||
val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
val resultCode = process.waitFor()
|
||||
return ShellResult(resultCode, output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.looker.installer.installers.shizuku
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.looker.core.common.extension.getPackageInfoCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import rikka.shizuku.Shizuku
|
||||
import rikka.shizuku.ShizukuProvider
|
||||
|
||||
class ShizukuPermissionHandler(
|
||||
private val context: Context
|
||||
) {
|
||||
|
||||
fun isInstalled(): Boolean =
|
||||
context.packageManager.getPackageInfoCompat(ShizukuProvider.MANAGER_APPLICATION_ID) != null
|
||||
|
||||
val isBinderAlive: Flow<Boolean> = callbackFlow {
|
||||
send(Shizuku.pingBinder())
|
||||
val listener = Shizuku.OnBinderReceivedListener {
|
||||
trySend(true)
|
||||
}
|
||||
Shizuku.addBinderReceivedListener(listener)
|
||||
val deadListener = Shizuku.OnBinderDeadListener {
|
||||
trySend(false)
|
||||
}
|
||||
Shizuku.addBinderDeadListener(deadListener)
|
||||
awaitClose {
|
||||
Shizuku.removeBinderReceivedListener(listener)
|
||||
Shizuku.removeBinderDeadListener(deadListener)
|
||||
}
|
||||
}.flowOn(Dispatchers.Default).distinctUntilChanged().conflate()
|
||||
|
||||
private val isGranted: Flow<Boolean> = callbackFlow {
|
||||
if (Shizuku.pingBinder()) {
|
||||
send(Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED)
|
||||
} else {
|
||||
send(false)
|
||||
}
|
||||
val listener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
|
||||
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
|
||||
trySend(grantResult == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
}
|
||||
Shizuku.addRequestPermissionResultListener(listener)
|
||||
awaitClose {
|
||||
Shizuku.removeRequestPermissionResultListener(listener)
|
||||
}
|
||||
}.flowOn(Dispatchers.Default).distinctUntilChanged().conflate()
|
||||
|
||||
fun requestPermission() {
|
||||
if (Shizuku.shouldShowRequestPermissionRationale()) {
|
||||
}
|
||||
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
|
||||
val state: Flow<State> = combine(
|
||||
flowOf(isInstalled()),
|
||||
isBinderAlive,
|
||||
isGranted
|
||||
) { isInstalled, isAlive, isGranted ->
|
||||
State(isGranted, isAlive, isInstalled)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
companion object {
|
||||
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263
|
||||
}
|
||||
|
||||
data class State(
|
||||
val isPermissionGranted: Boolean,
|
||||
val isAlive: Boolean,
|
||||
val isInstalled: Boolean
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.looker.installer.model
|
||||
|
||||
import com.looker.core.domain.model.PackageName
|
||||
import com.looker.core.domain.model.toPackageName
|
||||
|
||||
data class InstallItem(
|
||||
val packageName: PackageName,
|
||||
val installFileName: String
|
||||
)
|
||||
|
||||
infix fun String.installFrom(fileName: String) = InstallItem(this.toPackageName(), fileName)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.looker.installer.model
|
||||
|
||||
enum class InstallState { Failed, Pending, Installing, Installed }
|
||||
|
||||
inline val InstallState.isCancellable: Boolean
|
||||
get() = this == InstallState.Pending
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.looker.installer.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.looker.core.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.core.common.Constants.NOTIFICATION_ID_INSTALL
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.looker.installer.model.InstallState.Failed
|
||||
import com.looker.installer.model.InstallState.Installed
|
||||
import com.looker.installer.model.InstallState.Installing
|
||||
import com.looker.installer.model.InstallState.Pending
|
||||
import com.looker.core.common.R as CommonR
|
||||
|
||||
fun NotificationManager.installNotification(
|
||||
packageName: String,
|
||||
notification: Notification,
|
||||
) {
|
||||
notify(
|
||||
installTag(packageName),
|
||||
NOTIFICATION_ID_INSTALL,
|
||||
notification
|
||||
)
|
||||
}
|
||||
|
||||
fun NotificationManager.removeInstallNotification(
|
||||
packageName: String,
|
||||
) {
|
||||
cancel(installTag(packageName), NOTIFICATION_ID_INSTALL)
|
||||
}
|
||||
|
||||
fun installTag(name: String): String = "install-${name.trim().replace(' ', '_')}"
|
||||
|
||||
private const val SUCCESS_TIMEOUT = 5_000L
|
||||
|
||||
fun Context.createInstallNotification(
|
||||
appName: String,
|
||||
state: InstallState,
|
||||
isUninstall: Boolean = false,
|
||||
autoCancel: Boolean = true,
|
||||
block: NotificationCompat.Builder.() -> Unit = {},
|
||||
): Notification {
|
||||
return NotificationCompat
|
||||
.Builder(this, NOTIFICATION_CHANNEL_INSTALL)
|
||||
.apply {
|
||||
setAutoCancel(autoCancel)
|
||||
setOngoing(false)
|
||||
setOnlyAlertOnce(true)
|
||||
setColor(Color.GREEN)
|
||||
val (title, text) = if (isUninstall) {
|
||||
setTimeoutAfter(SUCCESS_TIMEOUT)
|
||||
setSmallIcon(CommonR.drawable.ic_delete)
|
||||
getString(CommonR.string.uninstalled_application) to
|
||||
getString(CommonR.string.uninstalled_application_DESC, appName)
|
||||
} else {
|
||||
when (state) {
|
||||
Failed -> {
|
||||
setSmallIcon(CommonR.drawable.ic_bug_report)
|
||||
getString(CommonR.string.installation_failed) to
|
||||
getString(CommonR.string.installation_failed_DESC, appName)
|
||||
}
|
||||
|
||||
Pending -> {
|
||||
setSmallIcon(CommonR.drawable.ic_download)
|
||||
getString(CommonR.string.downloaded_FORMAT, appName) to
|
||||
getString(CommonR.string.tap_to_install_DESC)
|
||||
}
|
||||
|
||||
Installing -> {
|
||||
setSmallIcon(CommonR.drawable.ic_download)
|
||||
setProgress(-1, -1, true)
|
||||
getString(CommonR.string.installing) to
|
||||
appName
|
||||
}
|
||||
|
||||
Installed -> {
|
||||
setTimeoutAfter(SUCCESS_TIMEOUT)
|
||||
setSmallIcon(CommonR.drawable.ic_check)
|
||||
getString(CommonR.string.installed) to
|
||||
appName
|
||||
}
|
||||
}
|
||||
}
|
||||
setContentTitle(title)
|
||||
setContentText(text)
|
||||
block()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
Reference in New Issue
Block a user