From d36ce5803e67d8d7f29b115a0825b02683b7c548 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:40:21 -0400 Subject: [PATCH] fix(updater): harden APK recovery and retry flows Use transaction-owned APK paths and atomic commits, clean cancellation and validation failures safely, serialize installer transactions, preserve retry semantics, and harden GitHub asset URL validation before canonicalization. --- app/src/main/java/com/papi/nova/PcView.kt | 56 +- .../preferences/NovaUpdateDownloadStore.kt | 165 +++++ .../nova/preferences/NovaUpdateInstaller.kt | 254 ++++--- .../NovaUpdatePromptPreferences.kt | 11 +- .../papi/nova/preferences/StreamSettings.kt | 70 +- app/src/main/res/values/strings.xml | 3 + .../preferences/NovaUpdateRecoveryTest.kt | 648 ++++++++++++++++++ 7 files changed, 1045 insertions(+), 162 deletions(-) create mode 100644 app/src/main/java/com/papi/nova/preferences/NovaUpdateDownloadStore.kt create mode 100644 app/src/test/java/com/papi/nova/preferences/NovaUpdateRecoveryTest.kt diff --git a/app/src/main/java/com/papi/nova/PcView.kt b/app/src/main/java/com/papi/nova/PcView.kt index 4985be98..dcc7dffe 100644 --- a/app/src/main/java/com/papi/nova/PcView.kt +++ b/app/src/main/java/com/papi/nova/PcView.kt @@ -1699,10 +1699,10 @@ class PcView : AppCompatActivity(), AdapterFragmentCallbacks { if (!NovaUpdatePromptPreferences.shouldRunAutomaticCheck(prefs, nowMs)) { return } - NovaUpdatePromptPreferences.recordAutomaticCheck(prefs, nowMs) runtimeTasks.launchIo("NovaAutomaticUpdateCheck") { val result = runCatching { NovaUpdateChecker.checkLatest() } + NovaUpdatePromptPreferences.recordAutomaticCheckResult(prefs, nowMs, result) result.onSuccess { updateResult -> if (updateResult !is NovaUpdateCheckResult.UpdateAvailable) { return@onSuccess @@ -1770,45 +1770,31 @@ class PcView : AppCompatActivity(), AdapterFragmentCallbacks { false ) runtimeTasks.launchMain("NovaUpdateInstall") { - val result = NovaUpdateInstaller.downloadValidateAndInstall(this@PcView, release) { progress -> - spinner.setMessage( - getString(R.string.nova_update_downloading_message, release.versionName, progress) + try { + val result = NovaUpdateInstaller.downloadValidateAndInstall(this@PcView, release) { progress -> + spinner.setMessage( + if (progress >= 100) { + getString(R.string.nova_update_verifying_message) + } else { + getString(R.string.nova_update_downloading_message, release.versionName, progress) + } + ) + } + NovaUpdateInstaller.showInstallResult( + this@PcView, + release, + result, + onRetry = { startNovaUpdateInstall(it) }, + onViewReleases = { + HelpLauncher.launchUrl(this@PcView, "https://github.com/papi-ux/nova/releases") + }, ) + } finally { + NovaUpdateInstaller.dismissIfAlive(this@PcView) { spinner.dismiss() } } - spinner.setMessage(getString(R.string.nova_update_verifying_message)) - spinner.dismiss() - showNovaUpdateInstallResult(result) } } - private fun showNovaUpdateInstallResult(result: NovaUpdateInstallResult) { - when (result) { - NovaUpdateInstallResult.StartedInstaller -> Toast.makeText( - this, - R.string.nova_update_installer_started, - Toast.LENGTH_LONG - ).show() - NovaUpdateInstallResult.PermissionRequired -> Unit - is NovaUpdateInstallResult.Blocked -> showNovaUpdateInstallProblem( - R.string.nova_update_install_blocked_title, - result.reason - ) - is NovaUpdateInstallResult.Failed -> showNovaUpdateInstallProblem( - R.string.nova_update_install_failed_title, - result.reason - ) - } - } - - private fun showNovaUpdateInstallProblem(titleRes: Int, message: String) { - val dialog = AlertDialog.Builder(this) - .setTitle(titleRes) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() - NovaSheetChrome.applyAlertDialogChrome(dialog) - } - private fun handleWelcomeAction(action: String?) { if (action != NovaWelcomeActivity.ACTION_SCAN_QR) { return diff --git a/app/src/main/java/com/papi/nova/preferences/NovaUpdateDownloadStore.kt b/app/src/main/java/com/papi/nova/preferences/NovaUpdateDownloadStore.kt new file mode 100644 index 00000000..fd2d2355 --- /dev/null +++ b/app/src/main/java/com/papi/nova/preferences/NovaUpdateDownloadStore.kt @@ -0,0 +1,165 @@ +package com.papi.nova.preferences + +import com.papi.nova.BuildConfig +import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request + +internal object NovaUpdateDownloadStore { + private const val MAX_APK_BYTES = 300L * 1024L * 1024L + private val COMPLETED_APK_RETENTION_MS = TimeUnit.HOURS.toMillis(24) + private val downloadMutex = Mutex() + + suspend fun download( + cacheDir: File, + release: NovaUpdateRelease, + client: OkHttpClient = OkHttpClient(), + onProgress: (Int) -> Unit = {}, + ): File = downloadMutex.withLock { + val downloadUrl = requireNotNull(release.apkDownloadUrl) { "Release does not contain an APK download URL." } + val safeAssetName = release.apkAssetName + ?.substringAfterLast('/') + ?.replace(Regex("[^A-Za-z0-9._-]"), "_") + ?.takeIf { it.endsWith(".apk", ignoreCase = true) } + ?: "Nova-${release.versionName}.apk" + val safeStem = safeAssetName.dropLast(4) + val transactionName = "$safeStem-${UUID.randomUUID()}.apk" + val updateDir = File(cacheDir, "nova-updates") + val apkFile = File(updateDir, transactionName) + val partialFile = File(updateDir, "$transactionName.part") + val request = Request.Builder() + .url(downloadUrl) + .header("Accept", "application/octet-stream") + .header("User-Agent", "Nova/${BuildConfig.VERSION_NAME}") + .build() + + try { + coroutineScope { + val call = client.newCall(request) + val cancellationWatcher = launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) { + try { + awaitCancellation() + } finally { + call.cancel() + } + } + try { + withContext(Dispatchers.IO) { + if (!updateDir.isDirectory && !updateDir.mkdirs()) { + throw IllegalStateException("Could not create Nova update cache.") + } + cleanupStaleArtifacts(updateDir) + + var committed = false + try { + call.execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException("Download failed: HTTP ${response.code}") + } + val body = response.body + val totalBytes = body.contentLength() + if (totalBytes > MAX_APK_BYTES) { + throw IllegalStateException("APK is unexpectedly large (${totalBytes / (1024 * 1024)} MB).") + } + body.byteStream().use { input -> + partialFile.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var copied = 0L + var lastProgress = -1 + while (true) { + currentCoroutineContext().ensureActive() + val read = input.read(buffer) + if (read == -1) break + copied += read + if (copied > MAX_APK_BYTES) { + throw IllegalStateException("APK exceeded the maximum safe download size.") + } + output.write(buffer, 0, read) + if (totalBytes > 0L) { + val progress = ((copied * 100L) / totalBytes).toInt().coerceIn(0, 100) + if (progress != lastProgress) { + lastProgress = progress + withContext(Dispatchers.Main.immediate) { onProgress(progress) } + } + } + } + } + } + } + if (!partialFile.isFile || partialFile.length() <= 0L) { + throw IllegalStateException("Downloaded APK is empty.") + } + currentCoroutineContext().ensureActive() + if (!partialFile.renameTo(apkFile)) { + throw IllegalStateException("Could not commit downloaded APK to the update cache.") + } + committed = true + apkFile + } finally { + if (!committed) { + cleanupArtifacts(partialFile, apkFile) + } + } + } + } finally { + cancellationWatcher.cancel() + } + } + } catch (e: Exception) { + val cancellation = if (e is CancellationException) { + e + } else { + try { + currentCoroutineContext().ensureActive() + null + } catch (cancelled: CancellationException) { + cancelled.apply { addSuppressed(e) } + } + } + if (cancellation != null) { + val cleanupFailure = withContext(NonCancellable + Dispatchers.IO) { + runCatching { cleanupArtifacts(partialFile, apkFile) }.exceptionOrNull() + } + cleanupFailure?.let(cancellation::addSuppressed) + throw cancellation + } + throw e + } + } + + private fun cleanupStaleArtifacts(updateDir: File) { + val staleBefore = System.currentTimeMillis() - COMPLETED_APK_RETENTION_MS + updateDir.listFiles().orEmpty().forEach { file -> + val stalePartial = file.name.endsWith(".apk.part", ignoreCase = true) + val staleCompletedApk = file.name.endsWith(".apk", ignoreCase = true) && file.lastModified() < staleBefore + if (stalePartial || staleCompletedApk) { + deleteForCleanup(file) + } + } + } + + private fun cleanupArtifacts(partialFile: File, apkFile: File) { + deleteForCleanup(partialFile) + deleteForCleanup(apkFile) + } + + private fun deleteForCleanup(file: File) { + if (file.exists() && !file.delete()) { + throw IllegalStateException("Could not remove update file ${file.name}.") + } + } +} diff --git a/app/src/main/java/com/papi/nova/preferences/NovaUpdateInstaller.kt b/app/src/main/java/com/papi/nova/preferences/NovaUpdateInstaller.kt index 08ccf8fd..c27ca288 100644 --- a/app/src/main/java/com/papi/nova/preferences/NovaUpdateInstaller.kt +++ b/app/src/main/java/com/papi/nova/preferences/NovaUpdateInstaller.kt @@ -2,11 +2,11 @@ package com.papi.nova.preferences import android.app.Activity import android.app.AlertDialog +import android.widget.Toast import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.net.Uri -import java.net.URI import android.os.Build import android.provider.Settings import androidx.core.content.FileProvider @@ -15,10 +15,12 @@ import com.papi.nova.R import com.papi.nova.ui.NovaSheetChrome import java.io.File import java.security.MessageDigest -import okhttp3.OkHttpClient -import okhttp3.Request +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull internal sealed class NovaUpdateInstallValidation { data object Valid : NovaUpdateInstallValidation() @@ -33,17 +35,31 @@ internal sealed class NovaUpdateInstallResult { } internal object NovaUpdateInstaller { - private const val MAX_APK_BYTES = 300L * 1024L * 1024L private const val APK_MIME_TYPE = "application/vnd.android.package-archive" + private val encodedPathMetaOctet = Regex("%(?:2e|2f|5c)", RegexOption.IGNORE_CASE) + private val installInProgress = AtomicBoolean(false) fun isTrustedDownloadUrl(url: String): Boolean { - val parsed = runCatching { URI(url) }.getOrNull() ?: return false - if (parsed.scheme != "https") return false - if (parsed.host != "github.com") return false - val path = parsed.rawPath ?: return false - if (!path.startsWith("/papi-ux/nova/releases/download/")) return false - if (path.contains("..")) return false - return path.endsWith(".apk", ignoreCase = true) + if (encodedPathMetaOctet.containsMatchIn(url)) return false + val parsed = url.toHttpUrlOrNull() ?: return false + if (parsed.scheme != "https" || parsed.host != "github.com" || parsed.port != 443) return false + if (parsed.username.isNotEmpty() || parsed.password.isNotEmpty()) return false + if (parsed.query != null || parsed.fragment != null) return false + + val encodedSegments = parsed.encodedPathSegments + if (encodedSegments.any { segment -> + val lower = segment.lowercase() + lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") + } + ) { + return false + } + + val segments = parsed.pathSegments + if (segments.size != 6) return false + if (segments.take(4) != listOf("papi-ux", "nova", "releases", "download")) return false + if (segments[4].isBlank()) return false + return segments[5].isNotBlank() && segments[5].endsWith(".apk", ignoreCase = true) } fun validateDownloadedApkMetadata( @@ -77,11 +93,106 @@ internal object NovaUpdateInstaller { return NovaUpdateInstallValidation.Valid } + fun showInstallResult( + activity: Activity, + release: NovaUpdateRelease, + result: NovaUpdateInstallResult, + onRetry: (NovaUpdateRelease) -> Unit, + onViewReleases: () -> Unit, + ) { + if (activity.isFinishing || activity.isDestroyed) return + when (result) { + NovaUpdateInstallResult.StartedInstaller -> Toast.makeText( + activity, + R.string.nova_update_installer_started, + Toast.LENGTH_LONG, + ).show() + NovaUpdateInstallResult.PermissionRequired -> Unit + is NovaUpdateInstallResult.Blocked -> showInstallProblem( + activity, + R.string.nova_update_install_blocked_title, + result.reason, + onRetry = null, + onViewReleases = onViewReleases, + ) + is NovaUpdateInstallResult.Failed -> showInstallProblem( + activity, + R.string.nova_update_install_failed_title, + result.reason, + onRetry = { onRetry(release) }, + onViewReleases = onViewReleases, + ) + } + } + + fun dismissIfAlive(activity: Activity, dismiss: () -> Unit) { + if (!activity.isDestroyed) dismiss() + } + + fun showCheckError( + activity: Activity, + error: Throwable, + onRetry: () -> Unit, + onViewReleases: () -> Unit, + ) { + if (activity.isFinishing || activity.isDestroyed) return + val detail = error.localizedMessage ?: error.javaClass.simpleName ?: "Unknown error" + val dialog = AlertDialog.Builder(activity) + .setTitle(R.string.nova_update_failed_title) + .setMessage(activity.getString(R.string.nova_update_failed_message, detail)) + .setPositiveButton(R.string.nova_update_retry) { _, _ -> onRetry() } + .setNeutralButton(R.string.nova_update_view_releases) { _, _ -> onViewReleases() } + .show() + NovaSheetChrome.applyAlertDialogChrome(dialog) + } + + private fun showInstallProblem( + activity: Activity, + titleRes: Int, + message: String, + onRetry: (() -> Unit)?, + onViewReleases: () -> Unit, + ) { + val builder = AlertDialog.Builder(activity) + .setTitle(titleRes) + .setMessage(message) + .setNeutralButton(R.string.nova_update_view_releases) { _, _ -> onViewReleases() } + if (onRetry == null) { + builder.setPositiveButton(android.R.string.ok, null) + } else { + builder.setPositiveButton(R.string.nova_update_retry) { _, _ -> onRetry() } + } + val dialog = builder.show() + NovaSheetChrome.applyAlertDialogChrome(dialog) + } + suspend fun downloadValidateAndInstall( activity: Activity, release: NovaUpdateRelease, client: OkHttpClient = OkHttpClient(), - onProgress: (Int) -> Unit = {} + onProgress: (Int) -> Unit = {}, + ): NovaUpdateInstallResult = downloadValidateAndInstall( + activity = activity, + release = release, + downloader = { + NovaUpdateDownloadStore.download(activity.cacheDir, release, client, onProgress) + }, + validator = { apkFile -> + withContext(Dispatchers.IO) { + validateDownloadedApk(activity, apkFile, release) + } + }, + installerLauncher = { apkFile -> + launchPackageInstaller(activity, apkFile) + }, + ) + + internal suspend fun downloadValidateAndInstall( + activity: Activity, + release: NovaUpdateRelease, + downloader: suspend () -> File, + validator: suspend (File) -> NovaUpdateInstallValidation, + installerLauncher: (File) -> Unit, ): NovaUpdateInstallResult { val downloadUrl = release.apkDownloadUrl ?: return NovaUpdateInstallResult.Blocked(activity.getString(R.string.nova_update_no_apk_message)) @@ -94,25 +205,61 @@ internal object NovaUpdateInstaller { } return NovaUpdateInstallResult.PermissionRequired } + if (!installInProgress.compareAndSet(false, true)) { + return NovaUpdateInstallResult.Failed(activity.getString(R.string.nova_update_install_in_progress)) + } - return withContext(Dispatchers.IO) { - try { - val apkFile = downloadApk(activity, release, downloadUrl, client, onProgress) - when (val validation = validateDownloadedApk(activity, apkFile, release)) { - NovaUpdateInstallValidation.Valid -> { - withContext(Dispatchers.Main.immediate) { - launchPackageInstaller(activity, apkFile) - } - NovaUpdateInstallResult.StartedInstaller + var downloadedApk: File? = null + return try { + val apkFile = downloader() + downloadedApk = apkFile + val validation = try { + validator(apkFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val cleanupProblem = deleteDownloadedApk(apkFile) + return NovaUpdateInstallResult.Blocked( + activity.getString( + R.string.nova_update_validation_failed_message, + e.localizedMessage ?: e.javaClass.simpleName, + ) + cleanupProblem.asDialogSuffix() + ) + } + when (validation) { + NovaUpdateInstallValidation.Valid -> { + withContext(Dispatchers.Main.immediate) { + installerLauncher(apkFile) } - is NovaUpdateInstallValidation.Invalid -> NovaUpdateInstallResult.Blocked(validation.reason) + NovaUpdateInstallResult.StartedInstaller } - } catch (e: Exception) { - NovaUpdateInstallResult.Failed(e.localizedMessage ?: e.javaClass.simpleName) + is NovaUpdateInstallValidation.Invalid -> { + val cleanupProblem = deleteDownloadedApk(apkFile) + NovaUpdateInstallResult.Blocked(validation.reason + cleanupProblem.asDialogSuffix()) + } + } + } catch (e: CancellationException) { + deleteDownloadedApk(downloadedApk)?.let { cleanupProblem -> + e.addSuppressed(IllegalStateException(cleanupProblem)) } + throw e + } catch (e: Exception) { + val cleanupProblem = deleteDownloadedApk(downloadedApk) + NovaUpdateInstallResult.Failed( + (e.localizedMessage ?: e.javaClass.simpleName) + cleanupProblem.asDialogSuffix() + ) + } finally { + installInProgress.set(false) } } + private fun deleteDownloadedApk(apkFile: File?): String? { + if (apkFile == null || !apkFile.exists() || apkFile.delete()) return null + return "Nova could not remove the downloaded APK from its private cache." + } + + private fun String?.asDialogSuffix(): String = if (this == null) "" else "\n\n$this" + private fun canRequestPackageInstalls(activity: Activity): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true return activity.packageManager.canRequestPackageInstalls() @@ -142,65 +289,6 @@ internal object NovaUpdateInstaller { .onFailure { activity.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) } } - private fun downloadApk( - activity: Activity, - release: NovaUpdateRelease, - downloadUrl: String, - client: OkHttpClient, - onProgress: (Int) -> Unit - ): File { - val safeName = release.apkAssetName - ?.substringAfterLast('/') - ?.replace(Regex("[^A-Za-z0-9._-]"), "_") - ?.takeIf { it.endsWith(".apk", ignoreCase = true) } - ?: "Nova-${release.versionName}.apk" - val updateDir = File(activity.cacheDir, "nova-updates").apply { mkdirs() } - val apkFile = File(updateDir, safeName) - - val request = Request.Builder() - .url(downloadUrl) - .header("Accept", "application/octet-stream") - .header("User-Agent", "Nova/${BuildConfig.VERSION_NAME}") - .build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw IllegalStateException("Download failed: HTTP ${response.code}") - } - val body = response.body - val totalBytes = body.contentLength() - if (totalBytes > MAX_APK_BYTES) { - throw IllegalStateException("APK is unexpectedly large (${totalBytes / (1024 * 1024)} MB).") - } - body.byteStream().use { input -> - apkFile.outputStream().use { output -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var copied = 0L - var lastProgress = -1 - while (true) { - val read = input.read(buffer) - if (read == -1) break - copied += read - if (copied > MAX_APK_BYTES) { - throw IllegalStateException("APK exceeded the maximum safe download size.") - } - output.write(buffer, 0, read) - if (totalBytes > 0L) { - val progress = ((copied * 100L) / totalBytes).toInt().coerceIn(0, 100) - if (progress != lastProgress) { - lastProgress = progress - onProgress(progress) - } - } - } - } - } - } - if (!apkFile.isFile || apkFile.length() <= 0L) { - throw IllegalStateException("Downloaded APK is empty.") - } - return apkFile - } - private fun validateDownloadedApk( activity: Activity, apkFile: File, diff --git a/app/src/main/java/com/papi/nova/preferences/NovaUpdatePromptPreferences.kt b/app/src/main/java/com/papi/nova/preferences/NovaUpdatePromptPreferences.kt index 883ab194..f09056d9 100644 --- a/app/src/main/java/com/papi/nova/preferences/NovaUpdatePromptPreferences.kt +++ b/app/src/main/java/com/papi/nova/preferences/NovaUpdatePromptPreferences.kt @@ -1,6 +1,7 @@ package com.papi.nova.preferences import android.content.SharedPreferences +import androidx.core.content.edit internal object NovaUpdatePromptPreferences { const val AUTO_CHECK_INTERVAL_MS: Long = 24L * 60L * 60L * 1000L @@ -15,7 +16,15 @@ internal object NovaUpdatePromptPreferences { } fun recordAutomaticCheck(prefs: SharedPreferences, nowMs: Long) { - prefs.edit().putLong(KEY_LAST_AUTO_CHECK_MS, nowMs).apply() + prefs.edit { putLong(KEY_LAST_AUTO_CHECK_MS, nowMs) } + } + + fun recordAutomaticCheckResult( + prefs: SharedPreferences, + nowMs: Long, + result: Result, + ) { + if (result.isSuccess) recordAutomaticCheck(prefs, nowMs) } fun shouldShowAutomaticPrompt(prefs: SharedPreferences, release: NovaUpdateRelease): Boolean { diff --git a/app/src/main/java/com/papi/nova/preferences/StreamSettings.kt b/app/src/main/java/com/papi/nova/preferences/StreamSettings.kt index 1aaee49e..7aee4a26 100644 --- a/app/src/main/java/com/papi/nova/preferences/StreamSettings.kt +++ b/app/src/main/java/com/papi/nova/preferences/StreamSettings.kt @@ -271,45 +271,31 @@ class StreamSettings : AppCompatActivity() { false ) lifecycleScope.launch { - val result = NovaUpdateInstaller.downloadValidateAndInstall(this@StreamSettings, release) { progress -> - spinner.setMessage( - getString(R.string.nova_update_downloading_message, release.versionName, progress) + try { + val result = NovaUpdateInstaller.downloadValidateAndInstall(this@StreamSettings, release) { progress -> + spinner.setMessage( + if (progress >= 100) { + getString(R.string.nova_update_verifying_message) + } else { + getString(R.string.nova_update_downloading_message, release.versionName, progress) + } + ) + } + NovaUpdateInstaller.showInstallResult( + this@StreamSettings, + release, + result, + onRetry = { startNovaUpdateInstall(it) }, + onViewReleases = { + HelpLauncher.launchUrl(this@StreamSettings, "https://github.com/papi-ux/nova/releases") + }, ) + } finally { + NovaUpdateInstaller.dismissIfAlive(this@StreamSettings) { spinner.dismiss() } } - spinner.setMessage(getString(R.string.nova_update_verifying_message)) - spinner.dismiss() - showNovaUpdateInstallResult(result) } } - private fun showNovaUpdateInstallResult(result: NovaUpdateInstallResult) { - when (result) { - NovaUpdateInstallResult.StartedInstaller -> Toast.makeText( - this, - R.string.nova_update_installer_started, - Toast.LENGTH_LONG - ).show() - NovaUpdateInstallResult.PermissionRequired -> Unit - is NovaUpdateInstallResult.Blocked -> showNovaUpdateInstallProblem( - R.string.nova_update_install_blocked_title, - result.reason - ) - is NovaUpdateInstallResult.Failed -> showNovaUpdateInstallProblem( - R.string.nova_update_install_failed_title, - result.reason - ) - } - } - - private fun showNovaUpdateInstallProblem(titleRes: Int, message: String) { - val dialog = AlertDialog.Builder(this) - .setTitle(titleRes) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show() - NovaSheetChrome.applyAlertDialogChrome(dialog) - } - private fun showNovaUpdateCurrent(release: NovaUpdateRelease) { val dialog = AlertDialog.Builder(this) .setTitle(R.string.nova_update_current_title) @@ -329,16 +315,14 @@ class StreamSettings : AppCompatActivity() { } private fun showNovaUpdateError(error: Throwable) { - val detail = error.localizedMessage ?: error.javaClass.simpleName ?: "Unknown error" - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.nova_update_failed_title) - .setMessage(getString(R.string.nova_update_failed_message, detail)) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(R.string.nova_update_view_releases) { _, _ -> + NovaUpdateInstaller.showCheckError( + this, + error, + onRetry = { checkForNovaUpdate() }, + onViewReleases = { HelpLauncher.launchUrl(this, "https://github.com/papi-ux/nova/releases") - } - .show() - NovaSheetChrome.applyAlertDialogChrome(dialog) + }, + ) } private fun resetStreamUiRuntimePreferences() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb679a36..19e3338f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -997,11 +997,14 @@ Android installer opened. Review the install prompt to finish updating Nova. This release does not include a direct APK for this device. Nova blocked this download because it was not a trusted Nova GitHub release APK. + Nova could not safely validate the downloaded APK: %1$s Allow Nova to install updates Android requires permission before Nova can hand an update APK to the system installer. Enable “Allow from this source”, then return to Nova and tap Download APK again. Open Android settings + Retry Update blocked for safety Update install failed + An update install is already in progress. Follow Update Get Nova updates via Obtainium Special Key Layout diff --git a/app/src/test/java/com/papi/nova/preferences/NovaUpdateRecoveryTest.kt b/app/src/test/java/com/papi/nova/preferences/NovaUpdateRecoveryTest.kt new file mode 100644 index 00000000..4565ce7f --- /dev/null +++ b/app/src/test/java/com/papi/nova/preferences/NovaUpdateRecoveryTest.kt @@ -0,0 +1,648 @@ +package com.papi.nova.preferences + +import android.app.Activity +import android.content.DialogInterface +import android.content.Context +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import androidx.test.core.app.ApplicationProvider +import java.io.File +import java.io.IOException +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.Source +import okio.Timeout +import okio.buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowAlertDialog +import org.robolectric.shadows.ShadowToast + +@Config(sdk = [33]) +@RunWith(RobolectricTestRunner::class) +class NovaUpdateRecoveryTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val release = NovaUpdateRelease( + tagName = "v1.3.2", + versionName = "1.3.2", + releaseUrl = "https://github.com/papi-ux/nova/releases/tag/v1.3.2", + apkAssetName = "Nova-Android-arm64-v8a.apk", + apkDownloadUrl = "https://github.com/papi-ux/nova/releases/download/v1.3.2/Nova-Android-arm64-v8a.apk", + ) + + @Test + fun trustedDownloadUrlRequiresCanonicalNovaGitHubAssetPath() { + assertTrue( + NovaUpdateInstaller.isTrustedDownloadUrl( + "https://github.com/papi-ux/nova/releases/download/v1.3.2/Nova-Android-arm64-v8a.apk" + ) + ) + listOf( + "https://github.com/papi-ux/ignored/%2e%2e/nova/releases/download/v1.3.2/Nova.apk", + "https://github.com/papi-ux/ignored/%2E%2E/nova/releases/download/v1.3.2/Nova.apk", + "https://github.com/papi-ux/nova/releases/download/%2e%2e/other.apk", + "https://github.com/papi-ux/nova/releases/download/v1.3.2%2F..%2Fother.apk", + "https://user@github.com/papi-ux/nova/releases/download/v1.3.2/Nova.apk", + "https://github.com/papi-ux/nova/releases/download/v1.3.2/Nova.apk?redirect=1", + "https://github.com/papi-ux/nova/releases/download/v1.3.2/Nova.apk#fragment", + "https://github.com/papi-ux/nova/releases/download/v1.3.2/extra/Nova.apk", + ).forEach { candidate -> + assertFalse("Untrusted updater URL was accepted: $candidate", NovaUpdateInstaller.isTrustedDownloadUrl(candidate)) + } + } + + @Test + fun successfulRetryWritesOnlyPartialUntilAtomicCommitAndReportsProgressOnMainThread() { + val cacheDir = freshCacheDir() + val updateDir = File(cacheDir, "nova-updates").apply { mkdirs() } + val staleFinalFile = File(updateDir, release.apkAssetName!!).apply { + writeText("stale-final") + setLastModified(1L) + } + val stalePartialFile = File(updateDir, "${release.apkAssetName}.part").apply { writeText("stale-partial") } + val responseBody = PausingResponseBody() + val mainThreadProgress = mutableListOf() + val executor = Executors.newSingleThreadExecutor() + try { + val future = executor.submit> { + runBlocking { + runCatching { + NovaUpdateDownloadStore.download(cacheDir, release, clientReturning(responseBody)) { + mainThreadProgress += Looper.myLooper() == Looper.getMainLooper() + } + } + } + } + awaitLatchWithMainLooper(responseBody.secondReadStarted) + val activePartials = updateDir.listFiles().orEmpty().filter { it.name.endsWith(".apk.part") } + val staleFinalWasRemoved = !staleFinalFile.exists() + val stalePartialWasRemoved = !stalePartialFile.exists() + val noFinalBeforeCommit = updateDir.listFiles().orEmpty().none { it.name.endsWith(".apk") } + val inFlightContent = activePartials.singleOrNull()?.readText() + + responseBody.allowSecondRead.countDown() + val downloaded = awaitFutureWithMainLooper(future).getOrThrow() + + assertTrue("Aged completed APK must be cleaned before retry", staleFinalWasRemoved) + assertTrue("Stale partial APK must be cleaned before retry", stalePartialWasRemoved) + assertTrue("Final APK must not exist before the transfer completes", noFinalBeforeCommit) + assertEquals("fresh-", inFlightContent) + assertEquals("fresh-apk", downloaded.readText()) + assertTrue(updateDir.listFiles().orEmpty().none { it.name.endsWith(".apk.part") }) + assertTrue(mainThreadProgress.isNotEmpty()) + assertTrue(mainThreadProgress.all { it }) + } finally { + responseBody.allowSecondRead.countDown() + executor.shutdownNow() + } + } + + @Test + fun sequentialDownloadsUseDistinctFinalFilesSoIssuedInstallerUriCannotBeReplaced() { + val cacheDir = freshCacheDir() + val first = runDownloadWithMainLooper { + NovaUpdateDownloadStore.download( + cacheDir, + release, + clientReturning("first-apk".toResponseBody(APK_MEDIA_TYPE)), + ) + }.getOrThrow() + val second = runDownloadWithMainLooper { + NovaUpdateDownloadStore.download( + cacheDir, + release, + clientReturning("second-apk".toResponseBody(APK_MEDIA_TYPE)), + ) + }.getOrThrow() + + assertTrue("Each completed download must own a unique immutable path", first != second) + assertEquals("first-apk", first.readText()) + assertEquals("second-apk", second.readText()) + } + + @Test + fun undeletableStaleArtifactFailsClosedBeforeDownloadStarts() { + val cacheDir = freshCacheDir() + val staleDirectory = File(cacheDir, "nova-updates/undeletable.apk.part").apply { mkdirs() } + File(staleDirectory, "child").writeText("keeps-directory-non-empty") + + val failure = runDownloadWithMainLooper { + NovaUpdateDownloadStore.download( + cacheDir, + release, + clientReturning("must-not-download".toResponseBody(APK_MEDIA_TYPE)), + ) + }.exceptionOrNull() + + assertTrue(failure is IllegalStateException) + assertTrue(failure?.message.orEmpty().contains("Could not remove update file")) + } + + @Test + fun interruptedDownloadDeletesPartialAndFinalArtifactsSoRetryStartsClean() { + val cacheDir = freshCacheDir() + val updateDir = File(cacheDir, "nova-updates").apply { mkdirs() } + File(updateDir, release.apkAssetName!!).apply { + writeText("stale-final") + setLastModified(1L) + } + File(updateDir, "${release.apkAssetName}.part").apply { writeText("stale-partial") } + + val failure = runDownloadWithMainLooper { + NovaUpdateDownloadStore.download(cacheDir, release, clientReturning(InterruptingResponseBody())) + }.exceptionOrNull() + + assertTrue(failure is IOException) + assertDownloadArtifactsAbsent(cacheDir) + } + + @Test + fun cancellingBlockedDownloadCancelsOkHttpCallAndDeletesArtifacts() = runBlocking { + val cacheDir = freshCacheDir() + val body = CancellationAwareResponseBody() + val job = launch(Dispatchers.Default) { + NovaUpdateDownloadStore.download(cacheDir, release, clientReturning(body)) + } + assertTrue("Download body never started", body.readStarted.await(2, TimeUnit.SECONDS)) + + job.cancel() + val callCancelled = body.callCancellationObserved.await(2, TimeUnit.SECONDS) + job.join() + + assertTrue("Coroutine cancellation must cancel the active OkHttp Call", callCancelled) + assertTrue(job.isCancelled) + assertDownloadArtifactsAbsent(cacheDir) + } + + @Test + fun cancellationAfterAtomicCommitDeletesFinalArtifact() { + val cacheDir = freshCacheDir() + val updateDir = File(cacheDir, "nova-updates") + val mainLooper = Shadows.shadowOf(Looper.getMainLooper()) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val job = scope.launch { + NovaUpdateDownloadStore.download( + cacheDir, + release, + clientReturning(UnknownLengthResponseBody("fresh-apk")), + ) + } + try { + mainLooper.idle() + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + var committedFile: File? = null + while (committedFile == null && System.nanoTime() < deadline) { + committedFile = updateDir.listFiles().orEmpty().singleOrNull { it.name.endsWith(".apk") } + if (committedFile == null) Thread.sleep(5L) + } + assertTrue("Download never reached the atomic commit boundary", committedFile != null) + + job.cancel() + while (!job.isCompleted && System.nanoTime() < deadline) { + mainLooper.idle() + Thread.sleep(5L) + } + mainLooper.idle() + + assertTrue(job.isCancelled) + assertDownloadArtifactsAbsent(cacheDir) + } finally { + scope.cancel() + } + } + + @Test + @Config(sdk = [25]) + fun installerDeletesRejectedApkAndReturnsBlocked() = runBlocking { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val apkFile = File(freshCacheDir(), "rejected.apk").apply { writeText("not-an-apk") } + + val result = NovaUpdateInstaller.downloadValidateAndInstall( + activity = activity, + release = release, + downloader = { apkFile }, + validator = { NovaUpdateInstallValidation.Invalid("signer mismatch") }, + installerLauncher = { throw AssertionError("Blocked APK must never reach the installer") }, + ) + + assertTrue(result is NovaUpdateInstallResult.Blocked) + assertFalse("Rejected APK must be deleted", apkFile.exists()) + } + + @Test + @Config(sdk = [25]) + fun rejectedApkCleanupFailureStaysBlockedAndIsReported() = runBlocking { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val apkDirectory = File(freshCacheDir(), "undeletable.apk").apply { mkdirs() } + File(apkDirectory, "child").writeText("prevents-directory-delete") + + val result = NovaUpdateInstaller.downloadValidateAndInstall( + activity = activity, + release = release, + downloader = { apkDirectory }, + validator = { NovaUpdateInstallValidation.Invalid("bad package") }, + installerLauncher = { throw AssertionError("Rejected APK must never reach installer") }, + ) + + assertTrue(result is NovaUpdateInstallResult.Blocked) + val reason = (result as NovaUpdateInstallResult.Blocked).reason + assertTrue(reason.contains("could not remove the downloaded APK")) + } + + @Test + @Config(sdk = [25]) + fun installerPropagatesCancellationAndDeletesDownloadedApk() = runBlocking { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val apkFile = File(freshCacheDir(), "cancelled.apk").apply { writeText("cancel-me") } + + val failure = runCatching { + NovaUpdateInstaller.downloadValidateAndInstall( + activity = activity, + release = release, + downloader = { apkFile }, + validator = { throw CancellationException("cancelled during validation") }, + installerLauncher = { throw AssertionError("Cancelled APK must never reach the installer") }, + ) + }.exceptionOrNull() + + assertTrue(failure is CancellationException) + assertFalse("Cancelled install must delete its downloaded APK", apkFile.exists()) + } + + @Test + @Config(sdk = [25]) + fun validatorExceptionRemainsSafetyBlockedAndDeletesDownloadedApk() = runBlocking { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val apkFile = File(freshCacheDir(), "validator-crash.apk").apply { writeText("unverifiable") } + + val result = NovaUpdateInstaller.downloadValidateAndInstall( + activity = activity, + release = release, + downloader = { apkFile }, + validator = { throw IllegalStateException("package parser failed") }, + installerLauncher = { throw AssertionError("Unverifiable APK must never reach the installer") }, + ) + + assertTrue("Validation exceptions must fail closed without Retry", result is NovaUpdateInstallResult.Blocked) + assertFalse("Unverifiable APK must be deleted", apkFile.exists()) + } + + @Test + @Config(sdk = [25]) + fun downloaderIoFailureRemainsTransientAndRetryable() = runBlocking { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val result = NovaUpdateInstaller.downloadValidateAndInstall( + activity = activity, + release = release, + downloader = { throw IOException("temporary network failure") }, + validator = { NovaUpdateInstallValidation.Valid }, + installerLauncher = {}, + ) + assertTrue(result is NovaUpdateInstallResult.Failed) + } + + @Test + @Config(sdk = [25]) + fun concurrentInstallerRequestIsRejectedBeforeStartingAnotherDownload() = runBlocking { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val firstStarted = CountDownLatch(1) + val allowFirstToFinish = CountDownLatch(1) + val downloaderCalls = AtomicInteger(0) + val firstApk = File(freshCacheDir(), "first.apk").apply { writeText("first") } + val secondApk = File(freshCacheDir(), "second.apk").apply { writeText("second") } + + val first = async(Dispatchers.Default) { + NovaUpdateInstaller.downloadValidateAndInstall( + activity = activity, + release = release, + downloader = { + downloaderCalls.incrementAndGet() + firstStarted.countDown() + withContext(Dispatchers.IO) { + if (!allowFirstToFinish.await(5, TimeUnit.SECONDS)) { + throw IOException("timed out waiting for duplicate-install assertion") + } + } + firstApk + }, + validator = { NovaUpdateInstallValidation.Valid }, + installerLauncher = {}, + ) + } + assertTrue(firstStarted.await(2, TimeUnit.SECONDS)) + + val second = NovaUpdateInstaller.downloadValidateAndInstall( + activity = activity, + release = release, + downloader = { + downloaderCalls.incrementAndGet() + secondApk + }, + validator = { NovaUpdateInstallValidation.Valid }, + installerLauncher = {}, + ) + + assertTrue(second is NovaUpdateInstallResult.Failed) + assertEquals(1, downloaderCalls.get()) + allowFirstToFinish.countDown() + val mainLooper = Shadows.shadowOf(Looper.getMainLooper()) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (!first.isCompleted && System.nanoTime() < deadline) { + mainLooper.idle() + Thread.sleep(5L) + } + assertTrue("First install did not complete after releasing the test barrier", first.isCompleted) + assertTrue(first.await() is NovaUpdateInstallResult.StartedInstaller) + + val afterCompletion = NovaUpdateInstaller.downloadValidateAndInstall( + activity = activity, + release = release, + downloader = { + downloaderCalls.incrementAndGet() + secondApk + }, + validator = { NovaUpdateInstallValidation.Valid }, + installerLauncher = {}, + ) + assertTrue("Install guard must be released after the first transaction", afterCompletion is NovaUpdateInstallResult.StartedInstaller) + assertEquals(2, downloaderCalls.get()) + } + + @Test + fun installResultPresenterRetriesCapturedReleaseOnlyForTransientFailure() { + val controller = Robolectric.buildActivity(UpdateDialogTestActivity::class.java) + val activity = controller.get().apply { setTheme(com.papi.nova.R.style.AppTheme) } + controller.setup() + var retriedRelease: NovaUpdateRelease? = null + + NovaUpdateInstaller.showInstallResult( + activity, + release, + NovaUpdateInstallResult.Failed("temporary network failure"), + onRetry = { retriedRelease = it }, + onViewReleases = {}, + ) + ShadowAlertDialog.getLatestAlertDialog() + .getButton(DialogInterface.BUTTON_POSITIVE) + .performClick() + Shadows.shadowOf(Looper.getMainLooper()).idle() + assertSame(release, retriedRelease) + + retriedRelease = null + NovaUpdateInstaller.showInstallResult( + activity, + release, + NovaUpdateInstallResult.Blocked("signer mismatch"), + onRetry = { retriedRelease = it }, + onViewReleases = {}, + ) + ShadowAlertDialog.getLatestAlertDialog() + .getButton(DialogInterface.BUTTON_POSITIVE) + .performClick() + Shadows.shadowOf(Looper.getMainLooper()).idle() + assertEquals(null, retriedRelease) + + NovaUpdateInstaller.showInstallResult( + activity, + release, + NovaUpdateInstallResult.StartedInstaller, + onRetry = { retriedRelease = it }, + onViewReleases = {}, + ) + assertEquals(activity.getString(com.papi.nova.R.string.nova_update_installer_started), ShadowToast.getTextOfLatestToast()) + + val latestDialog = ShadowAlertDialog.getLatestAlertDialog() + NovaUpdateInstaller.showInstallResult( + activity, + release, + NovaUpdateInstallResult.PermissionRequired, + onRetry = { retriedRelease = it }, + onViewReleases = {}, + ) + assertSame(latestDialog, ShadowAlertDialog.getLatestAlertDialog()) + } + + @Test + fun failedManualCheckDialogInvokesFreshRetryAction() { + val controller = Robolectric.buildActivity(UpdateDialogTestActivity::class.java) + val activity = controller.get().apply { setTheme(com.papi.nova.R.style.AppTheme) } + controller.setup() + var retryCalls = 0 + + NovaUpdateInstaller.showCheckError( + activity, + IOException("offline"), + onRetry = { retryCalls += 1 }, + onViewReleases = {}, + ) + ShadowAlertDialog.getLatestAlertDialog() + .getButton(DialogInterface.BUTTON_POSITIVE) + .performClick() + Shadows.shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, retryCalls) + } + + @Test + fun automaticCheckThrottleRecordsOnlySuccessfulResultsInRealPreferences() { + val prefs = context.getSharedPreferences("nova-update-${UUID.randomUUID()}", Context.MODE_PRIVATE) + val nowMs = 5L * NovaUpdatePromptPreferences.AUTO_CHECK_INTERVAL_MS + + NovaUpdatePromptPreferences.recordAutomaticCheckResult( + prefs, + nowMs, + Result.failure(IOException("offline")), + ) + assertTrue(NovaUpdatePromptPreferences.shouldRunAutomaticCheck(prefs, nowMs)) + + NovaUpdatePromptPreferences.recordAutomaticCheckResult( + prefs, + nowMs, + Result.success(NovaUpdateCheckResult.UpToDate(release)), + ) + assertFalse(NovaUpdatePromptPreferences.shouldRunAutomaticCheck(prefs, nowMs)) + } + + @Test + fun destroyedActivityNeverDismissesUpdaterDialog() { + val controller = Robolectric.buildActivity(Activity::class.java).setup() + val activity = controller.get() + var dismissCalls = 0 + controller.destroy() + + NovaUpdateInstaller.dismissIfAlive(activity) { dismissCalls += 1 } + + assertEquals(0, dismissCalls) + } + + @Test + fun liveActivityDismissesUpdaterDialog() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + var dismissCalls = 0 + + NovaUpdateInstaller.dismissIfAlive(activity) { dismissCalls += 1 } + + assertEquals(1, dismissCalls) + } + + private fun runDownloadWithMainLooper(block: suspend () -> File): Result { + val executor = Executors.newSingleThreadExecutor() + return try { + awaitFutureWithMainLooper( + executor.submit> { runBlocking { runCatching { block() } } } + ) + } finally { + executor.shutdownNow() + } + } + + private fun awaitFutureWithMainLooper(future: Future>): Result { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + val mainLooper = Shadows.shadowOf(Looper.getMainLooper()) + while (!future.isDone && System.nanoTime() < deadline) { + mainLooper.idle() + Thread.sleep(5L) + } + assertTrue("Download did not complete while the main looper was being driven", future.isDone) + return future.get() + } + + private fun awaitLatchWithMainLooper(latch: CountDownLatch) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + val mainLooper = Shadows.shadowOf(Looper.getMainLooper()) + while (latch.count > 0L && System.nanoTime() < deadline) { + mainLooper.idle() + Thread.sleep(5L) + } + assertEquals("Download did not reach the controlled transfer boundary", 0L, latch.count) + } + + private fun assertDownloadArtifactsAbsent(cacheDir: File) { + val artifacts = File(cacheDir, "nova-updates").listFiles().orEmpty().filter { + it.name.endsWith(".apk") || it.name.endsWith(".apk.part") + } + assertTrue("Updater artifacts remained: ${artifacts.joinToString { it.name }}", artifacts.isEmpty()) + } + + private fun freshCacheDir() = File(context.cacheDir, "updater-recovery-${UUID.randomUUID()}").apply { assertTrue(mkdirs()) } + + private fun clientReturning(body: ResponseBody) = OkHttpClient.Builder().addInterceptor(Interceptor { chain -> + if (body is CancellationAwareResponseBody) { + body.isCallCancelled = { chain.call().isCanceled() } + } + Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(200).message("OK").body(body).build() + }).build() + + private class PausingResponseBody : ResponseBody() { + val secondReadStarted = CountDownLatch(1) + val allowSecondRead = CountDownLatch(1) + + override fun contentType() = APK_MEDIA_TYPE + override fun contentLength() = 9L + override fun source(): BufferedSource { + val first = Buffer().writeUtf8("fresh-") + val second = Buffer().writeUtf8("apk") + return object : Source { + private var readIndex = 0 + override fun read(sink: Buffer, byteCount: Long): Long = when (readIndex++) { + 0 -> first.read(sink, byteCount) + 1 -> { + secondReadStarted.countDown() + if (!allowSecondRead.await(5, TimeUnit.SECONDS)) { + throw IOException("timed out waiting to finish controlled transfer") + } + second.read(sink, byteCount) + } + else -> -1L + } + override fun timeout() = Timeout.NONE + override fun close() = Unit + }.buffer() + } + } + + private class CancellationAwareResponseBody : ResponseBody() { + val readStarted = CountDownLatch(1) + val callCancellationObserved = CountDownLatch(1) + @Volatile var isCallCancelled: () -> Boolean = { false } + + override fun contentType() = APK_MEDIA_TYPE + override fun contentLength() = -1L + override fun source(): BufferedSource = object : Source { + override fun read(sink: Buffer, byteCount: Long): Long { + readStarted.countDown() + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (!isCallCancelled() && System.nanoTime() < deadline) { + Thread.sleep(5L) + } + if (isCallCancelled()) { + callCancellationObserved.countDown() + throw IOException("active call cancelled") + } + throw IOException("active call was not cancelled") + } + override fun timeout() = Timeout.NONE + override fun close() = Unit + }.buffer() + } + + private class UnknownLengthResponseBody(text: String) : ResponseBody() { + private val buffer = Buffer().writeUtf8(text) + override fun contentType() = APK_MEDIA_TYPE + override fun contentLength() = -1L + override fun source(): BufferedSource = buffer + } + + private class InterruptingResponseBody : ResponseBody() { + override fun contentType() = APK_MEDIA_TYPE + override fun contentLength() = 9L + override fun source(): BufferedSource { + val prefix = Buffer().writeUtf8("partial") + return object : Source { + private var first = true + override fun read(sink: Buffer, byteCount: Long): Long { + if (first) { first = false; return prefix.read(sink, byteCount) } + throw IOException("simulated interrupted transfer") + } + override fun timeout() = Timeout.NONE + override fun close() = Unit + }.buffer() + } + } + + companion object { + private val APK_MEDIA_TYPE = "application/vnd.android.package-archive".toMediaType() + } +} + +private class UpdateDialogTestActivity : AppCompatActivity()