Add update all button for extensions update notifications

This commit is contained in:
Jays2Kings 2021-07-17 01:16:29 -04:00
parent 2ea3142b32
commit ea0d8224b2
12 changed files with 382 additions and 80 deletions

View File

@ -204,6 +204,10 @@
android:name=".data.library.LibraryUpdateService" android:name=".data.library.LibraryUpdateService"
android:exported="false" /> android:exported="false" />
<service
android:name=".extension.ExtensionInstallService"
android:exported="false" />
<service <service
android:name=".data.download.DownloadService" android:name=".data.download.DownloadService"
android:exported="false" /> android:exported="false" />

View File

@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.updater.UpdaterService import eu.kanade.tachiyomi.data.updater.UpdaterService
import eu.kanade.tachiyomi.extension.ExtensionInstallService
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.manga.MangaDetailsController
@ -71,6 +72,7 @@ class NotificationReceiver : BroadcastReceiver() {
) )
// Cancel library update and dismiss notification // Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
ACTION_CANCEL_EXTENSION_UPDATE -> cancelExtensionUpdate(context)
ACTION_CANCEL_UPDATE_DOWNLOAD -> cancelDownloadUpdate(context) ACTION_CANCEL_UPDATE_DOWNLOAD -> cancelDownloadUpdate(context)
ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context) ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context)
// Share backup file // Share backup file
@ -199,6 +201,17 @@ class NotificationReceiver : BroadcastReceiver() {
Handler().post { dismissNotification(context, Notifications.ID_LIBRARY_PROGRESS) } Handler().post { dismissNotification(context, Notifications.ID_LIBRARY_PROGRESS) }
} }
/**
* Method called when user wants to stop a library update
*
* @param context context of application
* @param notificationId id of notification
*/
private fun cancelExtensionUpdate(context: Context) {
ExtensionInstallService.stop(context)
Handler().post { dismissNotification(context, Notifications.ID_EXTENSION_PROGRESS) }
}
/** /**
* Method called when user wants to mark as read * Method called when user wants to mark as read
* *
@ -251,6 +264,9 @@ class NotificationReceiver : BroadcastReceiver() {
// Called to cancel library update. // Called to cancel library update.
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
// Called to cancel extension update.
private const val ACTION_CANCEL_EXTENSION_UPDATE = "$ID.$NAME.CANCEL_EXTENSION_UPDATE"
private const val ACTION_CANCEL_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_UPDATE_DOWNLOAD" private const val ACTION_CANCEL_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_UPDATE_DOWNLOAD"
// Called to mark as read // Called to mark as read
@ -568,6 +584,19 @@ class NotificationReceiver : BroadcastReceiver() {
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
/**
* Returns [PendingIntent] that starts a service which stops the library update
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun cancelExtensionUpdatePendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CANCEL_EXTENSION_UPDATE
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/** /**
* Returns [PendingIntent] that cancels the download for a Tachiyomi update * Returns [PendingIntent] that cancels the download for a Tachiyomi update
* *

View File

@ -47,6 +47,7 @@ object Notifications {
*/ */
const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel" const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel"
const val ID_UPDATES_TO_EXTS = -401 const val ID_UPDATES_TO_EXTS = -401
const val ID_EXTENSION_PROGRESS = -402
private const val GROUP_BACKUP_RESTORE = "group_backup_restore" private const val GROUP_BACKUP_RESTORE = "group_backup_restore"
const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel" const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel"

View File

@ -0,0 +1,62 @@
package eu.kanade.tachiyomi.extension
import android.content.Context
import android.graphics.BitmapFactory
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notificationManager
class ExtensionInstallNotifier(private val context: Context) {
/**
* Bitmap of the app for notifications.
*/
private val notificationBitmap by lazy {
BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
}
/**
* Pending intent of action that cancels the library update
*/
private val cancelIntent by lazy {
NotificationReceiver.cancelExtensionUpdatePendingBroadcast(context)
}
/**
* Cached progress notification to avoid creating a lot.
*/
val progressNotificationBuilder by lazy {
context.notificationBuilder(Notifications.CHANNEL_UPDATES_TO_EXTS) {
setContentTitle(context.getString(R.string.app_name))
setSmallIcon(android.R.drawable.stat_sys_download)
setLargeIcon(notificationBitmap)
setContentTitle(context.getString(R.string.updating_extensions))
setProgress(0, 0, true)
setOngoing(true)
setSilent(true)
setOnlyAlertOnce(true)
color = ContextCompat.getColor(context, R.color.colorAccent)
addAction(R.drawable.ic_close_24dp, context.getString(android.R.string.cancel), cancelIntent)
}
}
/**
* Shows the notification containing the currently updating manga and the progress.
*
* @param manga the manga that's being updated.
* @param current the current progress.
* @param total the total progress.
*/
fun showProgressNotification() {
context.notificationManager.notify(
Notifications.ID_EXTENSION_PROGRESS,
progressNotificationBuilder
.setContentTitle(context.getString(R.string.updating_extensions))
.setProgress(0, 0, true)
.build()
)
}
}

View File

@ -0,0 +1,145 @@
package eu.kanade.tachiyomi.extension
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.os.PowerManager
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.extension.ExtensionManager.ExtensionInfo
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.util.system.notificationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.ArrayList
import java.util.concurrent.TimeUnit
class ExtensionInstallService(
val extensionManager: ExtensionManager = Injekt.get(),
) : Service() {
/**
* Wake lock that will be held until the service is destroyed.
*/
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var notifier: ExtensionInstallNotifier
private var job: Job? = null
private var serviceScope = CoroutineScope(Job() + Dispatchers.Default)
private val requestSemaphore = Semaphore(3)
/**
* This method needs to be implemented, but it's not used/needed.
*/
override fun onBind(intent: Intent): IBinder? {
return null
}
/**
* Method called when the service receives an intent.
*
* @param intent the start intent from.
* @param flags the flags of the command.
* @param startId the start id of this command.
* @return the start value of the command.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) return START_NOT_STICKY
instance = this
val list = intent.getParcelableArrayListExtra<ExtensionInfo>(KEY_EXTENSION)
?: return START_NOT_STICKY
job = serviceScope.launch {
val results = list.map {
async {
installExtension(it)
}
}
results.awaitAll()
}
job?.invokeOnCompletion { stopSelf(startId) }
return START_REDELIVER_INTENT
}
suspend fun installExtension(extension: ExtensionInfo) {
requestSemaphore.withPermit {
extensionManager.installExtension(extension, serviceScope)
.collect {
notifier.showProgressNotification()
}
}
}
/**
* Method called when the service is created. It injects dagger dependencies and acquire
* the wake lock.
*/
override fun onCreate() {
super.onCreate()
notificationManager.cancel(Notifications.ID_UPDATES_TO_EXTS)
notifier = ExtensionInstallNotifier(this)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"ExtensionInstallService:WakeLock"
)
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
startForeground(Notifications.ID_EXTENSION_PROGRESS, notifier.progressNotificationBuilder.build())
}
/**
* Method called when the service is destroyed. It cancels jobs and releases the wake lock.
*/
override fun onDestroy() {
job?.cancel()
serviceScope.cancel()
if (instance == this) {
instance = null
}
if (wakeLock.isHeld) {
wakeLock.release()
}
super.onDestroy()
}
companion object {
private var instance: ExtensionInstallService? = null
/**
* Stops the service.
*
* @param context the application context.
*/
fun stop(context: Context) {
instance?.serviceScope?.cancel()
context.stopService(Intent(context, ExtensionUpdateJob::class.java))
}
/**
* Key that defines what should be updated.
*/
private const val KEY_EXTENSION = "extension"
fun jobIntent(context: Context, extensions: List<Extension.Available>): Intent {
return Intent(context, ExtensionInstallService::class.java).apply {
val info = extensions.map(::ExtensionInfo)
putParcelableArrayListExtra(KEY_EXTENSION, ArrayList(info))
}
}
}
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Parcelable
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -15,11 +16,12 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo
import eu.kanade.tachiyomi.util.system.launchNow import eu.kanade.tachiyomi.util.system.launchNow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -49,6 +51,15 @@ class ExtensionManager(
*/ */
private val installer by lazy { ExtensionInstaller(context) } private val installer by lazy { ExtensionInstaller(context) }
val downloadRelay
get() = installer.downloadsStateFlow
fun getExtension(downloadId: Long): String? {
return installer.activeDownloads.entries.find { downloadId == it.value }?.key
}
fun getActiveInstalls(): Int = installer.activeDownloads.size
/** /**
* Relay used to notify the installed extensions. * Relay used to notify the installed extensions.
*/ */
@ -240,21 +251,8 @@ class ExtensionManager(
* *
* @param extension The extension to be installed. * @param extension The extension to be installed.
*/ */
fun installExtension(extension: Extension.Available): Flow<ExtensionIntallInfo> { suspend fun installExtension(extension: ExtensionInfo, scope: CoroutineScope): Flow<ExtensionIntallInfo> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension) return installer.downloadAndInstall(api.getApkUrl(extension), extension, scope)
}
/**
* Returns a flow of the installation process for the given extension. It will complete
* once the extension is updated or throws an error. The process will be canceled the scope
* is canceled before its completion.
*
* @param extension The extension to be updated.
*/
fun updateExtension(extension: Extension.Installed): Flow<ExtensionIntallInfo> {
val availableExt = availableExtensions.find { it.pkgName == extension.pkgName }
?: return emptyFlow()
return installExtension(availableExt)
} }
/** /**
@ -407,6 +405,19 @@ class ExtensionManager(
} }
return this return this
} }
@Parcelize
data class ExtensionInfo(
val apkName: String,
val pkgName: String,
val name: String,
) : Parcelable {
constructor(extension: Extension.Available) : this(
apkName = extension.apkName,
pkgName = extension.pkgName,
name = extension.name
)
}
} }
interface ExtensionsChangedListener { interface ExtensionsChangedListener {

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.extension package eu.kanade.tachiyomi.extension
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -17,6 +19,7 @@ import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.util.system.notification import eu.kanade.tachiyomi.util.system.notification
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -35,15 +38,20 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
} }
if (pendingUpdates.isNotEmpty()) { if (pendingUpdates.isNotEmpty()) {
createUpdateNotification(pendingUpdates.map { it.name }) createUpdateNotification(pendingUpdates)
} }
Result.success() Result.success()
} }
private fun createUpdateNotification(names: List<String>) { private fun createUpdateNotification(extensions: List<Extension.Available>) {
val preferences: PreferencesHelper by injectLazy() val preferences: PreferencesHelper by injectLazy()
preferences.extensionUpdatesCount().set(names.size) preferences.extensionUpdatesCount().set(extensions.size)
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && preferences.autoUpdateExtensions()) {
// val intent = ExtensionInstallService.jobIntent(context, extensions)
// context.startForegroundService(intent)
// return
// }
NotificationManagerCompat.from(context).apply { NotificationManagerCompat.from(context).apply {
notify( notify(
Notifications.ID_UPDATES_TO_EXTS, Notifications.ID_UPDATES_TO_EXTS,
@ -51,11 +59,11 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
setContentTitle( setContentTitle(
context.resources.getQuantityString( context.resources.getQuantityString(
R.plurals.extension_updates_available, R.plurals.extension_updates_available,
names.size, extensions.size,
names.size extensions.size
) )
) )
val extNames = names.joinToString(", ") val extNames = extensions.joinToString(", ") { it.name }
setContentText(extNames) setContentText(extNames)
setStyle(NotificationCompat.BigTextStyle().bigText(extNames)) setStyle(NotificationCompat.BigTextStyle().bigText(extNames))
setSmallIcon(R.drawable.ic_extension_update_24dp) setSmallIcon(R.drawable.ic_extension_update_24dp)
@ -65,6 +73,16 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
context context
) )
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val intent = ExtensionInstallService.jobIntent(context, extensions)
val pendingIntent =
PendingIntent.getForegroundService(context, 0, intent, 0)
addAction(
R.drawable.ic_file_download_24dp,
context.getString(R.string.update_all),
pendingIntent
)
}
setAutoCancel(true) setAutoCancel(true)
} }
) )

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.extension.api package eu.kanade.tachiyomi.extension.api
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
@ -30,7 +31,7 @@ internal class ExtensionGithubApi {
} }
} }
suspend fun checkForUpdates(context: Context): List<Extension.Installed> { suspend fun checkForUpdates(context: Context): List<Extension.Available> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val extensions = findExtensions() val extensions = findExtensions()
@ -38,7 +39,7 @@ internal class ExtensionGithubApi {
.filterIsInstance<LoadResult.Success>() .filterIsInstance<LoadResult.Success>()
.map { it.extension } .map { it.extension }
val extensionsWithUpdate = mutableListOf<Extension.Installed>() val extensionsWithUpdate = mutableListOf<Extension.Available>()
val mutInstalledExtensions = installedExtensions.toMutableList() val mutInstalledExtensions = installedExtensions.toMutableList()
for (installedExt in mutInstalledExtensions) { for (installedExt in mutInstalledExtensions) {
val pkgName = installedExt.pkgName val pkgName = installedExt.pkgName
@ -46,7 +47,7 @@ internal class ExtensionGithubApi {
val hasUpdate = availableExt.versionCode > installedExt.versionCode val hasUpdate = availableExt.versionCode > installedExt.versionCode
if (hasUpdate) { if (hasUpdate) {
extensionsWithUpdate.add(installedExt) extensionsWithUpdate.add(availableExt)
} }
} }
@ -75,7 +76,7 @@ internal class ExtensionGithubApi {
} }
} }
fun getApkUrl(extension: Extension.Available): String { fun getApkUrl(extension: ExtensionManager.ExtensionInfo): String {
return "${REPO_URL_PREFIX}apk/${extension.apkName}" return "${REPO_URL_PREFIX}apk/${extension.apkName}"
} }

View File

@ -1,7 +1,9 @@
package eu.kanade.tachiyomi.extension.util package eu.kanade.tachiyomi.extension.util
import android.app.Activity import android.app.Activity
import android.app.DownloadManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.SessionParams import android.content.pm.PackageInstaller.SessionParams
@ -56,6 +58,9 @@ class ExtensionInstallActivity : Activity() {
session.commit(statusReceiver) session.commit(statusReceiver)
val extensionManager: ExtensionManager by injectLazy() val extensionManager: ExtensionManager by injectLazy()
extensionManager.setInstalling(downloadId, sessionId) extensionManager.setInstalling(downloadId, sessionId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).remove(downloadId)
}
data.close() data.close()
} catch (error: Exception) { } catch (error: Exception) {
// Either install package can't be found (probably bots) or there's a security exception // Either install package can't be found (probably bots) or there's a security exception

View File

@ -5,14 +5,14 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -29,6 +29,8 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -54,12 +56,12 @@ internal class ExtensionInstaller(private val context: Context) {
* The currently requested downloads, with the package name (unique id) as key, and the id * The currently requested downloads, with the package name (unique id) as key, and the id
* returned by the download manager. * returned by the download manager.
*/ */
private val activeDownloads = hashMapOf<String, Long>() val activeDownloads = hashMapOf<String, Long>()
/** /**
* StateFlow used to notify the installation step of every download. * StateFlow used to notify the installation step of every download.
*/ */
private val downloadsStateFlow = MutableStateFlow(0L to InstallStep.Pending) val downloadsStateFlow = MutableStateFlow(0L to ExtensionIntallInfo(InstallStep.Pending, null))
/** Map of download id to installer session id */ /** Map of download id to installer session id */
val downloadInstallerMap = hashMapOf<Long, Int>() val downloadInstallerMap = hashMapOf<Long, Int>()
@ -71,7 +73,7 @@ internal class ExtensionInstaller(private val context: Context) {
* @param url The url of the apk. * @param url The url of the apk.
* @param extension The extension to install. * @param extension The extension to install.
*/ */
fun downloadAndInstall(url: String, extension: Extension): Flow<ExtensionIntallInfo> { suspend fun downloadAndInstall(url: String, extension: ExtensionManager.ExtensionInfo, scope: CoroutineScope): Flow<ExtensionIntallInfo> {
val pkgName = extension.pkgName val pkgName = extension.pkgName
val oldDownload = activeDownloads[pkgName] val oldDownload = activeDownloads[pkgName]
@ -96,33 +98,39 @@ internal class ExtensionInstaller(private val context: Context) {
val id = downloadManager.enqueue(request) val id = downloadManager.enqueue(request)
activeDownloads[pkgName] = id activeDownloads[pkgName] = id
return flowOf( scope.launch {
pollStatus(id), flowOf(
pollInstallStatus(id), pollStatus(id),
downloadsStateFlow.filter { it.first == id } pollInstallStatus(id)
.map { ).flattenMerge()
it.second to findSession(it.first) .transformWhile {
emit(it)
!it.first.isCompleted()
} }
).flattenMerge() .flowOn(Dispatchers.IO)
.catch { e ->
Timber.e(e)
emit(InstallStep.Error to null)
}
.onCompletion {
deleteDownload(pkgName)
}
.collect {
downloadsStateFlow.emit(id to it)
}
}
return downloadsStateFlow.filter { it.first == id }.map { it.second }
.flowOn(Dispatchers.IO)
.transformWhile { .transformWhile {
emit(it) emit(it)
!it.first.isCompleted() !it.first.isCompleted()
} }
.flowOn(Dispatchers.IO)
.catch { e ->
Timber.e(e)
emit(InstallStep.Error to null)
}
.onCompletion { .onCompletion {
deleteDownload(pkgName) deleteDownload(pkgName)
} }
} }
private fun findSession(downloadId: Long): PackageInstaller.SessionInfo? {
val sessionId = downloadInstallerMap[downloadId] ?: return null
return context.packageManager.packageInstaller.getSessionInfo(sessionId)
}
/** /**
* Returns a flow that polls the given download id for its status every second, as the * Returns a flow that polls the given download id for its status every second, as the
* manager doesn't have any notification system. It'll stop once the download finishes. * manager doesn't have any notification system. It'll stop once the download finishes.
@ -134,11 +142,16 @@ internal class ExtensionInstaller(private val context: Context) {
return flow { return flow {
while (true) { while (true) {
val newDownloadState = downloadManager.query(query).use { cursor -> val newDownloadState = try {
cursor.moveToFirst() downloadManager.query(query)?.use { cursor ->
cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) cursor.moveToFirst()
cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
}
} catch (_: Exception) {
}
if (newDownloadState != null) {
emit(newDownloadState)
} }
emit(newDownloadState)
delay(1000) delay(1000)
} }
} }
@ -213,7 +226,7 @@ internal class ExtensionInstaller(private val context: Context) {
* @param downloadId The id of the download. * @param downloadId The id of the download.
*/ */
fun setInstalling(downloadId: Long, sessionId: Int) { fun setInstalling(downloadId: Long, sessionId: Int) {
downloadsStateFlow.tryEmit(downloadId to InstallStep.Installing) downloadsStateFlow.tryEmit(downloadId to ExtensionIntallInfo(InstallStep.Installing, null))
downloadInstallerMap[downloadId] = sessionId downloadInstallerMap[downloadId] = sessionId
} }
@ -232,7 +245,11 @@ internal class ExtensionInstaller(private val context: Context) {
fun setInstallationResult(downloadId: Long, result: Boolean) { fun setInstallationResult(downloadId: Long, result: Boolean) {
val step = if (result) InstallStep.Installed else InstallStep.Error val step = if (result) InstallStep.Installed else InstallStep.Error
downloadInstallerMap.remove(downloadId) downloadInstallerMap.remove(downloadId)
downloadsStateFlow.tryEmit(downloadId to step) downloadsStateFlow.tryEmit(downloadId to ExtensionIntallInfo(step, null))
}
fun softDeleteDownload(downloadId: Long) {
downloadManager.remove(downloadId)
} }
/** /**
@ -295,10 +312,10 @@ internal class ExtensionInstaller(private val context: Context) {
// Set next installation step // Set next installation step
if (uri != null) { if (uri != null) {
downloadsStateFlow.tryEmit(id to InstallStep.Loading) downloadsStateFlow.tryEmit(id to ExtensionIntallInfo(InstallStep.Loading, null))
} else { } else {
Timber.e("Couldn't locate downloaded APK") Timber.e("Couldn't locate downloaded APK")
downloadsStateFlow.tryEmit(id to InstallStep.Error) downloadsStateFlow.tryEmit(id to ExtensionIntallInfo(InstallStep.Error, null))
return return
} }

View File

@ -23,11 +23,9 @@ import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -81,7 +79,10 @@ class ExtensionBottomPresenter(
sourceItems = findSourcesWithManga(favs) sourceItems = findSourcesWithManga(favs)
mangaItems = HashMap( mangaItems = HashMap(
sourceItems.associate { sourceItems.associate {
it.source.id to this@ExtensionBottomPresenter.libraryToMigrationItem(favs, it.source.id) it.source.id to this@ExtensionBottomPresenter.libraryToMigrationItem(
favs,
it.source.id
)
} }
) )
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -94,6 +95,27 @@ class ExtensionBottomPresenter(
} }
listOf(migrationJob, extensionJob).awaitAll() listOf(migrationJob, extensionJob).awaitAll()
} }
presenterScope.launch {
extensionManager.downloadRelay
.collect {
val extPageName = extensionManager.getExtension(it.first)
val extension = extensions.find { item ->
extPageName == item.extension.pkgName
} ?: return@collect
when (it.second.first) {
InstallStep.Installed, InstallStep.Error -> {
currentDownloads.remove(extension.extension.pkgName)
}
else -> {
currentDownloads[extension.extension.pkgName] = it.second
}
}
val item = updateInstallStep(extension.extension, it.second.first, it.second.second)
if (item != null) {
withUIContext { bottomSheet.downloadUpdate(item) }
}
}
}
} }
private fun findSourcesWithManga(library: List<Manga>): List<SourceItem> { private fun findSourcesWithManga(library: List<Manga>): List<SourceItem> {
@ -249,15 +271,19 @@ class ExtensionBottomPresenter(
fun installExtension(extension: Extension.Available) { fun installExtension(extension: Extension.Available) {
if (isNotMIUIOptimized()) { if (isNotMIUIOptimized()) {
presenterScope.launch { presenterScope.launch {
extensionManager.installExtension(extension).collectForInstallUpdate(extension) extensionManager.installExtension(ExtensionManager.ExtensionInfo(extension), presenterScope)
.launchIn(this)
} }
} }
} }
fun updateExtension(extension: Extension.Installed) { fun updateExtension(extension: Extension.Installed) {
if (isNotMIUIOptimized()) { if (isNotMIUIOptimized()) {
val availableExt =
extensionManager.availableExtensions.find { it.pkgName == extension.pkgName } ?: return
presenterScope.launch { presenterScope.launch {
extensionManager.updateExtension(extension).collectForInstallUpdate(extension) extensionManager.installExtension(ExtensionManager.ExtensionInfo(availableExt), presenterScope)
.launchIn(this)
} }
} }
} }
@ -270,24 +296,6 @@ class ExtensionBottomPresenter(
return true return true
} }
private suspend fun Flow<ExtensionIntallInfo>.collectForInstallUpdate(extension: Extension) {
this
.onEach { currentDownloads[extension.pkgName] = it }
.onCompletion {
currentDownloads.remove(extension.pkgName)
val item = updateInstallStep(extension, null, null)
if (item != null) {
withUIContext { bottomSheet.downloadUpdate(item) }
}
}
.collect { state ->
val item = updateInstallStep(extension, state.first, state.second)
if (item != null) {
withUIContext { bottomSheet.downloadUpdate(item) }
}
}
}
fun uninstallExtension(pkgName: String) { fun uninstallExtension(pkgName: String) {
extensionManager.uninstallExtension(pkgName) extensionManager.uninstallExtension(pkgName)
} }

View File

@ -313,6 +313,7 @@
<string name="_must_be_enabled_first">%1$s must be enabled first</string> <string name="_must_be_enabled_first">%1$s must be enabled first</string>
<string name="could_not_install_extension">Could not install extension</string> <string name="could_not_install_extension">Could not install extension</string>
<string name="update_all">Update all</string> <string name="update_all">Update all</string>
<string name="updating_extensions">Updating extensions</string>
<plurals name="_updates_pending"> <plurals name="_updates_pending">
<item quantity="one">%d update pending</item> <item quantity="one">%d update pending</item>
<item quantity="other">%d updates pending</item> <item quantity="other">%d updates pending</item>