diff --git a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt index e70d2d0cf..3bd79ad43 100644 --- a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt @@ -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 @@ -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) @@ -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) diff --git a/app/src/main/kotlin/com/looker/droidify/service/DownloadService.kt b/app/src/main/kotlin/com/looker/droidify/service/DownloadService.kt index 200d756b3..23d05b7d1 100644 --- a/app/src/main/kotlin/com/looker/droidify/service/DownloadService.kt +++ b/app/src/main/kotlin/com/looker/droidify/service/DownloadService.kt @@ -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 @@ -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() { companion object { + private const val TAG = "DroidifyUpdateAll" private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" } @@ -130,9 +135,46 @@ class DownloadService : ConnectionService() { private var currentTask: CurrentTask? = null private val lock = Mutex() + private val updateCompletions = ConcurrentHashMap>() 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() + 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, @@ -149,11 +191,17 @@ class DownloadService : ConnectionService() { 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, @@ -162,7 +210,7 @@ class DownloadService : ConnectionService() { if (currentTask == null) { handleDownload() } else { - updateCurrentQueue { add(packageName) } + updateCurrentQueue { add(task.packageName) } } } @@ -298,18 +346,42 @@ class DownloadService : ConnectionService() { 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) } } @@ -436,6 +508,7 @@ class DownloadService : ConnectionService() { task: Task, target: File, ) = launch { + var publishCalled = false try { val response = downloader.downloadToFile( url = task.url, @@ -458,6 +531,7 @@ class DownloadService : ConnectionService() { 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( @@ -465,6 +539,7 @@ class DownloadService : ConnectionService() { task.release.cacheFileName, ) target.renameTo(releaseFile) + publishCalled = true publishSuccess(task) } @@ -478,8 +553,14 @@ class DownloadService : ConnectionService() { 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() diff --git a/app/src/main/kotlin/com/looker/droidify/service/SyncService.kt b/app/src/main/kotlin/com/looker/droidify/service/SyncService.kt index 542d22c1f..c3bde0e12 100644 --- a/app/src/main/kotlin/com/looker/droidify/service/SyncService.kt +++ b/app/src/main/kotlin/com/looker/droidify/service/SyncService.kt @@ -9,6 +9,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Build +import android.util.Log import android.view.ContextThemeWrapper import androidx.core.app.NotificationCompat import androidx.fragment.app.Fragment @@ -17,6 +18,8 @@ import com.looker.droidify.R import com.looker.droidify.database.Database import com.looker.droidify.datastore.Settings import com.looker.droidify.datastore.SettingsRepository +import com.looker.droidify.datastore.model.InstallerType +import com.looker.droidify.installer.installers.dhizuku.ensureDhizukuInstallerReady import com.looker.droidify.index.RepositoryUpdater import com.looker.droidify.model.ProductItem import com.looker.droidify.model.Repository @@ -31,24 +34,31 @@ import com.looker.droidify.utility.common.extension.notificationManager import com.looker.droidify.utility.common.extension.startForegroundSafe import com.looker.droidify.utility.common.extension.startServiceCompat import com.looker.droidify.utility.common.extension.stopForegroundCompat +import com.looker.droidify.utility.common.log import com.looker.droidify.utility.common.result.Result import com.looker.droidify.utility.common.sdkAbove import com.looker.droidify.utility.extension.startUpdate +import com.looker.droidify.utility.extension.startUpdateAndAwait import com.looker.droidify.utility.notifications.updatesAvailableNotification import com.looker.droidify.work.DownloadStatsWorker import com.looker.droidify.work.RBLogWorker import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -66,9 +76,12 @@ import kotlinx.coroutines.Job as CoroutinesJob class SyncService : ConnectionService() { companion object { + private const val TAG = "DroidifyUpdateAll" private const val MAX_PROGRESS = 100 private const val NOTIFICATION_UPDATE_SAMPLING = 400L + private const val DOWNLOAD_BIND_POLL_ATTEMPTS = 30 + private const val DOWNLOAD_BIND_POLL_DELAY_MS = 100L private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL" @@ -76,6 +89,23 @@ class SyncService : ConnectionService() { var autoUpdating = false var autoUpdateStartedFor: List = emptyList() + + private val _updateAllProgress = MutableStateFlow(UpdateAllProgress()) + val updateAllProgress = _updateAllProgress.asStateFlow() + + fun isUpdateAllQueued(packageName: String): Boolean = + _updateAllProgress.value.isQueued(packageName) + } + + data class UpdateAllProgress( + val batchPackages: List = emptyList(), + val activePackage: String? = null, + val completedPackages: Set = emptySet(), + ) { + fun isQueued(packageName: String): Boolean = + packageName in batchPackages && + packageName !in completedPackages && + packageName != activePackage } @Inject @@ -122,6 +152,7 @@ class SyncService : ConnectionService() { private var currentTask: CurrentTask? = null private var updateNotificationBlockerFragment: WeakReference? = null + private var updateAllInProgress = false private val downloadConnection = Connection(DownloadService::class.java) private val lock = Mutex() @@ -171,10 +202,8 @@ class SyncService : ConnectionService() { } } - suspend fun updateAllApps() { - val skipSignature = settingsRepository.getInitial().ignoreSignature - val updates = Database.ProductAdapter.getUpdates(skipSignature) - updateAllAppsInternal(updates) + fun updateAllApps() { + this@SyncService.launchUpdateAllBatch() } fun setUpdateNotificationBlocker(fragment: Fragment?) { @@ -583,7 +612,9 @@ class SyncService : ConnectionService() { if (settings.autoUpdate) { autoUpdating = true autoUpdateStartedFor = updates.map { it.packageName } - updateAllAppsInternal(updates) + withContext(NonCancellable) { + updateAllAppsInternal(updates) + } } } @@ -596,25 +627,140 @@ class SyncService : ConnectionService() { } } + private fun launchUpdateAllBatch() { + if (updateAllInProgress) { + log("Update-all already in progress, ignoring duplicate request", TAG, Log.WARN) + return + } + updateAllInProgress = true + lifecycleScope.launch { + try { + withContext(NonCancellable) { + try { + val skipSignature = settingsRepository.getInitial().ignoreSignature + val updates = Database.ProductAdapter.getUpdates(skipSignature) + autoUpdating = true + autoUpdateStartedFor = updates.map { it.packageName } + log("Update-all started: ${updates.size} apps — $autoUpdateStartedFor", TAG) + updateAllAppsInternal(updates) + } catch (e: CancellationException) { + log("Update-all cancelled unexpectedly", TAG, Log.WARN) + throw e + } catch (e: Exception) { + log("Update-all failed: ${e.message}", TAG, Log.ERROR) + } + } + } finally { + updateAllInProgress = false + } + } + } + + private suspend fun ensureDownloadBinder(): DownloadService.Binder? { + downloadConnection.binder?.let { return it } + log("DownloadService binder missing, re-binding", TAG, Log.WARN) + val intent = Intent(this@SyncService, DownloadService::class.java) + try { + if (SdkCheck.isOreo) { + startForegroundService(intent) + } else { + startService(intent) + } + } catch (e: Exception) { + log("Failed to start DownloadService: ${e.message}", TAG, Log.ERROR) + } + downloadConnection.bind(this@SyncService) + repeat(DOWNLOAD_BIND_POLL_ATTEMPTS) { + delay(DOWNLOAD_BIND_POLL_DELAY_MS) + downloadConnection.binder?.let { return it } + } + return downloadConnection.binder + } + private suspend fun updateAllAppsInternal(updates: List) { - updates - // Update Droid-ify the last - .sortedBy { if (it.packageName == packageName) 1 else -1 } - .map { - Database.InstalledAdapter.get(it.packageName, null) to - Database.RepositoryAdapter.get(it.repositoryId) + try { + val settings = settingsRepository.getInitial() + if (ensureDownloadBinder() == null) { + log("Update-all aborted: DownloadService not available", TAG, Log.ERROR) + return } - .filter { it.first != null && it.second != null } - .forEach { (installItem, repo) -> - val productRepo = Database.ProductAdapter.get(installItem!!.packageName, null) - .filter { it.repositoryId == repo!!.id } - .map { it to repo!! } - downloadConnection.startUpdate( - installItem.packageName, - installItem, - productRepo, - ) + if (settings.installerType == InstallerType.DHIZUKU && + !ensureDhizukuInstallerReady(this@SyncService) + ) { + log("Update-all aborted: Dhizuku not ready", TAG, Log.ERROR) + return } + + val items = updates + // Update Droid-ify the last + .sortedBy { if (it.packageName == packageName) 1 else -1 } + .map { + Database.InstalledAdapter.get(it.packageName, null) to + Database.RepositoryAdapter.get(it.repositoryId) + } + .filter { it.first != null && it.second != null } + + _updateAllProgress.value = UpdateAllProgress( + batchPackages = items.map { it.first!!.packageName }, + ) + + for ((installItem, repo) in items) { + val packageName = installItem!!.packageName + _updateAllProgress.update { it.copy(activePackage = packageName) } + try { + val productRepo = Database.ProductAdapter.get(packageName, null) + .filter { it.repositoryId == repo!!.id } + .map { it to repo!! } + if (productRepo.isEmpty()) { + log( + "Update-all skipped $packageName: no product for repo ${repo!!.id}", + TAG, + Log.WARN, + ) + continue + } + if (ensureDownloadBinder() == null) { + log( + "Update-all skipped $packageName: DownloadService not bound", + TAG, + Log.ERROR, + ) + continue + } + if (settings.installerType == InstallerType.DHIZUKU && + !ensureDhizukuInstallerReady(this@SyncService) + ) { + log( + "Update-all skipped $packageName: Dhizuku not ready", + TAG, + Log.ERROR, + ) + continue + } + log("Update-all processing $packageName", TAG) + val success = downloadConnection.startUpdateAndAwait( + packageName, + installItem, + productRepo, + ) + log( + "Update-all finished $packageName: success=$success", + TAG, + if (success) Log.DEBUG else Log.WARN, + ) + } finally { + _updateAllProgress.update { + it.copy( + activePackage = null, + completedPackages = it.completedPackages + packageName, + ) + } + } + } + log("Update-all queue drained", TAG) + } finally { + _updateAllProgress.value = UpdateAllProgress() + } } @SuppressLint("SpecifyJobSchedulerIdRange") diff --git a/app/src/main/kotlin/com/looker/droidify/ui/MessageDialog.kt b/app/src/main/kotlin/com/looker/droidify/ui/MessageDialog.kt index c33b8d483..0016adef3 100644 --- a/app/src/main/kotlin/com/looker/droidify/ui/MessageDialog.kt +++ b/app/src/main/kotlin/com/looker/droidify/ui/MessageDialog.kt @@ -12,6 +12,7 @@ import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.looker.droidify.model.Release +import com.looker.droidify.ui.appDetail.AppDetailFragment import com.looker.droidify.ui.repository.RepositoryFragment import com.looker.droidify.utility.PackageItemResolver import com.looker.droidify.utility.common.SdkCheck @@ -52,6 +53,15 @@ class MessageDialog() : DialogFragment() { dialog.setNegativeButton(stringRes.cancel, null) } + is Message.UninstallConfirm -> { + dialog.setTitle(stringRes.confirmation) + dialog.setMessage(getString(stringRes.uninstall_application_DESC, message.appName)) + dialog.setPositiveButton(stringRes.uninstall) { _, _ -> + (parentFragment as? AppDetailFragment)?.onUninstallConfirm() + } + dialog.setNegativeButton(stringRes.cancel, null) + } + is Message.CantEditSyncing -> { dialog.setTitle(stringRes.action_failed) dialog.setMessage(stringRes.cant_edit_sync_DESC) @@ -203,6 +213,9 @@ sealed interface Message : Parcelable { @Parcelize data object DeleteRepositoryConfirm : Message + @Parcelize + data class UninstallConfirm(val appName: String) : Message + @Parcelize data object CantEditSyncing : Message diff --git a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailFragment.kt b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailFragment.kt index c5d987216..d17d67878 100644 --- a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailFragment.kt +++ b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailFragment.kt @@ -24,7 +24,9 @@ import coil3.load import coil3.request.allowHardware import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.looker.droidify.content.ProductPreferences +import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.installer.installers.launchShizuku +import com.looker.droidify.installer.installers.dhizuku.launchDhizuku import com.looker.droidify.installer.model.InstallState import com.looker.droidify.installer.model.isCancellable import com.looker.droidify.model.InstalledItem @@ -35,6 +37,7 @@ import com.looker.droidify.model.Repository import com.looker.droidify.model.findSuggested import com.looker.droidify.service.Connection import com.looker.droidify.service.DownloadService +import com.looker.droidify.service.SyncService import com.looker.droidify.ui.Message import com.looker.droidify.ui.MessageDialog import com.looker.droidify.ui.ScreenFragment @@ -65,6 +68,11 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { companion object { private const val STATE_LAYOUT_MANAGER = "layoutManager" private const val STATE_ADAPTER = "adapter" + private val SILENT_AUTO_INSTALLERS = setOf( + InstallerType.DHIZUKU, + InstallerType.SHIZUKU, + InstallerType.ROOT, + ) } constructor(packageName: String, repoAddress: String? = null) : this() { @@ -103,6 +111,13 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { private var installed: Installed? = null private var downloading = false private var installing: InstallState? = null + private var autoInstallTriggeredFor: String? = null + private var installerType = InstallerType.Default + + private val isInstallerOperationActive: Boolean + get() = installing == InstallState.Installing || + installing == InstallState.Uninstalling || + installing == InstallState.Pending private var recyclerView: RecyclerView? = null private var detailAdapter: AppDetailAdapter? = null @@ -112,7 +127,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { serviceClass = DownloadService::class.java, onBind = { _, binder -> lifecycleScope.launch { - binder.downloadState.collect(::updateDownloadState) + binder.downloadState.collect { refreshDownloadUi(it) } } }, ) @@ -170,6 +185,8 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { recyclerView?.layoutManager!!.onRestoreInstanceState(it) } layoutManagerState = null + installerType = state.installerType + val wasInstalled = installed != null installed = state.installedItem?.let { with(requireContext().packageManager) { val isSystem = isSystemApplication(viewModel.packageName) @@ -181,6 +198,15 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { Installed(it, isSystem, launcherActivities) } } + if (state.installedItem == null && wasInstalled) { + installing = null + downloading = false + detailAdapter?.status = AppDetailAdapter.Status.Idle + } else if (state.installedItem != null && installing == InstallState.Installing) { + installing = null + downloading = false + detailAdapter?.status = AppDetailAdapter.Status.Idle + } val adapter = recyclerView?.adapter as? AppDetailAdapter // `delay` is cancellable hence it waits for 50 milliseconds to show empty page @@ -208,6 +234,11 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { launch { viewModel.installerState.collect(::updateInstallState) } + launch { + SyncService.updateAllProgress.collect { + refreshDownloadUi() + } + } launch { recyclerView?.isFirstItemVisible?.collect(::updateToolbarButtons) } @@ -226,6 +257,18 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { downloadConnection.unbind(requireContext()) } + override fun onResume() { + super.onResume() + syncOperationState() + } + + private fun syncOperationState() { + updateInstallState(viewModel.installerState.value) + refreshDownloadUi() + clearStaleOperationStatus() + updateButtons() + } + @SuppressLint("RestrictedApi") override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) @@ -310,7 +353,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { } val adapterAction = when { - installing == InstallState.Installing -> null + isInstallerOperationActive && installing != InstallState.Pending -> null installing == InstallState.Pending -> AppDetailAdapter.Action.CANCEL downloading -> AppDetailAdapter.Action.CANCEL else -> primaryAction.adapterAction @@ -318,11 +361,13 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { (recyclerView?.adapter as? AppDetailAdapter)?.action = adapterAction + val operationInProgress = downloading || isInstallerOperationActive for (action in sequenceOf( Action.INSTALL, Action.UPDATE, + Action.LAUNCH, )) { - toolbar.menu.findItem(action.id).isEnabled = !downloading + toolbar.menu.findItem(action.id).isEnabled = !operationInProgress } this.actions = Pair(actions, primaryAction) updateToolbarButtons() @@ -352,22 +397,53 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { } private fun updateInstallState(installerState: InstallState?) { + val updateAllQueued = SyncService.isUpdateAllQueued(viewModel.packageName) val status = when (installerState) { InstallState.Pending -> AppDetailAdapter.Status.PendingInstall InstallState.Installing -> AppDetailAdapter.Status.Installing - else -> AppDetailAdapter.Status.Idle + InstallState.Uninstalling -> AppDetailAdapter.Status.Uninstalling + else -> if (updateAllQueued) { + AppDetailAdapter.Status.Pending + } else { + AppDetailAdapter.Status.Idle + } } (recyclerView?.adapter as? AppDetailAdapter)?.status = status - installing = installerState + installing = when (installerState) { + InstallState.Pending, InstallState.Installing, InstallState.Uninstalling -> installerState + else -> null + } + when { + installing != null -> downloading = false + updateAllQueued -> downloading = true + } updateButtons() } - private fun updateDownloadState(state: DownloadService.DownloadState) { + private fun clearStaleOperationStatus() { + if (isInstallerOperationActive || downloading) return + if (SyncService.isUpdateAllQueued(viewModel.packageName)) return + when (detailAdapter?.status) { + AppDetailAdapter.Status.Installing, + AppDetailAdapter.Status.Uninstalling, + AppDetailAdapter.Status.PendingInstall, + -> detailAdapter?.status = AppDetailAdapter.Status.Idle + else -> Unit + } + } + + private fun refreshDownloadUi( + state: DownloadService.DownloadState = + downloadConnection.binder?.downloadState?.value ?: DownloadService.DownloadState(), + ) { val packageName = viewModel.packageName - val isPending = packageName in state.queue + val updateAllQueued = SyncService.isUpdateAllQueued(packageName) + val isPending = packageName in state.queue || updateAllQueued val isDownloading = state isDownloading packageName val isCompleted = state isComplete packageName - val isActive = isPending || isDownloading + val downloadSucceeded = state.currentItem is DownloadService.State.Success && + state.currentItem.packageName == packageName + val isActive = if (downloadSucceeded) false else isPending || isDownloading if (isPending) { detailAdapter?.status = AppDetailAdapter.Status.Pending } @@ -382,18 +458,29 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { else -> AppDetailAdapter.Status.Idle } } - if (isCompleted) { + if (isCompleted && !updateAllQueued) { detailAdapter?.status = AppDetailAdapter.Status.Idle } if (this.downloading != isActive) { this.downloading = isActive updateButtons() } + clearStaleOperationStatus() if (state.currentItem is DownloadService.State.Success && isResumed) { - viewModel.installPackage( - state.currentItem.packageName, - state.currentItem.release.cacheFileName, - ) + if (installerType in SILENT_AUTO_INSTALLERS) { + return + } + val success = state.currentItem + if (success.packageName == packageName) { + val key = "${success.packageName}:${success.release.cacheFileName}" + if (autoInstallTriggeredFor != key) { + autoInstallTriggeredFor = key + viewModel.installPackage( + success.packageName, + success.release.cacheFileName, + ) + } + } } } @@ -416,11 +503,14 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { ).show() return } - downloadConnection.startUpdate( - packageName = viewModel.packageName, - installedItem = installed?.installedItem, - products = products, - ) + + runAfterDhizukuReady { + downloadConnection.startUpdate( + packageName = viewModel.packageName, + installedItem = installed?.installedItem, + products = products, + ) + } } AppDetailAdapter.Action.LAUNCH -> { @@ -444,7 +534,14 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { ) } - AppDetailAdapter.Action.UNINSTALL -> viewModel.uninstallPackage() + AppDetailAdapter.Action.UNINSTALL -> { + if (installerType == InstallerType.DHIZUKU) { + val appName = products.firstOrNull()?.first?.name ?: viewModel.packageName + MessageDialog(Message.UninstallConfirm(appName)).show(childFragmentManager) + } else { + viewModel.uninstallPackage() + } + } AppDetailAdapter.Action.CANCEL -> { val binder = downloadConnection.binder @@ -482,6 +579,28 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { } } + internal fun onUninstallConfirm() { + runAfterDhizukuReady { + viewModel.uninstallPackage() + } + } + + private fun runAfterDhizukuReady(onReady: () -> Unit) { + viewLifecycleOwner.lifecycleScope.launch { + val dhizukuState = viewModel.prepareDhizuku(requireContext()) + if (dhizukuState != null) { + dhizukuDialog( + context = requireContext(), + dhizukuState = dhizukuState, + openDhizuku = { launchDhizuku(requireContext()) }, + switchInstaller = { viewModel.setDefaultInstaller() }, + ).show() + return@launch + } + onReady() + } + } + override fun onFavouriteClicked() { viewModel.setFavouriteState() } diff --git a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailViewModel.kt b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailViewModel.kt index c84aaeaf9..9bf961c33 100644 --- a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailViewModel.kt +++ b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailViewModel.kt @@ -18,7 +18,9 @@ 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.isSuiAvailable -import com.looker.droidify.installer.installers.requestPermissionListener +import com.looker.droidify.installer.installers.dhizuku.ensureDhizukuInstallerReady +import com.looker.droidify.installer.installers.dhizuku.isDhizukuAlive +import com.looker.droidify.installer.installers.dhizuku.isDhizukuInstalled import com.looker.droidify.installer.model.InstallState import com.looker.droidify.installer.model.installFrom import com.looker.droidify.model.InstalledItem @@ -28,7 +30,8 @@ import com.looker.droidify.utility.common.extension.asStateFlow import com.looker.droidify.utility.extension.combine import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import javax.inject.Inject @@ -48,9 +51,9 @@ class AppDetailViewModel @Inject constructor( savedStateHandle.getStateFlow(ARG_REPO_ADDRESS, null) val installerState: StateFlow = - installer.state.mapNotNull { stateMap -> + installer.state.map { stateMap -> stateMap[packageName.toPackageName()] - }.asStateFlow(null) + }.asStateFlow(null, started = SharingStarted.Eagerly) val customButtons: StateFlow> = customButtonRepository.buttons .asStateFlow(emptyList()) @@ -79,6 +82,7 @@ class AppDetailViewModel @Inject constructor( allowIncompatibleVersions = settings.incompatibleVersions, isSelf = packageName == BuildConfig.APPLICATION_ID, addressIfUnavailable = suggestedAddress, + installerType = settings.installerType, ) }.asStateFlow(AppDetailUiState()) @@ -86,26 +90,51 @@ class AppDetailViewModel @Inject constructor( val isSelected = runBlocking { settingsRepository.getInitial().installerType == InstallerType.SHIZUKU } if (!isSelected) return null - val isAlive = isShizukuAlive() - val isSuiAvailable = isSuiAvailable() - if (isSuiAvailable) return null - - val isGranted = if (isAlive) { - if (isShizukuGranted()) { - true - } else { - runBlocking { requestPermissionListener() } - } - } else { - false - } + if (isSuiAvailable()) return null + + val installed = isShizukuInstalled(context) + val alive = isShizukuAlive() + val granted = isShizukuGranted() + if (installed && alive && granted) return null return ShizukuState( - isNotInstalled = !isShizukuInstalled(context), - isNotGranted = !isGranted, - isNotAlive = !isAlive, + isNotInstalled = !installed, + isNotAlive = installed && !alive, + isNotGranted = alive && !granted, ) } + suspend fun prepareDhizuku(context: Context): DhizukuState? { + if (settingsRepository.getInitial().installerType != InstallerType.DHIZUKU) return null + + if (!isDhizukuInstalled(context)) { + return DhizukuState( + isNotInstalled = true, + isNotAlive = false, + isNotGranted = false, + ) + } + if (!ensureDhizukuInstallerReady(context)) { + return when { + !isDhizukuInstalled(context) -> DhizukuState( + isNotInstalled = true, + isNotAlive = false, + isNotGranted = false, + ) + !isDhizukuAlive(context) -> DhizukuState( + isNotInstalled = false, + isNotAlive = true, + isNotGranted = false, + ) + else -> DhizukuState( + isNotInstalled = false, + isNotAlive = false, + isNotGranted = true, + ) + } + } + return null + } + fun setDefaultInstaller() { viewModelScope.launch { settingsRepository.setInstallerType(InstallerType.Default) @@ -146,6 +175,8 @@ class AppDetailViewModel @Inject constructor( } } +typealias DhizukuState = ShizukuState + data class ShizukuState( val isNotInstalled: Boolean, val isNotGranted: Boolean, @@ -165,4 +196,5 @@ data class AppDetailUiState( val isFavourite: Boolean = false, val allowIncompatibleVersions: Boolean = false, val addressIfUnavailable: String? = null, + val installerType: InstallerType = InstallerType.Default, ) diff --git a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/DhizukuErrorDialog.kt b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/DhizukuErrorDialog.kt new file mode 100644 index 000000000..46fb7aa5b --- /dev/null +++ b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/DhizukuErrorDialog.kt @@ -0,0 +1,36 @@ +package com.looker.droidify.ui.appDetail + +import android.content.Context +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.looker.droidify.R.string as stringRes + +fun dhizukuDialog( + context: Context, + dhizukuState: DhizukuState, + openDhizuku: () -> Unit, + switchInstaller: () -> Unit, +) = with(MaterialAlertDialogBuilder(context)) { + when { + dhizukuState.isNotAlive -> { + setTitle(stringRes.error_dhizuku_service_unavailable) + setMessage(stringRes.error_dhizuku_not_running_DESC) + } + + dhizukuState.isNotGranted -> { + setTitle(stringRes.error_dhizuku_not_granted) + setMessage(stringRes.error_dhizuku_not_granted_DESC) + } + + dhizukuState.isNotInstalled -> { + setTitle(stringRes.error_dhizuku_not_installed) + setMessage(stringRes.error_dhizuku_not_installed_DESC) + } + } + setPositiveButton(stringRes.switch_to_default_installer) { _, _ -> + switchInstaller() + } + setNeutralButton(stringRes.open_dhizuku) { _, _ -> + openDhizuku() + } + setNegativeButton(stringRes.cancel, null) +} diff --git a/app/src/main/kotlin/com/looker/droidify/ui/appList/AppListViewModel.kt b/app/src/main/kotlin/com/looker/droidify/ui/appList/AppListViewModel.kt index 123bc7f14..fcc353bad 100644 --- a/app/src/main/kotlin/com/looker/droidify/ui/appList/AppListViewModel.kt +++ b/app/src/main/kotlin/com/looker/droidify/ui/appList/AppListViewModel.kt @@ -65,9 +65,7 @@ class AppListViewModel val syncConnection = Connection(SyncService::class.java) fun updateAll() { - viewModelScope.launch { - syncConnection.binder?.updateAllApps() - } + syncConnection.binder?.updateAllApps() } fun setSection(newSection: ProductItem.Section) { diff --git a/app/src/main/kotlin/com/looker/droidify/utility/extension/Connection.kt b/app/src/main/kotlin/com/looker/droidify/utility/extension/Connection.kt index 5f7e62924..fd700a3dd 100644 --- a/app/src/main/kotlin/com/looker/droidify/utility/extension/Connection.kt +++ b/app/src/main/kotlin/com/looker/droidify/utility/extension/Connection.kt @@ -1,37 +1,89 @@ package com.looker.droidify.utility.extension +import android.util.Log import com.looker.droidify.model.InstalledItem import com.looker.droidify.model.Product +import com.looker.droidify.model.Release import com.looker.droidify.model.Repository import com.looker.droidify.model.findSuggested import com.looker.droidify.service.Connection import com.looker.droidify.service.DownloadService +import com.looker.droidify.utility.common.log import com.looker.droidify.utility.extension.android.Android +private const val TAG = "DroidifyUpdateAll" + fun Connection.startUpdate( packageName: String, installedItem: InstalledItem?, products: List>, ) { - if (binder == null || products.isEmpty()) return + val params = resolveUpdateParams(packageName, installedItem, products) ?: return + requireNotNull(binder).enqueue( + packageName = packageName, + name = params.product.name, + repository = params.repository, + release = params.release, + isUpdate = installedItem != null, + ) +} - val (product, repository) = products.findSuggested(installedItem) ?: return +suspend fun Connection.startUpdateAndAwait( + packageName: String, + installedItem: InstalledItem?, + products: List>, +): Boolean { + val params = resolveUpdateParams(packageName, installedItem, products) ?: return false + return requireNotNull(binder).enqueueAndAwait( + packageName = packageName, + name = params.product.name, + repository = params.repository, + release = params.release, + isUpdate = installedItem != null, + ) +} + +private data class UpdateParams( + val product: Product, + val repository: Repository, + val release: Release, +) + +private fun Connection.resolveUpdateParams( + packageName: String, + installedItem: InstalledItem?, + products: List>, +): UpdateParams? { + if (binder == null) { + log("startUpdate skipped $packageName: binder null", TAG, Log.ERROR) + return null + } + if (products.isEmpty()) { + log("startUpdate skipped $packageName: no products", TAG, Log.WARN) + return null + } + + val (product, repository) = products.findSuggested(installedItem) ?: run { + log("startUpdate skipped $packageName: findSuggested returned null", TAG, Log.WARN) + return null + } val compatibleReleases = product.selectedReleases .filter { installedItem == null || installedItem.signature == it.signature } - .ifEmpty { return } + if (compatibleReleases.isEmpty()) { + log("startUpdate skipped $packageName: no compatible release", TAG, Log.WARN) + return null + } val selectedRelease = compatibleReleases.singleOrNull() ?: compatibleReleases.run { filter { Android.primaryPlatform in it.platforms }.minByOrNull { it.platforms.size } ?: minByOrNull { it.platforms.size } ?: firstOrNull() - } ?: return + } + if (selectedRelease == null) { + log("startUpdate skipped $packageName: no selected release", TAG, Log.WARN) + return null + } - requireNotNull(binder).enqueue( - packageName = packageName, - name = product.name, - repository = repository, - release = selectedRelease, - isUpdate = installedItem != null, - ) + return UpdateParams(product, repository, selectedRelease) } diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b2a0d217e..efeacd82a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -171,6 +171,16 @@ セッションインストーラー Root インストーラー Shizuku インストーラー + Dhizuku インストーラー + Dhizuku が動作していません + Dhizuku はインストールされていません + Dhizuku サービスが動作していません + Dhizuku サービスが動作していません + Dhizuku の権限が付与されていません + Dhizuku の権限が付与されていません + Dhizuku がインストールされていません + Dhizuku はインストールされていません + Dhizuku を開く さらに %d 件 プロキシホスト プロキシポート @@ -179,6 +189,7 @@ アプリの自動更新 アップデートを自動的にインストールします インストール中 + アンインストール中 インストール開始を待機中… お気に入り Material You @@ -209,6 +220,7 @@ ファイルから全てのリポジトリをインポートする アンインストール %s をアンインストールしました + 「%s」をアンインストールしますか? インストールに失敗しました %s のインストールに失敗しました LSPosedユーザー、上級ユーザー向けに、apkをインストールする際の署名の検証を無視します diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2500b6c30..83ca7f73b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -115,6 +115,15 @@ Root installer Shizuku/Sui installer Dhizuku installer + Dhizuku isn\'t running + Dhizuku isn\'t installed + Dhizuku service not running + The Dhizuku service isn\'t running + No Dhizuku permission granted + The Dhizuku permission hasn\'t been granted + Dhizuku not installed + Dhizuku isn\'t installed + Open Dhizuku Shizuku/Sui legacy installer Shizuku/Sui isn\'t running Shizuku/Sui isn\'t installed @@ -125,6 +134,7 @@ Failed to install %s Uninstalled %s has been uninstalled + Uninstall %s? Couldn\'t check integrity Invalid address Invalid fingerprint format