From 3301f47e2622cff988fc1d9c22554a8ed8d55b04 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:27:25 +0200 Subject: [PATCH] fix: cancel APK downloads promptly --- app/src/main/AndroidManifest.xml | 4 +- .../android/util/UniversalApkManager.kt | 197 ++++++++++++------ 2 files changed, 137 insertions(+), 64 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1a1daa8d..c362b949 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,7 +26,9 @@ - + diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt index c4aca052..ab1e97f1 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -6,9 +6,14 @@ import android.os.Build import android.util.Log import com.bitchat.android.BuildConfig import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback import okhttp3.Request +import okhttp3.Response import org.json.JSONObject import java.io.File import java.io.FileOutputStream @@ -260,69 +265,15 @@ class UniversalApkManager(private val context: Context) { } val request = requestBuilder.build() - - downloadClient.newCall(request).execute().use { response -> - if (response.code == 416) { - // Our offset is no longer valid for this asset; discard - // the partial state so the retry starts from scratch. - Log.w(TAG, "Server rejected resume range, restarting download") - tempFile.delete() - progressFile.delete() - return@withContext Result.failure( - IOException("Resume rejected by server. Download will restart.") - ) - } - if (!response.isSuccessful && response.code != 206) { - return@withContext Result.failure( - IOException("Download failed: ${response.code} ${response.message}") - ) - } - - val body = response.body - ?: return@withContext Result.failure(IOException("Empty response body")) - - // Handle resume: 206 = partial content (append), 200 = full content (overwrite) - val append = response.code == 206 - if (!append && existingBytes > 0) { - Log.d(TAG, "Server didn't honor Range request, starting from scratch") - existingBytes = 0 - } - - // Save resume metadata - saveResumeInfo(url, expectedSize, release.versionName) - - // Report initial progress when resuming - if (existingBytes > 0 && expectedSize > 0) { - val initialProgress = ((existingBytes * 100) / expectedSize).toInt() - progressCallback?.invoke(initialProgress) - } - - // Download with progress tracking - body.byteStream().use { input -> - FileOutputStream(tempFile, append).use { output -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - var totalBytesRead = existingBytes - var lastProgress = if (expectedSize > 0) ((existingBytes * 100) / expectedSize).toInt() else 0 - - while (input.read(buffer).also { bytesRead = it } != -1) { - output.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead - - // Report progress - if (expectedSize > 0) { - val progress = ((totalBytesRead * 100) / expectedSize).toInt() - if (progress != lastProgress) { - lastProgress = progress - progressCallback?.invoke(progress) - } - } - } - - Log.d(TAG, "Download complete: ${totalBytesRead / 1024 / 1024}MB") - } - } - } + downloadToTempFile( + call = downloadClient.newCall(request), + tempFile = tempFile, + url = url, + expectedSize = expectedSize, + versionName = release.versionName, + existingBytes = existingBytes, + progressCallback = progressCallback + ) } // Verify checksum if available @@ -386,6 +337,8 @@ class UniversalApkManager(private val context: Context) { Log.d(TAG, "Universal APK downloaded successfully: ${finalFile.path}") Result.success(finalFile) + } catch (e: CancellationException) { + throw e } catch (e: IOException) { Log.e(TAG, "Network error downloading APK", e) Result.failure(e) @@ -395,6 +348,124 @@ class UniversalApkManager(private val context: Context) { } } + /** + * Streams an HTTP response into [tempFile] while keeping the coroutine + * suspended for the lifetime of the response body. Cancelling the worker + * therefore cancels the OkHttp call and promptly unblocks a pending read. + */ + private suspend fun downloadToTempFile( + call: Call, + tempFile: File, + url: String, + expectedSize: Long, + versionName: String, + existingBytes: Long, + progressCallback: ((Int) -> Unit)? + ) = suspendCancellableCoroutine { continuation -> + fun completeSuccessfully() { + continuation.resumeWith(Result.success(Unit)) + } + + fun completeWithError(error: Throwable) { + continuation.resumeWith(Result.failure(error)) + } + + continuation.invokeOnCancellation { + call.cancel() + } + + try { + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + completeWithError(e) + } + + override fun onResponse(call: Call, response: Response) { + try { + response.use { + if (response.code == 416) { + // Our offset is no longer valid for this asset; discard + // the partial state so the retry starts from scratch. + Log.w(TAG, "Server rejected resume range, restarting download") + tempFile.delete() + progressFile.delete() + throw IOException( + "Resume rejected by server. Download will restart." + ) + } + if (!response.isSuccessful && response.code != 206) { + throw IOException( + "Download failed: ${response.code} ${response.message}" + ) + } + + val body = response.body + ?: throw IOException("Empty response body") + + // Handle resume: 206 = partial content (append), 200 = full + // content (overwrite). + val append = response.code == 206 + val resumedBytes = if (!append && existingBytes > 0) { + Log.d( + TAG, + "Server didn't honor Range request, starting from scratch" + ) + 0L + } else { + existingBytes + } + + saveResumeInfo(url, expectedSize, versionName) + + if (resumedBytes > 0 && expectedSize > 0) { + val initialProgress = + ((resumedBytes * 100) / expectedSize).toInt() + progressCallback?.invoke(initialProgress) + } + + body.byteStream().use { input -> + FileOutputStream(tempFile, append).use { output -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead = resumedBytes + var lastProgress = if (expectedSize > 0) { + ((resumedBytes * 100) / expectedSize).toInt() + } else { + 0 + } + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + if (expectedSize > 0) { + val progress = + ((totalBytesRead * 100) / expectedSize).toInt() + if (progress != lastProgress) { + lastProgress = progress + progressCallback?.invoke(progress) + } + } + } + + Log.d( + TAG, + "Download complete: ${totalBytesRead / 1024 / 1024}MB" + ) + } + } + } + completeSuccessfully() + } catch (e: Exception) { + completeWithError(e) + } + } + }) + } catch (e: Exception) { + completeWithError(e) + } + } + /** * Cache the APK this process was installed from only when it is both * standalone and universal. A base APK from a split install is incomplete,