Improve app update prompt flow experience (#3669)
This commit is contained in:
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user