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:
Cuong-Tran 2024-11-28 21:42:44 +07:00
parent 7628e064e5
commit 5a893927a3
No known key found for this signature in database
GPG key ID: 733AA7624B9315C2
2 changed files with 120 additions and 17 deletions

View file

@ -12,13 +12,9 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
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.newCachelessCallWithProgress
import eu.kanade.tachiyomi.util.storage.getUriCompat 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.setForegroundSafely
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import logcat.LogPriority import logcat.LogPriority
@ -72,11 +68,16 @@ 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 fun downloadApk(title: String, url: String) {
// Show notification download starting. // Show notification download starting.
notifier.onDownloadStarted(title) notifier.onDownloadStarted(title)
val progressListener = object : ProgressListener { 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 // Progress of the download
var savedProgress = 0 var savedProgress = 0
@ -84,7 +85,16 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar
var lastTick = 0L var lastTick = 0L
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { 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() val currentTime = System.currentTimeMillis()
if (progress > savedProgress && currentTime - 200 > lastTick) { if (progress > savedProgress && currentTime - 200 > lastTick) {
savedProgress = progress savedProgress = progress
@ -95,19 +105,11 @@ class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerPar
} }
try { try {
// Download the new update.
val response = network.client.newCachelessCallWithProgress(GET(url), progressListener)
.await()
// File where the apk will be saved. // File where the apk will be saved.
val apkFile = File(context.externalCacheDir, "update.apk") val apkFile = File(context.externalCacheDir, "update.apk")
// KMK -->
if (response.isSuccessful) { network.downloadFileWithResume(url, apkFile, progressListener)
response.body.source().saveTo(apkFile) // KMK <--
} else {
response.close()
throw Exception("Unsuccessful response")
}
notifier.cancel() notifier.cancel()
notifier.promptInstall(apkFile.getUriCompat(context)) notifier.promptInstall(apkFile.getUriCompat(context))
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -5,12 +5,20 @@ import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.network.interceptor.IgnoreGzipInterceptor import eu.kanade.tachiyomi.network.interceptor.IgnoreGzipInterceptor
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
import logcat.LogPriority
import okhttp3.Cache import okhttp3.Cache
import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.brotli.BrotliInterceptor import okhttp3.brotli.BrotliInterceptor
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import okio.IOException
import tachiyomi.core.common.util.system.logcat
import java.io.File import java.io.File
import java.io.RandomAccessFile
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.pow
import kotlin.random.Random
/* SY --> */ /* SY --> */
open /* SY <-- */ class NetworkHelper( open /* SY <-- */ class NetworkHelper(
@ -85,6 +93,93 @@ open /* SY <-- */ class NetworkHelper(
builder.build() 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 * @deprecated Since extension-lib 1.5
*/ */
@ -95,4 +190,10 @@ open /* SY <-- */ class NetworkHelper(
get() = client get() = client
fun defaultUserAgentProvider() = preferences.defaultUserAgent().get().trim() fun defaultUserAgentProvider() = preferences.defaultUserAgent().get().trim()
companion object {
// KMK -->
private const val MAX_RETRY = 5
// KMK <--
}
} }