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.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) {
|
||||||
|
|
|
||||||
|
|
@ -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 <--
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue