App update retry/resume (#523)
* custom timeout for NetworkHelper.client * support retry/resume downloading app update apk * retain app update download progress over retries * increase app update download timeout to 180 seconds / 3 minutes from previously 2 minutes
This commit is contained in:
parent
7628e064e5
commit
5a893927a3
2 changed files with 120 additions and 17 deletions
|
|
@ -12,13 +12,9 @@ import androidx.work.OneTimeWorkRequestBuilder
|
|||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
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.system.setForegroundSafely
|
||||
import eu.kanade.tachiyomi.util.system.workManager
|
||||
import logcat.LogPriority
|
||||
|
|
@ -72,11 +68,16 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar
|
|||
*
|
||||
* @param url url location of file
|
||||
*/
|
||||
private suspend fun downloadApk(title: String, url: String) {
|
||||
private fun downloadApk(title: String, url: String) {
|
||||
// Show notification download starting.
|
||||
notifier.onDownloadStarted(title)
|
||||
|
||||
val progressListener = object : ProgressListener {
|
||||
// KMK -->
|
||||
// Total size of the downloading file, should be set when starting and kept over retries
|
||||
var totalSize = 0L
|
||||
// KMK <--
|
||||
|
||||
// Progress of the download
|
||||
var savedProgress = 0
|
||||
|
||||
|
|
@ -84,7 +85,16 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar
|
|||
var lastTick = 0L
|
||||
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
val progress = (100 * (bytesRead.toFloat() / contentLength)).toInt()
|
||||
// KMK -->
|
||||
val downloadedSize: Long
|
||||
if (totalSize == 0L) {
|
||||
totalSize = contentLength
|
||||
downloadedSize = bytesRead
|
||||
} else {
|
||||
downloadedSize = totalSize - contentLength + bytesRead
|
||||
}
|
||||
// KMK <--
|
||||
val progress = (100 * (downloadedSize.toFloat() / totalSize)).toInt()
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (progress > savedProgress && currentTime - 200 > lastTick) {
|
||||
savedProgress = progress
|
||||
|
|
@ -95,19 +105,11 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar
|
|||
}
|
||||
|
||||
try {
|
||||
// Download the new update.
|
||||
val response = network.client.newCachelessCallWithProgress(GET(url), progressListener)
|
||||
.await()
|
||||
|
||||
// File where the apk will be saved.
|
||||
val apkFile = File(context.externalCacheDir, "update.apk")
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body.source().saveTo(apkFile)
|
||||
} else {
|
||||
response.close()
|
||||
throw Exception("Unsuccessful response")
|
||||
}
|
||||
// KMK -->
|
||||
network.downloadFileWithResume(url, apkFile, progressListener)
|
||||
// KMK <--
|
||||
notifier.cancel()
|
||||
notifier.promptInstall(apkFile.getUriCompat(context))
|
||||
} catch (e: Exception) {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,20 @@ import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
|||
import eu.kanade.tachiyomi.network.interceptor.IgnoreGzipInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
|
||||
import logcat.LogPriority
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import okio.IOException
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.pow
|
||||
import kotlin.random.Random
|
||||
|
||||
/* SY --> */
|
||||
open /* SY <-- */ class NetworkHelper(
|
||||
|
|
@ -85,6 +93,93 @@ open /* SY <-- */ class NetworkHelper(
|
|||
builder.build()
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
/**
|
||||
* Allow to download a big file with retry & resume capability because
|
||||
* normally it would get a Timeout exception.
|
||||
*/
|
||||
fun downloadFileWithResume(url: String, outputFile: File, progressListener: ProgressListener) {
|
||||
val client = clientWithTimeOut(
|
||||
callTimeout = 120,
|
||||
)
|
||||
|
||||
var downloadedBytes: Long
|
||||
|
||||
var attempt = 0
|
||||
|
||||
while (attempt < MAX_RETRY) {
|
||||
try {
|
||||
// Check how much has already been downloaded
|
||||
downloadedBytes = outputFile.length()
|
||||
// Set up request with Range header to resume from the last byte
|
||||
val request = GET(
|
||||
url = url,
|
||||
headers = Headers.Builder()
|
||||
.add("Range", "bytes=$downloadedBytes-")
|
||||
.build(),
|
||||
)
|
||||
|
||||
var failed = false
|
||||
client.newCachelessCallWithProgress(request, progressListener).execute().use { response ->
|
||||
if (response.isSuccessful || response.code == 206) { // 206 indicates partial content
|
||||
saveResponseToFile(response, outputFile, downloadedBytes)
|
||||
if (response.isSuccessful) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
attempt++
|
||||
logcat(LogPriority.ERROR) { "Unexpected response code: ${response.code}. Retrying..." }
|
||||
if (response.code == 416) {
|
||||
// 416: Range Not Satisfiable
|
||||
outputFile.delete()
|
||||
}
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
if (failed) exponentialBackoff(attempt - 1)
|
||||
} catch (e: IOException) {
|
||||
logcat(LogPriority.ERROR) { "Download interrupted: ${e.message}. Retrying..." }
|
||||
// Wait or handle as needed before retrying
|
||||
attempt++
|
||||
exponentialBackoff(attempt - 1)
|
||||
}
|
||||
}
|
||||
throw IOException("Max retry attempts reached.")
|
||||
}
|
||||
|
||||
// Helper function to save data incrementally
|
||||
private fun saveResponseToFile(response: Response, outputFile: File, startPosition: Long) {
|
||||
val body = response.body
|
||||
|
||||
// Use RandomAccessFile to write from specific position
|
||||
RandomAccessFile(outputFile, "rw").use { file ->
|
||||
file.seek(startPosition)
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
file.write(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increment attempt and apply exponential backoff
|
||||
private fun exponentialBackoff(attempt: Int) {
|
||||
val backoffDelay = calculateExponentialBackoff(attempt)
|
||||
Thread.sleep(backoffDelay)
|
||||
}
|
||||
|
||||
// Helper function to calculate exponential backoff with jitter
|
||||
private fun calculateExponentialBackoff(attempt: Int, baseDelay: Long = 1000L, maxDelay: Long = 32000L): Long {
|
||||
// Calculate the exponential delay
|
||||
val delay = baseDelay * 2.0.pow(attempt).toLong()
|
||||
logcat(LogPriority.ERROR) { "Exponential backoff delay: $delay ms" }
|
||||
// Apply jitter by adding a random value to avoid synchronized retries in distributed systems
|
||||
return (delay + Random.nextLong(0, 1000)).coerceAtMost(maxDelay)
|
||||
}
|
||||
// KMK <--
|
||||
|
||||
/**
|
||||
* @deprecated Since extension-lib 1.5
|
||||
*/
|
||||
|
|
@ -95,4 +190,10 @@ open /* SY <-- */ class NetworkHelper(
|
|||
get() = client
|
||||
|
||||
fun defaultUserAgentProvider() = preferences.defaultUserAgent().get().trim()
|
||||
|
||||
companion object {
|
||||
// KMK -->
|
||||
private const val MAX_RETRY = 5
|
||||
// KMK <--
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue