Improve app update prompt flow experience (#3669)

This commit is contained in:
AntsyLich
2026-07-30 20:36:19 +06:00
committed by GitHub
parent 2ccb0ca163
commit 7aaa891aef
13 changed files with 199 additions and 304 deletions
+1
View File
@@ -27,6 +27,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Add comparison operators (`>`, `>=`, `<`, `<=`, `=`) for `id`, `added`, `fetchinterval` / `fi`, `nextupdate` / `nu`, `unread`, `read`, and `total` (Uses `yyyy-MM-dd` dates for `added` and `nextupdate`) - Add comparison operators (`>`, `>=`, `<`, `<=`, `=`) for `id`, `added`, `fetchinterval` / `fi`, `nextupdate` / `nu`, `unread`, `read`, and `total` (Uses `yyyy-MM-dd` dates for `added` and `nextupdate`)
- Support field-specific prefixes (`title`, `author`, `artist`, `description` / `desc`, `genre` / `tag`, `source` / `src`, `source_id` / `src_id`, `notes` / `note`, `language` / `lang`) and empty field matching (`""`, `''`) - Support field-specific prefixes (`title`, `author`, `artist`, `description` / `desc`, `genre` / `tag`, `source` / `src`, `source_id` / `src_id`, `notes` / `note`, `language` / `lang`) and empty field matching (`""`, `''`)
- Always use Private installer to update privately installed extensions ([@MajorTanya](https://github.com/MajorTanya)) ([#3614](https://github.com/mihonapp/mihon/pull/3614)) - Always use Private installer to update privately installed extensions ([@MajorTanya](https://github.com/MajorTanya)) ([#3614](https://github.com/mihonapp/mihon/pull/3614))
- App update prompt flow experience ([@AntsyLich](https://github.com/AntsyLich)) ([#3669](https://github.com/mihonapp/mihon/pull/3669))
### Fixed ### Fixed
- Fixed app bars remaining visible after changing pages by tapping in the paged reader after using the chapter navigator slider ([@AntsyLich](https://github.com/AntsyLich)) ([#3567](https://github.com/mihonapp/mihon/pull/3567)) - Fixed app bars remaining visible after changing pages by tapping in the paged reader after using the chapter navigator slider ([@AntsyLich](https://github.com/AntsyLich)) ([#3567](https://github.com/mihonapp/mihon/pull/3567))
@@ -13,10 +13,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import eu.kanade.presentation.manga.components.MarkdownRender import eu.kanade.presentation.manga.components.MarkdownRender
import eu.kanade.presentation.theme.TachiyomiPreviewTheme import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.ui.more.NewUpdateScreenModel
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
@@ -27,16 +29,27 @@ import tachiyomi.presentation.core.screens.InfoScreen
fun NewUpdateScreen( fun NewUpdateScreen(
versionName: String, versionName: String,
changelogInfo: String, changelogInfo: String,
stage: NewUpdateScreenModel.Stage,
downloadProgress: () -> Int,
onOpenInBrowser: () -> Unit, onOpenInBrowser: () -> Unit,
onRejectUpdate: () -> Unit,
onAcceptUpdate: () -> Unit, onAcceptUpdate: () -> Unit,
onRejectUpdate: () -> Unit,
) { ) {
InfoScreen( InfoScreen(
icon = Icons.Outlined.NewReleases, icon = Icons.Outlined.NewReleases,
headingText = stringResource(MR.strings.update_check_notification_update_available), headingText = stringResource(MR.strings.update_check_notification_update_available),
subtitleText = versionName, subtitleText = versionName,
acceptText = stringResource(MR.strings.update_check_confirm), acceptText = when (stage) {
NewUpdateScreenModel.Stage.Available -> stringResource(MR.strings.update_check_confirm)
NewUpdateScreenModel.Stage.Downloading -> stringResource(
MR.strings.downloading_with_progress,
downloadProgress(),
)
NewUpdateScreenModel.Stage.Downloaded -> stringResource(MR.strings.action_install)
NewUpdateScreenModel.Stage.Failed -> stringResource(MR.strings.action_retry)
},
onAcceptClick = onAcceptUpdate, onAcceptClick = onAcceptUpdate,
canAccept = stage != NewUpdateScreenModel.Stage.Downloading,
rejectText = stringResource(MR.strings.action_not_now), rejectText = stringResource(MR.strings.action_not_now),
onRejectClick = onRejectUpdate, onRejectClick = onRejectUpdate,
) { ) {
@@ -47,7 +60,7 @@ fun NewUpdateScreen(
) { ) {
MarkdownRender( MarkdownRender(
content = changelogInfo, content = changelogInfo,
flavour = GFMFlavourDescriptor(), flavour = remember { GFMFlavourDescriptor() },
) )
TextButton( TextButton(
@@ -76,9 +89,11 @@ private fun NewUpdateScreenPreview() {
- Hello - Hello
- World - World
""".trimIndent(), """.trimIndent(),
stage = NewUpdateScreenModel.Stage.Available,
downloadProgress = { 0 },
onOpenInBrowser = {}, onOpenInBrowser = {},
onRejectUpdate = {},
onAcceptUpdate = {}, onAcceptUpdate = {},
onRejectUpdate = {},
) )
} }
} }
@@ -219,7 +219,7 @@ object AboutScreen : Screen() {
val updateChecker = AppUpdateChecker() val updateChecker = AppUpdateChecker()
withUIContext { withUIContext {
try { try {
when (val result = withIOContext { updateChecker.checkForUpdate(context, forceCheck = true) }) { when (val result = withIOContext { updateChecker.checkForUpdate(forceCheck = true) }) {
is GetApplicationRelease.Result.NewUpdate -> { is GetApplicationRelease.Result.NewUpdate -> {
onAvailableUpdate(result) onAvailableUpdate(result)
} }
@@ -9,7 +9,6 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.cancelNotification
@@ -72,10 +71,6 @@ class NotificationReceiver : BroadcastReceiver() {
ACTION_CANCEL_RESTORE -> cancelRestore(context) ACTION_CANCEL_RESTORE -> cancelRestore(context)
// Cancel library update and dismiss notification // Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
// Start downloading app update
ACTION_START_APP_UPDATE -> startDownloadAppUpdate(context, intent)
// Cancel downloading app update
ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context)
// Open reader activity // Open reader activity
ACTION_OPEN_CHAPTER -> { ACTION_OPEN_CHAPTER -> {
openChapter( openChapter(
@@ -178,15 +173,6 @@ class NotificationReceiver : BroadcastReceiver() {
LibraryUpdateJob.stop(context) LibraryUpdateJob.stop(context)
} }
private fun startDownloadAppUpdate(context: Context, intent: Intent) {
val url = intent.getStringExtra(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL) ?: return
AppUpdateDownloadJob.start(context, url)
}
private fun cancelDownloadAppUpdate(context: Context) {
AppUpdateDownloadJob.stop(context)
}
/** /**
* Method called when user wants to mark manga chapters as read * Method called when user wants to mark manga chapters as read
* *
@@ -241,9 +227,6 @@ class NotificationReceiver : BroadcastReceiver() {
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
private const val ACTION_START_APP_UPDATE = "$ID.$NAME.ACTION_START_APP_UPDATE"
private const val ACTION_CANCEL_APP_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_APP_UPDATE_DOWNLOAD"
private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ"
private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER" private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER"
private const val ACTION_DOWNLOAD_CHAPTER = "$ID.$NAME.ACTION_DOWNLOAD_CHAPTER" private const val ACTION_DOWNLOAD_CHAPTER = "$ID.$NAME.ACTION_DOWNLOAD_CHAPTER"
@@ -524,45 +507,6 @@ class NotificationReceiver : BroadcastReceiver() {
) )
} }
/**
* Returns [PendingIntent] that starts the [AppUpdateDownloadJob] to download an app update.
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun downloadAppUpdatePendingBroadcast(
context: Context,
url: String,
title: String? = null,
): PendingIntent {
return Intent(context, NotificationReceiver::class.java).run {
action = ACTION_START_APP_UPDATE
putExtra(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL, url)
title?.let { putExtra(AppUpdateDownloadJob.EXTRA_DOWNLOAD_TITLE, it) }
PendingIntent.getBroadcast(
context,
0,
this,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
/**
*
*/
internal fun cancelDownloadAppUpdatePendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CANCEL_APP_UPDATE_DOWNLOAD
}
return PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/** /**
* Returns [PendingIntent] that opens the extensions controller. * Returns [PendingIntent] that opens the extensions controller.
* *
@@ -70,8 +70,6 @@ object Notifications {
private const val GROUP_APK_UPDATES = "group_apk_updates" private const val GROUP_APK_UPDATES = "group_apk_updates"
const val CHANNEL_APP_UPDATE = "app_apk_update_channel" const val CHANNEL_APP_UPDATE = "app_apk_update_channel"
const val ID_APP_UPDATER = 1 const val ID_APP_UPDATER = 1
const val ID_APP_UPDATE_PROMPT = 2
const val ID_APP_UPDATE_ERROR = 3
const val CHANNEL_EXTENSIONS_UPDATE = "ext_apk_update_channel" const val CHANNEL_EXTENSIONS_UPDATE = "ext_apk_update_channel"
const val ID_UPDATES_TO_EXTS = -401 const val ID_UPDATES_TO_EXTS = -401
const val ID_EXTENSION_INSTALLER = -402 const val ID_EXTENSION_INSTALLER = -402
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater
import android.content.Context
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.util.system.isFossBuildType import eu.kanade.tachiyomi.util.system.isFossBuildType
import eu.kanade.tachiyomi.util.system.isPreviewBuildType import eu.kanade.tachiyomi.util.system.isPreviewBuildType
@@ -12,7 +11,7 @@ class AppUpdateChecker {
private val getApplicationRelease: GetApplicationRelease by injectLazy() private val getApplicationRelease: GetApplicationRelease by injectLazy()
suspend fun checkForUpdate(context: Context, forceCheck: Boolean = false): GetApplicationRelease.Result { suspend fun checkForUpdate(forceCheck: Boolean = false): GetApplicationRelease.Result {
// Disable app update checks for older Android versions that we're going to drop support for // Disable app update checks for older Android versions that we're going to drop support for
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { // if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
// return GetApplicationRelease.Result.OsTooOld // return GetApplicationRelease.Result.OsTooOld
@@ -30,11 +29,6 @@ class AppUpdateChecker {
), ),
) )
when (result) {
is GetApplicationRelease.Result.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release)
else -> {}
}
result result
} }
} }
@@ -15,30 +15,26 @@ import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.network.ProgressListener
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.storage.saveTo
import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.setForegroundSafely
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import okhttp3.internal.http2.ErrorCode import kotlinx.coroutines.CancellationException
import okhttp3.internal.http2.StreamResetException
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import kotlin.coroutines.cancellation.CancellationException
class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerParameters) : class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) { CoroutineWorker(context, workerParams) {
private val notifier = AppUpdateNotifier(context)
private val network: NetworkHelper by injectLazy() private val network: NetworkHelper by injectLazy()
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val url = inputData.getString(EXTRA_DOWNLOAD_URL) val url = inputData.getString(EXTRA_DOWNLOAD_URL)
val title = inputData.getString(EXTRA_DOWNLOAD_TITLE) ?: context.stringResource(MR.strings.app_name)
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
return Result.failure() return Result.failure()
@@ -46,17 +42,27 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar
setForegroundSafely() setForegroundSafely()
withIOContext { return try {
downloadApk(title, url) withIOContext { downloadApk(url) }
Result.success(workDataOf(PROGRESS to 100, EXTRA_DOWNLOAD_URL to url))
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
Result.failure()
} }
return Result.success()
} }
override suspend fun getForegroundInfo(): ForegroundInfo { override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = context.notificationBuilder(Notifications.CHANNEL_APP_UPDATE)
.setContentTitle(context.stringResource(MR.strings.update_check_notification_update_available))
.setContentText(context.stringResource(MR.strings.update_check_notification_download_in_progress))
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.build()
return ForegroundInfo( return ForegroundInfo(
Notifications.ID_APP_UPDATER, Notifications.ID_APP_UPDATER,
notifier.onDownloadStarted().build(), notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else { } else {
@@ -70,10 +76,7 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar
* *
* @param url url location of file * @param url url location of file
*/ */
private suspend fun downloadApk(title: String, url: String) { private suspend fun downloadApk(url: String) {
// Show notification download starting.
notifier.onDownloadStarted(title)
val progressListener = object : ProgressListener { val progressListener = object : ProgressListener {
// Progress of the download // Progress of the download
var savedProgress = 0 var savedProgress = 0
@@ -87,45 +90,27 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar
if (progress > savedProgress && currentTime - 200 > lastTick) { if (progress > savedProgress && currentTime - 200 > lastTick) {
savedProgress = progress savedProgress = progress
lastTick = currentTime lastTick = currentTime
notifier.onProgressChange(progress) setProgressAsync(workDataOf(PROGRESS to progress, EXTRA_DOWNLOAD_URL to url))
} }
} }
} }
try { val response = network.client.newCachelessCallWithProgress(GET(url), progressListener).awaitSuccess()
// Download the new update.
val response = network.client.newCachelessCallWithProgress(GET(url), progressListener)
.await()
// File where the apk will be saved. val apkFile = updateApk(context)
val apkFile = File(context.externalCacheDir, "update.apk") response.body.source().saveTo(apkFile)
if (response.isSuccessful) {
response.body.source().saveTo(apkFile)
} else {
response.close()
throw Exception("Unsuccessful response")
}
notifier.cancel()
notifier.promptInstall(apkFile.getUriCompat(context))
} catch (e: Exception) {
val shouldCancel = e is CancellationException ||
(e is StreamResetException && e.errorCode == ErrorCode.CANCEL)
if (shouldCancel) {
notifier.cancel()
} else {
notifier.onDownloadError(url)
}
}
} }
companion object { companion object {
private const val TAG = "AppUpdateDownload" const val TAG = "AppUpdateDownload"
const val PROGRESS = "progress"
const val EXTRA_DOWNLOAD_URL = "DOWNLOAD_URL" const val EXTRA_DOWNLOAD_URL = "DOWNLOAD_URL"
const val EXTRA_DOWNLOAD_TITLE = "DOWNLOAD_TITLE"
fun start(context: Context, url: String, title: String? = null) { fun updateApk(context: Context): File = File(context.externalCacheDir, "update.apk")
fun start(context: Context, url: String) {
val constraints = Constraints( val constraints = Constraints(
requiredNetworkType = NetworkType.CONNECTED, requiredNetworkType = NetworkType.CONNECTED,
) )
@@ -133,12 +118,7 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar
val request = OneTimeWorkRequestBuilder<AppUpdateDownloadJob>() val request = OneTimeWorkRequestBuilder<AppUpdateDownloadJob>()
.setConstraints(constraints) .setConstraints(constraints)
.addTag(TAG) .addTag(TAG)
.setInputData( .setInputData(workDataOf(EXTRA_DOWNLOAD_URL to url))
workDataOf(
EXTRA_DOWNLOAD_URL to url,
EXTRA_DOWNLOAD_TITLE to title,
),
)
.build() .build()
context.workManager.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) context.workManager.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request)
@@ -1,168 +0,0 @@
package eu.kanade.tachiyomi.data.updater
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.NotificationHandler
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.notify
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.domain.release.model.Release
import tachiyomi.i18n.MR
internal class AppUpdateNotifier(private val context: Context) {
private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_APP_UPDATE)
/**
* Call to show notification.
*
* @param id id of the notification channel.
*/
private fun NotificationCompat.Builder.show(id: Int = Notifications.ID_APP_UPDATER) {
context.notify(id, build())
}
fun cancel() {
NotificationReceiver.dismissNotification(context, Notifications.ID_APP_UPDATER)
}
@SuppressLint("LaunchActivityFromNotification")
fun promptUpdate(release: Release) {
val updateIntent = NotificationReceiver.downloadAppUpdatePendingBroadcast(
context,
release.downloadLink,
release.version,
)
val releaseIntent = Intent(Intent.ACTION_VIEW, release.releaseLink.toUri()).run {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
PendingIntent.getActivity(
context,
release.hashCode(),
this,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
with(notificationBuilder) {
setContentTitle(context.stringResource(MR.strings.update_check_notification_update_available))
setContentText(release.version)
setSmallIcon(android.R.drawable.stat_sys_download_done)
setContentIntent(updateIntent)
clearActions()
addAction(
android.R.drawable.stat_sys_download_done,
context.stringResource(MR.strings.action_download),
updateIntent,
)
addAction(
R.drawable.ic_info_24dp,
context.stringResource(MR.strings.whats_new),
releaseIntent,
)
}
notificationBuilder.show()
}
/**
* Call when apk download starts.
*
* @param title tile of notification.
*/
fun onDownloadStarted(title: String? = null): NotificationCompat.Builder {
with(notificationBuilder) {
title?.let { setContentTitle(title) }
setContentText(context.stringResource(MR.strings.update_check_notification_download_in_progress))
setSmallIcon(android.R.drawable.stat_sys_download)
setOngoing(true)
clearActions()
addAction(
R.drawable.ic_close_24dp,
context.stringResource(MR.strings.action_cancel),
NotificationReceiver.cancelDownloadAppUpdatePendingBroadcast(context),
)
}
notificationBuilder.show()
return notificationBuilder
}
/**
* Call when apk download progress changes.
*
* @param progress progress of download (xx%/100).
*/
fun onProgressChange(progress: Int) {
with(notificationBuilder) {
setProgress(100, progress, false)
setOnlyAlertOnce(true)
}
notificationBuilder.show()
}
/**
* Call when apk download is finished.
*
* @param uri path location of apk.
*/
fun promptInstall(uri: Uri) {
val installIntent = NotificationHandler.installApkPendingActivity(context, uri)
with(notificationBuilder) {
setContentText(context.stringResource(MR.strings.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done)
setOnlyAlertOnce(false)
setProgress(0, 0, false)
setContentIntent(installIntent)
setOngoing(true)
clearActions()
addAction(
R.drawable.ic_system_update_alt_white_24dp,
context.stringResource(MR.strings.action_install),
installIntent,
)
addAction(
R.drawable.ic_close_24dp,
context.stringResource(MR.strings.action_cancel),
NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_APP_UPDATE_PROMPT),
)
}
notificationBuilder.show(Notifications.ID_APP_UPDATE_PROMPT)
}
/**
* Call when apk download throws a error
*
* @param url web location of apk to download.
*/
fun onDownloadError(url: String) {
with(notificationBuilder) {
setContentText(context.stringResource(MR.strings.update_check_notification_download_error))
setSmallIcon(R.drawable.ic_warning_white_24dp)
setOnlyAlertOnce(false)
setProgress(0, 0, false)
clearActions()
addAction(
R.drawable.ic_refresh_24dp,
context.stringResource(MR.strings.action_retry),
NotificationReceiver.downloadAppUpdatePendingBroadcast(context, url),
)
addAction(
R.drawable.ic_close_24dp,
context.stringResource(MR.strings.action_cancel),
NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_APP_UPDATE_ERROR),
)
}
notificationBuilder.show(Notifications.ID_APP_UPDATE_ERROR)
}
}
@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.di package eu.kanade.tachiyomi.di
import android.app.Application import android.app.Application
import android.content.Context
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.db.SqlDriver
@@ -53,6 +54,7 @@ class AppModule(val app: Application) : InjektModule {
override fun InjektRegistrar.registerInjectables() { override fun InjektRegistrar.registerInjectables() {
addSingleton(app) addSingleton(app)
addSingleton<Context>(app)
addSingletonFactory<SqlDriver> { addSingletonFactory<SqlDriver> {
synchronized(lock) { synchronized(lock) {
@@ -311,7 +311,7 @@ class MainActivity : BaseActivity() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (updaterEnabled) { if (updaterEnabled) {
try { try {
val result = AppUpdateChecker().checkForUpdate(context) val result = AppUpdateChecker().checkForUpdate()
if (result is GetApplicationRelease.Result.NewUpdate) { if (result is GetApplicationRelease.Result.NewUpdate) {
val updateScreen = NewUpdateScreen( val updateScreen = NewUpdateScreen(
versionName = result.release.version, versionName = result.release.version,
@@ -1,13 +1,15 @@
package eu.kanade.tachiyomi.ui.more package eu.kanade.tachiyomi.ui.more
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.more.NewUpdateScreen import eu.kanade.presentation.more.NewUpdateScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
class NewUpdateScreen( class NewUpdateScreen(
@@ -21,23 +23,30 @@ class NewUpdateScreen(
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current val context = LocalContext.current
val changelogInfoNoChecksum = remember { val viewModel = viewModel<NewUpdateScreenModel>(
changelogInfo.replace("""---(\R|.)*Checksums(\R|.)*""".toRegex(), "") factory = NewUpdateScreenModel.Factory,
} extras = CreationExtras {
set(NewUpdateScreenModel.CHANGELOG_INFO_KEY, changelogInfo)
set(NewUpdateScreenModel.DOWNLOAD_LINK_KEY, downloadLink)
},
)
val state by viewModel.state.collectAsState()
NewUpdateScreen( NewUpdateScreen(
versionName = versionName, versionName = versionName,
changelogInfo = changelogInfoNoChecksum, stage = state.stage,
downloadProgress = { state.downloadProgress },
changelogInfo = state.changelogInfo,
onOpenInBrowser = { context.openInBrowser(releaseLink) }, onOpenInBrowser = { context.openInBrowser(releaseLink) },
onRejectUpdate = navigator::pop,
onAcceptUpdate = { onAcceptUpdate = {
AppUpdateDownloadJob.start( when (state.stage) {
context = context, NewUpdateScreenModel.Stage.Available, NewUpdateScreenModel.Stage.Failed -> viewModel.startDownload()
url = downloadLink, NewUpdateScreenModel.Stage.Downloaded -> viewModel.installUpdate()
title = versionName, else -> Unit
) }
navigator.pop()
}, },
onRejectUpdate = navigator::pop,
) )
} }
} }
@@ -0,0 +1,121 @@
package eu.kanade.tachiyomi.ui.more
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Immutable
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.work.WorkInfo
import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import mihon.core.viewmodel.StateViewModel
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class NewUpdateScreenModel(
changelogInfo: String,
private val downloadLink: String,
private val context: Context = Injekt.get(),
) : StateViewModel<NewUpdateScreenModel.State>(State(changelogInfo = changelogInfo)) {
init {
context.workManager.getWorkInfosByTagFlow(AppUpdateDownloadJob.TAG)
.mapNotNull { it.firstOrNull() }
.map { workInfo ->
val progress = if (workInfo.state.isFinished) {
workInfo.outputData.getInt(AppUpdateDownloadJob.PROGRESS, 0)
} else {
workInfo.progress.getInt(AppUpdateDownloadJob.PROGRESS, 0)
}
val url = if (workInfo.state.isFinished) {
workInfo.outputData.getString(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL)
} else {
workInfo.progress.getString(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL)
}
if (url != downloadLink) {
return@map 0 to Stage.Available
}
val stage = when {
workInfo.state == WorkInfo.State.FAILED -> Stage.Failed
workInfo.state.isFinished && progress == 100 -> {
if (AppUpdateDownloadJob.updateApk(context).exists()) {
Stage.Downloaded
} else {
Stage.Available
}
}
workInfo.state in listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING) -> Stage.Downloading
else -> Stage.Available
}
progress to stage
}
.distinctUntilChanged()
.onEach { (progress, stage) ->
mutableState.update {
it.copy(
downloadProgress = progress,
stage = stage,
)
}
}
.launchIn(viewModelScope)
}
fun startDownload() {
mutableState.update { it.copy(downloadProgress = 0, stage = Stage.Downloading) }
AppUpdateDownloadJob.start(context, downloadLink)
}
fun installUpdate() {
val apkFile = AppUpdateDownloadJob.updateApk(context)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkFile.getUriCompat(context), ExtensionInstaller.APK_MIME)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
context.startActivity(intent)
}
override fun onCleared() {
AppUpdateDownloadJob.stop(context)
}
@Immutable
data class State(
val changelogInfo: String,
val downloadProgress: Int = 0,
val stage: Stage = Stage.Available,
)
enum class Stage {
Available,
Downloading,
Downloaded,
Failed,
}
companion object {
val CHANGELOG_INFO_KEY = CreationExtras.Key<String>()
val DOWNLOAD_LINK_KEY = CreationExtras.Key<String>()
val Factory = viewModelFactory {
initializer {
NewUpdateScreenModel(
changelogInfo = get(CHANGELOG_INFO_KEY)!!,
downloadLink = get(DOWNLOAD_LINK_KEY)!!,
)
}
}
}
}
@@ -362,6 +362,7 @@
<string name="ext_installer_shizuku_stopped">Shizuku is not running</string> <string name="ext_installer_shizuku_stopped">Shizuku is not running</string>
<string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string> <string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
<string name="ext_revoke_trust">Revoke trusted unknown extensions</string> <string name="ext_revoke_trust">Revoke trusted unknown extensions</string>
<string name="downloading_with_progress">Downloading… (%d%%)</string>
<!-- Extension repos --> <!-- Extension repos -->
<string name="action_open_repo">Open source repo</string> <string name="action_open_repo">Open source repo</string>
@@ -949,8 +950,6 @@
<!--UpdateCheck Notifications--> <!--UpdateCheck Notifications-->
<string name="update_check_notification_download_in_progress">Downloading…</string> <string name="update_check_notification_download_in_progress">Downloading…</string>
<string name="update_check_notification_download_complete">Tap to install update</string>
<string name="update_check_notification_download_error">Download error</string>
<string name="update_check_notification_update_available">New version available!</string> <string name="update_check_notification_update_available">New version available!</string>
<!-- Information Text --> <!-- Information Text -->