diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 97ef4d404..7f1ac6716 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1436,9 +1436,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun installDesktop(): String? { uiHandler.post { loading.percent = -1; loading.say(getString(DESKTOP_LINES.downloading), DESKTOP_LINES.topic, readable = true) } val entry = com.droiddeck.launcher.runtime.DesktopCatalog.desktopEntry(this) - val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry, { SessionState.stopRequested }, - progressFor(DESKTOP_LINES, entry.size / 1_000_000)) - return problem?.let { getString(R.string.session_desktop_install_failed, it) } + val cancellation = com.droiddeck.launcher.core.Downloader.Cancellation { SessionState.stopRequested } + SessionState.installCancellation = cancellation + return try { + val problem = com.droiddeck.launcher.runtime.DesktopCatalog.install(this, entry, cancellation, + progressFor(DESKTOP_LINES, entry.size / 1_000_000)) + problem?.let { getString(R.string.session_desktop_install_failed, it) } + } finally { + if (SessionState.installCancellation === cancellation) SessionState.installCancellation = null + } } private fun installProton(): String? { diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java index a92466738..dcf3f0d95 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java @@ -15,6 +15,8 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Properties; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; public final class Downloader { private static final String TAG = "Downloader"; @@ -24,6 +26,32 @@ public final class Downloader { private Downloader() {} + public static final class Cancellation implements java.util.function.BooleanSupplier { + private final AtomicBoolean cancelled = new AtomicBoolean(); + private final AtomicReference connection = new AtomicReference<>(); + private final java.util.function.BooleanSupplier external; + + public Cancellation() { this(null); } + public Cancellation(java.util.function.BooleanSupplier external) { this.external = external; } + + public void cancel() { + cancelled.set(true); + HttpURLConnection active = connection.get(); + if (active != null) active.disconnect(); + } + + @Override public boolean getAsBoolean() { + return cancelled.get() || (external != null && external.getAsBoolean()); + } + + private void attach(HttpURLConnection active) { + connection.set(active); + if (getAsBoolean()) active.disconnect(); + } + + private void detach(HttpURLConnection active) { connection.compareAndSet(active, null); } + } + public static String downloadString(String url) { List sources = DownloadSource.INSTANCE.candidates(url); for (int i = 0; i < sources.size(); i++) { @@ -182,6 +210,7 @@ public final class Downloader { have = 0; } connection = open(url, STALL_TIMEOUT_MS); + if (cancelled instanceof Cancellation) ((Cancellation) cancelled).attach(connection); if (have > 0) { connection.setRequestProperty("Range", "bytes=" + have + "-"); connection.setRequestProperty("If-Range", identity.etag); @@ -245,7 +274,10 @@ public final class Downloader { Log.w(TAG, "download " + url, e); return Outcome.RETRY; } finally { - if (connection != null) connection.disconnect(); + if (connection != null) { + if (cancelled instanceof Cancellation) ((Cancellation) cancelled).detach(connection); + connection.disconnect(); + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index 43f3f991c..b276b5b45 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -86,7 +86,10 @@ object DesktopCatalog { install(context, entry, { false }, listener) /** A cancel stops the download and is checked once more before anything on disk changes; extraction itself runs to the end. */ - fun install(context: Context, entry: Entry, cancelled: () -> Boolean, listener: LinuxRuntimeInstaller.ProgressListener?): String? { + fun install(context: Context, entry: Entry, cancelled: () -> Boolean, listener: LinuxRuntimeInstaller.ProgressListener?): String? = + install(context, entry, Downloader.Cancellation(cancelled), listener) + + fun install(context: Context, entry: Entry, cancelled: Downloader.Cancellation, listener: LinuxRuntimeInstaller.ProgressListener?): String? { val root = LinuxRuntime.rootDir(context) if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing) if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index e9f7fb390..f98aa7469 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -1733,6 +1733,7 @@ class SessionService : Service() { fun stop(context: Context) { if (!SessionState.running) { SessionState.stopRequested = true + SessionState.installCancellation?.cancel() return } context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP)) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index ab32b9059..395d839b4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -36,6 +36,9 @@ object SessionState { @Volatile var installing: String? = null + @Volatile + var installCancellation: com.droiddeck.launcher.core.Downloader.Cancellation? = null + @Volatile var stopRequested = false diff --git a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt index a505ee173..da6fec27b 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt @@ -6,6 +6,9 @@ import java.net.Socket import java.nio.file.Files import java.security.MessageDigest import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals @@ -29,6 +32,9 @@ class DownloaderResumeTest { @Volatile private var misbehave = false @Volatile private var etag: String? = "\"v1\"" @Volatile private var corrupt = false + @Volatile private var holdAfterFirstChunk = false + private var bodyStarted = CountDownLatch(1) + private var continueBody = CountDownLatch(1) @Before fun setUp() { dir = Files.createTempDirectory("downloader-resume").toFile() @@ -70,8 +76,15 @@ class DownloaderResumeTest { val start = if (from == null) "200 OK" else "206 Partial Content" val extra = if (from == null) "" else "Content-Range: bytes 0-${data.size - 1}/${data.size}\r\n" out.write("HTTP/1.1 $start\r\n${extra}${validator}Content-Length: ${data.size}\r\nConnection: close\r\n\r\n".toByteArray()) - out.write(content) - out.flush() + if (holdAfterFirstChunk) { + out.write(content, 0, 1024) + out.flush() + bodyStarted.countDown() + continueBody.await(5, TimeUnit.SECONDS) + } else { + out.write(content) + out.flush() + } } else if (from == null) { out.write("HTTP/1.1 200 OK\r\n${validator}Content-Length: ${data.size}\r\nConnection: close\r\n\r\n".toByteArray()) out.write(content, 0, cutAt) @@ -181,6 +194,25 @@ class DownloaderResumeTest { assertTrue("the transfer stopped early (kept=${target.length()})", target.length() in 1 until data.size) } + @Test fun cancellationDisconnectsAStalledTransfer() { + val url = "http://127.0.0.1:${server.localPort}/first" + val target = File(dir, "runtime.tar.zst") + misbehave = true + holdAfterFirstChunk = true + val cancellation = Downloader.Cancellation() + val executor = Executors.newSingleThreadExecutor() + try { + val result = executor.submit { Downloader.downloadFile(url, target, true, null, cancellation, null) } + assertTrue("server sent the first chunk", bodyStarted.await(2, TimeUnit.SECONDS)) + cancellation.cancel() + assertFalse("cancelled download", result.get(2, TimeUnit.SECONDS)) + assertEquals(listOf(null), requests.toList()) + } finally { + continueBody.countDown() + executor.shutdownNow() + } + } + @Test fun aCancelBeforeTheFirstRequestSendsNothing() { val url = "http://127.0.0.1:${server.localPort}/runtime.tar.zst" val target = File(dir, "runtime.tar.zst")