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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import com.looker.droidify.installer.installers.isMagiskGranted
import com.looker.droidify.installer.installers.isShizukuAlive
import com.looker.droidify.installer.installers.isShizukuGranted
import com.looker.droidify.installer.installers.isShizukuInstalled
import com.looker.droidify.installer.installers.dhizuku.isDhizukuAlive
import com.looker.droidify.installer.installers.dhizuku.isDhizukuGranted
import com.looker.droidify.installer.installers.dhizuku.isDhizukuInstalled
import com.looker.droidify.installer.installers.dhizuku.requestDhizukuPermission
import com.looker.droidify.installer.installers.requestPermissionListener
import com.looker.droidify.utility.common.extension.asStateFlow
import com.looker.droidify.utility.common.extension.updateAsMutable
Expand Down Expand Up @@ -150,6 +154,7 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
when (installerType) {
InstallerType.SHIZUKU -> handleShizukuInstaller(context, installerType)
InstallerType.DHIZUKU -> handleDhizukuInstaller(context, installerType)
InstallerType.ROOT -> handleRootInstaller(installerType)
InstallerType.LEGACY -> {
settingsRepository.setDeleteApkOnInstall(false)
Expand All @@ -176,6 +181,23 @@ class SettingsViewModel @Inject constructor(
}
}


private suspend fun handleDhizukuInstaller(context: Context, installerType: InstallerType) {
if (isDhizukuInstalled(context)) {
when {
!isDhizukuAlive(context) -> showSnackbar(R.string.dhizuku_not_alive)
isDhizukuGranted() -> settingsRepository.setInstallerType(installerType)
else -> {
if (requestDhizukuPermission(context)) {
settingsRepository.setInstallerType(installerType)
}
}
}
} else {
showSnackbar(R.string.dhizuku_not_installed)
}
}

private suspend fun handleRootInstaller(installerType: InstallerType) {
if (isMagiskGranted()) {
settingsRepository.setInstallerType(installerType)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,55 @@
package com.looker.droidify.installer.installers.dhizuku

import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import com.looker.droidify.R
import com.looker.droidify.data.model.PackageName
import com.looker.droidify.installer.installers.Installer
import com.looker.droidify.installer.installers.session.SessionInstaller
import com.looker.droidify.installer.model.InstallItem
import com.looker.droidify.installer.model.InstallState
import com.looker.droidify.utility.common.cache.Cache
import com.looker.droidify.utility.common.extension.getPackageInfoCompat
import com.looker.droidify.utility.common.extension.size
import kotlinx.coroutines.delay
import java.util.concurrent.atomic.AtomicBoolean

class DhizukuInstaller(private val context: Context) : Installer {

private val installManager = DhizukuInstallManager(context)

// Fallback for a non-compliant / unreachable Dhizuku server. Created eagerly (cheap, no side
// effects) so close() can release it deterministically.
private val sessionInstaller = SessionInstaller(context)

override suspend fun install(installItem: InstallItem): InstallState {
val file = Cache.getReleaseFile(context, installItem.installFileName)
if (file.length() == 0L) {
error("File is not valid: Size ${file.size}")
}
if (!ensureDhizukuInstallerReady(context)) {
Log.e(TAG, "Dhizuku not ready for ${installItem.packageName.name}")
return InstallState.Failed
Log.w(
TAG,
"Dhizuku not ready for ${installItem.packageName.name}; " +
"falling back to session installer",
)
return fallbackInstall(installItem)
}
return try {
installManager.installApk(file.absolutePath)
awaitPackageVisible(installItem.packageName.name)
InstallState.Installed
} catch (e: Exception) {
Log.e(TAG, "Dhizuku install failed: ${installItem.packageName.name}", e)
InstallState.Failed
Log.w(
TAG,
"Dhizuku install failed: ${installItem.packageName.name}; " +
"falling back to session installer",
e,
)
fallbackInstall(installItem)
}
}

Expand All @@ -42,14 +61,54 @@ class DhizukuInstaller(private val context: Context) : Installer {
}

override suspend fun uninstall(packageName: PackageName) {
installManager.uninstallPackage(packageName.name)
if (!ensureDhizukuInstallerReady(context)) {
Log.w(
TAG,
"Dhizuku not ready to uninstall ${packageName.name}; " +
"falling back to session installer",
)
warnFallback()
sessionInstaller.uninstall(packageName)
return
}
try {
installManager.uninstallPackage(packageName.name)
} catch (e: Exception) {
Log.w(
TAG,
"Dhizuku uninstall failed: ${packageName.name}; " +
"falling back to session installer",
e,
)
warnFallback()
sessionInstaller.uninstall(packageName)
}
}

override fun close() = Unit
private suspend fun fallbackInstall(installItem: InstallItem): InstallState {
warnFallback()
return sessionInstaller.install(installItem)
}

/**
* Warns the user, once per process, that the privileged Dhizuku path was unavailable and the
* default (session) installer is being used instead — which will prompt for confirmation.
*/
private fun warnFallback() {
if (fallbackWarned.getAndSet(true)) return
Handler(Looper.getMainLooper()).post {
Toast.makeText(context, R.string.dhizuku_fallback_warning, Toast.LENGTH_LONG).show()
}
}

override fun close() = sessionInstaller.close()

companion object {
private const val TAG = "DhizukuInstaller"
private const val PACKAGE_VISIBLE_ATTEMPTS = 20
private const val PACKAGE_VISIBLE_DELAY_MS = 250L

// Process-wide so a batch (e.g. update-all) shows the warning at most once.
private val fallbackWarned = AtomicBoolean(false)
}
}
91 changes: 86 additions & 5 deletions app/src/main/kotlin/com/looker/droidify/service/DownloadService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ import com.looker.droidify.utility.common.log
import com.looker.droidify.utility.notifications.createInstallNotification
import com.looker.droidify.utility.notifications.installNotification
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
Expand All @@ -55,14 +57,17 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import com.looker.droidify.R.string as stringRes

@AndroidEntryPoint
class DownloadService : ConnectionService<DownloadService.Binder>() {
companion object {
private const val TAG = "DroidifyUpdateAll"
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
}

Expand Down Expand Up @@ -130,9 +135,46 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
private var currentTask: CurrentTask? = null

private val lock = Mutex()
private val updateCompletions = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()

inner class Binder : android.os.Binder() {
val downloadState = _downloadState.asStateFlow()

suspend fun enqueueAndAwait(
packageName: String,
name: String,
repository: Repository,
release: Release,
isUpdate: Boolean = false,
): Boolean {
val task = Task(
packageName = packageName,
name = name,
release = release,
url = release.getDownloadUrl(repository),
authentication = repository.authentication,
isUpdate = isUpdate,
)
val deferred = CompletableDeferred<Boolean>()
updateCompletions[packageName] = deferred
log("enqueueAndAwait: $packageName", TAG)
return try {
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
log("Using cached APK for $packageName (await)", TAG)
publishSuccess(task)
} else {
enqueueDownload(task)
}
withContext(NonCancellable) { deferred.await() }
} catch (e: Exception) {
log("enqueueAndAwait failed: $packageName — ${e.message}", TAG, Log.ERROR)
signalUpdateComplete(packageName, false)
false
} finally {
updateCompletions.remove(packageName)
}
}

fun enqueue(
packageName: String,
name: String,
Expand All @@ -149,11 +191,17 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
isUpdate = isUpdate,
)
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
log("Using cached APK for $packageName", TAG)
lifecycleScope.launch { publishSuccess(task) }
return
}
cancelTasks(packageName)
cancelCurrentTask(packageName)
enqueueDownload(task)
}

private fun enqueueDownload(task: Task) {
log("Queued download for ${task.packageName} (pending=${tasks.size})", TAG)
cancelTasks(task.packageName)
cancelCurrentTask(task.packageName)
notificationManager?.cancel(
task.notificationTag,
Constants.NOTIFICATION_ID_DOWNLOADING,
Expand All @@ -162,7 +210,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
if (currentTask == null) {
handleDownload()
} else {
updateCurrentQueue { add(packageName) }
updateCurrentQueue { add(task.packageName) }
}
}

Expand Down Expand Up @@ -298,18 +346,42 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
val currentInstaller = installerType.first()
updateCurrentQueue { add("") }
updateCurrentState(State.Success(task.packageName, task.release))
log(
"Download complete: ${task.packageName}, installer=$currentInstaller, queue=${tasks.size}",
TAG,
)
val autoInstallWithSessionInstaller =
SdkCheck.canAutoInstall(task.release.targetSdkVersion) &&
currentInstaller == InstallerType.SESSION &&
task.isUpdate

showNotificationInstall(task)
if (currentInstaller == InstallerType.ROOT ||
val success = if (currentInstaller == InstallerType.ROOT ||
currentInstaller == InstallerType.SHIZUKU ||
currentInstaller == InstallerType.DHIZUKU ||
autoInstallWithSessionInstaller
) {
val installItem = task.packageName installFrom task.release.cacheFileName
installer install installItem
val result = withContext(NonCancellable) {
installer.installAndAwait(installItem)
}
log(
"Auto-install finished: ${task.packageName} -> $result",
TAG,
if (result == InstallState.Installed) Log.DEBUG else Log.WARN,
)
result == InstallState.Installed
} else {
true
}
signalUpdateComplete(task.packageName, success)
}

private fun signalUpdateComplete(packageName: String, success: Boolean) {
val pending = updateCompletions.remove(packageName)
if (pending != null) {
log("Update step complete: $packageName success=$success", TAG)
pending.complete(success)
}
}

Expand Down Expand Up @@ -436,6 +508,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
task: Task,
target: File,
) = launch {
var publishCalled = false
try {
val response = downloader.downloadToFile(
url = task.url,
Expand All @@ -458,13 +531,15 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
target.delete()
updateCurrentState(State.Error(task.packageName))
showErrorNotification(task, could_not_validate_FORMAT, result.message)
signalUpdateComplete(task.packageName, false)
return@launch
}
val releaseFile = Cache.getReleaseFile(
this@DownloadService,
task.release.cacheFileName,
)
target.renameTo(releaseFile)
publishCalled = true
publishSuccess(task)
}

Expand All @@ -478,8 +553,14 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
is NetworkResponse.Error.Unknown -> unknown_error_DESC
}
showErrorNotification(task, could_not_download_FORMAT, getString(description))
signalUpdateComplete(task.packageName, false)
}
}
} catch (e: Exception) {
log("Download failed: ${task.packageName} — ${e.message}", TAG, Log.ERROR)
if (!publishCalled) {
signalUpdateComplete(task.packageName, false)
}
} finally {
lock.withLock { currentTask = null }
handleDownload()
Expand Down
Loading