From 2f9d46c4ec35c7b4aa757ff139c256c84dec9e20 Mon Sep 17 00:00:00 2001 From: Cuong-Tran Date: Thu, 28 Nov 2024 21:42:44 +0700 Subject: [PATCH] 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 --- .../data/updater/AppUpdateDownloadJob.kt | 36 ++--- .../kanade/tachiyomi/network/NetworkHelper.kt | 128 +++++++++++++++++- 2 files changed, 141 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt index b2f361431d..cb6a8b9d8e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt @@ -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) { diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt index f508e6e08b..7c5d5886c8 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -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( @@ -25,12 +33,26 @@ open /* SY <-- */ class NetworkHelper( open /* SY <-- */val cookieJar = AndroidCookieJar() /* SY --> */ - open /* SY <-- */val client: OkHttpClient = run { + open /* SY <-- */val client: OkHttpClient = + // KMK --> + clientWithTimeOut() + + /** + * Timeout in unit of seconds. + */ + fun clientWithTimeOut( + connectTimeout: Long = 30, + readTimeout: Long = 30, + callTimeout: Long = 120, + // KMK <-- + ): OkHttpClient = run { val builder = OkHttpClient.Builder() .cookieJar(cookieJar) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .callTimeout(2, TimeUnit.MINUTES) + // KMK --> + .connectTimeout(connectTimeout, TimeUnit.SECONDS) + .readTimeout(readTimeout, TimeUnit.SECONDS) + .callTimeout(callTimeout, TimeUnit.SECONDS) + // KMK <-- .cache( Cache( directory = File(context.cacheDir, "network_cache"), @@ -71,13 +93,107 @@ 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 */ - @Deprecated("The regular client handles Cloudflare by default") + @Deprecated("The regular client handles Cloudflare by default", ReplaceWith("client")) @Suppress("UNUSED") /* SY --> */ - open /* SY <-- */val cloudflareClient: OkHttpClient = client + open /* SY <-- */val cloudflareClient: OkHttpClient + get() = client fun defaultUserAgentProvider() = preferences.defaultUserAgent().get().trim() + + companion object { + // KMK --> + private const val MAX_RETRY = 5 + // KMK <-- + } }