Skip to content

Commit

Permalink
App update retry/resume (#523)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
cuong-tran committed Nov 28, 2024
1 parent 7628e06 commit 5a89392
Show file tree
Hide file tree
Showing 2 changed files with 120 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,19 +68,33 @@ 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

// Keep track of the last notification sent to avoid posting too many.
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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
*/
Expand All @@ -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 <--
}
}

0 comments on commit 5a89392

Please sign in to comment.