Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ android {

defaultConfig {
applicationId = "com.looker.droidify"
minSdk = 23
minSdk = 26
versionName = latestVersionName
versionCode = 710

Expand Down Expand Up @@ -97,6 +97,7 @@ android {
}

buildFeatures {
aidl = true
compose = true
viewBinding = true
buildConfig = true
Expand Down Expand Up @@ -200,6 +201,7 @@ dependencies {

implementation(libs.libsu.core)
implementation(libs.bundles.shizuku)
implementation(libs.dhizuku.api)

implementation(libs.jackson.core)
implementation(libs.serialization)
Expand Down
16 changes: 16 additions & 0 deletions app/proguard.pro
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,19 @@
-dontwarn kotlinx.serialization.KSerializer
-dontwarn kotlinx.serialization.Serializable
-dontwarn org.slf4j.impl.StaticLoggerBinder

# Dhizuku UserService — class name is passed via ComponentName; R8 must not rename it.
-keep class com.looker.droidify.installer.installers.dhizuku.DroidifyDhizukuInstallerService { *; }
-keepclassmembers class com.looker.droidify.installer.installers.dhizuku.DroidifyDhizukuInstallerService {
<init>(android.content.Context);
<init>();
}

# AIDL-generated Stub/Proxy classes for IPC between app and Dhizuku process
-keep class com.looker.droidify.installer.installers.dhizuku.IDhizukuInstallerService { *; }
-keep class com.looker.droidify.installer.installers.dhizuku.IDhizukuInstallerService$Stub { *; }
-keep class com.looker.droidify.installer.installers.dhizuku.IDhizukuInstallerService$Stub$Proxy { *; }

# Dhizuku library internals
-keep class com.rosan.dhizuku.** { *; }
-dontwarn com.rosan.dhizuku.**
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="com.rosan.dhizuku.permission.API" />

<uses-feature
android:name="android.software.leanback"
Expand Down Expand Up @@ -225,4 +226,9 @@

</application>

<queries>
<package android:name="com.rosan.dhizuku" />
<provider android:authorities="com.rosan.dhizuku.server.provider" />
</queries>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.looker.droidify.installer.installers.dhizuku;

interface IDhizukuInstallerService {
int installPackage(in android.os.ParcelFileDescriptor pfd, long fileSize, String expectedPackageName, long expectedVersionCode, String installerPackageName);
void destroy();
int uninstallPackage(String packageName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ private fun InstallerTypeSetting(
InstallerType.LEGACY -> stringResource(R.string.legacy_installer)
InstallerType.SESSION -> stringResource(R.string.session_installer)
InstallerType.SHIZUKU -> stringResource(R.string.shizuku_installer)
InstallerType.DHIZUKU -> stringResource(R.string.dhizuku_installer)
InstallerType.ROOT -> stringResource(R.string.root_installer)
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum class InstallerType {
LEGACY,
SESSION,
SHIZUKU,
DHIZUKU,
ROOT,
;

Expand Down
168 changes: 130 additions & 38 deletions app/src/main/kotlin/com/looker/droidify/installer/InstallManager.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.looker.droidify.installer

import android.content.Context
import android.util.Log
import com.looker.droidify.data.model.PackageName
import com.looker.droidify.database.Database
import com.looker.droidify.datastore.SettingsRepository
Expand All @@ -11,19 +12,22 @@ import com.looker.droidify.installer.installers.LegacyInstaller
import com.looker.droidify.installer.installers.root.RootInstaller
import com.looker.droidify.installer.installers.session.SessionInstaller
import com.looker.droidify.installer.installers.shizuku.ShizukuInstaller
import com.looker.droidify.installer.installers.dhizuku.DhizukuInstaller
import com.looker.droidify.installer.model.InstallItem
import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.service.SyncService
import com.looker.droidify.utility.common.Constants
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.extension.addAndCompute
import com.looker.droidify.utility.common.extension.filter
import com.looker.droidify.utility.common.extension.getPackageInfoCompat
import com.looker.droidify.utility.common.extension.notificationManager
import com.looker.droidify.utility.common.extension.updateAsMutable
import com.looker.droidify.utility.common.log
import com.looker.droidify.utility.extension.toInstalledItem
import com.looker.droidify.utility.notifications.createInstallNotification
import com.looker.droidify.utility.notifications.installNotification
import com.looker.droidify.utility.notifications.removeInstallNotification
import com.looker.droidify.utility.notifications.updatesAvailableNotification
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
Expand All @@ -32,17 +36,20 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap

class InstallManager(
private val context: Context,
private val settingsRepository: SettingsRepository,
) {

private val installItems = Channel<InstallItem>()
private val uninstallItems = Channel<PackageName>()
private val installItems = Channel<InstallItem>(Channel.UNLIMITED)
private val uninstallItems = Channel<PackageName>(Channel.UNLIMITED)
private val installCompletions = ConcurrentHashMap<String, CompletableDeferred<InstallState>>()

val state = MutableStateFlow<Map<PackageName, InstallState>>(emptyMap())

Expand Down Expand Up @@ -75,6 +82,18 @@ class InstallManager(
installItems.send(installItem)
}

suspend fun installAndAwait(installItem: InstallItem): InstallState {
val key = installItem.packageName.name
installCompletions[key]?.let { inFlight ->
log("Joining in-flight install: $key", TAG)
return inFlight.await()
}
val deferred = CompletableDeferred<InstallState>()
installCompletions[key] = deferred
installItems.send(installItem)
return deferred.await()
}

suspend infix fun uninstall(packageName: PackageName) {
uninstallItems.send(packageName)
}
Expand All @@ -92,55 +111,92 @@ class InstallManager(
}

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)) {
installItems.consumeEach { item ->
val key = item.packageName.name
log("Install started: $key", TAG)
val result = try {
updateState { put(item.packageName, InstallState.Installing) }
notificationManager?.installNotification(
packageName = item.packageName.name,
packageName = key,
notification = context.createInstallNotification(
appName = item.packageName.name,
appName = key,
state = InstallState.Installing,
),
)
val result = installer.use { it.install(item) }
if (result == InstallState.Installed && installer !is LegacyInstaller) {
if (deleteApkPreference.first()) {
val apkFile = Cache.getReleaseFile(context, item.installFileName)
apkFile.delete()
}
}
if (result == InstallState.Installed && SyncService.autoUpdating) {
val updates = Database.ProductAdapter.getUpdates(skipSignature.first())
when {
updates.isEmpty() -> {
SyncService.autoUpdating = false
notificationManager?.cancel(Constants.NOTIFICATION_ID_UPDATES)
try {
installer.use { it.install(item) }
} catch (e: Exception) {
log("Install failed: $key — ${e.message}", TAG, Log.ERROR)
InstallState.Failed
}.also { installResult ->
notificationManager?.removeInstallNotification(key)
if (installResult == InstallState.Installed) {
updateState { remove(item.packageName) }
log("Install succeeded: $key", TAG)
if (installer !is LegacyInstaller) {
refreshInstalledPackage(key)
if (deleteApkPreference.first() && !SyncService.autoUpdating) {
Cache.getReleaseFile(context, item.installFileName).delete()
}
}
updates.map { it.packageName } != SyncService.autoUpdateStartedFor -> {
notificationManager?.notify(
Constants.NOTIFICATION_ID_UPDATES,
updatesAvailableNotification(context, updates),
)
if (SyncService.autoUpdating) {
val updates = Database.ProductAdapter.getUpdates(skipSignature.first())
when {
updates.isEmpty() -> {
SyncService.autoUpdating = false
notificationManager?.cancel(Constants.NOTIFICATION_ID_UPDATES)
log("Update-all batch complete", TAG)
}
updates.map { it.packageName } != SyncService.autoUpdateStartedFor -> {
notificationManager?.notify(
Constants.NOTIFICATION_ID_UPDATES,
updatesAvailableNotification(context, updates),
)
}
}
}
} else {
updateState { put(item.packageName, installResult) }
log("Install finished with $installResult: $key", TAG, Log.WARN)
}
}
notificationManager?.removeInstallNotification(item.packageName.name)
updateState { put(item.packageName, result) }
currentQueue.remove(item.packageName.name)
} catch (e: Exception) {
log("Install queue error: $key — ${e.message}", TAG, Log.ERROR)
InstallState.Failed
}
installCompletions.remove(key)?.complete(result)
}
}

private fun CoroutineScope.uninstaller() = launch {
uninstallItems.consumeEach {
installer.uninstall(it)
uninstallItems.consumeEach { packageName ->
val key = packageName.name
log("Uninstall queued: $key", TAG)
updateState { put(packageName, InstallState.Uninstalling) }
try {
log("Uninstall started: $key", TAG)
notificationManager?.installNotification(
packageName = key,
notification = context.createInstallNotification(
appName = key,
state = InstallState.Uninstalling,
),
)
try {
installer.uninstall(packageName)
} catch (e: Exception) {
log("Uninstall failed: $key — ${e.message}", TAG, Log.ERROR)
throw e
}
notificationManager?.removeInstallNotification(key)
updateState { remove(packageName) }
refreshUninstalledPackage(key)
log("Uninstall succeeded: $key", TAG)
} catch (e: Exception) {
log("Uninstall error (cleanup): $key — ${e.message}", TAG, Log.ERROR)
notificationManager?.removeInstallNotification(key)
updateState { remove(packageName) }
}
}
}

Expand All @@ -150,6 +206,7 @@ class InstallManager(
InstallerType.LEGACY -> LegacyInstaller(context, settingsRepository)
InstallerType.SESSION -> SessionInstaller(context)
InstallerType.SHIZUKU -> ShizukuInstaller(context)
InstallerType.DHIZUKU -> DhizukuInstaller(context)
InstallerType.ROOT -> RootInstaller(context)
}
}
Expand All @@ -158,4 +215,39 @@ class InstallManager(
private inline fun updateState(block: MutableMap<PackageName, InstallState>.() -> Unit) {
state.update { it.updateAsMutable(block) }
}

/**
* Silent installers (Session/Shizuku/Dhizuku) may not deliver [Intent.ACTION_PACKAGE_ADDED]
* to our dynamic receiver when install runs in another UID. Refresh the local DB explicitly.
*/
private suspend fun refreshInstalledPackage(packageName: String) {
repeat(REFRESH_INSTALLED_ATTEMPTS) { attempt ->
val packageInfo = context.packageManager.getPackageInfoCompat(packageName)
if (packageInfo != null) {
Database.InstalledAdapter.put(packageInfo.toInstalledItem())
return
}
if (attempt < REFRESH_INSTALLED_ATTEMPTS - 1) {
delay(REFRESH_INSTALLED_DELAY_MS)
}
}
}

private suspend fun refreshUninstalledPackage(packageName: String) {
repeat(REFRESH_INSTALLED_ATTEMPTS) { attempt ->
if (context.packageManager.getPackageInfoCompat(packageName) == null) {
Database.InstalledAdapter.delete(packageName)
return
}
if (attempt < REFRESH_INSTALLED_ATTEMPTS - 1) {
delay(REFRESH_INSTALLED_DELAY_MS)
}
}
}

companion object {
private const val TAG = "DroidifyUpdateAll"
private const val REFRESH_INSTALLED_ATTEMPTS = 20
private const val REFRESH_INSTALLED_DELAY_MS = 250L
}
}
Loading